@makerbi/remodex 1.5.4 → 1.5.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.
@@ -1,5 +1,5 @@
1
1
  // FILE: workspace-handler.js
2
- // Purpose: Executes workspace-scoped reverse patch previews/applies without touching unrelated repo changes.
2
+ // Purpose: Executes workspace-scoped previews, reads, and patch operations without touching unrelated repo changes.
3
3
  // Layer: Bridge handler
4
4
  // Exports: handleWorkspaceRequest
5
5
  // Depends on: child_process, fs, os, path, ./codex-home, ./git-handler
@@ -8,7 +8,7 @@ const { execFile } = require("child_process");
8
8
  const fs = require("fs");
9
9
  const os = require("os");
10
10
  const path = require("path");
11
- const { promisify } = require("util");
11
+ const { promisify, TextDecoder } = require("util");
12
12
  const { resolveCodexGeneratedImagesRoot } = require("./codex-home");
13
13
  const { gitStatus } = require("./git-handler");
14
14
  const {
@@ -23,6 +23,9 @@ const execFileAsync = promisify(execFile);
23
23
  const GIT_TIMEOUT_MS = 30_000;
24
24
  const MAX_IMAGE_READ_BYTES = 8 * 1024 * 1024;
25
25
  const MAX_IMAGE_PREVIEW_READ_BYTES = 2 * 1024 * 1024;
26
+ const MAX_TEXT_FILE_READ_BYTES = 2 * 1024 * 1024;
27
+ const BINARY_SNIFF_BYTES = 8 * 1024;
28
+ const MAX_BASENAME_FALLBACK_MATCHES = 2;
26
29
  const MIN_IMAGE_PREVIEW_PIXEL_DIMENSION = 128;
27
30
  const MAX_IMAGE_PREVIEW_PIXEL_DIMENSION = 3_200;
28
31
  const IMAGE_PREVIEW_RETRY_SCALE = 0.75;
@@ -32,6 +35,7 @@ const IMAGE_MIME_TYPES_BY_EXTENSION = new Map([
32
35
  [".jpg", "image/jpeg"],
33
36
  [".jpeg", "image/jpeg"],
34
37
  [".png", "image/png"],
38
+ [".svg", "image/svg+xml"],
35
39
  [".gif", "image/gif"],
36
40
  [".webp", "image/webp"],
37
41
  [".heic", "image/heic"],
@@ -83,6 +87,9 @@ async function handleWorkspaceMethod(method, params) {
83
87
  if (method === "workspace/readImage") {
84
88
  return workspaceReadImage(params);
85
89
  }
90
+ if (method === "workspace/readFile") {
91
+ return workspaceReadFile(params);
92
+ }
86
93
 
87
94
  const cwd = await resolveWorkspaceCwd(params);
88
95
  const repoRoot = await resolveRepoRoot(cwd);
@@ -111,6 +118,60 @@ async function handleWorkspaceMethod(method, params) {
111
118
  }
112
119
  }
113
120
 
121
+ // Reads a UTF-8 text file from the active workspace for the phone-side read-only viewer.
122
+ async function workspaceReadFile(params) {
123
+ const requestedPath = firstNonEmptyString([params.path, params.filePath, params.localPath]);
124
+ if (!requestedPath) {
125
+ throw workspaceError("missing_file_path", "The request must include a file path.");
126
+ }
127
+
128
+ const cwd = await resolveWorkspaceCwd(params);
129
+ const realWorkspaceRoot = await resolveReadableWorkspaceRoot(cwd);
130
+ const realFilePath = await resolveWorkspaceTextFilePath(cwd, requestedPath, realWorkspaceRoot);
131
+ if (!realFilePath) {
132
+ throw workspaceError("file_not_found", "The file no longer exists on this Mac.");
133
+ }
134
+ if (!realWorkspaceRoot || !isPathInside(realFilePath, realWorkspaceRoot)) {
135
+ throw workspaceError("file_path_not_allowed", "Only files in the current workspace can be viewed.");
136
+ }
137
+
138
+ const stat = await fs.promises.stat(realFilePath);
139
+ if (!stat.isFile()) {
140
+ throw workspaceError("file_not_found", "The path is not a file.");
141
+ }
142
+ if (stat.size > MAX_TEXT_FILE_READ_BYTES) {
143
+ throw workspaceError(
144
+ "file_too_large",
145
+ "This file is too large to send to the phone. Open it on the Mac or ask for a smaller section."
146
+ );
147
+ }
148
+
149
+ const result = {
150
+ path: realFilePath,
151
+ fileName: path.basename(realFilePath),
152
+ byteLength: stat.size,
153
+ mtimeMs: stat.mtimeMs,
154
+ encoding: "utf-8",
155
+ };
156
+ if (params.includeContent === false || params.metadataOnly === true) {
157
+ return result;
158
+ }
159
+ if (isUnchangedTextFileRead(params, stat)) {
160
+ return {
161
+ ...result,
162
+ notModified: true,
163
+ };
164
+ }
165
+
166
+ const data = await fs.promises.readFile(realFilePath);
167
+ const content = decodeUtf8TextFile(data);
168
+ return {
169
+ ...result,
170
+ content,
171
+ lineCount: countLines(content),
172
+ };
173
+ }
174
+
114
175
  // Reads recognized local image files from the bound repo, Codex image cache, or host temp screenshot folders.
115
176
  async function workspaceReadImage(params) {
116
177
  const requestedPath = firstNonEmptyString([params.path, params.filePath, params.localPath]);
@@ -125,10 +186,7 @@ async function workspaceReadImage(params) {
125
186
  ? path.resolve(requestedPath)
126
187
  : path.resolve(cwd || process.cwd(), requestedPath);
127
188
  const extension = path.extname(imagePath).toLowerCase();
128
- const mimeType = IMAGE_MIME_TYPES_BY_EXTENSION.get(extension);
129
- if (!mimeType) {
130
- throw workspaceError("unsupported_image_type", "Only local image files can be previewed.");
131
- }
189
+ let mimeType = IMAGE_MIME_TYPES_BY_EXTENSION.get(extension);
132
190
 
133
191
  const [realImagePath, realGeneratedImagesRoot] = await Promise.all([
134
192
  realpathOrNull(imagePath),
@@ -154,6 +212,12 @@ async function workspaceReadImage(params) {
154
212
  if (!stat.isFile()) {
155
213
  throw workspaceError("image_not_found", "The image path is not a file.");
156
214
  }
215
+ if (!mimeType) {
216
+ mimeType = await sniffImageMimeType(realImagePath);
217
+ }
218
+ if (!mimeType) {
219
+ throw workspaceError("unsupported_image_type", "Only local image files can be previewed.");
220
+ }
157
221
  const includeData = params.includeData !== false && params.metadataOnly !== true;
158
222
  const maxPixelDimension = normalizedPreviewPixelDimension(params);
159
223
  if (stat.size > MAX_IMAGE_READ_BYTES && !maxPixelDimension) {
@@ -181,7 +245,9 @@ async function workspaceReadImage(params) {
181
245
  };
182
246
  }
183
247
 
184
- const data = maxPixelDimension
248
+ const data = mimeType === "image/svg+xml"
249
+ ? await readSVGPreviewData(realImagePath, stat.size)
250
+ : maxPixelDimension
185
251
  ? await readPreviewImageData(realImagePath, maxPixelDimension, stat.size)
186
252
  : await fs.promises.readFile(realImagePath);
187
253
  return {
@@ -202,6 +268,45 @@ function normalizedPreviewPixelDimension(params) {
202
268
  );
203
269
  }
204
270
 
271
+ async function sniffImageMimeType(filePath) {
272
+ let header;
273
+ try {
274
+ const handle = await fs.promises.open(filePath, "r");
275
+ try {
276
+ header = Buffer.alloc(512);
277
+ const read = await handle.read(header, 0, header.length, 0);
278
+ header = header.subarray(0, read.bytesRead);
279
+ } finally {
280
+ await handle.close();
281
+ }
282
+ } catch {
283
+ return null;
284
+ }
285
+
286
+ if (header.length >= 8 && header.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
287
+ return "image/png";
288
+ }
289
+ if (header.length >= 3 && header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
290
+ return "image/jpeg";
291
+ }
292
+ if (header.length >= 6 && (header.subarray(0, 6).toString("ascii") === "GIF87a" || header.subarray(0, 6).toString("ascii") === "GIF89a")) {
293
+ return "image/gif";
294
+ }
295
+ if (header.length >= 12 && header.subarray(0, 4).toString("ascii") === "RIFF" && header.subarray(8, 12).toString("ascii") === "WEBP") {
296
+ return "image/webp";
297
+ }
298
+ if (looksLikeSVGHeader(header)) {
299
+ return "image/svg+xml";
300
+ }
301
+
302
+ return null;
303
+ }
304
+
305
+ function looksLikeSVGHeader(header) {
306
+ const sample = header.toString("utf8").replace(/^\uFEFF/, "").trimStart().toLowerCase();
307
+ return sample.startsWith("<svg") || (sample.startsWith("<?xml") && sample.includes("<svg"));
308
+ }
309
+
205
310
  async function realTemporaryImageRoots() {
206
311
  const candidates = [
207
312
  os.tmpdir(),
@@ -210,6 +315,9 @@ async function realTemporaryImageRoots() {
210
315
 
211
316
  if (process.platform === "darwin") {
212
317
  candidates.push("/tmp");
318
+ candidates.push(path.join(os.homedir(), "Library", "Caches", "com.raycast-x.macos", "clipboard"));
319
+ candidates.push(path.join(os.homedir(), "Library", "Application Support", "CleanShot", "media"));
320
+ candidates.push(path.join(os.homedir(), "Library", "Application Support", "CleanShot X", "media"));
213
321
  }
214
322
 
215
323
  const roots = await Promise.all(
@@ -218,8 +326,8 @@ async function realTemporaryImageRoots() {
218
326
  return Array.from(new Set(roots.filter(Boolean)));
219
327
  }
220
328
 
221
- // Image previews are read-only, so non-git Codex scratch workspaces can be scoped to their cwd.
222
- async function resolveImageWorkspaceRoot(cwd) {
329
+ // Read-only previews can scope non-git Codex scratch folders to cwd while rejecting broad roots.
330
+ async function resolveReadableWorkspaceRoot(cwd) {
223
331
  const realRepoRoot = await resolveRepoRoot(cwd).then(realpathOrNull).catch(() => null);
224
332
  if (realRepoRoot) {
225
333
  return realRepoRoot;
@@ -232,14 +340,99 @@ async function resolveImageWorkspaceRoot(cwd) {
232
340
  return realCwd;
233
341
  }
234
342
 
343
+ async function resolveImageWorkspaceRoot(cwd) {
344
+ return resolveReadableWorkspaceRoot(cwd);
345
+ }
346
+
347
+ // Handles assistant links that only include a filename by finding one unique workspace match.
348
+ async function resolveWorkspaceTextFilePath(cwd, requestedPath, realWorkspaceRoot) {
349
+ const filePath = path.isAbsolute(requestedPath)
350
+ ? path.resolve(requestedPath)
351
+ : path.resolve(cwd, requestedPath);
352
+ const realFilePath = await realpathOrNull(filePath);
353
+ if (realFilePath || path.isAbsolute(requestedPath) || !realWorkspaceRoot) {
354
+ return realFilePath;
355
+ }
356
+
357
+ if (!isBareFileName(requestedPath)) {
358
+ return null;
359
+ }
360
+
361
+ return resolveUniqueWorkspaceBasenameMatch(realWorkspaceRoot, requestedPath);
362
+ }
363
+
364
+ async function resolveUniqueWorkspaceBasenameMatch(realWorkspaceRoot, requestedFileName) {
365
+ const matches = await findWorkspaceBasenameMatches(realWorkspaceRoot, requestedFileName);
366
+ if (matches.length === 0) {
367
+ return null;
368
+ }
369
+ if (matches.length > 1) {
370
+ throw workspaceError(
371
+ "file_path_ambiguous",
372
+ `Multiple files named "${requestedFileName}" exist in this workspace. Use a path with folders.`
373
+ );
374
+ }
375
+ return matches[0];
376
+ }
377
+
378
+ async function findWorkspaceBasenameMatches(realWorkspaceRoot, requestedFileName) {
379
+ const matches = [];
380
+ const output = await git(realWorkspaceRoot, "ls-files", "-co", "--exclude-standard").catch(() => "");
381
+ const relativePaths = output
382
+ .split("\n")
383
+ .map((line) => line.trim())
384
+ .filter(Boolean);
385
+
386
+ for (const relativePath of relativePaths) {
387
+ if (path.basename(relativePath) !== requestedFileName) {
388
+ continue;
389
+ }
390
+ const realCandidate = await realpathOrNull(path.resolve(realWorkspaceRoot, relativePath));
391
+ if (realCandidate && isPathInside(realCandidate, realWorkspaceRoot)) {
392
+ matches.push(realCandidate);
393
+ if (matches.length >= MAX_BASENAME_FALLBACK_MATCHES) {
394
+ break;
395
+ }
396
+ }
397
+ }
398
+ return matches;
399
+ }
400
+
401
+ function isBareFileName(candidatePath) {
402
+ return candidatePath === path.basename(candidatePath)
403
+ && candidatePath !== "."
404
+ && candidatePath !== "..";
405
+ }
406
+
235
407
  function isBroadWorkspaceRoot(candidatePath) {
236
408
  const normalized = path.resolve(candidatePath);
237
409
  return normalized === path.parse(normalized).root
238
410
  || normalized === path.resolve(os.homedir());
239
411
  }
240
412
 
413
+ function decodeUtf8TextFile(data) {
414
+ const sample = data.subarray(0, Math.min(data.length, BINARY_SNIFF_BYTES));
415
+ if (sample.includes(0)) {
416
+ throw workspaceError("binary_file", "This file looks binary, so it cannot be shown as text.");
417
+ }
418
+
419
+ try {
420
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
421
+ } catch {
422
+ throw workspaceError("unsupported_text_encoding", "Only UTF-8 text files can be viewed.");
423
+ }
424
+ }
425
+
426
+ function countLines(content) {
427
+ if (!content) {
428
+ return 0;
429
+ }
430
+ const newlineCount = (content.match(/\n/g) || []).length;
431
+ return content.endsWith("\n") ? newlineCount : newlineCount + 1;
432
+ }
433
+
241
434
  async function readPreviewImageData(imagePath, maxPixelDimension, originalByteLength) {
242
- if (!usesSipsImagePreview()) {
435
+ if (!usesNativeImagePreview()) {
243
436
  if (originalByteLength <= MAX_IMAGE_PREVIEW_READ_BYTES) {
244
437
  return fs.promises.readFile(imagePath);
245
438
  }
@@ -262,7 +455,7 @@ async function readPreviewImageData(imagePath, maxPixelDimension, originalByteLe
262
455
  }
263
456
 
264
457
  try {
265
- const previewData = await downsampleImageWithSips(
458
+ const previewData = await downsampleImageNative(
266
459
  imagePath,
267
460
  candidateDimension,
268
461
  Math.min(IMAGE_PREVIEW_TOOL_TIMEOUT_MS, remainingTimeoutMs)
@@ -294,6 +487,17 @@ async function readPreviewImageData(imagePath, maxPixelDimension, originalByteLe
294
487
  );
295
488
  }
296
489
 
490
+ // SVGs are already compact vector source; send them as-is so the phone can render them in WebKit.
491
+ async function readSVGPreviewData(imagePath, originalByteLength) {
492
+ if (originalByteLength > MAX_IMAGE_PREVIEW_READ_BYTES) {
493
+ throw workspaceError(
494
+ "image_too_large",
495
+ "This SVG is too large to send to the phone. Open it on the Mac or move a smaller preview into the workspace."
496
+ );
497
+ }
498
+ return fs.promises.readFile(imagePath);
499
+ }
500
+
297
501
  function previewPixelDimensionCandidates(maxPixelDimension) {
298
502
  const dimensions = [];
299
503
  let next = maxPixelDimension;
@@ -318,18 +522,118 @@ function previewPixelDimensionCandidates(maxPixelDimension) {
318
522
  return Array.from(new Set(dimensions)).sort((a, b) => b - a);
319
523
  }
320
524
 
321
- function usesSipsImagePreview() {
525
+ function usesNativeImagePreview() {
526
+ const normalizedPlatform = String(process.platform || "").trim().toLowerCase();
527
+ return normalizedPlatform === "darwin"
528
+ || normalizedPlatform === "macos"
529
+ || normalizedPlatform === "mac"
530
+ || normalizedPlatform === "win32"
531
+ || normalizedPlatform === "windows";
532
+ }
533
+
534
+ async function downsampleImageNative(imagePath, maxPixelDimension, timeoutMs = IMAGE_PREVIEW_TOOL_TIMEOUT_MS) {
535
+ return usesWindowsImagePreview()
536
+ ? downsampleImageWithPowerShell(imagePath, maxPixelDimension, timeoutMs)
537
+ : downsampleImageWithSips(imagePath, maxPixelDimension, timeoutMs);
538
+ }
539
+
540
+ function usesWindowsImagePreview() {
322
541
  const normalizedPlatform = String(process.platform || "").trim().toLowerCase();
323
- return normalizedPlatform === "darwin" || normalizedPlatform === "macos" || normalizedPlatform === "mac";
542
+ return normalizedPlatform === "win32" || normalizedPlatform === "windows";
324
543
  }
325
544
 
326
545
  async function downsampleImageWithSips(imagePath, maxPixelDimension, timeoutMs = IMAGE_PREVIEW_TOOL_TIMEOUT_MS) {
327
546
  const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remodex-image-preview-"));
328
547
  const outputPath = path.join(tempDir, `preview${path.extname(imagePath) || ".png"}`);
548
+ const command = resolveHostCommand("sips");
549
+ try {
550
+ await execFileAsync(command.file, [
551
+ ...command.args,
552
+ "-Z",
553
+ String(maxPixelDimension),
554
+ imagePath,
555
+ "--out",
556
+ outputPath,
557
+ ], {
558
+ timeout: Math.max(1, Math.floor(timeoutMs)),
559
+ maxBuffer: 1024 * 1024,
560
+ });
561
+ return await fs.promises.readFile(outputPath);
562
+ } finally {
563
+ await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
564
+ }
565
+ }
566
+
567
+ function resolveHostCommand(commandName) {
568
+ if (path.delimiter !== ";") {
569
+ return { file: commandName, args: [] };
570
+ }
571
+
572
+ const pathEntries = String(process.env.PATH || "")
573
+ .split(path.delimiter)
574
+ .filter(Boolean);
575
+ for (const entry of pathEntries) {
576
+ const jsShim = path.join(entry, `${commandName}.js`);
577
+ if (fs.existsSync(jsShim)) {
578
+ return { file: process.execPath, args: [jsShim] };
579
+ }
580
+ }
581
+ return { file: commandName, args: [] };
582
+ }
583
+
584
+ async function downsampleImageWithPowerShell(imagePath, maxPixelDimension, timeoutMs = IMAGE_PREVIEW_TOOL_TIMEOUT_MS) {
585
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remodex-image-preview-"));
586
+ const outputPath = path.join(tempDir, `preview${path.extname(imagePath) || ".png"}`);
587
+ const scriptPath = path.join(tempDir, "resize-preview.ps1");
588
+ const script = `
589
+ param(
590
+ [Parameter(Mandatory=$true)][string]$InputPath,
591
+ [Parameter(Mandatory=$true)][string]$OutputPath,
592
+ [Parameter(Mandatory=$true)][int]$MaxDimension
593
+ )
594
+ Add-Type -AssemblyName System.Drawing
595
+ $image = [System.Drawing.Image]::FromFile($InputPath)
596
+ try {
597
+ $longest = [Math]::Max($image.Width, $image.Height)
598
+ if ($longest -le 0) { throw "Invalid image dimensions." }
599
+ $scale = [Math]::Min(1.0, [double]$MaxDimension / [double]$longest)
600
+ $targetWidth = [Math]::Max(1, [int][Math]::Round($image.Width * $scale))
601
+ $targetHeight = [Math]::Max(1, [int][Math]::Round($image.Height * $scale))
602
+ $bitmap = New-Object System.Drawing.Bitmap $targetWidth, $targetHeight
603
+ try {
604
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
605
+ try {
606
+ $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
607
+ $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
608
+ $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
609
+ $graphics.DrawImage($image, 0, 0, $targetWidth, $targetHeight)
610
+ } finally {
611
+ $graphics.Dispose()
612
+ }
613
+ $bitmap.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png)
614
+ } finally {
615
+ if ($bitmap) { $bitmap.Dispose() }
616
+ }
617
+ } finally {
618
+ $image.Dispose()
619
+ }
620
+ `;
329
621
  try {
330
- await execFileAsync("sips", ["-Z", String(maxPixelDimension), imagePath, "--out", outputPath], {
622
+ await fs.promises.writeFile(scriptPath, script, "utf8");
623
+ await execFileAsync("powershell.exe", [
624
+ "-NoProfile",
625
+ "-NonInteractive",
626
+ "-ExecutionPolicy",
627
+ "Bypass",
628
+ "-File",
629
+ scriptPath,
630
+ imagePath,
631
+ outputPath,
632
+ String(maxPixelDimension),
633
+ ], {
331
634
  timeout: Math.max(1, Math.floor(timeoutMs)),
332
635
  maxBuffer: 1024 * 1024,
636
+ windowsHide: true,
333
637
  });
334
638
  return await fs.promises.readFile(outputPath);
335
639
  } finally {
@@ -357,6 +661,15 @@ function isUnchangedImageRead(params, stat, maxPixelDimension) {
357
661
  && cachedMtimeMs === stat.mtimeMs;
358
662
  }
359
663
 
664
+ function isUnchangedTextFileRead(params, stat) {
665
+ const cachedByteLength = Number(params.ifByteLength);
666
+ const cachedMtimeMs = Number(params.ifMtimeMs);
667
+ return Number.isFinite(cachedByteLength)
668
+ && Number.isFinite(cachedMtimeMs)
669
+ && cachedByteLength === stat.size
670
+ && cachedMtimeMs === stat.mtimeMs;
671
+ }
672
+
360
673
  // Validates the reverse patch against the current tree without writing repo files.
361
674
  async function workspaceRevertPatchPreview(repoRoot, params) {
362
675
  const forwardPatch = resolveForwardPatch(params);