@jerryan/pi-hashline-edit 0.7.4 → 0.8.2
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 -21
- package/README.md +6 -4
- package/index.ts +6 -0
- package/package.json +53 -53
- package/src/edit-diff.ts +201 -390
- package/src/edit.ts +81 -65
- package/src/file-kind.ts +130 -130
- package/src/fs-write.ts +76 -76
- package/src/hashline.ts +699 -1071
- package/src/package-info.ts +1 -1
- package/src/path-utils.ts +13 -13
- package/src/read.ts +3 -3
- package/src/runtime.ts +3 -3
- package/src/snapshot.ts +29 -29
- package/src/undo.ts +212 -0
- package/tool-descriptions/edit.md +23 -23
- package/tool-descriptions/read-guidelines.md +1 -1
- package/tool-descriptions/read.md +5 -5
- package/tool-descriptions/undo.md +8 -0
package/src/edit.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveEditAnchors,
|
|
19
19
|
type HashlineToolEdit,
|
|
20
20
|
ANCHOR_SEP,
|
|
21
|
+
CONTENT_SEP,
|
|
21
22
|
} from "./hashline";
|
|
22
23
|
import { loadFileKindAndText } from "./file-kind";
|
|
23
24
|
import { resolveToCwd } from "./path-utils";
|
|
@@ -25,6 +26,7 @@ import { resolveToCwd } from "./path-utils";
|
|
|
25
26
|
import { throwIfAborted } from "./runtime";
|
|
26
27
|
import { getFileSnapshot } from "./snapshot";
|
|
27
28
|
import { buildChangedResponse, buildNoopResponse } from "./edit-response";
|
|
29
|
+
import { setLastEdit } from "./undo";
|
|
28
30
|
|
|
29
31
|
const editEntrySchema = Type.Object(
|
|
30
32
|
{
|
|
@@ -108,6 +110,73 @@ export function normalizeEditItems(edits: Record<string, unknown>[]): HashlineTo
|
|
|
108
110
|
});
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
type EditTargetResult =
|
|
114
|
+
| { ok: false; error: string; code?: string }
|
|
115
|
+
| {
|
|
116
|
+
ok: true;
|
|
117
|
+
normalized: string;
|
|
118
|
+
bom: string;
|
|
119
|
+
ending: "\r\n" | "\n";
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
async function resolveEditTarget(
|
|
123
|
+
absolutePath: string,
|
|
124
|
+
path: string,
|
|
125
|
+
accessMode: number,
|
|
126
|
+
): Promise<EditTargetResult> {
|
|
127
|
+
try {
|
|
128
|
+
await fsAccess(absolutePath, accessMode);
|
|
129
|
+
} catch (error: unknown) {
|
|
130
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
131
|
+
if (code === "ENOENT") {
|
|
132
|
+
return { ok: false, error: `File not found: ${path}` };
|
|
133
|
+
}
|
|
134
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
135
|
+
const action = accessMode & constants.W_OK ? "writable" : "readable";
|
|
136
|
+
return { ok: false, error: `File is not ${action}: ${path}` };
|
|
137
|
+
}
|
|
138
|
+
return { ok: false, error: `Cannot access file: ${path}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const file = await loadFileKindAndText(absolutePath);
|
|
142
|
+
if (file.kind === "directory") {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: `Path is a directory: ${path}. Use ls to inspect directories.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (file.kind === "image") {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: `Path is an image file: ${path}. Hashline edit only supports text files.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (file.kind === "binary") {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: `Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { bom, text: content } = stripBom(file.text);
|
|
162
|
+
const normalized = normalizeToLF(content);
|
|
163
|
+
if (normalized.length === 0) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
code: "E_EMPTY_FILE",
|
|
167
|
+
error: `File is empty: ${path}. The edit tool requires anchors from a read output, which an empty file cannot provide. Use the write tool to create initial content in an empty file.`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
normalized,
|
|
174
|
+
bom,
|
|
175
|
+
ending: detectLineEnding(content),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
111
180
|
type EditPreview = { diff: string } | { error: string };
|
|
112
181
|
type EditRenderState = {
|
|
113
182
|
argsKey?: string;
|
|
@@ -141,7 +210,6 @@ function colorDiffLines(
|
|
|
141
210
|
return theme.fg("dim", line);
|
|
142
211
|
});
|
|
143
212
|
}
|
|
144
|
-
|
|
145
213
|
function formatPreviewDiff(
|
|
146
214
|
diff: string,
|
|
147
215
|
expanded: boolean,
|
|
@@ -163,7 +231,6 @@ function formatResultDiff(
|
|
|
163
231
|
): string {
|
|
164
232
|
return colorDiffLines(diff.split("\n"), theme).join("\n");
|
|
165
233
|
}
|
|
166
|
-
|
|
167
234
|
function getRenderedEditTextContent(
|
|
168
235
|
result: { content?: Array<{ type: string; text?: string }> },
|
|
169
236
|
): string | undefined {
|
|
@@ -250,36 +317,13 @@ export async function computeEditPreview(
|
|
|
250
317
|
const absolutePath = resolveToCwd(path, cwd);
|
|
251
318
|
const toolEdits = normalizeEditItems(params.edits);
|
|
252
319
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
257
|
-
if (code === "ENOENT") {
|
|
258
|
-
return { error: `File not found: ${path}` };
|
|
259
|
-
}
|
|
260
|
-
if (code === "EACCES" || code === "EPERM") {
|
|
261
|
-
return { error: `File is not readable: ${path}` };
|
|
262
|
-
}
|
|
263
|
-
return { error: `Cannot access file: ${path}` };
|
|
320
|
+
const target = await resolveEditTarget(absolutePath, path, constants.R_OK);
|
|
321
|
+
if (!target.ok) {
|
|
322
|
+
return { error: target.error };
|
|
264
323
|
}
|
|
324
|
+
const originalNormalized = target.normalized;
|
|
265
325
|
|
|
266
326
|
try {
|
|
267
|
-
const file = await loadFileKindAndText(absolutePath);
|
|
268
|
-
if (file.kind === "directory") {
|
|
269
|
-
return { error: `Path is a directory: ${path}. Use ls to inspect directories.` };
|
|
270
|
-
}
|
|
271
|
-
if (file.kind === "image") {
|
|
272
|
-
return {
|
|
273
|
-
error: `Path is an image file: ${path}. Hashline edit only supports text files.`,
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
if (file.kind === "binary") {
|
|
277
|
-
return {
|
|
278
|
-
error: `Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const originalNormalized = normalizeToLF(stripBom(file.text).text);
|
|
283
327
|
const resolved = resolveEditAnchors(toolEdits);
|
|
284
328
|
const result = applyHashlineEdits(originalNormalized, resolved).content;
|
|
285
329
|
|
|
@@ -434,48 +478,20 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
434
478
|
const mutationTargetPath = await resolveMutationTargetPath(absolutePath);
|
|
435
479
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
436
480
|
throwIfAborted(signal);
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (code === "ENOENT") {
|
|
442
|
-
throw new Error(`File not found: ${path}`);
|
|
443
|
-
}
|
|
444
|
-
if (code === "EACCES" || code === "EPERM") {
|
|
445
|
-
throw new Error(`File is not writable: ${path}`);
|
|
446
|
-
}
|
|
447
|
-
throw new Error(`Cannot access file: ${path}`);
|
|
481
|
+
const target = await resolveEditTarget(absolutePath, path, constants.R_OK | constants.W_OK);
|
|
482
|
+
if (!target.ok) {
|
|
483
|
+
const prefix = target.code ? `[${target.code}] ` : "";
|
|
484
|
+
throw new Error(`${prefix}${target.error}`);
|
|
448
485
|
}
|
|
449
|
-
|
|
450
|
-
throwIfAborted(signal);
|
|
451
|
-
const file = await loadFileKindAndText(absolutePath);
|
|
452
|
-
if (file.kind === "directory") {
|
|
453
|
-
throw new Error(`Path is a directory: ${path}. Use ls to inspect directories.`);
|
|
454
|
-
}
|
|
455
|
-
if (file.kind === "image") {
|
|
456
|
-
throw new Error(
|
|
457
|
-
`Path is an image file: ${path}. Hashline edit only supports text files.`,
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
if (file.kind === "binary") {
|
|
461
|
-
throw new Error(
|
|
462
|
-
`Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`,
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
throwIfAborted(signal);
|
|
467
|
-
const { bom, text: content } = stripBom(file.text);
|
|
468
|
-
const originalEnding = detectLineEnding(content);
|
|
469
|
-
const originalNormalized = normalizeToLF(content);
|
|
486
|
+
const { bom, normalized: originalNormalized, ending: originalEnding } = target;
|
|
470
487
|
|
|
471
488
|
const resolved = resolveEditAnchors(toolEdits);
|
|
472
489
|
|
|
490
|
+
|
|
473
491
|
const anchorResult = applyHashlineEdits(originalNormalized, resolved, signal);
|
|
474
492
|
const result = anchorResult.content;
|
|
475
493
|
const warnings = anchorResult.warnings;
|
|
476
|
-
const originalLineCount = originalNormalized.length
|
|
477
|
-
? 0
|
|
478
|
-
: originalNormalized.split("\n").length - (originalNormalized.endsWith("\n") ? 1 : 0);
|
|
494
|
+
const originalLineCount = originalNormalized.split("\n").length - (originalNormalized.endsWith("\n") ? 1 : 0);
|
|
479
495
|
if (result.length === 0 && originalLineCount > 50) {
|
|
480
496
|
throw new Error(
|
|
481
497
|
"[E_WOULD_EMPTY] This edit would delete the entire file. The edit tool does not allow full-file deletion for files with more than 50 lines. If you truly intend to clear the file, use the write tool to overwrite it with an empty string.",
|
|
@@ -495,7 +511,7 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
495
511
|
warnings,
|
|
496
512
|
});
|
|
497
513
|
}
|
|
498
|
-
|
|
514
|
+
setLastEdit({ path, previousContent: originalNormalized });
|
|
499
515
|
throwIfAborted(signal);
|
|
500
516
|
await writeFileAtomically(
|
|
501
517
|
absolutePath,
|
package/src/file-kind.ts
CHANGED
|
@@ -1,130 +1,130 @@
|
|
|
1
|
-
import { open as fsOpen, stat as fsStat } from "fs/promises";
|
|
2
|
-
import { fileTypeFromBuffer } from "file-type";
|
|
3
|
-
|
|
4
|
-
const IMAGE_MIME_TYPES = new Set<string>([
|
|
5
|
-
"image/jpeg",
|
|
6
|
-
"image/png",
|
|
7
|
-
"image/gif",
|
|
8
|
-
"image/webp",
|
|
9
|
-
]);
|
|
10
|
-
|
|
11
|
-
const TEXT_LIKE_MIME_TYPES = new Set<string>([
|
|
12
|
-
"application/rtf",
|
|
13
|
-
"application/xml",
|
|
14
|
-
"application/x-ms-regedit",
|
|
15
|
-
]);
|
|
16
|
-
|
|
17
|
-
function isTextLikeMimeType(mimeType: string): boolean {
|
|
18
|
-
return mimeType.startsWith("text/") || TEXT_LIKE_MIME_TYPES.has(mimeType);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const FILE_TYPE_SNIFF_BYTES = 8192;
|
|
22
|
-
|
|
23
|
-
export type FileKind =
|
|
24
|
-
| { kind: "directory" }
|
|
25
|
-
| { kind: "image"; mimeType: string }
|
|
26
|
-
| { kind: "text" }
|
|
27
|
-
| { kind: "binary"; description: string };
|
|
28
|
-
|
|
29
|
-
export type LoadedFile =
|
|
30
|
-
| { kind: "directory" }
|
|
31
|
-
| { kind: "image"; mimeType: string }
|
|
32
|
-
| { kind: "text"; text: string }
|
|
33
|
-
| { kind: "binary"; description: string };
|
|
34
|
-
|
|
35
|
-
function hasNullByte(buffer: Uint8Array): boolean {
|
|
36
|
-
return buffer.includes(0);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
export async function loadFileKindAndText(filePath: string): Promise<LoadedFile> {
|
|
41
|
-
const pathStat = await fsStat(filePath);
|
|
42
|
-
if (pathStat.isDirectory()) {
|
|
43
|
-
return { kind: "directory" };
|
|
44
|
-
}
|
|
45
|
-
if (!pathStat.isFile()) {
|
|
46
|
-
return {
|
|
47
|
-
kind: "binary",
|
|
48
|
-
description: "unsupported file type",
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const fileHandle = await fsOpen(filePath, "r");
|
|
53
|
-
try {
|
|
54
|
-
const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
|
|
55
|
-
const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0);
|
|
56
|
-
if (bytesRead === 0) {
|
|
57
|
-
return { kind: "text", text: "" };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const sample = buffer.subarray(0, bytesRead);
|
|
61
|
-
const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
|
|
62
|
-
if (detectedMimeType !== undefined && !isTextLikeMimeType(detectedMimeType)) {
|
|
63
|
-
if (IMAGE_MIME_TYPES.has(detectedMimeType)) {
|
|
64
|
-
return { kind: "image", mimeType: detectedMimeType };
|
|
65
|
-
}
|
|
66
|
-
return {
|
|
67
|
-
kind: "binary",
|
|
68
|
-
description: detectedMimeType,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
if (hasNullByte(sample)) {
|
|
72
|
-
return {
|
|
73
|
-
kind: "binary",
|
|
74
|
-
description: "null bytes detected",
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Non-fatal decode, matching pi's built-in tools: invalid UTF-8 becomes
|
|
79
|
-
// U+FFFD rather than rejecting the file. The null-byte guard above is the
|
|
80
|
-
// only signal we treat as binary, so non-UTF-8 text (CP1251, GBK, …) reads
|
|
81
|
-
// instead of forcing the model to bypass hashline with raw shell edits.
|
|
82
|
-
const decoder = new TextDecoder("utf-8");
|
|
83
|
-
const parts: string[] = [decoder.decode(sample, { stream: true })];
|
|
84
|
-
|
|
85
|
-
let position = bytesRead;
|
|
86
|
-
while (true) {
|
|
87
|
-
const { bytesRead: chunkBytesRead } = await fileHandle.read(
|
|
88
|
-
buffer,
|
|
89
|
-
0,
|
|
90
|
-
FILE_TYPE_SNIFF_BYTES,
|
|
91
|
-
position,
|
|
92
|
-
);
|
|
93
|
-
if (chunkBytesRead === 0) {
|
|
94
|
-
break;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const chunk = buffer.subarray(0, chunkBytesRead);
|
|
98
|
-
if (hasNullByte(chunk)) {
|
|
99
|
-
return {
|
|
100
|
-
kind: "binary",
|
|
101
|
-
description: "null bytes detected",
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
parts.push(decoder.decode(chunk, { stream: true }));
|
|
105
|
-
position += chunkBytesRead;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
parts.push(decoder.decode());
|
|
109
|
-
|
|
110
|
-
return { kind: "text", text: parts.join("") };
|
|
111
|
-
|
|
112
|
-
return { kind: "text", text: parts.join("") };
|
|
113
|
-
} finally {
|
|
114
|
-
await fileHandle.close();
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export async function classifyFileKind(filePath: string): Promise<FileKind> {
|
|
119
|
-
const loaded = await loadFileKindAndText(filePath);
|
|
120
|
-
switch (loaded.kind) {
|
|
121
|
-
case "directory":
|
|
122
|
-
return loaded;
|
|
123
|
-
case "image":
|
|
124
|
-
return loaded;
|
|
125
|
-
case "binary":
|
|
126
|
-
return loaded;
|
|
127
|
-
case "text":
|
|
128
|
-
return { kind: "text" };
|
|
129
|
-
}
|
|
130
|
-
}
|
|
1
|
+
import { open as fsOpen, stat as fsStat } from "fs/promises";
|
|
2
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
3
|
+
|
|
4
|
+
const IMAGE_MIME_TYPES = new Set<string>([
|
|
5
|
+
"image/jpeg",
|
|
6
|
+
"image/png",
|
|
7
|
+
"image/gif",
|
|
8
|
+
"image/webp",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const TEXT_LIKE_MIME_TYPES = new Set<string>([
|
|
12
|
+
"application/rtf",
|
|
13
|
+
"application/xml",
|
|
14
|
+
"application/x-ms-regedit",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function isTextLikeMimeType(mimeType: string): boolean {
|
|
18
|
+
return mimeType.startsWith("text/") || TEXT_LIKE_MIME_TYPES.has(mimeType);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FILE_TYPE_SNIFF_BYTES = 8192;
|
|
22
|
+
|
|
23
|
+
export type FileKind =
|
|
24
|
+
| { kind: "directory" }
|
|
25
|
+
| { kind: "image"; mimeType: string }
|
|
26
|
+
| { kind: "text" }
|
|
27
|
+
| { kind: "binary"; description: string };
|
|
28
|
+
|
|
29
|
+
export type LoadedFile =
|
|
30
|
+
| { kind: "directory" }
|
|
31
|
+
| { kind: "image"; mimeType: string }
|
|
32
|
+
| { kind: "text"; text: string }
|
|
33
|
+
| { kind: "binary"; description: string };
|
|
34
|
+
|
|
35
|
+
function hasNullByte(buffer: Uint8Array): boolean {
|
|
36
|
+
return buffer.includes(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
export async function loadFileKindAndText(filePath: string): Promise<LoadedFile> {
|
|
41
|
+
const pathStat = await fsStat(filePath);
|
|
42
|
+
if (pathStat.isDirectory()) {
|
|
43
|
+
return { kind: "directory" };
|
|
44
|
+
}
|
|
45
|
+
if (!pathStat.isFile()) {
|
|
46
|
+
return {
|
|
47
|
+
kind: "binary",
|
|
48
|
+
description: "unsupported file type",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fileHandle = await fsOpen(filePath, "r");
|
|
53
|
+
try {
|
|
54
|
+
const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
|
|
55
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0);
|
|
56
|
+
if (bytesRead === 0) {
|
|
57
|
+
return { kind: "text", text: "" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sample = buffer.subarray(0, bytesRead);
|
|
61
|
+
const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
|
|
62
|
+
if (detectedMimeType !== undefined && !isTextLikeMimeType(detectedMimeType)) {
|
|
63
|
+
if (IMAGE_MIME_TYPES.has(detectedMimeType)) {
|
|
64
|
+
return { kind: "image", mimeType: detectedMimeType };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
kind: "binary",
|
|
68
|
+
description: detectedMimeType,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (hasNullByte(sample)) {
|
|
72
|
+
return {
|
|
73
|
+
kind: "binary",
|
|
74
|
+
description: "null bytes detected",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Non-fatal decode, matching pi's built-in tools: invalid UTF-8 becomes
|
|
79
|
+
// U+FFFD rather than rejecting the file. The null-byte guard above is the
|
|
80
|
+
// only signal we treat as binary, so non-UTF-8 text (CP1251, GBK, …) reads
|
|
81
|
+
// instead of forcing the model to bypass hashline with raw shell edits.
|
|
82
|
+
const decoder = new TextDecoder("utf-8");
|
|
83
|
+
const parts: string[] = [decoder.decode(sample, { stream: true })];
|
|
84
|
+
|
|
85
|
+
let position = bytesRead;
|
|
86
|
+
while (true) {
|
|
87
|
+
const { bytesRead: chunkBytesRead } = await fileHandle.read(
|
|
88
|
+
buffer,
|
|
89
|
+
0,
|
|
90
|
+
FILE_TYPE_SNIFF_BYTES,
|
|
91
|
+
position,
|
|
92
|
+
);
|
|
93
|
+
if (chunkBytesRead === 0) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const chunk = buffer.subarray(0, chunkBytesRead);
|
|
98
|
+
if (hasNullByte(chunk)) {
|
|
99
|
+
return {
|
|
100
|
+
kind: "binary",
|
|
101
|
+
description: "null bytes detected",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
parts.push(decoder.decode(chunk, { stream: true }));
|
|
105
|
+
position += chunkBytesRead;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
parts.push(decoder.decode());
|
|
109
|
+
|
|
110
|
+
return { kind: "text", text: parts.join("") };
|
|
111
|
+
|
|
112
|
+
return { kind: "text", text: parts.join("") };
|
|
113
|
+
} finally {
|
|
114
|
+
await fileHandle.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function classifyFileKind(filePath: string): Promise<FileKind> {
|
|
119
|
+
const loaded = await loadFileKindAndText(filePath);
|
|
120
|
+
switch (loaded.kind) {
|
|
121
|
+
case "directory":
|
|
122
|
+
return loaded;
|
|
123
|
+
case "image":
|
|
124
|
+
return loaded;
|
|
125
|
+
case "binary":
|
|
126
|
+
return loaded;
|
|
127
|
+
case "text":
|
|
128
|
+
return { kind: "text" };
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/fs-write.ts
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
import { randomUUID } from "crypto";
|
|
2
|
-
import { lstat, mkdir, readlink, rename, stat, writeFile } from "fs/promises";
|
|
3
|
-
import { dirname, join, parse, resolve, sep } from "path";
|
|
4
|
-
|
|
5
|
-
export async function resolveMutationTargetPath(path: string): Promise<string> {
|
|
6
|
-
const absolutePath = resolve(path);
|
|
7
|
-
const { root } = parse(absolutePath);
|
|
8
|
-
const parts = absolutePath.slice(root.length).split(sep).filter((part) => part.length > 0);
|
|
9
|
-
const visitedSymlinks = new Set<string>();
|
|
10
|
-
|
|
11
|
-
async function resolveFromParts(currentPath: string, remainingParts: string[]): Promise<string> {
|
|
12
|
-
if (remainingParts.length === 0) {
|
|
13
|
-
return currentPath;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const [nextPart, ...tail] = remainingParts;
|
|
17
|
-
const candidatePath = join(currentPath, nextPart);
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
const candidateStats = await lstat(candidatePath);
|
|
21
|
-
if (!candidateStats.isSymbolicLink()) {
|
|
22
|
-
return resolveFromParts(candidatePath, tail);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (visitedSymlinks.has(candidatePath)) {
|
|
26
|
-
const error = new Error(`Too many symbolic links while resolving ${path}`) as NodeJS.ErrnoException;
|
|
27
|
-
error.code = "ELOOP";
|
|
28
|
-
throw error;
|
|
29
|
-
}
|
|
30
|
-
visitedSymlinks.add(candidatePath);
|
|
31
|
-
|
|
32
|
-
const linkTargetPath = resolve(dirname(candidatePath), await readlink(candidatePath));
|
|
33
|
-
const targetParts = linkTargetPath
|
|
34
|
-
.slice(parse(linkTargetPath).root.length)
|
|
35
|
-
.split(sep)
|
|
36
|
-
.filter((part) => part.length > 0);
|
|
37
|
-
return resolveFromParts(parse(linkTargetPath).root, [...targetParts, ...tail]);
|
|
38
|
-
} catch (error: unknown) {
|
|
39
|
-
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
40
|
-
return join(candidatePath, ...tail);
|
|
41
|
-
}
|
|
42
|
-
throw error;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return resolveFromParts(root, parts);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function writeFileAtomically(
|
|
50
|
-
path: string,
|
|
51
|
-
content: string,
|
|
52
|
-
): Promise<void> {
|
|
53
|
-
const targetPath = await resolveMutationTargetPath(path);
|
|
54
|
-
|
|
55
|
-
let existingStats: Awaited<ReturnType<typeof stat>> | null = null;
|
|
56
|
-
try {
|
|
57
|
-
existingStats = await stat(targetPath);
|
|
58
|
-
} catch (error: unknown) {
|
|
59
|
-
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
60
|
-
throw error;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (existingStats && existingStats.nlink > 1) {
|
|
65
|
-
await writeFile(targetPath, content, "utf-8");
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const dir = dirname(targetPath);
|
|
70
|
-
const tempPath = join(dir, `.tmp-${randomUUID()}`);
|
|
71
|
-
await mkdir(dir, { recursive: true });
|
|
72
|
-
const mode = existingStats ? existingStats.mode & 0o7777 : 0o600;
|
|
73
|
-
await writeFile(tempPath, content, { encoding: "utf-8", flag: "wx", mode });
|
|
74
|
-
|
|
75
|
-
await rename(tempPath, targetPath);
|
|
76
|
-
}
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { lstat, mkdir, readlink, rename, stat, writeFile } from "fs/promises";
|
|
3
|
+
import { dirname, join, parse, resolve, sep } from "path";
|
|
4
|
+
|
|
5
|
+
export async function resolveMutationTargetPath(path: string): Promise<string> {
|
|
6
|
+
const absolutePath = resolve(path);
|
|
7
|
+
const { root } = parse(absolutePath);
|
|
8
|
+
const parts = absolutePath.slice(root.length).split(sep).filter((part) => part.length > 0);
|
|
9
|
+
const visitedSymlinks = new Set<string>();
|
|
10
|
+
|
|
11
|
+
async function resolveFromParts(currentPath: string, remainingParts: string[]): Promise<string> {
|
|
12
|
+
if (remainingParts.length === 0) {
|
|
13
|
+
return currentPath;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [nextPart, ...tail] = remainingParts;
|
|
17
|
+
const candidatePath = join(currentPath, nextPart);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const candidateStats = await lstat(candidatePath);
|
|
21
|
+
if (!candidateStats.isSymbolicLink()) {
|
|
22
|
+
return resolveFromParts(candidatePath, tail);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (visitedSymlinks.has(candidatePath)) {
|
|
26
|
+
const error = new Error(`Too many symbolic links while resolving ${path}`) as NodeJS.ErrnoException;
|
|
27
|
+
error.code = "ELOOP";
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
visitedSymlinks.add(candidatePath);
|
|
31
|
+
|
|
32
|
+
const linkTargetPath = resolve(dirname(candidatePath), await readlink(candidatePath));
|
|
33
|
+
const targetParts = linkTargetPath
|
|
34
|
+
.slice(parse(linkTargetPath).root.length)
|
|
35
|
+
.split(sep)
|
|
36
|
+
.filter((part) => part.length > 0);
|
|
37
|
+
return resolveFromParts(parse(linkTargetPath).root, [...targetParts, ...tail]);
|
|
38
|
+
} catch (error: unknown) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
40
|
+
return join(candidatePath, ...tail);
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return resolveFromParts(root, parts);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeFileAtomically(
|
|
50
|
+
path: string,
|
|
51
|
+
content: string,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const targetPath = await resolveMutationTargetPath(path);
|
|
54
|
+
|
|
55
|
+
let existingStats: Awaited<ReturnType<typeof stat>> | null = null;
|
|
56
|
+
try {
|
|
57
|
+
existingStats = await stat(targetPath);
|
|
58
|
+
} catch (error: unknown) {
|
|
59
|
+
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (existingStats && existingStats.nlink > 1) {
|
|
65
|
+
await writeFile(targetPath, content, "utf-8");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const dir = dirname(targetPath);
|
|
70
|
+
const tempPath = join(dir, `.tmp-${randomUUID()}`);
|
|
71
|
+
await mkdir(dir, { recursive: true });
|
|
72
|
+
const mode = existingStats ? existingStats.mode & 0o7777 : 0o600;
|
|
73
|
+
await writeFile(tempPath, content, { encoding: "utf-8", flag: "wx", mode });
|
|
74
|
+
|
|
75
|
+
await rename(tempPath, targetPath);
|
|
76
|
+
}
|