@open-pets/zed 4.0.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/LICENSE +21 -0
- package/dist/check-zed.d.ts +2 -0
- package/dist/check-zed.d.ts.map +1 -0
- package/dist/check-zed.js +747 -0
- package/dist/check-zed.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/zed-mcp.d.ts +36 -0
- package/dist/zed-mcp.d.ts.map +1 -0
- package/dist/zed-mcp.js +72 -0
- package/dist/zed-mcp.js.map +1 -0
- package/dist/zed-status.d.ts +48 -0
- package/dist/zed-status.d.ts.map +1 -0
- package/dist/zed-status.js +1219 -0
- package/dist/zed-status.js.map +1 -0
- package/package.json +38 -0
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, parse, resolve } from "node:path";
|
|
4
|
+
import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser";
|
|
5
|
+
import { buildZedMcpEntry, isValidOpenPetsMcpScriptPath, isValidOpenPetsPackageVersion, isValidPetId, isValidZedNodeCommand, zedMcpServerName, } from "./zed-mcp.js";
|
|
6
|
+
export const maxZedSettingsBytes = 256 * 1024;
|
|
7
|
+
const zedWriteLockVersion = 1;
|
|
8
|
+
const maxZedWriteLockBytes = 16 * 1024;
|
|
9
|
+
const staleUnownedZedLockMs = 10 * 60 * 1000;
|
|
10
|
+
const activeZedWriteLockTokens = new Set();
|
|
11
|
+
export function parseZedSettings(text) {
|
|
12
|
+
if (Buffer.byteLength(text, "utf8") > maxZedSettingsBytes) {
|
|
13
|
+
return { ok: false, message: "Zed settings exceed 256 KiB.", reason: "size" };
|
|
14
|
+
}
|
|
15
|
+
const errors = [];
|
|
16
|
+
const parsed = parseJsonc(text.trim() ? text : "{}", errors, { allowTrailingComma: true, disallowComments: false });
|
|
17
|
+
if (errors.length > 0)
|
|
18
|
+
return { ok: false, message: "Zed settings JSONC is invalid.", reason: "parse" };
|
|
19
|
+
if (parsed === undefined)
|
|
20
|
+
return { ok: true, value: {} };
|
|
21
|
+
if (!isRecord(parsed))
|
|
22
|
+
return { ok: false, message: "Zed settings must be a JSON object.", reason: "invalid-schema" };
|
|
23
|
+
if (parsed.context_servers !== undefined && !isRecord(parsed.context_servers)) {
|
|
24
|
+
return { ok: false, message: "Zed settings context_servers must be an object.", reason: "invalid-schema" };
|
|
25
|
+
}
|
|
26
|
+
return { ok: true, value: parsed };
|
|
27
|
+
}
|
|
28
|
+
export function updateZedSettingsText(text, path, value) {
|
|
29
|
+
const parsed = parseZedSettings(text);
|
|
30
|
+
if (!parsed.ok)
|
|
31
|
+
return parsed;
|
|
32
|
+
let next = text.trim() ? text : "{}\n";
|
|
33
|
+
const edits = modify(next, [...path], value, { formattingOptions: { tabSize: 2, insertSpaces: true } });
|
|
34
|
+
next = applyEdits(next, edits);
|
|
35
|
+
const validated = parseZedSettings(next);
|
|
36
|
+
if (!validated.ok)
|
|
37
|
+
return validated;
|
|
38
|
+
return next.endsWith("\n") ? next : `${next}\n`;
|
|
39
|
+
}
|
|
40
|
+
export function readZedSettings(settingsPath) {
|
|
41
|
+
try {
|
|
42
|
+
const pathSafety = assertSafeConfigPath(settingsPath);
|
|
43
|
+
if (!pathSafety.ok)
|
|
44
|
+
return pathSafety;
|
|
45
|
+
const existing = assertSafeExistingSettingsFile(settingsPath, true);
|
|
46
|
+
if (!existing.ok)
|
|
47
|
+
return existing;
|
|
48
|
+
if (!existing.exists)
|
|
49
|
+
return { ok: true, config: {}, content: "", exists: false };
|
|
50
|
+
const content = readFileSync(settingsPath, "utf8");
|
|
51
|
+
if (Buffer.byteLength(content, "utf8") > maxZedSettingsBytes) {
|
|
52
|
+
return { ok: false, message: "Zed settings exceed 256 KiB.", reason: "size" };
|
|
53
|
+
}
|
|
54
|
+
const parsed = parseZedSettings(content);
|
|
55
|
+
if (!parsed.ok)
|
|
56
|
+
return parsed;
|
|
57
|
+
return { ok: true, config: parsed.value, content, exists: true };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return { ok: false, message: `IO error: ${error instanceof Error ? error.message : String(error)}`, reason: "io" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export const readZedMcpConfig = readZedSettings;
|
|
64
|
+
export function classifyZedMcpStatus(configResult, settingsPath, expected) {
|
|
65
|
+
if (!configResult.ok) {
|
|
66
|
+
const messages = {
|
|
67
|
+
parse: "Zed settings JSONC is invalid.",
|
|
68
|
+
size: "Zed settings are too large.",
|
|
69
|
+
symlink: "Zed settings path is a symlink.",
|
|
70
|
+
"not-regular": "Zed settings path is not a regular file.",
|
|
71
|
+
"unsafe-path": "Zed settings path is unsafe.",
|
|
72
|
+
"invalid-schema": "Zed settings have an invalid schema.",
|
|
73
|
+
io: "Failed to read Zed settings.",
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
status: configResult.reason === "io" ? "error" : "invalid",
|
|
77
|
+
message: messages[configResult.reason],
|
|
78
|
+
settingsPath,
|
|
79
|
+
canInstall: false,
|
|
80
|
+
canReplace: false,
|
|
81
|
+
canRemove: false,
|
|
82
|
+
redactedDetails: configResult.message,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (!configResult.exists)
|
|
86
|
+
return missingStatus(settingsPath, expected, "Zed settings do not exist.");
|
|
87
|
+
const contextServers = isRecord(configResult.config.context_servers) ? configResult.config.context_servers : undefined;
|
|
88
|
+
if (!contextServers || contextServers[zedMcpServerName] === undefined) {
|
|
89
|
+
return missingStatus(settingsPath, expected, "OpenPets MCP is not configured in Zed settings.");
|
|
90
|
+
}
|
|
91
|
+
const entry = contextServers[zedMcpServerName];
|
|
92
|
+
const expectedEntry = buildZedMcpEntry(expected);
|
|
93
|
+
const shape = inspectZedMcpEntry(entry);
|
|
94
|
+
if (shape === "invalid") {
|
|
95
|
+
return {
|
|
96
|
+
status: "invalid",
|
|
97
|
+
message: "Zed openpets MCP entry has an invalid schema.",
|
|
98
|
+
settingsPath,
|
|
99
|
+
canInstall: false,
|
|
100
|
+
canReplace: false,
|
|
101
|
+
canRemove: false,
|
|
102
|
+
redactedDetails: "context_servers.openpets is malformed",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (shape === "conflict") {
|
|
106
|
+
return {
|
|
107
|
+
status: "conflict",
|
|
108
|
+
message: "Zed settings have a non-OpenPets context server using the openpets key.",
|
|
109
|
+
settingsPath,
|
|
110
|
+
canInstall: false,
|
|
111
|
+
canReplace: true,
|
|
112
|
+
canRemove: false,
|
|
113
|
+
redactedDetails: "Existing context_servers.openpets entry is not managed by OpenPets",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (isRecord(entry) && entry.remote === true) {
|
|
117
|
+
const disabled = entry.enabled === false;
|
|
118
|
+
return {
|
|
119
|
+
status: "needs-update",
|
|
120
|
+
message: disabled
|
|
121
|
+
? "OpenPets MCP is configured for remote execution and disabled in Zed; local execution is required. Use replace to re-enable it."
|
|
122
|
+
: "OpenPets MCP is configured for remote execution in Zed; local execution is required.",
|
|
123
|
+
settingsPath,
|
|
124
|
+
canInstall: !disabled,
|
|
125
|
+
canReplace: true,
|
|
126
|
+
canRemove: true,
|
|
127
|
+
previewEntry: expectedEntry,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (shape === "disabled") {
|
|
131
|
+
return {
|
|
132
|
+
status: "disabled",
|
|
133
|
+
message: "OpenPets MCP is configured in Zed but disabled.",
|
|
134
|
+
settingsPath,
|
|
135
|
+
canInstall: false,
|
|
136
|
+
canReplace: true,
|
|
137
|
+
canRemove: true,
|
|
138
|
+
previewEntry: buildZedMcpEntry(expected),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (isSameZedMcpEntry(entry, expectedEntry)) {
|
|
142
|
+
return {
|
|
143
|
+
status: "installed",
|
|
144
|
+
message: "OpenPets MCP is installed in Zed and up to date.",
|
|
145
|
+
settingsPath,
|
|
146
|
+
canInstall: false,
|
|
147
|
+
canReplace: false,
|
|
148
|
+
canRemove: true,
|
|
149
|
+
previewEntry: expectedEntry,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
status: "needs-update",
|
|
154
|
+
message: "OpenPets MCP in Zed needs an update (version, pet, or command differs).",
|
|
155
|
+
settingsPath,
|
|
156
|
+
canInstall: true,
|
|
157
|
+
canReplace: true,
|
|
158
|
+
canRemove: true,
|
|
159
|
+
previewEntry: expectedEntry,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function isManagedOpenPetsMcpEntry(value) {
|
|
163
|
+
const shape = inspectZedMcpEntry(value);
|
|
164
|
+
return shape === "managed" || shape === "disabled";
|
|
165
|
+
}
|
|
166
|
+
export function planZedMcpInstall(settingsPath, options, allowReplace = false) {
|
|
167
|
+
const existing = readZedSettings(settingsPath);
|
|
168
|
+
if (!existing.ok)
|
|
169
|
+
return existing;
|
|
170
|
+
const status = classifyZedMcpStatus(existing, settingsPath, options);
|
|
171
|
+
if (status.status === "invalid" || status.status === "error") {
|
|
172
|
+
return { ok: false, message: status.message, reason: "invalid-schema" };
|
|
173
|
+
}
|
|
174
|
+
if (status.status === "disabled") {
|
|
175
|
+
return { ok: false, message: "Cannot install: OpenPets MCP is disabled in Zed settings. Use replace to explicitly re-enable it.", reason: "invalid-schema" };
|
|
176
|
+
}
|
|
177
|
+
if (status.status === "needs-update" && !status.canInstall) {
|
|
178
|
+
return { ok: false, message: "Cannot install: OpenPets MCP is disabled in Zed settings. Use replace to explicitly re-enable it.", reason: "invalid-schema" };
|
|
179
|
+
}
|
|
180
|
+
if (status.status === "conflict" && !allowReplace) {
|
|
181
|
+
return { ok: false, message: "Cannot install: Zed has a conflicting openpets context server. Use replace instead.", reason: "invalid-schema" };
|
|
182
|
+
}
|
|
183
|
+
if (status.status === "installed") {
|
|
184
|
+
return { ok: false, message: "OpenPets MCP is already installed in Zed.", reason: "invalid-schema" };
|
|
185
|
+
}
|
|
186
|
+
const currentEntry = getOpenPetsEntry(existing.config);
|
|
187
|
+
const nextEntry = status.status === "needs-update" ? preserveManagedFields(currentEntry, options) : buildZedMcpEntry(options);
|
|
188
|
+
return planZedSettingsWrite(settingsPath, existing.content, existing.exists, nextEntry);
|
|
189
|
+
}
|
|
190
|
+
export function planZedMcpReplace(settingsPath, options) {
|
|
191
|
+
const existing = readZedSettings(settingsPath);
|
|
192
|
+
if (!existing.ok)
|
|
193
|
+
return existing;
|
|
194
|
+
const status = classifyZedMcpStatus(existing, settingsPath, options);
|
|
195
|
+
if (status.status === "invalid" || status.status === "error") {
|
|
196
|
+
return { ok: false, message: status.message, reason: "invalid-schema" };
|
|
197
|
+
}
|
|
198
|
+
if (status.status === "missing") {
|
|
199
|
+
return { ok: false, message: "Cannot replace: OpenPets MCP is not configured in Zed settings. Use install instead.", reason: "invalid-schema" };
|
|
200
|
+
}
|
|
201
|
+
if (status.status === "installed") {
|
|
202
|
+
return { ok: false, message: "Cannot replace: OpenPets MCP is already installed in Zed.", reason: "invalid-schema" };
|
|
203
|
+
}
|
|
204
|
+
const currentEntry = getOpenPetsEntry(existing.config);
|
|
205
|
+
const nextEntry = status.status === "conflict"
|
|
206
|
+
? buildZedMcpEntry(options)
|
|
207
|
+
: preserveManagedFields(currentEntry, options, isRecord(currentEntry) && currentEntry.enabled === false);
|
|
208
|
+
return planZedSettingsWrite(settingsPath, existing.content, existing.exists, nextEntry);
|
|
209
|
+
}
|
|
210
|
+
export function planZedMcpRemove(settingsPath, expectedOptions) {
|
|
211
|
+
const existing = readZedSettings(settingsPath);
|
|
212
|
+
if (!existing.ok)
|
|
213
|
+
return existing;
|
|
214
|
+
const status = classifyZedMcpStatus(existing, settingsPath, expectedOptions ?? { mcpVersion: "0.0.0" });
|
|
215
|
+
if (status.status === "invalid" || status.status === "error") {
|
|
216
|
+
return { ok: false, message: status.message, reason: "invalid-schema" };
|
|
217
|
+
}
|
|
218
|
+
if (status.status === "missing") {
|
|
219
|
+
return { ok: false, message: "OpenPets MCP is not installed in Zed settings.", reason: "invalid-schema" };
|
|
220
|
+
}
|
|
221
|
+
if (status.status === "conflict") {
|
|
222
|
+
return { ok: false, message: "Cannot remove: Zed's openpets context server is not managed by OpenPets.", reason: "invalid-schema" };
|
|
223
|
+
}
|
|
224
|
+
const next = updateZedSettingsText(existing.content, ["context_servers", zedMcpServerName], undefined);
|
|
225
|
+
if (typeof next !== "string")
|
|
226
|
+
return next;
|
|
227
|
+
return buildZedWritePlan(settingsPath, next, existing.content, existing.exists);
|
|
228
|
+
}
|
|
229
|
+
export function executeZedMcpWrite(plan) {
|
|
230
|
+
const pathSafety = assertSafeConfigPath(plan.targetPath);
|
|
231
|
+
if (!pathSafety.ok)
|
|
232
|
+
throw new Error(pathSafety.message);
|
|
233
|
+
const targetSafety = assertSafeExistingSettingsFile(plan.targetPath, true);
|
|
234
|
+
if (!targetSafety.ok)
|
|
235
|
+
throw new Error(targetSafety.message);
|
|
236
|
+
const parsed = parseZedSettings(plan.content);
|
|
237
|
+
if (!parsed.ok)
|
|
238
|
+
throw new Error(parsed.message);
|
|
239
|
+
const parent = dirname(plan.targetPath);
|
|
240
|
+
if (targetSafety.exists && !plan.backupPath)
|
|
241
|
+
throw new Error("Zed writes require a backup path for existing settings.");
|
|
242
|
+
const parentSafety = assertSafeParentDirectory(parent);
|
|
243
|
+
if (!parentSafety.ok)
|
|
244
|
+
throw new Error(parentSafety.message);
|
|
245
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
246
|
+
const lockPath = join(parent, ".openpets-zed.lock");
|
|
247
|
+
if (!isSafeSiblingPath(parent, lockPath))
|
|
248
|
+
throw new Error("Zed write lock path is unsafe.");
|
|
249
|
+
const withdrawalPath = targetSafety.exists
|
|
250
|
+
? uniquePath(join(parent, `.openpets-zed-withdraw-${process.pid}-${Date.now()}-${randomUUID()}.tmp`))
|
|
251
|
+
: undefined;
|
|
252
|
+
let lockFd;
|
|
253
|
+
let lockToken;
|
|
254
|
+
let lockOwnerTempPath;
|
|
255
|
+
let lockOwnerTempHash;
|
|
256
|
+
let tempCreated = false;
|
|
257
|
+
let backupCreated = false;
|
|
258
|
+
let claimCreated = false;
|
|
259
|
+
let withdrawalCreated = false;
|
|
260
|
+
let committed = false;
|
|
261
|
+
let retainLock = false;
|
|
262
|
+
try {
|
|
263
|
+
assertZedHardLinkSupport(parent);
|
|
264
|
+
const lock = acquireZedWriteLock(lockPath, parent, plan, withdrawalPath);
|
|
265
|
+
lockFd = lock.fd;
|
|
266
|
+
lockToken = lock.token;
|
|
267
|
+
lockOwnerTempPath = lock.ownerTempPath;
|
|
268
|
+
lockOwnerTempHash = lock.ownerTempHash;
|
|
269
|
+
const supportPaths = [plan.backupPath, plan.claimPath, withdrawalPath, plan.tempPath].filter((path) => typeof path === "string");
|
|
270
|
+
for (const supportPath of supportPaths) {
|
|
271
|
+
if (!isSafeSiblingPath(parent, supportPath))
|
|
272
|
+
throw new Error("Zed write support path is unsafe.");
|
|
273
|
+
if (lstatSync(supportPath, { throwIfNoEntry: false }))
|
|
274
|
+
throw new Error("Zed write support path already exists.");
|
|
275
|
+
}
|
|
276
|
+
const currentTarget = assertSafeExistingSettingsFile(plan.targetPath, true);
|
|
277
|
+
if (!currentTarget.ok)
|
|
278
|
+
throw new Error(currentTarget.message);
|
|
279
|
+
const currentContent = currentTarget.exists ? readFileSync(plan.targetPath, "utf8") : "";
|
|
280
|
+
if (currentTarget.exists !== plan.sourceExists || currentContent !== plan.sourceContent)
|
|
281
|
+
throw new Error("Zed settings changed since this operation was previewed. Refresh the status and try again.");
|
|
282
|
+
const fd = openSync(plan.tempPath, "wx", 0o600);
|
|
283
|
+
tempCreated = true;
|
|
284
|
+
try {
|
|
285
|
+
writeFileSync(fd, plan.content, "utf8");
|
|
286
|
+
fsyncSync(fd);
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
closeSync(fd);
|
|
290
|
+
}
|
|
291
|
+
const finalTarget = assertSafeExistingSettingsFile(plan.targetPath, true);
|
|
292
|
+
if (!finalTarget.ok)
|
|
293
|
+
throw new Error(finalTarget.message);
|
|
294
|
+
const finalContent = finalTarget.exists ? readFileSync(plan.targetPath, "utf8") : "";
|
|
295
|
+
if (finalTarget.exists !== plan.sourceExists || finalContent !== plan.sourceContent)
|
|
296
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
297
|
+
if (finalTarget.exists) {
|
|
298
|
+
if (!plan.backupPath || !plan.claimPath || !withdrawalPath)
|
|
299
|
+
throw new Error("Zed writes require backup, claim, and withdrawal paths for existing settings.");
|
|
300
|
+
try {
|
|
301
|
+
// Keep the original inode recoverable while the visible target is
|
|
302
|
+
// withdrawn. An exclusive hard link also preserves writes through an
|
|
303
|
+
// already-open descriptor after publication.
|
|
304
|
+
linkSync(plan.targetPath, plan.backupPath);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (isAlreadyExistsError(error))
|
|
308
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
backupCreated = true;
|
|
312
|
+
const backup = readZedRecoveryArtifact(plan.backupPath, parent);
|
|
313
|
+
if (!backup.exists || backup.content !== plan.sourceContent) {
|
|
314
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
315
|
+
}
|
|
316
|
+
const copiedTarget = assertSafeExistingSettingsFile(plan.targetPath, true);
|
|
317
|
+
if (!copiedTarget.ok)
|
|
318
|
+
throw new Error(copiedTarget.message);
|
|
319
|
+
const copiedContent = copiedTarget.exists ? readFileSync(plan.targetPath, "utf8") : "";
|
|
320
|
+
if (!copiedTarget.exists || copiedContent !== plan.sourceContent)
|
|
321
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
322
|
+
try {
|
|
323
|
+
// Claim creation is no-clobber. The backup hard link keeps the source
|
|
324
|
+
// inode recoverable even after the target directory entry is removed.
|
|
325
|
+
linkSync(plan.targetPath, plan.claimPath);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (isAlreadyExistsError(error))
|
|
329
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
claimCreated = true;
|
|
333
|
+
const claim = readZedRecoveryArtifact(plan.claimPath, parent);
|
|
334
|
+
if (!claim.exists || claim.content !== plan.sourceContent) {
|
|
335
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
336
|
+
}
|
|
337
|
+
const claimStat = lstatSync(plan.claimPath);
|
|
338
|
+
const targetStat = lstatSync(plan.targetPath);
|
|
339
|
+
if (claimStat.dev !== targetStat.dev || claimStat.ino !== targetStat.ino) {
|
|
340
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
renameSync(plan.targetPath, withdrawalPath);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
if (isAlreadyExistsError(error))
|
|
347
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
withdrawalCreated = true;
|
|
351
|
+
const withdrawnTarget = assertSafeExistingSettingsFile(plan.targetPath, true);
|
|
352
|
+
if (!withdrawnTarget.ok)
|
|
353
|
+
throw new Error(withdrawnTarget.message);
|
|
354
|
+
if (withdrawnTarget.exists)
|
|
355
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
356
|
+
try {
|
|
357
|
+
linkSync(plan.tempPath, plan.targetPath);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
if (isAlreadyExistsError(error))
|
|
361
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
const publishedClaim = readZedRecoveryArtifact(plan.claimPath, parent);
|
|
365
|
+
if (!publishedClaim.exists || publishedClaim.content !== plan.sourceContent) {
|
|
366
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
367
|
+
}
|
|
368
|
+
const publishedBackup = readZedRecoveryArtifact(plan.backupPath, parent);
|
|
369
|
+
if (!publishedBackup.exists || publishedBackup.content !== plan.sourceContent) {
|
|
370
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
371
|
+
}
|
|
372
|
+
const publishedWithdrawal = readZedRecoveryArtifact(withdrawalPath, parent);
|
|
373
|
+
if (!publishedWithdrawal.exists || publishedWithdrawal.content !== plan.sourceContent) {
|
|
374
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
375
|
+
}
|
|
376
|
+
tempCreated = false;
|
|
377
|
+
committed = true;
|
|
378
|
+
try {
|
|
379
|
+
removeZedRecoveryArtifact(plan.tempPath, parent, hashZedSettingsContent(plan.content), "Zed write recovery is ambiguous; the prepared settings changed.");
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
retainLock = true;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
removeZedRecoveryArtifact(plan.claimPath, parent, hashZedSettingsContent(plan.sourceContent), "Zed write recovery is ambiguous; the original claim changed.");
|
|
386
|
+
claimCreated = false;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
retainLock = true;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
removeZedRecoveryArtifact(withdrawalPath, parent, hashZedSettingsContent(plan.sourceContent), "Zed write recovery is ambiguous; the withdrawn settings changed.");
|
|
393
|
+
withdrawalCreated = false;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
retainLock = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
// A missing target must not be replaced if another writer creates it first.
|
|
401
|
+
try {
|
|
402
|
+
linkSync(plan.tempPath, plan.targetPath);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
if (isAlreadyExistsError(error))
|
|
406
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
committed = true;
|
|
410
|
+
try {
|
|
411
|
+
removeZedRecoveryArtifact(plan.tempPath, parent, hashZedSettingsContent(plan.content), "Zed write recovery is ambiguous; the prepared settings changed.");
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
retainLock = true;
|
|
415
|
+
}
|
|
416
|
+
tempCreated = false;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
chmodSync(plan.targetPath, 0o600);
|
|
420
|
+
}
|
|
421
|
+
catch { /* best effort */ }
|
|
422
|
+
const committedTarget = assertSafeExistingSettingsFile(plan.targetPath, true);
|
|
423
|
+
if (!committedTarget.ok)
|
|
424
|
+
throw new Error(committedTarget.message);
|
|
425
|
+
if (!committedTarget.exists || readFileSync(plan.targetPath, "utf8") !== plan.content)
|
|
426
|
+
throw new Error("Zed settings changed during this operation. Refresh the status and try again.");
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (committed)
|
|
430
|
+
retainLock = true;
|
|
431
|
+
if (!committed && withdrawalCreated && plan.backupPath && plan.claimPath && withdrawalPath) {
|
|
432
|
+
try {
|
|
433
|
+
const restoredContent = restoreZedWithdrawalAfterFailedWrite(plan.targetPath, plan.tempPath, withdrawalPath, plan.content);
|
|
434
|
+
if (restoredContent !== undefined) {
|
|
435
|
+
withdrawalCreated = false;
|
|
436
|
+
if (restoredContent === plan.sourceContent && cleanupZedRollbackArtifacts(plan.targetPath, plan.backupPath, plan.claimPath, plan.sourceContent)) {
|
|
437
|
+
claimCreated = false;
|
|
438
|
+
backupCreated = false;
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
retainLock = true;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
retainLock = true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
retainLock = true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (!committed && claimCreated && !withdrawalCreated && plan.backupPath && plan.claimPath) {
|
|
453
|
+
try {
|
|
454
|
+
if (cleanupZedRollbackArtifacts(plan.targetPath, plan.backupPath, plan.claimPath, plan.sourceContent)) {
|
|
455
|
+
claimCreated = false;
|
|
456
|
+
backupCreated = false;
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
retainLock = true;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
retainLock = true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (!committed && backupCreated && plan.backupPath && !claimCreated) {
|
|
467
|
+
try {
|
|
468
|
+
const currentTarget = assertSafeExistingSettingsFile(plan.targetPath, true);
|
|
469
|
+
if (!currentTarget.ok || !currentTarget.exists || readFileSync(plan.targetPath, "utf8") !== plan.sourceContent) {
|
|
470
|
+
retainLock = true;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
removeZedRecoveryArtifact(plan.backupPath, parent, hashZedSettingsContent(plan.sourceContent), "Zed write recovery is ambiguous; the original backup changed.");
|
|
474
|
+
backupCreated = false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
retainLock = true;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (tempCreated) {
|
|
482
|
+
try {
|
|
483
|
+
removeZedRecoveryArtifact(plan.tempPath, parent, hashZedSettingsContent(plan.content), "Zed write recovery is ambiguous; the prepared settings changed.");
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
retainLock = true;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
if (lockFd !== undefined) {
|
|
493
|
+
try {
|
|
494
|
+
closeSync(lockFd);
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
if (lockToken && !retainLock)
|
|
498
|
+
removeOwnedZedWriteLock(lockPath, lockToken);
|
|
499
|
+
if (lockOwnerTempPath && lockOwnerTempHash) {
|
|
500
|
+
try {
|
|
501
|
+
removeZedRecoveryArtifact(lockOwnerTempPath, parent, lockOwnerTempHash, "Zed write lock owner artifact changed during cleanup.");
|
|
502
|
+
}
|
|
503
|
+
catch { /* best effort; preserve ambiguous artifacts */ }
|
|
504
|
+
}
|
|
505
|
+
if (lockToken)
|
|
506
|
+
activeZedWriteLockTokens.delete(lockToken);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function assertZedHardLinkSupport(parent) {
|
|
512
|
+
const stamp = `${process.pid}-${Date.now()}-${randomUUID()}`;
|
|
513
|
+
const sourcePath = uniquePath(join(parent, `.openpets-zed-link-probe-${stamp}.tmp`));
|
|
514
|
+
const linkPath = uniquePath(join(parent, `.openpets-zed-link-probe-${stamp}.link`));
|
|
515
|
+
let sourceCreated = false;
|
|
516
|
+
let linkCreated = false;
|
|
517
|
+
let sourceFd;
|
|
518
|
+
try {
|
|
519
|
+
sourceFd = openSync(sourcePath, "wx", 0o600);
|
|
520
|
+
sourceCreated = true;
|
|
521
|
+
closeSync(sourceFd);
|
|
522
|
+
sourceFd = undefined;
|
|
523
|
+
linkSync(sourcePath, linkPath);
|
|
524
|
+
linkCreated = true;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
throw new Error("Zed settings writes require filesystem hard-link support.");
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
if (sourceFd !== undefined) {
|
|
531
|
+
try {
|
|
532
|
+
closeSync(sourceFd);
|
|
533
|
+
}
|
|
534
|
+
catch { /* best effort */ }
|
|
535
|
+
}
|
|
536
|
+
if (sourceCreated) {
|
|
537
|
+
try {
|
|
538
|
+
rmSync(sourcePath, { force: true });
|
|
539
|
+
}
|
|
540
|
+
catch { /* best effort */ }
|
|
541
|
+
}
|
|
542
|
+
if (linkCreated) {
|
|
543
|
+
try {
|
|
544
|
+
rmSync(linkPath, { force: true });
|
|
545
|
+
}
|
|
546
|
+
catch { /* best effort */ }
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function acquireZedWriteLock(lockPath, parent, plan, withdrawalPath) {
|
|
551
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
552
|
+
const token = randomUUID();
|
|
553
|
+
const ownerTempPath = uniquePath(join(parent, `.openpets-zed-lock-${process.pid}-${Date.now()}-${token}.tmp`));
|
|
554
|
+
const record = {
|
|
555
|
+
version: zedWriteLockVersion,
|
|
556
|
+
token,
|
|
557
|
+
pid: process.pid,
|
|
558
|
+
lockTempPath: ownerTempPath,
|
|
559
|
+
targetPath: plan.targetPath,
|
|
560
|
+
...(plan.backupPath ? { backupPath: plan.backupPath } : {}),
|
|
561
|
+
...(plan.claimPath ? { claimPath: plan.claimPath } : {}),
|
|
562
|
+
...(withdrawalPath ? { withdrawalPath } : {}),
|
|
563
|
+
tempPath: plan.tempPath,
|
|
564
|
+
sourceExists: plan.sourceExists,
|
|
565
|
+
sourceHash: hashZedSettingsContent(plan.sourceContent),
|
|
566
|
+
contentHash: hashZedSettingsContent(plan.content),
|
|
567
|
+
};
|
|
568
|
+
const recordContent = JSON.stringify(record);
|
|
569
|
+
const ownerTempHash = hashZedSettingsContent(recordContent);
|
|
570
|
+
const ownerFd = openSync(ownerTempPath, "wx", 0o600);
|
|
571
|
+
try {
|
|
572
|
+
writeFileSync(ownerFd, recordContent, "utf8");
|
|
573
|
+
fsyncSync(ownerFd);
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
closeSync(ownerFd);
|
|
577
|
+
try {
|
|
578
|
+
removeZedRecoveryArtifact(ownerTempPath, parent, ownerTempHash, "Zed write lock owner artifact changed during cleanup.");
|
|
579
|
+
}
|
|
580
|
+
catch { /* preserve an ambiguous owner artifact */ }
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
closeSync(ownerFd);
|
|
584
|
+
try {
|
|
585
|
+
linkSync(ownerTempPath, lockPath);
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
try {
|
|
589
|
+
removeZedRecoveryArtifact(ownerTempPath, parent, ownerTempHash, "Zed write lock owner artifact changed during cleanup.");
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
if (!isAlreadyExistsError(error))
|
|
595
|
+
throw error;
|
|
596
|
+
if (attempt === 2)
|
|
597
|
+
throw zedWriteInProgressError();
|
|
598
|
+
recoverStaleZedWriteLock(lockPath, parent, plan.targetPath);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
removeZedRecoveryArtifact(ownerTempPath, parent, ownerTempHash, "Zed write lock owner artifact changed during acquisition.");
|
|
602
|
+
try {
|
|
603
|
+
const fd = openSync(lockPath, "r+");
|
|
604
|
+
activeZedWriteLockTokens.add(token);
|
|
605
|
+
return { fd, token, ownerTempPath, ownerTempHash };
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
removeOwnedZedWriteLock(lockPath, token);
|
|
609
|
+
try {
|
|
610
|
+
removeZedRecoveryArtifact(ownerTempPath, parent, ownerTempHash, "Zed write lock owner artifact changed during cleanup.");
|
|
611
|
+
}
|
|
612
|
+
catch { /* best effort; preserve ambiguous owner artifacts */ }
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
throw zedWriteInProgressError();
|
|
617
|
+
}
|
|
618
|
+
function recoverStaleZedWriteLock(lockPath, parent, targetPath) {
|
|
619
|
+
const lockStat = lstatSync(lockPath, { throwIfNoEntry: false });
|
|
620
|
+
if (!lockStat)
|
|
621
|
+
return;
|
|
622
|
+
if (lockStat.isSymbolicLink() || !lockStat.isFile())
|
|
623
|
+
throw new Error("Zed write lock path is not a regular file.");
|
|
624
|
+
const record = readZedWriteLockRecord(lockPath);
|
|
625
|
+
if (!record) {
|
|
626
|
+
if (Date.now() - lockStat.mtimeMs < staleUnownedZedLockMs)
|
|
627
|
+
throw zedWriteInProgressError();
|
|
628
|
+
}
|
|
629
|
+
// A retained lock from a completed write may be retried by this process,
|
|
630
|
+
// but a live lock owned by another process must never be recovered.
|
|
631
|
+
if (record && isZedWriteLockInUse(record)) {
|
|
632
|
+
throw zedWriteInProgressError();
|
|
633
|
+
}
|
|
634
|
+
const claimPath = uniquePath(join(parent, `.openpets-zed-lock-recovery-${process.pid}-${Date.now()}-${randomUUID()}.tmp`));
|
|
635
|
+
try {
|
|
636
|
+
renameSync(lockPath, claimPath);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
if (isMissingError(error))
|
|
640
|
+
return;
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
let claimOwned = true;
|
|
644
|
+
try {
|
|
645
|
+
const claimedArtifact = readZedRecoveryArtifact(claimPath, parent);
|
|
646
|
+
const claimedRecord = readZedWriteLockRecord(claimPath);
|
|
647
|
+
if ((record && (!claimedRecord || claimedRecord.token !== record.token)) || (!record && claimedRecord)) {
|
|
648
|
+
restoreZedWriteLockClaim(claimPath, lockPath);
|
|
649
|
+
claimOwned = false;
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (claimedRecord) {
|
|
653
|
+
if (isZedWriteLockInUse(claimedRecord)) {
|
|
654
|
+
restoreZedWriteLockClaim(claimPath, lockPath);
|
|
655
|
+
claimOwned = false;
|
|
656
|
+
throw zedWriteInProgressError();
|
|
657
|
+
}
|
|
658
|
+
validateZedWriteLockRecord(claimedRecord, lockPath, parent, targetPath);
|
|
659
|
+
if (lstatSync(lockPath, { throwIfNoEntry: false })) {
|
|
660
|
+
if (claimedArtifact.exists && claimedArtifact.content !== undefined) {
|
|
661
|
+
removeZedRecoveryArtifact(claimPath, parent, hashZedSettingsContent(claimedArtifact.content), "Zed write lock claim changed during recovery.");
|
|
662
|
+
}
|
|
663
|
+
claimOwned = false;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const ownerTemp = readZedRecoveryArtifact(claimedRecord.lockTempPath, parent);
|
|
667
|
+
recoverZedWriteArtifacts(claimedRecord, parent);
|
|
668
|
+
if (ownerTemp.exists && ownerTemp.content !== undefined) {
|
|
669
|
+
removeZedRecoveryArtifact(claimedRecord.lockTempPath, parent, hashZedSettingsContent(ownerTemp.content), "Zed write lock owner artifact changed during recovery.");
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (claimedArtifact.exists && claimedArtifact.content !== undefined) {
|
|
673
|
+
removeZedRecoveryArtifact(claimPath, parent, hashZedSettingsContent(claimedArtifact.content), "Zed write lock claim changed during recovery.");
|
|
674
|
+
}
|
|
675
|
+
claimOwned = false;
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
if (claimOwned) {
|
|
679
|
+
try {
|
|
680
|
+
restoreZedWriteLockClaim(claimPath, lockPath);
|
|
681
|
+
}
|
|
682
|
+
catch { /* preserve the claim for safe manual recovery */ }
|
|
683
|
+
}
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function isZedWriteLockInUse(record) {
|
|
688
|
+
return isProcessAlive(record.pid) && (record.pid !== process.pid || activeZedWriteLockTokens.has(record.token));
|
|
689
|
+
}
|
|
690
|
+
function restoreZedWriteLockClaim(claimPath, lockPath) {
|
|
691
|
+
const parent = dirname(lockPath);
|
|
692
|
+
const claim = readZedRecoveryArtifact(claimPath, parent);
|
|
693
|
+
try {
|
|
694
|
+
linkSync(claimPath, lockPath);
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
if (!isAlreadyExistsError(error))
|
|
698
|
+
throw error;
|
|
699
|
+
}
|
|
700
|
+
if (claim.exists && claim.content !== undefined) {
|
|
701
|
+
removeZedRecoveryArtifact(claimPath, parent, hashZedSettingsContent(claim.content), "Zed write lock claim changed during recovery.");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function readZedWriteLockRecord(lockPath) {
|
|
705
|
+
const stat = lstatSync(lockPath, { throwIfNoEntry: false });
|
|
706
|
+
if (!stat)
|
|
707
|
+
return undefined;
|
|
708
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
709
|
+
throw new Error("Zed write lock path is not a regular file.");
|
|
710
|
+
if (stat.size > maxZedWriteLockBytes)
|
|
711
|
+
throw new Error("Zed write lock metadata is too large.");
|
|
712
|
+
const text = readFileSync(lockPath, "utf8");
|
|
713
|
+
if (!text.trim())
|
|
714
|
+
return undefined;
|
|
715
|
+
let parsed;
|
|
716
|
+
try {
|
|
717
|
+
parsed = JSON.parse(text);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
throw new Error("Zed write lock metadata is invalid.");
|
|
721
|
+
}
|
|
722
|
+
if (!isRecord(parsed))
|
|
723
|
+
throw new Error("Zed write lock metadata is invalid.");
|
|
724
|
+
const backupPath = parsed.backupPath;
|
|
725
|
+
const claimPath = parsed.claimPath;
|
|
726
|
+
const withdrawalPath = parsed.withdrawalPath;
|
|
727
|
+
if (parsed.version !== zedWriteLockVersion
|
|
728
|
+
|| typeof parsed.token !== "string" || parsed.token.length < 1 || parsed.token.length > 128
|
|
729
|
+
|| !Number.isSafeInteger(parsed.pid) || parsed.pid < 1
|
|
730
|
+
|| typeof parsed.lockTempPath !== "string"
|
|
731
|
+
|| typeof parsed.targetPath !== "string"
|
|
732
|
+
|| (backupPath !== undefined && typeof backupPath !== "string")
|
|
733
|
+
|| (claimPath !== undefined && typeof claimPath !== "string")
|
|
734
|
+
|| (withdrawalPath !== undefined && typeof withdrawalPath !== "string")
|
|
735
|
+
|| typeof parsed.tempPath !== "string"
|
|
736
|
+
|| typeof parsed.sourceExists !== "boolean"
|
|
737
|
+
|| !isZedContentHash(parsed.sourceHash)
|
|
738
|
+
|| !isZedContentHash(parsed.contentHash)) {
|
|
739
|
+
throw new Error("Zed write lock metadata is invalid.");
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
version: parsed.version,
|
|
743
|
+
token: parsed.token,
|
|
744
|
+
pid: parsed.pid,
|
|
745
|
+
lockTempPath: parsed.lockTempPath,
|
|
746
|
+
targetPath: parsed.targetPath,
|
|
747
|
+
...(backupPath === undefined ? {} : { backupPath }),
|
|
748
|
+
...(claimPath === undefined ? {} : { claimPath }),
|
|
749
|
+
...(withdrawalPath === undefined ? {} : { withdrawalPath }),
|
|
750
|
+
tempPath: parsed.tempPath,
|
|
751
|
+
sourceExists: parsed.sourceExists,
|
|
752
|
+
sourceHash: parsed.sourceHash,
|
|
753
|
+
contentHash: parsed.contentHash,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
function validateZedWriteLockRecord(record, lockPath, parent, targetPath) {
|
|
757
|
+
if (resolve(record.targetPath) !== resolve(targetPath) || !isSafeSiblingPath(parent, record.targetPath)) {
|
|
758
|
+
throw new Error("Zed write lock metadata targets an unsafe settings path.");
|
|
759
|
+
}
|
|
760
|
+
if (!isSafeSiblingPath(parent, record.lockTempPath) || !parse(record.lockTempPath).base.startsWith(".openpets-zed-lock-")) {
|
|
761
|
+
throw new Error("Zed write lock metadata targets an unsafe lock path.");
|
|
762
|
+
}
|
|
763
|
+
if (hasResolvedPathCollision(record.lockTempPath, [lockPath, record.targetPath, record.tempPath, record.backupPath, record.claimPath, record.withdrawalPath])) {
|
|
764
|
+
throw new Error("Zed write lock metadata targets an unsafe lock path.");
|
|
765
|
+
}
|
|
766
|
+
if (!isSafeSiblingPath(parent, record.tempPath) || !parse(record.tempPath).base.startsWith(".openpets-")) {
|
|
767
|
+
throw new Error("Zed write lock metadata targets an unsafe temp path.");
|
|
768
|
+
}
|
|
769
|
+
if (hasResolvedPathCollision(record.tempPath, [lockPath, record.targetPath, record.backupPath, record.claimPath, record.withdrawalPath])) {
|
|
770
|
+
throw new Error("Zed write lock metadata targets an unsafe temp path.");
|
|
771
|
+
}
|
|
772
|
+
if (record.claimPath !== undefined) {
|
|
773
|
+
if (!record.sourceExists || record.backupPath === undefined) {
|
|
774
|
+
throw new Error("Zed write lock metadata has inconsistent claim state.");
|
|
775
|
+
}
|
|
776
|
+
const claimName = parse(record.claimPath).base;
|
|
777
|
+
if (!isSafeSiblingPath(parent, record.claimPath) || !claimName.startsWith(".openpets-zed-claim-")) {
|
|
778
|
+
throw new Error("Zed write lock metadata targets an unsafe claim path.");
|
|
779
|
+
}
|
|
780
|
+
if (hasResolvedPathCollision(record.claimPath, [lockPath, record.targetPath, record.tempPath, record.backupPath, record.withdrawalPath])) {
|
|
781
|
+
throw new Error("Zed write lock metadata targets an unsafe claim path.");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (record.withdrawalPath !== undefined) {
|
|
785
|
+
if (!record.sourceExists)
|
|
786
|
+
throw new Error("Zed write lock metadata has inconsistent withdrawal state.");
|
|
787
|
+
const withdrawalName = parse(record.withdrawalPath).base;
|
|
788
|
+
if (!isSafeSiblingPath(parent, record.withdrawalPath) || !withdrawalName.startsWith(".openpets-zed-withdraw-")) {
|
|
789
|
+
throw new Error("Zed write lock metadata targets an unsafe withdrawal path.");
|
|
790
|
+
}
|
|
791
|
+
if (hasResolvedPathCollision(record.withdrawalPath, [lockPath, record.targetPath, record.tempPath, record.backupPath])) {
|
|
792
|
+
throw new Error("Zed write lock metadata targets an unsafe withdrawal path.");
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (record.sourceExists !== (record.backupPath !== undefined)) {
|
|
796
|
+
throw new Error("Zed write lock metadata has inconsistent backup state.");
|
|
797
|
+
}
|
|
798
|
+
if (record.backupPath !== undefined) {
|
|
799
|
+
const backupName = parse(record.backupPath).base;
|
|
800
|
+
const targetName = parse(record.targetPath).base;
|
|
801
|
+
if (!isSafeSiblingPath(parent, record.backupPath) || !backupName.startsWith(`${targetName}.openpets-backup-`)) {
|
|
802
|
+
throw new Error("Zed write lock metadata targets an unsafe backup path.");
|
|
803
|
+
}
|
|
804
|
+
if (hasResolvedPathCollision(record.backupPath, [lockPath, record.targetPath, record.tempPath, record.withdrawalPath])) {
|
|
805
|
+
throw new Error("Zed write lock metadata targets an unsafe backup path.");
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function hasResolvedPathCollision(candidatePath, otherPaths) {
|
|
810
|
+
const resolvedCandidatePath = resolve(candidatePath);
|
|
811
|
+
return otherPaths.some((otherPath) => otherPath !== undefined && resolve(otherPath) === resolvedCandidatePath);
|
|
812
|
+
}
|
|
813
|
+
function recoverZedWriteArtifacts(record, parent) {
|
|
814
|
+
const targetSafety = assertSafeExistingSettingsFile(record.targetPath, true);
|
|
815
|
+
if (!targetSafety.ok)
|
|
816
|
+
throw new Error(targetSafety.message);
|
|
817
|
+
const targetContent = targetSafety.exists ? readFileSync(record.targetPath, "utf8") : undefined;
|
|
818
|
+
const temp = readZedRecoveryArtifact(record.tempPath, parent);
|
|
819
|
+
const backup = record.backupPath ? readZedRecoveryArtifact(record.backupPath, parent) : undefined;
|
|
820
|
+
const claim = record.claimPath ? readZedRecoveryArtifact(record.claimPath, parent) : undefined;
|
|
821
|
+
const withdrawal = record.withdrawalPath ? readZedRecoveryArtifact(record.withdrawalPath, parent) : undefined;
|
|
822
|
+
const targetHash = targetContent === undefined ? undefined : hashZedSettingsContent(targetContent);
|
|
823
|
+
const backupHash = backup?.content === undefined ? undefined : hashZedSettingsContent(backup.content);
|
|
824
|
+
const targetMatchesSource = targetSafety.exists === record.sourceExists && (!targetSafety.exists || targetHash === record.sourceHash);
|
|
825
|
+
const targetMatchesContent = targetSafety.exists && targetHash === record.contentHash;
|
|
826
|
+
if (targetMatchesContent) {
|
|
827
|
+
if (record.sourceExists && (!backup?.exists || backupHash !== record.sourceHash)) {
|
|
828
|
+
throw new Error("Zed write recovery is ambiguous; the original backup is missing or changed.");
|
|
829
|
+
}
|
|
830
|
+
removeZedRecoveryTemp(record, temp);
|
|
831
|
+
removeZedRecoveryClaim(record, claim);
|
|
832
|
+
removeZedRecoveryWithdrawal(record, withdrawal);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (record.sourceExists && !targetSafety.exists && backup?.exists && backupHash === record.sourceHash) {
|
|
836
|
+
restoreZedOriginalFromJournal(record, parent, backup.content);
|
|
837
|
+
removeZedRecoveryTemp(record, temp);
|
|
838
|
+
removeZedRecoveryClaim(record, claim);
|
|
839
|
+
removeZedRecoveryWithdrawal(record, withdrawal);
|
|
840
|
+
removeZedRecoveryArtifact(record.backupPath, parent, record.sourceHash, "Zed write recovery is ambiguous; the original backup changed.");
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (targetMatchesSource) {
|
|
844
|
+
if (record.sourceExists && backup?.exists && backupHash !== record.sourceHash) {
|
|
845
|
+
throw new Error("Zed write recovery is ambiguous; the original backup is missing or changed.");
|
|
846
|
+
}
|
|
847
|
+
removeZedRecoveryTemp(record, temp);
|
|
848
|
+
removeZedRecoveryClaim(record, claim);
|
|
849
|
+
removeZedRecoveryWithdrawal(record, withdrawal);
|
|
850
|
+
if (backup?.exists)
|
|
851
|
+
removeZedRecoveryArtifact(record.backupPath, parent, record.sourceHash, "Zed write recovery is ambiguous; the original backup changed.");
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (!record.sourceExists && !targetSafety.exists) {
|
|
855
|
+
removeZedRecoveryTemp(record, temp);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
throw new Error("Zed write recovery is ambiguous; settings changed while the previous write was interrupted.");
|
|
859
|
+
}
|
|
860
|
+
function removeZedRecoveryTemp(record, temp) {
|
|
861
|
+
if (!temp.exists)
|
|
862
|
+
return;
|
|
863
|
+
if (temp.content === undefined || hashZedSettingsContent(temp.content) !== record.contentHash) {
|
|
864
|
+
throw new Error("Zed write recovery is ambiguous; the prepared settings changed.");
|
|
865
|
+
}
|
|
866
|
+
removeZedRecoveryArtifact(record.tempPath, dirname(record.targetPath), record.contentHash, "Zed write recovery is ambiguous; the prepared settings changed.");
|
|
867
|
+
}
|
|
868
|
+
function removeZedRecoveryClaim(record, claim) {
|
|
869
|
+
if (!record.claimPath || !claim?.exists)
|
|
870
|
+
return;
|
|
871
|
+
if (claim.content === undefined || hashZedSettingsContent(claim.content) !== record.sourceHash) {
|
|
872
|
+
throw new Error("Zed write recovery is ambiguous; the original claim changed.");
|
|
873
|
+
}
|
|
874
|
+
removeZedRecoveryArtifact(record.claimPath, dirname(record.targetPath), record.sourceHash, "Zed write recovery is ambiguous; the original claim changed.");
|
|
875
|
+
}
|
|
876
|
+
function removeZedRecoveryWithdrawal(record, withdrawal) {
|
|
877
|
+
if (!record.withdrawalPath || !withdrawal?.exists)
|
|
878
|
+
return;
|
|
879
|
+
if (withdrawal.content === undefined || hashZedSettingsContent(withdrawal.content) !== record.sourceHash) {
|
|
880
|
+
throw new Error("Zed write recovery is ambiguous; the withdrawn settings changed.");
|
|
881
|
+
}
|
|
882
|
+
removeZedRecoveryArtifact(record.withdrawalPath, dirname(record.targetPath), record.sourceHash, "Zed write recovery is ambiguous; the withdrawn settings changed.");
|
|
883
|
+
}
|
|
884
|
+
function readZedRecoveryArtifact(path, parent) {
|
|
885
|
+
if (!isSafeSiblingPath(parent, path))
|
|
886
|
+
throw new Error("Zed write recovery path is unsafe.");
|
|
887
|
+
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
888
|
+
if (!stat)
|
|
889
|
+
return { exists: false };
|
|
890
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
891
|
+
throw new Error("Zed write recovery artifact is not a regular file.");
|
|
892
|
+
if (stat.size > maxZedSettingsBytes)
|
|
893
|
+
throw new Error("Zed write recovery artifact is too large.");
|
|
894
|
+
return { exists: true, content: readFileSync(path, "utf8") };
|
|
895
|
+
}
|
|
896
|
+
function removeZedRecoveryArtifact(path, parent, expectedHash, ambiguousMessage) {
|
|
897
|
+
const artifact = readZedRecoveryArtifact(path, parent);
|
|
898
|
+
if (!artifact.exists)
|
|
899
|
+
return;
|
|
900
|
+
if (artifact.content === undefined || hashZedSettingsContent(artifact.content) !== expectedHash)
|
|
901
|
+
throw new Error(ambiguousMessage);
|
|
902
|
+
const cleanupPath = uniquePath(join(parent, `.openpets-zed-cleanup-${process.pid}-${Date.now()}-${randomUUID()}.tmp`));
|
|
903
|
+
try {
|
|
904
|
+
renameSync(path, cleanupPath);
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
if (isMissingError(error))
|
|
908
|
+
return;
|
|
909
|
+
throw error;
|
|
910
|
+
}
|
|
911
|
+
const moved = readZedRecoveryArtifact(cleanupPath, parent);
|
|
912
|
+
if (!moved.exists || moved.content === undefined || hashZedSettingsContent(moved.content) !== expectedHash) {
|
|
913
|
+
restoreZedCleanupArtifact(cleanupPath, path, parent);
|
|
914
|
+
throw new Error(ambiguousMessage);
|
|
915
|
+
}
|
|
916
|
+
rmSync(cleanupPath, { force: true });
|
|
917
|
+
}
|
|
918
|
+
function restoreZedCleanupArtifact(cleanupPath, originalPath, parent) {
|
|
919
|
+
const original = readZedRecoveryArtifact(originalPath, parent);
|
|
920
|
+
if (original.exists)
|
|
921
|
+
return;
|
|
922
|
+
try {
|
|
923
|
+
linkSync(cleanupPath, originalPath);
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
if (!isAlreadyExistsError(error))
|
|
927
|
+
throw error;
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
rmSync(cleanupPath, { force: true });
|
|
931
|
+
}
|
|
932
|
+
function restoreZedOriginalFromJournal(record, parent, content) {
|
|
933
|
+
const restorePath = uniquePath(join(parent, `.openpets-zed-recovery-${process.pid}-${randomUUID()}.tmp`));
|
|
934
|
+
if (!isSafeSiblingPath(parent, restorePath))
|
|
935
|
+
throw new Error("Zed write recovery path is unsafe.");
|
|
936
|
+
const fd = openSync(restorePath, "wx", 0o600);
|
|
937
|
+
try {
|
|
938
|
+
writeFileSync(fd, content, "utf8");
|
|
939
|
+
fsyncSync(fd);
|
|
940
|
+
}
|
|
941
|
+
finally {
|
|
942
|
+
closeSync(fd);
|
|
943
|
+
}
|
|
944
|
+
try {
|
|
945
|
+
try {
|
|
946
|
+
linkSync(restorePath, record.targetPath);
|
|
947
|
+
}
|
|
948
|
+
catch (error) {
|
|
949
|
+
if (isAlreadyExistsError(error))
|
|
950
|
+
throw new Error("Zed settings changed during recovery. Refresh the status and try again.");
|
|
951
|
+
throw error;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
finally {
|
|
955
|
+
rmSync(restorePath, { force: true });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function restoreZedWithdrawalAfterFailedWrite(targetPath, preparedPath, withdrawalPath, preparedContent) {
|
|
959
|
+
const parent = dirname(targetPath);
|
|
960
|
+
const withdrawal = readZedRecoveryArtifact(withdrawalPath, parent);
|
|
961
|
+
if (!withdrawal.exists || withdrawal.content === undefined)
|
|
962
|
+
return undefined;
|
|
963
|
+
const target = assertSafeExistingSettingsFile(targetPath, true);
|
|
964
|
+
if (!target.ok)
|
|
965
|
+
throw new Error(target.message);
|
|
966
|
+
if (target.exists) {
|
|
967
|
+
if (readFileSync(targetPath, "utf8") !== preparedContent)
|
|
968
|
+
return undefined;
|
|
969
|
+
const prepared = readZedRecoveryArtifact(preparedPath, parent);
|
|
970
|
+
if (!prepared.exists || prepared.content !== preparedContent)
|
|
971
|
+
return undefined;
|
|
972
|
+
const targetStat = lstatSync(targetPath);
|
|
973
|
+
const preparedStat = lstatSync(preparedPath);
|
|
974
|
+
if (targetStat.dev !== preparedStat.dev || targetStat.ino !== preparedStat.ino)
|
|
975
|
+
return undefined;
|
|
976
|
+
removeZedRecoveryArtifact(targetPath, parent, hashZedSettingsContent(preparedContent), "Zed settings changed during recovery. Refresh the status and try again.");
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
linkSync(withdrawalPath, targetPath);
|
|
980
|
+
}
|
|
981
|
+
catch (error) {
|
|
982
|
+
if (isAlreadyExistsError(error))
|
|
983
|
+
return undefined;
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
const restored = assertSafeExistingSettingsFile(targetPath, true);
|
|
987
|
+
if (!restored.ok)
|
|
988
|
+
throw new Error(restored.message);
|
|
989
|
+
if (!restored.exists || readFileSync(targetPath, "utf8") !== withdrawal.content) {
|
|
990
|
+
throw new Error("Zed settings changed during recovery. Refresh the status and try again.");
|
|
991
|
+
}
|
|
992
|
+
removeZedRecoveryArtifact(withdrawalPath, parent, hashZedSettingsContent(withdrawal.content), "Zed settings changed during recovery. Refresh the status and try again.");
|
|
993
|
+
return withdrawal.content;
|
|
994
|
+
}
|
|
995
|
+
function cleanupZedRollbackArtifacts(targetPath, backupPath, claimPath, sourceContent) {
|
|
996
|
+
const parent = dirname(targetPath);
|
|
997
|
+
const target = assertSafeExistingSettingsFile(targetPath, true);
|
|
998
|
+
const backup = readZedRecoveryArtifact(backupPath, parent);
|
|
999
|
+
const claim = readZedRecoveryArtifact(claimPath, parent);
|
|
1000
|
+
if (!target.ok || !target.exists || readFileSync(targetPath, "utf8") !== sourceContent || !backup.exists || backup.content !== sourceContent || !claim.exists || claim.content !== sourceContent)
|
|
1001
|
+
return false;
|
|
1002
|
+
removeZedRecoveryArtifact(claimPath, parent, hashZedSettingsContent(sourceContent), "Zed write recovery is ambiguous; the original claim changed.");
|
|
1003
|
+
removeZedRecoveryArtifact(backupPath, parent, hashZedSettingsContent(sourceContent), "Zed write recovery is ambiguous; the original backup changed.");
|
|
1004
|
+
return true;
|
|
1005
|
+
}
|
|
1006
|
+
function removeOwnedZedWriteLock(lockPath, token) {
|
|
1007
|
+
try {
|
|
1008
|
+
const record = readZedWriteLockRecord(lockPath);
|
|
1009
|
+
if (record?.token !== token)
|
|
1010
|
+
return;
|
|
1011
|
+
const lock = readZedRecoveryArtifact(lockPath, dirname(lockPath));
|
|
1012
|
+
if (lock.exists && lock.content !== undefined) {
|
|
1013
|
+
removeZedRecoveryArtifact(lockPath, dirname(lockPath), hashZedSettingsContent(lock.content), "Zed write lock changed during cleanup.");
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
// Leave an unreadable lock for the next invocation to handle safely.
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
function isProcessAlive(pid) {
|
|
1021
|
+
try {
|
|
1022
|
+
process.kill(pid, 0);
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
1025
|
+
catch (error) {
|
|
1026
|
+
return error !== null && typeof error === "object" && "code" in error && error.code === "EPERM";
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
function zedWriteInProgressError() {
|
|
1030
|
+
return new Error("EEXIST: Zed settings write is already in progress. Try again later.");
|
|
1031
|
+
}
|
|
1032
|
+
function hashZedSettingsContent(content) {
|
|
1033
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
1034
|
+
}
|
|
1035
|
+
function isZedContentHash(value) {
|
|
1036
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
1037
|
+
}
|
|
1038
|
+
function isAlreadyExistsError(error) {
|
|
1039
|
+
return error !== null && typeof error === "object" && "code" in error && error.code === "EEXIST";
|
|
1040
|
+
}
|
|
1041
|
+
function isMissingError(error) {
|
|
1042
|
+
return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
|
|
1043
|
+
}
|
|
1044
|
+
function missingStatus(settingsPath, expected, message) {
|
|
1045
|
+
return {
|
|
1046
|
+
status: "missing",
|
|
1047
|
+
message,
|
|
1048
|
+
settingsPath,
|
|
1049
|
+
canInstall: true,
|
|
1050
|
+
canReplace: false,
|
|
1051
|
+
canRemove: false,
|
|
1052
|
+
previewEntry: buildZedMcpEntry(expected),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
function inspectZedMcpEntry(value) {
|
|
1056
|
+
if (!isRecord(value))
|
|
1057
|
+
return "invalid";
|
|
1058
|
+
const commandLooksManaged = looksLikeOpenPetsCommand(value.command, value.args);
|
|
1059
|
+
if (!isValidZedEntryShape(value))
|
|
1060
|
+
return commandLooksManaged ? "invalid" : "conflict";
|
|
1061
|
+
if (!commandLooksManaged)
|
|
1062
|
+
return "conflict";
|
|
1063
|
+
return value.enabled === false ? "disabled" : "managed";
|
|
1064
|
+
}
|
|
1065
|
+
function isValidZedEntryShape(value) {
|
|
1066
|
+
const allowedKeys = new Set(["command", "args", "enabled", "remote", "env", "timeout"]);
|
|
1067
|
+
if (Object.keys(value).some((key) => !allowedKeys.has(key)))
|
|
1068
|
+
return false;
|
|
1069
|
+
if (typeof value.command !== "string" || !Array.isArray(value.args) || !value.args.every((arg) => typeof arg === "string"))
|
|
1070
|
+
return false;
|
|
1071
|
+
if (value.enabled !== undefined && typeof value.enabled !== "boolean")
|
|
1072
|
+
return false;
|
|
1073
|
+
if (value.remote !== undefined && typeof value.remote !== "boolean")
|
|
1074
|
+
return false;
|
|
1075
|
+
if (value.env !== undefined && (!isRecord(value.env) || !Object.values(value.env).every((entry) => typeof entry === "string")))
|
|
1076
|
+
return false;
|
|
1077
|
+
if (value.timeout !== undefined && (typeof value.timeout !== "number" || !Number.isSafeInteger(value.timeout) || value.timeout < 0))
|
|
1078
|
+
return false;
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
function looksLikeOpenPetsCommand(command, args) {
|
|
1082
|
+
if (typeof command !== "string" || !Array.isArray(args) || !args.every((arg) => typeof arg === "string"))
|
|
1083
|
+
return false;
|
|
1084
|
+
const parts = args;
|
|
1085
|
+
if (command === "npx")
|
|
1086
|
+
return isPublishedOpenPetsArgs(parts);
|
|
1087
|
+
return isNodeCommand(command) && isLocalOpenPetsArgs(parts);
|
|
1088
|
+
}
|
|
1089
|
+
function isPublishedOpenPetsArgs(args) {
|
|
1090
|
+
if (args.length < 2 || args[0] !== "-y")
|
|
1091
|
+
return false;
|
|
1092
|
+
const packageArg = args[1] ?? "";
|
|
1093
|
+
if (!packageArg.startsWith("@open-pets/mcp@") || !isValidOpenPetsPackageVersion(packageArg.slice("@open-pets/mcp@".length)))
|
|
1094
|
+
return false;
|
|
1095
|
+
return hasValidPetArgs(args.slice(2));
|
|
1096
|
+
}
|
|
1097
|
+
function isLocalOpenPetsArgs(args) {
|
|
1098
|
+
if (args.length < 1)
|
|
1099
|
+
return false;
|
|
1100
|
+
const scriptPath = args[0] ?? "";
|
|
1101
|
+
return isValidOpenPetsMcpScriptPath(scriptPath) && hasValidPetArgs(args.slice(1));
|
|
1102
|
+
}
|
|
1103
|
+
function isNodeCommand(command) {
|
|
1104
|
+
return isValidZedNodeCommand(command);
|
|
1105
|
+
}
|
|
1106
|
+
function hasValidPetArgs(args) {
|
|
1107
|
+
if (args.length === 0)
|
|
1108
|
+
return true;
|
|
1109
|
+
return args.length === 2 && args[0] === "--pet" && isValidPetId(args[1] ?? "");
|
|
1110
|
+
}
|
|
1111
|
+
function isSameZedMcpEntry(value, expected) {
|
|
1112
|
+
if (!isRecord(value) || value.command !== expected.command || !Array.isArray(value.args))
|
|
1113
|
+
return false;
|
|
1114
|
+
return value.args.length === expected.args.length && value.args.every((part, index) => part === expected.args[index]);
|
|
1115
|
+
}
|
|
1116
|
+
function getOpenPetsEntry(config) {
|
|
1117
|
+
return isRecord(config.context_servers) ? config.context_servers[zedMcpServerName] : undefined;
|
|
1118
|
+
}
|
|
1119
|
+
function preserveManagedFields(existing, options, reenable = false) {
|
|
1120
|
+
const base = buildZedMcpEntry(options);
|
|
1121
|
+
if (!isRecord(existing))
|
|
1122
|
+
return base;
|
|
1123
|
+
return {
|
|
1124
|
+
...base,
|
|
1125
|
+
...(existing.enabled === true && !reenable ? { enabled: true } : {}),
|
|
1126
|
+
...(isRecord(existing.env) && Object.values(existing.env).every((entry) => typeof entry === "string") ? { env: existing.env } : {}),
|
|
1127
|
+
...(typeof existing.timeout === "number" && Number.isSafeInteger(existing.timeout) && existing.timeout >= 0 ? { timeout: existing.timeout } : {}),
|
|
1128
|
+
...(reenable ? { enabled: true } : {}),
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
function planZedSettingsWrite(settingsPath, source, sourceExists, entry) {
|
|
1132
|
+
const next = updateZedSettingsText(source, ["context_servers", zedMcpServerName], entry);
|
|
1133
|
+
if (typeof next !== "string")
|
|
1134
|
+
return next;
|
|
1135
|
+
return buildZedWritePlan(settingsPath, next, source, sourceExists);
|
|
1136
|
+
}
|
|
1137
|
+
function buildZedWritePlan(settingsPath, content, sourceContent = "", sourceExists = false) {
|
|
1138
|
+
const pathSafety = assertSafeConfigPath(settingsPath);
|
|
1139
|
+
if (!pathSafety.ok)
|
|
1140
|
+
return pathSafety;
|
|
1141
|
+
const targetSafety = assertSafeExistingSettingsFile(settingsPath, true);
|
|
1142
|
+
if (!targetSafety.ok)
|
|
1143
|
+
return targetSafety;
|
|
1144
|
+
const parent = dirname(settingsPath);
|
|
1145
|
+
const stamp = `${process.pid}-${Date.now()}-${randomUUID()}`;
|
|
1146
|
+
return {
|
|
1147
|
+
targetPath: settingsPath,
|
|
1148
|
+
backupPath: targetSafety.exists ? uniquePath(`${settingsPath}.openpets-backup-${stamp}.jsonc`) : undefined,
|
|
1149
|
+
claimPath: targetSafety.exists ? uniquePath(join(parent, `.openpets-zed-claim-${stamp}.tmp`)) : undefined,
|
|
1150
|
+
tempPath: uniquePath(join(parent, `.openpets-${stamp}.tmp`)),
|
|
1151
|
+
sourceExists,
|
|
1152
|
+
sourceContent,
|
|
1153
|
+
content,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
function assertSafeConfigPath(settingsPath) {
|
|
1157
|
+
if (!isAbsolute(settingsPath) || settingsPath.includes("\0")) {
|
|
1158
|
+
return { ok: false, message: "Zed settings path must be an absolute safe path.", reason: "unsafe-path" };
|
|
1159
|
+
}
|
|
1160
|
+
if (hasParentTraversal(settingsPath)) {
|
|
1161
|
+
return { ok: false, message: "Zed settings path must not contain parent traversal segments.", reason: "unsafe-path" };
|
|
1162
|
+
}
|
|
1163
|
+
return assertSafeParentDirectory(dirname(settingsPath));
|
|
1164
|
+
}
|
|
1165
|
+
function assertSafeExistingSettingsFile(settingsPath, allowMissing) {
|
|
1166
|
+
const stat = lstatSync(settingsPath, { throwIfNoEntry: false });
|
|
1167
|
+
if (!stat) {
|
|
1168
|
+
return allowMissing ? { ok: true, exists: false } : { ok: false, message: "Zed settings file does not exist.", reason: "io" };
|
|
1169
|
+
}
|
|
1170
|
+
if (stat.isSymbolicLink())
|
|
1171
|
+
return { ok: false, message: "Zed settings file is a symlink.", reason: "symlink" };
|
|
1172
|
+
if (!stat.isFile())
|
|
1173
|
+
return { ok: false, message: "Zed settings path is not a regular file.", reason: "not-regular" };
|
|
1174
|
+
if (stat.size > maxZedSettingsBytes)
|
|
1175
|
+
return { ok: false, message: "Zed settings exceed 256 KiB.", reason: "size" };
|
|
1176
|
+
return { ok: true, exists: true };
|
|
1177
|
+
}
|
|
1178
|
+
function assertSafeParentDirectory(path) {
|
|
1179
|
+
if (!isAbsolute(path) || path.includes("\0") || hasParentTraversal(path)) {
|
|
1180
|
+
return { ok: false, message: "Zed settings parent path is unsafe.", reason: "unsafe-path" };
|
|
1181
|
+
}
|
|
1182
|
+
const absolutePath = resolve(path);
|
|
1183
|
+
const root = parse(absolutePath).root;
|
|
1184
|
+
const parts = absolutePath.slice(root.length).split(/[\\/]+/u).filter(Boolean);
|
|
1185
|
+
let current = root;
|
|
1186
|
+
for (const part of parts) {
|
|
1187
|
+
current = join(current, part);
|
|
1188
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
1189
|
+
if (!stat)
|
|
1190
|
+
break;
|
|
1191
|
+
if (stat.isSymbolicLink())
|
|
1192
|
+
return { ok: false, message: "Zed settings parent must not contain symlink segments.", reason: "symlink" };
|
|
1193
|
+
if (!stat.isDirectory())
|
|
1194
|
+
return { ok: false, message: "Zed settings parent path segment must be a directory.", reason: "unsafe-path" };
|
|
1195
|
+
}
|
|
1196
|
+
return { ok: true };
|
|
1197
|
+
}
|
|
1198
|
+
function isSafeSiblingPath(parent, candidate) {
|
|
1199
|
+
if (!isAbsolute(candidate) || candidate.includes("\0") || hasParentTraversal(candidate))
|
|
1200
|
+
return false;
|
|
1201
|
+
return resolve(dirname(candidate)) === resolve(parent);
|
|
1202
|
+
}
|
|
1203
|
+
function hasParentTraversal(path) {
|
|
1204
|
+
return path.split(/[\\/]+/u).includes("..");
|
|
1205
|
+
}
|
|
1206
|
+
function uniquePath(path) {
|
|
1207
|
+
if (!lstatSync(path, { throwIfNoEntry: false }))
|
|
1208
|
+
return path;
|
|
1209
|
+
for (let index = 1; index < 1000; index += 1) {
|
|
1210
|
+
const candidate = `${path}.${index}`;
|
|
1211
|
+
if (!lstatSync(candidate, { throwIfNoEntry: false }))
|
|
1212
|
+
return candidate;
|
|
1213
|
+
}
|
|
1214
|
+
throw new Error("Unable to allocate unique Zed temp path.");
|
|
1215
|
+
}
|
|
1216
|
+
function isRecord(value) {
|
|
1217
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1218
|
+
}
|
|
1219
|
+
//# sourceMappingURL=zed-status.js.map
|