linkany 0.0.1
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 +24 -0
- package/README.md +156 -0
- package/dist/api/add.d.ts +17 -0
- package/dist/api/add.js +129 -0
- package/dist/api/index.d.ts +4 -0
- package/dist/api/index.js +4 -0
- package/dist/api/install.d.ts +6 -0
- package/dist/api/install.js +88 -0
- package/dist/api/remove.d.ts +12 -0
- package/dist/api/remove.js +62 -0
- package/dist/api/uninstall.d.ts +5 -0
- package/dist/api/uninstall.js +24 -0
- package/dist/cli/config.d.ts +16 -0
- package/dist/cli/config.js +38 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +181 -0
- package/dist/core/apply.d.ts +6 -0
- package/dist/core/apply.js +159 -0
- package/dist/core/audit.d.ts +4 -0
- package/dist/core/audit.js +23 -0
- package/dist/core/backup.d.ts +18 -0
- package/dist/core/backup.js +44 -0
- package/dist/core/format-plan.d.ts +2 -0
- package/dist/core/format-plan.js +14 -0
- package/dist/core/fs-ops.d.ts +16 -0
- package/dist/core/fs-ops.js +41 -0
- package/dist/core/fs.d.ts +7 -0
- package/dist/core/fs.js +6 -0
- package/dist/core/plan.d.ts +40 -0
- package/dist/core/plan.js +149 -0
- package/dist/core/runner.d.ts +13 -0
- package/dist/core/runner.js +29 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/manifest/io.d.ts +17 -0
- package/dist/manifest/io.js +52 -0
- package/dist/manifest/types.d.ts +29 -0
- package/dist/manifest/types.js +44 -0
- package/dist/types.d.ts +72 -0
- package/dist/types.js +1 -0
- package/package.json +38 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { add } from './api/add.js';
|
|
5
|
+
import { install } from './api/install.js';
|
|
6
|
+
import { remove } from './api/remove.js';
|
|
7
|
+
import { uninstall } from './api/uninstall.js';
|
|
8
|
+
import { clearDefaultManifestPath, getDefaultManifestPath, setDefaultManifestPath } from './cli/config.js';
|
|
9
|
+
class CliExit extends Error {
|
|
10
|
+
exitCode;
|
|
11
|
+
constructor(message, exitCode = 1) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.exitCode = exitCode;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function die(msg, code = 1) {
|
|
17
|
+
throw new CliExit(msg, code);
|
|
18
|
+
}
|
|
19
|
+
function popFlagValue(args, names) {
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const a = args[i];
|
|
22
|
+
if (!names.includes(a))
|
|
23
|
+
continue;
|
|
24
|
+
const v = args[i + 1];
|
|
25
|
+
if (!v || v.startsWith('-'))
|
|
26
|
+
return undefined;
|
|
27
|
+
args.splice(i, 2);
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
function hasFlag(args, names) {
|
|
33
|
+
const idx = args.findIndex(a => names.includes(a));
|
|
34
|
+
if (idx >= 0) {
|
|
35
|
+
args.splice(idx, 1);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
function parseCommonOptions(args) {
|
|
41
|
+
const dryRun = hasFlag(args, ['--dry-run']);
|
|
42
|
+
const includePlanText = hasFlag(args, ['--plan']);
|
|
43
|
+
const auditLogPath = popFlagValue(args, ['--audit-log']);
|
|
44
|
+
return { dryRun, includePlanText, auditLogPath };
|
|
45
|
+
}
|
|
46
|
+
async function resolveManifestPath(args) {
|
|
47
|
+
const m = popFlagValue(args, ['-m', '--manifest']);
|
|
48
|
+
if (m)
|
|
49
|
+
return path.resolve(m);
|
|
50
|
+
const d = await getDefaultManifestPath();
|
|
51
|
+
if (d)
|
|
52
|
+
return d;
|
|
53
|
+
die('No manifest specified. Please run `linkany manifest set <path>` or pass `--manifest <path>`.');
|
|
54
|
+
}
|
|
55
|
+
function printHelp() {
|
|
56
|
+
const msg = `
|
|
57
|
+
linkany
|
|
58
|
+
|
|
59
|
+
Usage:
|
|
60
|
+
linkany manifest set <path>
|
|
61
|
+
linkany manifest show
|
|
62
|
+
linkany manifest clear
|
|
63
|
+
|
|
64
|
+
linkany add --source <path> --target <path> [--kind file|dir] [--atomic|--no-atomic] [-m <manifest>] [--dry-run] [--plan]
|
|
65
|
+
linkany remove <key> [--keep-link] [-m <manifest>] [--dry-run] [--plan]
|
|
66
|
+
linkany install [-m <manifest>] [--dry-run] [--plan]
|
|
67
|
+
linkany uninstall [-m <manifest>] [--dry-run] [--plan]
|
|
68
|
+
`;
|
|
69
|
+
process.stdout.write(msg.trimStart());
|
|
70
|
+
process.stdout.write('\n');
|
|
71
|
+
}
|
|
72
|
+
function parseAddArgs(args) {
|
|
73
|
+
const source = popFlagValue(args, ['--source']);
|
|
74
|
+
const target = popFlagValue(args, ['--target']);
|
|
75
|
+
const kind = popFlagValue(args, ['--kind']);
|
|
76
|
+
const atomic = hasFlag(args, ['--atomic']) ? true : (hasFlag(args, ['--no-atomic']) ? false : undefined);
|
|
77
|
+
if (!source || !target) {
|
|
78
|
+
die('add requires --source and --target');
|
|
79
|
+
}
|
|
80
|
+
if (kind && kind !== 'file' && kind !== 'dir') {
|
|
81
|
+
die(`Invalid --kind: ${kind} (expected file|dir)`);
|
|
82
|
+
}
|
|
83
|
+
return { source, target, kind, atomic };
|
|
84
|
+
}
|
|
85
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
86
|
+
try {
|
|
87
|
+
const args = [...argv];
|
|
88
|
+
if (args.length === 0 || hasFlag(args, ['-h', '--help'])) {
|
|
89
|
+
printHelp();
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
const cmd = args.shift();
|
|
93
|
+
if (!cmd) {
|
|
94
|
+
printHelp();
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
if (cmd === 'manifest') {
|
|
98
|
+
const sub = args.shift();
|
|
99
|
+
if (sub === 'set') {
|
|
100
|
+
const p = args.shift();
|
|
101
|
+
if (!p)
|
|
102
|
+
die('manifest set requires a path');
|
|
103
|
+
const abs = await setDefaultManifestPath(p);
|
|
104
|
+
process.stdout.write(abs + '\n');
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (sub === 'show') {
|
|
108
|
+
const p = await getDefaultManifestPath();
|
|
109
|
+
if (!p)
|
|
110
|
+
die('No default manifest set. Run `linkany manifest set <path>`.', 2);
|
|
111
|
+
process.stdout.write(p + '\n');
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
if (sub === 'clear') {
|
|
115
|
+
await clearDefaultManifestPath();
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
die('Unknown manifest subcommand. Expected: set|show|clear');
|
|
119
|
+
}
|
|
120
|
+
if (cmd === 'add') {
|
|
121
|
+
const opts = parseCommonOptions(args);
|
|
122
|
+
const manifestPath = await resolveManifestPath(args);
|
|
123
|
+
const mapping = parseAddArgs(args);
|
|
124
|
+
if (args.length)
|
|
125
|
+
die(`Unknown arguments: ${args.join(' ')}`);
|
|
126
|
+
const res = await add(manifestPath, mapping, opts);
|
|
127
|
+
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
|
128
|
+
return res.ok ? 0 : 1;
|
|
129
|
+
}
|
|
130
|
+
if (cmd === 'remove') {
|
|
131
|
+
const opts = parseCommonOptions(args);
|
|
132
|
+
const manifestPath = await resolveManifestPath(args);
|
|
133
|
+
const keepLink = hasFlag(args, ['--keep-link']);
|
|
134
|
+
const key = args.shift();
|
|
135
|
+
if (!key)
|
|
136
|
+
die('remove requires <key>');
|
|
137
|
+
if (args.length)
|
|
138
|
+
die(`Unknown arguments: ${args.join(' ')}`);
|
|
139
|
+
const res = await remove(manifestPath, key, { ...opts, keepLink });
|
|
140
|
+
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
|
141
|
+
return res.ok ? 0 : 1;
|
|
142
|
+
}
|
|
143
|
+
if (cmd === 'install') {
|
|
144
|
+
const opts = parseCommonOptions(args);
|
|
145
|
+
const manifestPath = await resolveManifestPath(args);
|
|
146
|
+
if (args.length)
|
|
147
|
+
die(`Unknown arguments: ${args.join(' ')}`);
|
|
148
|
+
const res = await install(manifestPath, opts);
|
|
149
|
+
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
|
150
|
+
return res.ok ? 0 : 1;
|
|
151
|
+
}
|
|
152
|
+
if (cmd === 'uninstall') {
|
|
153
|
+
const opts = parseCommonOptions(args);
|
|
154
|
+
const manifestPath = await resolveManifestPath(args);
|
|
155
|
+
if (args.length)
|
|
156
|
+
die(`Unknown arguments: ${args.join(' ')}`);
|
|
157
|
+
const res = await uninstall(manifestPath, opts);
|
|
158
|
+
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
|
159
|
+
return res.ok ? 0 : 1;
|
|
160
|
+
}
|
|
161
|
+
die(`Unknown command: ${cmd}`);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
if (e instanceof CliExit) {
|
|
165
|
+
const msg = e.message || 'Command failed';
|
|
166
|
+
process.stderr.write(msg.endsWith('\n') ? msg : msg + '\n');
|
|
167
|
+
return e.exitCode;
|
|
168
|
+
}
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Only run when executed as a script, not when imported (e.g., tests).
|
|
173
|
+
const isEntry = process.argv[1] &&
|
|
174
|
+
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
175
|
+
if (isEntry) {
|
|
176
|
+
main().then((code) => process.exit(code), (err) => {
|
|
177
|
+
const msg = err?.stack ? String(err.stack) : String(err);
|
|
178
|
+
process.stderr.write(msg.endsWith('\n') ? msg : msg + '\n');
|
|
179
|
+
process.exit(1);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { copyPath, createDir, createEmptyFile, createSymlink, removePath, removeSymlink, renameAtomic } from './fs-ops.js';
|
|
4
|
+
function nowIso() {
|
|
5
|
+
return new Date().toISOString();
|
|
6
|
+
}
|
|
7
|
+
function durationMs(start) {
|
|
8
|
+
return Date.now() - start;
|
|
9
|
+
}
|
|
10
|
+
function defaultLogger() {
|
|
11
|
+
return {
|
|
12
|
+
info: () => { },
|
|
13
|
+
warn: () => { },
|
|
14
|
+
error: () => { },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export async function applyPlan(operation, steps, opts = {}) {
|
|
18
|
+
const startTs = Date.now();
|
|
19
|
+
const startedAt = nowIso();
|
|
20
|
+
const logger = opts.logger ?? defaultLogger();
|
|
21
|
+
const result = {
|
|
22
|
+
ok: true,
|
|
23
|
+
operation,
|
|
24
|
+
startedAt,
|
|
25
|
+
finishedAt: startedAt,
|
|
26
|
+
durationMs: 0,
|
|
27
|
+
steps: [],
|
|
28
|
+
warnings: [],
|
|
29
|
+
errors: [],
|
|
30
|
+
changes: [],
|
|
31
|
+
};
|
|
32
|
+
for (const s of steps) {
|
|
33
|
+
const step = { ...s, status: 'planned' };
|
|
34
|
+
try {
|
|
35
|
+
if (opts.dryRun && s.kind !== 'noop') {
|
|
36
|
+
step.status = 'skipped';
|
|
37
|
+
result.steps.push(step);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
switch (s.kind) {
|
|
41
|
+
case 'noop':
|
|
42
|
+
step.status = 'skipped';
|
|
43
|
+
break;
|
|
44
|
+
case 'mkdirp': {
|
|
45
|
+
const dir = s.paths?.dir;
|
|
46
|
+
if (!dir)
|
|
47
|
+
throw new Error('mkdirp step missing dir');
|
|
48
|
+
await createDir(dir);
|
|
49
|
+
step.status = 'executed';
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case 'touch': {
|
|
53
|
+
const file = s.paths?.file;
|
|
54
|
+
if (!file)
|
|
55
|
+
throw new Error('touch step missing file');
|
|
56
|
+
await createEmptyFile(file);
|
|
57
|
+
step.status = 'executed';
|
|
58
|
+
result.changes.push({ action: 'create_source_file', source: file });
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case 'symlink': {
|
|
62
|
+
const source = s.paths?.source;
|
|
63
|
+
const target = s.paths?.target;
|
|
64
|
+
if (!source || !target)
|
|
65
|
+
throw new Error('symlink step missing source/target');
|
|
66
|
+
let kind = s.paths?.kind;
|
|
67
|
+
if (!kind) {
|
|
68
|
+
try {
|
|
69
|
+
const st = await fs.lstat(source);
|
|
70
|
+
kind = st.isDirectory() ? 'dir' : 'file';
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
kind = 'file';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
await createSymlink(source, target, kind);
|
|
77
|
+
step.status = 'executed';
|
|
78
|
+
result.changes.push({ action: 'symlink', source, target });
|
|
79
|
+
step.undo = { kind: 'unlink', message: 'Rollback: remove created symlink', paths: { target } };
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case 'unlink': {
|
|
83
|
+
const target = s.paths?.target;
|
|
84
|
+
if (!target)
|
|
85
|
+
throw new Error('unlink step missing target');
|
|
86
|
+
await removeSymlink(target);
|
|
87
|
+
step.status = 'executed';
|
|
88
|
+
result.changes.push({ action: 'unlink', target });
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case 'rm': {
|
|
92
|
+
const p = s.paths?.path;
|
|
93
|
+
if (!p)
|
|
94
|
+
throw new Error('rm step missing path');
|
|
95
|
+
await removePath(p);
|
|
96
|
+
step.status = 'executed';
|
|
97
|
+
result.changes.push({ action: 'rm', target: p });
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case 'move': {
|
|
101
|
+
const from = s.paths?.from;
|
|
102
|
+
const to = s.paths?.to;
|
|
103
|
+
if (!from || !to)
|
|
104
|
+
throw new Error('move step missing from/to');
|
|
105
|
+
await renameAtomic(from, to);
|
|
106
|
+
step.status = 'executed';
|
|
107
|
+
result.changes.push({ action: 'move', source: from, target: to });
|
|
108
|
+
step.undo = step.undo ?? { kind: 'move', message: 'Rollback: move back', paths: { from: to, to: from } };
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case 'copy': {
|
|
112
|
+
const from = s.paths?.from;
|
|
113
|
+
const to = s.paths?.to;
|
|
114
|
+
if (!from || !to)
|
|
115
|
+
throw new Error('copy step missing from/to');
|
|
116
|
+
await copyPath(from, to);
|
|
117
|
+
step.status = 'executed';
|
|
118
|
+
result.changes.push({ action: 'copy', source: from, target: to });
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'write_manifest':
|
|
122
|
+
case 'audit':
|
|
123
|
+
step.status = 'skipped';
|
|
124
|
+
break;
|
|
125
|
+
default: {
|
|
126
|
+
const _exhaustive = s.kind;
|
|
127
|
+
throw new Error(`Unknown step kind: ${String(_exhaustive)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
result.steps.push(step);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
step.status = 'failed';
|
|
134
|
+
step.error = e?.message ? String(e.message) : String(e);
|
|
135
|
+
result.steps.push(step);
|
|
136
|
+
result.ok = false;
|
|
137
|
+
result.errors.push(step.error);
|
|
138
|
+
logger.error(`[linkany] step failed: ${step.kind} ${step.error}`);
|
|
139
|
+
const tmp = step.paths?.target && path.basename(step.paths.target).includes('.tmp.')
|
|
140
|
+
? step.paths.target
|
|
141
|
+
: undefined;
|
|
142
|
+
if (tmp) {
|
|
143
|
+
try {
|
|
144
|
+
await removePath(tmp);
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Build a best-effort rollback plan in reverse execution order.
|
|
152
|
+
result.rollbackSteps = result.steps
|
|
153
|
+
.filter(s => s.status === 'executed' && s.undo)
|
|
154
|
+
.map(s => s.undo)
|
|
155
|
+
.reverse();
|
|
156
|
+
result.finishedAt = nowIso();
|
|
157
|
+
result.durationMs = durationMs(startTs);
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { CommonOptions, Result } from '../types.js';
|
|
2
|
+
export declare function defaultAuditLogPath(manifestPath: string, opts?: CommonOptions): string;
|
|
3
|
+
export declare function appendAudit(logPath: string, result: Result): Promise<void>;
|
|
4
|
+
export declare function tryAppendAuditStep(result: Result, manifestPath: string, opts?: CommonOptions): Promise<Result>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export function defaultAuditLogPath(manifestPath, opts) {
|
|
4
|
+
return opts?.auditLogPath ?? `${path.resolve(manifestPath)}.log.jsonl`;
|
|
5
|
+
}
|
|
6
|
+
export async function appendAudit(logPath, result) {
|
|
7
|
+
await fs.ensureDir(path.dirname(logPath));
|
|
8
|
+
const line = JSON.stringify(result) + '\n';
|
|
9
|
+
await fs.appendFile(logPath, line, 'utf8');
|
|
10
|
+
}
|
|
11
|
+
export async function tryAppendAuditStep(result, manifestPath, opts) {
|
|
12
|
+
const logPath = defaultAuditLogPath(manifestPath, opts);
|
|
13
|
+
try {
|
|
14
|
+
await appendAudit(logPath, result);
|
|
15
|
+
result.steps.push({ kind: 'audit', message: 'Append audit log', status: 'executed', paths: { file: logPath } });
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
const msg = e?.message ? String(e.message) : String(e);
|
|
19
|
+
result.warnings.push(`Failed to write audit log: ${msg}`);
|
|
20
|
+
result.steps.push({ kind: 'audit', message: 'Append audit log', status: 'failed', error: msg, paths: { file: logPath } });
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Step } from '../types.js';
|
|
2
|
+
export declare function backupPathForTarget(targetAbs: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Plan an atomic replace for symlink target:
|
|
5
|
+
* - create tmp symlink (done elsewhere)
|
|
6
|
+
* - move existing target to backup
|
|
7
|
+
* - move tmp into place
|
|
8
|
+
*
|
|
9
|
+
* This does NOT delete backups; leaving backups is safer and aids rollback.
|
|
10
|
+
*/
|
|
11
|
+
export declare function planReplaceTargetWithTmp(opts: {
|
|
12
|
+
targetAbs: string;
|
|
13
|
+
atomic: boolean;
|
|
14
|
+
}): {
|
|
15
|
+
backupAbs?: string;
|
|
16
|
+
steps: Step[];
|
|
17
|
+
tmpAbs: string;
|
|
18
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tmpPathForTarget } from './plan.js';
|
|
2
|
+
function rand() {
|
|
3
|
+
return Math.random().toString(16).slice(2);
|
|
4
|
+
}
|
|
5
|
+
export function backupPathForTarget(targetAbs) {
|
|
6
|
+
return `${targetAbs}.bak.${Date.now()}.${rand()}`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Plan an atomic replace for symlink target:
|
|
10
|
+
* - create tmp symlink (done elsewhere)
|
|
11
|
+
* - move existing target to backup
|
|
12
|
+
* - move tmp into place
|
|
13
|
+
*
|
|
14
|
+
* This does NOT delete backups; leaving backups is safer and aids rollback.
|
|
15
|
+
*/
|
|
16
|
+
export function planReplaceTargetWithTmp(opts) {
|
|
17
|
+
const steps = [];
|
|
18
|
+
const tmpAbs = opts.atomic ? tmpPathForTarget(opts.targetAbs) : opts.targetAbs;
|
|
19
|
+
if (opts.atomic) {
|
|
20
|
+
const backupAbs = backupPathForTarget(opts.targetAbs);
|
|
21
|
+
steps.push({
|
|
22
|
+
kind: 'move',
|
|
23
|
+
message: 'Move existing target aside to backup before replacement',
|
|
24
|
+
paths: { from: opts.targetAbs, to: backupAbs },
|
|
25
|
+
undo: {
|
|
26
|
+
kind: 'move',
|
|
27
|
+
message: 'Rollback: restore previous target from backup',
|
|
28
|
+
paths: { from: backupAbs, to: opts.targetAbs },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
steps.push({
|
|
32
|
+
kind: 'move',
|
|
33
|
+
message: 'Atomically move temp symlink into place',
|
|
34
|
+
paths: { from: tmpAbs, to: opts.targetAbs },
|
|
35
|
+
undo: {
|
|
36
|
+
kind: 'move',
|
|
37
|
+
message: 'Rollback: move current target back to tmp',
|
|
38
|
+
paths: { from: opts.targetAbs, to: tmpAbs },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
return { backupAbs, steps, tmpAbs };
|
|
42
|
+
}
|
|
43
|
+
return { steps, tmpAbs };
|
|
44
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function formatPlan(steps) {
|
|
2
|
+
if (!steps.length)
|
|
3
|
+
return 'No changes.';
|
|
4
|
+
const lines = [];
|
|
5
|
+
for (const s of steps) {
|
|
6
|
+
const paths = s.paths
|
|
7
|
+
? Object.entries(s.paths)
|
|
8
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
9
|
+
.join(' ')
|
|
10
|
+
: '';
|
|
11
|
+
lines.push(`- ${s.kind}: ${s.message}${paths ? ` (${paths})` : ''}`);
|
|
12
|
+
}
|
|
13
|
+
return lines.join('\n');
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { LinkKind } from '../types.js';
|
|
2
|
+
export declare function ensureParentDir(p: string): Promise<void>;
|
|
3
|
+
export declare function createEmptyFile(p: string): Promise<void>;
|
|
4
|
+
export declare function createDir(p: string): Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Create a symlink at target pointing to source.
|
|
7
|
+
* On macOS/Linux we do not fallback to copy. If symlink fails, it throws.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createSymlink(sourceAbs: string, targetAbs: string, kind: LinkKind): Promise<void>;
|
|
10
|
+
export declare function removePath(p: string): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Remove only if it's a symlink. Throws if it exists but isn't a symlink.
|
|
13
|
+
*/
|
|
14
|
+
export declare function removeSymlink(p: string): Promise<void>;
|
|
15
|
+
export declare function renameAtomic(from: string, to: string): Promise<void>;
|
|
16
|
+
export declare function copyPath(from: string, to: string): Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export async function ensureParentDir(p) {
|
|
4
|
+
await fs.ensureDir(path.dirname(p));
|
|
5
|
+
}
|
|
6
|
+
export async function createEmptyFile(p) {
|
|
7
|
+
await ensureParentDir(p);
|
|
8
|
+
await fs.ensureFile(p);
|
|
9
|
+
}
|
|
10
|
+
export async function createDir(p) {
|
|
11
|
+
await fs.ensureDir(p);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Create a symlink at target pointing to source.
|
|
15
|
+
* On macOS/Linux we do not fallback to copy. If symlink fails, it throws.
|
|
16
|
+
*/
|
|
17
|
+
export async function createSymlink(sourceAbs, targetAbs, kind) {
|
|
18
|
+
await ensureParentDir(targetAbs);
|
|
19
|
+
const rel = path.relative(path.dirname(targetAbs), sourceAbs) || '.';
|
|
20
|
+
await fs.symlink(rel, targetAbs, kind === 'dir' ? 'dir' : 'file');
|
|
21
|
+
}
|
|
22
|
+
export async function removePath(p) {
|
|
23
|
+
await fs.remove(p);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Remove only if it's a symlink. Throws if it exists but isn't a symlink.
|
|
27
|
+
*/
|
|
28
|
+
export async function removeSymlink(p) {
|
|
29
|
+
const st = await fs.lstat(p);
|
|
30
|
+
if (!st.isSymbolicLink()) {
|
|
31
|
+
throw new Error(`Refusing to remove non-symlink: ${p}`);
|
|
32
|
+
}
|
|
33
|
+
await fs.unlink(p);
|
|
34
|
+
}
|
|
35
|
+
export async function renameAtomic(from, to) {
|
|
36
|
+
await fs.rename(from, to);
|
|
37
|
+
}
|
|
38
|
+
export async function copyPath(from, to) {
|
|
39
|
+
await ensureParentDir(to);
|
|
40
|
+
await fs.copy(from, to, { dereference: true });
|
|
41
|
+
}
|
package/dist/core/fs.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { LinkKind, Step } from '../types.js';
|
|
2
|
+
import { FS } from './fs.js';
|
|
3
|
+
export interface EnsureSourceSpec {
|
|
4
|
+
sourceAbs: string;
|
|
5
|
+
kind: LinkKind;
|
|
6
|
+
}
|
|
7
|
+
export interface EnsureLinkSpec {
|
|
8
|
+
sourceAbs: string;
|
|
9
|
+
targetAbs: string;
|
|
10
|
+
kind: LinkKind;
|
|
11
|
+
atomic: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface UnlinkSpec {
|
|
14
|
+
targetAbs: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CopySpec {
|
|
17
|
+
fromAbs: string;
|
|
18
|
+
toAbs: string;
|
|
19
|
+
kind: LinkKind;
|
|
20
|
+
atomic: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function tmpPathForTarget(targetAbs: string): string;
|
|
23
|
+
export declare function backupPathForTarget(targetAbs: string): string;
|
|
24
|
+
export declare function detectKind(p: string): Promise<LinkKind>;
|
|
25
|
+
export declare function detectKindWithFS(fs: FS, p: string): Promise<LinkKind>;
|
|
26
|
+
export declare function isSymlinkTo(targetAbs: string, sourceAbs: string): Promise<boolean>;
|
|
27
|
+
export declare function isSymlinkToWithFS(fs: FS, targetAbs: string, sourceAbs: string): Promise<boolean>;
|
|
28
|
+
export declare function planEnsureSource(spec: EnsureSourceSpec): Promise<Step[]>;
|
|
29
|
+
export declare function planEnsureSourceWithFS(fs: FS, spec: EnsureSourceSpec): Promise<Step[]>;
|
|
30
|
+
export declare function planEnsureLink(spec: EnsureLinkSpec): Promise<{
|
|
31
|
+
steps: Step[];
|
|
32
|
+
reason: 'noop' | 'create' | 'replace_symlink' | 'conflict';
|
|
33
|
+
}>;
|
|
34
|
+
export declare function planEnsureLinkWithFS(fs: FS, spec: EnsureLinkSpec): Promise<{
|
|
35
|
+
steps: Step[];
|
|
36
|
+
reason: 'noop' | 'create' | 'replace_symlink' | 'conflict';
|
|
37
|
+
}>;
|
|
38
|
+
export declare function planUnlink(spec: UnlinkSpec): Promise<Step[]>;
|
|
39
|
+
export declare function planUnlinkWithFS(fs: FS, spec: UnlinkSpec): Promise<Step[]>;
|
|
40
|
+
export declare function planCopy(spec: CopySpec): Promise<Step[]>;
|