@morehao/dsh-skills-console 0.2.4 → 0.3.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/README.md +14 -19
- package/client.js +43 -17
- package/lib/core/archive.js +246 -0
- package/lib/core/discovery.js +148 -0
- package/lib/core/git-tree.js +132 -0
- package/lib/core/installer.js +351 -0
- package/lib/core/lock.js +310 -0
- package/lib/core/remote.js +332 -0
- package/lib/core/service.js +348 -354
- package/lib/core/skill-md.js +49 -0
- package/lib/core/trash.js +2 -2
- package/lib/index.js +3 -3
- package/package.json +2 -2
- package/lib/core/cli.js +0 -256
- package/lib/core/git.js +0 -282
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 本地计算 Git tree SHA(40 位),与 `git write-tree` / GitHub Trees API 的 `sha` 同一算法。
|
|
7
|
+
*
|
|
8
|
+
* 用途:native installer 落盘后算技能目录的 `skillFolderHash`,与旧 CLI lock 的 40 位基线对齐。
|
|
9
|
+
* 语义边界(与 git 一致):
|
|
10
|
+
* - 文件 mode 只区分 `100644` / `100755`,目录 `40000`;不保留其他权限位。
|
|
11
|
+
* - 目录名排序按 git 的 `base_name_compare`:目录视作名字末尾多一个 `/` 再按字节比较。
|
|
12
|
+
* - 空目录不进 tree(git 根本不跟踪空目录),否则会与上游 tree SHA 对不上。
|
|
13
|
+
* - symlink、hardlink、设备、FIFO 等无法用普通 blob/tree 表示的类型**直接拒绝**,
|
|
14
|
+
* 绝不用内容 hash 冒充,避免写进 lock 的基线是猜的。
|
|
15
|
+
*
|
|
16
|
+
* 纯函数:只读传入目录,不写任何东西;失败抛 GitTreeError。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
20
|
+
|
|
21
|
+
const MODE_FILE = '100644';
|
|
22
|
+
const MODE_EXEC = '100755';
|
|
23
|
+
const MODE_DIR = '40000';
|
|
24
|
+
|
|
25
|
+
export class GitTreeError extends Error {
|
|
26
|
+
constructor(code, message, extra = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'GitTreeError';
|
|
29
|
+
this.code = code;
|
|
30
|
+
Object.assign(this, extra);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function blobSha(content) {
|
|
35
|
+
const header = Buffer.from(`blob ${content.length}\0`, 'utf8');
|
|
36
|
+
return crypto.createHash('sha1').update(header).update(content).digest();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function treeShaOf(payload) {
|
|
40
|
+
const header = Buffer.from(`tree ${payload.length}\0`, 'utf8');
|
|
41
|
+
return crypto.createHash('sha1').update(header).update(payload).digest();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** git 的 tree 排序:目录名末尾补 `/` 后按字节比较。 */
|
|
45
|
+
function compareEntries(left, right) {
|
|
46
|
+
const a = Buffer.from(left.dir ? `${left.name}/` : left.name, 'utf8');
|
|
47
|
+
const b = Buffer.from(right.dir ? `${right.name}/` : right.name, 'utf8');
|
|
48
|
+
return Buffer.compare(a, b);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function modeOf(stat) {
|
|
52
|
+
return (stat.mode & 0o111) !== 0 ? MODE_EXEC : MODE_FILE;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 递归读取目录,返回 `{ sha: Buffer, empty: boolean }`。
|
|
57
|
+
* `empty` 表示这个目录在 git 意义上是空的(没有文件,或只有空子目录)。
|
|
58
|
+
*/
|
|
59
|
+
function hashDir(dir) {
|
|
60
|
+
let entries;
|
|
61
|
+
try {
|
|
62
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new GitTreeError('read-failed', `无法读取目录 ${dir}: ${error.message}`, { cause: error });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const items = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const full = path.join(dir, entry.name);
|
|
70
|
+
let stat;
|
|
71
|
+
try {
|
|
72
|
+
stat = fs.lstatSync(full);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new GitTreeError('read-failed', `无法读取 ${full}: ${error.message}`, { cause: error });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (stat.isSymbolicLink()) {
|
|
78
|
+
throw new GitTreeError('unsupported-entry', `符号链接无法进 git tree: ${full}`, { path: full });
|
|
79
|
+
}
|
|
80
|
+
if (stat.isDirectory()) {
|
|
81
|
+
const nested = hashDir(full);
|
|
82
|
+
if (!nested.empty) items.push({ name: entry.name, dir: true, sha: nested.sha });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (stat.isFile()) {
|
|
86
|
+
let content;
|
|
87
|
+
try {
|
|
88
|
+
content = fs.readFileSync(full);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new GitTreeError('read-failed', `无法读取 ${full}: ${error.message}`, { cause: error });
|
|
91
|
+
}
|
|
92
|
+
items.push({ name: entry.name, dir: false, mode: modeOf(stat), sha: blobSha(content) });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// FIFO / socket / 块设备 / 字符设备:git 里都不是普通条目,直接拒绝。
|
|
96
|
+
throw new GitTreeError('unsupported-entry', `不支持的文件类型,无法进 git tree: ${full}`, {
|
|
97
|
+
path: full,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
items.sort(compareEntries);
|
|
102
|
+
|
|
103
|
+
const chunks = [];
|
|
104
|
+
for (const item of items) {
|
|
105
|
+
chunks.push(Buffer.from(`${item.mode || MODE_DIR} ${item.name}\0`, 'utf8'), item.sha);
|
|
106
|
+
}
|
|
107
|
+
const payload = Buffer.concat(chunks);
|
|
108
|
+
return { sha: treeShaOf(payload), empty: items.length === 0 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 计算目录的 Git tree SHA(40 位小写 hex)。
|
|
113
|
+
* 目录不存在 / 不可读 / 含不可表示条目时抛 GitTreeError,调用方不得写 lock。
|
|
114
|
+
*/
|
|
115
|
+
export function gitTreeSha(dir) {
|
|
116
|
+
if (typeof dir !== 'string' || dir.length === 0) {
|
|
117
|
+
throw new GitTreeError('invalid-path', 'gitTreeSha 需要非空目录路径');
|
|
118
|
+
}
|
|
119
|
+
let stat;
|
|
120
|
+
try {
|
|
121
|
+
stat = fs.lstatSync(dir);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new GitTreeError('read-failed', `目录不存在或不可读 ${dir}: ${error.message}`, { cause: error });
|
|
124
|
+
}
|
|
125
|
+
if (stat.isSymbolicLink()) {
|
|
126
|
+
throw new GitTreeError('unsupported-entry', `技能目录本身是符号链接: ${dir}`, { path: dir });
|
|
127
|
+
}
|
|
128
|
+
if (!stat.isDirectory()) {
|
|
129
|
+
throw new GitTreeError('invalid-path', `不是目录: ${dir}`);
|
|
130
|
+
}
|
|
131
|
+
return hashDir(dir).sha.toString('hex');
|
|
132
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { extractTarball } from './archive.js';
|
|
6
|
+
import { contentHash } from './hash.js';
|
|
7
|
+
import { discoverSkills } from './discovery.js';
|
|
8
|
+
import { gitTreeSha } from './git-tree.js';
|
|
9
|
+
import { createBackup, pruneBackups, readBackup, restoreBackup } from './trash.js';
|
|
10
|
+
import { makeLockEntry, readLockFile, updateLock } from './lock.js';
|
|
11
|
+
import { parseSource } from './updates.js';
|
|
12
|
+
import { downloadTarball } from './remote.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Native Installer:canonical(`~/.agents/skills`)与 v3 lock 的**唯一写入者**。
|
|
16
|
+
*
|
|
17
|
+
* 事务边界(设计 §核心流程/安装):
|
|
18
|
+
* 1. 下载 + 安全解压 + 发现全部在临时目录完成;此时 canonical/lock 一个字节都没动。
|
|
19
|
+
* 2. 校验 lock 可写(版本/损坏在下载前就失败)。
|
|
20
|
+
* 3. 需要覆盖的旧目录先 `createBackup`,再把 staging 复制到 canonical 下的同盘临时目录,
|
|
21
|
+
* 逐个 `rename` 到最终位置(同文件系统,单目录原子替换)。
|
|
22
|
+
* 4. 所有目录就位后**一次** `updateLock` 写全部条目(`expectedDigest` 防外部并发)。
|
|
23
|
+
* 5. 任一步失败:反向删除已 promote 的目录、从备份恢复旧目录;lock 未写或保持原样。
|
|
24
|
+
*
|
|
25
|
+
* 只负责文件与 lock,不碰 state / 链接 / 回收站(那些仍是 Service 的职责)。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export class InstallerError extends Error {
|
|
29
|
+
constructor(code, message, extra = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'InstallerError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
Object.assign(this, extra);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nearestExisting(dir) {
|
|
38
|
+
let current = path.resolve(dir);
|
|
39
|
+
for (;;) {
|
|
40
|
+
if (fs.existsSync(current)) return current;
|
|
41
|
+
const parent = path.dirname(current);
|
|
42
|
+
if (parent === current) return current;
|
|
43
|
+
current = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertSafeSkillName(name) {
|
|
48
|
+
if (typeof name !== 'string' || name.length === 0 || name.length > 120) {
|
|
49
|
+
throw new InstallerError('invalid-name', `技能名非法: ${String(name)}`);
|
|
50
|
+
}
|
|
51
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
52
|
+
throw new InstallerError('invalid-name', `技能名不能含路径分隔符: ${name}`);
|
|
53
|
+
}
|
|
54
|
+
// eslint-disable-next-line no-control-regex
|
|
55
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) {
|
|
56
|
+
throw new InstallerError('invalid-name', `技能名含控制字符: ${JSON.stringify(name)}`);
|
|
57
|
+
}
|
|
58
|
+
return name;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function copyTree(from, to) {
|
|
62
|
+
fs.cpSync(from, to, { recursive: true, dereference: false, preserveTimestamps: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validIsoOrUndefined(value) {
|
|
66
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeError(error) {
|
|
70
|
+
if (error instanceof InstallerError) return error;
|
|
71
|
+
const code = error?.code || 'install-failed';
|
|
72
|
+
return new InstallerError(code, error?.message || String(error), {
|
|
73
|
+
cause: error,
|
|
74
|
+
status: error?.status ?? null,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createInstaller({
|
|
79
|
+
paths,
|
|
80
|
+
env = process.env,
|
|
81
|
+
fetchImpl = fetch,
|
|
82
|
+
token,
|
|
83
|
+
logger = () => {},
|
|
84
|
+
} = {}) {
|
|
85
|
+
const authToken = token ?? env.GITHUB_TOKEN ?? env.GH_TOKEN ?? null;
|
|
86
|
+
|
|
87
|
+
/** 与旧 `/state.cli` 兼容的就绪状态:只有 lock 与 canonical 都可写时 usable 才为 true。 */
|
|
88
|
+
function readiness() {
|
|
89
|
+
let problem = null;
|
|
90
|
+
try {
|
|
91
|
+
readLockFile(paths.lockFile);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
problem = error?.code || 'lock-unreadable';
|
|
94
|
+
}
|
|
95
|
+
let writable = false;
|
|
96
|
+
try {
|
|
97
|
+
fs.accessSync(nearestExisting(paths.canonical), fs.constants.W_OK);
|
|
98
|
+
writable = true;
|
|
99
|
+
} catch {
|
|
100
|
+
writable = false;
|
|
101
|
+
}
|
|
102
|
+
const usable = !problem && writable;
|
|
103
|
+
return {
|
|
104
|
+
usable,
|
|
105
|
+
source: 'native',
|
|
106
|
+
version: null,
|
|
107
|
+
json: true,
|
|
108
|
+
fullDepth: true,
|
|
109
|
+
problem: problem || (writable ? null : 'canonical-not-writable'),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 安装/重装一组技能。
|
|
115
|
+
* @param {{source:string, ref?:string|null, commit?:string|null, subpath?:string|null,
|
|
116
|
+
* names?:string[], expectedEntries?:Array<{name:string, treeSha?:string|null}>}} options
|
|
117
|
+
*/
|
|
118
|
+
async function install({ source, ref = null, commit = null, subpath = null, names = [], expectedEntries = null, strict = false } = {}) {
|
|
119
|
+
const parsed = parseSource(source);
|
|
120
|
+
if (!parsed) throw new InstallerError('bad-source', `只接受 owner/repo 或 GitHub URL: ${source}`);
|
|
121
|
+
const canonicalSource = `${parsed.owner}/${parsed.repo}`;
|
|
122
|
+
const pin = commit || ref;
|
|
123
|
+
if (typeof pin !== 'string' || pin.length === 0) {
|
|
124
|
+
throw new InstallerError('missing-ref', '安装需要 commit 或 ref');
|
|
125
|
+
}
|
|
126
|
+
const requested = (Array.isArray(names) ? names : []).filter(Boolean);
|
|
127
|
+
if (new Set(requested).size !== requested.length) {
|
|
128
|
+
throw new InstallerError('duplicate-selection', '选择列表里有重复技能名');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 下载前先确认 lock 可写:坏 lock / 版本过高不应该先花一次网络请求
|
|
132
|
+
const lockBefore = readLockFile(paths.lockFile);
|
|
133
|
+
|
|
134
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dshsc-install-'));
|
|
135
|
+
const tarball = path.join(tempRoot, 'repo.tar.gz');
|
|
136
|
+
const staging = path.join(tempRoot, 'stage');
|
|
137
|
+
const backups = new Map();
|
|
138
|
+
const attempted = [];
|
|
139
|
+
let preparedRoot = null;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const downloaded = await downloadTarball({
|
|
143
|
+
source: canonicalSource,
|
|
144
|
+
ref: pin,
|
|
145
|
+
token: authToken,
|
|
146
|
+
dest: tarball,
|
|
147
|
+
fetchImpl,
|
|
148
|
+
});
|
|
149
|
+
const extracted = await extractTarball({ tarball, dest: staging });
|
|
150
|
+
const discovered = discoverSkills({ dir: extracted.dir, subpath, repoName: parsed.repo });
|
|
151
|
+
if (discovered.length === 0) {
|
|
152
|
+
throw new InstallerError('no-skills', `${canonicalSource} 里没有找到 SKILL.md`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const available = new Map(discovered.map((skill) => [skill.name, skill]));
|
|
156
|
+
const selected =
|
|
157
|
+
requested.length > 0
|
|
158
|
+
? requested.map((name) => {
|
|
159
|
+
const skill = available.get(name);
|
|
160
|
+
if (!skill) {
|
|
161
|
+
throw new InstallerError('skill-not-found', `${canonicalSource} 里找不到技能 ${name}`, {
|
|
162
|
+
name,
|
|
163
|
+
available: [...available.keys()],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return skill;
|
|
167
|
+
})
|
|
168
|
+
: discovered;
|
|
169
|
+
|
|
170
|
+
const planned = selected.map((skill) => ({
|
|
171
|
+
...skill,
|
|
172
|
+
treeSha: gitTreeSha(skill.dirPath),
|
|
173
|
+
contentHash: contentHash(skill.dirPath),
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const warnings = [];
|
|
177
|
+
const expected = new Map((expectedEntries || []).map((entry) => [entry.name, entry]));
|
|
178
|
+
const mismatched = [];
|
|
179
|
+
for (const item of planned) {
|
|
180
|
+
const want = expected.get(item.name);
|
|
181
|
+
if (want?.treeSha && want.treeSha !== item.treeSha) {
|
|
182
|
+
const detail = {
|
|
183
|
+
name: item.name,
|
|
184
|
+
warning: '本地 tree SHA 与快照不一致(可能下载到了别的 commit)',
|
|
185
|
+
expected: want.treeSha,
|
|
186
|
+
actual: item.treeSha,
|
|
187
|
+
};
|
|
188
|
+
mismatched.push(detail);
|
|
189
|
+
warnings.push(detail);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// 更新要求「下载到的内容」与计划中的目标完全一致;普通安装只警告,不阻断。
|
|
193
|
+
if (strict && mismatched.length > 0) {
|
|
194
|
+
throw new InstallerError('tree-mismatch', `下载内容与目标 tree 不一致: ${mismatched[0].name}`, {
|
|
195
|
+
mismatched,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 同盘 staging:先复制到 canonical 下的隐藏目录,再 rename 到最终位置。
|
|
200
|
+
// 隐藏目录以 `.` 开头,inventory 扫描会跳过,不会把半成品当成技能。
|
|
201
|
+
fs.mkdirSync(paths.canonical, { recursive: true });
|
|
202
|
+
preparedRoot = path.join(
|
|
203
|
+
paths.canonical,
|
|
204
|
+
`.dshsc-staging-${process.pid}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`,
|
|
205
|
+
);
|
|
206
|
+
fs.mkdirSync(preparedRoot, { recursive: false });
|
|
207
|
+
for (const item of planned) {
|
|
208
|
+
copyTree(item.dirPath, path.join(preparedRoot, item.name));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const item of planned) {
|
|
212
|
+
const dest = path.join(paths.canonical, item.name);
|
|
213
|
+
if (fs.existsSync(dest)) {
|
|
214
|
+
const stat = fs.lstatSync(dest);
|
|
215
|
+
if (stat.isSymbolicLink()) {
|
|
216
|
+
throw new InstallerError('unsupported-existing', `${dest} 是符号链接,拒绝覆盖`);
|
|
217
|
+
}
|
|
218
|
+
const previousHash = contentHash(dest);
|
|
219
|
+
// 事务期间不 prune:多技能安装时先备份的目录不能被后面的 createBackup 挤掉
|
|
220
|
+
const backup = createBackup({ paths, skill: item.name, fromDir: dest, hash: previousHash, prune: false });
|
|
221
|
+
backups.set(item.name, { key: backup.key, previousHash });
|
|
222
|
+
}
|
|
223
|
+
// 先登记再动盘:任何一步失败都要能从备份恢复(包括 rm 之后、rename 之前)
|
|
224
|
+
attempted.push({ item, dest, backup: backups.get(item.name) || null });
|
|
225
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
226
|
+
fs.renameSync(path.join(preparedRoot, item.name), dest);
|
|
227
|
+
const promotedHash = contentHash(dest);
|
|
228
|
+
if (promotedHash !== item.contentHash) {
|
|
229
|
+
throw new InstallerError('verify-failed', `${item.name} 落盘后内容哈希不一致`, {
|
|
230
|
+
expected: item.contentHash,
|
|
231
|
+
actual: promotedHash,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const now = new Date();
|
|
237
|
+
const updatedAt = now.toISOString();
|
|
238
|
+
const entries = new Map();
|
|
239
|
+
for (const item of planned) {
|
|
240
|
+
const existing = lockBefore.data?.skills?.[item.name] || null;
|
|
241
|
+
entries.set(
|
|
242
|
+
item.name,
|
|
243
|
+
makeLockEntry({
|
|
244
|
+
source: canonicalSource,
|
|
245
|
+
ref: pin,
|
|
246
|
+
skillPath: item.skillPath,
|
|
247
|
+
treeSha: item.treeSha,
|
|
248
|
+
installedAt: validIsoOrUndefined(existing?.installedAt),
|
|
249
|
+
updatedAt,
|
|
250
|
+
now,
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
updateLock({
|
|
256
|
+
lockPath: paths.lockFile,
|
|
257
|
+
expectedDigest: lockBefore.digest,
|
|
258
|
+
mutate: (draft) => {
|
|
259
|
+
for (const [name, entry] of entries) {
|
|
260
|
+
const previous = draft.skills[name];
|
|
261
|
+
// 已知字段以新条目为准,旧条目里的未知字段保留(旧 CLI/未来 CLI 加的字段不丢)
|
|
262
|
+
draft.skills[name] = previous && typeof previous === 'object' ? { ...previous, ...entry } : entry;
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
pruneBackups({ paths });
|
|
269
|
+
} catch {
|
|
270
|
+
// 备份清理是维护动作,失败不影响安装结果
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const installed = planned.map((item) => {
|
|
274
|
+
const backup = backups.get(item.name) || null;
|
|
275
|
+
const previousLockHash = lockBefore.data?.skills?.[item.name]?.skillFolderHash || null;
|
|
276
|
+
return {
|
|
277
|
+
name: item.name,
|
|
278
|
+
skillPath: item.skillPath,
|
|
279
|
+
treeSha: item.treeSha,
|
|
280
|
+
contentHash: item.contentHash,
|
|
281
|
+
backup: backup?.key || null,
|
|
282
|
+
backupHash: backup?.previousHash || null,
|
|
283
|
+
backupTreeSha:
|
|
284
|
+
typeof previousLockHash === 'string' && /^[0-9a-f]{40}$/.test(previousLockHash)
|
|
285
|
+
? previousLockHash
|
|
286
|
+
: null,
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
logger('installer.install', {
|
|
290
|
+
source: canonicalSource,
|
|
291
|
+
ref: pin,
|
|
292
|
+
skills: installed.map((item) => item.name),
|
|
293
|
+
bytes: downloaded.bytes,
|
|
294
|
+
warnings: warnings.length,
|
|
295
|
+
});
|
|
296
|
+
return { installed, warnings, root: extracted.root };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
for (const entry of [...attempted].reverse()) {
|
|
299
|
+
fs.rmSync(entry.dest, { recursive: true, force: true });
|
|
300
|
+
if (entry.backup) {
|
|
301
|
+
const restored = readBackup({ paths, key: entry.backup.key });
|
|
302
|
+
if (restored) restoreBackup({ backup: restored, toDir: entry.dest });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw normalizeError(error);
|
|
306
|
+
} finally {
|
|
307
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
308
|
+
if (preparedRoot) fs.rmSync(preparedRoot, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 删除 canonical 目录与 lock 条目;lock 写失败时不删目录,避免半状态。 */
|
|
313
|
+
function remove({ name } = {}) {
|
|
314
|
+
assertSafeSkillName(name);
|
|
315
|
+
const lockBefore = readLockFile(paths.lockFile);
|
|
316
|
+
const entry = lockBefore.data?.skills?.[name] || null;
|
|
317
|
+
const canonicalPath = path.join(paths.canonical, name);
|
|
318
|
+
|
|
319
|
+
updateLock({
|
|
320
|
+
lockPath: paths.lockFile,
|
|
321
|
+
expectedDigest: lockBefore.digest,
|
|
322
|
+
mutate: (draft) => {
|
|
323
|
+
delete draft.skills[name];
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
fs.rmSync(canonicalPath, { recursive: true, force: true });
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// 目录删不掉就把 lock 条目放回去(尽量恢复,恢复不了也如实报错)
|
|
331
|
+
if (entry) {
|
|
332
|
+
try {
|
|
333
|
+
updateLock({
|
|
334
|
+
lockPath: paths.lockFile,
|
|
335
|
+
mutate: (draft) => {
|
|
336
|
+
draft.skills[name] = entry;
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
} catch {
|
|
340
|
+
// 恢复失败只能让调用方看到原始错误;lock 里最多少一条,不会指错目录
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
throw new InstallerError('remove-failed', `删除 ${name} 失败: ${error.message}`, { cause: error });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
logger('installer.remove', { name, hadLockEntry: Boolean(entry) });
|
|
347
|
+
return { removed: Boolean(entry), treeSha: entry?.skillFolderHash || null };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { readiness, install, remove };
|
|
351
|
+
}
|