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 ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # linkany
2
+
3
+ `linkany` 是一个 **macOS/Linux** 上的“安全 symlink 管理器”,围绕一个 `manifest` 文件维护一组“source ↔ target”的链接关系,并提供四个高层 API:
4
+
5
+ - `add(manifestPath, { source, target, ... })`
6
+ - `remove(manifestPath, key, opts?)`
7
+ - `install(manifestPath, opts?)`
8
+ - `uninstall(manifestPath, opts?)`
9
+
10
+ 它的设计原则是:**安全第一、可追溯、默认拒绝任何可能导致数据丢失的行为**。
11
+
12
+ ## CLI(命令行)
13
+
14
+ `linkany` 同时提供 **库 API** 与 **CLI**。CLI 的核心设计是:支持设置一个“全局默认 manifest”,让你后续无需重复传 `--manifest`。
15
+
16
+ ### 设置/查看默认 manifest
17
+
18
+ - 设置默认 manifest(写入全局配置,路径会被 resolve 成绝对路径):
19
+ - `linkany manifest set ./linkany.manifest.json`
20
+ - 查看当前默认 manifest:
21
+ - `linkany manifest show`
22
+ - 清空默认 manifest:
23
+ - `linkany manifest clear`
24
+
25
+ ### 在命令中使用 manifest(优先级)
26
+
27
+ - **优先级**:`-m/--manifest <path>`(单次覆盖) > 全局默认 manifest
28
+ - 示例:
29
+ - 使用默认 manifest:`linkany install`
30
+ - 单次覆盖:`linkany install -m ./other.manifest.json`
31
+
32
+ ### 常用命令
33
+
34
+ - `linkany add --source <path> --target <path> [--kind file|dir] [--atomic|--no-atomic] [-m <manifest>] [--dry-run] [--plan]`
35
+ - `linkany remove <key> [--keep-link] [-m <manifest>] [--dry-run] [--plan]`
36
+ - `linkany install [-m <manifest>] [--dry-run] [--plan]`
37
+ - `linkany uninstall [-m <manifest>] [--dry-run] [--plan]`
38
+
39
+ ### 全局配置文件路径(XDG)
40
+
41
+ - 若设置了 `XDG_CONFIG_HOME`:`$XDG_CONFIG_HOME/linkany/config.json`
42
+ - 否则:`~/.config/linkany/config.json`
43
+ - 格式:
44
+
45
+ ```json
46
+ { "manifestPath": "/abs/path/to/manifest.json" }
47
+ ```
48
+
49
+ ## 能力概览
50
+
51
+ - **仅使用 symlink**:如果 symlink 失败(权限/文件系统限制等),直接报错,不会退化为 copy 安装。
52
+ - **文件 & 目录**:同时支持文件和目录链接。
53
+ - **安全策略**:
54
+ - `add`:当 `source` 和 `target` 同时存在(且 `target` 不是指向 `source` 的 symlink)时 **拒绝**。
55
+ - `remove/uninstall`:只会删除 `target` 的 symlink,**绝不删除 source**。
56
+ - `install`:如果发现某个 `target` 存在但不是 symlink,会 **整体 abort**,避免误伤真实文件/目录。
57
+ - **原子性(尽力而为)**:
58
+ - 创建/替换 symlink 时优先使用 `target.tmp.<rand>`,再 `rename` 到位。
59
+ - 替换已有 symlink 时会优先把旧 target 移到 `target.bak.<timestamp>.<rand>`(便于恢复)。
60
+ - **审计记录(有记录的)**:每次调用都会把 `Result` 追加写入 JSONL 文件,默认路径为 `${manifestPath}.log.jsonl`。
61
+ - **dry-run / plan 输出**:
62
+ - `opts.dryRun=true` 时不触发任何文件系统写操作(不 symlink/rename/unlink),只返回计划与结果结构。
63
+ - `opts.includePlanText=true` 时会在 `Result.planText` 中附带可读的 plan 文本。
64
+ - **rollback 协议(best-effort)**:`Result.rollbackSteps` 会尽力给出“可逆步骤”的回滚计划(例如 move/symlink 的逆操作)。目前是协议与数据结构,未提供一键 rollback API。
65
+
66
+ ## Manifest 格式(v1)
67
+
68
+ ```json
69
+ {
70
+ "version": 1,
71
+ "installs": [
72
+ {
73
+ "id": "optional-stable-id",
74
+ "source": "path/to/source",
75
+ "target": "path/to/target",
76
+ "kind": "file",
77
+ "atomic": true
78
+ }
79
+ ]
80
+ }
81
+ ```
82
+
83
+ 说明:
84
+
85
+ - `source/target` 支持绝对路径或相对路径;相对路径以 **manifest 文件所在目录** 为基准。
86
+ - `id` 可选;如果没有 `id`,内部默认以 `target` 作为该条目的 identity(用于 remove)。
87
+ - `kind` 可选:`file | dir`。不写时,`add` 会尽力推断;`install` 会从 source 的实际类型推断。
88
+ - `atomic` 默认 `true`。
89
+ - 允许存在额外字段(`linkany` 会尽量保留并写回)。
90
+
91
+ ## API
92
+
93
+ ### `add(manifestPath, { source, target, kind?, atomic? }, opts?)`
94
+
95
+ 用途:把一条映射写入 manifest,并把 `target` 收敛为指向 `source` 的 symlink。
96
+
97
+ 核心语义:
98
+
99
+ - **source 不存在**:自动创建空 source(文件:空文件;目录:空目录)。
100
+ - **target 已存在且不是 symlink、source 不存在**:会执行一次“安全迁移”:
101
+ - copy `target -> source`
102
+ - 将原 `target` 移到 `target.bak.<timestamp>.<rand>`
103
+ - 再把 `target` 改成指向 `source` 的 symlink
104
+ - **source 与 target 同时存在**:拒绝(error),要求用户手动处理冲突。
105
+
106
+ ### `remove(manifestPath, key, opts?)`
107
+
108
+ 用途:从 manifest 移除一条映射,并且 **默认删除 target 的 symlink**。
109
+
110
+ - `key`:优先匹配 `id`,否则匹配 `target`。
111
+ - `opts.keepLink=true` 可仅移除 manifest 记录,不删除 target symlink。
112
+ - **永远不删除 source**。
113
+
114
+ ### `install(manifestPath, opts?)`
115
+
116
+ 用途:按 manifest 全量落地,确保每个 `target` 都是指向 `source` 的 symlink。
117
+
118
+ 安全策略:
119
+
120
+ - 任意一条出现以下情况,都会 **abort 且不做任何变更**:
121
+ - source 不存在
122
+ - target 存在但不是 symlink
123
+
124
+ ### `uninstall(manifestPath, opts?)`
125
+
126
+ 用途:按 manifest 全量撤销,只删除 `target` 的 symlink;**永远不删除 source**。
127
+
128
+ ## 审计日志(Audit Log)
129
+
130
+ - 默认写入:`${manifestPath}.log.jsonl`
131
+ - 每行是一条 JSON(完整 `Result`),包含:执行步骤、错误、耗时、变更摘要。
132
+ - 可通过 `opts.auditLogPath` 指定自定义路径。
133
+
134
+ ## Options(opts)
135
+
136
+ 适用于四个 API 的通用 options(`CommonOptions`):
137
+
138
+ - `auditLogPath?: string`:覆盖默认审计日志路径。
139
+ - `dryRun?: boolean`:只返回计划/结果,不写文件系统。
140
+ - `includePlanText?: boolean`:在 `Result.planText` 中包含可读 plan 文本。
141
+ - `logger?: { info/warn/error }`:注入日志实现(可选)。
142
+
143
+ ## 目录结构(维护者)
144
+
145
+ ```text
146
+ src/
147
+ api/ # 4 个对外操作,分别一个文件
148
+ core/ # 执行引擎:plan/apply/fs/audit/runner/backup
149
+ manifest/ # manifest 类型与读写(写回保持未知字段)
150
+ cli/ # CLI 相关模块(全局配置等)
151
+ cli.ts # CLI 入口(argv 解析与命令分发)
152
+ index.ts # 对外统一导出
153
+ types.ts # 公共类型(Result/Step/Options)
154
+ ```
155
+
156
+ 更详细的维护说明见 `KNOWLEDGE_BASE.md`。
@@ -0,0 +1,17 @@
1
+ import { CommonOptions, LinkKind, Result } from '../types.js';
2
+ export interface Mapping {
3
+ source: string;
4
+ target: string;
5
+ kind?: LinkKind;
6
+ atomic?: boolean;
7
+ }
8
+ /**
9
+ * add: write manifest + converge target to symlink(source).
10
+ *
11
+ * Safety rules:
12
+ * - If source and target both exist AND target is not already a symlink to source => reject.
13
+ * - If target exists and is not a symlink AND source is missing => migrate:
14
+ * copy(target -> source), move original target aside to backup, then link target -> source.
15
+ * - If symlink creation fails => error (no copy fallback).
16
+ */
17
+ export declare function add(manifestPath: string, mapping: Mapping, opts?: CommonOptions): Promise<Result>;
@@ -0,0 +1,129 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { runOperation } from '../core/runner.js';
4
+ import { planReplaceTargetWithTmp } from '../core/backup.js';
5
+ import { detectKind, isSymlinkTo, planCopy, planEnsureSource, planUnlink, tmpPathForTarget } from '../core/plan.js';
6
+ import { getManifestBaseDir } from '../manifest/types.js';
7
+ import { loadOrCreateManifest, saveManifest, upsertEntry } from '../manifest/io.js';
8
+ function mkLogger(opts) {
9
+ return opts?.logger;
10
+ }
11
+ function mkResult(operation, manifestPath) {
12
+ const now = new Date();
13
+ return {
14
+ ok: true,
15
+ operation,
16
+ manifestPath,
17
+ startedAt: now.toISOString(),
18
+ finishedAt: now.toISOString(),
19
+ durationMs: 0,
20
+ steps: [],
21
+ warnings: [],
22
+ errors: [],
23
+ changes: [],
24
+ };
25
+ }
26
+ function linkSteps(sourceAbs, targetAbs, kind, atomic) {
27
+ const steps = [];
28
+ steps.push({ kind: 'mkdirp', message: 'Ensure target parent directory exists', paths: { dir: path.dirname(targetAbs) } });
29
+ const tmp = atomic ? tmpPathForTarget(targetAbs) : targetAbs;
30
+ steps.push({ kind: 'symlink', message: atomic ? 'Create symlink at temp path' : 'Create symlink', paths: { source: sourceAbs, target: tmp, kind } });
31
+ if (atomic) {
32
+ steps.push({ kind: 'move', message: 'Atomically move temp symlink into place', paths: { from: tmp, to: targetAbs } });
33
+ }
34
+ return steps;
35
+ }
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) {
46
+ const baseDir = getManifestBaseDir(manifestPath);
47
+ const sourceAbs = path.isAbsolute(mapping.source) ? mapping.source : path.resolve(baseDir, mapping.source);
48
+ 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
+ if (await isSymlinkTo(targetAbs, sourceAbs)) {
52
+ const manifest = await loadOrCreateManifest(manifestPath);
53
+ upsertEntry(manifest, { source: mapping.source, target: mapping.target, kind: mapping.kind, atomic: mapping.atomic });
54
+ await saveManifest(manifestPath, manifest);
55
+ return await runOperation({
56
+ operation: 'add',
57
+ manifestPath,
58
+ steps: [],
59
+ opts,
60
+ finalize: async (res) => {
61
+ res.steps.push({ kind: 'write_manifest', message: 'Update manifest', status: 'executed', paths: { file: path.resolve(manifestPath) } });
62
+ res.changes.push({ action: 'manifest_upsert', source: sourceAbs, target: targetAbs });
63
+ return res;
64
+ },
65
+ });
66
+ }
67
+ if (sourceExists && targetExists) {
68
+ const res = mkResult('add', manifestPath);
69
+ res.ok = false;
70
+ res.errors.push(`Refusing to proceed: source and target both exist: source=${sourceAbs} target=${targetAbs}`);
71
+ res.steps.push({ kind: 'noop', message: 'Safety refusal', status: 'failed', error: res.errors[0] });
72
+ return res;
73
+ }
74
+ let kind = mapping.kind ?? 'file';
75
+ if (targetExists) {
76
+ const st = await fs.lstat(targetAbs);
77
+ if (st.isSymbolicLink()) {
78
+ let res = mkResult('add', manifestPath);
79
+ res.ok = false;
80
+ res.errors.push(`Refusing to migrate: target is an existing symlink: ${targetAbs}`);
81
+ res.steps.push({ kind: 'noop', message: 'Safety refusal', status: 'failed', error: res.errors[0] });
82
+ return res;
83
+ }
84
+ kind = st.isDirectory() ? 'dir' : 'file';
85
+ }
86
+ else if (sourceExists) {
87
+ kind = await detectKind(sourceAbs);
88
+ }
89
+ const atomic = mapping.atomic ?? true;
90
+ const steps = [];
91
+ if (!sourceExists && targetExists) {
92
+ steps.push(...await planCopy({ fromAbs: targetAbs, toAbs: sourceAbs, kind, atomic }));
93
+ // Move original target to backup, then put symlink into place.
94
+ const { tmpAbs, steps: replaceSteps } = planReplaceTargetWithTmp({ targetAbs, atomic: true });
95
+ steps.push(...replaceSteps.slice(0, 1)); // move target -> backup
96
+ steps.push(...linkSteps(sourceAbs, targetAbs, kind, atomic)); // creates tmp + move tmp -> target
97
+ }
98
+ else {
99
+ steps.push(...await planEnsureSource({ sourceAbs, kind }));
100
+ if (targetExists) {
101
+ steps.push(...await planUnlink({ targetAbs }));
102
+ }
103
+ steps.push(...linkSteps(sourceAbs, targetAbs, kind, atomic));
104
+ }
105
+ const manifest = await loadOrCreateManifest(manifestPath);
106
+ upsertEntry(manifest, { source: mapping.source, target: mapping.target, kind: kind, atomic });
107
+ return await runOperation({
108
+ operation: 'add',
109
+ manifestPath,
110
+ steps,
111
+ opts,
112
+ finalize: async (res) => {
113
+ if (!res.ok)
114
+ return res;
115
+ try {
116
+ await saveManifest(manifestPath, manifest);
117
+ res.steps.push({ kind: 'write_manifest', message: 'Update manifest', status: 'executed', paths: { file: path.resolve(manifestPath) } });
118
+ res.changes.push({ action: 'manifest_upsert', source: sourceAbs, target: targetAbs });
119
+ }
120
+ catch (e) {
121
+ const msg = e?.message ? String(e.message) : String(e);
122
+ res.ok = false;
123
+ res.errors.push(`Failed to write manifest: ${msg}`);
124
+ res.steps.push({ kind: 'write_manifest', message: 'Update manifest', status: 'failed', error: msg, paths: { file: path.resolve(manifestPath) } });
125
+ }
126
+ return res;
127
+ },
128
+ });
129
+ }
@@ -0,0 +1,4 @@
1
+ export { add } from './add.js';
2
+ export { remove } from './remove.js';
3
+ export { install } from './install.js';
4
+ export { uninstall } from './uninstall.js';
@@ -0,0 +1,4 @@
1
+ export { add } from './add.js';
2
+ export { remove } from './remove.js';
3
+ export { install } from './install.js';
4
+ export { uninstall } from './uninstall.js';
@@ -0,0 +1,6 @@
1
+ import { CommonOptions, Result } from '../types.js';
2
+ /**
3
+ * Ensure all targets are symlinks to sources. Never mutates manifest.
4
+ * Safety: if any target exists and is not a symlink, abort without changes.
5
+ */
6
+ export declare function install(manifestPath: string, opts?: CommonOptions): Promise<Result>;
@@ -0,0 +1,88 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { planReplaceTargetWithTmp } from '../core/backup.js';
4
+ import { runOperation } from '../core/runner.js';
5
+ import { detectKind, isSymlinkTo, tmpPathForTarget } from '../core/plan.js';
6
+ import { getManifestBaseDir, loadManifest, resolveEntry } from '../manifest/types.js';
7
+ function mkLogger(opts) {
8
+ return opts?.logger;
9
+ }
10
+ function mkResult(operation, manifestPath) {
11
+ const now = new Date();
12
+ return {
13
+ ok: true,
14
+ operation,
15
+ manifestPath,
16
+ startedAt: now.toISOString(),
17
+ finishedAt: now.toISOString(),
18
+ durationMs: 0,
19
+ steps: [],
20
+ warnings: [],
21
+ errors: [],
22
+ changes: [],
23
+ };
24
+ }
25
+ function linkSteps(sourceAbs, targetAbs, kind, atomic) {
26
+ const steps = [];
27
+ steps.push({ kind: 'mkdirp', message: 'Ensure target parent directory exists', paths: { dir: path.dirname(targetAbs) } });
28
+ const tmp = atomic ? tmpPathForTarget(targetAbs) : targetAbs;
29
+ steps.push({ kind: 'symlink', message: atomic ? 'Create symlink at temp path' : 'Create symlink', paths: { source: sourceAbs, target: tmp, kind } });
30
+ if (atomic) {
31
+ steps.push({ kind: 'move', message: 'Atomically move temp symlink into place', paths: { from: tmp, to: targetAbs } });
32
+ }
33
+ return steps;
34
+ }
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) {
40
+ const logger = mkLogger(opts);
41
+ const result = await (async () => {
42
+ const manifest = await loadManifest(manifestPath);
43
+ const baseDir = getManifestBaseDir(manifestPath);
44
+ const allSteps = [];
45
+ for (const entry of manifest.installs) {
46
+ const r = resolveEntry(baseDir, entry);
47
+ if (!await fs.pathExists(r.sourceAbs)) {
48
+ const res = mkResult('install', manifestPath);
49
+ res.ok = false;
50
+ res.errors.push(`Source missing: ${r.sourceAbs}`);
51
+ res.steps.push({ kind: 'noop', message: 'Source missing; aborting without changes', status: 'failed', error: res.errors[0], paths: { source: r.sourceAbs } });
52
+ return res;
53
+ }
54
+ const targetExists = await fs.pathExists(r.targetAbs);
55
+ if (targetExists) {
56
+ const st = await fs.lstat(r.targetAbs);
57
+ if (!st.isSymbolicLink()) {
58
+ const res = mkResult('install', manifestPath);
59
+ res.ok = false;
60
+ res.errors.push(`Conflict: target exists and is not a symlink: ${r.targetAbs}`);
61
+ res.steps.push({ kind: 'noop', message: 'Conflict detected; aborting without changes', status: 'failed', error: res.errors[0], paths: { target: r.targetAbs } });
62
+ return res;
63
+ }
64
+ }
65
+ const kind = r.kind ?? await detectKind(r.sourceAbs);
66
+ if (await isSymlinkTo(r.targetAbs, r.sourceAbs))
67
+ continue;
68
+ if (targetExists) {
69
+ // Stronger atomic replace: move old target aside to backup, then replace with tmp.
70
+ if (r.atomic) {
71
+ const { steps: replaceSteps } = planReplaceTargetWithTmp({ targetAbs: r.targetAbs, atomic: true });
72
+ allSteps.push(...replaceSteps.slice(0, 1)); // move target -> backup
73
+ }
74
+ else {
75
+ allSteps.push({ kind: 'unlink', message: 'Remove existing target symlink before linking', paths: { target: r.targetAbs } });
76
+ }
77
+ }
78
+ allSteps.push(...linkSteps(r.sourceAbs, r.targetAbs, kind, r.atomic));
79
+ }
80
+ return await runOperation({
81
+ operation: 'install',
82
+ manifestPath,
83
+ steps: allSteps,
84
+ opts,
85
+ });
86
+ })();
87
+ return result;
88
+ }
@@ -0,0 +1,12 @@
1
+ import { CommonOptions, Result } from '../types.js';
2
+ export interface RemoveOptions extends CommonOptions {
3
+ /**
4
+ * Default: false. If true, do NOT delete the target link.
5
+ */
6
+ keepLink?: boolean;
7
+ }
8
+ /**
9
+ * remove: remove an entry from manifest, and by default delete the target symlink.
10
+ * Never deletes sources.
11
+ */
12
+ export declare function remove(manifestPath: string, key: string, opts?: RemoveOptions): Promise<Result>;
@@ -0,0 +1,62 @@
1
+ import path from 'path';
2
+ import { runOperation } from '../core/runner.js';
3
+ import { planUnlink } from '../core/plan.js';
4
+ import { getManifestBaseDir, loadManifest, resolveEntry } from '../manifest/types.js';
5
+ import { removeEntry, saveManifest } from '../manifest/io.js';
6
+ function mkLogger(opts) {
7
+ return opts?.logger;
8
+ }
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) {
14
+ const manifest = await loadManifest(manifestPath);
15
+ const baseDir = getManifestBaseDir(manifestPath);
16
+ const entry = manifest.installs.find(e => (e.id && e.id === key) || e.target === key || (e.id || e.target) === key);
17
+ if (!entry) {
18
+ 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: [],
29
+ };
30
+ }
31
+ const r = resolveEntry(baseDir, entry);
32
+ const steps = [];
33
+ if (!opts?.keepLink) {
34
+ steps.push(...await planUnlink({ targetAbs: r.targetAbs }));
35
+ }
36
+ else {
37
+ steps.push({ kind: 'noop', message: 'keepLink=true; not unlinking target', status: 'skipped', paths: { target: r.targetAbs } });
38
+ }
39
+ return await runOperation({
40
+ operation: 'remove',
41
+ manifestPath,
42
+ steps,
43
+ opts,
44
+ finalize: async (res) => {
45
+ if (!res.ok)
46
+ return res;
47
+ removeEntry(manifest, entry.id || entry.target);
48
+ try {
49
+ await saveManifest(manifestPath, manifest);
50
+ res.steps.push({ kind: 'write_manifest', message: 'Update manifest', status: 'executed', paths: { file: path.resolve(manifestPath) } });
51
+ res.changes.push({ action: 'manifest_remove', target: r.targetAbs });
52
+ }
53
+ catch (e) {
54
+ const msg = e?.message ? String(e.message) : String(e);
55
+ res.ok = false;
56
+ res.errors.push(`Failed to write manifest: ${msg}`);
57
+ res.steps.push({ kind: 'write_manifest', message: 'Update manifest', status: 'failed', error: msg, paths: { file: path.resolve(manifestPath) } });
58
+ }
59
+ return res;
60
+ },
61
+ });
62
+ }
@@ -0,0 +1,5 @@
1
+ import { CommonOptions, Result } from '../types.js';
2
+ /**
3
+ * Remove all target symlinks listed in manifest. Never deletes sources.
4
+ */
5
+ export declare function uninstall(manifestPath: string, opts?: CommonOptions): Promise<Result>;
@@ -0,0 +1,24 @@
1
+ import { runOperation } from '../core/runner.js';
2
+ import { planUnlink } from '../core/plan.js';
3
+ import { getManifestBaseDir, loadManifest, resolveEntry } from '../manifest/types.js';
4
+ function mkLogger(opts) {
5
+ return opts?.logger;
6
+ }
7
+ /**
8
+ * Remove all target symlinks listed in manifest. Never deletes sources.
9
+ */
10
+ export async function uninstall(manifestPath, opts) {
11
+ const manifest = await loadManifest(manifestPath);
12
+ const baseDir = getManifestBaseDir(manifestPath);
13
+ const allSteps = [];
14
+ for (const entry of manifest.installs) {
15
+ const r = resolveEntry(baseDir, entry);
16
+ allSteps.push(...await planUnlink({ targetAbs: r.targetAbs }));
17
+ }
18
+ return await runOperation({
19
+ operation: 'uninstall',
20
+ manifestPath,
21
+ steps: allSteps,
22
+ opts,
23
+ });
24
+ }
@@ -0,0 +1,16 @@
1
+ export interface LinkanyConfig {
2
+ manifestPath?: string;
3
+ }
4
+ export interface ConfigEnv {
5
+ env?: NodeJS.ProcessEnv;
6
+ /**
7
+ * For tests or embedding, override home dir (default: os.homedir()).
8
+ */
9
+ homeDir?: string;
10
+ }
11
+ export declare function getGlobalConfigPath(opts?: ConfigEnv): string;
12
+ export declare function readGlobalConfig(opts?: ConfigEnv): Promise<LinkanyConfig>;
13
+ export declare function writeGlobalConfig(config: LinkanyConfig, opts?: ConfigEnv): Promise<void>;
14
+ export declare function setDefaultManifestPath(manifestPath: string, opts?: ConfigEnv): Promise<string>;
15
+ export declare function getDefaultManifestPath(opts?: ConfigEnv): Promise<string | undefined>;
16
+ export declare function clearDefaultManifestPath(opts?: ConfigEnv): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import fs from 'fs-extra';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ export function getGlobalConfigPath(opts = {}) {
5
+ const env = opts.env ?? process.env;
6
+ const base = env.XDG_CONFIG_HOME || path.join(opts.homeDir ?? os.homedir(), '.config');
7
+ return path.join(base, 'linkany', 'config.json');
8
+ }
9
+ export async function readGlobalConfig(opts = {}) {
10
+ const p = getGlobalConfigPath(opts);
11
+ if (!await fs.pathExists(p))
12
+ return {};
13
+ const json = await fs.readJson(p);
14
+ if (!json || typeof json !== 'object')
15
+ return {};
16
+ const manifestPath = json.manifestPath;
17
+ return typeof manifestPath === 'string' ? { manifestPath } : {};
18
+ }
19
+ export async function writeGlobalConfig(config, opts = {}) {
20
+ const p = getGlobalConfigPath(opts);
21
+ await fs.ensureDir(path.dirname(p));
22
+ await fs.writeJson(p, config, { spaces: 2 });
23
+ }
24
+ export async function setDefaultManifestPath(manifestPath, opts = {}) {
25
+ const abs = path.resolve(manifestPath);
26
+ await writeGlobalConfig({ manifestPath: abs }, opts);
27
+ return abs;
28
+ }
29
+ export async function getDefaultManifestPath(opts = {}) {
30
+ const cfg = await readGlobalConfig(opts);
31
+ return cfg.manifestPath;
32
+ }
33
+ export async function clearDefaultManifestPath(opts = {}) {
34
+ const p = getGlobalConfigPath(opts);
35
+ if (!await fs.pathExists(p))
36
+ return;
37
+ await fs.remove(p);
38
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(argv?: string[]): Promise<number>;