dsh-config-manager 0.1.78 → 0.1.80
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/lib/adapters/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +401 -112
- package/lib/client.js +2899 -1525
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +9 -0
- package/lib/ui/i18n.js +22 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/market/market-api.test.ts +3 -2
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +220 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +248 -0
- package/src/client/sync/merge-view.ts +398 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.test.ts +5 -3
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/ancestor.ts +76 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +22 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** 索引文件名:JSONL,一行一条记录 */
|
|
2
|
+
export declare const UNDO_JOURNAL_FILE_NAME = "journal.jsonl";
|
|
3
|
+
/** 完成标记文件名(原子写入);存在 = 这次回退的前像已**完整**落盘 */
|
|
4
|
+
export declare const UNDO_DONE_FILE_NAME = "done";
|
|
5
|
+
/** journal 里一条记录:索引(path/after 镜像)+ 前像引用(bin/bytes/sha256) */
|
|
6
|
+
export interface UndoJournalEntry {
|
|
7
|
+
/** 本进程内的记录序号(0 起,一次 record 一条;bin 名 = `<seq>.bin`) */
|
|
8
|
+
seq: number;
|
|
9
|
+
/** 回退动作作用的**绝对**路径 */
|
|
10
|
+
path: string;
|
|
11
|
+
/** 前像文件名(相对 undo 目录);null = 回退前该路径不存在 */
|
|
12
|
+
bin: string | null;
|
|
13
|
+
/** 前像字节数 */
|
|
14
|
+
bytes: number;
|
|
15
|
+
/** 前像 sha256;bin === null 时恒 null */
|
|
16
|
+
sha256: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* **回退动作写入**的内容 sha256(删除动作为 null)。
|
|
19
|
+
* 重放的唯一安全判据:当前内容 sha256 === 该值才敢写回。
|
|
20
|
+
*/
|
|
21
|
+
afterSha256: string | null;
|
|
22
|
+
/** true = 该路径被回退动作删除(重放要求「当前仍不存在」) */
|
|
23
|
+
afterDeleted: boolean;
|
|
24
|
+
/** 记录时间戳(注入的 now()) */
|
|
25
|
+
at: number;
|
|
26
|
+
}
|
|
27
|
+
export interface UndoJournalSnapshot {
|
|
28
|
+
entries: UndoJournalEntry[];
|
|
29
|
+
/** true = 文件最后一段是崩溃写了一半的行(已丢弃,绝不因此抛错) */
|
|
30
|
+
truncatedTail: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface InterruptedUndo {
|
|
33
|
+
dir: string;
|
|
34
|
+
pid: number;
|
|
35
|
+
entries: UndoJournalEntry[];
|
|
36
|
+
truncatedTail: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** 回退动作的 after 镜像:写入了某内容 / 删除了该路径 */
|
|
39
|
+
export type UndoAfter = {
|
|
40
|
+
sha256: string;
|
|
41
|
+
} | {
|
|
42
|
+
deleted: true;
|
|
43
|
+
};
|
|
44
|
+
export interface ReplayHooks {
|
|
45
|
+
/** 读当前内容;返回 null = 该路径当前不存在(缺省用 fs.readFile,ENOENT 视为 null) */
|
|
46
|
+
readCurrent?: (target: string) => Promise<Uint8Array | null>;
|
|
47
|
+
/** 写回前像 */
|
|
48
|
+
write: (target: string, bytes: Uint8Array) => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
export interface UndoJournalWriterOptions {
|
|
51
|
+
/** 时间戳来源(测试可注入;缺省 Date.now) */
|
|
52
|
+
now?: () => number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 前像 journal 写入端。
|
|
56
|
+
*
|
|
57
|
+
* 生命周期:`create()` → 逐条 `record()` →(全部落盘)`finish()` 写完成标记 → `dispose()` 关句柄。
|
|
58
|
+
* 中断(未 finish)留在盘上的目录就是崩溃证据;调用方用 `findInterruptedUndos` + `replayUndoJournal` 收尾。
|
|
59
|
+
*/
|
|
60
|
+
export declare class UndoJournalWriter {
|
|
61
|
+
/** 前像目录(`<root>/undo/<pid>-<ts36>-<8hex>`) */
|
|
62
|
+
readonly dir: string;
|
|
63
|
+
private readonly journalPath;
|
|
64
|
+
private readonly now;
|
|
65
|
+
private handle;
|
|
66
|
+
private seq;
|
|
67
|
+
/** 已成功 fsync 的 journal 字节数:写失败时回截到这里,保证不留半行 */
|
|
68
|
+
private committedBytes;
|
|
69
|
+
private disposed;
|
|
70
|
+
private constructor();
|
|
71
|
+
/**
|
|
72
|
+
* 打开(或续写)一个前像目录。目录建不出来 / journal 打不开 ⇒ 抛错,调用方必须**零写入失败**。
|
|
73
|
+
*/
|
|
74
|
+
static create(dir: string, options?: UndoJournalWriterOptions): Promise<UndoJournalWriter>;
|
|
75
|
+
/**
|
|
76
|
+
* 记一条:先落前像 bin、再 append 索引行并 fsync。
|
|
77
|
+
*
|
|
78
|
+
* @param before 前像字节;null = 回退前该路径**不存在**
|
|
79
|
+
* @param after 回退动作的 after 镜像(写入了什么 / 删除了)
|
|
80
|
+
*/
|
|
81
|
+
record(targetPath: string, before: Uint8Array | null, after: UndoAfter): Promise<void>;
|
|
82
|
+
/** 条目全部落盘后写 done 标记(原子 rename):标记存在 = 这份前像完整,崩溃清理不得当残留删掉。 */
|
|
83
|
+
finish(): Promise<void>;
|
|
84
|
+
/** 关句柄(幂等 + best-effort);**不删目录** —— 删不删由调用方按 done/重放结果决定。 */
|
|
85
|
+
dispose(): Promise<void>;
|
|
86
|
+
private requireHandle;
|
|
87
|
+
/** 回截到 length(best-effort):只用于「append + fsync 失败」后的自愈,绝不掩盖原始写错误。 */
|
|
88
|
+
private truncateTo;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 读回 journal 快照。`null` = 该目录**本来就没有** journal(ENOENT)。
|
|
92
|
+
*
|
|
93
|
+
* 崩溃容忍:文件最后一段(可能是写了一半的行)解析失败 ⇒ 丢弃它、`truncatedTail = true`,
|
|
94
|
+
* **绝不抛异常**。中间段坏掉不是 append 能造成的形状(篡改/损坏)⇒ 跳过且不算截断。
|
|
95
|
+
*/
|
|
96
|
+
export declare function readUndoJournal(dir: string): Promise<UndoJournalSnapshot | null>;
|
|
97
|
+
/**
|
|
98
|
+
* 找中断的回退前像目录。
|
|
99
|
+
*
|
|
100
|
+
* @param root 既可以是**暂存根目录**(其直接子目录就是 `<pid>-<ts36>-<8hex>`),
|
|
101
|
+
* 也可以是上层 dataRoot(此时会自动再看 `<root>/undo`);两种调用口径都支持。
|
|
102
|
+
* @param isAlive 进程存活探测(可注入,便于测试);缺省 `process.kill(pid, 0)`。
|
|
103
|
+
*
|
|
104
|
+
* 判据(严格):目录名形态匹配 + **有 journal** + **无 done** + **pid 已死**。
|
|
105
|
+
* 读不出 journal 的目录跳过(判不准就不报,绝不猜);结果按 dir 升序稳定排序。
|
|
106
|
+
*/
|
|
107
|
+
export declare function findInterruptedUndos(root: string, isAlive?: (pid: number) => boolean): Promise<InterruptedUndo[]>;
|
|
108
|
+
/**
|
|
109
|
+
* 重放一份中断的前像(**逆序**:同一路径被记两次时,最后写的是最早状态)。
|
|
110
|
+
*
|
|
111
|
+
* 安全判据(**没有证明就绝不写盘**):
|
|
112
|
+
* - `afterDeleted` ⇒ 目标当前必须不存在(`readCurrent` 返回 null),否则 skipped;
|
|
113
|
+
* - 否则 `sha256(current)` 必须 === `afterSha256`,否则 skipped;
|
|
114
|
+
* - after 信息缺失/不可判(老行/损坏行,`afterSha256 === null`)⇒ skipped。
|
|
115
|
+
*
|
|
116
|
+
* 失败一律逐条进 `failed`(绝不静默):`unsafe-path` / `unsafe-bin` / `before-checksum-mismatch` /
|
|
117
|
+
* `read-current-failed: …` / 写入或删除的真实错误。读当前态失败属**保守失败**(该条不写)。
|
|
118
|
+
*/
|
|
119
|
+
export declare function replayUndoJournal(undo: InterruptedUndo, hooks: ReplayHooks): Promise<{
|
|
120
|
+
restored: number;
|
|
121
|
+
skipped: string[];
|
|
122
|
+
failed: string[];
|
|
123
|
+
}>;
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回退前像 journal:**崩溃可检测、可证明安全重放**的前像日志。
|
|
3
|
+
*
|
|
4
|
+
* 背景(已确认缺陷):`rollback-point.ts` 的 UndoStager 把前像写成 `<dir>/N.bin`,既没有「路径 → 前像」
|
|
5
|
+
* 索引,也没有完成标记。进程崩溃后 `rollback-ledger.ts` 的 cleanupOrphanTempFiles 只按「pid 已死」
|
|
6
|
+
* 把整个 undo 目录删掉 ⇒ 工作区被留在半回退状态:**无人知晓、也无法恢复**。本模块提供修复所需的原语。
|
|
7
|
+
*
|
|
8
|
+
* 提供的原语(纯模块,不接触引擎;零运行时依赖:node: 内置 + 仓内 utils):
|
|
9
|
+
* - `UndoJournalWriter`:先落前像 bin、再 append 一行 JSON 索引并 fsync(顺序不可交换);
|
|
10
|
+
* 条目全部落盘后写 `done` 完成标记(原子 rename)。目录形态沿用既有实现
|
|
11
|
+
* `<root>/undo/<pid>-<ts36>-<8hex>/`。
|
|
12
|
+
* - `readUndoJournal`:读回快照;**尾行被截断**(崩在 append 中途)绝不抛异常,只置 `truncatedTail`。
|
|
13
|
+
* - `findInterruptedUndos`:只认「目录名形态 + 有 journal + 无 done + pid 已死」的中断残留。
|
|
14
|
+
* - `replayUndoJournal`:**逆序**写回,且只有当目标**当前内容确实等于**该条记录记录的 after 镜像时
|
|
15
|
+
* 才写(删除动作 ⇒ 当前必须不存在);否则计入 `skipped` —— 崩溃期间被用户/别的进程改过的文件
|
|
16
|
+
* **绝不覆盖**。
|
|
17
|
+
*
|
|
18
|
+
* 崩溃安全为什么成立(写入协议的排序理由):
|
|
19
|
+
* 1) 先写 bin、再记索引 ⇒ 崩在两者之间只留一个**未被任何索引引用**的 bin,重放按索引工作,不受影响;
|
|
20
|
+
* 2) 每条索引 = 一次 append + fsync ⇒ 崩在写行中途只可能伤到最后一行,前面的行逐字完整;
|
|
21
|
+
* 3) 完成后写 done ⇒「有 journal 无 done + pid 已死」就是「被中断」的信号。
|
|
22
|
+
*
|
|
23
|
+
* 所有返回给调用方的失败/跳过都是**逐条**文本 `<path>: <reason>`,绝不静默丢弃。
|
|
24
|
+
*/
|
|
25
|
+
import fs from 'node:fs/promises';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
28
|
+
import { isENOENT, isRecord } from '../utils/guards.js';
|
|
29
|
+
import { sha256Hex } from '../utils/hashing.js';
|
|
30
|
+
/* ---------------------------------------------------------------------- 常量 */
|
|
31
|
+
/** 索引文件名:JSONL,一行一条记录 */
|
|
32
|
+
export const UNDO_JOURNAL_FILE_NAME = 'journal.jsonl';
|
|
33
|
+
/** 完成标记文件名(原子写入);存在 = 这次回退的前像已**完整**落盘 */
|
|
34
|
+
export const UNDO_DONE_FILE_NAME = 'done';
|
|
35
|
+
/**
|
|
36
|
+
* 前像暂存子目录名(与 `rollback-ledger.ts` 的 `ROLLBACK_UNDO_DIR_NAME` 同值)。
|
|
37
|
+
* 刻意**不 import**:ledger 将来很可能反过来 import 本模块(清理前先查 journal),互相 import 会成环。
|
|
38
|
+
*/
|
|
39
|
+
const UNDO_SUBDIR_NAME = 'undo';
|
|
40
|
+
/**
|
|
41
|
+
* 目录名形态 `<pid>-<ts36>-<8hex>`(与 rollback-ledger.ts 的 UNDO_DIR_RE 同源,**改动必须两处同步**)。
|
|
42
|
+
* pid 必须在最前:中断判定要读它。
|
|
43
|
+
*/
|
|
44
|
+
const UNDO_DIR_RE = /^(\d+)-[0-9a-z]+-[0-9a-f]{8}$/;
|
|
45
|
+
/** 前像文件名形态:只允许 `<seq>.bin`(防篡改 journal 把读引到目录外) */
|
|
46
|
+
const UNDO_BIN_RE = /^\d+\.bin$/;
|
|
47
|
+
/** 找最后一个换行符时的反向分块大小(截断残缺尾行用;内存有界) */
|
|
48
|
+
const SCAN_CHUNK_BYTES = 64 * 1024;
|
|
49
|
+
/* --------------------------------------------------------------- 写入端 */
|
|
50
|
+
/**
|
|
51
|
+
* 前像 journal 写入端。
|
|
52
|
+
*
|
|
53
|
+
* 生命周期:`create()` → 逐条 `record()` →(全部落盘)`finish()` 写完成标记 → `dispose()` 关句柄。
|
|
54
|
+
* 中断(未 finish)留在盘上的目录就是崩溃证据;调用方用 `findInterruptedUndos` + `replayUndoJournal` 收尾。
|
|
55
|
+
*/
|
|
56
|
+
export class UndoJournalWriter {
|
|
57
|
+
/** 前像目录(`<root>/undo/<pid>-<ts36>-<8hex>`) */
|
|
58
|
+
dir;
|
|
59
|
+
journalPath;
|
|
60
|
+
now;
|
|
61
|
+
handle;
|
|
62
|
+
seq = 0;
|
|
63
|
+
/** 已成功 fsync 的 journal 字节数:写失败时回截到这里,保证不留半行 */
|
|
64
|
+
committedBytes = 0;
|
|
65
|
+
disposed = false;
|
|
66
|
+
constructor(dir, journalPath, handle, committedBytes, seq, now) {
|
|
67
|
+
this.dir = dir;
|
|
68
|
+
this.journalPath = journalPath;
|
|
69
|
+
this.handle = handle;
|
|
70
|
+
this.committedBytes = committedBytes;
|
|
71
|
+
this.seq = seq;
|
|
72
|
+
this.now = now;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 打开(或续写)一个前像目录。目录建不出来 / journal 打不开 ⇒ 抛错,调用方必须**零写入失败**。
|
|
76
|
+
*/
|
|
77
|
+
static async create(dir, options = {}) {
|
|
78
|
+
await fs.mkdir(dir, { recursive: true });
|
|
79
|
+
const journalPath = path.join(dir, UNDO_JOURNAL_FILE_NAME);
|
|
80
|
+
// 续写前先截掉上次崩溃留下的半行:否则新行会接在残句后面,把「残句 + 新行」粘成一行垃圾
|
|
81
|
+
await trimTornJournalTail(journalPath);
|
|
82
|
+
// 续写时序号必须接在已有条目之后,否则 bin 名会重号、覆盖上一条前像
|
|
83
|
+
let seq = 0;
|
|
84
|
+
const existing = await readUndoJournal(dir);
|
|
85
|
+
if (existing !== null) {
|
|
86
|
+
for (const entry of existing.entries)
|
|
87
|
+
seq = Math.max(seq, entry.seq + 1);
|
|
88
|
+
}
|
|
89
|
+
let committedBytes = 0;
|
|
90
|
+
try {
|
|
91
|
+
committedBytes = (await fs.stat(journalPath)).size;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
committedBytes = 0;
|
|
95
|
+
}
|
|
96
|
+
const handle = await fs.open(journalPath, 'a+');
|
|
97
|
+
return new UndoJournalWriter(dir, journalPath, handle, committedBytes, seq, options.now ?? Date.now);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 记一条:先落前像 bin、再 append 索引行并 fsync。
|
|
101
|
+
*
|
|
102
|
+
* @param before 前像字节;null = 回退前该路径**不存在**
|
|
103
|
+
* @param after 回退动作的 after 镜像(写入了什么 / 删除了)
|
|
104
|
+
*/
|
|
105
|
+
async record(targetPath, before, after) {
|
|
106
|
+
const handle = this.requireHandle();
|
|
107
|
+
// 路径在**记录那一刻**就必须绝对可解析:宁可当场失败,也不写入一条重放时无法安全落地的索引
|
|
108
|
+
if (!isJournalablePath(targetPath)) {
|
|
109
|
+
throw new Error('undo journal path must be absolute and non-empty: ' + JSON.stringify(targetPath));
|
|
110
|
+
}
|
|
111
|
+
const mirror = normalizeAfter(after);
|
|
112
|
+
const seq = this.seq;
|
|
113
|
+
let bin = null;
|
|
114
|
+
let bytes = 0;
|
|
115
|
+
let sha256 = null;
|
|
116
|
+
if (before !== null) {
|
|
117
|
+
bin = String(seq) + '.bin';
|
|
118
|
+
bytes = before.byteLength;
|
|
119
|
+
sha256 = sha256Hex(before);
|
|
120
|
+
// 顺序不可交换:bin 失败 ⇒ 一行索引都不写(journal 仍是此前完整的旧内容)
|
|
121
|
+
await fs.writeFile(path.join(this.dir, bin), before);
|
|
122
|
+
}
|
|
123
|
+
const line = JSON.stringify({
|
|
124
|
+
seq,
|
|
125
|
+
path: targetPath,
|
|
126
|
+
bin,
|
|
127
|
+
bytes,
|
|
128
|
+
sha256,
|
|
129
|
+
afterSha256: mirror.afterSha256,
|
|
130
|
+
afterDeleted: mirror.afterDeleted,
|
|
131
|
+
at: this.now(),
|
|
132
|
+
}) + '\n';
|
|
133
|
+
const prior = this.committedBytes;
|
|
134
|
+
try {
|
|
135
|
+
await handle.write(line);
|
|
136
|
+
await handle.sync();
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
// 写盘失败(磁盘满 / 句柄坏)⇒ 回截到写入前长度,**绝不留半行 JSON**;回截失败也不掩盖原始错误
|
|
140
|
+
await this.truncateTo(prior);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
this.committedBytes = prior + Buffer.byteLength(line, 'utf8');
|
|
144
|
+
this.seq = seq + 1;
|
|
145
|
+
}
|
|
146
|
+
/** 条目全部落盘后写 done 标记(原子 rename):标记存在 = 这份前像完整,崩溃清理不得当残留删掉。 */
|
|
147
|
+
async finish() {
|
|
148
|
+
this.requireHandle();
|
|
149
|
+
const marker = JSON.stringify({ pid: process.pid, finishedAt: this.now() }) + '\n';
|
|
150
|
+
await atomicWriteFile(path.join(this.dir, UNDO_DONE_FILE_NAME), marker);
|
|
151
|
+
}
|
|
152
|
+
/** 关句柄(幂等 + best-effort);**不删目录** —— 删不删由调用方按 done/重放结果决定。 */
|
|
153
|
+
async dispose() {
|
|
154
|
+
if (this.disposed)
|
|
155
|
+
return;
|
|
156
|
+
this.disposed = true;
|
|
157
|
+
const handle = this.handle;
|
|
158
|
+
this.handle = null;
|
|
159
|
+
if (handle !== null) {
|
|
160
|
+
try {
|
|
161
|
+
await handle.close();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* best-effort:句柄已经坏了也不该把调用方带崩 */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
requireHandle() {
|
|
169
|
+
if (this.disposed || this.handle === null) {
|
|
170
|
+
throw new Error('undo journal writer already disposed: ' + this.journalPath);
|
|
171
|
+
}
|
|
172
|
+
return this.handle;
|
|
173
|
+
}
|
|
174
|
+
/** 回截到 length(best-effort):只用于「append + fsync 失败」后的自愈,绝不掩盖原始写错误。 */
|
|
175
|
+
async truncateTo(length) {
|
|
176
|
+
const handle = this.handle;
|
|
177
|
+
if (handle === null)
|
|
178
|
+
return;
|
|
179
|
+
try {
|
|
180
|
+
await handle.truncate(length);
|
|
181
|
+
await handle.sync();
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* best-effort */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/* --------------------------------------------------------------- 读取端 */
|
|
189
|
+
/**
|
|
190
|
+
* 读回 journal 快照。`null` = 该目录**本来就没有** journal(ENOENT)。
|
|
191
|
+
*
|
|
192
|
+
* 崩溃容忍:文件最后一段(可能是写了一半的行)解析失败 ⇒ 丢弃它、`truncatedTail = true`,
|
|
193
|
+
* **绝不抛异常**。中间段坏掉不是 append 能造成的形状(篡改/损坏)⇒ 跳过且不算截断。
|
|
194
|
+
*/
|
|
195
|
+
export async function readUndoJournal(dir) {
|
|
196
|
+
const journalPath = path.join(dir, UNDO_JOURNAL_FILE_NAME);
|
|
197
|
+
let text;
|
|
198
|
+
try {
|
|
199
|
+
text = await fs.readFile(journalPath, 'utf8');
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
// 只有「本来就没有 journal」才是 null;读不出来(EACCES/EISDIR…)如实抛,绝不假装「没有中断回退」
|
|
203
|
+
if (isENOENT(error))
|
|
204
|
+
return null;
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
const segments = text.split('\n');
|
|
208
|
+
const entries = [];
|
|
209
|
+
let truncatedTail = false;
|
|
210
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
211
|
+
const raw = segments[i] ?? '';
|
|
212
|
+
if (raw === '')
|
|
213
|
+
continue;
|
|
214
|
+
let parsed;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(raw);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
if (i === segments.length - 1)
|
|
220
|
+
truncatedTail = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const entry = asJournalEntry(parsed);
|
|
224
|
+
if (entry !== null)
|
|
225
|
+
entries.push(entry);
|
|
226
|
+
}
|
|
227
|
+
return { entries, truncatedTail };
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* 找中断的回退前像目录。
|
|
231
|
+
*
|
|
232
|
+
* @param root 既可以是**暂存根目录**(其直接子目录就是 `<pid>-<ts36>-<8hex>`),
|
|
233
|
+
* 也可以是上层 dataRoot(此时会自动再看 `<root>/undo`);两种调用口径都支持。
|
|
234
|
+
* @param isAlive 进程存活探测(可注入,便于测试);缺省 `process.kill(pid, 0)`。
|
|
235
|
+
*
|
|
236
|
+
* 判据(严格):目录名形态匹配 + **有 journal** + **无 done** + **pid 已死**。
|
|
237
|
+
* 读不出 journal 的目录跳过(判不准就不报,绝不猜);结果按 dir 升序稳定排序。
|
|
238
|
+
*/
|
|
239
|
+
export async function findInterruptedUndos(root, isAlive = defaultIsProcessAlive) {
|
|
240
|
+
const bases = [root];
|
|
241
|
+
const nested = path.join(root, UNDO_SUBDIR_NAME);
|
|
242
|
+
if (path.resolve(nested) !== path.resolve(root))
|
|
243
|
+
bases.push(nested);
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const base of bases) {
|
|
247
|
+
let names;
|
|
248
|
+
try {
|
|
249
|
+
names = await fs.readdir(base);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
for (const name of names) {
|
|
255
|
+
const m = UNDO_DIR_RE.exec(name);
|
|
256
|
+
if (m === null)
|
|
257
|
+
continue;
|
|
258
|
+
const pid = Number(m[1]);
|
|
259
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
260
|
+
continue;
|
|
261
|
+
if (isAlive(pid))
|
|
262
|
+
continue;
|
|
263
|
+
const dir = path.join(base, name);
|
|
264
|
+
if (seen.has(dir))
|
|
265
|
+
continue;
|
|
266
|
+
const snapshot = await readJournalSafe(dir);
|
|
267
|
+
if (snapshot === null)
|
|
268
|
+
continue;
|
|
269
|
+
if (await pathExists(path.join(dir, UNDO_DONE_FILE_NAME)))
|
|
270
|
+
continue;
|
|
271
|
+
seen.add(dir);
|
|
272
|
+
out.push({ dir, pid, entries: snapshot.entries, truncatedTail: snapshot.truncatedTail });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
out.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
/* --------------------------------------------------------------- 重放端 */
|
|
279
|
+
/**
|
|
280
|
+
* 重放一份中断的前像(**逆序**:同一路径被记两次时,最后写的是最早状态)。
|
|
281
|
+
*
|
|
282
|
+
* 安全判据(**没有证明就绝不写盘**):
|
|
283
|
+
* - `afterDeleted` ⇒ 目标当前必须不存在(`readCurrent` 返回 null),否则 skipped;
|
|
284
|
+
* - 否则 `sha256(current)` 必须 === `afterSha256`,否则 skipped;
|
|
285
|
+
* - after 信息缺失/不可判(老行/损坏行,`afterSha256 === null`)⇒ skipped。
|
|
286
|
+
*
|
|
287
|
+
* 失败一律逐条进 `failed`(绝不静默):`unsafe-path` / `unsafe-bin` / `before-checksum-mismatch` /
|
|
288
|
+
* `read-current-failed: …` / 写入或删除的真实错误。读当前态失败属**保守失败**(该条不写)。
|
|
289
|
+
*/
|
|
290
|
+
export async function replayUndoJournal(undo, hooks) {
|
|
291
|
+
let restored = 0;
|
|
292
|
+
const skipped = [];
|
|
293
|
+
const failed = [];
|
|
294
|
+
const readCurrent = hooks.readCurrent ?? readCurrentDefault;
|
|
295
|
+
for (const entry of [...undo.entries].reverse()) {
|
|
296
|
+
const target = safeReplayTarget(entry.path);
|
|
297
|
+
if (target === null) {
|
|
298
|
+
failed.push(entry.path + ': unsafe-path');
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
let current;
|
|
302
|
+
try {
|
|
303
|
+
current = await readCurrent(target);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
failed.push(target + ': read-current-failed: ' + errorMessage(error));
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const blocked = afterStateMismatch(entry, current);
|
|
310
|
+
if (blocked !== null) {
|
|
311
|
+
skipped.push(target + ': ' + blocked);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
if (entry.bin === null) {
|
|
316
|
+
// 前像 = 「回退前不存在」⇒ 回退动作就是把它删掉(force:已经不在了也算成功)
|
|
317
|
+
await fs.rm(target, { force: true });
|
|
318
|
+
restored += 1;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const binPath = safeBinPath(undo.dir, entry.bin);
|
|
322
|
+
if (binPath === null) {
|
|
323
|
+
failed.push(target + ': unsafe-bin');
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const bytes = await fs.readFile(binPath);
|
|
327
|
+
if (entry.sha256 !== null && sha256Hex(bytes) !== entry.sha256) {
|
|
328
|
+
failed.push(target + ': before-checksum-mismatch');
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
await hooks.write(target, bytes);
|
|
332
|
+
restored += 1;
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
failed.push(target + ': ' + errorMessage(error));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { restored, skipped, failed };
|
|
339
|
+
}
|
|
340
|
+
/* --------------------------------------------------------------- 内部 */
|
|
341
|
+
/** 与记录的 after 镜像比对:返回 null = 当前状态一致,可以安全写回;否则返回跳过原因。 */
|
|
342
|
+
function afterStateMismatch(entry, current) {
|
|
343
|
+
if (entry.afterDeleted) {
|
|
344
|
+
return current === null ? null : 'after-state-changed: expected-absent';
|
|
345
|
+
}
|
|
346
|
+
if (entry.afterSha256 === null) {
|
|
347
|
+
// 无证明(老行/损坏行):宁可留下可人工处理的中断,也不覆盖用户后来的修改
|
|
348
|
+
return 'after-state-unknown';
|
|
349
|
+
}
|
|
350
|
+
if (current === null)
|
|
351
|
+
return 'after-state-changed: missing';
|
|
352
|
+
if (sha256Hex(current) !== entry.afterSha256)
|
|
353
|
+
return 'after-state-changed: sha256-mismatch';
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
function asJournalEntry(value) {
|
|
357
|
+
if (!isRecord(value))
|
|
358
|
+
return null;
|
|
359
|
+
const seq = value['seq'];
|
|
360
|
+
const target = value['path'];
|
|
361
|
+
const bin = value['bin'];
|
|
362
|
+
const bytes = value['bytes'];
|
|
363
|
+
const sha = value['sha256'];
|
|
364
|
+
if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0)
|
|
365
|
+
return null;
|
|
366
|
+
if (typeof target !== 'string' || target === '')
|
|
367
|
+
return null;
|
|
368
|
+
if (bin !== null && typeof bin !== 'string')
|
|
369
|
+
return null;
|
|
370
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0)
|
|
371
|
+
return null;
|
|
372
|
+
if (sha !== null && typeof sha !== 'string')
|
|
373
|
+
return null;
|
|
374
|
+
const afterDeleted = value['afterDeleted'] === true;
|
|
375
|
+
const afterRaw = value['afterSha256'];
|
|
376
|
+
const afterSha256 = !afterDeleted && typeof afterRaw === 'string' && afterRaw !== '' ? afterRaw : null;
|
|
377
|
+
const at = value['at'];
|
|
378
|
+
return {
|
|
379
|
+
seq,
|
|
380
|
+
path: target,
|
|
381
|
+
bin,
|
|
382
|
+
bytes,
|
|
383
|
+
sha256: sha,
|
|
384
|
+
afterSha256,
|
|
385
|
+
afterDeleted,
|
|
386
|
+
// 缺/坏时间戳归一为 0:**不影响安全判据**(重放只看 after 镜像),只作诊断
|
|
387
|
+
at: typeof at === 'number' && Number.isFinite(at) && at >= 0 ? at : 0,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/** 记录期路径校验:绝对 + 非空 + 无 NUL(否则重放时无法安全落地) */
|
|
391
|
+
function isJournalablePath(raw) {
|
|
392
|
+
return raw !== '' && !raw.includes('\0') && path.isAbsolute(raw);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* 重放期路径校验:必须绝对且可解析。相对路径会落到进程 CWD(越权)⇒ 拒绝;
|
|
396
|
+
* 归一化后的绝对路径(`path.resolve`)是实际写入目标。
|
|
397
|
+
*/
|
|
398
|
+
function safeReplayTarget(raw) {
|
|
399
|
+
if (raw === '' || raw.includes('\0'))
|
|
400
|
+
return null;
|
|
401
|
+
if (!path.isAbsolute(raw))
|
|
402
|
+
return null;
|
|
403
|
+
return path.resolve(raw);
|
|
404
|
+
}
|
|
405
|
+
/** 前像文件名必须在 undo 目录内且形态为 `<seq>.bin`(防篡改 journal 读目录外文件) */
|
|
406
|
+
function safeBinPath(dir, bin) {
|
|
407
|
+
if (!UNDO_BIN_RE.test(bin))
|
|
408
|
+
return null;
|
|
409
|
+
const base = path.resolve(dir);
|
|
410
|
+
const resolved = path.resolve(base, bin);
|
|
411
|
+
return resolved.startsWith(base + path.sep) ? resolved : null;
|
|
412
|
+
}
|
|
413
|
+
function isDeletedAfter(after) {
|
|
414
|
+
return after.deleted === true;
|
|
415
|
+
}
|
|
416
|
+
function normalizeAfter(after) {
|
|
417
|
+
if (isDeletedAfter(after))
|
|
418
|
+
return { afterDeleted: true, afterSha256: null };
|
|
419
|
+
const digest = after.sha256;
|
|
420
|
+
if (typeof digest !== 'string' || digest === '') {
|
|
421
|
+
throw new Error('undo journal after image must be { sha256 } or { deleted: true }');
|
|
422
|
+
}
|
|
423
|
+
return { afterDeleted: false, afterSha256: digest };
|
|
424
|
+
}
|
|
425
|
+
function defaultIsProcessAlive(pid) {
|
|
426
|
+
// 判不出来一律当「活着」:宁可留垃圾,也绝不删除/覆盖用户数据
|
|
427
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
428
|
+
return true;
|
|
429
|
+
try {
|
|
430
|
+
process.kill(pid, 0);
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
// EPERM = 进程存在但没权限发信号(Windows 上很常见)⇒ 仍然算活着
|
|
435
|
+
return error.code === 'EPERM';
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async function readJournalSafe(dir) {
|
|
439
|
+
try {
|
|
440
|
+
return await readUndoJournal(dir);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
async function readCurrentDefault(target) {
|
|
447
|
+
try {
|
|
448
|
+
return await fs.readFile(target);
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (isENOENT(error))
|
|
452
|
+
return null;
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async function pathExists(target) {
|
|
457
|
+
try {
|
|
458
|
+
await fs.stat(target);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* 截掉 journal 末尾**不完整的那一行**(上次崩溃残留)。
|
|
467
|
+
* 末尾是完整行(以 '\n' 收尾)/ 文件不存在 ⇒ 零写入;找不到任何换行 ⇒ 清零。
|
|
468
|
+
*/
|
|
469
|
+
async function trimTornJournalTail(file) {
|
|
470
|
+
let size = 0;
|
|
471
|
+
try {
|
|
472
|
+
size = (await fs.stat(file)).size;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (size === 0)
|
|
478
|
+
return;
|
|
479
|
+
const handle = await fs.open(file, 'r+');
|
|
480
|
+
try {
|
|
481
|
+
if (await endsWithNewline(handle, size))
|
|
482
|
+
return;
|
|
483
|
+
const cut = await lastNewlineOffset(handle, size);
|
|
484
|
+
await handle.truncate(cut);
|
|
485
|
+
await handle.sync();
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
await handle.close();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async function endsWithNewline(handle, size) {
|
|
492
|
+
const last = Buffer.alloc(1);
|
|
493
|
+
await handle.read(last, 0, 1, size - 1);
|
|
494
|
+
return last[0] === 0x0a;
|
|
495
|
+
}
|
|
496
|
+
/** 反向分块找最后一个 '\n',返回其**之后**的偏移(无换行则 0);内存与文件大小无关 */
|
|
497
|
+
async function lastNewlineOffset(handle, size) {
|
|
498
|
+
let end = size;
|
|
499
|
+
while (end > 0) {
|
|
500
|
+
const start = Math.max(0, end - SCAN_CHUNK_BYTES);
|
|
501
|
+
const buf = Buffer.allocUnsafe(end - start);
|
|
502
|
+
await handle.read(buf, 0, buf.length, start);
|
|
503
|
+
const idx = buf.lastIndexOf(0x0a);
|
|
504
|
+
if (idx >= 0)
|
|
505
|
+
return start + idx + 1;
|
|
506
|
+
end = start;
|
|
507
|
+
}
|
|
508
|
+
return 0;
|
|
509
|
+
}
|
|
510
|
+
function errorMessage(error) {
|
|
511
|
+
if (error instanceof Error) {
|
|
512
|
+
const code = error.code;
|
|
513
|
+
return typeof code === 'string' && code !== '' ? code + ' ' + error.message : error.message;
|
|
514
|
+
}
|
|
515
|
+
return String(error);
|
|
516
|
+
}
|
|
517
|
+
//# sourceMappingURL=rollback-undo-journal.js.map
|
package/lib/core/self-update.js
CHANGED
|
@@ -17,15 +17,13 @@
|
|
|
17
17
|
* ⑥ 失败必须如实:exitCode≠0 / 超时 / spawn 失败 → 复用 installErrorFor 的可操作诊断;
|
|
18
18
|
* 成功也只表示**文件已换新**,必须重启 DSH 才生效(调用方回 needsRestart)。
|
|
19
19
|
*/
|
|
20
|
-
import { compareVersionStrings } from './validator.js';
|
|
20
|
+
import { compareVersionStrings, isConcreteVersion } from './validator.js';
|
|
21
21
|
import { installErrorFor } from './plugin-cli.js';
|
|
22
22
|
import { PLUGIN_NPM_PACKAGE } from './update-check.js';
|
|
23
23
|
/** 目标安装 spec(钉住精确版本;单一拼接点,界面命令与 argv 恒等)。 */
|
|
24
24
|
export function selfUpdateSpec(version) {
|
|
25
25
|
return `${PLUGIN_NPM_PACKAGE}@${version}`;
|
|
26
26
|
}
|
|
27
|
-
/** semver 形态判定(与 update-check 同一形态:允许预发布/构建后缀,不允许多余字符)。 */
|
|
28
|
-
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
29
27
|
/** 非 registry 来源的依赖 spec 前缀(与 core/plugin-cli.ts 的安装语义同一口径)。 */
|
|
30
28
|
const NON_REGISTRY_SPEC = /^(github:|gitlab:|bitbucket:|git\+|file:|link:|workspace:|https?:)/i;
|
|
31
29
|
/** 纯函数:校验一次自更新请求(零副作用;不读盘、不起进程)。 */
|
|
@@ -38,7 +36,7 @@ export function planSelfUpdate(input) {
|
|
|
38
36
|
return { ok: false, code: 'unsupported-profile', error: 'the desktop profile is managed by the desktop app' };
|
|
39
37
|
}
|
|
40
38
|
const target = (input.target ?? '').trim();
|
|
41
|
-
if (!
|
|
39
|
+
if (!isConcreteVersion(target)) {
|
|
42
40
|
return { ok: false, code: 'invalid-version', error: `not a valid version: ${target === '' ? '(empty)' : target}` };
|
|
43
41
|
}
|
|
44
42
|
if (compareVersionStrings(target, input.current) <= 0) {
|