@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.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/cli.js +150 -0
- package/dist/commands.js +298 -0
- package/dist/config.js +105 -0
- package/dist/constants.js +23 -0
- package/dist/linker.js +177 -0
- package/dist/paths.js +144 -0
- package/dist/resources.js +93 -0
- package/dist/src/cli.js +97 -0
- package/dist/src/commands.js +291 -0
- package/dist/src/config.js +65 -0
- package/dist/src/constants.js +26 -0
- package/dist/src/linker.js +132 -0
- package/dist/src/paths.js +147 -0
- package/dist/src/resources.js +86 -0
- package/dist/src/state.js +83 -0
- package/dist/src/types.js +5 -0
- package/dist/state.js +99 -0
- package/dist/tests/cli.test.js +38 -0
- package/dist/tests/commands.test.js +218 -0
- package/dist/tests/config-state-resources.test.js +146 -0
- package/dist/tests/e2e.test.js +74 -0
- package/dist/tests/linker.test.js +234 -0
- package/dist/tests/paths.test.js +120 -0
- package/dist/types.js +5 -0
- package/package.json +48 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** 资源扫描与格式化 */
|
|
2
|
+
import { readdir, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { RESOURCE_TYPES } from './types.js';
|
|
6
|
+
import { assertSafeItemName, isHiddenItem } from './paths.js';
|
|
7
|
+
/**
|
|
8
|
+
* 确保 store 目录和三类资源目录存在。
|
|
9
|
+
*/
|
|
10
|
+
export async function ensureStoreLayout(storePath, _config) {
|
|
11
|
+
await mkdir(storePath, { recursive: true });
|
|
12
|
+
for (const type of RESOURCE_TYPES) {
|
|
13
|
+
await mkdir(join(storePath, type), { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 扫描 store 中的资源目录(一级子项)。
|
|
18
|
+
*/
|
|
19
|
+
export async function scanResources(storePath, _config) {
|
|
20
|
+
const result = {
|
|
21
|
+
skills: [],
|
|
22
|
+
hooks: [],
|
|
23
|
+
agents: [],
|
|
24
|
+
};
|
|
25
|
+
for (const type of RESOURCE_TYPES) {
|
|
26
|
+
const dir = join(storePath, type);
|
|
27
|
+
if (!existsSync(dir))
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
31
|
+
const items = [];
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
const name = entry.name;
|
|
34
|
+
if (isHiddenItem(name))
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
assertSafeItemName(name);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const fullPath = join(dir, name);
|
|
43
|
+
let kind;
|
|
44
|
+
if (entry.isSymbolicLink())
|
|
45
|
+
kind = 'symlink';
|
|
46
|
+
else if (entry.isDirectory())
|
|
47
|
+
kind = 'directory';
|
|
48
|
+
else
|
|
49
|
+
kind = 'file';
|
|
50
|
+
items.push({ name, kind, path: fullPath });
|
|
51
|
+
}
|
|
52
|
+
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
53
|
+
result[type] = items;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// ignore unreadable dir
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 格式化资源列表输出(供 `dak list` 使用)。
|
|
63
|
+
* 输出稳定,支持 snapshot 测试。
|
|
64
|
+
*/
|
|
65
|
+
export function formatResourceList(resources) {
|
|
66
|
+
const lines = [];
|
|
67
|
+
const labels = {
|
|
68
|
+
skills: 'Skills:',
|
|
69
|
+
hooks: 'Hooks:',
|
|
70
|
+
agents: 'Agents:',
|
|
71
|
+
};
|
|
72
|
+
for (const type of RESOURCE_TYPES) {
|
|
73
|
+
lines.push(labels[type]);
|
|
74
|
+
const items = resources[type];
|
|
75
|
+
if (items.length === 0) {
|
|
76
|
+
lines.push(' (empty)');
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
for (const item of items) {
|
|
80
|
+
const kind = item.kind === 'directory' ? ' [dir]' : item.kind === 'symlink' ? ' [link]' : '';
|
|
81
|
+
lines.push(` ${item.name}${kind}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** 状态读写 */
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { STATE_FILE } from './constants.js';
|
|
6
|
+
/** 空状态 */
|
|
7
|
+
const EMPTY_STATE = { version: 1, targets: {} };
|
|
8
|
+
/**
|
|
9
|
+
* 读取状态文件,不存在时返回空状态。
|
|
10
|
+
*/
|
|
11
|
+
export async function readState(storePath) {
|
|
12
|
+
const statePath = join(storePath, STATE_FILE);
|
|
13
|
+
if (!existsSync(statePath))
|
|
14
|
+
return EMPTY_STATE;
|
|
15
|
+
const raw = await readFile(statePath, 'utf-8');
|
|
16
|
+
const state = JSON.parse(raw);
|
|
17
|
+
if (state.version !== 1) {
|
|
18
|
+
throw new Error(`unsupported state version: ${state.version}`);
|
|
19
|
+
}
|
|
20
|
+
return state;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 写入状态文件。
|
|
24
|
+
*/
|
|
25
|
+
export async function writeState(storePath, state) {
|
|
26
|
+
const statePath = join(storePath, STATE_FILE);
|
|
27
|
+
await mkdir(storePath, { recursive: true });
|
|
28
|
+
await writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 确保状态文件存在(不存在则创建空状态)。
|
|
32
|
+
*/
|
|
33
|
+
export async function writeStateIfMissing(storePath) {
|
|
34
|
+
const statePath = join(storePath, STATE_FILE);
|
|
35
|
+
if (existsSync(statePath))
|
|
36
|
+
return;
|
|
37
|
+
await writeState(storePath, EMPTY_STATE);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 确保目标 state 存在(skills/hooks/agents 三个 map)。
|
|
41
|
+
*/
|
|
42
|
+
export function ensureTargetState(state, targetName) {
|
|
43
|
+
if (!state.targets[targetName]) {
|
|
44
|
+
state.targets[targetName] = {
|
|
45
|
+
skills: {},
|
|
46
|
+
hooks: {},
|
|
47
|
+
agents: {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 替换某个 target/resource 的所有 records。
|
|
54
|
+
*/
|
|
55
|
+
export function replaceTargetResourceState(state, targetName, resourceType, records) {
|
|
56
|
+
ensureTargetState(state, targetName);
|
|
57
|
+
state.targets[targetName][resourceType] = records;
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 新增或更新单条 record。
|
|
62
|
+
*/
|
|
63
|
+
export function upsertLinkRecord(state, targetName, resourceType, itemName, record) {
|
|
64
|
+
ensureTargetState(state, targetName);
|
|
65
|
+
state.targets[targetName][resourceType][itemName] = record;
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 删除单条 record,返回是否删除成功(存在才删)。
|
|
70
|
+
*/
|
|
71
|
+
export function removeLinkRecord(state, targetName, resourceType, itemName) {
|
|
72
|
+
if (state.targets[targetName]?.[resourceType]) {
|
|
73
|
+
delete state.targets[targetName][resourceType][itemName];
|
|
74
|
+
}
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 从 state 中移除整个 target 的 state。
|
|
79
|
+
*/
|
|
80
|
+
export function removeTargetState(state, targetName) {
|
|
81
|
+
delete state.targets[targetName];
|
|
82
|
+
return state;
|
|
83
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** 状态读写 */
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { STATE_FILE } from './constants.js';
|
|
6
|
+
import { declaredResourceTypes } from './config.js';
|
|
7
|
+
/** 空状态 */
|
|
8
|
+
const EMPTY_STATE = { version: 1, targets: {} };
|
|
9
|
+
/**
|
|
10
|
+
* 读取状态文件,不存在时返回空状态。
|
|
11
|
+
*/
|
|
12
|
+
export async function readState(storePath) {
|
|
13
|
+
const statePath = join(storePath, STATE_FILE);
|
|
14
|
+
if (!existsSync(statePath))
|
|
15
|
+
return EMPTY_STATE;
|
|
16
|
+
const raw = await readFile(statePath, 'utf-8');
|
|
17
|
+
const state = JSON.parse(raw);
|
|
18
|
+
if (state.version !== 1) {
|
|
19
|
+
throw new Error(`unsupported state version: ${state.version}`);
|
|
20
|
+
}
|
|
21
|
+
return state;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 写入状态文件。
|
|
25
|
+
*/
|
|
26
|
+
export async function writeState(storePath, state) {
|
|
27
|
+
const statePath = join(storePath, STATE_FILE);
|
|
28
|
+
await mkdir(storePath, { recursive: true });
|
|
29
|
+
await writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 确保状态文件存在(不存在则创建空状态)。
|
|
33
|
+
*/
|
|
34
|
+
export async function writeStateIfMissing(storePath) {
|
|
35
|
+
const statePath = join(storePath, STATE_FILE);
|
|
36
|
+
if (existsSync(statePath))
|
|
37
|
+
return;
|
|
38
|
+
await writeState(storePath, EMPTY_STATE);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 确保目标 state 存在,并补齐声明的各资源类型 map。
|
|
42
|
+
* 旧 state 缺失新声明的类型时补空 map;不删除已有 key。
|
|
43
|
+
*/
|
|
44
|
+
export function ensureTargetState(state, targetName, config) {
|
|
45
|
+
if (!state.targets[targetName]) {
|
|
46
|
+
state.targets[targetName] = {};
|
|
47
|
+
}
|
|
48
|
+
const targetState = state.targets[targetName];
|
|
49
|
+
for (const type of declaredResourceTypes(config)) {
|
|
50
|
+
if (!targetState[type]) {
|
|
51
|
+
targetState[type] = {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 确保 target 容器与指定资源类型的 map 存在(惰性创建)。
|
|
58
|
+
*/
|
|
59
|
+
function ensureResourceMap(state, targetName, resourceType) {
|
|
60
|
+
if (!state.targets[targetName]) {
|
|
61
|
+
state.targets[targetName] = {};
|
|
62
|
+
}
|
|
63
|
+
if (!state.targets[targetName][resourceType]) {
|
|
64
|
+
state.targets[targetName][resourceType] = {};
|
|
65
|
+
}
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 替换某个 target/resource 的所有 records。
|
|
70
|
+
*/
|
|
71
|
+
export function replaceTargetResourceState(state, targetName, resourceType, records) {
|
|
72
|
+
ensureResourceMap(state, targetName, resourceType);
|
|
73
|
+
state.targets[targetName][resourceType] = records;
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 新增或更新单条 record。
|
|
78
|
+
*/
|
|
79
|
+
export function upsertLinkRecord(state, targetName, resourceType, itemName, record) {
|
|
80
|
+
ensureResourceMap(state, targetName, resourceType);
|
|
81
|
+
state.targets[targetName][resourceType][itemName] = record;
|
|
82
|
+
return state;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 删除单条 record,返回是否删除成功(存在才删)。
|
|
86
|
+
*/
|
|
87
|
+
export function removeLinkRecord(state, targetName, resourceType, itemName) {
|
|
88
|
+
if (state.targets[targetName]?.[resourceType]) {
|
|
89
|
+
delete state.targets[targetName][resourceType][itemName];
|
|
90
|
+
}
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 从 state 中移除整个 target 的 state。
|
|
95
|
+
*/
|
|
96
|
+
export function removeTargetState(state, targetName) {
|
|
97
|
+
delete state.targets[targetName];
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseArgv, main } from '../src/cli.js';
|
|
3
|
+
describe('parseArgv', () => {
|
|
4
|
+
it('link codex --store /tmp/store --on-conflict backup', () => {
|
|
5
|
+
const result = parseArgv(['link', 'codex', '--store', '/tmp/store', '--on-conflict', 'backup']);
|
|
6
|
+
expect(result.command).toBe('link');
|
|
7
|
+
expect(result.target).toBe('codex');
|
|
8
|
+
expect(result.store).toBe('/tmp/store');
|
|
9
|
+
expect(result.onConflict).toBe('backup');
|
|
10
|
+
});
|
|
11
|
+
it('非法 conflict policy 报错', () => {
|
|
12
|
+
expect(() => parseArgv(['link', 'codex', '--on-conflict', 'invalid']))
|
|
13
|
+
.toThrow('Invalid conflict policy');
|
|
14
|
+
});
|
|
15
|
+
it('status 无 target', () => {
|
|
16
|
+
const result = parseArgv(['status', '--store', '/tmp/store']);
|
|
17
|
+
expect(result.command).toBe('status');
|
|
18
|
+
expect(result.target).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
it('link 缺 target 报错', () => {
|
|
21
|
+
expect(() => parseArgv(['link']))
|
|
22
|
+
.toThrow('target is required');
|
|
23
|
+
});
|
|
24
|
+
it('unlink 缺 target 报错', () => {
|
|
25
|
+
expect(() => parseArgv(['unlink']))
|
|
26
|
+
.toThrow('target is required');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('main', () => {
|
|
30
|
+
it('未知命令报错', async () => {
|
|
31
|
+
const code = await main(['unknown']);
|
|
32
|
+
expect(code).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
it('link 缺 target 返回 1', async () => {
|
|
35
|
+
const code = await main(['link']);
|
|
36
|
+
expect(code).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { runInit, runList, runLink, runStatus, runUpdate, runUnlink, } from '../src/commands.js';
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
const TMP_PREFIX = 'dak-cmd-';
|
|
7
|
+
const FIXED_NOW = new Date('2025-06-28T10:00:00.000Z');
|
|
8
|
+
function makeStore(tmp) {
|
|
9
|
+
const store = join(tmp, 'store');
|
|
10
|
+
mkdirSync(store, { recursive: true });
|
|
11
|
+
return store;
|
|
12
|
+
}
|
|
13
|
+
function makeTarget(tmp, name) {
|
|
14
|
+
const target = join(tmp, name);
|
|
15
|
+
mkdirSync(target, { recursive: true });
|
|
16
|
+
return target;
|
|
17
|
+
}
|
|
18
|
+
function makeHome(tmp) {
|
|
19
|
+
const home = join(tmp, 'home');
|
|
20
|
+
mkdirSync(join(home, '.codex'), { recursive: true });
|
|
21
|
+
mkdirSync(join(home, '.claude'), { recursive: true });
|
|
22
|
+
return home;
|
|
23
|
+
}
|
|
24
|
+
describe('runInit', () => {
|
|
25
|
+
let tmp;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
rmSync(tmp, { recursive: true });
|
|
31
|
+
});
|
|
32
|
+
it('创建 store/config/state/resource dirs', async () => {
|
|
33
|
+
const storePath = join(tmp, 'init-store');
|
|
34
|
+
const output = await runInit({ store: storePath, homeDir: '/tmp/home' });
|
|
35
|
+
expect(output).toContain('Initialized');
|
|
36
|
+
// config 存在
|
|
37
|
+
const config = JSON.parse(readFileSync(join(storePath, 'dak.config.json'), 'utf-8'));
|
|
38
|
+
expect(config.store).toBe(storePath);
|
|
39
|
+
// state 存在
|
|
40
|
+
const state = JSON.parse(readFileSync(join(storePath, '.dak-state.json'), 'utf-8'));
|
|
41
|
+
expect(state.version).toBe(1);
|
|
42
|
+
// resource dirs 存在
|
|
43
|
+
for (const type of ['skills', 'hooks', 'agents']) {
|
|
44
|
+
expect(require('node:fs').existsSync(join(storePath, type))).toBe(true);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('runList', () => {
|
|
49
|
+
let tmp;
|
|
50
|
+
let store;
|
|
51
|
+
let home;
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
|
|
54
|
+
store = makeStore(tmp);
|
|
55
|
+
home = makeHome(tmp);
|
|
56
|
+
await runInit({ store, homeDir: home });
|
|
57
|
+
mkdirSync(join(store, 'skills'), { recursive: true });
|
|
58
|
+
mkdirSync(join(store, 'skills', 'my-skill'));
|
|
59
|
+
mkdirSync(join(store, 'hooks'), { recursive: true });
|
|
60
|
+
mkdirSync(join(store, 'hooks', 'my-hook'));
|
|
61
|
+
mkdirSync(join(store, 'agents'), { recursive: true });
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
rmSync(tmp, { recursive: true });
|
|
65
|
+
});
|
|
66
|
+
it('输出三类资源', async () => {
|
|
67
|
+
const output = await runList({ store, homeDir: home });
|
|
68
|
+
expect(output).toContain('Skills:');
|
|
69
|
+
expect(output).toContain('Hooks:');
|
|
70
|
+
expect(output).toContain('Agents:');
|
|
71
|
+
expect(output).toContain('my-skill');
|
|
72
|
+
expect(output).toContain('my-hook');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('runLink', () => {
|
|
76
|
+
let tmp;
|
|
77
|
+
let store;
|
|
78
|
+
let home;
|
|
79
|
+
let codex;
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
|
|
82
|
+
store = makeStore(tmp);
|
|
83
|
+
home = makeHome(tmp);
|
|
84
|
+
codex = join(home, '.codex'); // 使用 home 下的 .codex
|
|
85
|
+
mkdirSync(join(codex, 'skills'), { recursive: true });
|
|
86
|
+
mkdirSync(join(codex, 'agents'), { recursive: true });
|
|
87
|
+
// 初始化 config(使用真实存在的 homeDir)
|
|
88
|
+
await runInit({ store, homeDir: home });
|
|
89
|
+
// 写入 store 资源
|
|
90
|
+
writeFileSync(join(store, 'skills', 'foo'), 'x');
|
|
91
|
+
mkdirSync(join(store, 'hooks', 'my-hook'));
|
|
92
|
+
writeFileSync(join(store, 'agents', 'bar'), 'x');
|
|
93
|
+
});
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
rmSync(tmp, { recursive: true });
|
|
96
|
+
});
|
|
97
|
+
it('runLink(codex) 为三类资源创建 symlink 并写 state', async () => {
|
|
98
|
+
const output = await runLink('codex', { store, homeDir: home, now: FIXED_NOW });
|
|
99
|
+
expect(output).toContain('created');
|
|
100
|
+
// symlink 已创建
|
|
101
|
+
expect(readFileSync(join(codex, 'skills', 'foo'), 'utf-8')).toBe('x');
|
|
102
|
+
expect(readFileSync(join(codex, 'agents', 'bar'), 'utf-8')).toBe('x');
|
|
103
|
+
// state 存在
|
|
104
|
+
const state = JSON.parse(readFileSync(join(store, '.dak-state.json'), 'utf-8'));
|
|
105
|
+
expect(state.targets['codex'].skills['foo']).toBeDefined();
|
|
106
|
+
});
|
|
107
|
+
it('runLink(all) 支持配置里的所有 targets', async () => {
|
|
108
|
+
const claude = join(home, '.claude');
|
|
109
|
+
mkdirSync(join(claude, 'skills'), { recursive: true });
|
|
110
|
+
mkdirSync(join(claude, 'agents'), { recursive: true });
|
|
111
|
+
const output = await runLink('all', { store, homeDir: home, now: FIXED_NOW });
|
|
112
|
+
expect(output).toContain('created');
|
|
113
|
+
expect(readFileSync(join(claude, 'skills', 'foo'), 'utf-8')).toBe('x');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe('runUpdate', () => {
|
|
117
|
+
let tmp;
|
|
118
|
+
let store;
|
|
119
|
+
let home;
|
|
120
|
+
let codex;
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
|
|
123
|
+
store = makeStore(tmp);
|
|
124
|
+
home = makeHome(tmp);
|
|
125
|
+
codex = join(home, '.codex');
|
|
126
|
+
mkdirSync(join(codex, 'skills'), { recursive: true });
|
|
127
|
+
await runInit({ store, homeDir: home });
|
|
128
|
+
// 初始资源
|
|
129
|
+
writeFileSync(join(store, 'skills', 'foo'), 'x');
|
|
130
|
+
});
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
rmSync(tmp, { recursive: true });
|
|
133
|
+
});
|
|
134
|
+
it('store 新增 item 后补 symlink', async () => {
|
|
135
|
+
await runLink('codex', { store, homeDir: '/home/test', now: FIXED_NOW });
|
|
136
|
+
// store 新增 bar
|
|
137
|
+
writeFileSync(join(store, 'skills', 'bar'), 'y');
|
|
138
|
+
const output = await runUpdate({ store, homeDir: '/home/test', now: FIXED_NOW });
|
|
139
|
+
expect(output).toContain('created');
|
|
140
|
+
expect(readFileSync(join(codex, 'skills', 'bar'), 'utf-8')).toBe('y');
|
|
141
|
+
});
|
|
142
|
+
it('store 删除 item 后安全删除旧 symlink', async () => {
|
|
143
|
+
await runLink('codex', { store, homeDir: home, now: FIXED_NOW });
|
|
144
|
+
// 删除 store 中的 foo
|
|
145
|
+
rmSync(join(store, 'skills', 'foo'));
|
|
146
|
+
const output = await runUpdate({ store, homeDir: home, now: FIXED_NOW });
|
|
147
|
+
expect(output).toContain('deleted');
|
|
148
|
+
// target symlink 已删除,读取应抛错
|
|
149
|
+
expect(() => readFileSync(join(codex, 'skills', 'foo'))).toThrow();
|
|
150
|
+
});
|
|
151
|
+
it('只 link 过 codex 时不得刷新 claude-code', async () => {
|
|
152
|
+
const claude = join(home, '.claude');
|
|
153
|
+
mkdirSync(join(claude, 'skills'), { recursive: true });
|
|
154
|
+
// 只 link codex
|
|
155
|
+
await runLink('codex', { store, homeDir: home, now: FIXED_NOW });
|
|
156
|
+
// update 应只更新 codex
|
|
157
|
+
const output = await runUpdate({ store, homeDir: home, now: FIXED_NOW });
|
|
158
|
+
expect(output).not.toContain('claude-code');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe('runStatus', () => {
|
|
162
|
+
let tmp;
|
|
163
|
+
let store;
|
|
164
|
+
let home;
|
|
165
|
+
let codex;
|
|
166
|
+
beforeEach(async () => {
|
|
167
|
+
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
|
|
168
|
+
store = makeStore(tmp);
|
|
169
|
+
home = makeHome(tmp);
|
|
170
|
+
codex = join(home, '.codex');
|
|
171
|
+
mkdirSync(join(codex, 'skills'), { recursive: true });
|
|
172
|
+
await runInit({ store, homeDir: home });
|
|
173
|
+
writeFileSync(join(store, 'skills', 'foo'), 'x');
|
|
174
|
+
writeFileSync(join(store, 'skills', 'stale-item'), 'x');
|
|
175
|
+
});
|
|
176
|
+
afterEach(() => {
|
|
177
|
+
rmSync(tmp, { recursive: true });
|
|
178
|
+
});
|
|
179
|
+
it('输出 linked/missing/stale', async () => {
|
|
180
|
+
// link foo
|
|
181
|
+
await runLink('codex', { store, homeDir: home, now: FIXED_NOW });
|
|
182
|
+
// 创建 missing item(store 中存在但未 link)
|
|
183
|
+
mkdirSync(join(store, 'skills', 'missing-item'));
|
|
184
|
+
// stale:store 已删但 state 有记录
|
|
185
|
+
rmSync(join(store, 'skills', 'stale-item'));
|
|
186
|
+
const output = await runStatus({ store, homeDir: home });
|
|
187
|
+
expect(output).toContain('linked');
|
|
188
|
+
expect(output).toContain('missing');
|
|
189
|
+
expect(output).toContain('stale');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe('runUnlink', () => {
|
|
193
|
+
let tmp;
|
|
194
|
+
let store;
|
|
195
|
+
let home;
|
|
196
|
+
let codex;
|
|
197
|
+
beforeEach(async () => {
|
|
198
|
+
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
|
|
199
|
+
store = makeStore(tmp);
|
|
200
|
+
home = makeHome(tmp);
|
|
201
|
+
codex = join(home, '.codex');
|
|
202
|
+
mkdirSync(join(codex, 'skills'), { recursive: true });
|
|
203
|
+
await runInit({ store, homeDir: home });
|
|
204
|
+
writeFileSync(join(store, 'skills', 'foo'), 'x');
|
|
205
|
+
});
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
rmSync(tmp, { recursive: true });
|
|
208
|
+
});
|
|
209
|
+
it('只删除 dak 管理的 symlink,不删除 store 资源', async () => {
|
|
210
|
+
await runLink('codex', { store, homeDir: home, now: FIXED_NOW });
|
|
211
|
+
const output = await runUnlink('codex', { store, homeDir: home, now: FIXED_NOW });
|
|
212
|
+
expect(output).toContain('deleted');
|
|
213
|
+
// store 源文件仍在
|
|
214
|
+
expect(readFileSync(join(store, 'skills', 'foo'), 'utf-8')).toBe('x');
|
|
215
|
+
// target 已删除
|
|
216
|
+
expect(() => readFileSync(join(codex, 'skills', 'foo'))).toThrow();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolveStorePath, createDefaultConfig, writeConfigIfMissing, validateConfigStore, } from '../src/config.js';
|
|
3
|
+
import { readState, writeState, } from '../src/state.js';
|
|
4
|
+
import { scanResources, formatResourceList } from '../src/resources.js';
|
|
5
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
const TMP_PREFIX = 'dak-config-';
|
|
9
|
+
describe('resolveStorePath', () => {
|
|
10
|
+
it('默认 store 解析为 <home>/.dog-agents-kit', () => {
|
|
11
|
+
const result = resolveStorePath(undefined, '/home/test');
|
|
12
|
+
expect(result).toBe('/home/test/.dog-agents-kit');
|
|
13
|
+
});
|
|
14
|
+
it('--store ~/custom-store 展开 home', () => {
|
|
15
|
+
const result = resolveStorePath('~/custom-store', '/home/test');
|
|
16
|
+
expect(result).toBe('/home/test/custom-store');
|
|
17
|
+
});
|
|
18
|
+
it('绝对路径直接返回', () => {
|
|
19
|
+
const result = resolveStorePath('/abs/store', '/home/test');
|
|
20
|
+
expect(result).toBe('/abs/store');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe('createDefaultConfig', () => {
|
|
24
|
+
it('写入绝对 store path', () => {
|
|
25
|
+
const config = createDefaultConfig('/abs/store', '/home/test');
|
|
26
|
+
expect(config.store).toBe('/abs/store');
|
|
27
|
+
expect(config.targets['codex'].path).toBe('/home/test/.codex');
|
|
28
|
+
expect(config.targets['claude-code'].path).toBe('/home/test/.claude');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe('writeConfigIfMissing', () => {
|
|
32
|
+
let tmp;
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
rmSync(tmp, { recursive: true });
|
|
38
|
+
});
|
|
39
|
+
it('store 不存在时创建 config', async () => {
|
|
40
|
+
const storePath = join(tmp, 'store');
|
|
41
|
+
const config = createDefaultConfig(storePath, '/home/test');
|
|
42
|
+
await writeConfigIfMissing(storePath, config);
|
|
43
|
+
const content = readFileSync(join(storePath, 'dak.config.json'), 'utf-8');
|
|
44
|
+
expect(JSON.parse(content).store).toBe(storePath);
|
|
45
|
+
});
|
|
46
|
+
it('已存在 dak.config.json 时不覆盖', async () => {
|
|
47
|
+
const storePath = join(tmp, 'store');
|
|
48
|
+
mkdirSync(storePath);
|
|
49
|
+
writeFileSync(join(storePath, 'dak.config.json'), '{"store":"custom"}');
|
|
50
|
+
const config = createDefaultConfig(storePath, '/home/test');
|
|
51
|
+
await writeConfigIfMissing(storePath, config);
|
|
52
|
+
const content = readFileSync(join(storePath, 'dak.config.json'), 'utf-8');
|
|
53
|
+
expect(JSON.parse(content).store).toBe('custom');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('validateConfigStore', () => {
|
|
57
|
+
it('config.store 和当前 store 不一致时报错', () => {
|
|
58
|
+
const config = { store: '/abs/store', targets: {} };
|
|
59
|
+
expect(() => validateConfigStore(config, '/other/store')).toThrow('config store mismatch');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('scanResources', () => {
|
|
63
|
+
let tmp;
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
rmSync(tmp, { recursive: true });
|
|
69
|
+
});
|
|
70
|
+
it('只扫一级子项', async () => {
|
|
71
|
+
const storePath = join(tmp, 'store');
|
|
72
|
+
mkdirSync(join(storePath, 'skills', 'nested'), { recursive: true });
|
|
73
|
+
writeFileSync(join(storePath, 'skills', 'top-skill'), 'x');
|
|
74
|
+
writeFileSync(join(storePath, 'skills', 'nested', 'deep'), 'x');
|
|
75
|
+
const config = createDefaultConfig(storePath, '/home/test');
|
|
76
|
+
const resources = await scanResources(storePath, config);
|
|
77
|
+
// 一级子项应包含 top-skill(文件)和 nested(目录),但不含 nested/deep
|
|
78
|
+
const names = resources.skills.map(i => i.name).sort();
|
|
79
|
+
expect(names).toEqual(['nested', 'top-skill']);
|
|
80
|
+
expect(resources.skills.find(i => i.name === 'nested')?.kind).toBe('directory');
|
|
81
|
+
});
|
|
82
|
+
it('hidden item 跳过', async () => {
|
|
83
|
+
const storePath = join(tmp, 'store');
|
|
84
|
+
mkdirSync(join(storePath, 'skills'), { recursive: true });
|
|
85
|
+
writeFileSync(join(storePath, 'skills', '.hidden'), 'x');
|
|
86
|
+
writeFileSync(join(storePath, 'skills', 'visible'), 'x');
|
|
87
|
+
const config = createDefaultConfig(storePath, '/home/test');
|
|
88
|
+
const resources = await scanResources(storePath, config);
|
|
89
|
+
expect(resources.skills.map(i => i.name)).toEqual(['visible']);
|
|
90
|
+
});
|
|
91
|
+
it('文件、目录、symlink 都算 item', async () => {
|
|
92
|
+
const storePath = join(tmp, 'store');
|
|
93
|
+
mkdirSync(join(storePath, 'skills'), { recursive: true });
|
|
94
|
+
writeFileSync(join(storePath, 'skills', 'file-skill'), 'x');
|
|
95
|
+
mkdirSync(join(storePath, 'skills', 'dir-skill'));
|
|
96
|
+
const linkTarget = join(tmp, 'real-target');
|
|
97
|
+
writeFileSync(linkTarget, 'x');
|
|
98
|
+
// symlink via fs.symlinkSync
|
|
99
|
+
const { symlinkSync } = await import('node:fs');
|
|
100
|
+
symlinkSync(linkTarget, join(storePath, 'skills', 'link-skill'));
|
|
101
|
+
const config = createDefaultConfig(storePath, '/home/test');
|
|
102
|
+
const resources = await scanResources(storePath, config);
|
|
103
|
+
const names = resources.skills.map(i => i.name).sort();
|
|
104
|
+
expect(names).toEqual(['dir-skill', 'file-skill', 'link-skill']);
|
|
105
|
+
});
|
|
106
|
+
it('formatResourceList 输出固定标题行', async () => {
|
|
107
|
+
const resources = {
|
|
108
|
+
skills: [{ name: 'foo', kind: 'file', path: '/s/foo' }],
|
|
109
|
+
hooks: [],
|
|
110
|
+
agents: [],
|
|
111
|
+
};
|
|
112
|
+
const output = formatResourceList(resources);
|
|
113
|
+
expect(output).toContain('Skills:');
|
|
114
|
+
expect(output).toContain('Hooks:');
|
|
115
|
+
expect(output).toContain('Agents:');
|
|
116
|
+
expect(output).toContain('foo');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('readState / writeState', () => {
|
|
120
|
+
let tmp;
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
tmp = mkdtempSync(join(tmpdir(), TMP_PREFIX));
|
|
123
|
+
});
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
rmSync(tmp, { recursive: true });
|
|
126
|
+
});
|
|
127
|
+
it('缺文件时返回空状态', async () => {
|
|
128
|
+
const state = await readState(join(tmp, 'no-store'));
|
|
129
|
+
expect(state.version).toBe(1);
|
|
130
|
+
expect(state.targets).toEqual({});
|
|
131
|
+
});
|
|
132
|
+
it('只接受 version: 1', async () => {
|
|
133
|
+
const storePath = join(tmp, 'store');
|
|
134
|
+
mkdirSync(storePath);
|
|
135
|
+
writeFileSync(join(storePath, '.dak-state.json'), '{"version":2,"targets":{}}');
|
|
136
|
+
await expect(readState(storePath)).rejects.toThrow('unsupported state version');
|
|
137
|
+
});
|
|
138
|
+
it('写状态后可读回', async () => {
|
|
139
|
+
const storePath = join(tmp, 'store');
|
|
140
|
+
mkdirSync(storePath);
|
|
141
|
+
const state = { version: 1, targets: {} };
|
|
142
|
+
await writeState(storePath, state);
|
|
143
|
+
const read = await readState(storePath);
|
|
144
|
+
expect(read).toEqual(state);
|
|
145
|
+
});
|
|
146
|
+
});
|