@anionex/dsh-turn-rewind 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.
Files changed (45) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +182 -0
  3. package/README.zh.md +180 -0
  4. package/SECURITY.md +53 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/FORMAT.md +101 -0
  7. package/docs/IMPLEMENTATION_PLAN.md +56 -0
  8. package/docs/assets/turn-rewind-action.png +0 -0
  9. package/docs/assets/turn-rewind-dialog.png +0 -0
  10. package/lib/client.js +473 -0
  11. package/lib/client.js.map +1 -0
  12. package/lib/engine.js +616 -0
  13. package/lib/errors.js +20 -0
  14. package/lib/git.js +155 -0
  15. package/lib/index.js +71 -0
  16. package/lib/path-utils.js +262 -0
  17. package/lib/rewind-host.js +421 -0
  18. package/lib/snapshot.js +183 -0
  19. package/lib/store.js +391 -0
  20. package/lib/types/client/index.d.ts +81 -0
  21. package/lib/types/engine.d.ts +90 -0
  22. package/lib/types/errors.d.ts +15 -0
  23. package/lib/types/git.d.ts +14 -0
  24. package/lib/types/index.d.ts +43 -0
  25. package/lib/types/path-utils.d.ts +34 -0
  26. package/lib/types/rewind-host.d.ts +137 -0
  27. package/lib/types/snapshot.d.ts +35 -0
  28. package/lib/types/store.d.ts +39 -0
  29. package/lib/types/types.d.ts +177 -0
  30. package/lib/types/validate.d.ts +11 -0
  31. package/lib/types.js +2 -0
  32. package/lib/validate.js +242 -0
  33. package/package.json +91 -0
  34. package/src/client/index.tsx +648 -0
  35. package/src/client/platform.d.ts +27 -0
  36. package/src/engine.ts +770 -0
  37. package/src/errors.ts +23 -0
  38. package/src/git.ts +178 -0
  39. package/src/index.ts +100 -0
  40. package/src/path-utils.ts +255 -0
  41. package/src/rewind-host.ts +584 -0
  42. package/src/snapshot.ts +229 -0
  43. package/src/store.ts +383 -0
  44. package/src/types.ts +195 -0
  45. package/src/validate.ts +245 -0
package/src/errors.ts ADDED
@@ -0,0 +1,23 @@
1
+ /** Stable error carrying a machine-readable change-ledger code. */
2
+ export class ChangeLedgerError extends Error {
3
+ override readonly name = 'ChangeLedgerError'
4
+
5
+ /**
6
+ * @param code - Stable error category.
7
+ * @param message - Human-readable diagnostic.
8
+ * @param cause - Optional lower-level failure.
9
+ */
10
+ constructor(
11
+ readonly code: string,
12
+ message: string,
13
+ options?: { readonly cause?: unknown },
14
+ ) {
15
+ super(`[${code}] ${message}`, options)
16
+ }
17
+ }
18
+
19
+ /** Convert an unknown thrown value into one bounded diagnostic line. */
20
+ export function errorMessage(error: unknown): string {
21
+ if (error instanceof Error) return error.message.slice(0, 2_000)
22
+ return String(error).slice(0, 2_000)
23
+ }
package/src/git.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { lstat, realpath } from 'node:fs/promises'
3
+ import { dirname, isAbsolute, resolve } from 'node:path'
4
+ import { ChangeLedgerError, errorMessage } from './errors.js'
5
+ import { canonicalDirectory, isNodeError, validateRelativePath } from './path-utils.js'
6
+ import type { RepositoryState } from './types.js'
7
+
8
+ const GIT_MAX_BUFFER = 32 * 1024 * 1024
9
+
10
+ /** Repository discovery result plus the eligible path inventory. */
11
+ export interface RepositorySnapshotSource {
12
+ readonly state: RepositoryState
13
+ readonly paths: readonly string[]
14
+ }
15
+
16
+ /** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
17
+ export async function discoverRepository(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource> {
18
+ const root = await discoverRepositoryRoot(cwd, signal)
19
+
20
+ const sparse = (await gitOptional(root, ['config', '--bool', '--get', 'core.sparseCheckout'], signal))?.trim() === 'true'
21
+ if (sparse) {
22
+ throw new ChangeLedgerError('SPARSE_CHECKOUT_UNSUPPORTED', 'sparse-checkout worktrees are not supported because absent paths are ambiguous')
23
+ }
24
+
25
+ const gitDirRaw = stripLineEnding(await git(root, ['rev-parse', '--git-dir'], signal))
26
+ const gitDir = await realpath(resolve(root, gitDirRaw))
27
+ const commonDirRaw = stripLineEnding(await git(root, ['rev-parse', '--git-common-dir'], signal))
28
+ const commonDir = await realpath(resolve(root, commonDirRaw))
29
+
30
+ const stageOutput = await git(root, ['ls-files', '--stage', '-z'], signal)
31
+ const submodules = parseSubmodules(stageOutput)
32
+ if (submodules.length > 0) {
33
+ throw new ChangeLedgerError(
34
+ 'SUBMODULE_UNSUPPORTED',
35
+ `submodules require independent restore points; unsupported gitlinks: ${submodules.slice(0, 10).join(', ')}`,
36
+ )
37
+ }
38
+
39
+ const pathOutput = await git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], signal)
40
+ const paths = [...new Set(splitNul(pathOutput).map(validateRelativePath))].sort(comparePaths)
41
+ const head = trimOptional(await gitOptional(root, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal))
42
+ const branch = trimOptional(await gitOptional(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal))
43
+ const stagedOutput = await git(root, ['diff', '--cached', '--name-only', '-z'], signal)
44
+ const stagedPaths = splitNul(stagedOutput).map(validateRelativePath).sort(comparePaths)
45
+ const operation = await gitOperation(gitDir)
46
+
47
+ return {
48
+ state: {
49
+ root,
50
+ commonDir,
51
+ ...(head === undefined ? {} : { head }),
52
+ ...(branch === undefined ? {} : { branch }),
53
+ ...(operation === undefined ? {} : { operation }),
54
+ stagedPaths,
55
+ },
56
+ paths,
57
+ }
58
+ }
59
+
60
+ /** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
61
+ export async function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string> {
62
+ const canonicalCwd = await canonicalDirectory(cwd)
63
+ const rootRaw = await git(canonicalCwd, ['rev-parse', '--show-toplevel'], signal)
64
+ const root = await realpath(stripLineEnding(rootRaw))
65
+ if (!isAbsolute(root)) {
66
+ throw new ChangeLedgerError('GIT_ROOT_INVALID', `git returned a non-absolute worktree root: ${JSON.stringify(root)}`)
67
+ }
68
+ return root
69
+ }
70
+
71
+ /** Return true when two repository fences refer to the same checkout state. */
72
+ export function sameRepositoryFence(left: RepositoryState, right: RepositoryState): boolean {
73
+ return left.root === right.root
74
+ && left.commonDir === right.commonDir
75
+ && left.head === right.head
76
+ && left.branch === right.branch
77
+ && left.operation === right.operation
78
+ }
79
+
80
+ async function git(cwd: string, args: readonly string[], signal?: AbortSignal): Promise<string> {
81
+ return new Promise((resolvePromise, reject) => {
82
+ execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
83
+ encoding: 'utf8',
84
+ maxBuffer: GIT_MAX_BUFFER,
85
+ windowsHide: true,
86
+ signal,
87
+ env: {
88
+ ...process.env,
89
+ GIT_OPTIONAL_LOCKS: '0',
90
+ GIT_TERMINAL_PROMPT: '0',
91
+ },
92
+ }, (error, stdout, stderr) => {
93
+ if (error !== null) {
94
+ reject(new ChangeLedgerError(
95
+ 'GIT_COMMAND_FAILED',
96
+ `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.trim() || errorMessage(error)}`,
97
+ { cause: error },
98
+ ))
99
+ return
100
+ }
101
+ resolvePromise(stdout)
102
+ })
103
+ })
104
+ }
105
+
106
+ async function gitOptional(cwd: string, args: readonly string[], signal?: AbortSignal): Promise<string | undefined> {
107
+ try {
108
+ return await git(cwd, args, signal)
109
+ } catch (error) {
110
+ if (error instanceof ChangeLedgerError && error.code === 'GIT_COMMAND_FAILED' && gitExitCode(error.cause) === 1) return undefined
111
+ throw error
112
+ }
113
+ }
114
+
115
+ function gitExitCode(error: unknown): string | number | undefined {
116
+ if (error === null || typeof error !== 'object' || !('code' in error)) return undefined
117
+ const code = error.code
118
+ return typeof code === 'string' || typeof code === 'number' ? code : undefined
119
+ }
120
+
121
+ function splitNul(value: string): string[] {
122
+ if (value === '') return []
123
+ const parts = value.split('\0')
124
+ if (parts.at(-1) === '') parts.pop()
125
+ return parts
126
+ }
127
+
128
+ function parseSubmodules(value: string): string[] {
129
+ const paths: string[] = []
130
+ for (const record of splitNul(value)) {
131
+ const match = /^(\d{6}) [0-9a-f]+ \d+\t([\s\S]+)$/.exec(record)
132
+ if (match === null) {
133
+ throw new ChangeLedgerError('GIT_INDEX_PARSE_FAILED', `cannot parse git index record: ${JSON.stringify(record.slice(0, 200))}`)
134
+ }
135
+ if (match[1] === '160000') paths.push(validateRelativePath(match[2] ?? ''))
136
+ }
137
+ return paths.sort(comparePaths)
138
+ }
139
+
140
+ async function gitOperation(gitDir: string): Promise<string | undefined> {
141
+ const markers: readonly [string, string][] = [
142
+ ['rebase-merge', 'rebase'],
143
+ ['rebase-apply', 'rebase'],
144
+ ['MERGE_HEAD', 'merge'],
145
+ ['CHERRY_PICK_HEAD', 'cherry-pick'],
146
+ ['REVERT_HEAD', 'revert'],
147
+ ['BISECT_LOG', 'bisect'],
148
+ ]
149
+ for (const [marker, operation] of markers) {
150
+ try {
151
+ await lstat(resolve(gitDir, marker))
152
+ return operation
153
+ } catch (error) {
154
+ if (!isNodeError(error, 'ENOENT')) throw error
155
+ }
156
+ }
157
+ return undefined
158
+ }
159
+
160
+ function trimOptional(value: string | undefined): string | undefined {
161
+ const trimmed = value?.trim()
162
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed
163
+ }
164
+
165
+ function stripLineEnding(value: string): string {
166
+ if (value.endsWith('\r\n')) return value.slice(0, -2)
167
+ if (value.endsWith('\n')) return value.slice(0, -1)
168
+ return value
169
+ }
170
+
171
+ function comparePaths(left: string, right: string): number {
172
+ return Buffer.from(left).compare(Buffer.from(right))
173
+ }
174
+
175
+ /** Return the Git metadata directory for diagnostics. */
176
+ export function gitMetadataParent(state: RepositoryState): string {
177
+ return dirname(state.commonDir)
178
+ }
package/src/index.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * DSH Turn Rewind, powered by persistent, inspectable, approval-gated Change Ledger restore points.
3
+ * @module @anionex/dsh-turn-rewind
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis'
6
+ import { ChangeLedgerEngine } from './engine.js'
7
+ import { installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js'
8
+ import type { ChangeLedgerConfig } from './types.js'
9
+
10
+ export * from './engine.js'
11
+ export * from './errors.js'
12
+ export * from './rewind-host.js'
13
+ export * from './types.js'
14
+
15
+ declare module '@deepseek-ai/cordis' {
16
+ interface Context {
17
+ changeLedger: ChangeLedgerService
18
+ }
19
+ }
20
+
21
+ /** Cordis service exposed as `ctx.changeLedger` for other DSH plugins. */
22
+ export class ChangeLedgerService {
23
+ readonly engine: ChangeLedgerEngine
24
+
25
+ /** Register the service and startup reconciliation. */
26
+ constructor(ctx: Context, config: ChangeLedgerConfig = {}) {
27
+ ctx.provide('changeLedger', this)
28
+ this.engine = new ChangeLedgerEngine(config)
29
+ const checkpoints = new TurnCheckpointCoordinator(this.engine)
30
+ ctx.inject(['agents'], (scope: Context) => { checkpoints.install(scope) })
31
+ ctx.inject(['webServer', 'sessions', 'sessionQuery', 'apiProxy', 'agents'], (scope: Context) => {
32
+ installRewindHttp(scope, this.engine, checkpoints)
33
+ })
34
+ void this.engine.initialize().then((reconciled) => {
35
+ if (reconciled > 0) {
36
+ ctx.logger.warn(`[change-ledger] marked ${reconciled} interrupted restore operation(s) for manual recovery`)
37
+ } else {
38
+ ctx.logger.info(`[change-ledger] ready; state=${this.engine.config.storageDir}`)
39
+ }
40
+ }).catch((error: unknown) => {
41
+ ctx.logger.error(`[change-ledger] startup failed: ${error instanceof Error ? error.message : String(error)}`)
42
+ })
43
+ }
44
+
45
+ /** Wait for startup reconciliation. */
46
+ initialize(): ReturnType<ChangeLedgerEngine['initialize']> {
47
+ return this.engine.initialize()
48
+ }
49
+
50
+ /** Create a user restore point. */
51
+ create(options: Parameters<ChangeLedgerEngine['create']>[0]): ReturnType<ChangeLedgerEngine['create']> {
52
+ return this.engine.create(options)
53
+ }
54
+
55
+ /** Capture project files before one turn enters its first Agent step. */
56
+ createTurnCheckpoint(
57
+ options: Parameters<ChangeLedgerEngine['createTurnCheckpoint']>[0],
58
+ ): ReturnType<ChangeLedgerEngine['createTurnCheckpoint']> {
59
+ return this.engine.createTurnCheckpoint(options)
60
+ }
61
+
62
+ /** Find the prompt-anchored checkpoint for one session turn. */
63
+ findTurnCheckpoint(
64
+ options: Parameters<ChangeLedgerEngine['findTurnCheckpoint']>[0],
65
+ ): ReturnType<ChangeLedgerEngine['findTurnCheckpoint']> {
66
+ return this.engine.findTurnCheckpoint(options)
67
+ }
68
+
69
+ /** List restore points. */
70
+ list(options: Parameters<ChangeLedgerEngine['list']>[0]): ReturnType<ChangeLedgerEngine['list']> {
71
+ return this.engine.list(options)
72
+ }
73
+
74
+ /** Compare a restore point with the current worktree. */
75
+ inspect(options: Parameters<ChangeLedgerEngine['inspect']>[0]): ReturnType<ChangeLedgerEngine['inspect']> {
76
+ return this.engine.inspect(options)
77
+ }
78
+
79
+ /** Create an expiring restore plan. */
80
+ planRestore(options: Parameters<ChangeLedgerEngine['planRestore']>[0]): ReturnType<ChangeLedgerEngine['planRestore']> {
81
+ return this.engine.planRestore(options)
82
+ }
83
+
84
+ /** Apply an exact restore plan after approval. */
85
+ applyRestore(options: Parameters<ChangeLedgerEngine['applyRestore']>[0]): ReturnType<ChangeLedgerEngine['applyRestore']> {
86
+ return this.engine.applyRestore(options)
87
+ }
88
+
89
+ /** Delete one restore point and collect unused blobs. */
90
+ delete(options: Parameters<ChangeLedgerEngine['delete']>[0]): ReturnType<ChangeLedgerEngine['delete']> {
91
+ return this.engine.delete(options)
92
+ }
93
+
94
+ /** List interrupted restore operations and their rescue points. */
95
+ listRecovery(options: Parameters<ChangeLedgerEngine['listRecovery']>[0]): ReturnType<ChangeLedgerEngine['listRecovery']> {
96
+ return this.engine.listRecovery(options)
97
+ }
98
+ }
99
+
100
+ export default ChangeLedgerService
@@ -0,0 +1,255 @@
1
+ import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm, rmdir, stat, symlink, unlink } from 'node:fs/promises'
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { ChangeLedgerError } from './errors.js'
5
+
6
+ /** Return whether `candidate` is equal to or below `root`. */
7
+ export function isWithin(root: string, candidate: string): boolean {
8
+ const rel = relative(root, candidate)
9
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))
10
+ }
11
+
12
+ /** Validate and normalize one repository-relative path without filesystem access. */
13
+ export function validateRelativePath(path: string): string {
14
+ if (path === '' || path.includes('\0')) {
15
+ throw new ChangeLedgerError('INVALID_PATH', 'snapshot paths must be non-empty and contain no NUL bytes')
16
+ }
17
+ if (path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)) {
18
+ throw new ChangeLedgerError('INVALID_PATH', `path escapes the workspace: ${JSON.stringify(path)}`)
19
+ }
20
+ if (sep === '\\' && path.includes('\\')) {
21
+ throw new ChangeLedgerError('INVALID_PATH', `Git paths must use forward slashes: ${JSON.stringify(path)}`)
22
+ }
23
+ const segments = path.split('/')
24
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
25
+ throw new ChangeLedgerError('INVALID_PATH', `path is not canonical: ${JSON.stringify(path)}`)
26
+ }
27
+ return path
28
+ }
29
+
30
+ /** Resolve a validated relative path below `root`. */
31
+ export function resolveWorkspacePath(root: string, path: string): string {
32
+ const normalized = validateRelativePath(path)
33
+ const target = resolve(root, ...normalized.split('/'))
34
+ if (!isWithin(root, target)) {
35
+ throw new ChangeLedgerError('INVALID_PATH', `path escapes the workspace: ${JSON.stringify(path)}`)
36
+ }
37
+ return target
38
+ }
39
+
40
+ /** Expand a leading `~/` against the provided home directory. */
41
+ export function expandHome(path: string, home: string): string {
42
+ if (path === '~') return home
43
+ if (path.startsWith(`~${sep}`) || path.startsWith('~/')) return join(home, path.slice(2))
44
+ return path
45
+ }
46
+
47
+ /** Canonicalize an existing directory and reject non-directories. */
48
+ export async function canonicalDirectory(path: string): Promise<string> {
49
+ let canonical: string
50
+ try {
51
+ canonical = await realpath(path)
52
+ } catch (error) {
53
+ throw new ChangeLedgerError('WORKSPACE_NOT_FOUND', `cannot resolve directory ${JSON.stringify(path)}`, { cause: error })
54
+ }
55
+ const info = await stat(canonical)
56
+ if (!info.isDirectory()) {
57
+ throw new ChangeLedgerError('WORKSPACE_NOT_DIRECTORY', `${JSON.stringify(path)} is not a directory`)
58
+ }
59
+ return canonical
60
+ }
61
+
62
+ /** Atomically replace one JSON file with owner-only permissions. */
63
+ export async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
64
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
65
+ const temporary = join(dirname(path), `.${randomUUID()}.tmp`)
66
+ const body = `${JSON.stringify(value, null, 2)}\n`
67
+ try {
68
+ const handle = await open(temporary, 'wx', 0o600)
69
+ try {
70
+ await handle.writeFile(body, 'utf8')
71
+ await handle.sync()
72
+ } finally {
73
+ await handle.close()
74
+ }
75
+ await renameReplacing(temporary, path)
76
+ await syncDirectory(dirname(path))
77
+ } finally {
78
+ await rm(temporary, { force: true })
79
+ }
80
+ }
81
+
82
+ /** Read and parse one JSON file. */
83
+ export async function readJson(path: string): Promise<unknown> {
84
+ let body: string
85
+ try {
86
+ body = await readFile(path, 'utf8')
87
+ } catch (error) {
88
+ throw new ChangeLedgerError('STATE_READ_FAILED', `cannot read ${JSON.stringify(path)}`, { cause: error })
89
+ }
90
+ try {
91
+ return JSON.parse(body) as unknown
92
+ } catch (error) {
93
+ throw new ChangeLedgerError('STATE_CORRUPT', `invalid JSON in ${JSON.stringify(path)}`, { cause: error })
94
+ }
95
+ }
96
+
97
+ /** Return whether a filesystem path exists without following its final symlink. */
98
+ export async function pathExists(path: string): Promise<boolean> {
99
+ try {
100
+ await lstat(path)
101
+ return true
102
+ } catch (error) {
103
+ if (isNodeError(error, 'ENOENT')) return false
104
+ throw error
105
+ }
106
+ }
107
+
108
+ /** Ensure every existing parent below `root` is a real directory, never a symlink. */
109
+ export async function ensureSafeParents(root: string, target: string): Promise<void> {
110
+ if (!isWithin(root, target) || target === root) {
111
+ throw new ChangeLedgerError('UNSAFE_TARGET', `restore target is outside the workspace: ${JSON.stringify(target)}`)
112
+ }
113
+ const rel = relative(root, dirname(target))
114
+ let current = root
115
+ if (rel === '') return
116
+ for (const segment of rel.split(sep)) {
117
+ current = join(current, segment)
118
+ try {
119
+ const info = await lstat(current)
120
+ if (info.isSymbolicLink()) {
121
+ throw new ChangeLedgerError('SYMLINK_PARENT', `restore path traverses symlink parent ${JSON.stringify(current)}`)
122
+ }
123
+ if (!info.isDirectory()) {
124
+ throw new ChangeLedgerError('PARENT_NOT_DIRECTORY', `restore parent is not a directory: ${JSON.stringify(current)}`)
125
+ }
126
+ } catch (error) {
127
+ if (!isNodeError(error, 'ENOENT')) throw error
128
+ await mkdir(current, { mode: 0o755 })
129
+ }
130
+ }
131
+ }
132
+
133
+ /** Replace a path with a regular file using a sibling temporary file. */
134
+ export async function replaceRegularFile(path: string, content: Buffer, mode: number): Promise<void> {
135
+ const temporary = join(dirname(path), `.${randomUUID()}.change-ledger.tmp`)
136
+ try {
137
+ const handle = await open(temporary, 'wx', mode & 0o777)
138
+ try {
139
+ await handle.writeFile(content)
140
+ await handle.sync()
141
+ } finally {
142
+ await handle.close()
143
+ }
144
+ await chmod(temporary, mode & 0o777)
145
+ await renameReplacing(temporary, path)
146
+ await syncDirectory(dirname(path))
147
+ } finally {
148
+ await rm(temporary, { force: true })
149
+ }
150
+ }
151
+
152
+ /** Replace a path with a symbolic link using a sibling temporary name. */
153
+ export async function replaceSymbolicLink(path: string, target: string): Promise<void> {
154
+ const temporary = join(dirname(path), `.${randomUUID()}.change-ledger.tmp`)
155
+ try {
156
+ await symlink(target, temporary)
157
+ await renameReplacing(temporary, path)
158
+ await syncDirectory(dirname(path))
159
+ } finally {
160
+ await rm(temporary, { force: true })
161
+ }
162
+ }
163
+
164
+ /** Remove one file/symlink or one empty directory, never a non-empty tree. */
165
+ export async function removeRestoreTarget(path: string): Promise<void> {
166
+ let info
167
+ try {
168
+ info = await lstat(path)
169
+ } catch (error) {
170
+ if (isNodeError(error, 'ENOENT')) return
171
+ throw error
172
+ }
173
+ if (info.isDirectory() && !info.isSymbolicLink()) {
174
+ try {
175
+ await rmdir(path)
176
+ } catch (error) {
177
+ if (isNodeError(error, 'ENOTEMPTY') || isNodeError(error, 'EEXIST')) {
178
+ throw new ChangeLedgerError('DIRECTORY_NOT_EMPTY', `refusing to remove non-empty directory ${JSON.stringify(path)}`)
179
+ }
180
+ throw error
181
+ }
182
+ return
183
+ }
184
+ await unlink(path)
185
+ }
186
+
187
+ /** Best-effort removal of empty parent directories up to but excluding `root`. */
188
+ export async function pruneEmptyParents(root: string, start: string): Promise<void> {
189
+ let current = dirname(start)
190
+ while (current !== root && isWithin(root, current)) {
191
+ try {
192
+ await rmdir(current)
193
+ } catch (error) {
194
+ if (isNodeError(error, 'ENOENT')) {
195
+ current = dirname(current)
196
+ continue
197
+ }
198
+ if (isNodeError(error, 'ENOTEMPTY') || isNodeError(error, 'EEXIST')) return
199
+ throw error
200
+ }
201
+ current = dirname(current)
202
+ }
203
+ }
204
+
205
+ /** Test whether a process id currently exists. */
206
+ export function processExists(pid: number): boolean {
207
+ try {
208
+ process.kill(pid, 0)
209
+ return true
210
+ } catch (error) {
211
+ return isNodeError(error, 'EPERM')
212
+ }
213
+ }
214
+
215
+ /** Type guard for Node.js errors with a selected `code`. */
216
+ export function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException {
217
+ return error instanceof Error && 'code' in error && error.code === code
218
+ }
219
+
220
+ async function renameReplacing(source: string, destination: string): Promise<void> {
221
+ try {
222
+ await rename(source, destination)
223
+ return
224
+ } catch (error) {
225
+ if (!isNodeError(error, 'EEXIST') && !isNodeError(error, 'EPERM') && !isNodeError(error, 'ENOTEMPTY')) {
226
+ throw error
227
+ }
228
+ }
229
+ await removeRestoreTarget(destination)
230
+ await rename(source, destination)
231
+ }
232
+
233
+ /** Create one owner-only file exclusively and return its handle. */
234
+ export async function openExclusive(path: string) {
235
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
236
+ return open(path, 'wx', 0o600)
237
+ }
238
+
239
+ /** Flush directory-entry changes where the platform supports directory fsync. */
240
+ export async function syncDirectory(path: string): Promise<void> {
241
+ let handle
242
+ try {
243
+ handle = await open(path, 'r')
244
+ await handle.sync()
245
+ } catch (error) {
246
+ if (isNodeError(error, 'EINVAL')
247
+ || isNodeError(error, 'ENOTSUP')
248
+ || isNodeError(error, 'EISDIR')
249
+ || isNodeError(error, 'EPERM')
250
+ || isNodeError(error, 'EACCES')) return
251
+ throw error
252
+ } finally {
253
+ await handle?.close()
254
+ }
255
+ }