deepseek-web-retrieval 0.2.0 → 0.4.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 +204 -1
- package/README.md +55 -28
- package/dist/cli.js +4224 -184
- 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} +34 -11
- package/docs/{pi.md → agents/pi.md} +39 -11
- 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/docs/research/search-api-defaults.md +309 -0
- package/package.json +5 -3
- package/src/core/auth.ts +1 -1
- package/src/core/client.ts +42 -249
- package/src/core/constants.ts +14 -3
- 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 +3 -4
- 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 +63 -32
- 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,253 @@
|
|
|
1
|
+
import { MAX_ERROR_BODY_CHARS, MAX_RESPONSE_BYTES, MAX_RETRY_DELAY_MS } from "./constants"
|
|
2
|
+
import { formatApiError, formatTimeoutError, redactSecrets, truncate } from "./format"
|
|
3
|
+
import type { RequestAuth, RetryOptions } from "./types"
|
|
4
|
+
|
|
5
|
+
/** Everything one POST needs; `fetchImpl` is injectable so tests need no monkey-patching. */
|
|
6
|
+
export interface TransportOptions {
|
|
7
|
+
readonly signal: AbortSignal
|
|
8
|
+
readonly auth: RequestAuth
|
|
9
|
+
readonly retry: RetryOptions
|
|
10
|
+
readonly timeoutMs: number
|
|
11
|
+
readonly fetchImpl?: typeof fetch
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class DeepSeekApiError extends Error {
|
|
15
|
+
readonly operation: string
|
|
16
|
+
readonly status: number
|
|
17
|
+
readonly body: string
|
|
18
|
+
readonly retryable: boolean
|
|
19
|
+
readonly retryAfterMs?: number
|
|
20
|
+
|
|
21
|
+
constructor(input: {
|
|
22
|
+
operation: string
|
|
23
|
+
status: number
|
|
24
|
+
body: string
|
|
25
|
+
retryAfterMs?: number
|
|
26
|
+
}) {
|
|
27
|
+
const body = redactSecrets(input.body)
|
|
28
|
+
super(formatApiError(input.operation, input.status, body))
|
|
29
|
+
this.name = "DeepSeekApiError"
|
|
30
|
+
this.operation = input.operation
|
|
31
|
+
this.status = input.status
|
|
32
|
+
this.body = body
|
|
33
|
+
this.retryable =
|
|
34
|
+
input.status === 408 ||
|
|
35
|
+
input.status === 425 ||
|
|
36
|
+
input.status === 429 ||
|
|
37
|
+
(input.status >= 500 && input.status <= 599)
|
|
38
|
+
this.retryAfterMs = input.retryAfterMs
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function postJson<T>(
|
|
43
|
+
endpoint: string,
|
|
44
|
+
body: unknown,
|
|
45
|
+
operation: string,
|
|
46
|
+
opts: TransportOptions,
|
|
47
|
+
): Promise<T> {
|
|
48
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch
|
|
49
|
+
const attempts = opts.retry.maxRetries + 1
|
|
50
|
+
let lastError: unknown
|
|
51
|
+
|
|
52
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
53
|
+
try {
|
|
54
|
+
return await requestOnce<T>(endpoint, body, operation, opts, fetchImpl)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
lastError = error
|
|
57
|
+
if (!(error instanceof DeepSeekApiError) || !error.retryable || attempt === attempts - 1) {
|
|
58
|
+
throw error
|
|
59
|
+
}
|
|
60
|
+
await sleep(retryDelay(error.retryAfterMs, opts.retry.baseDelayMs, attempt), opts.signal)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw lastError
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function requestOnce<T>(
|
|
68
|
+
endpoint: string,
|
|
69
|
+
body: unknown,
|
|
70
|
+
operation: string,
|
|
71
|
+
opts: TransportOptions,
|
|
72
|
+
fetchImpl: typeof fetch,
|
|
73
|
+
): Promise<T> {
|
|
74
|
+
return fetchWithTimeout(
|
|
75
|
+
endpoint,
|
|
76
|
+
{
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${opts.auth.apiKey}`,
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify(body),
|
|
83
|
+
},
|
|
84
|
+
opts.signal,
|
|
85
|
+
opts.timeoutMs,
|
|
86
|
+
operation,
|
|
87
|
+
fetchImpl,
|
|
88
|
+
async (response) => {
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const text = await readBoundedText(response, MAX_ERROR_BODY_CHARS)
|
|
91
|
+
throw new DeepSeekApiError({
|
|
92
|
+
operation,
|
|
93
|
+
status: response.status,
|
|
94
|
+
body: text,
|
|
95
|
+
retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const text = await readResponseText(response, MAX_RESPONSE_BYTES, operation)
|
|
100
|
+
if (!text.trim()) throw new Error(`${operation} returned an empty response body`)
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(text) as T
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error(`${operation} returned invalid JSON: ${truncate(redactSecrets(text), 500)}`)
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Runs the request and the callback that consumes its body inside one
|
|
113
|
+
* timeout/abort scope. The timer must stay active while the response body is
|
|
114
|
+
* read, otherwise a slow or stalled body can outlive the configured timeout.
|
|
115
|
+
*/
|
|
116
|
+
async function fetchWithTimeout<T>(
|
|
117
|
+
endpoint: string,
|
|
118
|
+
init: RequestInit,
|
|
119
|
+
signal: AbortSignal,
|
|
120
|
+
timeoutMs: number,
|
|
121
|
+
operation: string,
|
|
122
|
+
fetchImpl: typeof fetch,
|
|
123
|
+
handleResponse: (response: Response) => Promise<T>,
|
|
124
|
+
): Promise<T> {
|
|
125
|
+
const controller = new AbortController()
|
|
126
|
+
let timedOut = false
|
|
127
|
+
const forwardAbort = () => controller.abort(signal.reason)
|
|
128
|
+
if (signal.aborted) forwardAbort()
|
|
129
|
+
else signal.addEventListener("abort", forwardAbort, { once: true })
|
|
130
|
+
|
|
131
|
+
const timer = signal.aborted
|
|
132
|
+
? undefined
|
|
133
|
+
: setTimeout(() => {
|
|
134
|
+
timedOut = true
|
|
135
|
+
controller.abort(new Error("timeout"))
|
|
136
|
+
}, timeoutMs)
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetchImpl(endpoint, { ...init, signal: controller.signal })
|
|
140
|
+
return await handleResponse(response)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (timedOut) throw new Error(formatTimeoutError(operation, timeoutMs))
|
|
143
|
+
if (signal.aborted) throw abortReason(signal, operation)
|
|
144
|
+
throw error
|
|
145
|
+
} finally {
|
|
146
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
147
|
+
signal.removeEventListener("abort", forwardAbort)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function parseRetryAfter(value: string | null): number | undefined {
|
|
152
|
+
if (!value) return undefined
|
|
153
|
+
const trimmed = value.trim()
|
|
154
|
+
if (!trimmed) return undefined
|
|
155
|
+
|
|
156
|
+
if (/^\d+$/.test(trimmed)) {
|
|
157
|
+
return Math.min(MAX_RETRY_DELAY_MS, Number(trimmed) * 1000)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const timestamp = Date.parse(trimmed)
|
|
161
|
+
if (!Number.isFinite(timestamp)) return undefined
|
|
162
|
+
return Math.min(MAX_RETRY_DELAY_MS, Math.max(0, timestamp - Date.now()))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function retryDelay(retryAfterMs: number | undefined, baseDelayMs: number, attempt: number): number {
|
|
166
|
+
if (retryAfterMs !== undefined) return retryAfterMs
|
|
167
|
+
return Math.min(MAX_RETRY_DELAY_MS, baseDelayMs * 2 ** attempt)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
171
|
+
if (ms <= 0) return Promise.resolve()
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
signal.removeEventListener("abort", onAbort)
|
|
175
|
+
resolve()
|
|
176
|
+
}, ms)
|
|
177
|
+
const onAbort = () => {
|
|
178
|
+
clearTimeout(timer)
|
|
179
|
+
reject(abortReason(signal, "DeepSeek request"))
|
|
180
|
+
}
|
|
181
|
+
if (signal.aborted) {
|
|
182
|
+
clearTimeout(timer)
|
|
183
|
+
reject(abortReason(signal, "DeepSeek request"))
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function abortReason(signal: AbortSignal, operation: string): unknown {
|
|
191
|
+
return signal.reason instanceof Error ? signal.reason : new Error(`${operation} request aborted`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Reads at most `maxChars + 1` characters, enough for formatApiError to add its marker. */
|
|
195
|
+
async function readBoundedText(response: Response, maxChars: number): Promise<string> {
|
|
196
|
+
if (!response.body) return truncate(await response.text(), maxChars + 1)
|
|
197
|
+
|
|
198
|
+
const reader = response.body.getReader()
|
|
199
|
+
const decoder = new TextDecoder()
|
|
200
|
+
const limit = maxChars + 1
|
|
201
|
+
let text = ""
|
|
202
|
+
let chars = 0
|
|
203
|
+
try {
|
|
204
|
+
while (chars < limit) {
|
|
205
|
+
const { done, value } = await reader.read()
|
|
206
|
+
if (done) break
|
|
207
|
+
const chunk = decoder.decode(value, { stream: true })
|
|
208
|
+
text += chunk
|
|
209
|
+
chars += Array.from(chunk).length
|
|
210
|
+
}
|
|
211
|
+
text += decoder.decode()
|
|
212
|
+
} finally {
|
|
213
|
+
await reader.cancel().catch(() => undefined)
|
|
214
|
+
reader.releaseLock()
|
|
215
|
+
}
|
|
216
|
+
return truncate(text, limit)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Reads a successful response body, rejecting payloads above `maxBytes`. */
|
|
220
|
+
async function readResponseText(
|
|
221
|
+
response: Response,
|
|
222
|
+
maxBytes: number,
|
|
223
|
+
operation: string,
|
|
224
|
+
): Promise<string> {
|
|
225
|
+
if (!response.body) {
|
|
226
|
+
const text = await response.text()
|
|
227
|
+
if (new TextEncoder().encode(text).byteLength > maxBytes) {
|
|
228
|
+
throw new Error(`${operation} response exceeded ${maxBytes} bytes`)
|
|
229
|
+
}
|
|
230
|
+
return text
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const reader = response.body.getReader()
|
|
234
|
+
const decoder = new TextDecoder()
|
|
235
|
+
let text = ""
|
|
236
|
+
let bytes = 0
|
|
237
|
+
try {
|
|
238
|
+
while (true) {
|
|
239
|
+
const { done, value } = await reader.read()
|
|
240
|
+
if (done) break
|
|
241
|
+
bytes += value.byteLength
|
|
242
|
+
if (bytes > maxBytes) {
|
|
243
|
+
throw new Error(`${operation} response exceeded ${maxBytes} bytes`)
|
|
244
|
+
}
|
|
245
|
+
text += decoder.decode(value, { stream: true })
|
|
246
|
+
}
|
|
247
|
+
text += decoder.decode()
|
|
248
|
+
} finally {
|
|
249
|
+
await reader.cancel().catch(() => undefined)
|
|
250
|
+
reader.releaseLock()
|
|
251
|
+
}
|
|
252
|
+
return text
|
|
253
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface SearchOptions {
|
|
|
15
15
|
readonly language?: string
|
|
16
16
|
readonly maxSummaryChars: number
|
|
17
17
|
readonly timeoutMs: number
|
|
18
|
-
readonly
|
|
18
|
+
readonly enabled: boolean
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export interface FetchOptions {
|
|
@@ -23,9 +23,8 @@ export interface FetchOptions {
|
|
|
23
23
|
readonly allowedDomains: readonly string[]
|
|
24
24
|
readonly blockedDomains: readonly string[]
|
|
25
25
|
readonly timeoutMs: number
|
|
26
|
-
readonly overrideWebfetch: boolean
|
|
27
|
-
readonly registerTool: boolean
|
|
28
26
|
readonly blockPrivateHosts: boolean
|
|
27
|
+
readonly enabled: boolean
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
export interface AuthOptions {
|
|
@@ -54,12 +53,12 @@ export interface DeepSeekSearchResult {
|
|
|
54
53
|
readonly date?: string | null
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
/**
|
|
58
|
-
export interface
|
|
56
|
+
/** A neutral, host-agnostic search result. */
|
|
57
|
+
export interface SearchResult {
|
|
59
58
|
readonly url: string
|
|
60
59
|
readonly title?: string
|
|
61
60
|
readonly content?: string
|
|
62
|
-
readonly
|
|
61
|
+
readonly published?: number
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
export interface FetchedPage {
|
|
@@ -73,11 +72,14 @@ export interface FormattedFetchContent {
|
|
|
73
72
|
readonly truncated: boolean
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
/** A fetched page formatted for the model, with truncation reported. */
|
|
76
|
+
export interface FetchedDocument {
|
|
77
|
+
readonly url: string
|
|
78
|
+
readonly title: string
|
|
79
|
+
readonly content: string
|
|
80
|
+
readonly truncated: boolean
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
export interface
|
|
81
|
-
readonly
|
|
82
|
-
readonly baseDelayMs: number
|
|
83
|
+
export interface RequestAuth {
|
|
84
|
+
readonly apiKey: string
|
|
83
85
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
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"
|
|
7
|
+
|
|
8
|
+
export interface ApplyResult {
|
|
9
|
+
readonly lines: string[]
|
|
10
|
+
readonly ok: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PreparedEdit {
|
|
14
|
+
readonly edit: FileEdit
|
|
15
|
+
readonly recorded: RecordedFile
|
|
16
|
+
readonly write: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Applies a plan: refuses first when the config drifted since the plan was
|
|
21
|
+
* rendered (the plan and the record must describe one plan-time snapshot —
|
|
22
|
+
* ADR-0001), prepares every edit in memory (a malformed existing config fails
|
|
23
|
+
* before any file is written), writes the files, reverts artifacts orphaned by
|
|
24
|
+
* a tier change, records surgical-undo state, and verifies the result.
|
|
25
|
+
*/
|
|
26
|
+
export function applyPlan(plan: InstallPlan, input: InstallerInput): ApplyResult {
|
|
27
|
+
if (plan.errors.length > 0) {
|
|
28
|
+
throw new Error(`refusing to apply a plan with errors: ${plan.errors.map((error) => error.message).join("; ")}`)
|
|
29
|
+
}
|
|
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}`)
|
|
39
|
+
}
|
|
40
|
+
if ((plan.reverts ?? []).length > 0) {
|
|
41
|
+
lines.push(...applyReverts(plan))
|
|
42
|
+
}
|
|
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,
|
|
50
|
+
}
|
|
51
|
+
writeRecord(input, record)
|
|
52
|
+
const verification = verifyEdits(plan.edits, plan.host)
|
|
53
|
+
lines.push(...verification.lines)
|
|
54
|
+
return { lines, ok: verification.ok }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ADR-0001: every set op the plan previewed must still find the value it showed.
|
|
59
|
+
* A file that changed between plan review and apply refuses the whole apply with
|
|
60
|
+
* re-run guidance, so an interim user edit is never silently overwritten and the
|
|
61
|
+
* record's snapshots stay provably equal to what the user approved.
|
|
62
|
+
*/
|
|
63
|
+
function assertNoDrift(plan: InstallPlan): void {
|
|
64
|
+
const drifts: string[] = []
|
|
65
|
+
for (const edit of plan.edits) {
|
|
66
|
+
if (edit.format === "rules") continue
|
|
67
|
+
const loaded = codecFor(edit.format, plan.host).load(edit.file)
|
|
68
|
+
if (loaded.parseError) {
|
|
69
|
+
drifts.push(`${edit.file} no longer parses (${loaded.parseError})`)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
for (const op of edit.ops) {
|
|
73
|
+
if (op.op !== "set") continue
|
|
74
|
+
const current = valueAt(loaded.value, op.path)
|
|
75
|
+
if (!deepEqual(current, op.previous)) {
|
|
76
|
+
drifts.push(`${edit.file}: ${op.path.join(".")} changed since the plan was rendered`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (drifts.length > 0) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`refusing to apply — the config changed since the plan was rendered: ${drifts.join("; ")}. ` +
|
|
83
|
+
"Re-run init to re-plan against the current files.",
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function prepareEdit(edit: FileEdit, host: InstallPlan["host"]): PreparedEdit {
|
|
89
|
+
const codec = codecFor(edit.format, host)
|
|
90
|
+
const loaded = codec.load(edit.file)
|
|
91
|
+
if (loaded.parseError) {
|
|
92
|
+
throw new Error(`${edit.file} does not parse: ${loaded.parseError}`)
|
|
93
|
+
}
|
|
94
|
+
const applied = codec.apply(loaded.text, edit)
|
|
95
|
+
const recorded: RecordedFile =
|
|
96
|
+
edit.format === "rules"
|
|
97
|
+
? { 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 }
|
|
99
|
+
return {
|
|
100
|
+
edit,
|
|
101
|
+
recorded,
|
|
102
|
+
write: () => writeConfig(edit.file, applied.text),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reverts artifacts from a superseded init: shown in the plan, undone here, gone
|
|
108
|
+
* from the record. Files the new plan still claims revert partially (never
|
|
109
|
+
* deleted); files it no longer wants revert wholly and are deleted when the
|
|
110
|
+
* installer created them and they are left empty.
|
|
111
|
+
*/
|
|
112
|
+
function applyReverts(plan: InstallPlan): string[] {
|
|
113
|
+
const lines: string[] = []
|
|
114
|
+
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 }))
|
|
117
|
+
}
|
|
118
|
+
return lines
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Merges a re-apply with the record it supersedes: a set op whose previous value is
|
|
123
|
+
* what an earlier run applied inherits that run's snapshot, appended arrays
|
|
124
|
+
* accumulate, and the created flag keeps the first install's truth.
|
|
125
|
+
*/
|
|
126
|
+
function mergeRecorded(current: RecordedFile, before: RecordedFile | undefined): RecordedFile {
|
|
127
|
+
if (before === undefined) return current
|
|
128
|
+
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
|
|
131
|
+
if (op.op === "set" && old.op === "set" && deepEqual(op.previous, old.applied)) {
|
|
132
|
+
return { ...op, previous: old.previous }
|
|
133
|
+
}
|
|
134
|
+
if (op.op === "append-array" && old.op === "append-array") {
|
|
135
|
+
return { ...op, applied: [...(old.applied as unknown[]), ...(op.applied as unknown[])] }
|
|
136
|
+
}
|
|
137
|
+
return op
|
|
138
|
+
})
|
|
139
|
+
return {
|
|
140
|
+
...current,
|
|
141
|
+
created: before.created,
|
|
142
|
+
ops,
|
|
143
|
+
createdParents: [...(before.createdParents ?? []), ...(current.createdParents ?? [])],
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function verifyEdits(edits: readonly FileEdit[], host: InstallPlan["host"]): { lines: string[]; ok: boolean } {
|
|
148
|
+
const lines: string[] = []
|
|
149
|
+
let ok = true
|
|
150
|
+
for (const edit of edits) {
|
|
151
|
+
if (!existsSync(edit.file)) {
|
|
152
|
+
lines.push(`FAIL ${edit.file} does not exist after apply`)
|
|
153
|
+
ok = false
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const codec = codecFor(edit.format, host)
|
|
157
|
+
const text = readFileSync(edit.file, "utf8")
|
|
158
|
+
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
|
|
163
|
+
}
|
|
164
|
+
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
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { lines, ok }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function describeOp(op: ConfigOp): string {
|
|
174
|
+
if (op.op === "set") return `set ${op.path.join(".")}`
|
|
175
|
+
return `append ${op.path.join(".")}`
|
|
176
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { HOST_PLANNERS, planForHost } from "./hosts"
|
|
4
|
+
import { applyPlan } from "./apply"
|
|
5
|
+
import { renderPlan } from "./render"
|
|
6
|
+
import { listRecords } from "./state"
|
|
7
|
+
import { uninstall } from "./uninstall"
|
|
8
|
+
import type { HostName, InstallerInput, Scope, Tier } from "./types"
|
|
9
|
+
|
|
10
|
+
const HOSTS = Object.keys(HOST_PLANNERS) as readonly HostName[]
|
|
11
|
+
const HOST_CHOICES = `<${HOSTS.join("|")}>`
|
|
12
|
+
const SCOPES: readonly Scope[] = ["project", "global"]
|
|
13
|
+
const TIERS: readonly Tier[] = ["optional", "preferred", "forced", "off"]
|
|
14
|
+
|
|
15
|
+
const USAGE = `Usage:
|
|
16
|
+
deepseek-web-retrieval init --host ${HOST_CHOICES} --scope <project|global> \
|
|
17
|
+
--search <optional|preferred|forced|off> --fetch <optional|preferred|forced|off> \
|
|
18
|
+
[--package <npm ref>] [--cwd <dir>] [--apply]
|
|
19
|
+
deepseek-web-retrieval uninstall --host ${HOST_CHOICES} --scope <project|global> [--cwd <dir>]
|
|
20
|
+
deepseek-web-retrieval list [--cwd <dir>]
|
|
21
|
+
|
|
22
|
+
init prints the full plan (every file, old value → new value) and writes nothing
|
|
23
|
+
until --apply is passed. uninstall restores what the plan applied.`
|
|
24
|
+
|
|
25
|
+
interface Flags {
|
|
26
|
+
readonly values: Map<string, string>
|
|
27
|
+
readonly booleans: Set<string>
|
|
28
|
+
readonly problems: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const VALUE_FLAGS = new Set(["host", "scope", "search", "fetch", "package", "cwd"])
|
|
32
|
+
const BOOLEAN_FLAGS = new Set(["apply"])
|
|
33
|
+
|
|
34
|
+
function parseFlags(args: readonly string[]): Flags {
|
|
35
|
+
const values = new Map<string, string>()
|
|
36
|
+
const booleans = new Set<string>()
|
|
37
|
+
const problems: string[] = []
|
|
38
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
39
|
+
const arg = args[i] as string
|
|
40
|
+
if (!arg.startsWith("--")) {
|
|
41
|
+
problems.push(`unexpected argument "${arg}" (flags only)`)
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
const eq = arg.indexOf("=")
|
|
45
|
+
const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq)
|
|
46
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
47
|
+
booleans.add(name)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (!VALUE_FLAGS.has(name)) {
|
|
51
|
+
problems.push(`unknown flag --${name}`)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (eq !== -1) {
|
|
55
|
+
values.set(name, arg.slice(eq + 1))
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
const next = args[i + 1]
|
|
59
|
+
if (next === undefined || next.startsWith("--")) {
|
|
60
|
+
problems.push(`--${name} requires a value`)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
values.set(name, next)
|
|
64
|
+
i += 1
|
|
65
|
+
}
|
|
66
|
+
return { values, booleans, problems }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where the CLI writes; injectable so tests can assert command output. */
|
|
70
|
+
export interface CliIo {
|
|
71
|
+
readonly out?: (line: string) => void
|
|
72
|
+
readonly err?: (line: string) => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** CLI entry for the init/uninstall/list subcommands; returns the process exit code. */
|
|
76
|
+
export async function runInstallerCli(argv: readonly string[], io: CliIo = {}): Promise<number> {
|
|
77
|
+
const out = io.out ?? ((line: string) => console.log(line))
|
|
78
|
+
const err = io.err ?? ((line: string) => console.error(line))
|
|
79
|
+
const [command, ...rest] = argv
|
|
80
|
+
if (command === undefined || command === "help" || command === "--help") {
|
|
81
|
+
out(USAGE)
|
|
82
|
+
return command === undefined ? 1 : 0
|
|
83
|
+
}
|
|
84
|
+
const flags = parseFlags(rest)
|
|
85
|
+
if (flags.problems.length > 0) {
|
|
86
|
+
for (const problem of flags.problems) err(`error: ${problem}`)
|
|
87
|
+
err(`\n${USAGE}`)
|
|
88
|
+
return 1
|
|
89
|
+
}
|
|
90
|
+
const cwd = resolve(flags.values.get("cwd") ?? process.cwd())
|
|
91
|
+
const home = process.env.HOME ?? homedir()
|
|
92
|
+
const configHome = resolveConfigHome(home)
|
|
93
|
+
|
|
94
|
+
if (command === "list") {
|
|
95
|
+
const records = listRecords({ home, cwd, configHome })
|
|
96
|
+
if (records.length === 0) {
|
|
97
|
+
out("No installs recorded.")
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
for (const record of records) {
|
|
101
|
+
out(`${record.host} (${record.scope}) — search=${record.tiers.search}, fetch=${record.tiers.fetch}, installed ${record.createdAt}`)
|
|
102
|
+
for (const file of record.files) out(` ${file.created ? "created" : "modified"} ${file.file}`)
|
|
103
|
+
}
|
|
104
|
+
return 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (command === "uninstall") {
|
|
108
|
+
const required = requireInput(flags, cwd, home, configHome, false)
|
|
109
|
+
if (!required.ok) return usageError(required.problems, err)
|
|
110
|
+
try {
|
|
111
|
+
for (const line of uninstall(required.input)) out(line)
|
|
112
|
+
return 0
|
|
113
|
+
} catch (error) {
|
|
114
|
+
err(error instanceof Error ? error.message : String(error))
|
|
115
|
+
return 1
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (command !== "init") {
|
|
120
|
+
err(`Unknown command "${command}".\n\n${USAGE}`)
|
|
121
|
+
return 1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const required = requireInput(flags, cwd, home, configHome, true)
|
|
125
|
+
if (!required.ok) return usageError(required.problems, err)
|
|
126
|
+
const { input } = required
|
|
127
|
+
const plan = planForHost(input.host, input)
|
|
128
|
+
if (plan.errors.length > 0) {
|
|
129
|
+
for (const error of plan.errors) err(`error: ${error.message}`)
|
|
130
|
+
err("Nothing was written.")
|
|
131
|
+
return 1
|
|
132
|
+
}
|
|
133
|
+
out(renderPlan(plan))
|
|
134
|
+
if (!flags.booleans.has("apply")) {
|
|
135
|
+
out("\nPlan only — re-run with --apply to write these changes.")
|
|
136
|
+
return 0
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const result = applyPlan(plan, input)
|
|
140
|
+
for (const line of result.lines) out(line)
|
|
141
|
+
return result.ok ? 0 : 1
|
|
142
|
+
} catch (error) {
|
|
143
|
+
err(error instanceof Error ? error.message : String(error))
|
|
144
|
+
return 1
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function usageError(problems: readonly string[], err: (line: string) => void): number {
|
|
149
|
+
for (const problem of problems) err(`error: ${problem}`)
|
|
150
|
+
err(`\n${USAGE}`)
|
|
151
|
+
return 1
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* XDG_CONFIG_HOME is honored only when it belongs to the real home; the one
|
|
156
|
+
* environment read happens here, at the edge, and flows down as explicit input.
|
|
157
|
+
*/
|
|
158
|
+
function resolveConfigHome(home: string): string | undefined {
|
|
159
|
+
const xdg = process.env.XDG_CONFIG_HOME
|
|
160
|
+
if (xdg && home === (process.env.HOME ?? "")) return xdg
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type RequireInputResult =
|
|
165
|
+
| { readonly ok: true; readonly input: InstallerInput }
|
|
166
|
+
| { readonly ok: false; readonly problems: readonly string[] }
|
|
167
|
+
|
|
168
|
+
function requireInput(
|
|
169
|
+
flags: Flags,
|
|
170
|
+
cwd: string,
|
|
171
|
+
home: string,
|
|
172
|
+
configHome: string | undefined,
|
|
173
|
+
needsTiers: boolean,
|
|
174
|
+
): RequireInputResult {
|
|
175
|
+
const problems: string[] = []
|
|
176
|
+
const host = flags.values.get("host")
|
|
177
|
+
const scope = flags.values.get("scope")
|
|
178
|
+
const search = needsTiers ? (flags.values.get("search") ?? "") : "optional"
|
|
179
|
+
const fetch = needsTiers ? (flags.values.get("fetch") ?? "") : "optional"
|
|
180
|
+
if (host === undefined) problems.push(`--host is required (one of: ${HOSTS.join(", ")})`)
|
|
181
|
+
else if (!HOSTS.includes(host as HostName)) problems.push(`invalid --host value "${host}" (one of: ${HOSTS.join(", ")})`)
|
|
182
|
+
if (scope === undefined) problems.push(`--scope is required (one of: ${SCOPES.join(", ")})`)
|
|
183
|
+
else if (!SCOPES.includes(scope as Scope)) problems.push(`invalid --scope value "${scope}" (one of: ${SCOPES.join(", ")})`)
|
|
184
|
+
if (needsTiers) {
|
|
185
|
+
if (!TIERS.includes(search as Tier)) {
|
|
186
|
+
problems.push(
|
|
187
|
+
flags.values.has("search")
|
|
188
|
+
? `invalid --search value "${search}" (one of: ${TIERS.join(", ")})`
|
|
189
|
+
: `--search is required (one of: ${TIERS.join(", ")})`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
if (!TIERS.includes(fetch as Tier)) {
|
|
193
|
+
problems.push(
|
|
194
|
+
flags.values.has("fetch")
|
|
195
|
+
? `invalid --fetch value "${fetch}" (one of: ${TIERS.join(", ")})`
|
|
196
|
+
: `--fetch is required (one of: ${TIERS.join(", ")})`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (home === "") problems.push("HOME is not set and the user home cannot be resolved; cannot proceed")
|
|
201
|
+
if (problems.length > 0) return { ok: false, problems }
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
input: {
|
|
205
|
+
host: host as HostName,
|
|
206
|
+
scope: scope as Scope,
|
|
207
|
+
search: search as Tier,
|
|
208
|
+
fetch: fetch as Tier,
|
|
209
|
+
packageRef: flags.values.get("package") ?? "deepseek-web-retrieval",
|
|
210
|
+
home,
|
|
211
|
+
cwd,
|
|
212
|
+
configHome,
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|