@dog_world/dak 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ /** 命令工作流 */
2
+ import { join } from 'node:path';
3
+ import { readConfig, createDefaultConfig, validateConfigStore, writeConfigIfMissing } from './config.js';
4
+ import { readState, writeState, writeStateIfMissing, upsertLinkRecord, removeLinkRecord, removeTargetState } from './state.js';
5
+ import { scanResources, formatResourceList, ensureStoreLayout } from './resources.js';
6
+ import { classifyTarget, linkItem, safeDeleteManagedLink } from './linker.js';
7
+ import { RESOURCE_TYPES } from './types.js';
8
+ /**
9
+ * 加载上下文(store/config/state)。
10
+ */
11
+ async function loadContext(opts) {
12
+ const homeDir = opts.homeDir ?? process.env.HOME ?? '';
13
+ const storePath = opts.store ?? join(homeDir, '.dog-agents-kit');
14
+ const config = await readConfig(storePath);
15
+ validateConfigStore(config, storePath);
16
+ const state = await readState(storePath);
17
+ return { storePath, config, state, homeDir };
18
+ }
19
+ /**
20
+ * 解析目标名称(支持 all)。
21
+ */
22
+ function resolveTargetNames(target, config) {
23
+ if (target === 'all') {
24
+ return Object.keys(config.targets ?? {});
25
+ }
26
+ if (!config.targets?.[target]) {
27
+ throw new Error(`unknown target: ${target}`);
28
+ }
29
+ return [target];
30
+ }
31
+ /**
32
+ * 获取目标 resource 路径(未配置则返回 null)。
33
+ */
34
+ function targetResourcePath(config, targetName, resourceType) {
35
+ const t = config.targets?.[targetName];
36
+ if (!t?.resources?.[resourceType])
37
+ return null;
38
+ return join(t.path, t.resources[resourceType]);
39
+ }
40
+ /**
41
+ * 确定冲突策略。
42
+ */
43
+ function selectedPolicy(opts, _target, _resource, _item) {
44
+ if (opts.conflictPolicy)
45
+ return opts.conflictPolicy;
46
+ if (opts.interactive && opts.chooseConflict) {
47
+ return opts.chooseConflict(_target, _resource, _item);
48
+ }
49
+ return 'skip';
50
+ }
51
+ /** ─── Commands ─── **/
52
+ /** 初始化 store */
53
+ export async function runInit(opts) {
54
+ const homeDir = opts.homeDir ?? process.env.HOME ?? '';
55
+ const storePath = opts.store ?? join(homeDir, '.dog-agents-kit');
56
+ const config = createDefaultConfig(storePath, homeDir);
57
+ await ensureStoreLayout(storePath, config);
58
+ await writeConfigIfMissing(storePath, config);
59
+ await writeStateIfMissing(storePath);
60
+ return `Initialized dak store at ${storePath}`;
61
+ }
62
+ /** 列出资源 */
63
+ export async function runList(opts) {
64
+ const { storePath, config } = await loadContext(opts);
65
+ const resources = await scanResources(storePath, config);
66
+ return formatResourceList(resources);
67
+ }
68
+ /** 链接资源到目标 */
69
+ export async function runLink(targetArg, opts) {
70
+ const { storePath, config, state, homeDir } = await loadContext(opts);
71
+ const targetNames = resolveTargetNames(targetArg, config);
72
+ const lines = [];
73
+ let linked = 0, created = 0, skipped = 0, conflicts = 0;
74
+ for (const tName of targetNames) {
75
+ for (const resourceType of RESOURCE_TYPES) {
76
+ const targetPath = targetResourcePath(config, tName, resourceType);
77
+ if (!targetPath)
78
+ continue; // target 未配置此 resource,跳过
79
+ const items = await scanResources(storePath, config);
80
+ for (const item of items[resourceType]) {
81
+ const itemTargetPath = join(targetPath, item.name);
82
+ const policy = selectedPolicy(opts, tName, resourceType, item.name);
83
+ const outcome = await linkItem({
84
+ sourcePath: item.path,
85
+ targetPath: itemTargetPath,
86
+ resourceType,
87
+ itemName: item.name,
88
+ storePath,
89
+ policy,
90
+ now: opts.now,
91
+ });
92
+ // 写 state
93
+ if (outcome.record) {
94
+ upsertLinkRecord(state, tName, resourceType, item.name, outcome.record);
95
+ }
96
+ let status = '';
97
+ switch (outcome.status) {
98
+ case 'linked':
99
+ status = 'linked';
100
+ linked++;
101
+ break;
102
+ case 'created':
103
+ status = 'created';
104
+ created++;
105
+ break;
106
+ case 'backed-up':
107
+ status = 'backed-up';
108
+ created++;
109
+ break;
110
+ case 'overwritten':
111
+ status = 'overwritten';
112
+ created++;
113
+ break;
114
+ case 'conflict':
115
+ status = 'conflict';
116
+ conflicts++;
117
+ break;
118
+ }
119
+ lines.push(`${tName} ${resourceType}/${item.name} ${status} ${itemTargetPath}`);
120
+ }
121
+ }
122
+ }
123
+ await writeState(storePath, state);
124
+ lines.push(`Summary: linked=${linked} created=${created} skipped=${skipped} conflicts=${conflicts}`);
125
+ return lines.join('\n');
126
+ }
127
+ /** 状态检查 */
128
+ export async function runStatus(opts) {
129
+ const { storePath, config, state, homeDir } = await loadContext(opts);
130
+ const storeItems = await scanResources(storePath, config);
131
+ const lines = [];
132
+ for (const [tName, tState] of Object.entries(state.targets)) {
133
+ for (const resourceType of RESOURCE_TYPES) {
134
+ const targetPath = targetResourcePath(config, tName, resourceType);
135
+ if (!targetPath)
136
+ continue;
137
+ const records = tState[resourceType] ?? {};
138
+ const storeItemsForType = storeItems[resourceType] ?? [];
139
+ const storeItemMap = new Map(storeItemsForType.map(i => [i.name, i]));
140
+ // 当前 store 中的 items
141
+ for (const item of storeItemsForType) {
142
+ const itemTargetPath = join(targetPath, item.name);
143
+ const category = await classifyTarget(itemTargetPath, item.path);
144
+ lines.push(`${tName} ${resourceType}/${item.name} ${category} ${itemTargetPath}`);
145
+ }
146
+ // state 中已存在但 store 已删除(stale)
147
+ for (const [itemName, record] of Object.entries(records)) {
148
+ if (storeItemMap.has(itemName))
149
+ continue;
150
+ const itemTargetPath = record.target;
151
+ lines.push(`${tName} ${resourceType}/${itemName} stale ${itemTargetPath}`);
152
+ }
153
+ }
154
+ }
155
+ return lines.join('\n');
156
+ }
157
+ /** 更新(补链接 + 清理 stale) */
158
+ export async function runUpdate(opts) {
159
+ const { storePath, config, state, homeDir } = await loadContext(opts);
160
+ const lines = [];
161
+ let created = 0, deleted = 0, missingCount = 0, skipped = 0, conflicts = 0;
162
+ // target 集合只能来自 state.targets(已 link 过的 targets)
163
+ const targetNames = Object.keys(state.targets);
164
+ for (const tName of targetNames) {
165
+ const tState = state.targets[tName];
166
+ for (const resourceType of RESOURCE_TYPES) {
167
+ const targetPath = targetResourcePath(config, tName, resourceType);
168
+ if (!targetPath)
169
+ continue;
170
+ const records = { ...(tState[resourceType] ?? {}) };
171
+ // 1. 清理 stale
172
+ for (const [itemName, record] of Object.entries(records)) {
173
+ const sourcePath = record.source;
174
+ if (!existsSync(sourcePath)) {
175
+ // stale:state 中有记录但 store 已无源
176
+ const result = await safeDeleteManagedLink(record, storePath);
177
+ switch (result) {
178
+ case 'deleted':
179
+ removeLinkRecord(state, tName, resourceType, itemName);
180
+ deleted++;
181
+ lines.push(`${tName} ${resourceType}/${itemName} deleted ${record.target}`);
182
+ break;
183
+ case 'missing':
184
+ removeLinkRecord(state, tName, resourceType, itemName);
185
+ missingCount++;
186
+ lines.push(`${tName} ${resourceType}/${itemName} missing ${record.target}`);
187
+ break;
188
+ case 'conflict':
189
+ conflicts++;
190
+ lines.push(`${tName} ${resourceType}/${itemName} conflict ${record.target}`);
191
+ break;
192
+ }
193
+ }
194
+ }
195
+ // 2. 为当前 store item 补链接
196
+ const storeItems = await scanResources(storePath, config);
197
+ for (const item of storeItems[resourceType]) {
198
+ const itemTargetPath = join(targetPath, item.name);
199
+ const policy = selectedPolicy(opts, tName, resourceType, item.name);
200
+ const outcome = await linkItem({
201
+ sourcePath: item.path,
202
+ targetPath: itemTargetPath,
203
+ resourceType,
204
+ itemName: item.name,
205
+ storePath,
206
+ policy,
207
+ now: opts.now,
208
+ });
209
+ if (outcome.record) {
210
+ upsertLinkRecord(state, tName, resourceType, item.name, outcome.record);
211
+ }
212
+ switch (outcome.status) {
213
+ case 'linked':
214
+ skipped++;
215
+ lines.push(`${tName} ${resourceType}/${item.name} linked ${itemTargetPath}`);
216
+ break;
217
+ case 'created':
218
+ created++;
219
+ lines.push(`${tName} ${resourceType}/${item.name} created ${itemTargetPath}`);
220
+ break;
221
+ case 'backed-up':
222
+ created++;
223
+ lines.push(`${tName} ${resourceType}/${item.name} backed-up ${itemTargetPath}`);
224
+ break;
225
+ case 'overwritten':
226
+ created++;
227
+ lines.push(`${tName} ${resourceType}/${item.name} overwritten ${itemTargetPath}`);
228
+ break;
229
+ case 'conflict':
230
+ conflicts++;
231
+ lines.push(`${tName} ${resourceType}/${item.name} conflict ${itemTargetPath}`);
232
+ break;
233
+ }
234
+ }
235
+ }
236
+ }
237
+ await writeState(storePath, state);
238
+ lines.push(`Summary: created=${created} deleted=${deleted} missing=${missingCount} skipped=${skipped} conflicts=${conflicts}`);
239
+ return lines.join('\n');
240
+ }
241
+ /** 取消链接 */
242
+ export async function runUnlink(targetArg, opts) {
243
+ const { storePath, state, homeDir } = await loadContext(opts);
244
+ const config = await readConfig(storePath);
245
+ const targetNames = resolveTargetNames(targetArg, config);
246
+ const lines = [];
247
+ let deleted = 0, missingCount = 0, conflicts = 0;
248
+ for (const tName of targetNames) {
249
+ const tState = state.targets[tName];
250
+ if (!tState)
251
+ continue;
252
+ for (const resourceType of RESOURCE_TYPES) {
253
+ const records = { ...(tState[resourceType] ?? {}) };
254
+ for (const [itemName, record] of Object.entries(records)) {
255
+ const result = await safeDeleteManagedLink(record, storePath);
256
+ switch (result) {
257
+ case 'deleted':
258
+ removeLinkRecord(state, tName, resourceType, itemName);
259
+ deleted++;
260
+ lines.push(`${tName} ${resourceType}/${itemName} deleted ${record.target}`);
261
+ break;
262
+ case 'missing':
263
+ removeLinkRecord(state, tName, resourceType, itemName);
264
+ missingCount++;
265
+ lines.push(`${tName} ${resourceType}/${itemName} missing ${record.target}`);
266
+ break;
267
+ case 'conflict':
268
+ conflicts++;
269
+ lines.push(`${tName} ${resourceType}/${itemName} conflict ${record.target}`);
270
+ break;
271
+ }
272
+ }
273
+ }
274
+ // 如果 target 下所有 records 都已删完,移除 target state
275
+ if (tState && Object.values(tState).every(m => Object.keys(m).length === 0)) {
276
+ removeTargetState(state, tName);
277
+ }
278
+ }
279
+ await writeState(storePath, state);
280
+ lines.push(`Summary: deleted=${deleted} missing=${missingCount} conflicts=${conflicts}`);
281
+ return lines.join('\n');
282
+ }
283
+ // existsSync 辅助(内联避免额外导入)
284
+ function existsSync(p) {
285
+ try {
286
+ return require('node:fs').existsSync(p);
287
+ }
288
+ catch {
289
+ return false;
290
+ }
291
+ }
@@ -0,0 +1,65 @@
1
+ /** 配置读写与校验 */
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { DEFAULT_CONFIG, DEFAULT_STORE, CONFIG_FILE } from './constants.js';
6
+ import { toAbsolutePath } from './paths.js';
7
+ /**
8
+ * 解析 store 路径。
9
+ * @param storeArg CLI --store 参数
10
+ * @param homeDir 用户主目录
11
+ */
12
+ export function resolveStorePath(storeArg, homeDir) {
13
+ const home = homeDir ?? process.env.HOME ?? '';
14
+ if (storeArg) {
15
+ return toAbsolutePath(storeArg, home, home);
16
+ }
17
+ return toAbsolutePath(DEFAULT_STORE, home, home);
18
+ }
19
+ /**
20
+ * 创建默认配置(内存对象,不写入磁盘)。
21
+ * @param storePath 绝对 store 路径
22
+ * @param homeDir 用户主目录
23
+ */
24
+ export function createDefaultConfig(storePath, homeDir) {
25
+ const home = homeDir ?? process.env.HOME ?? '';
26
+ const targets = {};
27
+ for (const [name, t] of Object.entries(DEFAULT_CONFIG.targets)) {
28
+ targets[name] = {
29
+ path: toAbsolutePath(t.path, home, home),
30
+ resources: t.resources,
31
+ };
32
+ }
33
+ return { store: storePath, targets };
34
+ }
35
+ /**
36
+ * 从磁盘读取配置。
37
+ * @param storePath store 绝对路径
38
+ */
39
+ export async function readConfig(storePath) {
40
+ const configPath = join(storePath, CONFIG_FILE);
41
+ const raw = await readFile(configPath, 'utf-8');
42
+ const config = JSON.parse(raw);
43
+ return config;
44
+ }
45
+ /**
46
+ * 仅当配置文件不存在时写入。
47
+ */
48
+ export async function writeConfigIfMissing(storePath, config) {
49
+ const configPath = join(storePath, CONFIG_FILE);
50
+ if (existsSync(configPath))
51
+ return;
52
+ await mkdir(storePath, { recursive: true });
53
+ await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
54
+ }
55
+ /**
56
+ * 校验 config.store 与当前加载 store 是否一致。
57
+ * @throws 不一致时抛错
58
+ */
59
+ export function validateConfigStore(config, storePath) {
60
+ const configStore = resolveStorePath(config.store);
61
+ const currentStore = resolveStorePath(storePath);
62
+ if (configStore !== currentStore) {
63
+ throw new Error('config store mismatch');
64
+ }
65
+ }
@@ -0,0 +1,26 @@
1
+ /** dak 常量定义 */
2
+ /** 默认资源仓库目录 */
3
+ export const DEFAULT_STORE = '~/.dog-agents-kit';
4
+ /** 配置文件文件名 */
5
+ export const CONFIG_FILE = 'dak.config.json';
6
+ /** 状态文件名 */
7
+ export const STATE_FILE = '.dak-state.json';
8
+ /** 默认配置 */
9
+ export const DEFAULT_CONFIG = {
10
+ store: DEFAULT_STORE,
11
+ targets: {
12
+ 'codex': {
13
+ path: '~/.codex',
14
+ resources: { skills: 'skills', hooks: 'hooks', agents: 'agents' },
15
+ },
16
+ 'claude-code': {
17
+ path: '~/.claude',
18
+ resources: { skills: 'skills', hooks: 'hooks', agents: 'agents' },
19
+ },
20
+ },
21
+ };
22
+ /** 空状态模板 */
23
+ export const EMPTY_STATE = {
24
+ version: 1,
25
+ targets: {},
26
+ };
@@ -0,0 +1,132 @@
1
+ /** symlink 引擎与冲突策略 */
2
+ import { mkdir, unlink, rename, lstat, realpath } from 'node:fs/promises';
3
+ import { existsSync, symlinkSync } from 'node:fs';
4
+ import { dirname, join, relative } from 'node:path';
5
+ import { realParentJoined, isPathInside } from './paths.js';
6
+ import { STATE_FILE } from './constants.js';
7
+ /**
8
+ * 分类目标路径状态。
9
+ * 直接 lstat;broken symlink 的 lstat 成功(是 symlink)但 realpath 抛错。
10
+ */
11
+ export async function classifyTarget(targetPath, expectedSource) {
12
+ try {
13
+ const linkStat = await lstat(targetPath);
14
+ if (!linkStat.isSymbolicLink())
15
+ return 'conflict';
16
+ try {
17
+ const realTarget = await realpath(targetPath);
18
+ if (realTarget === expectedSource)
19
+ return 'linked';
20
+ return 'conflict';
21
+ }
22
+ catch {
23
+ return 'broken';
24
+ }
25
+ }
26
+ catch (e) {
27
+ if (e?.code === 'ENOENT')
28
+ return 'missing';
29
+ throw e;
30
+ }
31
+ }
32
+ /**
33
+ * 创建相对 symlink(Linux/macOS)。
34
+ * Windows 目录 junction 另做处理,这里先实现相对 symlink。
35
+ */
36
+ async function createRelativeSymlink(sourcePath, targetPath) {
37
+ const targetParent = await realParentJoined(dirname(targetPath));
38
+ const rel = relative(targetParent, sourcePath);
39
+ // 确保父目录存在
40
+ await mkdir(dirname(targetPath), { recursive: true });
41
+ // Windows 下目录 symlink 需要权限,这里统一用 symlink
42
+ symlinkSync(rel, targetPath);
43
+ }
44
+ /**
45
+ * 移动旧内容到备份目录。
46
+ * 备份根目录 = 目标 resource 根目录的父目录(即 target 根目录)下 .dak-backup
47
+ */
48
+ async function moveToBackup(targetPath, storePath, resourceType, itemName, now) {
49
+ const ts = now.toISOString().replace(/[:.]/g, '-');
50
+ // targetPath = <targetRoot>/<resourceType>/<item>
51
+ // resource root parent = targetRoot = dirname(dirname(targetPath))
52
+ const targetRoot = dirname(dirname(targetPath));
53
+ const backupRoot = join(targetRoot, '.dak-backup');
54
+ const backupPath = join(backupRoot, ts, resourceType, itemName);
55
+ await mkdir(dirname(backupPath), { recursive: true });
56
+ await rename(targetPath, backupPath);
57
+ return backupPath;
58
+ }
59
+ /**
60
+ * 执行链接操作。
61
+ */
62
+ export async function linkItem(input) {
63
+ const { sourcePath, targetPath, resourceType, itemName, storePath, policy, now } = input;
64
+ const category = await classifyTarget(targetPath, sourcePath);
65
+ if (category === 'linked') {
66
+ return { status: 'linked' };
67
+ }
68
+ const timestamp = now ?? new Date();
69
+ if (category === 'conflict' || category === 'broken') {
70
+ if (policy === 'skip') {
71
+ return { status: 'conflict' };
72
+ }
73
+ // 删除旧内容(broken symlink 或真实文件)
74
+ if (policy === 'backup') {
75
+ await moveToBackup(targetPath, storePath, resourceType, itemName, timestamp);
76
+ }
77
+ else if (policy === 'overwrite') {
78
+ try {
79
+ await unlink(targetPath);
80
+ }
81
+ catch {
82
+ // 可能不存在或已被删除,忽略
83
+ }
84
+ }
85
+ }
86
+ // 创建新链接
87
+ await createRelativeSymlink(sourcePath, targetPath);
88
+ const record = {
89
+ source: sourcePath,
90
+ target: targetPath,
91
+ linkedAt: timestamp.toISOString(),
92
+ };
93
+ return {
94
+ status: category === 'missing' ? 'created' : policy === 'backup' ? 'backed-up' : 'overwritten',
95
+ record,
96
+ };
97
+ }
98
+ /**
99
+ * 安全删除 dak 管理的 symlink。
100
+ * 仅当所有条件满足时删除:
101
+ * - target 存在且是 symlink
102
+ * - symlink 指向当前 store 内的路径
103
+ * - symlink 指向 record.source
104
+ */
105
+ export async function safeDeleteManagedLink(record, storePath) {
106
+ const { target, source } = record;
107
+ if (!existsSync(target))
108
+ return 'missing';
109
+ try {
110
+ const linkStat = await lstat(target);
111
+ if (!linkStat.isSymbolicLink())
112
+ return 'conflict';
113
+ }
114
+ catch {
115
+ return 'conflict';
116
+ }
117
+ const realTarget = await realpath(target);
118
+ // 检查是否指向当前 store
119
+ if (!isPathInside(realTarget, storePath))
120
+ return 'conflict';
121
+ // 检查是否指向 record.source
122
+ if (realTarget !== source)
123
+ return 'conflict';
124
+ await unlink(target);
125
+ return 'deleted';
126
+ }
127
+ /**
128
+ * 获取 state 文件路径。
129
+ */
130
+ export function statePathFor(storePath) {
131
+ return join(storePath, STATE_FILE);
132
+ }
@@ -0,0 +1,147 @@
1
+ /** 路径工具函数 */
2
+ import { existsSync } from 'node:fs';
3
+ import { realpath } from 'node:fs/promises';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ /**
6
+ * 将路径中的 ~ 展开为用户主目录。
7
+ * @param input 输入路径
8
+ * @param homeDir 用户主目录绝对路径
9
+ * @returns 展开后的绝对路径
10
+ */
11
+ export function expandHome(input, homeDir) {
12
+ if (input === '~')
13
+ return homeDir;
14
+ if (input.startsWith('~/'))
15
+ return homeDir + input.slice(1);
16
+ return input;
17
+ }
18
+ /**
19
+ * 将输入路径解析为绝对路径。
20
+ * 支持 ~ 展开、相对路径拼接、自动 normalize。
21
+ * @param input 输入路径(支持 ~ 前缀或相对路径)
22
+ * @param baseDir 相对路径的基准目录
23
+ * @param homeDir 用于 ~ 展开的主目录
24
+ * @returns 解析后的绝对路径
25
+ */
26
+ export function toAbsolutePath(input, baseDir, homeDir) {
27
+ const expanded = expandHome(input, homeDir);
28
+ if (expanded.startsWith('/')) {
29
+ return resolve(expanded);
30
+ }
31
+ return resolve(baseDir, expanded);
32
+ }
33
+ /**
34
+ * 判断名称是否为隐藏项(以 . 开头)。
35
+ * @param name 文件/目录名称
36
+ * @returns 是否隐藏
37
+ */
38
+ export function isHiddenItem(name) {
39
+ const base = dirname(name) === '.' ? name : name.split('/').pop() ?? name;
40
+ return base.startsWith('.');
41
+ }
42
+ /**
43
+ * 校验资源项名称是否合法。
44
+ * @param name 资源项名称
45
+ * @throws 名称非法时抛出错误
46
+ */
47
+ export function assertSafeItemName(name) {
48
+ const base = dirname(name) === '.' ? name : name.split('/').pop() ?? name;
49
+ // 先检查 . 和 ..(它们是合法 hidden item,但应报 "Invalid")
50
+ if (name === '.' || name === '..') {
51
+ throw new Error('Invalid resource item name');
52
+ }
53
+ if (isHiddenItem(base)) {
54
+ throw new Error('Hidden resource items are ignored');
55
+ }
56
+ if (name.includes('/') || name.includes('\\') || name.includes('.')) {
57
+ throw new Error('Invalid resource item name');
58
+ }
59
+ if (name === '..') {
60
+ throw new Error('Invalid resource item name');
61
+ }
62
+ }
63
+ /**
64
+ * 判断 child 路径是否在 parent 路径内部(严格前缀匹配,防止边界欺骗)。
65
+ * @param child 子路径
66
+ * @param parent 父路径
67
+ * @returns 是否在内部
68
+ */
69
+ export function isPathInside(child, parent) {
70
+ const normalizedChild = resolve(child);
71
+ let normalizedParent = resolve(parent);
72
+ if (!normalizedParent.endsWith('/')) {
73
+ normalizedParent += '/';
74
+ }
75
+ // 子路径在父目录内(严格前缀)
76
+ if (normalizedChild.startsWith(normalizedParent))
77
+ return true;
78
+ // 子路径等于父目录本身
79
+ if (normalizedChild === parent || normalizedChild + '/' === normalizedParent)
80
+ return true;
81
+ return false;
82
+ }
83
+ /**
84
+ * 获取路径的可比较真实路径。
85
+ * 尝试 fs.realpath,失败时 fallback 到 path.resolve。
86
+ * @param input 输入路径
87
+ * @returns 绝对路径
88
+ */
89
+ export async function realComparablePath(input) {
90
+ try {
91
+ return await realpath(input);
92
+ }
93
+ catch {
94
+ return resolve(input);
95
+ }
96
+ }
97
+ /**
98
+ * 解析 symlink 的实际目标路径。
99
+ * @param linkPath 链接文件的实际路径
100
+ * @param rawTarget 链接原始 target(相对或绝对)
101
+ * @returns 解析后的绝对目标路径
102
+ */
103
+ export async function resolveLinkTarget(linkPath, rawTarget) {
104
+ if (rawTarget.startsWith('/')) {
105
+ return resolve(rawTarget);
106
+ }
107
+ const realParent = await realParentJoined(dirname(linkPath));
108
+ const resolved = join(realParent, rawTarget);
109
+ return resolve(resolved);
110
+ }
111
+ /**
112
+ * 获取路径的"真实父目录 + 剩余相对部分"拼接结果。
113
+ * 向上逐级查找最近存在的目录取 realpath,再拼接剩余部分。
114
+ * @param path 目标路径
115
+ * @returns 拼接后的绝对路径
116
+ */
117
+ export async function realParentJoined(path) {
118
+ const dir = dirname(path);
119
+ const basename = path.split('/').pop() ?? '';
120
+ // 向上逐级查找最近存在的目录
121
+ let current = dir;
122
+ const segments = [];
123
+ let foundExisting = null;
124
+ let remainingSegments = [];
125
+ while (current !== segments.join('/')) {
126
+ if (existsSync(current)) {
127
+ foundExisting = current;
128
+ // 计算还需要拼接的部分
129
+ const dirSegments = dir.split('/').filter(Boolean);
130
+ const foundSegments = foundExisting.split('/').filter(Boolean);
131
+ remainingSegments = dirSegments.slice(foundSegments.length);
132
+ remainingSegments.push(basename);
133
+ break;
134
+ }
135
+ segments.unshift(current.split('/').pop() ?? '');
136
+ const parent = dirname(current);
137
+ if (parent === current)
138
+ break;
139
+ current = parent;
140
+ }
141
+ if (foundExisting) {
142
+ const realAncestor = await realpath(foundExisting);
143
+ const remaining = remainingSegments.join('/');
144
+ return join(realAncestor, remaining);
145
+ }
146
+ return resolve(path);
147
+ }