linkany 0.0.1 → 0.0.3
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/README.md +21 -4
- package/dist/api/add.d.ts +8 -1
- package/dist/api/add.js +129 -17
- package/dist/api/install.d.ts +8 -1
- package/dist/api/install.js +59 -8
- package/dist/api/manifest-input.d.ts +16 -0
- package/dist/api/manifest-input.js +8 -0
- package/dist/api/remove.d.ts +8 -1
- package/dist/api/remove.js +70 -16
- package/dist/api/uninstall.d.ts +8 -1
- package/dist/api/uninstall.js +26 -5
- package/dist/cli.js +18 -17
- package/dist/core/audit.d.ts +1 -1
- package/dist/core/audit.js +5 -1
- package/dist/core/runner.d.ts +1 -1
- package/dist/core/runner.js +5 -2
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,7 +90,16 @@
|
|
|
90
90
|
|
|
91
91
|
## API
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
`linkany` 的 API 支持两种输入方式,合并为同一个入口:
|
|
94
|
+
|
|
95
|
+
- **文件模式**:`manifest` 传入 manifest 文件路径(string)
|
|
96
|
+
- **in-memory 模式**:`manifest` 直接传入 manifest JSON/对象
|
|
97
|
+
|
|
98
|
+
四个核心 API 的返回值统一为:
|
|
99
|
+
|
|
100
|
+
- `{ result, manifest }`(`result` 为本次操作结果,`manifest` 为操作后的 manifest 对象)
|
|
101
|
+
|
|
102
|
+
### `add(manifest, { source, target, kind?, atomic? }, opts?)`
|
|
94
103
|
|
|
95
104
|
用途:把一条映射写入 manifest,并把 `target` 收敛为指向 `source` 的 symlink。
|
|
96
105
|
|
|
@@ -103,7 +112,7 @@
|
|
|
103
112
|
- 再把 `target` 改成指向 `source` 的 symlink
|
|
104
113
|
- **source 与 target 同时存在**:拒绝(error),要求用户手动处理冲突。
|
|
105
114
|
|
|
106
|
-
### `remove(
|
|
115
|
+
### `remove(manifest, key, opts?)`
|
|
107
116
|
|
|
108
117
|
用途:从 manifest 移除一条映射,并且 **默认删除 target 的 symlink**。
|
|
109
118
|
|
|
@@ -111,7 +120,7 @@
|
|
|
111
120
|
- `opts.keepLink=true` 可仅移除 manifest 记录,不删除 target symlink。
|
|
112
121
|
- **永远不删除 source**。
|
|
113
122
|
|
|
114
|
-
### `install(
|
|
123
|
+
### `install(manifest, opts?)`
|
|
115
124
|
|
|
116
125
|
用途:按 manifest 全量落地,确保每个 `target` 都是指向 `source` 的 symlink。
|
|
117
126
|
|
|
@@ -121,15 +130,23 @@
|
|
|
121
130
|
- source 不存在
|
|
122
131
|
- target 存在但不是 symlink
|
|
123
132
|
|
|
124
|
-
### `uninstall(
|
|
133
|
+
### `uninstall(manifest, opts?)`
|
|
125
134
|
|
|
126
135
|
用途:按 manifest 全量撤销,只删除 `target` 的 symlink;**永远不删除 source**。
|
|
127
136
|
|
|
137
|
+
### in-memory 模式的额外 options
|
|
138
|
+
|
|
139
|
+
当 `manifest` 传入 JSON/对象(而不是路径)时,`opts` 额外支持:
|
|
140
|
+
|
|
141
|
+
- `baseDir?: string`:用于解析相对路径(默认 `process.cwd()`)
|
|
142
|
+
- `manifestPath?: string`:仅用于 `Result.manifestPath` 与 audit 默认路径推导(不会触发读写 manifest 文件)
|
|
143
|
+
|
|
128
144
|
## 审计日志(Audit Log)
|
|
129
145
|
|
|
130
146
|
- 默认写入:`${manifestPath}.log.jsonl`
|
|
131
147
|
- 每行是一条 JSON(完整 `Result`),包含:执行步骤、错误、耗时、变更摘要。
|
|
132
148
|
- 可通过 `opts.auditLogPath` 指定自定义路径。
|
|
149
|
+
- 如需完全关闭审计(由上层自行处理),传 `opts.audit=false`;CLI 对应 `--no-audit`。
|
|
133
150
|
|
|
134
151
|
## Options(opts)
|
|
135
152
|
|
package/dist/api/add.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { Manifest } from '../manifest/types.js';
|
|
1
2
|
import { CommonOptions, LinkKind, Result } from '../types.js';
|
|
3
|
+
import type { ManifestInputOptions } from './manifest-input.js';
|
|
2
4
|
export interface Mapping {
|
|
3
5
|
source: string;
|
|
4
6
|
target: string;
|
|
@@ -14,4 +16,9 @@ export interface Mapping {
|
|
|
14
16
|
* copy(target -> source), move original target aside to backup, then link target -> source.
|
|
15
17
|
* - If symlink creation fails => error (no copy fallback).
|
|
16
18
|
*/
|
|
17
|
-
export
|
|
19
|
+
export interface AddOptions extends CommonOptions, ManifestInputOptions {
|
|
20
|
+
}
|
|
21
|
+
export declare function add(manifest: string | unknown, mapping: Mapping, opts?: AddOptions): Promise<{
|
|
22
|
+
result: Result;
|
|
23
|
+
manifest: Manifest;
|
|
24
|
+
}>;
|
package/dist/api/add.js
CHANGED
|
@@ -5,6 +5,7 @@ import { planReplaceTargetWithTmp } from '../core/backup.js';
|
|
|
5
5
|
import { detectKind, isSymlinkTo, planCopy, planEnsureSource, planUnlink, tmpPathForTarget } from '../core/plan.js';
|
|
6
6
|
import { getManifestBaseDir } from '../manifest/types.js';
|
|
7
7
|
import { loadOrCreateManifest, saveManifest, upsertEntry } from '../manifest/io.js';
|
|
8
|
+
import { normalizeManifestJson, resolveBaseDir } from './manifest-input.js';
|
|
8
9
|
function mkLogger(opts) {
|
|
9
10
|
return opts?.logger;
|
|
10
11
|
}
|
|
@@ -33,26 +34,21 @@ function linkSteps(sourceAbs, targetAbs, kind, atomic) {
|
|
|
33
34
|
}
|
|
34
35
|
return steps;
|
|
35
36
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* - If symlink creation fails => error (no copy fallback).
|
|
44
|
-
*/
|
|
45
|
-
export async function add(manifestPath, mapping, opts) {
|
|
37
|
+
export async function add(manifest, mapping, opts) {
|
|
38
|
+
if (typeof manifest === 'string') {
|
|
39
|
+
return await addFromPath(manifest, mapping, opts);
|
|
40
|
+
}
|
|
41
|
+
return await addFromJson(manifest, mapping, opts);
|
|
42
|
+
}
|
|
43
|
+
async function addFromPath(manifestPath, mapping, opts) {
|
|
46
44
|
const baseDir = getManifestBaseDir(manifestPath);
|
|
47
45
|
const sourceAbs = path.isAbsolute(mapping.source) ? mapping.source : path.resolve(baseDir, mapping.source);
|
|
48
46
|
const targetAbs = path.isAbsolute(mapping.target) ? mapping.target : path.resolve(baseDir, mapping.target);
|
|
49
|
-
const sourceExists = await fs.pathExists(sourceAbs);
|
|
50
|
-
const targetExists = await fs.pathExists(targetAbs);
|
|
51
47
|
if (await isSymlinkTo(targetAbs, sourceAbs)) {
|
|
52
48
|
const manifest = await loadOrCreateManifest(manifestPath);
|
|
53
49
|
upsertEntry(manifest, { source: mapping.source, target: mapping.target, kind: mapping.kind, atomic: mapping.atomic });
|
|
54
50
|
await saveManifest(manifestPath, manifest);
|
|
55
|
-
|
|
51
|
+
const result = await runOperation({
|
|
56
52
|
operation: 'add',
|
|
57
53
|
manifestPath,
|
|
58
54
|
steps: [],
|
|
@@ -63,23 +59,28 @@ export async function add(manifestPath, mapping, opts) {
|
|
|
63
59
|
return res;
|
|
64
60
|
},
|
|
65
61
|
});
|
|
62
|
+
return { result, manifest };
|
|
66
63
|
}
|
|
64
|
+
const sourceExists = await fs.pathExists(sourceAbs);
|
|
65
|
+
const targetExists = await fs.pathExists(targetAbs);
|
|
67
66
|
if (sourceExists && targetExists) {
|
|
67
|
+
const current = await loadOrCreateManifest(manifestPath);
|
|
68
68
|
const res = mkResult('add', manifestPath);
|
|
69
69
|
res.ok = false;
|
|
70
70
|
res.errors.push(`Refusing to proceed: source and target both exist: source=${sourceAbs} target=${targetAbs}`);
|
|
71
71
|
res.steps.push({ kind: 'noop', message: 'Safety refusal', status: 'failed', error: res.errors[0] });
|
|
72
|
-
return res;
|
|
72
|
+
return { result: res, manifest: current };
|
|
73
73
|
}
|
|
74
74
|
let kind = mapping.kind ?? 'file';
|
|
75
75
|
if (targetExists) {
|
|
76
76
|
const st = await fs.lstat(targetAbs);
|
|
77
77
|
if (st.isSymbolicLink()) {
|
|
78
|
-
|
|
78
|
+
const current = await loadOrCreateManifest(manifestPath);
|
|
79
|
+
const res = mkResult('add', manifestPath);
|
|
79
80
|
res.ok = false;
|
|
80
81
|
res.errors.push(`Refusing to migrate: target is an existing symlink: ${targetAbs}`);
|
|
81
82
|
res.steps.push({ kind: 'noop', message: 'Safety refusal', status: 'failed', error: res.errors[0] });
|
|
82
|
-
return res;
|
|
83
|
+
return { result: res, manifest: current };
|
|
83
84
|
}
|
|
84
85
|
kind = st.isDirectory() ? 'dir' : 'file';
|
|
85
86
|
}
|
|
@@ -104,7 +105,7 @@ export async function add(manifestPath, mapping, opts) {
|
|
|
104
105
|
}
|
|
105
106
|
const manifest = await loadOrCreateManifest(manifestPath);
|
|
106
107
|
upsertEntry(manifest, { source: mapping.source, target: mapping.target, kind: kind, atomic });
|
|
107
|
-
|
|
108
|
+
const result = await runOperation({
|
|
108
109
|
operation: 'add',
|
|
109
110
|
manifestPath,
|
|
110
111
|
steps,
|
|
@@ -126,4 +127,115 @@ export async function add(manifestPath, mapping, opts) {
|
|
|
126
127
|
return res;
|
|
127
128
|
},
|
|
128
129
|
});
|
|
130
|
+
return { result, manifest };
|
|
131
|
+
}
|
|
132
|
+
async function addFromJson(manifestJson, mapping, opts) {
|
|
133
|
+
const manifest = normalizeManifestJson(manifestJson);
|
|
134
|
+
const baseDir = resolveBaseDir(opts);
|
|
135
|
+
const sourceAbs = path.isAbsolute(mapping.source) ? mapping.source : path.resolve(baseDir, mapping.source);
|
|
136
|
+
const targetAbs = path.isAbsolute(mapping.target) ? mapping.target : path.resolve(baseDir, mapping.target);
|
|
137
|
+
const sourceExists = await fs.pathExists(sourceAbs);
|
|
138
|
+
const targetExists = await fs.pathExists(targetAbs);
|
|
139
|
+
if (await isSymlinkTo(targetAbs, sourceAbs)) {
|
|
140
|
+
upsertEntry(manifest, { source: mapping.source, target: mapping.target, kind: mapping.kind, atomic: mapping.atomic });
|
|
141
|
+
const result = await runOperation({
|
|
142
|
+
operation: 'add',
|
|
143
|
+
manifestPath: opts?.manifestPath,
|
|
144
|
+
steps: [],
|
|
145
|
+
opts,
|
|
146
|
+
finalize: async (res) => {
|
|
147
|
+
res.steps.push({ kind: 'write_manifest', message: 'In-memory manifest; not writing to disk', status: 'skipped' });
|
|
148
|
+
res.changes.push({ action: 'manifest_upsert', source: sourceAbs, target: targetAbs });
|
|
149
|
+
return res;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
return { result, manifest };
|
|
153
|
+
}
|
|
154
|
+
if (sourceExists && targetExists) {
|
|
155
|
+
const now = new Date().toISOString();
|
|
156
|
+
const res = {
|
|
157
|
+
ok: false,
|
|
158
|
+
operation: 'add',
|
|
159
|
+
manifestPath: opts?.manifestPath,
|
|
160
|
+
startedAt: now,
|
|
161
|
+
finishedAt: now,
|
|
162
|
+
durationMs: 0,
|
|
163
|
+
steps: [{ kind: 'noop', message: 'Safety refusal', status: 'failed', error: `Refusing to proceed: source and target both exist: source=${sourceAbs} target=${targetAbs}` }],
|
|
164
|
+
warnings: [],
|
|
165
|
+
errors: [`Refusing to proceed: source and target both exist: source=${sourceAbs} target=${targetAbs}`],
|
|
166
|
+
changes: [],
|
|
167
|
+
};
|
|
168
|
+
return { result: res, manifest };
|
|
169
|
+
}
|
|
170
|
+
let kind = mapping.kind ?? 'file';
|
|
171
|
+
if (targetExists) {
|
|
172
|
+
const st = await fs.lstat(targetAbs);
|
|
173
|
+
if (st.isSymbolicLink()) {
|
|
174
|
+
const now = new Date().toISOString();
|
|
175
|
+
const res = {
|
|
176
|
+
ok: false,
|
|
177
|
+
operation: 'add',
|
|
178
|
+
manifestPath: opts?.manifestPath,
|
|
179
|
+
startedAt: now,
|
|
180
|
+
finishedAt: now,
|
|
181
|
+
durationMs: 0,
|
|
182
|
+
steps: [{ kind: 'noop', message: 'Safety refusal', status: 'failed', error: `Refusing to migrate: target is an existing symlink: ${targetAbs}` }],
|
|
183
|
+
warnings: [],
|
|
184
|
+
errors: [`Refusing to migrate: target is an existing symlink: ${targetAbs}`],
|
|
185
|
+
changes: [],
|
|
186
|
+
};
|
|
187
|
+
return { result: res, manifest };
|
|
188
|
+
}
|
|
189
|
+
kind = st.isDirectory() ? 'dir' : 'file';
|
|
190
|
+
}
|
|
191
|
+
else if (sourceExists) {
|
|
192
|
+
kind = await detectKind(sourceAbs);
|
|
193
|
+
}
|
|
194
|
+
const atomic = mapping.atomic ?? true;
|
|
195
|
+
const steps = [];
|
|
196
|
+
if (!sourceExists && targetExists) {
|
|
197
|
+
steps.push(...await planCopy({ fromAbs: targetAbs, toAbs: sourceAbs, kind, atomic }));
|
|
198
|
+
// Move original target to backup, then put symlink into place.
|
|
199
|
+
const { steps: replaceSteps } = planReplaceTargetWithTmp({ targetAbs, atomic: true });
|
|
200
|
+
steps.push(...replaceSteps.slice(0, 1)); // move target -> backup
|
|
201
|
+
steps.push(...(function linkSteps(sourceAbs, targetAbs, kind, atomic) {
|
|
202
|
+
const s = [];
|
|
203
|
+
s.push({ kind: 'mkdirp', message: 'Ensure target parent directory exists', paths: { dir: path.dirname(targetAbs) } });
|
|
204
|
+
const tmp = atomic ? tmpPathForTarget(targetAbs) : targetAbs;
|
|
205
|
+
s.push({ kind: 'symlink', message: atomic ? 'Create symlink at temp path' : 'Create symlink', paths: { source: sourceAbs, target: tmp, kind } });
|
|
206
|
+
if (atomic)
|
|
207
|
+
s.push({ kind: 'move', message: 'Atomically move temp symlink into place', paths: { from: tmp, to: targetAbs } });
|
|
208
|
+
return s;
|
|
209
|
+
})(sourceAbs, targetAbs, kind, atomic));
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
steps.push(...await planEnsureSource({ sourceAbs, kind }));
|
|
213
|
+
if (targetExists) {
|
|
214
|
+
steps.push(...await planUnlink({ targetAbs }));
|
|
215
|
+
}
|
|
216
|
+
steps.push(...(function linkSteps(sourceAbs, targetAbs, kind, atomic) {
|
|
217
|
+
const s = [];
|
|
218
|
+
s.push({ kind: 'mkdirp', message: 'Ensure target parent directory exists', paths: { dir: path.dirname(targetAbs) } });
|
|
219
|
+
const tmp = atomic ? tmpPathForTarget(targetAbs) : targetAbs;
|
|
220
|
+
s.push({ kind: 'symlink', message: atomic ? 'Create symlink at temp path' : 'Create symlink', paths: { source: sourceAbs, target: tmp, kind } });
|
|
221
|
+
if (atomic)
|
|
222
|
+
s.push({ kind: 'move', message: 'Atomically move temp symlink into place', paths: { from: tmp, to: targetAbs } });
|
|
223
|
+
return s;
|
|
224
|
+
})(sourceAbs, targetAbs, kind, atomic));
|
|
225
|
+
}
|
|
226
|
+
upsertEntry(manifest, { source: mapping.source, target: mapping.target, kind: kind, atomic });
|
|
227
|
+
const result = await runOperation({
|
|
228
|
+
operation: 'add',
|
|
229
|
+
manifestPath: opts?.manifestPath,
|
|
230
|
+
steps,
|
|
231
|
+
opts,
|
|
232
|
+
finalize: async (res) => {
|
|
233
|
+
if (!res.ok)
|
|
234
|
+
return res;
|
|
235
|
+
res.steps.push({ kind: 'write_manifest', message: 'In-memory manifest; not writing to disk', status: 'skipped' });
|
|
236
|
+
res.changes.push({ action: 'manifest_upsert', source: sourceAbs, target: targetAbs });
|
|
237
|
+
return res;
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
return { result, manifest };
|
|
129
241
|
}
|
package/dist/api/install.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import type { Manifest } from '../manifest/types.js';
|
|
1
2
|
import { CommonOptions, Result } from '../types.js';
|
|
3
|
+
import type { ManifestInputOptions } from './manifest-input.js';
|
|
2
4
|
/**
|
|
3
5
|
* Ensure all targets are symlinks to sources. Never mutates manifest.
|
|
4
6
|
* Safety: if any target exists and is not a symlink, abort without changes.
|
|
5
7
|
*/
|
|
6
|
-
export
|
|
8
|
+
export interface InstallOptions extends CommonOptions, ManifestInputOptions {
|
|
9
|
+
}
|
|
10
|
+
export declare function install(manifest: string | unknown, opts?: InstallOptions): Promise<{
|
|
11
|
+
result: Result;
|
|
12
|
+
manifest: Manifest;
|
|
13
|
+
}>;
|
package/dist/api/install.js
CHANGED
|
@@ -4,6 +4,7 @@ import { planReplaceTargetWithTmp } from '../core/backup.js';
|
|
|
4
4
|
import { runOperation } from '../core/runner.js';
|
|
5
5
|
import { detectKind, isSymlinkTo, tmpPathForTarget } from '../core/plan.js';
|
|
6
6
|
import { getManifestBaseDir, loadManifest, resolveEntry } from '../manifest/types.js';
|
|
7
|
+
import { normalizeManifestJson, resolveBaseDir } from './manifest-input.js';
|
|
7
8
|
function mkLogger(opts) {
|
|
8
9
|
return opts?.logger;
|
|
9
10
|
}
|
|
@@ -32,11 +33,13 @@ function linkSteps(sourceAbs, targetAbs, kind, atomic) {
|
|
|
32
33
|
}
|
|
33
34
|
return steps;
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
export async function install(manifest, opts) {
|
|
37
|
+
if (typeof manifest === 'string') {
|
|
38
|
+
return await installFromPath(manifest, opts);
|
|
39
|
+
}
|
|
40
|
+
return await installFromJson(manifest, opts);
|
|
41
|
+
}
|
|
42
|
+
async function installFromPath(manifestPath, opts) {
|
|
40
43
|
const logger = mkLogger(opts);
|
|
41
44
|
const result = await (async () => {
|
|
42
45
|
const manifest = await loadManifest(manifestPath);
|
|
@@ -49,7 +52,7 @@ export async function install(manifestPath, opts) {
|
|
|
49
52
|
res.ok = false;
|
|
50
53
|
res.errors.push(`Source missing: ${r.sourceAbs}`);
|
|
51
54
|
res.steps.push({ kind: 'noop', message: 'Source missing; aborting without changes', status: 'failed', error: res.errors[0], paths: { source: r.sourceAbs } });
|
|
52
|
-
return res;
|
|
55
|
+
return { result: res, manifest };
|
|
53
56
|
}
|
|
54
57
|
const targetExists = await fs.pathExists(r.targetAbs);
|
|
55
58
|
if (targetExists) {
|
|
@@ -59,7 +62,7 @@ export async function install(manifestPath, opts) {
|
|
|
59
62
|
res.ok = false;
|
|
60
63
|
res.errors.push(`Conflict: target exists and is not a symlink: ${r.targetAbs}`);
|
|
61
64
|
res.steps.push({ kind: 'noop', message: 'Conflict detected; aborting without changes', status: 'failed', error: res.errors[0], paths: { target: r.targetAbs } });
|
|
62
|
-
return res;
|
|
65
|
+
return { result: res, manifest };
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
const kind = r.kind ?? await detectKind(r.sourceAbs);
|
|
@@ -77,12 +80,60 @@ export async function install(manifestPath, opts) {
|
|
|
77
80
|
}
|
|
78
81
|
allSteps.push(...linkSteps(r.sourceAbs, r.targetAbs, kind, r.atomic));
|
|
79
82
|
}
|
|
80
|
-
|
|
83
|
+
const result = await runOperation({
|
|
81
84
|
operation: 'install',
|
|
82
85
|
manifestPath,
|
|
83
86
|
steps: allSteps,
|
|
84
87
|
opts,
|
|
85
88
|
});
|
|
89
|
+
return { result, manifest };
|
|
86
90
|
})();
|
|
91
|
+
logger; // keep logger referenced (existing pattern)
|
|
87
92
|
return result;
|
|
88
93
|
}
|
|
94
|
+
async function installFromJson(manifestJson, opts) {
|
|
95
|
+
const manifest = normalizeManifestJson(manifestJson);
|
|
96
|
+
const baseDir = resolveBaseDir(opts);
|
|
97
|
+
const allSteps = [];
|
|
98
|
+
for (const entry of manifest.installs) {
|
|
99
|
+
const r = resolveEntry(baseDir, entry);
|
|
100
|
+
if (!await fs.pathExists(r.sourceAbs)) {
|
|
101
|
+
const res = mkResult('install', opts?.manifestPath);
|
|
102
|
+
res.ok = false;
|
|
103
|
+
res.errors.push(`Source missing: ${r.sourceAbs}`);
|
|
104
|
+
res.steps.push({ kind: 'noop', message: 'Source missing; aborting without changes', status: 'failed', error: res.errors[0], paths: { source: r.sourceAbs } });
|
|
105
|
+
return { result: res, manifest };
|
|
106
|
+
}
|
|
107
|
+
const targetExists = await fs.pathExists(r.targetAbs);
|
|
108
|
+
if (targetExists) {
|
|
109
|
+
const st = await fs.lstat(r.targetAbs);
|
|
110
|
+
if (!st.isSymbolicLink()) {
|
|
111
|
+
const res = mkResult('install', opts?.manifestPath);
|
|
112
|
+
res.ok = false;
|
|
113
|
+
res.errors.push(`Conflict: target exists and is not a symlink: ${r.targetAbs}`);
|
|
114
|
+
res.steps.push({ kind: 'noop', message: 'Conflict detected; aborting without changes', status: 'failed', error: res.errors[0], paths: { target: r.targetAbs } });
|
|
115
|
+
return { result: res, manifest };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const kind = r.kind ?? await detectKind(r.sourceAbs);
|
|
119
|
+
if (await isSymlinkTo(r.targetAbs, r.sourceAbs))
|
|
120
|
+
continue;
|
|
121
|
+
if (targetExists) {
|
|
122
|
+
if (r.atomic) {
|
|
123
|
+
const { steps: replaceSteps } = planReplaceTargetWithTmp({ targetAbs: r.targetAbs, atomic: true });
|
|
124
|
+
allSteps.push(...replaceSteps.slice(0, 1));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
allSteps.push({ kind: 'unlink', message: 'Remove existing target symlink before linking', paths: { target: r.targetAbs } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
allSteps.push(...linkSteps(r.sourceAbs, r.targetAbs, kind, r.atomic));
|
|
131
|
+
}
|
|
132
|
+
const result = await runOperation({
|
|
133
|
+
operation: 'install',
|
|
134
|
+
manifestPath: opts?.manifestPath,
|
|
135
|
+
steps: allSteps,
|
|
136
|
+
opts,
|
|
137
|
+
});
|
|
138
|
+
return { result, manifest };
|
|
139
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Manifest } from '../manifest/types.js';
|
|
2
|
+
export interface ManifestInputOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Base dir for resolving relative paths in manifest entries / mappings.
|
|
5
|
+
* Default: process.cwd()
|
|
6
|
+
*/
|
|
7
|
+
baseDir?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Optional manifestPath for observability/audit only.
|
|
10
|
+
* - If provided, it will be set on Result.manifestPath.
|
|
11
|
+
* - If opts.auditLogPath is NOT provided, audit default path will use this manifestPath.
|
|
12
|
+
*/
|
|
13
|
+
manifestPath?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function normalizeManifestJson(raw: unknown): Manifest;
|
|
16
|
+
export declare function resolveBaseDir(opts?: ManifestInputOptions): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { normalizeManifest } from '../manifest/types.js';
|
|
3
|
+
export function normalizeManifestJson(raw) {
|
|
4
|
+
return normalizeManifest(raw);
|
|
5
|
+
}
|
|
6
|
+
export function resolveBaseDir(opts) {
|
|
7
|
+
return path.resolve(opts?.baseDir ?? process.cwd());
|
|
8
|
+
}
|
package/dist/api/remove.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { Manifest } from '../manifest/types.js';
|
|
1
2
|
import { CommonOptions, Result } from '../types.js';
|
|
3
|
+
import type { ManifestInputOptions } from './manifest-input.js';
|
|
2
4
|
export interface RemoveOptions extends CommonOptions {
|
|
3
5
|
/**
|
|
4
6
|
* Default: false. If true, do NOT delete the target link.
|
|
@@ -9,4 +11,9 @@ export interface RemoveOptions extends CommonOptions {
|
|
|
9
11
|
* remove: remove an entry from manifest, and by default delete the target symlink.
|
|
10
12
|
* Never deletes sources.
|
|
11
13
|
*/
|
|
12
|
-
export
|
|
14
|
+
export interface RemoveUnifiedOptions extends RemoveOptions, ManifestInputOptions {
|
|
15
|
+
}
|
|
16
|
+
export declare function remove(manifest: string | unknown, key: string, opts?: RemoveUnifiedOptions): Promise<{
|
|
17
|
+
result: Result;
|
|
18
|
+
manifest: Manifest;
|
|
19
|
+
}>;
|
package/dist/api/remove.js
CHANGED
|
@@ -3,29 +3,36 @@ import { runOperation } from '../core/runner.js';
|
|
|
3
3
|
import { planUnlink } from '../core/plan.js';
|
|
4
4
|
import { getManifestBaseDir, loadManifest, resolveEntry } from '../manifest/types.js';
|
|
5
5
|
import { removeEntry, saveManifest } from '../manifest/io.js';
|
|
6
|
+
import { normalizeManifestJson, resolveBaseDir } from './manifest-input.js';
|
|
6
7
|
function mkLogger(opts) {
|
|
7
8
|
return opts?.logger;
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
export async function remove(manifest, key, opts) {
|
|
11
|
+
if (typeof manifest === 'string') {
|
|
12
|
+
return await removeFromPath(manifest, key, opts);
|
|
13
|
+
}
|
|
14
|
+
return await removeFromJson(manifest, key, opts);
|
|
15
|
+
}
|
|
16
|
+
async function removeFromPath(manifestPath, key, opts) {
|
|
14
17
|
const manifest = await loadManifest(manifestPath);
|
|
15
18
|
const baseDir = getManifestBaseDir(manifestPath);
|
|
16
19
|
const entry = manifest.installs.find(e => (e.id && e.id === key) || e.target === key || (e.id || e.target) === key);
|
|
17
20
|
if (!entry) {
|
|
21
|
+
const now = new Date().toISOString();
|
|
18
22
|
return {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
result: {
|
|
24
|
+
ok: false,
|
|
25
|
+
operation: 'remove',
|
|
26
|
+
manifestPath,
|
|
27
|
+
startedAt: now,
|
|
28
|
+
finishedAt: now,
|
|
29
|
+
durationMs: 0,
|
|
30
|
+
steps: [],
|
|
31
|
+
warnings: [],
|
|
32
|
+
errors: [`Entry not found in manifest: ${key}`],
|
|
33
|
+
changes: [],
|
|
34
|
+
},
|
|
35
|
+
manifest,
|
|
29
36
|
};
|
|
30
37
|
}
|
|
31
38
|
const r = resolveEntry(baseDir, entry);
|
|
@@ -36,7 +43,7 @@ export async function remove(manifestPath, key, opts) {
|
|
|
36
43
|
else {
|
|
37
44
|
steps.push({ kind: 'noop', message: 'keepLink=true; not unlinking target', status: 'skipped', paths: { target: r.targetAbs } });
|
|
38
45
|
}
|
|
39
|
-
|
|
46
|
+
const result = await runOperation({
|
|
40
47
|
operation: 'remove',
|
|
41
48
|
manifestPath,
|
|
42
49
|
steps,
|
|
@@ -59,4 +66,51 @@ export async function remove(manifestPath, key, opts) {
|
|
|
59
66
|
return res;
|
|
60
67
|
},
|
|
61
68
|
});
|
|
69
|
+
return { result, manifest };
|
|
70
|
+
}
|
|
71
|
+
async function removeFromJson(manifestJson, key, opts) {
|
|
72
|
+
const manifest = normalizeManifestJson(manifestJson);
|
|
73
|
+
const baseDir = resolveBaseDir(opts);
|
|
74
|
+
const entry = manifest.installs.find(e => (e.id && e.id === key) || e.target === key || (e.id || e.target) === key);
|
|
75
|
+
if (!entry) {
|
|
76
|
+
const now = new Date().toISOString();
|
|
77
|
+
return {
|
|
78
|
+
result: {
|
|
79
|
+
ok: false,
|
|
80
|
+
operation: 'remove',
|
|
81
|
+
manifestPath: opts?.manifestPath,
|
|
82
|
+
startedAt: now,
|
|
83
|
+
finishedAt: now,
|
|
84
|
+
durationMs: 0,
|
|
85
|
+
steps: [],
|
|
86
|
+
warnings: [],
|
|
87
|
+
errors: [`Entry not found in manifest: ${key}`],
|
|
88
|
+
changes: [],
|
|
89
|
+
},
|
|
90
|
+
manifest,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const r = resolveEntry(baseDir, entry);
|
|
94
|
+
const steps = [];
|
|
95
|
+
if (!opts?.keepLink) {
|
|
96
|
+
steps.push(...await planUnlink({ targetAbs: r.targetAbs }));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
steps.push({ kind: 'noop', message: 'keepLink=true; not unlinking target', status: 'skipped', paths: { target: r.targetAbs } });
|
|
100
|
+
}
|
|
101
|
+
const result = await runOperation({
|
|
102
|
+
operation: 'remove',
|
|
103
|
+
manifestPath: opts?.manifestPath,
|
|
104
|
+
steps,
|
|
105
|
+
opts,
|
|
106
|
+
finalize: async (res) => {
|
|
107
|
+
if (!res.ok)
|
|
108
|
+
return res;
|
|
109
|
+
removeEntry(manifest, entry.id || entry.target);
|
|
110
|
+
res.steps.push({ kind: 'write_manifest', message: 'In-memory manifest; not writing to disk', status: 'skipped' });
|
|
111
|
+
res.changes.push({ action: 'manifest_remove', target: r.targetAbs });
|
|
112
|
+
return res;
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
return { result, manifest };
|
|
62
116
|
}
|
package/dist/api/uninstall.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import type { Manifest } from '../manifest/types.js';
|
|
1
2
|
import { CommonOptions, Result } from '../types.js';
|
|
3
|
+
import type { ManifestInputOptions } from './manifest-input.js';
|
|
2
4
|
/**
|
|
3
5
|
* Remove all target symlinks listed in manifest. Never deletes sources.
|
|
4
6
|
*/
|
|
5
|
-
export
|
|
7
|
+
export interface UninstallOptions extends CommonOptions, ManifestInputOptions {
|
|
8
|
+
}
|
|
9
|
+
export declare function uninstall(manifest: string | unknown, opts?: UninstallOptions): Promise<{
|
|
10
|
+
result: Result;
|
|
11
|
+
manifest: Manifest;
|
|
12
|
+
}>;
|
package/dist/api/uninstall.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { runOperation } from '../core/runner.js';
|
|
2
2
|
import { planUnlink } from '../core/plan.js';
|
|
3
3
|
import { getManifestBaseDir, loadManifest, resolveEntry } from '../manifest/types.js';
|
|
4
|
+
import { normalizeManifestJson, resolveBaseDir } from './manifest-input.js';
|
|
4
5
|
function mkLogger(opts) {
|
|
5
6
|
return opts?.logger;
|
|
6
7
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
export async function uninstall(manifest, opts) {
|
|
9
|
+
if (typeof manifest === 'string') {
|
|
10
|
+
return await uninstallFromPath(manifest, opts);
|
|
11
|
+
}
|
|
12
|
+
return await uninstallFromJson(manifest, opts);
|
|
13
|
+
}
|
|
14
|
+
async function uninstallFromPath(manifestPath, opts) {
|
|
11
15
|
const manifest = await loadManifest(manifestPath);
|
|
12
16
|
const baseDir = getManifestBaseDir(manifestPath);
|
|
13
17
|
const allSteps = [];
|
|
@@ -15,10 +19,27 @@ export async function uninstall(manifestPath, opts) {
|
|
|
15
19
|
const r = resolveEntry(baseDir, entry);
|
|
16
20
|
allSteps.push(...await planUnlink({ targetAbs: r.targetAbs }));
|
|
17
21
|
}
|
|
18
|
-
|
|
22
|
+
const result = await runOperation({
|
|
19
23
|
operation: 'uninstall',
|
|
20
24
|
manifestPath,
|
|
21
25
|
steps: allSteps,
|
|
22
26
|
opts,
|
|
23
27
|
});
|
|
28
|
+
return { result, manifest };
|
|
29
|
+
}
|
|
30
|
+
async function uninstallFromJson(manifestJson, opts) {
|
|
31
|
+
const manifest = normalizeManifestJson(manifestJson);
|
|
32
|
+
const baseDir = resolveBaseDir(opts);
|
|
33
|
+
const allSteps = [];
|
|
34
|
+
for (const entry of manifest.installs) {
|
|
35
|
+
const r = resolveEntry(baseDir, entry);
|
|
36
|
+
allSteps.push(...await planUnlink({ targetAbs: r.targetAbs }));
|
|
37
|
+
}
|
|
38
|
+
const result = await runOperation({
|
|
39
|
+
operation: 'uninstall',
|
|
40
|
+
manifestPath: opts?.manifestPath,
|
|
41
|
+
steps: allSteps,
|
|
42
|
+
opts,
|
|
43
|
+
});
|
|
44
|
+
return { result, manifest };
|
|
24
45
|
}
|
package/dist/cli.js
CHANGED
|
@@ -41,7 +41,8 @@ function parseCommonOptions(args) {
|
|
|
41
41
|
const dryRun = hasFlag(args, ['--dry-run']);
|
|
42
42
|
const includePlanText = hasFlag(args, ['--plan']);
|
|
43
43
|
const auditLogPath = popFlagValue(args, ['--audit-log']);
|
|
44
|
-
|
|
44
|
+
const audit = hasFlag(args, ['--no-audit']) ? false : undefined;
|
|
45
|
+
return { dryRun, includePlanText, auditLogPath, audit };
|
|
45
46
|
}
|
|
46
47
|
async function resolveManifestPath(args) {
|
|
47
48
|
const m = popFlagValue(args, ['-m', '--manifest']);
|
|
@@ -61,10 +62,10 @@ Usage:
|
|
|
61
62
|
linkany manifest show
|
|
62
63
|
linkany manifest clear
|
|
63
64
|
|
|
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]
|
|
65
|
+
linkany add --source <path> --target <path> [--kind file|dir] [--atomic|--no-atomic] [-m <manifest>] [--dry-run] [--plan] [--no-audit]
|
|
66
|
+
linkany remove <key> [--keep-link] [-m <manifest>] [--dry-run] [--plan] [--no-audit]
|
|
67
|
+
linkany install [-m <manifest>] [--dry-run] [--plan] [--no-audit]
|
|
68
|
+
linkany uninstall [-m <manifest>] [--dry-run] [--plan] [--no-audit]
|
|
68
69
|
`;
|
|
69
70
|
process.stdout.write(msg.trimStart());
|
|
70
71
|
process.stdout.write('\n');
|
|
@@ -123,9 +124,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
123
124
|
const mapping = parseAddArgs(args);
|
|
124
125
|
if (args.length)
|
|
125
126
|
die(`Unknown arguments: ${args.join(' ')}`);
|
|
126
|
-
const
|
|
127
|
-
process.stdout.write(JSON.stringify(
|
|
128
|
-
return
|
|
127
|
+
const { result } = await add(manifestPath, mapping, opts);
|
|
128
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
129
|
+
return result.ok ? 0 : 1;
|
|
129
130
|
}
|
|
130
131
|
if (cmd === 'remove') {
|
|
131
132
|
const opts = parseCommonOptions(args);
|
|
@@ -136,27 +137,27 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
136
137
|
die('remove requires <key>');
|
|
137
138
|
if (args.length)
|
|
138
139
|
die(`Unknown arguments: ${args.join(' ')}`);
|
|
139
|
-
const
|
|
140
|
-
process.stdout.write(JSON.stringify(
|
|
141
|
-
return
|
|
140
|
+
const { result } = await remove(manifestPath, key, { ...opts, keepLink });
|
|
141
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
142
|
+
return result.ok ? 0 : 1;
|
|
142
143
|
}
|
|
143
144
|
if (cmd === 'install') {
|
|
144
145
|
const opts = parseCommonOptions(args);
|
|
145
146
|
const manifestPath = await resolveManifestPath(args);
|
|
146
147
|
if (args.length)
|
|
147
148
|
die(`Unknown arguments: ${args.join(' ')}`);
|
|
148
|
-
const
|
|
149
|
-
process.stdout.write(JSON.stringify(
|
|
150
|
-
return
|
|
149
|
+
const { result } = await install(manifestPath, opts);
|
|
150
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
151
|
+
return result.ok ? 0 : 1;
|
|
151
152
|
}
|
|
152
153
|
if (cmd === 'uninstall') {
|
|
153
154
|
const opts = parseCommonOptions(args);
|
|
154
155
|
const manifestPath = await resolveManifestPath(args);
|
|
155
156
|
if (args.length)
|
|
156
157
|
die(`Unknown arguments: ${args.join(' ')}`);
|
|
157
|
-
const
|
|
158
|
-
process.stdout.write(JSON.stringify(
|
|
159
|
-
return
|
|
158
|
+
const { result } = await uninstall(manifestPath, opts);
|
|
159
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
160
|
+
return result.ok ? 0 : 1;
|
|
160
161
|
}
|
|
161
162
|
die(`Unknown command: ${cmd}`);
|
|
162
163
|
}
|
package/dist/core/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { CommonOptions, Result } from '../types.js';
|
|
2
2
|
export declare function defaultAuditLogPath(manifestPath: string, opts?: CommonOptions): string;
|
|
3
3
|
export declare function appendAudit(logPath: string, result: Result): Promise<void>;
|
|
4
|
-
export declare function tryAppendAuditStep(result: Result, manifestPath
|
|
4
|
+
export declare function tryAppendAuditStep(result: Result, manifestPath?: string, opts?: CommonOptions): Promise<Result>;
|
package/dist/core/audit.js
CHANGED
|
@@ -9,7 +9,11 @@ export async function appendAudit(logPath, result) {
|
|
|
9
9
|
await fs.appendFile(logPath, line, 'utf8');
|
|
10
10
|
}
|
|
11
11
|
export async function tryAppendAuditStep(result, manifestPath, opts) {
|
|
12
|
-
|
|
12
|
+
if (opts?.audit === false)
|
|
13
|
+
return result;
|
|
14
|
+
const logPath = opts?.auditLogPath ?? (manifestPath ? `${path.resolve(manifestPath)}.log.jsonl` : undefined);
|
|
15
|
+
if (!logPath)
|
|
16
|
+
return result;
|
|
13
17
|
try {
|
|
14
18
|
await appendAudit(logPath, result);
|
|
15
19
|
result.steps.push({ kind: 'audit', message: 'Append audit log', status: 'executed', paths: { file: logPath } });
|
package/dist/core/runner.d.ts
CHANGED
package/dist/core/runner.js
CHANGED
|
@@ -13,7 +13,8 @@ export async function runOperation(input) {
|
|
|
13
13
|
dryRun: input.opts?.dryRun,
|
|
14
14
|
});
|
|
15
15
|
res.operation = input.operation;
|
|
16
|
-
|
|
16
|
+
if (input.manifestPath)
|
|
17
|
+
res.manifestPath = input.manifestPath;
|
|
17
18
|
res.startedAt = startedAt;
|
|
18
19
|
res.durationMs = Date.now() - startedMs;
|
|
19
20
|
res.finishedAt = nowIso();
|
|
@@ -23,7 +24,9 @@ export async function runOperation(input) {
|
|
|
23
24
|
if (input.finalize) {
|
|
24
25
|
res = await input.finalize(res);
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
if (input.opts?.audit !== false) {
|
|
28
|
+
res = await tryAppendAuditStep(res, input.manifestPath, input.opts);
|
|
29
|
+
}
|
|
27
30
|
logger?.info?.(`[linkany] ${input.operation} ${res.ok ? 'ok' : 'fail'} (${res.durationMs}ms)`);
|
|
28
31
|
return res;
|
|
29
32
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -55,6 +55,13 @@ export interface Logger {
|
|
|
55
55
|
error(msg: string): void;
|
|
56
56
|
}
|
|
57
57
|
export interface CommonOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Whether to append an audit log entry for this operation.
|
|
60
|
+
* Default: true.
|
|
61
|
+
*
|
|
62
|
+
* Set to false if the caller wants to fully disable audit logging and handle it themselves.
|
|
63
|
+
*/
|
|
64
|
+
audit?: boolean;
|
|
58
65
|
/**
|
|
59
66
|
* If provided, we append one JSON line per operation (Result summary).
|
|
60
67
|
* Default strategy (V1): `${manifestPath}.log.jsonl` when manifestPath is known.
|