@42ailab/42plugin 0.1.17 → 0.1.19

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/config.ts +4 -3
  3. package/src/db.ts +166 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@42ailab/42plugin",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "活水插件",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -5,11 +5,12 @@
5
5
  import path from 'path';
6
6
  import os from 'os';
7
7
 
8
- // 数据目录
8
+ // 数据目录(支持环境变量覆盖,用于测试)
9
9
  const dataDir =
10
- process.platform === 'win32'
10
+ process.env.PLUGIN_DATA_DIR ||
11
+ (process.platform === 'win32'
11
12
  ? path.join(process.env.APPDATA || os.homedir(), '42plugin')
12
- : path.join(os.homedir(), '.42plugin');
13
+ : path.join(os.homedir(), '.42plugin'));
13
14
 
14
15
  export const config = {
15
16
  // 目录
package/src/db.ts CHANGED
@@ -24,9 +24,53 @@ const PROGRESS_THRESHOLD = 1024 * 1024; // 1MB
24
24
 
25
25
  let db: Database | null = null;
26
26
 
27
+ /**
28
+ * 检查目录权限,返回友好的错误信息
29
+ */
30
+ async function checkDirectoryPermissions(dir: string): Promise<void> {
31
+ try {
32
+ // 检查目录是否存在
33
+ const stat = await fs.stat(dir);
34
+
35
+ // 检查是否可写
36
+ await fs.access(dir, fs.constants.W_OK);
37
+
38
+ // 检查所有者(仅在非 Windows 系统)
39
+ if (process.platform !== 'win32') {
40
+ const expectedUid = process.getuid?.();
41
+ if (expectedUid !== undefined && stat.uid !== expectedUid && stat.uid === 0) {
42
+ throw new Error('OWNED_BY_ROOT');
43
+ }
44
+ }
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
47
+ // 目录不存在,稍后会创建
48
+ return;
49
+ }
50
+
51
+ if ((error as Error).message === 'OWNED_BY_ROOT' ||
52
+ (error as NodeJS.ErrnoException).code === 'EACCES') {
53
+ const homeDir = os.homedir();
54
+ throw new Error(
55
+ `数据目录权限错误: ${dir}\n\n` +
56
+ `这通常是因为之前使用了 sudo 运行 CLI 导致的。\n` +
57
+ `请执行以下命令修复权限:\n\n` +
58
+ ` sudo chown -R $(whoami) ${path.join(homeDir, '.42plugin')}\n`
59
+ );
60
+ }
61
+
62
+ throw error;
63
+ }
64
+ }
65
+
27
66
  async function getDb(): Promise<Database> {
28
67
  if (!db) {
29
- await fs.mkdir(path.dirname(config.dbPath), { recursive: true });
68
+ const dir = path.dirname(config.dbPath);
69
+
70
+ // 检查权限
71
+ await checkDirectoryPermissions(dir);
72
+
73
+ await fs.mkdir(dir, { recursive: true });
30
74
  db = new Database(config.dbPath);
31
75
  initSchema();
32
76
  }
@@ -34,6 +78,9 @@ async function getDb(): Promise<Database> {
34
78
  }
35
79
 
36
80
  function initSchema(): void {
81
+ // 检查并执行数据库迁移
82
+ migrateIfNeeded();
83
+
37
84
  db!.run(`
38
85
  CREATE TABLE IF NOT EXISTS projects (
39
86
  id TEXT PRIMARY KEY,
@@ -75,6 +122,82 @@ function initSchema(): void {
75
122
  `);
76
123
  }
77
124
 
125
+ /**
126
+ * 数据库迁移:处理旧版本 schema 升级
127
+ */
128
+ function migrateIfNeeded(): void {
129
+ try {
130
+ // 检查 plugin_cache 表是否存在
131
+ const tableExists = db!.prepare(`
132
+ SELECT name FROM sqlite_master
133
+ WHERE type='table' AND name='plugin_cache'
134
+ `).get();
135
+
136
+ if (!tableExists) {
137
+ // 表不存在,无需迁移
138
+ return;
139
+ }
140
+
141
+ // 获取表结构
142
+ const columns = db!.prepare('PRAGMA table_info(plugin_cache)').all() as { name: string }[];
143
+ const columnNames = columns.map(c => c.name);
144
+
145
+ // 检查是否是旧版本 schema(有 downloaded_at 但没有 cached_at)
146
+ const hasDownloadedAt = columnNames.includes('downloaded_at');
147
+ const hasCachedAt = columnNames.includes('cached_at');
148
+
149
+ if (hasDownloadedAt && !hasCachedAt) {
150
+ console.log('检测到旧版本数据库,正在迁移...');
151
+
152
+ // 开始事务
153
+ db!.run('BEGIN TRANSACTION');
154
+
155
+ try {
156
+ // 1. 重命名旧表
157
+ db!.run('ALTER TABLE plugin_cache RENAME TO plugin_cache_old');
158
+
159
+ // 2. 创建新表
160
+ db!.run(`
161
+ CREATE TABLE plugin_cache (
162
+ full_name TEXT NOT NULL,
163
+ version TEXT NOT NULL,
164
+ type TEXT NOT NULL,
165
+ cache_path TEXT NOT NULL,
166
+ checksum TEXT NOT NULL,
167
+ size_bytes INTEGER NOT NULL,
168
+ cached_at TEXT NOT NULL,
169
+ PRIMARY KEY (full_name, version)
170
+ )
171
+ `);
172
+
173
+ // 3. 迁移数据(downloaded_at → cached_at)
174
+ db!.run(`
175
+ INSERT INTO plugin_cache (full_name, version, type, cache_path, checksum, size_bytes, cached_at)
176
+ SELECT full_name, version, type, cache_path, checksum, COALESCE(size_bytes, 0), downloaded_at
177
+ FROM plugin_cache_old
178
+ `);
179
+
180
+ // 4. 删除旧表
181
+ db!.run('DROP TABLE plugin_cache_old');
182
+
183
+ // 提交事务
184
+ db!.run('COMMIT');
185
+
186
+ console.log('数据库迁移完成');
187
+ } catch (error) {
188
+ // 回滚事务
189
+ db!.run('ROLLBACK');
190
+ throw error;
191
+ }
192
+ }
193
+ } catch (error) {
194
+ // 迁移失败不应阻止程序运行,只记录警告
195
+ if (config.debug) {
196
+ console.warn('数据库迁移检查失败:', error);
197
+ }
198
+ }
199
+ }
200
+
78
201
  // ============================================================================
79
202
  // 项目管理
80
203
  // ============================================================================
@@ -295,15 +418,27 @@ interface Secrets {
295
418
 
296
419
  async function readSecrets(): Promise<Secrets> {
297
420
  try {
421
+ // 先检查目录权限
422
+ await checkDirectoryPermissions(path.dirname(SECRETS_FILE));
423
+
298
424
  const content = await fs.readFile(SECRETS_FILE, 'utf-8');
299
425
  return JSON.parse(content);
300
- } catch {
426
+ } catch (error) {
427
+ // 权限错误会直接抛出友好提示
428
+ if ((error as Error).message.includes('数据目录权限错误')) {
429
+ throw error;
430
+ }
301
431
  return {};
302
432
  }
303
433
  }
304
434
 
305
435
  async function writeSecrets(secrets: Secrets): Promise<void> {
306
- await fs.mkdir(path.dirname(SECRETS_FILE), { recursive: true });
436
+ const dir = path.dirname(SECRETS_FILE);
437
+
438
+ // 检查目录权限
439
+ await checkDirectoryPermissions(dir);
440
+
441
+ await fs.mkdir(dir, { recursive: true });
307
442
  await fs.writeFile(SECRETS_FILE, JSON.stringify(secrets, null, 2), { mode: 0o600 });
308
443
  }
309
444
 
@@ -332,7 +467,8 @@ export async function downloadAndExtract(
332
467
  url: string,
333
468
  expectedChecksum: string,
334
469
  fullName: string,
335
- version: string
470
+ version: string,
471
+ pluginType: string
336
472
  ): Promise<string> {
337
473
  const parts = fullName.split('/');
338
474
  const targetDir = path.join(config.cacheDir, ...parts, version);
@@ -415,7 +551,7 @@ export async function downloadAndExtract(
415
551
  await fs.unlink(tempFile).catch(() => {});
416
552
 
417
553
  // 返回最终路径(如果只有一个子目录则进入)
418
- return resolveFinalPath(targetDir);
554
+ return resolveFinalPath(targetDir, pluginType);
419
555
  } catch (error) {
420
556
  await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {});
421
557
  throw error;
@@ -424,8 +560,9 @@ export async function downloadAndExtract(
424
560
 
425
561
  /**
426
562
  * 修正旧缓存路径(如果是目录但应该是文件)
563
+ * @param pluginType 插件类型:skill 需要目录,agent/command 需要文件
427
564
  */
428
- async function correctCachePath(cachePath: string): Promise<string> {
565
+ async function correctCachePath(cachePath: string, pluginType: string): Promise<string> {
429
566
  try {
430
567
  const stat = await fs.stat(cachePath);
431
568
  if (!stat.isDirectory()) {
@@ -442,11 +579,16 @@ async function correctCachePath(cachePath: string): Promise<string> {
442
579
  // 如果只有一个子目录(且没有其他文件),递归进入
443
580
  if (dirs.length === 1 && nonMetadataFiles.length === 0) {
444
581
  const subDir = path.join(cachePath, dirs[0].name);
445
- return correctCachePath(subDir);
582
+ return correctCachePath(subDir, pluginType);
446
583
  }
447
584
 
448
- // 如果只有一个主文件(排除 metadata.json),返回该文件路径
585
+ // Skill 类型:保持目录结构(包含 SKILL.md)
586
+ // Agent/Command 类型:返回 .md 文件路径
449
587
  if (nonMetadataFiles.length === 1 && dirs.length === 0) {
588
+ if (pluginType === 'skill') {
589
+ // Skill 需要目录,不返回文件
590
+ return cachePath;
591
+ }
450
592
  return path.join(cachePath, nonMetadataFiles[0].name);
451
593
  }
452
594
 
@@ -456,7 +598,11 @@ async function correctCachePath(cachePath: string): Promise<string> {
456
598
  }
457
599
  }
458
600
 
459
- async function resolveFinalPath(dir: string): Promise<string> {
601
+ /**
602
+ * 解析最终路径
603
+ * @param pluginType 插件类型:skill 需要目录,agent/command 需要文件
604
+ */
605
+ async function resolveFinalPath(dir: string, pluginType: string): Promise<string> {
460
606
  const entries = await fs.readdir(dir, { withFileTypes: true });
461
607
  const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
462
608
  const files = entries.filter((e) => e.isFile() && !e.name.startsWith('.'));
@@ -468,13 +614,17 @@ async function resolveFinalPath(dir: string): Promise<string> {
468
614
  const subEntries = await fs.readdir(subDir);
469
615
  if (subEntries.length > 0) {
470
616
  // 递归查找,处理 agent/command 类型的 name/name.md 结构
471
- return resolveFinalPath(subDir);
617
+ return resolveFinalPath(subDir, pluginType);
472
618
  }
473
619
  }
474
620
 
475
- // 如果只有一个主文件(排除 metadata.json),返回该文件路径
476
- // 这是单文件插件(如 agent/command)的情况
621
+ // Skill 类型:保持目录结构(包含 SKILL.md)
622
+ // Agent/Command 类型:返回 .md 文件路径
477
623
  if (nonMetadataFiles.length === 1 && dirs.length === 0) {
624
+ if (pluginType === 'skill') {
625
+ // Skill 需要目录,不返回文件
626
+ return dir;
627
+ }
478
628
  return path.join(dir, nonMetadataFiles[0].name);
479
629
  }
480
630
 
@@ -566,8 +716,8 @@ export async function resolveCachePath(
566
716
  // 验证缓存文件是否仍然存在
567
717
  try {
568
718
  await fs.access(cached.cachePath);
569
- // 修正旧缓存的路径(可能是目录而应该是文件)
570
- const correctedPath = await correctCachePath(cached.cachePath);
719
+ // 修正旧缓存的路径(根据类型:skill 需要目录,agent/command 需要文件)
720
+ const correctedPath = await correctCachePath(cached.cachePath, downloadInfo.type);
571
721
  // 如果路径被修正,更新缓存记录
572
722
  if (correctedPath !== cached.cachePath) {
573
723
  await setCache({
@@ -591,7 +741,8 @@ export async function resolveCachePath(
591
741
  downloadInfo.downloadUrl,
592
742
  downloadInfo.checksum,
593
743
  downloadInfo.fullName,
594
- downloadInfo.version
744
+ downloadInfo.version,
745
+ downloadInfo.type
595
746
  );
596
747
 
597
748
  // 更新缓存记录