@42ailab/42plugin 0.1.0-beta.0 → 0.1.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/README.md +211 -68
- package/package.json +13 -8
- package/src/api.ts +447 -0
- package/src/cli.ts +33 -16
- package/src/commands/auth.ts +83 -69
- package/src/commands/check.ts +118 -0
- package/src/commands/completion.ts +210 -0
- package/src/commands/index.ts +13 -0
- package/src/commands/install-helper.ts +71 -0
- package/src/commands/install.ts +219 -300
- package/src/commands/list.ts +42 -66
- package/src/commands/publish.ts +121 -0
- package/src/commands/search.ts +89 -72
- package/src/commands/setup.ts +158 -0
- package/src/commands/uninstall.ts +53 -44
- package/src/config.ts +27 -36
- package/src/db.ts +593 -0
- package/src/errors.ts +40 -0
- package/src/index.ts +4 -31
- package/src/services/packager.ts +177 -0
- package/src/services/publisher.ts +237 -0
- package/src/services/upload.ts +52 -0
- package/src/services/version-manager.ts +65 -0
- package/src/types.ts +396 -0
- package/src/utils.ts +128 -0
- package/src/validators/plugin-validator.ts +635 -0
- package/src/commands/version.ts +0 -20
- package/src/db/client.ts +0 -180
- package/src/services/api.ts +0 -128
- package/src/services/auth.ts +0 -46
- package/src/services/cache.ts +0 -101
- package/src/services/download.ts +0 -148
- package/src/services/link.ts +0 -86
- package/src/services/project.ts +0 -179
- package/src/types/api.ts +0 -115
- package/src/types/db.ts +0 -31
- package/src/utils/errors.ts +0 -40
- package/src/utils/platform.ts +0 -6
- package/src/utils/target.ts +0 -114
package/src/db.ts
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地存储
|
|
3
|
+
*
|
|
4
|
+
* 单文件封装 SQLite 存储和文件操作
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createClient, type Client } from '@libsql/client';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import * as tar from 'tar';
|
|
12
|
+
import cliProgress from 'cli-progress';
|
|
13
|
+
import { config } from './config';
|
|
14
|
+
import { formatBytes } from './utils';
|
|
15
|
+
import type { LocalProject, LocalCache, LocalInstallation, PluginType, PluginDownloadInfo } from './types';
|
|
16
|
+
|
|
17
|
+
// 文件大小超过 1MB 时显示下载进度条
|
|
18
|
+
const PROGRESS_THRESHOLD = 1024 * 1024; // 1MB
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// SQLite 客户端
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
let db: Client | null = null;
|
|
25
|
+
|
|
26
|
+
async function getDb(): Promise<Client> {
|
|
27
|
+
if (!db) {
|
|
28
|
+
await fs.mkdir(path.dirname(config.dbPath), { recursive: true });
|
|
29
|
+
db = createClient({ url: `file:${config.dbPath}` });
|
|
30
|
+
await initSchema();
|
|
31
|
+
}
|
|
32
|
+
return db;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function initSchema(): Promise<void> {
|
|
36
|
+
const client = db!;
|
|
37
|
+
|
|
38
|
+
await client.execute(`
|
|
39
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
path TEXT UNIQUE NOT NULL,
|
|
42
|
+
name TEXT NOT NULL,
|
|
43
|
+
registered_at TEXT NOT NULL,
|
|
44
|
+
last_used_at TEXT NOT NULL
|
|
45
|
+
)
|
|
46
|
+
`);
|
|
47
|
+
|
|
48
|
+
await client.execute(`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS plugin_cache (
|
|
50
|
+
full_name TEXT NOT NULL,
|
|
51
|
+
version TEXT NOT NULL,
|
|
52
|
+
type TEXT NOT NULL,
|
|
53
|
+
cache_path TEXT NOT NULL,
|
|
54
|
+
checksum TEXT NOT NULL,
|
|
55
|
+
size_bytes INTEGER NOT NULL,
|
|
56
|
+
cached_at TEXT NOT NULL,
|
|
57
|
+
PRIMARY KEY (full_name, version)
|
|
58
|
+
)
|
|
59
|
+
`);
|
|
60
|
+
|
|
61
|
+
await client.execute(`
|
|
62
|
+
CREATE TABLE IF NOT EXISTS installations (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
project_id TEXT NOT NULL,
|
|
65
|
+
full_name TEXT NOT NULL,
|
|
66
|
+
type TEXT NOT NULL,
|
|
67
|
+
version TEXT NOT NULL,
|
|
68
|
+
cache_path TEXT NOT NULL,
|
|
69
|
+
link_path TEXT NOT NULL,
|
|
70
|
+
source TEXT NOT NULL,
|
|
71
|
+
source_kit TEXT,
|
|
72
|
+
installed_at TEXT NOT NULL,
|
|
73
|
+
FOREIGN KEY (project_id) REFERENCES projects(id),
|
|
74
|
+
UNIQUE (project_id, full_name)
|
|
75
|
+
)
|
|
76
|
+
`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// 项目管理
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
export async function getOrCreateProject(projectPath: string): Promise<LocalProject> {
|
|
84
|
+
const client = await getDb();
|
|
85
|
+
const absPath = path.resolve(projectPath);
|
|
86
|
+
const name = path.basename(absPath);
|
|
87
|
+
|
|
88
|
+
// 查找现有项目
|
|
89
|
+
const result = await client.execute({
|
|
90
|
+
sql: 'SELECT * FROM projects WHERE path = ?',
|
|
91
|
+
args: [absPath],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (result.rows.length > 0) {
|
|
95
|
+
const row = result.rows[0];
|
|
96
|
+
// 更新最后使用时间
|
|
97
|
+
await client.execute({
|
|
98
|
+
sql: 'UPDATE projects SET last_used_at = ? WHERE id = ?',
|
|
99
|
+
args: [new Date().toISOString(), row.id],
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
id: row.id as string,
|
|
103
|
+
path: row.path as string,
|
|
104
|
+
name: row.name as string,
|
|
105
|
+
registeredAt: row.registered_at as string,
|
|
106
|
+
lastUsedAt: new Date().toISOString(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 创建新项目
|
|
111
|
+
const id = crypto.randomUUID();
|
|
112
|
+
const now = new Date().toISOString();
|
|
113
|
+
await client.execute({
|
|
114
|
+
sql: 'INSERT INTO projects (id, path, name, registered_at, last_used_at) VALUES (?, ?, ?, ?, ?)',
|
|
115
|
+
args: [id, absPath, name, now, now],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return { id, path: absPath, name, registeredAt: now, lastUsedAt: now };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============================================================================
|
|
122
|
+
// 缓存管理
|
|
123
|
+
// ============================================================================
|
|
124
|
+
|
|
125
|
+
export async function getCache(fullName: string, version: string): Promise<LocalCache | null> {
|
|
126
|
+
const client = await getDb();
|
|
127
|
+
const result = await client.execute({
|
|
128
|
+
sql: 'SELECT * FROM plugin_cache WHERE full_name = ? AND version = ?',
|
|
129
|
+
args: [fullName, version],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (result.rows.length === 0) return null;
|
|
133
|
+
|
|
134
|
+
const row = result.rows[0];
|
|
135
|
+
return {
|
|
136
|
+
fullName: row.full_name as string,
|
|
137
|
+
type: row.type as PluginType,
|
|
138
|
+
version: row.version as string,
|
|
139
|
+
cachePath: row.cache_path as string,
|
|
140
|
+
checksum: row.checksum as string,
|
|
141
|
+
sizeBytes: row.size_bytes as number,
|
|
142
|
+
cachedAt: row.cached_at as string,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function setCache(cache: Omit<LocalCache, 'cachedAt'>): Promise<void> {
|
|
147
|
+
const client = await getDb();
|
|
148
|
+
const now = new Date().toISOString();
|
|
149
|
+
|
|
150
|
+
await client.execute({
|
|
151
|
+
sql: `INSERT OR REPLACE INTO plugin_cache
|
|
152
|
+
(full_name, version, type, cache_path, checksum, size_bytes, cached_at)
|
|
153
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
154
|
+
args: [
|
|
155
|
+
cache.fullName,
|
|
156
|
+
cache.version,
|
|
157
|
+
cache.type,
|
|
158
|
+
cache.cachePath,
|
|
159
|
+
cache.checksum,
|
|
160
|
+
cache.sizeBytes,
|
|
161
|
+
now,
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function removeCache(fullName: string, version: string): Promise<boolean> {
|
|
167
|
+
const client = await getDb();
|
|
168
|
+
|
|
169
|
+
// 先获取缓存路径
|
|
170
|
+
const cache = await getCache(fullName, version);
|
|
171
|
+
if (!cache) return false;
|
|
172
|
+
|
|
173
|
+
// 删除缓存文件/目录
|
|
174
|
+
try {
|
|
175
|
+
// cachePath 可能是文件或目录,需要获取版本目录
|
|
176
|
+
const versionDir = path.dirname(cache.cachePath);
|
|
177
|
+
await fs.rm(versionDir, { recursive: true, force: true });
|
|
178
|
+
|
|
179
|
+
// 尝试清理空的父目录
|
|
180
|
+
const pluginDir = path.dirname(versionDir);
|
|
181
|
+
const remaining = await fs.readdir(pluginDir);
|
|
182
|
+
if (remaining.length === 0) {
|
|
183
|
+
await fs.rm(pluginDir, { recursive: true, force: true });
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// 文件不存在,忽略
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 删除数据库记录
|
|
190
|
+
const result = await client.execute({
|
|
191
|
+
sql: 'DELETE FROM plugin_cache WHERE full_name = ? AND version = ?',
|
|
192
|
+
args: [fullName, version],
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return result.rowsAffected > 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// 安装记录
|
|
200
|
+
// ============================================================================
|
|
201
|
+
|
|
202
|
+
export async function addInstallation(data: {
|
|
203
|
+
projectId: string;
|
|
204
|
+
fullName: string;
|
|
205
|
+
type: PluginType;
|
|
206
|
+
version: string;
|
|
207
|
+
cachePath: string;
|
|
208
|
+
linkPath: string;
|
|
209
|
+
source: 'direct' | 'kit';
|
|
210
|
+
sourceKit?: string;
|
|
211
|
+
}): Promise<LocalInstallation> {
|
|
212
|
+
const client = await getDb();
|
|
213
|
+
const id = crypto.randomUUID();
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
|
|
216
|
+
await client.execute({
|
|
217
|
+
sql: `INSERT OR REPLACE INTO installations
|
|
218
|
+
(id, project_id, full_name, type, version, cache_path, link_path, source, source_kit, installed_at)
|
|
219
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
220
|
+
args: [
|
|
221
|
+
id,
|
|
222
|
+
data.projectId,
|
|
223
|
+
data.fullName,
|
|
224
|
+
data.type,
|
|
225
|
+
data.version,
|
|
226
|
+
data.cachePath,
|
|
227
|
+
data.linkPath,
|
|
228
|
+
data.source,
|
|
229
|
+
data.sourceKit || null,
|
|
230
|
+
now,
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return { id, ...data, installedAt: now };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function getInstallations(projectPath: string): Promise<LocalInstallation[]> {
|
|
238
|
+
const client = await getDb();
|
|
239
|
+
const absPath = path.resolve(projectPath);
|
|
240
|
+
|
|
241
|
+
const result = await client.execute({
|
|
242
|
+
sql: `SELECT i.* FROM installations i
|
|
243
|
+
JOIN projects p ON i.project_id = p.id
|
|
244
|
+
WHERE p.path = ?
|
|
245
|
+
ORDER BY i.installed_at DESC`,
|
|
246
|
+
args: [absPath],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return result.rows.map((row) => ({
|
|
250
|
+
id: row.id as string,
|
|
251
|
+
projectId: row.project_id as string,
|
|
252
|
+
fullName: row.full_name as string,
|
|
253
|
+
type: row.type as PluginType,
|
|
254
|
+
version: row.version as string,
|
|
255
|
+
cachePath: row.cache_path as string,
|
|
256
|
+
linkPath: row.link_path as string,
|
|
257
|
+
source: row.source as 'direct' | 'kit',
|
|
258
|
+
sourceKit: row.source_kit as string | undefined,
|
|
259
|
+
installedAt: row.installed_at as string,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function removeInstallation(projectPath: string, fullName: string): Promise<boolean> {
|
|
264
|
+
const client = await getDb();
|
|
265
|
+
const absPath = path.resolve(projectPath);
|
|
266
|
+
|
|
267
|
+
const result = await client.execute({
|
|
268
|
+
sql: `DELETE FROM installations
|
|
269
|
+
WHERE full_name = ?
|
|
270
|
+
AND project_id IN (SELECT id FROM projects WHERE path = ?)`,
|
|
271
|
+
args: [fullName, absPath],
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return result.rowsAffected > 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// 密钥管理
|
|
279
|
+
// ============================================================================
|
|
280
|
+
|
|
281
|
+
const SECRETS_FILE = path.join(config.dataDir, 'secrets.json');
|
|
282
|
+
|
|
283
|
+
interface Secrets {
|
|
284
|
+
sessionToken?: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function readSecrets(): Promise<Secrets> {
|
|
288
|
+
try {
|
|
289
|
+
const content = await fs.readFile(SECRETS_FILE, 'utf-8');
|
|
290
|
+
return JSON.parse(content);
|
|
291
|
+
} catch {
|
|
292
|
+
return {};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function writeSecrets(secrets: Secrets): Promise<void> {
|
|
297
|
+
await fs.mkdir(path.dirname(SECRETS_FILE), { recursive: true });
|
|
298
|
+
await fs.writeFile(SECRETS_FILE, JSON.stringify(secrets, null, 2), { mode: 0o600 });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function getSessionToken(): Promise<string | null> {
|
|
302
|
+
const secrets = await readSecrets();
|
|
303
|
+
return secrets.sessionToken || null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function saveSessionToken(token: string): Promise<void> {
|
|
307
|
+
const secrets = await readSecrets();
|
|
308
|
+
secrets.sessionToken = token;
|
|
309
|
+
await writeSecrets(secrets);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function clearSessionToken(): Promise<void> {
|
|
313
|
+
const secrets = await readSecrets();
|
|
314
|
+
delete secrets.sessionToken;
|
|
315
|
+
await writeSecrets(secrets);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ============================================================================
|
|
319
|
+
// 文件操作
|
|
320
|
+
// ============================================================================
|
|
321
|
+
|
|
322
|
+
export async function downloadAndExtract(
|
|
323
|
+
url: string,
|
|
324
|
+
expectedChecksum: string,
|
|
325
|
+
fullName: string,
|
|
326
|
+
version: string
|
|
327
|
+
): Promise<string> {
|
|
328
|
+
const parts = fullName.split('/');
|
|
329
|
+
const targetDir = path.join(config.cacheDir, ...parts, version);
|
|
330
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
331
|
+
|
|
332
|
+
const tempFile = path.join(targetDir, '.download.tmp');
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
// 下载
|
|
336
|
+
const response = await fetch(url, {
|
|
337
|
+
headers: { 'User-Agent': '42plugin-cli/1.0.0' },
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (!response.ok) {
|
|
341
|
+
throw new Error(`下载失败: ${response.status}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const contentLength = parseInt(response.headers.get('content-length') || '0');
|
|
345
|
+
const showProgress = contentLength > PROGRESS_THRESHOLD;
|
|
346
|
+
|
|
347
|
+
let buffer: Buffer;
|
|
348
|
+
|
|
349
|
+
if (showProgress && response.body) {
|
|
350
|
+
// 流式下载,显示进度条
|
|
351
|
+
const reader = response.body.getReader();
|
|
352
|
+
const chunks: Uint8Array[] = [];
|
|
353
|
+
let downloadedBytes = 0;
|
|
354
|
+
const startTime = Date.now();
|
|
355
|
+
|
|
356
|
+
const progressBar = new cliProgress.SingleBar({
|
|
357
|
+
format: ' 下载中... {bar} {percentage}% ({downloaded}/{total}) {speed}',
|
|
358
|
+
barCompleteChar: '█',
|
|
359
|
+
barIncompleteChar: '░',
|
|
360
|
+
hideCursor: true,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
progressBar.start(contentLength, 0, {
|
|
364
|
+
downloaded: '0 B',
|
|
365
|
+
total: formatBytes(contentLength),
|
|
366
|
+
speed: '0 B/s',
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
while (true) {
|
|
370
|
+
const { done, value } = await reader.read();
|
|
371
|
+
if (done) break;
|
|
372
|
+
|
|
373
|
+
chunks.push(value);
|
|
374
|
+
downloadedBytes += value.length;
|
|
375
|
+
|
|
376
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
377
|
+
const speed = elapsed > 0 ? downloadedBytes / elapsed : 0;
|
|
378
|
+
|
|
379
|
+
progressBar.update(downloadedBytes, {
|
|
380
|
+
downloaded: formatBytes(downloadedBytes),
|
|
381
|
+
speed: formatBytes(speed) + '/s',
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
progressBar.stop();
|
|
386
|
+
buffer = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
387
|
+
} else {
|
|
388
|
+
// 小文件直接下载
|
|
389
|
+
buffer = Buffer.from(await response.arrayBuffer());
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
await fs.writeFile(tempFile, buffer);
|
|
393
|
+
|
|
394
|
+
// 校验
|
|
395
|
+
const actualChecksum = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
396
|
+
const expectedHash = expectedChecksum.replace(/^sha256:/, '');
|
|
397
|
+
|
|
398
|
+
if (actualChecksum !== expectedHash) {
|
|
399
|
+
throw new Error(`校验和不匹配: 期望 ${expectedHash}, 实际 ${actualChecksum}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 解压
|
|
403
|
+
await tar.extract({ file: tempFile, cwd: targetDir, strip: 0 });
|
|
404
|
+
|
|
405
|
+
// 清理
|
|
406
|
+
await fs.unlink(tempFile).catch(() => {});
|
|
407
|
+
|
|
408
|
+
// 返回最终路径(如果只有一个子目录则进入)
|
|
409
|
+
return resolveFinalPath(targetDir);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {});
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 修正旧缓存路径(如果是目录但应该是文件)
|
|
418
|
+
*/
|
|
419
|
+
async function correctCachePath(cachePath: string): Promise<string> {
|
|
420
|
+
try {
|
|
421
|
+
const stat = await fs.stat(cachePath);
|
|
422
|
+
if (!stat.isDirectory()) {
|
|
423
|
+
// 已经是文件,不需要修正
|
|
424
|
+
return cachePath;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 是目录,检查是否应该返回其中的文件
|
|
428
|
+
const entries = await fs.readdir(cachePath, { withFileTypes: true });
|
|
429
|
+
const files = entries.filter((e) => e.isFile() && !e.name.startsWith('.'));
|
|
430
|
+
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
|
|
431
|
+
const nonMetadataFiles = files.filter((f) => f.name !== 'metadata.json');
|
|
432
|
+
|
|
433
|
+
// 如果只有一个主文件(排除 metadata.json),返回该文件路径
|
|
434
|
+
if (nonMetadataFiles.length === 1 && dirs.length === 0) {
|
|
435
|
+
return path.join(cachePath, nonMetadataFiles[0].name);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return cachePath;
|
|
439
|
+
} catch {
|
|
440
|
+
return cachePath;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function resolveFinalPath(dir: string): Promise<string> {
|
|
445
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
446
|
+
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
|
|
447
|
+
const files = entries.filter((e) => e.isFile() && !e.name.startsWith('.'));
|
|
448
|
+
|
|
449
|
+
// 如果只有一个子目录(且没有其他文件,除了 metadata.json),进入该目录
|
|
450
|
+
const nonMetadataFiles = files.filter((f) => f.name !== 'metadata.json');
|
|
451
|
+
if (dirs.length === 1 && nonMetadataFiles.length === 0) {
|
|
452
|
+
const subDir = path.join(dir, dirs[0].name);
|
|
453
|
+
const subEntries = await fs.readdir(subDir);
|
|
454
|
+
if (subEntries.length > 0) {
|
|
455
|
+
return subDir;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 如果只有一个主文件(排除 metadata.json),返回该文件路径
|
|
460
|
+
// 这是单文件插件(如 agent/command)的情况
|
|
461
|
+
if (nonMetadataFiles.length === 1 && dirs.length === 0) {
|
|
462
|
+
return path.join(dir, nonMetadataFiles[0].name);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return dir;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function getDirectorySize(pathOrDir: string): Promise<number> {
|
|
469
|
+
const stat = await fs.stat(pathOrDir);
|
|
470
|
+
|
|
471
|
+
// 如果是文件,直接返回文件大小
|
|
472
|
+
if (stat.isFile()) {
|
|
473
|
+
return stat.size;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 如果是目录,递归计算总大小
|
|
477
|
+
let totalSize = 0;
|
|
478
|
+
|
|
479
|
+
const walk = async (dir: string): Promise<void> => {
|
|
480
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
481
|
+
for (const entry of entries) {
|
|
482
|
+
const fullPath = path.join(dir, entry.name);
|
|
483
|
+
if (entry.isDirectory()) {
|
|
484
|
+
await walk(fullPath);
|
|
485
|
+
} else {
|
|
486
|
+
const fileStat = await fs.stat(fullPath);
|
|
487
|
+
totalSize += fileStat.size;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
await walk(pathOrDir);
|
|
493
|
+
return totalSize;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export async function createLink(
|
|
497
|
+
sourcePath: string,
|
|
498
|
+
targetPath: string
|
|
499
|
+
): Promise<void> {
|
|
500
|
+
// 移除目标路径末尾的斜杠
|
|
501
|
+
const normalizedTarget = targetPath.replace(/\/+$/, '');
|
|
502
|
+
const targetDir = path.dirname(normalizedTarget);
|
|
503
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
504
|
+
|
|
505
|
+
// 移除已存在的链接
|
|
506
|
+
try {
|
|
507
|
+
const stat = await fs.lstat(normalizedTarget);
|
|
508
|
+
if (stat.isSymbolicLink() || stat.isDirectory() || stat.isFile()) {
|
|
509
|
+
await fs.rm(normalizedTarget, { recursive: true, force: true });
|
|
510
|
+
}
|
|
511
|
+
} catch {
|
|
512
|
+
// 不存在,继续
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 检测源是文件还是目录,创建对应类型的符号链接
|
|
516
|
+
const sourceStat = await fs.stat(sourcePath);
|
|
517
|
+
const linkType = sourceStat.isDirectory() ? 'dir' : 'file';
|
|
518
|
+
await fs.symlink(sourcePath, normalizedTarget, linkType);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export async function removeLink(linkPath: string): Promise<void> {
|
|
522
|
+
try {
|
|
523
|
+
const stat = await fs.lstat(linkPath);
|
|
524
|
+
if (stat.isSymbolicLink()) {
|
|
525
|
+
await fs.unlink(linkPath);
|
|
526
|
+
} else if (stat.isDirectory()) {
|
|
527
|
+
await fs.rm(linkPath, { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
} catch {
|
|
530
|
+
// 不存在,忽略
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ============================================================================
|
|
535
|
+
// 缓存路径解析
|
|
536
|
+
// ============================================================================
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 解析缓存路径(检查缓存或下载新文件)
|
|
540
|
+
*/
|
|
541
|
+
export async function resolveCachePath(
|
|
542
|
+
downloadInfo: PluginDownloadInfo,
|
|
543
|
+
force: boolean,
|
|
544
|
+
useCache: boolean
|
|
545
|
+
): Promise<{ cachePath: string; fromCache: boolean }> {
|
|
546
|
+
// 检查缓存
|
|
547
|
+
if (!force && useCache) {
|
|
548
|
+
const cached = await getCache(downloadInfo.fullName, downloadInfo.version);
|
|
549
|
+
if (cached && cached.checksum === downloadInfo.checksum) {
|
|
550
|
+
// 验证缓存文件是否仍然存在
|
|
551
|
+
try {
|
|
552
|
+
await fs.access(cached.cachePath);
|
|
553
|
+
// 修正旧缓存的路径(可能是目录而应该是文件)
|
|
554
|
+
const correctedPath = await correctCachePath(cached.cachePath);
|
|
555
|
+
// 如果路径被修正,更新缓存记录
|
|
556
|
+
if (correctedPath !== cached.cachePath) {
|
|
557
|
+
await setCache({
|
|
558
|
+
fullName: cached.fullName,
|
|
559
|
+
type: cached.type,
|
|
560
|
+
version: cached.version,
|
|
561
|
+
cachePath: correctedPath,
|
|
562
|
+
checksum: cached.checksum,
|
|
563
|
+
sizeBytes: cached.sizeBytes,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
return { cachePath: correctedPath, fromCache: true };
|
|
567
|
+
} catch {
|
|
568
|
+
// 缓存文件不存在,继续下载
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 下载
|
|
574
|
+
const cachePath = await downloadAndExtract(
|
|
575
|
+
downloadInfo.downloadUrl,
|
|
576
|
+
downloadInfo.checksum,
|
|
577
|
+
downloadInfo.fullName,
|
|
578
|
+
downloadInfo.version
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
// 更新缓存记录
|
|
582
|
+
const size = await getDirectorySize(cachePath);
|
|
583
|
+
await setCache({
|
|
584
|
+
fullName: downloadInfo.fullName,
|
|
585
|
+
type: downloadInfo.type,
|
|
586
|
+
version: downloadInfo.version,
|
|
587
|
+
cachePath,
|
|
588
|
+
checksum: downloadInfo.checksum,
|
|
589
|
+
sizeBytes: size,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
return { cachePath, fromCache: false };
|
|
593
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发布相关错误类型
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class ValidationError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
message: string,
|
|
8
|
+
public code: string
|
|
9
|
+
) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ValidationError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class VersionConflictError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
public existingVersion: string
|
|
19
|
+
) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'VersionConflictError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class UploadError extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
message: string,
|
|
28
|
+
public statusCode?: number
|
|
29
|
+
) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'UploadError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class AuthRequiredError extends Error {
|
|
36
|
+
constructor(message: string = '请先登录: 42plugin auth') {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'AuthRequiredError';
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,35 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* 42plugin CLI
|
|
4
|
+
*/
|
|
2
5
|
|
|
3
6
|
import { program } from './cli';
|
|
4
|
-
import { initDb } from './db/client';
|
|
5
|
-
import { api } from './services/api';
|
|
6
|
-
import { CliError } from './utils/errors';
|
|
7
|
-
import chalk from 'chalk';
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
// Initialize database
|
|
12
|
-
await initDb();
|
|
13
|
-
|
|
14
|
-
// Initialize API client
|
|
15
|
-
await api.init();
|
|
16
|
-
|
|
17
|
-
// Parse and execute command
|
|
18
|
-
await program.parseAsync(process.argv);
|
|
19
|
-
} catch (error) {
|
|
20
|
-
if (error instanceof CliError) {
|
|
21
|
-
console.error(chalk.red(`错误: ${error.message}`));
|
|
22
|
-
process.exit(error.exitCode);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Unknown error
|
|
26
|
-
if (process.env.DEBUG) {
|
|
27
|
-
console.error(error);
|
|
28
|
-
} else {
|
|
29
|
-
console.error(chalk.red(`发生错误: ${(error as Error).message}`));
|
|
30
|
-
}
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
main();
|
|
8
|
+
program.parse();
|