@davideasden/pi-undo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/extensions/pi-undo.ts +133 -0
- package/package.json +54 -0
- package/src/atomic-fs.ts +156 -0
- package/src/controller.ts +598 -0
- package/src/encoding.ts +620 -0
- package/src/git-runner.ts +308 -0
- package/src/journal.ts +297 -0
- package/src/model.ts +160 -0
- package/src/mutation-journal.ts +229 -0
- package/src/path-safety.ts +121 -0
- package/src/pi-runtime.ts +415 -0
- package/src/quarantine.ts +591 -0
- package/src/recovery.ts +143 -0
- package/src/restore-engine.ts +1184 -0
- package/src/root-discovery.ts +388 -0
- package/src/session-state.ts +448 -0
- package/src/snapshot-store.ts +1279 -0
- package/src/status-reporter.ts +80 -0
- package/src/workspace-lock.ts +407 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DavidEasden
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# pi-undo
|
|
2
|
+
|
|
3
|
+
`pi-undo` 为 Pi 0.80.10 提供持久化的 `/undo` 和 `/redo`。一次操作对应一个完整 Agent run,并同时恢复 Pi session tree 的逻辑位置和工作区文件。
|
|
4
|
+
|
|
5
|
+
实现使用 Pi session JSONL、私有 snapshot store 和 WAL journal。它不会在项目中创建 `.git`,也不会要求用户执行 `git commit`、`git stash`、`git reset` 或维护隐藏分支。
|
|
6
|
+
|
|
7
|
+
## 安装与本地试用
|
|
8
|
+
|
|
9
|
+
安装本地 package:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install ./path/to/pi-undo
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
开发时也可以直接加载 extension:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
要求 Node.js 22.19 或更高版本,并且系统可执行 `git`。
|
|
22
|
+
|
|
23
|
+
## 使用方式
|
|
24
|
+
|
|
25
|
+
- `/undo`:回退当前 branch 上最后一个完整 Agent run。执行前会保存当前工作区作为 redo safety snapshot。
|
|
26
|
+
- `/redo`:恢复最近一次 undo 前保存的工作区状态,而不是简单覆盖成 checkpoint 的原始 after snapshot。
|
|
27
|
+
- `/tree`:仍使用 Pi 原生命令。package 在导航前预检目标 boundary 并保存 rescue snapshot,导航完成后恢复该 boundary 对应的文件状态。
|
|
28
|
+
|
|
29
|
+
如果 Agent 正在 streaming,`/undo` 和 `/redo` 会先请求 abort,再等待 Agent idle;等待超时或工具仍未停止时不会恢复文件。streaming 中的普通 `/tree` 会被取消,用户可在 idle 后重试。
|
|
30
|
+
|
|
31
|
+
undo 成功后,TUI 编辑器为空时会尝试回填原始 prompt;编辑器已有文字时不会覆盖用户输入。RPC 只报告回填请求,print/json 模式不承诺编辑器回填。
|
|
32
|
+
|
|
33
|
+
## 数据与 Git 边界
|
|
34
|
+
|
|
35
|
+
私有数据位于 Pi session 目录下:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
<sessionDir>/.pi-undo/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
snapshot 使用独立 Git object database 和临时 index,但不会写入用户仓库的 `HEAD`、index、refs、reflog、stash、config 或其他真实 Git metadata。
|
|
42
|
+
|
|
43
|
+
restore 对普通文件和 symlink 使用原路径同目录、同文件系统的 quarantine。目标安装采用 no-clobber 语义:外部进程在 restore 期间重建路径时,`pi-undo` 不会覆盖该路径,并会保留 transaction mutation WAL 与仍可验证的 source/target artifact,供重启恢复或人工诊断。
|
|
44
|
+
|
|
45
|
+
普通文件的 source capture 先创建 hard link,再在删除原路径前重新检查路径 fingerprint 和 inode identity。这是纯 Node.js 能提供的 CAS-before-unlink best-effort;它显著缩小并发窗口,但不能原子合并最后一次检查与 `unlink`。
|
|
46
|
+
|
|
47
|
+
普通目录、outer repository、nested repository 和已初始化 submodule 都按 root forest 独立捕获和恢复。nested repository/submodule 只恢复内部工作区文件:
|
|
48
|
+
|
|
49
|
+
- 不切换真实 HEAD;
|
|
50
|
+
- 不运行 `git submodule update`;
|
|
51
|
+
- 不修改真实 index、refs、reflog 或 `.git`;
|
|
52
|
+
- 不重建被删除的真实 `.git`。
|
|
53
|
+
|
|
54
|
+
## 持久化与故障恢复
|
|
55
|
+
|
|
56
|
+
每次 undo、redo 或 tree restore 都先写 WAL journal。Pi 重启后:
|
|
57
|
+
|
|
58
|
+
- 没有可信 cursor marker:恢复 rollback manifest,并把操作终止为 `ABORTED`;
|
|
59
|
+
- 有可信 cursor marker:重新恢复 target manifest、补齐 cursor durability,并把操作完成为 `COMMITTED`;
|
|
60
|
+
- journal、session identity、logical leaf、manifest 或 cursor payload 不一致:进入 `recovery required`,停止新的 undo/redo、tree mutation 和 Agent boundary。
|
|
61
|
+
|
|
62
|
+
故障 journal 会保留在 `<sessionDir>/.pi-undo/transactions/`,用于诊断和人工恢复。不要在未备份的情况下删除该目录。footer 显示 `recovery required` 时,应先保存工作区和 session JSONL,再检查对应 transaction 的 `descriptor.json`、`restore-plan.json` 和 `state.json`。
|
|
63
|
+
|
|
64
|
+
## 明确限制
|
|
65
|
+
|
|
66
|
+
- Git ignored 文件不进入 snapshot,也不会被 restore 创建或删除。
|
|
67
|
+
- 空目录不属于 snapshot。
|
|
68
|
+
- package 不恢复真实 `.git` metadata。
|
|
69
|
+
- package 锁只能协调其他 `pi-undo` 实例,不能阻止外部编辑器、watcher 或其他进程同时写文件;无法证明安全时会 fail closed。
|
|
70
|
+
- quarantine 不能消除任意外部进程带来的最终 `check -> unlink` TOCTOU 窗口。无法证明 original、target 或 artifact 的内容与归属时,package 会保留现场并进入 `recovery required`,不会猜测、强制删除或覆盖未知内容。
|
|
71
|
+
- `--no-session` 模式没有可耐久验证的 session cursor,只提供进程内能力,不能承诺进程崩溃后的 undo/redo 持久化。
|
|
72
|
+
- uninitialized gitlink 不会被自动初始化;broken nested root 会使 capture 失败,而不是静默遗漏。
|
|
73
|
+
- package 不对工作区文件与 Pi JSONL 提供操作系统级 ACID 事务;它通过 snapshot、WAL、cursor marker 和幂等 set-state recovery 收敛到旧状态或新状态。
|
|
74
|
+
|
|
75
|
+
## 开发验证
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm test
|
|
79
|
+
npm run test:integration
|
|
80
|
+
npm run typecheck
|
|
81
|
+
npm run pack:dry-run
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
真实 Pi `AgentSession` 集成测试需要先安装并构建 `resources/pi-0.80.10` 的 workspace packages。
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
InputEvent,
|
|
6
|
+
SessionBeforeTreeEvent as PiSessionBeforeTreeEvent,
|
|
7
|
+
SessionTreeEvent as PiSessionTreeEvent,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
import type { UndoController } from "../src/controller.ts";
|
|
11
|
+
import { createPiUndoRuntime } from "../src/pi-runtime.ts";
|
|
12
|
+
import { StatusReporter } from "../src/status-reporter.ts";
|
|
13
|
+
|
|
14
|
+
export interface PiUndoRuntime {
|
|
15
|
+
readonly controller: UndoController;
|
|
16
|
+
readonly reporter: StatusReporter;
|
|
17
|
+
readonly recovery?: { readonly files?: number; readonly opId?: string };
|
|
18
|
+
setCommandContext?(context: ExtensionCommandContext | undefined): void;
|
|
19
|
+
isInternalNavigation?(): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type PiUndoRuntimeFactory = (
|
|
23
|
+
context: ExtensionContext,
|
|
24
|
+
pi: ExtensionAPI,
|
|
25
|
+
) => Promise<PiUndoRuntime>;
|
|
26
|
+
|
|
27
|
+
export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi: ExtensionAPI) => void {
|
|
28
|
+
return (pi) => {
|
|
29
|
+
let runtime: PiUndoRuntime | undefined;
|
|
30
|
+
let generation = 0;
|
|
31
|
+
|
|
32
|
+
const initialize = async (context: ExtensionContext): Promise<void> => {
|
|
33
|
+
const currentGeneration = ++generation;
|
|
34
|
+
try {
|
|
35
|
+
const next = await runtimeFactory(context, pi);
|
|
36
|
+
if (currentGeneration !== generation) return;
|
|
37
|
+
runtime = next;
|
|
38
|
+
await next.controller.recover();
|
|
39
|
+
const history = next.controller.history();
|
|
40
|
+
if (history.locked) next.reporter.setRecoveryRequired("pending journal", next.recovery);
|
|
41
|
+
else next.reporter.setReady(history.undoCount, history.redoCount);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (currentGeneration !== generation) return;
|
|
44
|
+
runtime = undefined;
|
|
45
|
+
new StatusReporter(context).setRecoveryRequired(errorMessage(error));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const runCommand = async (
|
|
50
|
+
action: "undo" | "redo",
|
|
51
|
+
context: ExtensionCommandContext,
|
|
52
|
+
): Promise<void> => {
|
|
53
|
+
const active = runtime;
|
|
54
|
+
const commandGeneration = generation;
|
|
55
|
+
if (active === undefined) {
|
|
56
|
+
context.ui.notify("pi-undo session unavailable", "warning");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
|
|
60
|
+
active.setCommandContext?.(context);
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = action === "undo"
|
|
64
|
+
? await active.controller.undo()
|
|
65
|
+
: await active.controller.redo();
|
|
66
|
+
} finally {
|
|
67
|
+
active.setCommandContext?.(undefined);
|
|
68
|
+
}
|
|
69
|
+
if (commandGeneration !== generation || runtime !== active) return;
|
|
70
|
+
active.reporter.result(result);
|
|
71
|
+
if (action === "undo" && result.code === "ok" && result.refillPrompt !== undefined) {
|
|
72
|
+
active.reporter.refillPrompt(result.refillPrompt);
|
|
73
|
+
}
|
|
74
|
+
const history = active.controller.history();
|
|
75
|
+
if (history.locked) active.reporter.setRecoveryRequired(result.message ?? result.code);
|
|
76
|
+
else active.reporter.setReady(history.undoCount, history.redoCount);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
pi.registerCommand("undo", {
|
|
80
|
+
description: "Undo the last completed Agent run",
|
|
81
|
+
handler: async (_args: string, context: ExtensionCommandContext) => runCommand("undo", context),
|
|
82
|
+
});
|
|
83
|
+
pi.registerCommand("redo", {
|
|
84
|
+
description: "Redo the last undone Agent run",
|
|
85
|
+
handler: async (_args: string, context: ExtensionCommandContext) => runCommand("redo", context),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
|
|
89
|
+
pi.on("input", async (event: InputEvent) => {
|
|
90
|
+
if (runtime === undefined) return { action: "handled" as const };
|
|
91
|
+
return runtime.controller.prepareInput(event.text, { streaming: event.streamingBehavior !== undefined });
|
|
92
|
+
});
|
|
93
|
+
pi.on("before_agent_start", async () => { await runtime?.controller.beforeAgentStart(); });
|
|
94
|
+
pi.on("agent_settled", async () => {
|
|
95
|
+
const active = runtime;
|
|
96
|
+
if (active === undefined) return;
|
|
97
|
+
await active.controller.agentSettled();
|
|
98
|
+
if (runtime !== active) return;
|
|
99
|
+
const history = active.controller.history();
|
|
100
|
+
if (history.locked) active.reporter.setRecoveryRequired("session state ambiguous");
|
|
101
|
+
else active.reporter.setReady(history.undoCount, history.redoCount);
|
|
102
|
+
});
|
|
103
|
+
pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent) => {
|
|
104
|
+
if (runtime === undefined) return { cancel: true };
|
|
105
|
+
if (runtime.isInternalNavigation?.()) return undefined;
|
|
106
|
+
const active = runtime;
|
|
107
|
+
const result = await active.controller.beforeTree({ targetLeafId: event.preparation.targetId });
|
|
108
|
+
if (result === undefined) {
|
|
109
|
+
event.signal?.addEventListener("abort", () => { void active.controller.cancelTree?.(); }, { once: true });
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
});
|
|
113
|
+
pi.on("session_tree", async (event: PiSessionTreeEvent) => {
|
|
114
|
+
if (runtime?.isInternalNavigation?.()) return;
|
|
115
|
+
await runtime?.controller.afterTree({
|
|
116
|
+
newLeafId: event.newLeafId,
|
|
117
|
+
navigationTargetLeafId: event.summaryEntry?.parentId ?? event.newLeafId,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
pi.on("session_shutdown", async () => {
|
|
121
|
+
generation += 1;
|
|
122
|
+
await runtime?.controller.cancelTree?.();
|
|
123
|
+
runtime?.reporter.clear();
|
|
124
|
+
runtime = undefined;
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function errorMessage(error: unknown): string {
|
|
130
|
+
return error instanceof Error ? error.message : String(error);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default createPiUndoExtension(createPiUndoRuntime);
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@davideasden/pi-undo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Persistent workspace undo and redo for Pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/DavidEasden/pi-undo.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/DavidEasden/pi-undo#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/DavidEasden/pi-undo/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"registry": "https://registry.npmjs.org/"
|
|
20
|
+
},
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./extensions/pi-undo.ts"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"extensions",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"test:integration": "vitest run test/extension.integration.test.ts test/pi-runtime.test.ts",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "^0.80.10"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"proper-lockfile": "4.1.2"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "file:resources/pi-0.80.10/packages/coding-agent",
|
|
46
|
+
"@types/node": "24.12.4",
|
|
47
|
+
"@types/proper-lockfile": "4.1.4",
|
|
48
|
+
"typescript": "5.9.3",
|
|
49
|
+
"vitest": "4.1.9"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22.19.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/atomic-fs.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { link, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import type { FileHandle } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { canonicalJson } from "./encoding.ts";
|
|
7
|
+
|
|
8
|
+
export type AtomicFileErrorCode = "content_mismatch" | "invalid_mode";
|
|
9
|
+
|
|
10
|
+
export class AtomicFileError extends Error {
|
|
11
|
+
readonly code: AtomicFileErrorCode;
|
|
12
|
+
|
|
13
|
+
constructor(code: AtomicFileErrorCode, message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "AtomicFileError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
|
|
21
|
+
await writeBytesAtomic(file, Buffer.from(canonicalJson(value), "utf8"));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function writeBytesAtomic(file: string, bytes: Uint8Array, mode?: number): Promise<void> {
|
|
25
|
+
const directory = dirname(file);
|
|
26
|
+
await mkdir(directory, { recursive: true });
|
|
27
|
+
const fileMode = await targetMode(file, mode);
|
|
28
|
+
const temporary = await writeTemporary(directory, basename(file), bytes, fileMode);
|
|
29
|
+
let published = false;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await rename(temporary, file);
|
|
33
|
+
published = true;
|
|
34
|
+
await fsyncDirectory(directory);
|
|
35
|
+
} finally {
|
|
36
|
+
if (!published) {
|
|
37
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function writeContentAddressed(file: string, bytes: Uint8Array, mode?: number): Promise<void> {
|
|
43
|
+
const expected = Buffer.from(bytes);
|
|
44
|
+
try {
|
|
45
|
+
const existing = await readFile(file);
|
|
46
|
+
if (!existing.equals(expected)) {
|
|
47
|
+
throw new AtomicFileError("content_mismatch", "内容寻址文件已存在但内容不同");
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!hasErrorCode(error, "ENOENT")) {
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const directory = dirname(file);
|
|
57
|
+
await mkdir(directory, { recursive: true });
|
|
58
|
+
const temporary = await writeTemporary(directory, basename(file), expected, await targetMode(file, mode));
|
|
59
|
+
let linked = false;
|
|
60
|
+
try {
|
|
61
|
+
await link(temporary, file);
|
|
62
|
+
linked = true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!hasErrorCode(error, "EEXIST")) {
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const published = await readFile(file);
|
|
68
|
+
if (!published.equals(expected)) {
|
|
69
|
+
throw new AtomicFileError("content_mismatch", "内容寻址文件已存在但内容不同");
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (linked) {
|
|
76
|
+
await fsyncDirectory(directory);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function fsyncFile(file: string): Promise<void> {
|
|
81
|
+
const handle = await open(file, "r");
|
|
82
|
+
try {
|
|
83
|
+
await handle.sync();
|
|
84
|
+
} finally {
|
|
85
|
+
await handle.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function fsyncDirectory(directory: string): Promise<void> {
|
|
90
|
+
const handle = await open(directory, "r");
|
|
91
|
+
try {
|
|
92
|
+
await handle.sync();
|
|
93
|
+
} finally {
|
|
94
|
+
await handle.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function writeBytesExclusive(file: string, bytes: Uint8Array, mode: number): Promise<void> {
|
|
99
|
+
const handle = await open(file, "wx", mode);
|
|
100
|
+
try {
|
|
101
|
+
await handle.writeFile(Buffer.from(bytes));
|
|
102
|
+
await handle.chmod(mode);
|
|
103
|
+
await handle.sync();
|
|
104
|
+
} finally {
|
|
105
|
+
await handle.close();
|
|
106
|
+
}
|
|
107
|
+
await fsyncDirectory(dirname(file));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function targetMode(file: string, mode: number | undefined): Promise<number> {
|
|
111
|
+
if (mode !== undefined) {
|
|
112
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o7777) {
|
|
113
|
+
throw new AtomicFileError("invalid_mode", "文件 mode 无效");
|
|
114
|
+
}
|
|
115
|
+
return mode;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
return (await stat(file)).mode & 0o7777;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
122
|
+
return 0o600;
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function writeTemporary(
|
|
129
|
+
directory: string,
|
|
130
|
+
fileName: string,
|
|
131
|
+
bytes: Uint8Array,
|
|
132
|
+
mode: number,
|
|
133
|
+
): Promise<string> {
|
|
134
|
+
const temporary = join(
|
|
135
|
+
directory,
|
|
136
|
+
`.${fileName}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
|
|
137
|
+
);
|
|
138
|
+
let handle: FileHandle | undefined;
|
|
139
|
+
try {
|
|
140
|
+
handle = await open(temporary, "wx", mode);
|
|
141
|
+
await handle.writeFile(Buffer.from(bytes));
|
|
142
|
+
await handle.chmod(mode);
|
|
143
|
+
await handle.sync();
|
|
144
|
+
await handle.close();
|
|
145
|
+
handle = undefined;
|
|
146
|
+
return temporary;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
await handle?.close().catch(() => {});
|
|
149
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
155
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
156
|
+
}
|