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 CHANGED
@@ -90,7 +90,16 @@
90
90
 
91
91
  ## API
92
92
 
93
- ### `add(manifestPath, { source, target, kind?, atomic? }, opts?)`
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(manifestPath, key, opts?)`
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(manifestPath, opts?)`
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(manifestPath, opts?)`
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 declare function add(manifestPath: string, mapping: Mapping, opts?: CommonOptions): Promise<Result>;
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
- * add: write manifest + converge target to symlink(source).
38
- *
39
- * Safety rules:
40
- * - If source and target both exist AND target is not already a symlink to source => reject.
41
- * - If target exists and is not a symlink AND source is missing => migrate:
42
- * copy(target -> source), move original target aside to backup, then link target -> source.
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
- return await runOperation({
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
- let res = mkResult('add', manifestPath);
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
- return await runOperation({
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
  }
@@ -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 declare function install(manifestPath: string, opts?: CommonOptions): Promise<Result>;
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
+ }>;
@@ -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
- * Ensure all targets are symlinks to sources. Never mutates manifest.
37
- * Safety: if any target exists and is not a symlink, abort without changes.
38
- */
39
- export async function install(manifestPath, opts) {
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
- return await runOperation({
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
+ }
@@ -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 declare function remove(manifestPath: string, key: string, opts?: RemoveOptions): Promise<Result>;
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
+ }>;
@@ -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
- * remove: remove an entry from manifest, and by default delete the target symlink.
11
- * Never deletes sources.
12
- */
13
- export async function remove(manifestPath, key, opts) {
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
- ok: false,
20
- operation: 'remove',
21
- manifestPath,
22
- startedAt: new Date().toISOString(),
23
- finishedAt: new Date().toISOString(),
24
- durationMs: 0,
25
- steps: [],
26
- warnings: [],
27
- errors: [`Entry not found in manifest: ${key}`],
28
- changes: [],
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
- return await runOperation({
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
  }
@@ -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 declare function uninstall(manifestPath: string, opts?: CommonOptions): Promise<Result>;
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
+ }>;
@@ -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
- * Remove all target symlinks listed in manifest. Never deletes sources.
9
- */
10
- export async function uninstall(manifestPath, opts) {
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
- return await runOperation({
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
- return { dryRun, includePlanText, auditLogPath };
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 res = await add(manifestPath, mapping, opts);
127
- process.stdout.write(JSON.stringify(res, null, 2) + '\n');
128
- return res.ok ? 0 : 1;
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 res = await remove(manifestPath, key, { ...opts, keepLink });
140
- process.stdout.write(JSON.stringify(res, null, 2) + '\n');
141
- return res.ok ? 0 : 1;
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 res = await install(manifestPath, opts);
149
- process.stdout.write(JSON.stringify(res, null, 2) + '\n');
150
- return res.ok ? 0 : 1;
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 res = await uninstall(manifestPath, opts);
158
- process.stdout.write(JSON.stringify(res, null, 2) + '\n');
159
- return res.ok ? 0 : 1;
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
  }
@@ -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: string, opts?: CommonOptions): Promise<Result>;
4
+ export declare function tryAppendAuditStep(result: Result, manifestPath?: string, opts?: CommonOptions): Promise<Result>;
@@ -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
- const logPath = defaultAuditLogPath(manifestPath, opts);
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 } });
@@ -1,7 +1,7 @@
1
1
  import { CommonOptions, Result, Step } from '../types.js';
2
2
  export interface RunOperationInput {
3
3
  operation: Result['operation'];
4
- manifestPath: string;
4
+ manifestPath?: string;
5
5
  steps: Step[];
6
6
  opts?: CommonOptions;
7
7
  /**
@@ -13,7 +13,8 @@ export async function runOperation(input) {
13
13
  dryRun: input.opts?.dryRun,
14
14
  });
15
15
  res.operation = input.operation;
16
- res.manifestPath = input.manifestPath;
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
- res = await tryAppendAuditStep(res, input.manifestPath, input.opts);
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkany",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",