@bigknoxy/hashpilot 4.6.3
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/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { join, isAbsolute, resolve, sep, normalize } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical path representation: absolute, symlink-resolved where possible,
|
|
5
|
+
* stored relative to the project root (cwd).
|
|
6
|
+
*
|
|
7
|
+
* All path comparisons in the codebase should route through this helper
|
|
8
|
+
* so that `foo.ts`, `./foo.ts`, `src/../src/foo.ts`, and `/abs/path/src/foo.ts`
|
|
9
|
+
* all compare as equal.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizePath(file: string | undefined | null): string {
|
|
12
|
+
if (!file) return "";
|
|
13
|
+
let p = file.trim();
|
|
14
|
+
if (p === "") return "";
|
|
15
|
+
|
|
16
|
+
// Resolve to absolute first (handles relative segments, ./, ../)
|
|
17
|
+
if (!isAbsolute(p)) {
|
|
18
|
+
p = join(process.cwd(), p);
|
|
19
|
+
}
|
|
20
|
+
p = resolve(p);
|
|
21
|
+
// Normalize removes trailing slashes and resolves ../ etc.
|
|
22
|
+
p = normalize(p);
|
|
23
|
+
|
|
24
|
+
// Now make relative to cwd if possible
|
|
25
|
+
const cwd = normalize(process.cwd());
|
|
26
|
+
if (p.startsWith(cwd + sep)) {
|
|
27
|
+
return p.slice(cwd.length + 1);
|
|
28
|
+
}
|
|
29
|
+
if (p === cwd) {
|
|
30
|
+
return ".";
|
|
31
|
+
}
|
|
32
|
+
// On case-insensitive filesystems, also try case-insensitive match
|
|
33
|
+
if (isCaseInsensitiveFS()) {
|
|
34
|
+
const lowerCwd = cwd.toLowerCase();
|
|
35
|
+
const lowerP = p.toLowerCase();
|
|
36
|
+
if (lowerP.startsWith(lowerCwd + sep)) {
|
|
37
|
+
return p.slice(cwd.length + 1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// If outside cwd, return the normalized absolute path
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isCaseInsensitiveFS(): boolean {
|
|
46
|
+
// macOS (darwin) and Windows (win32) are case-insensitive by default
|
|
47
|
+
return process.platform === "darwin" || process.platform === "win32";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compare two paths after normalization.
|
|
52
|
+
* Returns true if both resolve to the same canonical path.
|
|
53
|
+
*/
|
|
54
|
+
export function pathsEqual(a: string | undefined | null, b: string | undefined | null): boolean {
|
|
55
|
+
// No special-casing for nullish input: `normalizePath` already maps
|
|
56
|
+
// undefined, null, "", and whitespace to the same empty canonical form.
|
|
57
|
+
// An explicit undefined-only guard here made the relation inconsistent —
|
|
58
|
+
// (null, undefined) compared false while (null, null) and (null, "")
|
|
59
|
+
// compared true.
|
|
60
|
+
return normalizePath(a) === normalizePath(b);
|
|
61
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync, readFileSync, realpathSync, writeFileSync, renameSync, unlinkSync,
|
|
3
|
+
statSync, openSync, fsyncSync, closeSync,
|
|
4
|
+
} from "node:fs";
|
|
5
|
+
import { decodeText, encodeText } from "./encoding";
|
|
6
|
+
import { homedir, platform } from "node:os";
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { ErrorCode } from "./telemetry";
|
|
9
|
+
import { recordSnapshot, cleanOrphanTempFiles } from "./snapshot";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Filesystem write boundary.
|
|
13
|
+
*
|
|
14
|
+
* Every write in HashPilot funnels through `assertWritable`. Enforcing this in
|
|
15
|
+
* the write helpers rather than in `cli.ts` means a new command cannot forget
|
|
16
|
+
* the check, and the library API is bounded too — not just the CLI.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Thrown when a write target fails the boundary check. */
|
|
20
|
+
export class PathDeniedError extends Error {
|
|
21
|
+
readonly errorCode = ErrorCode.PATH_DENIED;
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly reason: string;
|
|
24
|
+
|
|
25
|
+
constructor(path: string, reason: string) {
|
|
26
|
+
super(`Refusing to write ${path}: ${reason}`);
|
|
27
|
+
this.name = "PathDeniedError";
|
|
28
|
+
this.path = path;
|
|
29
|
+
this.reason = reason;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AssertWritableOptions {
|
|
34
|
+
/** Directory the project root is discovered from. Defaults to `process.cwd()`. */
|
|
35
|
+
cwd?: string;
|
|
36
|
+
/** Extra roots to permit, from config `allowedRoots`. Relative entries resolve against `cwd`. */
|
|
37
|
+
allowedRoots?: string[];
|
|
38
|
+
/** Sole bypass for the containment check. Does NOT bypass the hard-deny list. */
|
|
39
|
+
allowOutsideRoot?: boolean;
|
|
40
|
+
/** Suppress the stderr warning emitted when `allowOutsideRoot` is used. For tests. */
|
|
41
|
+
quiet?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Process-wide defaults, set once at CLI bootstrap from config + global flags,
|
|
46
|
+
* so that write helpers deep in the call graph get the boundary policy without
|
|
47
|
+
* every caller threading options through.
|
|
48
|
+
*/
|
|
49
|
+
let boundaryDefaults: AssertWritableOptions = {};
|
|
50
|
+
|
|
51
|
+
export function configureWriteBoundary(options: AssertWritableOptions): void {
|
|
52
|
+
boundaryDefaults = { ...boundaryDefaults, ...options };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Reset to built-in defaults. For tests. */
|
|
56
|
+
export function resetWriteBoundary(): void {
|
|
57
|
+
boundaryDefaults = {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** macOS and Windows are case-insensitive; comparing case-sensitively there lets `/ETC/passwd` slip past. */
|
|
61
|
+
const CASE_INSENSITIVE = platform() === "darwin" || platform() === "win32";
|
|
62
|
+
|
|
63
|
+
function normalizeForCompare(p: string): string {
|
|
64
|
+
return CASE_INSENSITIVE ? p.toLowerCase() : p;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when `child` is `parent` or lives beneath it. Segment-aware: `/foo/bar-baz` is not inside `/foo/bar`. */
|
|
68
|
+
function isInside(child: string, parent: string): boolean {
|
|
69
|
+
const c = normalizeForCompare(child);
|
|
70
|
+
const p = normalizeForCompare(parent);
|
|
71
|
+
if (c === p) return true;
|
|
72
|
+
const rel = relative(p, c);
|
|
73
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Directories and files never writable, regardless of `--allow-outside-root`
|
|
78
|
+
* or `allowedRoots`. A structured editor has no legitimate reason to touch
|
|
79
|
+
* credentials, agent configuration, or system config.
|
|
80
|
+
*/
|
|
81
|
+
function hardDenyTargets(): { dirs: string[]; files: string[] } {
|
|
82
|
+
const home = homedir();
|
|
83
|
+
return {
|
|
84
|
+
dirs: [
|
|
85
|
+
join(home, ".ssh"),
|
|
86
|
+
join(home, ".aws"),
|
|
87
|
+
join(home, ".gnupg"),
|
|
88
|
+
join(home, ".claude"),
|
|
89
|
+
join(home, ".config", "hashpilot"),
|
|
90
|
+
join(home, ".agentic-tools"),
|
|
91
|
+
"/etc",
|
|
92
|
+
],
|
|
93
|
+
files: [
|
|
94
|
+
".bashrc", ".bash_profile", ".bash_login", ".profile",
|
|
95
|
+
".zshrc", ".zshenv", ".zprofile", ".zlogin",
|
|
96
|
+
".netrc", ".npmrc", ".gitconfig",
|
|
97
|
+
].map((f) => join(home, f)),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve a path to absolute, following symlinks on the longest existing
|
|
103
|
+
* ancestor. Resolving the *parent* rather than the target itself matters: the
|
|
104
|
+
* target may not exist yet (a new file), and a symlinked parent directory is
|
|
105
|
+
* the classic escape vector.
|
|
106
|
+
*/
|
|
107
|
+
function resolveThroughSymlinks(target: string): string {
|
|
108
|
+
const abs = resolve(target);
|
|
109
|
+
let existing = abs;
|
|
110
|
+
const trailing: string[] = [];
|
|
111
|
+
|
|
112
|
+
while (!existsSync(existing)) {
|
|
113
|
+
const parent = dirname(existing);
|
|
114
|
+
if (parent === existing) return abs; // hit the filesystem root; nothing to resolve
|
|
115
|
+
trailing.unshift(existing.slice(parent.length + 1));
|
|
116
|
+
existing = parent;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
return trailing.length ? join(realpathSync(existing), ...trailing) : realpathSync(existing);
|
|
121
|
+
} catch {
|
|
122
|
+
return abs;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Locate the project root: the nearest ancestor of `cwd` containing `.git`,
|
|
128
|
+
* falling back to `cwd` itself for non-repo usage.
|
|
129
|
+
*/
|
|
130
|
+
export function findProjectRoot(cwd: string = process.cwd()): string {
|
|
131
|
+
let dir = resolveThroughSymlinks(cwd);
|
|
132
|
+
while (true) {
|
|
133
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
134
|
+
const parent = dirname(dir);
|
|
135
|
+
if (parent === dir) break;
|
|
136
|
+
dir = parent;
|
|
137
|
+
}
|
|
138
|
+
return resolveThroughSymlinks(cwd);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate that `target` may be written, and return its resolved absolute path.
|
|
143
|
+
* Callers must write to the returned path, not the input — the returned value
|
|
144
|
+
* is the symlink-resolved location that was actually checked.
|
|
145
|
+
*
|
|
146
|
+
* @throws {PathDeniedError} if the target is on the hard-deny list, or escapes
|
|
147
|
+
* the project root without `allowOutsideRoot`.
|
|
148
|
+
*/
|
|
149
|
+
export function assertWritable(target: string, options: AssertWritableOptions = {}): string {
|
|
150
|
+
const { cwd = process.cwd(), allowedRoots = [], allowOutsideRoot = false, quiet = false } = {
|
|
151
|
+
...boundaryDefaults,
|
|
152
|
+
...options,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (!target || target.trim() === "") {
|
|
156
|
+
throw new PathDeniedError(String(target), "empty path");
|
|
157
|
+
}
|
|
158
|
+
if (target.includes("\0")) {
|
|
159
|
+
throw new PathDeniedError(target, "path contains a null byte");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const resolved = resolveThroughSymlinks(target);
|
|
163
|
+
|
|
164
|
+
// Hard-deny first: unconditional, and not bypassable by any flag or config.
|
|
165
|
+
const { dirs, files } = hardDenyTargets();
|
|
166
|
+
for (const rawDir of dirs) {
|
|
167
|
+
// Resolve the deny target too: on macOS `/etc` is a symlink to
|
|
168
|
+
// `/private/etc`, so comparing against the literal path misses everything.
|
|
169
|
+
const dir = resolveThroughSymlinks(rawDir);
|
|
170
|
+
if (isInside(resolved, dir)) {
|
|
171
|
+
throw new PathDeniedError(resolved, `${dir} is never writable by HashPilot`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const rawFile of files) {
|
|
175
|
+
const file = resolveThroughSymlinks(rawFile);
|
|
176
|
+
if (normalizeForCompare(resolved) === normalizeForCompare(file)) {
|
|
177
|
+
throw new PathDeniedError(resolved, "shell and tool configuration files are never writable by HashPilot");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (allowOutsideRoot) {
|
|
182
|
+
if (!quiet) {
|
|
183
|
+
console.error(
|
|
184
|
+
`WARNING: --allow-outside-root is set; writing outside the project root to ${resolved}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return resolved;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const roots = [findProjectRoot(cwd), ...allowedRoots.map((r) => resolveThroughSymlinks(resolve(cwd, r)))];
|
|
191
|
+
if (roots.some((root) => isInside(resolved, root))) return resolved;
|
|
192
|
+
|
|
193
|
+
throw new PathDeniedError(
|
|
194
|
+
resolved,
|
|
195
|
+
`outside the project root (${roots[0]}). Pass --allow-outside-root or add the location to "allowedRoots" in .hashpilot.json`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Batch form. Reports every rejection at once rather than failing on the first. */
|
|
200
|
+
export function assertAllWritable(targets: string[], options: AssertWritableOptions = {}): string[] {
|
|
201
|
+
const resolved: string[] = [];
|
|
202
|
+
const denied: string[] = [];
|
|
203
|
+
for (const t of targets) {
|
|
204
|
+
try {
|
|
205
|
+
resolved.push(assertWritable(t, options));
|
|
206
|
+
} catch (err) {
|
|
207
|
+
denied.push(err instanceof PathDeniedError ? err.message : String(err));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (denied.length) throw new PathDeniedError(targets.join(", "), denied.join("; "));
|
|
211
|
+
return resolved;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The only write primitive in the codebase. Validates the boundary, then writes
|
|
216
|
+
* to the *resolved* path. Call this instead of `Bun.write` for any file the
|
|
217
|
+
* user or an agent supplied the path for.
|
|
218
|
+
*
|
|
219
|
+
* @throws {PathDeniedError} if the target fails the boundary check.
|
|
220
|
+
*/
|
|
221
|
+
export async function safeWrite(
|
|
222
|
+
target: string,
|
|
223
|
+
content: string,
|
|
224
|
+
options: AssertWritableOptions = {},
|
|
225
|
+
): Promise<string> {
|
|
226
|
+
const resolved = assertWritable(target, options);
|
|
227
|
+
// Encode before snapshotting: the snapshot records the bytes that actually
|
|
228
|
+
// land on disk, and undo verifies against them (#30).
|
|
229
|
+
const encoded = restoreEncoding(resolved, content);
|
|
230
|
+
recordSnapshot(resolved, encoded);
|
|
231
|
+
atomicWrite(resolved, encoded);
|
|
232
|
+
return resolved;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Put the target file's byte layout back on the content about to replace it
|
|
237
|
+
* (#30).
|
|
238
|
+
*
|
|
239
|
+
* The editing tiers work on plain-LF text with no BOM, which is what makes
|
|
240
|
+
* line splitting, hashing, and AST offsets tractable. That normalization is
|
|
241
|
+
* only safe if the bytes are restored on the way out — otherwise a one-line
|
|
242
|
+
* edit in a CRLF repo rewrites every line ending in the file, and a BOM or a
|
|
243
|
+
* trailing newline disappears.
|
|
244
|
+
*
|
|
245
|
+
* The incoming content is decoded first, so this is correct whether the caller
|
|
246
|
+
* handed us normalized text or text that still carries the file's own endings,
|
|
247
|
+
* and applying it twice changes nothing.
|
|
248
|
+
*/
|
|
249
|
+
function restoreEncoding(resolved: string, content: string): string {
|
|
250
|
+
const { text } = decodeText(content);
|
|
251
|
+
let encoding;
|
|
252
|
+
try {
|
|
253
|
+
// A file that does not exist yet has no layout to preserve; the content's
|
|
254
|
+
// own is the only evidence available.
|
|
255
|
+
encoding = existsSync(resolved)
|
|
256
|
+
? decodeText(readFileSync(resolved, "utf8")).encoding
|
|
257
|
+
: decodeText(content).encoding;
|
|
258
|
+
} catch {
|
|
259
|
+
return content;
|
|
260
|
+
}
|
|
261
|
+
return encodeText(text, encoding);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Injectable failure point, so a test can simulate a crash mid-write. */
|
|
265
|
+
let crashAfterTempWrite = false;
|
|
266
|
+
|
|
267
|
+
/** Make the next atomic write throw between the temp write and the rename. For tests. */
|
|
268
|
+
export function simulateCrashAfterTempWrite(value: boolean): void {
|
|
269
|
+
crashAfterTempWrite = value;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Replace `target`'s contents without ever exposing a partial file.
|
|
274
|
+
*
|
|
275
|
+
* A bare truncating write that is interrupted — SIGINT, a crash, a full disk —
|
|
276
|
+
* leaves the source file permanently truncated and the original content gone
|
|
277
|
+
* (#12). Writing to a sibling temp file and renaming over the target avoids
|
|
278
|
+
* that: `rename(2)` within a filesystem is atomic, so a concurrent reader sees
|
|
279
|
+
* either the whole old file or the whole new one.
|
|
280
|
+
*
|
|
281
|
+
* The temp file must live in the target's own directory. In `/tmp` the rename
|
|
282
|
+
* would usually cross a device boundary and degrade into a copy, which is
|
|
283
|
+
* exactly the non-atomic write this replaces.
|
|
284
|
+
*/
|
|
285
|
+
export function atomicWrite(resolved: string, content: string): void {
|
|
286
|
+
content = restoreEncoding(resolved, content);
|
|
287
|
+
const dir = dirname(resolved);
|
|
288
|
+
const tmp = join(dir, `.hashpilot-tmp-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
289
|
+
|
|
290
|
+
// Carry the target's permissions onto the replacement, or every edit
|
|
291
|
+
// silently resets the file to the default mode.
|
|
292
|
+
let mode = 0o644;
|
|
293
|
+
try {
|
|
294
|
+
if (existsSync(resolved)) mode = statSync(resolved).mode & 0o777;
|
|
295
|
+
} catch { /* unreadable target: fall back to the default mode */ }
|
|
296
|
+
|
|
297
|
+
let fd: number | undefined;
|
|
298
|
+
try {
|
|
299
|
+
writeFileSync(tmp, content, { mode });
|
|
300
|
+
// fsync the data before the rename; otherwise a power loss can land the
|
|
301
|
+
// rename in the journal while the file's blocks are still unwritten.
|
|
302
|
+
fd = openSync(tmp, "r+");
|
|
303
|
+
fsyncSync(fd);
|
|
304
|
+
closeSync(fd);
|
|
305
|
+
fd = undefined;
|
|
306
|
+
|
|
307
|
+
if (crashAfterTempWrite) throw new Error("simulated crash after temp write");
|
|
308
|
+
|
|
309
|
+
renameSync(tmp, resolved);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
312
|
+
try { unlinkSync(tmp); } catch { /* nothing to clean up */ }
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// fsync the directory so the rename itself is durable.
|
|
317
|
+
try {
|
|
318
|
+
const dirFd = openSync(dir, "r");
|
|
319
|
+
fsyncSync(dirFd);
|
|
320
|
+
closeSync(dirFd);
|
|
321
|
+
} catch { /* not supported on every platform; the rename still applied */ }
|
|
322
|
+
|
|
323
|
+
cleanOrphanTempFiles(dir);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export const PATH_SEPARATOR = sep;
|