@morehao/dsh-skills-console 0.0.0 → 0.2.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 +112 -0
- package/client.js +1246 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +15 -0
- package/lib/agent-table.json +331 -0
- package/lib/core/agents.js +124 -0
- package/lib/core/cli.js +232 -0
- package/lib/core/hash.js +46 -0
- package/lib/core/inventory.js +174 -0
- package/lib/core/links.js +104 -0
- package/lib/core/log.js +23 -0
- package/lib/core/paths.js +54 -0
- package/lib/core/service.js +897 -0
- package/lib/core/state.js +149 -0
- package/lib/core/trash.js +142 -0
- package/lib/core/updates.js +138 -0
- package/lib/index.js +77 -0
- package/lib/routes.js +163 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const STATE_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export function emptyState() {
|
|
7
|
+
return {
|
|
8
|
+
version: STATE_VERSION,
|
|
9
|
+
settings: { autoLinkTargets: null, placement: 'settings' },
|
|
10
|
+
agents: { custom: [], dismissed: [] },
|
|
11
|
+
subscriptions: [],
|
|
12
|
+
installs: [],
|
|
13
|
+
links: [],
|
|
14
|
+
checks: {},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isPlainObject(value) {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 状态读写。写入规则:全量读 → 校验 → 临时文件 → rename。
|
|
24
|
+
* 损坏时**只读降级**,绝不 fail-open:宁可只读,也不要拿坏状态去覆盖链接。
|
|
25
|
+
*/
|
|
26
|
+
export class StateStore {
|
|
27
|
+
constructor(paths, logger = () => {}) {
|
|
28
|
+
this.paths = paths;
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
this.readOnly = false;
|
|
31
|
+
this.problem = null;
|
|
32
|
+
this.state = emptyState();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
load() {
|
|
36
|
+
const file = this.paths.stateFile;
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error && error.code === 'ENOENT') {
|
|
42
|
+
this.state = emptyState();
|
|
43
|
+
this.readOnly = false;
|
|
44
|
+
this.problem = null;
|
|
45
|
+
return this.state;
|
|
46
|
+
}
|
|
47
|
+
this.readOnly = true;
|
|
48
|
+
this.problem = `state unreadable: ${error.message}`;
|
|
49
|
+
return this.state;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.readOnly = true;
|
|
57
|
+
this.problem = `state not JSON: ${error.message}`;
|
|
58
|
+
this.logger('state.corrupt', { reason: this.problem });
|
|
59
|
+
return this.state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const verdict = validateState(parsed);
|
|
63
|
+
if (!verdict.ok) {
|
|
64
|
+
this.readOnly = true;
|
|
65
|
+
this.problem = verdict.reason;
|
|
66
|
+
this.logger('state.corrupt', { reason: verdict.reason });
|
|
67
|
+
return this.state;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.state = verdict.state;
|
|
71
|
+
this.readOnly = false;
|
|
72
|
+
this.problem = null;
|
|
73
|
+
return this.state;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
save(next) {
|
|
77
|
+
if (this.readOnly) throw new StateReadOnlyError(this.problem);
|
|
78
|
+
const state = { ...next, version: STATE_VERSION };
|
|
79
|
+
fs.mkdirSync(this.paths.stateDir, { recursive: true });
|
|
80
|
+
const tmp = path.join(this.paths.stateDir, `.state.${process.pid}.${Date.now()}.tmp`);
|
|
81
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
82
|
+
fs.renameSync(tmp, this.paths.stateFile);
|
|
83
|
+
this.state = state;
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 便捷读改写:mutator 直接改传入对象,返回值写回。 */
|
|
88
|
+
update(mutator) {
|
|
89
|
+
const draft = structuredClone(this.state);
|
|
90
|
+
const result = mutator(draft);
|
|
91
|
+
const next = result && typeof result === 'object' && result.version ? result : draft;
|
|
92
|
+
return this.save(next);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get installs() {
|
|
96
|
+
return this.state.installs;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
findInstall(name) {
|
|
100
|
+
return this.state.installs.find((item) => item.name === name);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class StateReadOnlyError extends Error {
|
|
105
|
+
constructor(problem) {
|
|
106
|
+
super(problem || 'state is read-only');
|
|
107
|
+
this.code = 'error.skills.state-readonly';
|
|
108
|
+
this.problem = problem;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function validateState(value) {
|
|
113
|
+
if (!isPlainObject(value)) return { ok: false, reason: 'state is not an object' };
|
|
114
|
+
if (typeof value.version !== 'number' || !Number.isInteger(value.version)) {
|
|
115
|
+
return { ok: false, reason: 'state.version missing' };
|
|
116
|
+
}
|
|
117
|
+
if (value.version > STATE_VERSION) {
|
|
118
|
+
return { ok: false, reason: `state.version ${value.version} newer than ${STATE_VERSION}` };
|
|
119
|
+
}
|
|
120
|
+
if (value.version < 1) return { ok: false, reason: `state.version ${value.version} invalid` };
|
|
121
|
+
|
|
122
|
+
const state = { ...emptyState(), ...value };
|
|
123
|
+
for (const key of ['subscriptions', 'installs', 'links']) {
|
|
124
|
+
if (!Array.isArray(state[key])) return { ok: false, reason: `state.${key} is not an array` };
|
|
125
|
+
}
|
|
126
|
+
if (!isPlainObject(state.settings)) return { ok: false, reason: 'state.settings is not an object' };
|
|
127
|
+
if (!isPlainObject(state.agents)) return { ok: false, reason: 'state.agents is not an object' };
|
|
128
|
+
if (!isPlainObject(state.checks)) state.checks = {};
|
|
129
|
+
if (!Array.isArray(state.agents.custom)) state.agents.custom = [];
|
|
130
|
+
if (!Array.isArray(state.agents.dismissed)) state.agents.dismissed = [];
|
|
131
|
+
// v1 的老状态只有布尔 autoLinkAgents:true 表示"所有探测到的 Agent"(null),false 表示一个都不选。
|
|
132
|
+
const settings = state.settings;
|
|
133
|
+
if (settings.autoLinkTargets === undefined) {
|
|
134
|
+
settings.autoLinkTargets = settings.autoLinkAgents === false ? [] : null;
|
|
135
|
+
}
|
|
136
|
+
if (settings.autoLinkTargets !== null && !Array.isArray(settings.autoLinkTargets)) {
|
|
137
|
+
settings.autoLinkTargets = null;
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(settings.autoLinkTargets)) {
|
|
140
|
+
settings.autoLinkTargets = settings.autoLinkTargets.filter(
|
|
141
|
+
(item) => typeof item === 'string' && item.startsWith('agent:'),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
delete settings.autoLinkAgents;
|
|
145
|
+
// 入口位置:settings = 设置页分区(默认),sidebar = 左侧栏独立面板。
|
|
146
|
+
if (settings.placement !== 'sidebar') settings.placement = 'settings';
|
|
147
|
+
state.version = STATE_VERSION;
|
|
148
|
+
return { ok: true, state };
|
|
149
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const KEEP_GENERATIONS = 5;
|
|
6
|
+
const MAX_BYTES = 200 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
function dirSize(dir) {
|
|
9
|
+
let total = 0;
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
13
|
+
} catch {
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const full = path.join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) total += dirSize(full);
|
|
19
|
+
else if (entry.isFile()) {
|
|
20
|
+
try {
|
|
21
|
+
total += fs.statSync(full).size;
|
|
22
|
+
} catch {
|
|
23
|
+
/* 忽略统计失败 */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return total;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function copyTree(from, to) {
|
|
31
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
32
|
+
fs.cpSync(from, to, { recursive: true, dereference: false, preserveTimestamps: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function newKey(skill) {
|
|
36
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
37
|
+
const rand = crypto.randomBytes(3).toString('hex');
|
|
38
|
+
return `${skill}-${stamp}-${rand}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 备份落 `skills-console/backups/<key>/`,**绝不进 `skills/`**:
|
|
43
|
+
* 任何含 `SKILL.md` 的子目录都会被原生当成技能扫到(F13)。
|
|
44
|
+
*/
|
|
45
|
+
export function createBackup({ paths, skill, fromDir, hash = null }) {
|
|
46
|
+
const key = newKey(skill);
|
|
47
|
+
const target = path.join(paths.backupsDir, key);
|
|
48
|
+
copyTree(fromDir, path.join(target, 'content'));
|
|
49
|
+
const meta = { key, skill, hash, from: fromDir, createdAt: new Date().toISOString() };
|
|
50
|
+
fs.writeFileSync(path.join(target, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
|
|
51
|
+
pruneBackups({ paths });
|
|
52
|
+
return { key, dir: target, ...meta };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readBackup({ paths, key }) {
|
|
56
|
+
const dir = path.join(paths.backupsDir, key);
|
|
57
|
+
const metaPath = path.join(dir, 'meta.json');
|
|
58
|
+
if (!fs.existsSync(metaPath)) return null;
|
|
59
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
60
|
+
return { ...meta, dir, contentDir: path.join(dir, 'content') };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function listBackups({ paths }) {
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = fs.readdirSync(paths.backupsDir, { withFileTypes: true });
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
return entries
|
|
71
|
+
.filter((entry) => entry.isDirectory())
|
|
72
|
+
.map((entry) => readBackup({ paths, key: entry.name }))
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 保留最近 N 代 + 总量上限,超出按最旧清理。 */
|
|
78
|
+
export function pruneBackups({ paths, keep = KEEP_GENERATIONS, maxBytes = MAX_BYTES }) {
|
|
79
|
+
const backups = listBackups({ paths });
|
|
80
|
+
let total = backups.reduce((sum, item) => sum + dirSize(item.dir), 0);
|
|
81
|
+
backups.forEach((item, index) => {
|
|
82
|
+
const tooMany = index >= keep;
|
|
83
|
+
const tooBig = total > maxBytes;
|
|
84
|
+
if (!tooMany && !tooBig) return;
|
|
85
|
+
const size = dirSize(item.dir);
|
|
86
|
+
fs.rmSync(item.dir, { recursive: true, force: true });
|
|
87
|
+
total -= size;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 回收站:删除前的快照,保留 7 天。 */
|
|
92
|
+
export function trashSkill({ paths, skill, fromDir, hash = null }) {
|
|
93
|
+
const key = newKey(skill);
|
|
94
|
+
const target = path.join(paths.trashDir, key);
|
|
95
|
+
copyTree(fromDir, path.join(target, 'content'));
|
|
96
|
+
const meta = {
|
|
97
|
+
key,
|
|
98
|
+
skill,
|
|
99
|
+
hash,
|
|
100
|
+
from: fromDir,
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
|
103
|
+
};
|
|
104
|
+
fs.writeFileSync(path.join(target, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
|
|
105
|
+
return { key, dir: target, ...meta };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function listTrash({ paths }) {
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = fs.readdirSync(paths.trashDir, { withFileTypes: true });
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
return entries
|
|
117
|
+
.filter((entry) => entry.isDirectory())
|
|
118
|
+
.map((entry) => {
|
|
119
|
+
const metaPath = path.join(paths.trashDir, entry.name, 'meta.json');
|
|
120
|
+
if (!fs.existsSync(metaPath)) return null;
|
|
121
|
+
try {
|
|
122
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
123
|
+
return { ...meta, dir: path.join(paths.trashDir, entry.name), expired: Date.parse(meta.expiresAt) < now };
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function pruneTrash({ paths }) {
|
|
132
|
+
for (const item of listTrash({ paths })) {
|
|
133
|
+
if (item.expired) fs.rmSync(item.dir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 把备份内容换回 canonical(先删再拷,保持原子性由调用方的 add/校验兜底)。 */
|
|
138
|
+
export function restoreBackup({ backup, toDir }) {
|
|
139
|
+
fs.rmSync(toDir, { recursive: true, force: true });
|
|
140
|
+
copyTree(backup.contentDir, toDir);
|
|
141
|
+
return { restored: true, toDir };
|
|
142
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const API = 'https://api.github.com';
|
|
4
|
+
|
|
5
|
+
/** `owner/repo`、`https://github.com/owner/repo(.git)`、`git@github.com:owner/repo.git` 都能认。 */
|
|
6
|
+
export function parseSource(source) {
|
|
7
|
+
if (!source || typeof source !== 'string') return null;
|
|
8
|
+
const trimmed = source.trim();
|
|
9
|
+
const short = trimmed.match(/^([\w.-]+)\/([\w.-]+)$/);
|
|
10
|
+
if (short) return { owner: short[1], repo: short[2].replace(/\.git$/, ''), kind: 'short' };
|
|
11
|
+
const url = trimmed.match(/github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[/#?].*)?$/);
|
|
12
|
+
if (url) return { owner: url[1], repo: url[2], kind: 'url' };
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function snapshotId({ source, ref, subpath }) {
|
|
17
|
+
return crypto
|
|
18
|
+
.createHash('sha256')
|
|
19
|
+
.update(`${source}\n${ref || ''}\n${subpath || ''}`)
|
|
20
|
+
.digest('hex')
|
|
21
|
+
.slice(0, 16);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function githubToken(env = process.env) {
|
|
25
|
+
return env.GITHUB_TOKEN || env.GH_TOKEN || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function callJson(url, { token, etag, fetchImpl = fetch } = {}) {
|
|
29
|
+
const headers = {
|
|
30
|
+
accept: 'application/vnd.github+json',
|
|
31
|
+
'user-agent': 'dsh-skills-console',
|
|
32
|
+
'x-github-api-version': '2022-11-28',
|
|
33
|
+
};
|
|
34
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
35
|
+
if (etag) headers['if-none-match'] = etag;
|
|
36
|
+
const response = await fetchImpl(url, { headers });
|
|
37
|
+
if (response.status === 304) return { status: 304, notModified: true, etag };
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const text = await response.text().catch(() => '');
|
|
40
|
+
return { status: response.status, error: `HTTP ${response.status} ${text.slice(0, 200)}` };
|
|
41
|
+
}
|
|
42
|
+
const body = await response.json();
|
|
43
|
+
return { status: response.status, body, etag: response.headers.get('etag') };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 一次 Trees 请求取整树(F8)。`ref` 可以是分支 / tag / commit —— Trees API 两种都收。
|
|
48
|
+
* ETag 是**整响应级**,缓存键是 `(repo, ref, commit)`;304 时只能复用上次数据,绝不把 entries 置空。
|
|
49
|
+
*/
|
|
50
|
+
export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl } = {}) {
|
|
51
|
+
const parsed = parseSource(source);
|
|
52
|
+
if (!parsed) return { error: 'invalid-source' };
|
|
53
|
+
const url = `${API}/repos/${parsed.owner}/${parsed.repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
|
|
54
|
+
const result = await callJson(url, { token, etag, fetchImpl });
|
|
55
|
+
if (result.notModified) return { notModified: true, etag: result.etag };
|
|
56
|
+
if (result.error) return { error: result.error, status: result.status };
|
|
57
|
+
const tree = Array.isArray(result.body?.tree) ? result.body.tree : [];
|
|
58
|
+
return {
|
|
59
|
+
tree,
|
|
60
|
+
rootSha: result.body?.sha || null,
|
|
61
|
+
truncated: Boolean(result.body?.truncated),
|
|
62
|
+
etag: result.etag,
|
|
63
|
+
repo: `${parsed.owner}/${parsed.repo}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 解析 ref 对应的 commit(只在安装/计划时需要,检测阶段不付这笔成本)。 */
|
|
68
|
+
export async function resolveCommit({ source, ref = 'main', token, fetchImpl } = {}) {
|
|
69
|
+
const parsed = parseSource(source);
|
|
70
|
+
if (!parsed) return null;
|
|
71
|
+
const result = await callJson(`${API}/repos/${parsed.owner}/${parsed.repo}/commits/${encodeURIComponent(ref)}`, {
|
|
72
|
+
token,
|
|
73
|
+
fetchImpl,
|
|
74
|
+
});
|
|
75
|
+
if (result.error || !result.body?.sha) return null;
|
|
76
|
+
return result.body.sha;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `KEY` + `/dir/SKILL.md` → `dir`(F18:`skillPath` 是文件路径,目录运算先 dirname)。 */
|
|
80
|
+
export function skillDir(skillPath) {
|
|
81
|
+
const parts = String(skillPath).split('/');
|
|
82
|
+
parts.pop();
|
|
83
|
+
return parts.join('/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function skillTreeSha({ tree, skillPath, rootSha }) {
|
|
87
|
+
const dir = skillDir(skillPath);
|
|
88
|
+
if (!dir) return rootSha;
|
|
89
|
+
const entry = tree.find((item) => item.type === 'tree' && item.path === dir);
|
|
90
|
+
return entry ? entry.sha : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 快照 = 仓库里所有 `SKILL.md` 的目录条目。
|
|
95
|
+
* 名字取目录名;根级 `SKILL.md` 用仓库名(读不到 frontmatter,名字只能是近似值,装完以 canonical 为准)。
|
|
96
|
+
*/
|
|
97
|
+
export function listSkillsFromTree({ tree, rootSha, subpath, repoName }) {
|
|
98
|
+
const prefix = subpath ? `${subpath.replace(/\/+$/, '')}/` : '';
|
|
99
|
+
const byName = new Map();
|
|
100
|
+
for (const entry of tree) {
|
|
101
|
+
if (entry.type !== 'blob') continue;
|
|
102
|
+
if (entry.path !== 'SKILL.md' && !entry.path.endsWith('/SKILL.md')) continue;
|
|
103
|
+
if (prefix && !entry.path.startsWith(prefix)) continue;
|
|
104
|
+
const dir = skillDir(entry.path);
|
|
105
|
+
const name = dir ? dir.split('/').pop() : repoName || 'root';
|
|
106
|
+
if (byName.has(name)) continue;
|
|
107
|
+
byName.set(name, {
|
|
108
|
+
name,
|
|
109
|
+
skillPath: entry.path,
|
|
110
|
+
treeSha: skillTreeSha({ tree, skillPath: entry.path, rootSha }),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 单技能判定(L2/L3)。哈希形态必须配对(F19):
|
|
118
|
+
* `sourceType=github` 时 lock 里应是 40 位 tree SHA;64 位内容哈希说明来源形态不对,直接给 `unknown`。
|
|
119
|
+
*/
|
|
120
|
+
export function checkSkill({ lockHash, snapshotEntry, tree, rootSha }) {
|
|
121
|
+
if (!snapshotEntry) return { status: 'upstream-deleted', detail: '上游整树里已找不到这个 skillPath' };
|
|
122
|
+
if (!lockHash) return { status: 'unknown', detail: 'lock 里没有 skillFolderHash' };
|
|
123
|
+
if (!/^[0-9a-f]{40}$/.test(lockHash)) {
|
|
124
|
+
return {
|
|
125
|
+
status: 'unknown',
|
|
126
|
+
detail: 'lock 的哈希不是 40 位 tree SHA(形态不配对),无法按 tree 比对',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const target = skillTreeSha({ tree, skillPath: snapshotEntry.skillPath, rootSha });
|
|
130
|
+
if (!target) return { status: 'unknown', detail: '整树里找不到该目录的 tree 条目' };
|
|
131
|
+
if (target === lockHash) return { status: 'current', detail: '与安装时的 tree SHA 一致' };
|
|
132
|
+
return { status: 'update-available', detail: 'tree SHA 已变化', targetTreeSha: target };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 计划 → 应用的防换手 token:磁盘或上游在"计划"之后被改动就拒绝(设计 §7.4③)。 */
|
|
136
|
+
export function planToken(parts) {
|
|
137
|
+
return crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex');
|
|
138
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createCli } from './core/cli.js';
|
|
2
|
+
import { createLogger } from './core/log.js';
|
|
3
|
+
import { createPaths } from './core/paths.js';
|
|
4
|
+
import { createRegistry } from './core/agents.js';
|
|
5
|
+
import { createService } from './core/service.js';
|
|
6
|
+
import { StateStore } from './core/state.js';
|
|
7
|
+
import { createRoutes, ROUTE_PREFIX } from './routes.js';
|
|
8
|
+
|
|
9
|
+
/** cordis 插件 id:必须等于 `cordis.patch.yml` 的 `insert[0].id`(设计 §15.2)。 */
|
|
10
|
+
export const name = 'dsh-skills-console';
|
|
11
|
+
|
|
12
|
+
/** 硬依赖只有 HTTP 载体;skills 注册表按可选服务取(缺失时不影响清单与链接)。 */
|
|
13
|
+
export const inject = ['webServer'];
|
|
14
|
+
|
|
15
|
+
export function apply(ctx, config = {}) {
|
|
16
|
+
const env = process.env;
|
|
17
|
+
const paths = createPaths(env, config.stateDir ? { stateDir: config.stateDir } : {});
|
|
18
|
+
const logger = createLogger(paths.logFile);
|
|
19
|
+
|
|
20
|
+
const store = new StateStore(paths, logger);
|
|
21
|
+
store.load();
|
|
22
|
+
if (store.readOnly) logger('state.readonly', { problem: store.problem });
|
|
23
|
+
|
|
24
|
+
const registry = createRegistry({ paths, getState: () => store.state });
|
|
25
|
+
const cli = createCli({ env, logger });
|
|
26
|
+
|
|
27
|
+
const visibleProvider = async (cwd) => {
|
|
28
|
+
const skills = ctx.get('skills');
|
|
29
|
+
if (!skills) return { skills: [], complete: false };
|
|
30
|
+
const snapshot = await skills.snapshot({ cwd: cwd || undefined });
|
|
31
|
+
return {
|
|
32
|
+
complete: snapshot.complete,
|
|
33
|
+
skills: snapshot.skills.map((item) => ({
|
|
34
|
+
name: item.name,
|
|
35
|
+
description: item.description,
|
|
36
|
+
source: item.source,
|
|
37
|
+
provider: item.provider,
|
|
38
|
+
path: item.path || null,
|
|
39
|
+
invocation: item.invocation,
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 「作用范围」里可以直接勾 DSH 工作区,不必每次走系统目录选择器。
|
|
46
|
+
* 只把目录还在的工作区列出来,避免把链接写进一个已经消失的路径。
|
|
47
|
+
*/
|
|
48
|
+
const workspacesProvider = async () => {
|
|
49
|
+
const workspaces = ctx.get('workspaceRegistry');
|
|
50
|
+
if (!workspaces?.list) return [];
|
|
51
|
+
const rows = [];
|
|
52
|
+
for (const workspace of workspaces.list()) {
|
|
53
|
+
try {
|
|
54
|
+
if ((await workspace.status()) !== 'ok') continue;
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
rows.push({ id: workspace.id, title: workspace.title, path: workspace.path });
|
|
59
|
+
}
|
|
60
|
+
return rows;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const service = createService({
|
|
64
|
+
paths,
|
|
65
|
+
store,
|
|
66
|
+
registry,
|
|
67
|
+
cli,
|
|
68
|
+
logger,
|
|
69
|
+
env,
|
|
70
|
+
visibleProvider,
|
|
71
|
+
workspacesProvider,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const handler = createRoutes({ service, logger });
|
|
75
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }), 'dsh-skills-console:routes');
|
|
76
|
+
logger('plugin.apply', { stateDir: paths.stateDir, canonical: paths.canonical, readOnly: store.readOnly });
|
|
77
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ServiceError } from './core/service.js';
|
|
2
|
+
import { StateReadOnlyError } from './core/state.js';
|
|
3
|
+
|
|
4
|
+
export const ROUTE_PREFIX = '/api/morehao/skills-console';
|
|
5
|
+
const MAX_BODY = 256 * 1024;
|
|
6
|
+
|
|
7
|
+
function sendJson(res, status, payload) {
|
|
8
|
+
const body = JSON.stringify(payload);
|
|
9
|
+
res.writeHead(status, {
|
|
10
|
+
'content-type': 'application/json; charset=utf-8',
|
|
11
|
+
'cache-control': 'no-store',
|
|
12
|
+
'content-length': Buffer.byteLength(body),
|
|
13
|
+
});
|
|
14
|
+
res.end(body);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readBody(req) {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
let size = 0;
|
|
20
|
+
for await (const chunk of req) {
|
|
21
|
+
size += chunk.length;
|
|
22
|
+
if (size > MAX_BODY) {
|
|
23
|
+
throw new ServiceError('error.skills.body-too-large', 'request body too large');
|
|
24
|
+
}
|
|
25
|
+
chunks.push(chunk);
|
|
26
|
+
}
|
|
27
|
+
if (chunks.length === 0) return {};
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
throw new ServiceError('error.skills.bad-json', 'request body is not JSON');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchRoute(method, pathname) {
|
|
36
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
37
|
+
// 前缀在 handler 之前已被剥掉:这里拿到的是 /state、/skills/:name 之类
|
|
38
|
+
if (segments.length === 1 && segments[0] === 'state' && method === 'GET') return { name: 'state' };
|
|
39
|
+
if (segments.length === 1 && segments[0] === 'agents' && method === 'GET') return { name: 'agents' };
|
|
40
|
+
if (segments.length === 1 && segments[0] === 'settings' && method === 'POST') return { name: 'settings' };
|
|
41
|
+
if (segments.length === 1 && segments[0] === 'links' && method === 'POST') return { name: 'links' };
|
|
42
|
+
if (segments.length === 1 && segments[0] === 'install' && method === 'POST') return { name: 'install' };
|
|
43
|
+
if (segments.length === 1 && segments[0] === 'diagnostics' && method === 'GET') return { name: 'diagnostics' };
|
|
44
|
+
|
|
45
|
+
if (segments[0] === 'skills' && segments.length === 2 && method === 'GET') {
|
|
46
|
+
return { name: 'skill.body', params: { name: decodeURIComponent(segments[1]) } };
|
|
47
|
+
}
|
|
48
|
+
if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'adopt' && method === 'POST') {
|
|
49
|
+
return { name: 'skill.adopt', params: { name: decodeURIComponent(segments[1]) } };
|
|
50
|
+
}
|
|
51
|
+
if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'mode' && method === 'POST') {
|
|
52
|
+
return { name: 'skill.mode', params: { name: decodeURIComponent(segments[1]) } };
|
|
53
|
+
}
|
|
54
|
+
if (segments[0] === 'skills' && segments.length === 2 && method === 'DELETE') {
|
|
55
|
+
return { name: 'skill.remove', params: { name: decodeURIComponent(segments[1]) } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (segments[0] === 'subscriptions') {
|
|
59
|
+
if (segments.length === 1 && method === 'POST') return { name: 'subscriptions.add' };
|
|
60
|
+
if (segments.length === 2 && method === 'DELETE') {
|
|
61
|
+
return { name: 'subscriptions.remove', params: { id: decodeURIComponent(segments[1]) } };
|
|
62
|
+
}
|
|
63
|
+
if (segments.length === 3 && segments[2] === 'refresh' && method === 'POST') {
|
|
64
|
+
return { name: 'subscriptions.refresh', params: { id: decodeURIComponent(segments[1]) } };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (segments[0] === 'updates') {
|
|
69
|
+
if (segments.length === 2 && segments[1] === 'check' && method === 'POST') return { name: 'updates.check' };
|
|
70
|
+
if (segments.length === 2 && segments[1] === 'plan' && method === 'POST') return { name: 'updates.plan' };
|
|
71
|
+
if (segments.length === 2 && segments[1] === 'apply' && method === 'POST') return { name: 'updates.apply' };
|
|
72
|
+
if (segments.length === 2 && segments[1] === 'rollback' && method === 'POST') return { name: 'updates.rollback' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createRoutes({ service, logger }) {
|
|
79
|
+
return async function handler(req, res) {
|
|
80
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
81
|
+
const pathname = url.pathname.startsWith(ROUTE_PREFIX)
|
|
82
|
+
? url.pathname.slice(ROUTE_PREFIX.length) || '/'
|
|
83
|
+
: url.pathname;
|
|
84
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
85
|
+
|
|
86
|
+
// 只认自己的页面:一律要求自定义头。跨源请求带自定义头会先触发预检,等于顺带挡住 CSRF。
|
|
87
|
+
if (req.headers['x-dsh-skills-console'] !== '1') {
|
|
88
|
+
sendJson(res, 403, { ok: false, code: 'error.skills.forbidden', message: 'missing x-dsh-skills-console header' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const route = matchRoute(method, pathname);
|
|
93
|
+
if (!route) {
|
|
94
|
+
sendJson(res, 404, { ok: false, code: 'error.skills.no-route', message: `${method} ${pathname}` });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
service.reloadState();
|
|
100
|
+
const body = method === 'GET' ? {} : await readBody(req);
|
|
101
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
102
|
+
const result = await dispatch(route, { body, query, service });
|
|
103
|
+
sendJson(res, 200, result);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof StateReadOnlyError) {
|
|
106
|
+
sendJson(res, 409, {
|
|
107
|
+
ok: false,
|
|
108
|
+
code: 'error.skills.state-readonly',
|
|
109
|
+
message: '状态文件损坏,插件处于只读模式',
|
|
110
|
+
detail: error.problem || null,
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (error instanceof ServiceError) {
|
|
115
|
+
sendJson(res, 400, { ok: false, code: error.code, message: error.message, detail: error.detail ?? null });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
logger('route.error', { message: error?.message, stack: String(error?.stack || '').slice(0, 800) });
|
|
119
|
+
sendJson(res, 500, { ok: false, code: 'error.skills.internal', message: error?.message || 'internal error' });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function dispatch(route, { body, query, service }) {
|
|
125
|
+
switch (route.name) {
|
|
126
|
+
case 'state':
|
|
127
|
+
return service.getState({ cwd: query.cwd || body.cwd, workspace: query.workspace });
|
|
128
|
+
case 'agents':
|
|
129
|
+
return { ok: true, all: service.registry.all(), detected: service.registry.detected() };
|
|
130
|
+
case 'settings':
|
|
131
|
+
return service.setSettings(body);
|
|
132
|
+
case 'links':
|
|
133
|
+
return service.setLinks(body);
|
|
134
|
+
case 'install':
|
|
135
|
+
return service.install(body);
|
|
136
|
+
case 'diagnostics':
|
|
137
|
+
return service.diagnostics();
|
|
138
|
+
case 'skill.body':
|
|
139
|
+
return service.listSkillBody({ name: route.params.name });
|
|
140
|
+
case 'skill.adopt':
|
|
141
|
+
return service.adopt({ name: route.params.name });
|
|
142
|
+
case 'skill.mode':
|
|
143
|
+
return service.setMode({ name: route.params.name, mode: body.mode });
|
|
144
|
+
case 'skill.remove':
|
|
145
|
+
return service.remove({ name: route.params.name });
|
|
146
|
+
case 'subscriptions.add':
|
|
147
|
+
return service.addSubscription(body);
|
|
148
|
+
case 'subscriptions.remove':
|
|
149
|
+
return service.removeSubscription({ id: route.params.id, keepInstalled: body.keepInstalled !== false });
|
|
150
|
+
case 'subscriptions.refresh':
|
|
151
|
+
return service.refreshSubscription({ id: route.params.id, force: Boolean(body.force) });
|
|
152
|
+
case 'updates.check':
|
|
153
|
+
return service.checkUpdates(body);
|
|
154
|
+
case 'updates.plan':
|
|
155
|
+
return service.planUpdate({ name: body.name });
|
|
156
|
+
case 'updates.apply':
|
|
157
|
+
return service.applyUpdate({ name: body.name, token: body.token, overwrite: Boolean(body.overwrite) });
|
|
158
|
+
case 'updates.rollback':
|
|
159
|
+
return service.rollback({ name: body.name });
|
|
160
|
+
default:
|
|
161
|
+
throw new ServiceError('error.skills.no-route', `unhandled route ${route.name}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/locale/en.json
ADDED