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