@makerbi/remodex 1.3.8
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/README.md +483 -0
- package/bin/phodex.js +10 -0
- package/bin/remodex.js +314 -0
- package/package.json +33 -0
- package/src/account-status.js +175 -0
- package/src/bootstrap-codex-cli.js +53 -0
- package/src/bridge.js +2032 -0
- package/src/codex-cli-bootstrap.js +187 -0
- package/src/codex-desktop-refresher.js +780 -0
- package/src/codex-home.js +21 -0
- package/src/codex-transport.js +353 -0
- package/src/daemon-state.js +153 -0
- package/src/desktop-handler.js +465 -0
- package/src/git-handler.js +2371 -0
- package/src/index.js +36 -0
- package/src/ios-app-compatibility.js +244 -0
- package/src/macos-launch-agent.js +506 -0
- package/src/notifications-handler.js +95 -0
- package/src/package-version-status.js +185 -0
- package/src/private-defaults.json +4 -0
- package/src/project-handler.js +359 -0
- package/src/push-notification-completion-dedupe.js +147 -0
- package/src/push-notification-service-client.js +151 -0
- package/src/push-notification-tracker.js +688 -0
- package/src/qr.js +63 -0
- package/src/rollout-live-mirror.js +825 -0
- package/src/rollout-watch.js +834 -0
- package/src/scripts/codex-handoff.applescript +100 -0
- package/src/scripts/codex-refresh.applescript +51 -0
- package/src/secure-device-state.js +430 -0
- package/src/secure-transport.js +738 -0
- package/src/session-state.js +57 -0
- package/src/thread-context-handler.js +80 -0
- package/src/voice-handler.js +321 -0
- package/src/workspace-handler.js +630 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// FILE: workspace-handler.js
|
|
2
|
+
// Purpose: Executes workspace-scoped reverse patch previews/applies without touching unrelated repo changes.
|
|
3
|
+
// Layer: Bridge handler
|
|
4
|
+
// Exports: handleWorkspaceRequest
|
|
5
|
+
// Depends on: child_process, fs, os, path, ./codex-home, ./git-handler
|
|
6
|
+
|
|
7
|
+
const { execFile } = require("child_process");
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { promisify } = require("util");
|
|
12
|
+
const { resolveCodexGeneratedImagesRoot } = require("./codex-home");
|
|
13
|
+
const { gitStatus } = require("./git-handler");
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
17
|
+
const MAX_IMAGE_READ_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
const MAX_IMAGE_PREVIEW_READ_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
const MIN_IMAGE_PREVIEW_PIXEL_DIMENSION = 128;
|
|
20
|
+
const MAX_IMAGE_PREVIEW_PIXEL_DIMENSION = 3_200;
|
|
21
|
+
const IMAGE_MIME_TYPES_BY_EXTENSION = new Map([
|
|
22
|
+
[".jpg", "image/jpeg"],
|
|
23
|
+
[".jpeg", "image/jpeg"],
|
|
24
|
+
[".png", "image/png"],
|
|
25
|
+
[".gif", "image/gif"],
|
|
26
|
+
[".webp", "image/webp"],
|
|
27
|
+
[".heic", "image/heic"],
|
|
28
|
+
[".heif", "image/heif"],
|
|
29
|
+
]);
|
|
30
|
+
const repoMutationLocks = new Map();
|
|
31
|
+
|
|
32
|
+
function handleWorkspaceRequest(rawMessage, sendResponse) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(rawMessage);
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
|
|
41
|
+
if (!method.startsWith("workspace/")) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const id = parsed.id;
|
|
46
|
+
const params = parsed.params || {};
|
|
47
|
+
|
|
48
|
+
handleWorkspaceMethod(method, params)
|
|
49
|
+
.then((result) => {
|
|
50
|
+
sendResponse(JSON.stringify({ id, result }));
|
|
51
|
+
})
|
|
52
|
+
.catch((err) => {
|
|
53
|
+
const errorCode = err.errorCode || "workspace_error";
|
|
54
|
+
const message = err.userMessage || err.message || "Unknown workspace error";
|
|
55
|
+
sendResponse(
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
id,
|
|
58
|
+
error: {
|
|
59
|
+
code: -32000,
|
|
60
|
+
message,
|
|
61
|
+
data: { errorCode },
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function handleWorkspaceMethod(method, params) {
|
|
71
|
+
if (method === "workspace/readImage") {
|
|
72
|
+
return workspaceReadImage(params);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cwd = await resolveWorkspaceCwd(params);
|
|
76
|
+
const repoRoot = await resolveRepoRoot(cwd);
|
|
77
|
+
|
|
78
|
+
switch (method) {
|
|
79
|
+
case "workspace/revertPatchPreview":
|
|
80
|
+
return workspaceRevertPatchPreview(repoRoot, params);
|
|
81
|
+
case "workspace/revertPatchApply":
|
|
82
|
+
return withRepoMutationLock(repoRoot, () => workspaceRevertPatchApply(repoRoot, params));
|
|
83
|
+
default:
|
|
84
|
+
throw workspaceError("unknown_method", `Unknown workspace method: ${method}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Reads only recognized local image files, and only from the bound repo or Codex generated-images cache.
|
|
89
|
+
async function workspaceReadImage(params) {
|
|
90
|
+
const requestedPath = firstNonEmptyString([params.path, params.filePath, params.localPath]);
|
|
91
|
+
if (!requestedPath) {
|
|
92
|
+
throw workspaceError("missing_image_path", "The request must include an image path.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const cwd = firstNonEmptyString([params.cwd, params.currentWorkingDirectory])
|
|
96
|
+
? await resolveWorkspaceCwd(params)
|
|
97
|
+
: null;
|
|
98
|
+
const imagePath = path.isAbsolute(requestedPath)
|
|
99
|
+
? path.resolve(requestedPath)
|
|
100
|
+
: path.resolve(cwd || process.cwd(), requestedPath);
|
|
101
|
+
const extension = path.extname(imagePath).toLowerCase();
|
|
102
|
+
const mimeType = IMAGE_MIME_TYPES_BY_EXTENSION.get(extension);
|
|
103
|
+
if (!mimeType) {
|
|
104
|
+
throw workspaceError("unsupported_image_type", "Only local image files can be previewed.");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const [realImagePath, realGeneratedImagesRoot] = await Promise.all([
|
|
108
|
+
realpathOrNull(imagePath),
|
|
109
|
+
realpathOrNull(resolveCodexGeneratedImagesRoot()),
|
|
110
|
+
]);
|
|
111
|
+
if (!realImagePath) {
|
|
112
|
+
throw workspaceError("image_not_found", "The image file no longer exists on this Mac.");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const repoRoot = cwd ? await resolveRepoRoot(cwd).catch(() => null) : null;
|
|
116
|
+
const realRepoRoot = repoRoot ? await realpathOrNull(repoRoot) : null;
|
|
117
|
+
const isAllowed =
|
|
118
|
+
(realRepoRoot && isPathInside(realImagePath, realRepoRoot))
|
|
119
|
+
|| (realGeneratedImagesRoot && isPathInside(realImagePath, realGeneratedImagesRoot));
|
|
120
|
+
if (!isAllowed) {
|
|
121
|
+
throw workspaceError("image_path_not_allowed", "Only images in this workspace or Codex generated images can be previewed.");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const stat = await fs.promises.stat(realImagePath);
|
|
125
|
+
if (!stat.isFile()) {
|
|
126
|
+
throw workspaceError("image_not_found", "The image path is not a file.");
|
|
127
|
+
}
|
|
128
|
+
const includeData = params.includeData !== false && params.metadataOnly !== true;
|
|
129
|
+
const maxPixelDimension = normalizedPreviewPixelDimension(params);
|
|
130
|
+
if (stat.size > MAX_IMAGE_READ_BYTES && !maxPixelDimension) {
|
|
131
|
+
throw workspaceError(
|
|
132
|
+
"image_too_large",
|
|
133
|
+
"This image is too large to send to the phone. Open it on the Mac or move a smaller preview into the workspace."
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = {
|
|
138
|
+
path: realImagePath,
|
|
139
|
+
fileName: path.basename(realImagePath),
|
|
140
|
+
mimeType,
|
|
141
|
+
byteLength: stat.size,
|
|
142
|
+
mtimeMs: stat.mtimeMs,
|
|
143
|
+
previewMaxPixelDimension: maxPixelDimension || undefined,
|
|
144
|
+
};
|
|
145
|
+
if (!includeData) {
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
if (isUnchangedImageRead(params, stat, maxPixelDimension)) {
|
|
149
|
+
return {
|
|
150
|
+
...result,
|
|
151
|
+
notModified: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const data = maxPixelDimension
|
|
156
|
+
? await readPreviewImageData(realImagePath, maxPixelDimension)
|
|
157
|
+
: await fs.promises.readFile(realImagePath);
|
|
158
|
+
return {
|
|
159
|
+
...result,
|
|
160
|
+
dataByteLength: data.length,
|
|
161
|
+
dataBase64: data.toString("base64"),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizedPreviewPixelDimension(params) {
|
|
166
|
+
const requested = Number(params.maxPixelDimension || params.previewMaxPixelDimension);
|
|
167
|
+
if (!Number.isFinite(requested) || requested <= 0) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return Math.min(
|
|
171
|
+
MAX_IMAGE_PREVIEW_PIXEL_DIMENSION,
|
|
172
|
+
Math.max(MIN_IMAGE_PREVIEW_PIXEL_DIMENSION, Math.round(requested))
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function readPreviewImageData(imagePath, maxPixelDimension) {
|
|
177
|
+
let previewData;
|
|
178
|
+
try {
|
|
179
|
+
previewData = await downsampleImageWithSips(imagePath, maxPixelDimension);
|
|
180
|
+
} catch {
|
|
181
|
+
throw workspaceError(
|
|
182
|
+
"image_preview_failed",
|
|
183
|
+
"This image could not be converted into a lightweight phone preview."
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (!previewData || previewData.length === 0 || previewData.length > MAX_IMAGE_PREVIEW_READ_BYTES) {
|
|
187
|
+
throw workspaceError(
|
|
188
|
+
"image_preview_too_large",
|
|
189
|
+
"This image preview is still too large to send to the phone."
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return previewData;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function downsampleImageWithSips(imagePath, maxPixelDimension) {
|
|
196
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remodex-image-preview-"));
|
|
197
|
+
const outputPath = path.join(tempDir, `preview${path.extname(imagePath) || ".png"}`);
|
|
198
|
+
try {
|
|
199
|
+
await execFileAsync("sips", ["-Z", String(maxPixelDimension), imagePath, "--out", outputPath], {
|
|
200
|
+
timeout: 15_000,
|
|
201
|
+
maxBuffer: 1024 * 1024,
|
|
202
|
+
});
|
|
203
|
+
return await fs.promises.readFile(outputPath);
|
|
204
|
+
} finally {
|
|
205
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isUnchangedImageRead(params, stat, maxPixelDimension) {
|
|
210
|
+
const cachedByteLength = Number(params.ifByteLength);
|
|
211
|
+
const cachedMtimeMs = Number(params.ifMtimeMs);
|
|
212
|
+
const cachedPreviewMaxPixelDimension = Number(params.ifPreviewMaxPixelDimension || params.ifMaxPixelDimension);
|
|
213
|
+
const previewDimensionMatches = maxPixelDimension
|
|
214
|
+
? Number.isFinite(cachedPreviewMaxPixelDimension) && cachedPreviewMaxPixelDimension === maxPixelDimension
|
|
215
|
+
: !Number.isFinite(cachedPreviewMaxPixelDimension);
|
|
216
|
+
return Number.isFinite(cachedByteLength)
|
|
217
|
+
&& Number.isFinite(cachedMtimeMs)
|
|
218
|
+
&& previewDimensionMatches
|
|
219
|
+
&& cachedByteLength === stat.size
|
|
220
|
+
&& cachedMtimeMs === stat.mtimeMs;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Validates the reverse patch against the current tree without writing repo files.
|
|
224
|
+
async function workspaceRevertPatchPreview(repoRoot, params) {
|
|
225
|
+
const forwardPatch = resolveForwardPatch(params);
|
|
226
|
+
const analysis = analyzeUnifiedPatch(forwardPatch);
|
|
227
|
+
const stagedFiles = await findStagedTargetedFiles(repoRoot, analysis.affectedFiles);
|
|
228
|
+
|
|
229
|
+
if (analysis.unsupportedReasons.length || stagedFiles.length) {
|
|
230
|
+
return {
|
|
231
|
+
canRevert: false,
|
|
232
|
+
affectedFiles: analysis.affectedFiles,
|
|
233
|
+
conflicts: [],
|
|
234
|
+
unsupportedReasons: analysis.unsupportedReasons,
|
|
235
|
+
stagedFiles,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const applyCheck = await runGitApply(repoRoot, ["apply", "--reverse", "--check"], forwardPatch);
|
|
240
|
+
const conflicts = applyCheck.ok
|
|
241
|
+
? []
|
|
242
|
+
: parseApplyConflicts(applyCheck.stderr || applyCheck.stdout || "Patch does not apply.");
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
canRevert: applyCheck.ok && conflicts.length === 0,
|
|
246
|
+
affectedFiles: analysis.affectedFiles,
|
|
247
|
+
conflicts,
|
|
248
|
+
unsupportedReasons: [],
|
|
249
|
+
stagedFiles,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Reverse-applies the patch only after the same safety checks pass in the locked mutation path.
|
|
254
|
+
async function workspaceRevertPatchApply(repoRoot, params) {
|
|
255
|
+
const preview = await workspaceRevertPatchPreview(repoRoot, params);
|
|
256
|
+
if (!preview.canRevert) {
|
|
257
|
+
return {
|
|
258
|
+
success: false,
|
|
259
|
+
revertedFiles: [],
|
|
260
|
+
conflicts: preview.conflicts,
|
|
261
|
+
unsupportedReasons: preview.unsupportedReasons,
|
|
262
|
+
stagedFiles: preview.stagedFiles,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const forwardPatch = resolveForwardPatch(params);
|
|
267
|
+
const applyResult = await runGitApply(repoRoot, ["apply", "--reverse"], forwardPatch);
|
|
268
|
+
if (!applyResult.ok) {
|
|
269
|
+
return {
|
|
270
|
+
success: false,
|
|
271
|
+
revertedFiles: [],
|
|
272
|
+
conflicts: parseApplyConflicts(applyResult.stderr || applyResult.stdout || "Patch does not apply."),
|
|
273
|
+
unsupportedReasons: [],
|
|
274
|
+
stagedFiles: [],
|
|
275
|
+
status: await gitStatus(repoRoot).catch(() => null),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const status = await gitStatus(repoRoot).catch(() => null);
|
|
280
|
+
return {
|
|
281
|
+
success: true,
|
|
282
|
+
revertedFiles: preview.affectedFiles,
|
|
283
|
+
conflicts: [],
|
|
284
|
+
unsupportedReasons: [],
|
|
285
|
+
stagedFiles: [],
|
|
286
|
+
status,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function resolveForwardPatch(params) {
|
|
291
|
+
const forwardPatch =
|
|
292
|
+
typeof params.forwardPatch === "string" ? params.forwardPatch : "";
|
|
293
|
+
|
|
294
|
+
if (!forwardPatch.trim()) {
|
|
295
|
+
throw workspaceError("missing_patch", "The request must include a non-empty forwardPatch.");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return forwardPatch.endsWith("\n") ? forwardPatch : `${forwardPatch}\n`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function analyzeUnifiedPatch(rawPatch) {
|
|
302
|
+
const patch = rawPatch.trim();
|
|
303
|
+
if (!patch) {
|
|
304
|
+
return {
|
|
305
|
+
affectedFiles: [],
|
|
306
|
+
unsupportedReasons: ["No exact patch was captured."],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const chunks = splitPatchIntoChunks(patch);
|
|
311
|
+
if (!chunks.length) {
|
|
312
|
+
return {
|
|
313
|
+
affectedFiles: [],
|
|
314
|
+
unsupportedReasons: ["No exact patch was captured."],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const affectedFiles = [];
|
|
319
|
+
const unsupportedReasons = new Set();
|
|
320
|
+
|
|
321
|
+
for (const chunk of chunks) {
|
|
322
|
+
const analysis = analyzePatchChunk(chunk);
|
|
323
|
+
if (analysis.path) {
|
|
324
|
+
affectedFiles.push(analysis.path);
|
|
325
|
+
}
|
|
326
|
+
for (const reason of analysis.unsupportedReasons) {
|
|
327
|
+
unsupportedReasons.add(reason);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!affectedFiles.length) {
|
|
332
|
+
unsupportedReasons.add("No exact patch was captured.");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
affectedFiles: [...new Set(affectedFiles)].sort(),
|
|
337
|
+
unsupportedReasons: [...unsupportedReasons].sort(),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function splitPatchIntoChunks(patch) {
|
|
342
|
+
const lines = patch.split("\n");
|
|
343
|
+
if (!lines.length) {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const chunks = [];
|
|
348
|
+
let current = [];
|
|
349
|
+
|
|
350
|
+
for (const line of lines) {
|
|
351
|
+
if (line.startsWith("diff --git ") && current.length) {
|
|
352
|
+
chunks.push(current);
|
|
353
|
+
current = [];
|
|
354
|
+
}
|
|
355
|
+
current.push(line);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (current.length) {
|
|
359
|
+
chunks.push(current);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return chunks;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function analyzePatchChunk(lines) {
|
|
366
|
+
const path = extractPatchPath(lines);
|
|
367
|
+
const isBinary = lines.some((line) => line.startsWith("Binary files ") || line === "GIT binary patch");
|
|
368
|
+
const isRenameOrModeOnly = lines.some((line) =>
|
|
369
|
+
line.startsWith("rename from ")
|
|
370
|
+
|| line.startsWith("rename to ")
|
|
371
|
+
|| line.startsWith("copy from ")
|
|
372
|
+
|| line.startsWith("copy to ")
|
|
373
|
+
|| line.startsWith("old mode ")
|
|
374
|
+
|| line.startsWith("new mode ")
|
|
375
|
+
|| line.startsWith("similarity index ")
|
|
376
|
+
|| line.startsWith("new file mode 120")
|
|
377
|
+
|| line.startsWith("deleted file mode 120")
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
let additions = 0;
|
|
381
|
+
let deletions = 0;
|
|
382
|
+
for (const line of lines) {
|
|
383
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
384
|
+
additions += 1;
|
|
385
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
386
|
+
deletions += 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const unsupportedReasons = [];
|
|
391
|
+
if (isBinary) {
|
|
392
|
+
unsupportedReasons.push("Binary changes are not auto-revertable in v1.");
|
|
393
|
+
}
|
|
394
|
+
if (isRenameOrModeOnly) {
|
|
395
|
+
unsupportedReasons.push("Rename, mode-only, or symlink changes are not auto-revertable in v1.");
|
|
396
|
+
}
|
|
397
|
+
if (!path || (!additions && !deletions && !lines.includes("--- /dev/null") && !lines.includes("+++ /dev/null"))) {
|
|
398
|
+
if (!isBinary && !isRenameOrModeOnly) {
|
|
399
|
+
unsupportedReasons.push("No exact patch was captured.");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return { path, unsupportedReasons };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function extractPatchPath(lines) {
|
|
407
|
+
for (const line of lines) {
|
|
408
|
+
if (line.startsWith("+++ ")) {
|
|
409
|
+
const normalized = normalizeDiffPath(line.slice(4).trim());
|
|
410
|
+
if (normalized && normalized !== "/dev/null") {
|
|
411
|
+
return normalized;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const line of lines) {
|
|
417
|
+
if (line.startsWith("diff --git ")) {
|
|
418
|
+
const components = line.trim().split(/\s+/);
|
|
419
|
+
if (components.length >= 4) {
|
|
420
|
+
return normalizeDiffPath(components[3]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return "";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function normalizeDiffPath(rawPath) {
|
|
429
|
+
if (!rawPath) {
|
|
430
|
+
return "";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (rawPath.startsWith("a/") || rawPath.startsWith("b/")) {
|
|
434
|
+
return rawPath.slice(2);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return rawPath;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function findStagedTargetedFiles(cwd, affectedFiles) {
|
|
441
|
+
if (!affectedFiles.length) {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const output = await git(cwd, "diff", "--name-only", "--cached", "--", ...affectedFiles);
|
|
447
|
+
return output
|
|
448
|
+
.split("\n")
|
|
449
|
+
.map((line) => line.trim())
|
|
450
|
+
.filter(Boolean)
|
|
451
|
+
.sort();
|
|
452
|
+
} catch {
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function runGitApply(cwd, args, patchText) {
|
|
458
|
+
const tempPatchPath = await writeTempPatchFile(patchText);
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
const { stdout, stderr } = await execFileAsync("git", [...args, tempPatchPath], {
|
|
462
|
+
cwd,
|
|
463
|
+
timeout: GIT_TIMEOUT_MS,
|
|
464
|
+
});
|
|
465
|
+
return { ok: true, stdout, stderr };
|
|
466
|
+
} catch (err) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
stdout: err.stdout || "",
|
|
470
|
+
stderr: err.stderr || err.message || "",
|
|
471
|
+
};
|
|
472
|
+
} finally {
|
|
473
|
+
try {
|
|
474
|
+
fs.unlinkSync(tempPatchPath);
|
|
475
|
+
} catch {
|
|
476
|
+
// Ignore temp cleanup failures.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function writeTempPatchFile(patchText) {
|
|
482
|
+
const tempPatchPath = path.join(
|
|
483
|
+
os.tmpdir(),
|
|
484
|
+
`remodex-revert-${Date.now()}-${Math.random().toString(16).slice(2)}.patch`
|
|
485
|
+
);
|
|
486
|
+
await fs.promises.writeFile(tempPatchPath, patchText, "utf8");
|
|
487
|
+
return tempPatchPath;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function parseApplyConflicts(stderr) {
|
|
491
|
+
const lines = String(stderr || "")
|
|
492
|
+
.split("\n")
|
|
493
|
+
.map((line) => line.trim())
|
|
494
|
+
.filter(Boolean);
|
|
495
|
+
|
|
496
|
+
const conflictsByPath = new Map();
|
|
497
|
+
for (const line of lines) {
|
|
498
|
+
let path = "unknown";
|
|
499
|
+
const patchFailedMatch = line.match(/^error:\s+patch failed:\s+(.+?):\d+$/i);
|
|
500
|
+
const doesNotApplyMatch = line.match(/^error:\s+(.+?):\s+patch does not apply$/i);
|
|
501
|
+
|
|
502
|
+
if (patchFailedMatch) {
|
|
503
|
+
path = patchFailedMatch[1];
|
|
504
|
+
} else if (doesNotApplyMatch) {
|
|
505
|
+
path = doesNotApplyMatch[1];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!conflictsByPath.has(path)) {
|
|
509
|
+
conflictsByPath.set(path, { path, message: line });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!conflictsByPath.size && lines.length) {
|
|
514
|
+
return [{ path: "unknown", message: lines.join(" ") }];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return [...conflictsByPath.values()];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function withRepoMutationLock(cwd, callback) {
|
|
521
|
+
const previous = repoMutationLocks.get(cwd) || Promise.resolve();
|
|
522
|
+
let releaseCurrent = null;
|
|
523
|
+
const current = new Promise((resolve) => {
|
|
524
|
+
releaseCurrent = resolve;
|
|
525
|
+
});
|
|
526
|
+
const chained = previous.then(() => current);
|
|
527
|
+
repoMutationLocks.set(cwd, chained);
|
|
528
|
+
|
|
529
|
+
await previous;
|
|
530
|
+
try {
|
|
531
|
+
return await callback();
|
|
532
|
+
} finally {
|
|
533
|
+
releaseCurrent();
|
|
534
|
+
if (repoMutationLocks.get(cwd) === chained) {
|
|
535
|
+
repoMutationLocks.delete(cwd);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function resolveWorkspaceCwd(params) {
|
|
541
|
+
const requestedCwd = firstNonEmptyString([params.cwd, params.currentWorkingDirectory]);
|
|
542
|
+
|
|
543
|
+
if (!requestedCwd) {
|
|
544
|
+
throw workspaceError(
|
|
545
|
+
"missing_working_directory",
|
|
546
|
+
"Workspace actions require a bound local working directory."
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!isExistingDirectory(requestedCwd)) {
|
|
551
|
+
throw workspaceError(
|
|
552
|
+
"missing_working_directory",
|
|
553
|
+
"The requested local working directory does not exist on this Mac."
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return requestedCwd;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Resolves the canonical repo root so revert safety checks stay stable from nested chat folders.
|
|
561
|
+
async function resolveRepoRoot(cwd) {
|
|
562
|
+
try {
|
|
563
|
+
const output = await git(cwd, "rev-parse", "--show-toplevel");
|
|
564
|
+
const repoRoot = output.trim();
|
|
565
|
+
if (repoRoot) {
|
|
566
|
+
return repoRoot;
|
|
567
|
+
}
|
|
568
|
+
} catch {
|
|
569
|
+
// Fall through to the user-facing error below.
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
throw workspaceError(
|
|
573
|
+
"missing_working_directory",
|
|
574
|
+
"The selected local folder is not inside a Git repository."
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function firstNonEmptyString(candidates) {
|
|
579
|
+
for (const candidate of candidates) {
|
|
580
|
+
if (typeof candidate !== "string") {
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const trimmed = candidate.trim();
|
|
585
|
+
if (trimmed) {
|
|
586
|
+
return trimmed;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function isExistingDirectory(candidatePath) {
|
|
594
|
+
try {
|
|
595
|
+
return fs.statSync(candidatePath).isDirectory();
|
|
596
|
+
} catch {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function realpathOrNull(candidatePath) {
|
|
602
|
+
try {
|
|
603
|
+
return await fs.promises.realpath(candidatePath);
|
|
604
|
+
} catch {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function isPathInside(candidatePath, rootPath) {
|
|
610
|
+
const relative = path.relative(rootPath, candidatePath);
|
|
611
|
+
return relative === "" || (relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function workspaceError(errorCode, userMessage) {
|
|
615
|
+
const err = new Error(userMessage);
|
|
616
|
+
err.errorCode = errorCode;
|
|
617
|
+
err.userMessage = userMessage;
|
|
618
|
+
return err;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function git(cwd, ...args) {
|
|
622
|
+
return execFileAsync("git", args, { cwd, timeout: GIT_TIMEOUT_MS })
|
|
623
|
+
.then(({ stdout }) => stdout)
|
|
624
|
+
.catch((err) => {
|
|
625
|
+
const msg = (err.stderr || err.message || "").trim();
|
|
626
|
+
throw new Error(msg || "git command failed");
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
module.exports = { handleWorkspaceMethod, handleWorkspaceRequest };
|