dsh-plugin-pack 0.0.0-stage → 1.0.2
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 +537 -3
- package/bin/dsh-plugin-pack.js +200 -0
- package/client.js +2320 -0
- package/cordis.patch.yml +24 -0
- package/hotload.js +230 -0
- package/lib/export.js +444 -0
- package/lib/home.js +75 -0
- package/lib/import-plugin.js +356 -0
- package/lib/import-tgz.js +115 -0
- package/lib/import.js +831 -0
- package/lib/inbox.js +194 -0
- package/lib/index.js +725 -0
- package/lib/jobs.js +208 -0
- package/lib/patch-text.js +672 -0
- package/lib/scan.js +488 -0
- package/lib/server.js +926 -0
- package/lib/tar.js +241 -0
- package/lib/util.js +333 -0
- package/lib/zip-stream.js +166 -0
- package/lib/zip.js +514 -0
- package/package.json +72 -6
package/lib/home.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径与运行环境解析。
|
|
3
|
+
*
|
|
4
|
+
* dsh 在运行时注入 DSH_HOME / DSH_PROFILE / DSH_PROFILE_DIR,
|
|
5
|
+
* 但 CLI 直接跑时这些变量可能不存在,所以要有和宿主一致的兜底。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-plugin-pack/home
|
|
8
|
+
*/
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
/** DSH 主目录。 */
|
|
13
|
+
export function dshHome(env = process.env) {
|
|
14
|
+
const raw = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : '';
|
|
15
|
+
return raw ? path.resolve(raw) : path.join(os.homedir(), '.dsh');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 当前 profile 名。 */
|
|
19
|
+
export function profileName(env = process.env) {
|
|
20
|
+
const raw = typeof env.DSH_PROFILE === 'string' ? env.DSH_PROFILE.trim() : '';
|
|
21
|
+
return raw || 'desktop';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 当前 profile 目录。 */
|
|
25
|
+
export function profileDir(env = process.env) {
|
|
26
|
+
const raw = typeof env.DSH_PROFILE_DIR === 'string' ? env.DSH_PROFILE_DIR.trim() : '';
|
|
27
|
+
return raw ? path.resolve(raw) : path.join(dshHome(env), 'profiles', profileName(env));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 把任意包名/目录名变成可安全落进 zip 的段名。 */
|
|
31
|
+
export function safeSegment(value) {
|
|
32
|
+
const text = String(value ?? '').trim();
|
|
33
|
+
const cleaned = text
|
|
34
|
+
.replace(/\\/g, '/')
|
|
35
|
+
.replace(/^\/+|\/+$/g, '')
|
|
36
|
+
.replace(/[<>:"|?*\u0000-\u001f]/g, '_')
|
|
37
|
+
.replace(/\.\./g, '__');
|
|
38
|
+
return cleaned || 'plugin';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 包名 → 单层目录名。scoped 包(@local/x)会被压成 `@local__x`,
|
|
43
|
+
* 这样 zip 里的 `packages/<段>/` 永远只有一层,解包不会踩到路径歧义。
|
|
44
|
+
*/
|
|
45
|
+
export function packSegment(name) {
|
|
46
|
+
return safeSegment(String(name ?? '').replace(/\//g, '__'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `20261026-145723` 形式的时间戳。 */
|
|
50
|
+
export function stamp(date = new Date()) {
|
|
51
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
52
|
+
return [
|
|
53
|
+
date.getFullYear(),
|
|
54
|
+
pad(date.getMonth() + 1),
|
|
55
|
+
pad(date.getDate()),
|
|
56
|
+
'-',
|
|
57
|
+
pad(date.getHours()),
|
|
58
|
+
pad(date.getMinutes()),
|
|
59
|
+
pad(date.getSeconds()),
|
|
60
|
+
].join('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 人类可读体积。 */
|
|
64
|
+
export function humanBytes(bytes) {
|
|
65
|
+
const n = Number(bytes) || 0;
|
|
66
|
+
if (n < 1024) return `${n} B`;
|
|
67
|
+
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
|
|
68
|
+
let value = n / 1024;
|
|
69
|
+
let unit = 0;
|
|
70
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
71
|
+
value /= 1024;
|
|
72
|
+
unit += 1;
|
|
73
|
+
}
|
|
74
|
+
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unit]}`;
|
|
75
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地导入插件:把一个已存在的插件目录接进当前 profile。
|
|
3
|
+
*
|
|
4
|
+
* 与整合包导入的区别:那边读 zip,这边直接指向磁盘上的目录
|
|
5
|
+
* (自己写的插件、已经解压好的插件、或 node_modules 里已有的包)。
|
|
6
|
+
*
|
|
7
|
+
* 做的事和整合包导入一致,只是源不同:
|
|
8
|
+
* 复制到 profile/local-plugins/<名字> → 写 dependencies(link:) + bundles
|
|
9
|
+
* → 合并 cordis.patch.yml 里属于它的行 → 建 node_modules 链接 → 备份可回滚
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-plugin-pack/import-plugin
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { readJson, writeJson, readText, writeText, ensureDir, isDir, isFile, copyTree, removeTree } from './util.js';
|
|
17
|
+
import { packSegment, stamp, humanBytes } from './home.js';
|
|
18
|
+
import { existingIds, rowId, splitTopLevel, splitInsertChildren, entryMatchesPlugin, appendInsertBlock } from './patch-text.js';
|
|
19
|
+
import { resolveEntry, isAbsoluteSpec } from './scan.js';
|
|
20
|
+
|
|
21
|
+
/** 从一个目录读出插件身份,判断它能不能装。 */
|
|
22
|
+
export function describeLocalPlugin(dir) {
|
|
23
|
+
const target = path.resolve(dir);
|
|
24
|
+
if (!isDir(target)) {
|
|
25
|
+
const error = new Error(`不是目录: ${target}`);
|
|
26
|
+
error.code = 'NOT_FOUND';
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
const meta = readJson(path.join(target, 'package.json'), null);
|
|
30
|
+
if (!meta || typeof meta.name !== 'string' || !meta.name.trim()) {
|
|
31
|
+
const error = new Error(`目录里没有可用的 package.json(需要 name 字段): ${target}`);
|
|
32
|
+
error.code = 'BAD_PLUGIN';
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
const entry = resolveEntry(target, meta);
|
|
36
|
+
const patchRel = typeof meta?.dsh?.bundle?.patch === 'string' ? meta.dsh.bundle.patch : null;
|
|
37
|
+
return {
|
|
38
|
+
dir: target,
|
|
39
|
+
name: meta.name.trim(),
|
|
40
|
+
version: meta.version ?? '0.0.0',
|
|
41
|
+
description: typeof meta.description === 'string' ? meta.description : '',
|
|
42
|
+
entry,
|
|
43
|
+
hasPatch: patchRel ? isFile(path.join(target, patchRel)) : false,
|
|
44
|
+
patchRel,
|
|
45
|
+
client: meta.dsh?.client ?? null,
|
|
46
|
+
deps: Object.keys(meta.dependencies ?? {}).filter((dep) => !dep.startsWith('@deepseek-ai/')),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 导入本地插件。
|
|
52
|
+
*
|
|
53
|
+
* @param {object} options
|
|
54
|
+
* @param {string} options.dir 插件目录
|
|
55
|
+
* @param {string} options.profileDir profile 目录
|
|
56
|
+
* @param {boolean} [options.dryRun]
|
|
57
|
+
* @param {'merge'|'skip'} [options.mode]
|
|
58
|
+
* @param {boolean} [options.linkNodeModules]
|
|
59
|
+
* @param {boolean} [options.keepNodeModules]
|
|
60
|
+
* true = 连目录里的 node_modules 一起搬过去。
|
|
61
|
+
* 从 .tgz 解出来的包必须为 true —— 它的依赖就在那儿,不搬过去插件加载会失败。
|
|
62
|
+
* @returns {object}
|
|
63
|
+
*/
|
|
64
|
+
export function importLocalPlugin(options = {}) {
|
|
65
|
+
const profile = path.resolve(options.profileDir);
|
|
66
|
+
const mode = options.mode === 'skip' ? 'skip' : 'merge';
|
|
67
|
+
const dryRun = Boolean(options.dryRun);
|
|
68
|
+
const linkNodeModules = options.linkNodeModules !== false;
|
|
69
|
+
const keepNodeModules = options.keepNodeModules === true;
|
|
70
|
+
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
71
|
+
|
|
72
|
+
const info = describeLocalPlugin(options.dir);
|
|
73
|
+
const segment = packSegment(info.name);
|
|
74
|
+
const dest = path.join(profile, 'local-plugins', segment);
|
|
75
|
+
const exists = isDir(dest);
|
|
76
|
+
// 逐插件决策优先;没给就退回全局 mode
|
|
77
|
+
const decided = options.decisions?.[info.name];
|
|
78
|
+
const action = exists
|
|
79
|
+
? decided === 'skip' || decided === 'overwrite'
|
|
80
|
+
? decided
|
|
81
|
+
: mode === 'skip'
|
|
82
|
+
? 'skip'
|
|
83
|
+
: 'overwrite'
|
|
84
|
+
: 'create';
|
|
85
|
+
|
|
86
|
+
// 读 profile 现状
|
|
87
|
+
const pkgFile = path.join(profile, 'package.json');
|
|
88
|
+
const patchFile = path.join(profile, 'cordis.patch.yml');
|
|
89
|
+
const pkg = readJson(pkgFile, { name: `dsh-profile`, private: true, dependencies: {}, dsh: { profile: { bundles: [] } } });
|
|
90
|
+
const bundles = Array.isArray(pkg?.dsh?.profile?.bundles) ? [...pkg.dsh.profile.bundles] : [];
|
|
91
|
+
const patchText = readText(patchFile, '') ?? '';
|
|
92
|
+
const knownIds = existingIds(patchText);
|
|
93
|
+
|
|
94
|
+
// 这个插件自己的 patch 行(从它自带的 cordis.patch.yml 里读)
|
|
95
|
+
const patchRows = [];
|
|
96
|
+
const insertRows = [];
|
|
97
|
+
const skippedRows = [];
|
|
98
|
+
if (info.hasPatch) {
|
|
99
|
+
const ownPatch = readText(path.join(info.dir, info.patchRel), '') ?? '';
|
|
100
|
+
const target = { name: info.name, dirs: [info.dir] };
|
|
101
|
+
for (const entry of splitTopLevel(ownPatch)) {
|
|
102
|
+
if (entry.kind === 'insert') {
|
|
103
|
+
for (const child of splitInsertChildren(entry.raw)) {
|
|
104
|
+
const id = rowId(child.raw);
|
|
105
|
+
if (!id) continue;
|
|
106
|
+
if (knownIds.has(id)) {
|
|
107
|
+
skippedRows.push(id);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
knownIds.add(id);
|
|
111
|
+
insertRows.push(child.raw);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (entry.kind !== 'row') continue;
|
|
116
|
+
if (!entryMatchesPlugin(entry, target)) continue;
|
|
117
|
+
const id = rowId(entry.raw);
|
|
118
|
+
if (!id || knownIds.has(id)) {
|
|
119
|
+
if (id) skippedRows.push(id);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
knownIds.add(id);
|
|
123
|
+
patchRows.push(entry.raw);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const inBundles = bundles.includes(info.name);
|
|
128
|
+
const plan = {
|
|
129
|
+
name: info.name,
|
|
130
|
+
version: info.version,
|
|
131
|
+
dir: info.dir,
|
|
132
|
+
dest,
|
|
133
|
+
action,
|
|
134
|
+
inBundles,
|
|
135
|
+
entry: info.entry,
|
|
136
|
+
hasPatch: info.hasPatch,
|
|
137
|
+
patchRows: patchRows.length,
|
|
138
|
+
insertRows: insertRows.length,
|
|
139
|
+
skippedRows,
|
|
140
|
+
deps: info.deps.length,
|
|
141
|
+
bytes: measureLocal(info.dir),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// 冲突信息:单个插件也可能已装过,界面据此弹窗
|
|
145
|
+
const conflicts = exists
|
|
146
|
+
? [
|
|
147
|
+
{
|
|
148
|
+
name: info.name,
|
|
149
|
+
version: info.version,
|
|
150
|
+
installedVersion: readJson(path.join(dest, 'package.json'), null)?.version ?? null,
|
|
151
|
+
dest,
|
|
152
|
+
bytes: plan.bytes,
|
|
153
|
+
sameVersion: readJson(path.join(dest, 'package.json'), null)?.version === info.version,
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
: [];
|
|
157
|
+
|
|
158
|
+
if (dryRun) {
|
|
159
|
+
return { ok: true, dryRun: true, plan, conflicts, restartRequired: true };
|
|
160
|
+
}
|
|
161
|
+
if (action === 'skip') {
|
|
162
|
+
return { ok: true, skipped: true, plan, conflicts, restartRequired: false, note: '目标已存在,按选择跳过' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- 备份 ---
|
|
166
|
+
const backupRoot = uniqueDir(path.join(profile, '.plugin-backups', `plugin-pack-import-${stamp()}`));
|
|
167
|
+
ensureDir(backupRoot);
|
|
168
|
+
if (isFile(pkgFile)) fs.copyFileSync(pkgFile, path.join(backupRoot, 'package.json'));
|
|
169
|
+
if (isFile(patchFile)) fs.copyFileSync(patchFile, path.join(backupRoot, 'cordis.patch.yml'));
|
|
170
|
+
const record = {
|
|
171
|
+
format: 'dsh-plugin-pack-backup@1',
|
|
172
|
+
createdAt: new Date().toISOString(),
|
|
173
|
+
source: `local-plugin:${info.dir}`,
|
|
174
|
+
profile,
|
|
175
|
+
addedDirs: [],
|
|
176
|
+
replacedDirs: [],
|
|
177
|
+
removedLinks: [],
|
|
178
|
+
links: [],
|
|
179
|
+
dataDirs: [],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// --- 复制本体 ---
|
|
183
|
+
if (exists) {
|
|
184
|
+
const backupDir = path.join(backupRoot, 'local-plugins', segment);
|
|
185
|
+
copyTree(dest, backupDir);
|
|
186
|
+
record.replacedDirs.push({ dest, backup: backupDir });
|
|
187
|
+
removeTree(dest);
|
|
188
|
+
} else {
|
|
189
|
+
record.addedDirs.push(dest);
|
|
190
|
+
}
|
|
191
|
+
ensureDir(path.dirname(dest));
|
|
192
|
+
onProgress(`复制 ${info.name} 到 local-plugins`, 50);
|
|
193
|
+
copyTree(info.dir, dest, {
|
|
194
|
+
filter: (src) => {
|
|
195
|
+
const rel = path.relative(info.dir, src);
|
|
196
|
+
if (!rel) return true;
|
|
197
|
+
const first = rel.split(path.sep)[0];
|
|
198
|
+
if (first === '.git') return false;
|
|
199
|
+
// node_modules 是否带上,取决于这次导入的来源:
|
|
200
|
+
// - 从 .tgz 解出来的包:依赖就在 node_modules 里,必须原样带过来,
|
|
201
|
+
// 否则插件一加载就 ERR_MODULE_NOT_FOUND(dsh-pet 的 @electron/get 事故)
|
|
202
|
+
// - 直接导入本地插件目录:依赖由用户自己的 pnpm 管理,不复制(可能几百 MB)
|
|
203
|
+
if (first === 'node_modules' && !keepNodeModules) return false;
|
|
204
|
+
return true;
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// --- 写 profile/package.json ---
|
|
209
|
+
pkg.dependencies = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
|
|
210
|
+
pkg.dsh = pkg.dsh && typeof pkg.dsh === 'object' ? pkg.dsh : {};
|
|
211
|
+
pkg.dsh.profile = pkg.dsh.profile && typeof pkg.dsh.profile === 'object' ? pkg.dsh.profile : {};
|
|
212
|
+
pkg.dsh.profile.bundles = Array.isArray(pkg.dsh.profile.bundles) ? pkg.dsh.profile.bundles : [];
|
|
213
|
+
|
|
214
|
+
// 是"路径插件"(patch 里靠绝对路径挂的)就不进 bundles/依赖
|
|
215
|
+
const pathPlugin = Boolean(info.hasPatch) && !info.entry;
|
|
216
|
+
if (!pathPlugin) {
|
|
217
|
+
pkg.dependencies[info.name] = `link:./local-plugins/${segment}`;
|
|
218
|
+
if (!pkg.dsh.profile.bundles.includes(info.name)) pkg.dsh.profile.bundles.push(info.name);
|
|
219
|
+
}
|
|
220
|
+
writeJson(pkgFile, pkg);
|
|
221
|
+
|
|
222
|
+
// --- 合并 patch 行 ---
|
|
223
|
+
let nextPatch = patchText;
|
|
224
|
+
if (patchRows.length) {
|
|
225
|
+
nextPatch = appendTopRows(nextPatch, patchRows.map((raw) => rewriteAbsName(raw, info.name)), `plugin-pack import ${info.name}`);
|
|
226
|
+
}
|
|
227
|
+
if (insertRows.length) {
|
|
228
|
+
nextPatch = appendInsertBlock(nextPatch, dedentRows(insertRows), `plugin-pack import ${info.name}`).text;
|
|
229
|
+
}
|
|
230
|
+
if (nextPatch !== patchText) writeText(patchFile, nextPatch);
|
|
231
|
+
|
|
232
|
+
// --- 建链接 ---
|
|
233
|
+
let link = null;
|
|
234
|
+
if (linkNodeModules && !pathPlugin) {
|
|
235
|
+
onProgress('建立 node_modules 链接', 85);
|
|
236
|
+
link = ensureLink(profile, info.name, dest, { backupRoot, record });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
record.links = link ? [link] : [];
|
|
240
|
+
writeJson(path.join(backupRoot, 'record.json'), record);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
plan,
|
|
245
|
+
name: info.name,
|
|
246
|
+
version: info.version,
|
|
247
|
+
dest,
|
|
248
|
+
entry: info.entry,
|
|
249
|
+
deps: info.deps,
|
|
250
|
+
link,
|
|
251
|
+
backup: backupRoot,
|
|
252
|
+
restartRequired: true,
|
|
253
|
+
note: '重启 dsh 加载这个插件;如果它带客户端界面,之后可以用热加载',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 目录大小(跳过 .git / node_modules)。 */
|
|
258
|
+
function measureLocal(dir) {
|
|
259
|
+
let bytes = 0;
|
|
260
|
+
const stack = [dir];
|
|
261
|
+
while (stack.length) {
|
|
262
|
+
const current = stack.pop();
|
|
263
|
+
let entries;
|
|
264
|
+
try {
|
|
265
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
266
|
+
} catch {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
271
|
+
const full = path.join(current, entry.name);
|
|
272
|
+
if (entry.isDirectory()) {
|
|
273
|
+
stack.push(full);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (entry.isFile()) {
|
|
277
|
+
try {
|
|
278
|
+
bytes += fs.statSync(full).size;
|
|
279
|
+
} catch {
|
|
280
|
+
/* 忽略 */
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return bytes;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 唯一的备份目录。 */
|
|
289
|
+
function uniqueDir(base) {
|
|
290
|
+
if (!isDir(base) && !isFile(base)) return base;
|
|
291
|
+
for (let i = 2; i < 100; i += 1) {
|
|
292
|
+
const candidate = `${base}-${i}`;
|
|
293
|
+
if (!isDir(candidate) && !isFile(candidate)) return candidate;
|
|
294
|
+
}
|
|
295
|
+
return `${base}-${Date.now()}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 顶层 row 追加。 */
|
|
299
|
+
function appendTopRows(text, rows, comment) {
|
|
300
|
+
const eol = String(text).includes('\r\n') ? '\r\n' : '\n';
|
|
301
|
+
let out = String(text ?? '');
|
|
302
|
+
if (out && !out.endsWith('\n')) out += eol;
|
|
303
|
+
if (out && !out.endsWith(`${eol}${eol}`)) out += eol;
|
|
304
|
+
out += `# >>> dsh-plugin-pack ${comment}${eol}`;
|
|
305
|
+
for (const row of rows) {
|
|
306
|
+
out += `${String(row).replace(/\r?\n$/, '').replace(/\r?\n/g, eol)}${eol}`;
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** insert 子条目去掉 4 空格缩进(交给 appendInsertBlock 重新缩进)。 */
|
|
312
|
+
function dedentRows(rows) {
|
|
313
|
+
return rows.map((raw) =>
|
|
314
|
+
String(raw)
|
|
315
|
+
.split(/\r?\n/)
|
|
316
|
+
.map((line) => (line.startsWith(' ') ? line.slice(4) : line))
|
|
317
|
+
.join('\n'),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 把写死的绝对路径 name 改回包名。 */
|
|
322
|
+
function rewriteAbsName(raw, packageName) {
|
|
323
|
+
return String(raw).replace(/(^|[ \t])name:([ \t]*)(['"]?)([^'"\r\n]+)\3/g, (match, lead, gap, quote, value) => {
|
|
324
|
+
const trimmed = String(value).trim();
|
|
325
|
+
if (!isAbsoluteSpec(trimmed)) return match;
|
|
326
|
+
return `${lead}name:${gap}${quote}${packageName}${quote}`;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 建 node_modules/<name> → dest 链接。 */
|
|
331
|
+
function ensureLink(profile, name, dest, { backupRoot, record }) {
|
|
332
|
+
const linkPath = path.join(profile, 'node_modules', ...String(name).split('/'));
|
|
333
|
+
try {
|
|
334
|
+
const stat = fs.lstatSync(linkPath);
|
|
335
|
+
if (stat.isSymbolicLink()) {
|
|
336
|
+
fs.unlinkSync(linkPath);
|
|
337
|
+
record.removedLinks.push(linkPath);
|
|
338
|
+
} else if (stat.isDirectory()) {
|
|
339
|
+
const backup = path.join(backupRoot, 'node_modules', packSegment(name));
|
|
340
|
+
copyTree(linkPath, backup);
|
|
341
|
+
record.replacedDirs.push({ dest: linkPath, backup });
|
|
342
|
+
removeTree(linkPath);
|
|
343
|
+
} else {
|
|
344
|
+
fs.unlinkSync(linkPath);
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
/* 不存在,直接建 */
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
ensureDir(path.dirname(linkPath));
|
|
351
|
+
fs.symlinkSync(dest, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
352
|
+
return { status: 'linked', path: linkPath };
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return { status: 'failed', path: linkPath, error: `建链接失败: ${error?.message ?? error}` };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导入 npm tarball(.tgz):单插件包的解包与落地。
|
|
3
|
+
*
|
|
4
|
+
* 和整合包(zip)的区别:
|
|
5
|
+
* - 整合包 = 多个插件 + 依赖 + 配置,由本插件自己的 importBundle 还原
|
|
6
|
+
* - tarball = 一个标准 npm 包,政策上解包到 package/ 目录后,
|
|
7
|
+
* 直接交给 importLocalPlugin 走"本地插件导入"那套
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-plugin-pack/import-tgz
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { readTgz } from './tar.js';
|
|
15
|
+
import { ensureDir, removeTree } from './util.js';
|
|
16
|
+
import { stamp } from './home.js';
|
|
17
|
+
import { importLocalPlugin } from './import-plugin.js';
|
|
18
|
+
|
|
19
|
+
/** 是不是 tarball。 */
|
|
20
|
+
export function isTarball(file) {
|
|
21
|
+
const lower = String(file ?? '').toLowerCase();
|
|
22
|
+
return lower.endsWith('.tgz') || lower.endsWith('.tar.gz');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 把一个 .tgz 解包到临时目录,返回包根目录。
|
|
27
|
+
*
|
|
28
|
+
* npm tarball 的内容都在 `package/` 下;如果解出来没有这一层(手搓的包),
|
|
29
|
+
* 就把解包目录本身当包根,尽量兼容。
|
|
30
|
+
*
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {string} options.file tarball 路径
|
|
33
|
+
* @param {string} options.home $DSH_HOME(解包到 inbox 下)
|
|
34
|
+
* @returns {{ dir: string, files: number, bytes: number, hasPackagePrefix: boolean }}
|
|
35
|
+
*/
|
|
36
|
+
export function unpackTarball({ file, home }) {
|
|
37
|
+
const buffer = fs.readFileSync(file);
|
|
38
|
+
const items = readTgz(buffer);
|
|
39
|
+
if (!items.length) {
|
|
40
|
+
const error = new Error('tarball 里没有文件');
|
|
41
|
+
error.code = 'BAD_PACK';
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const hasPackagePrefix = items.every((item) => item.name.startsWith('package/'));
|
|
46
|
+
const root = path.join(home, 'plugin-pack', 'inbox', stamp(), 'unpacked');
|
|
47
|
+
ensureDir(root);
|
|
48
|
+
const base = path.resolve(root);
|
|
49
|
+
|
|
50
|
+
let bytes = 0;
|
|
51
|
+
let count = 0;
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
// 去掉 package/ 前缀(有的话);同时防穿越
|
|
54
|
+
let rel = item.name.replace(/\\/g, '/');
|
|
55
|
+
if (hasPackagePrefix) rel = rel.slice('package/'.length);
|
|
56
|
+
rel = rel.replace(/^\/+/, '');
|
|
57
|
+
if (!rel) continue;
|
|
58
|
+
const segments = rel.split('/').filter(Boolean);
|
|
59
|
+
if (segments.some((seg) => seg === '..' || seg === '.')) continue;
|
|
60
|
+
const target = path.join(base, ...segments);
|
|
61
|
+
if (!path.resolve(target).startsWith(base)) continue;
|
|
62
|
+
ensureDir(path.dirname(target));
|
|
63
|
+
fs.writeFileSync(target, item.data);
|
|
64
|
+
bytes += item.data.length;
|
|
65
|
+
count += 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { dir: root, files: count, bytes, hasPackagePrefix };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 导入一个 npm tarball 作为本地插件。
|
|
73
|
+
*
|
|
74
|
+
* @param {object} options
|
|
75
|
+
* @param {string} options.file tarball 路径
|
|
76
|
+
* @param {string} options.home $DSH_HOME
|
|
77
|
+
* @param {string} options.profileDir profile 目录
|
|
78
|
+
* @param {boolean} [options.dryRun]
|
|
79
|
+
* @param {'merge'|'skip'} [options.mode]
|
|
80
|
+
* @returns {object}
|
|
81
|
+
*/
|
|
82
|
+
export function importTarball(options = {}) {
|
|
83
|
+
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
84
|
+
onProgress('解包 tarball', 10);
|
|
85
|
+
const unpacked = unpackTarball({ file: options.file, home: options.home });
|
|
86
|
+
onProgress(`解出 ${unpacked.files} 个文件,开始落地`, 40);
|
|
87
|
+
|
|
88
|
+
const pkgFile = path.join(unpacked.dir, 'package.json');
|
|
89
|
+
if (!fs.existsSync(pkgFile)) {
|
|
90
|
+
const error = new Error(`tarball 里没有 package.json,不是有效的插件包:${unpacked.dir}`);
|
|
91
|
+
error.code = 'BAD_PLUGIN';
|
|
92
|
+
// 清掉半成品
|
|
93
|
+
removeTree(path.dirname(unpacked.dir));
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = importLocalPlugin({
|
|
98
|
+
dir: unpacked.dir,
|
|
99
|
+
profileDir: options.profileDir,
|
|
100
|
+
dryRun: Boolean(options.dryRun),
|
|
101
|
+
mode: options.mode === 'skip' ? 'skip' : 'merge',
|
|
102
|
+
// 单个插件也可能重名,同样支持逐插件决策
|
|
103
|
+
decisions: options.decisions && typeof options.decisions === 'object' ? options.decisions : {},
|
|
104
|
+
onProgress,
|
|
105
|
+
// 关键:tgz 里的 node_modules 是插件的真实依赖,必须一起搬过去。
|
|
106
|
+
// 漏了这一项会导致插件加载时报 ERR_MODULE_NOT_FOUND(dsh-pet 的 @electron/get 事故)。
|
|
107
|
+
keepNodeModules: true,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
...result,
|
|
112
|
+
fromTarball: true,
|
|
113
|
+
unpacked: { dir: unpacked.dir, files: unpacked.files, bytes: unpacked.bytes },
|
|
114
|
+
};
|
|
115
|
+
}
|