@42ailab/42plugin 0.1.21 → 0.1.23

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/src/config.ts CHANGED
@@ -12,13 +12,54 @@ const dataDir =
12
12
  ? path.join(process.env.APPDATA || os.homedir(), '42plugin')
13
13
  : path.join(os.homedir(), '.42plugin'));
14
14
 
15
+ /**
16
+ * 平台配置 - 支持多种 AI 编程助手
17
+ * 未来可扩展支持 Codex、Gemini 等
18
+ */
19
+ export interface PlatformConfig {
20
+ id: string;
21
+ name: string;
22
+ globalDir: string; // 全局安装目录(用户级)
23
+ projectDir: string; // 项目安装目录名
24
+ }
25
+
26
+ const homeDir = os.homedir();
27
+
28
+ export const platforms: Record<string, PlatformConfig> = {
29
+ claude: {
30
+ id: 'claude',
31
+ name: 'Claude Code',
32
+ globalDir: path.join(homeDir, '.claude'),
33
+ projectDir: '.claude',
34
+ },
35
+ // 预留:未来扩展
36
+ // codex: {
37
+ // id: 'codex',
38
+ // name: 'OpenAI Codex CLI',
39
+ // globalDir: path.join(homeDir, '.codex'),
40
+ // projectDir: '.codex',
41
+ // },
42
+ // gemini: {
43
+ // id: 'gemini',
44
+ // name: 'Google Gemini',
45
+ // globalDir: path.join(homeDir, '.gemini'),
46
+ // projectDir: '.gemini',
47
+ // },
48
+ };
49
+
50
+ // 当前默认平台
51
+ export const currentPlatform: PlatformConfig = platforms.claude;
52
+
15
53
  export const config = {
16
54
  // 目录
17
55
  dataDir,
18
56
  cacheDir: path.join(dataDir, 'cache'),
19
- globalDir: path.join(dataDir, 'global'),
57
+ globalDir: currentPlatform.globalDir, // 全局安装目录:~/.claude
20
58
  dbPath: path.join(dataDir, 'local.db'),
21
59
 
60
+ // 平台
61
+ platform: currentPlatform,
62
+
22
63
  // API
23
64
  apiBaseUrl: process.env.API_BASE_URL || 'https://api.42plugin.com',
24
65
  webBaseUrl: process.env.WEB_BASE_URL || 'https://42plugin.com',
@@ -34,3 +75,22 @@ export const config = {
34
75
  export const getDataDir = () => config.dataDir;
35
76
  export const getCacheDir = () => config.cacheDir;
36
77
  export const getDbPath = () => config.dbPath;
78
+
79
+ /**
80
+ * 获取全局目录路径
81
+ * @param platformId 平台 ID(默认当前平台)
82
+ */
83
+ export const getGlobalDir = (platformId?: string): string => {
84
+ if (platformId && platforms[platformId]) {
85
+ return platforms[platformId].globalDir;
86
+ }
87
+ return config.globalDir;
88
+ };
89
+
90
+ /**
91
+ * 判断路径是否为全局目录
92
+ */
93
+ export const isGlobalDir = (dirPath: string): boolean => {
94
+ const resolved = path.resolve(dirPath);
95
+ return Object.values(platforms).some((p) => path.resolve(p.globalDir) === resolved);
96
+ };
package/src/db.ts CHANGED
@@ -367,6 +367,37 @@ export async function getInstallations(projectPath: string): Promise<LocalInstal
367
367
  }));
368
368
  }
369
369
 
370
+ export interface InstallationWithProject extends LocalInstallation {
371
+ projectPath: string;
372
+ projectName: string;
373
+ }
374
+
375
+ export async function getAllInstallations(): Promise<InstallationWithProject[]> {
376
+ const client = await getDb();
377
+
378
+ const rows = client.prepare(`
379
+ SELECT i.*, p.path as project_path, p.name as project_name
380
+ FROM installations i
381
+ JOIN projects p ON i.project_id = p.id
382
+ ORDER BY p.path, i.installed_at DESC
383
+ `).all() as Record<string, unknown>[];
384
+
385
+ return rows.map((row) => ({
386
+ id: row.id as string,
387
+ projectId: row.project_id as string,
388
+ fullName: row.full_name as string,
389
+ type: row.type as PluginType,
390
+ version: row.version as string,
391
+ cachePath: row.cache_path as string,
392
+ linkPath: row.link_path as string,
393
+ source: row.source as 'direct' | 'kit',
394
+ sourceKit: row.source_kit as string | undefined,
395
+ installedAt: row.installed_at as string,
396
+ projectPath: row.project_path as string,
397
+ projectName: row.project_name as string,
398
+ }));
399
+ }
400
+
370
401
  export async function removeInstallation(projectPath: string, fullName: string): Promise<boolean> {
371
402
  const client = await getDb();
372
403
  const absPath = path.resolve(projectPath);
@@ -9,6 +9,8 @@
9
9
  * 5. 确认发布
10
10
  */
11
11
 
12
+ import fs from 'fs/promises';
13
+ import path from 'path';
12
14
  import ora from 'ora';
13
15
  import chalk from 'chalk';
14
16
  import { confirm } from '@inquirer/prompts';
@@ -24,6 +26,7 @@ import type {
24
26
  PublishResult,
25
27
  PackageResult,
26
28
  ValidationResult,
29
+ PluginType,
27
30
  } from '../types';
28
31
 
29
32
  export class Publisher {
@@ -49,7 +52,7 @@ export class Publisher {
49
52
  try {
50
53
  // Phase 1: 完整验证
51
54
  spinner.text = '验证插件...';
52
- const validation = await this.validator.validateFull(options.path, options.name);
55
+ const validation = await this.validator.validateFull(options.path, options.name, options.type);
53
56
 
54
57
  // 显示验证结果
55
58
  spinner.stop();
@@ -143,6 +146,9 @@ export class Publisher {
143
146
  const uploadResult = await this.uploader.upload(metadata, pkg, versionDecision.version);
144
147
  spinner.succeed('上传完成');
145
148
 
149
+ // 读取文档内容
150
+ const docContent = await this.readDocContent(metadata.sourcePath, metadata.type);
151
+
146
152
  // Phase 5: 确认发布
147
153
  spinner.start('确认发布...');
148
154
  const result = await api.confirmPublish({
@@ -157,6 +163,7 @@ export class Publisher {
157
163
  description: metadata.description,
158
164
  tags: metadata.tags,
159
165
  visibility: options.visibility || 'self',
166
+ docContent,
160
167
  });
161
168
  spinner.succeed('发布成功');
162
169
 
@@ -234,4 +241,82 @@ export class Publisher {
234
241
  const session = await api.getSession();
235
242
  return session.user.username || session.user.name || 'unknown';
236
243
  }
244
+
245
+ /**
246
+ * 读取插件文档内容
247
+ * 优先级:类型对应文档 > README.md > 任意 .md 文件
248
+ */
249
+ private async readDocContent(sourcePath: string, pluginType: PluginType): Promise<string | undefined> {
250
+ const MAX_DOC_SIZE = 100 * 1024; // 100KB 限制(与 Pipeline 一致)
251
+
252
+ const stat = await fs.stat(sourcePath).catch(() => null);
253
+
254
+ // 单文件插件:文件本身就是文档
255
+ if (stat?.isFile() && sourcePath.endsWith('.md')) {
256
+ const content = await fs.readFile(sourcePath, 'utf-8');
257
+ if (content.length > MAX_DOC_SIZE) {
258
+ return content.slice(0, MAX_DOC_SIZE);
259
+ }
260
+ return content;
261
+ }
262
+
263
+ if (!stat?.isDirectory()) return undefined;
264
+
265
+ // 根据类型定义优先查找的文档名
266
+ const typeDocNames: Record<PluginType, string[]> = {
267
+ skill: ['SKILL.md', 'skill.md'],
268
+ agent: ['AGENT.md', 'agent.md'],
269
+ command: ['COMMAND.md', 'command.md'],
270
+ hook: ['HOOK.md', 'hook.md'],
271
+ mcp: ['MCP.md', 'mcp.md'],
272
+ };
273
+
274
+ const fallbackNames = ['README.md', 'readme.md'];
275
+
276
+ // 1. 优先匹配类型对应的文档
277
+ const primaryDocs = typeDocNames[pluginType] || [];
278
+ for (const name of primaryDocs) {
279
+ const filePath = path.join(sourcePath, name);
280
+ try {
281
+ const content = await fs.readFile(filePath, 'utf-8');
282
+ if (content.length > MAX_DOC_SIZE) {
283
+ return content.slice(0, MAX_DOC_SIZE);
284
+ }
285
+ return content;
286
+ } catch {
287
+ // 继续尝试下一个
288
+ }
289
+ }
290
+
291
+ // 2. Fallback 到 README
292
+ for (const name of fallbackNames) {
293
+ const filePath = path.join(sourcePath, name);
294
+ try {
295
+ const content = await fs.readFile(filePath, 'utf-8');
296
+ if (content.length > MAX_DOC_SIZE) {
297
+ return content.slice(0, MAX_DOC_SIZE);
298
+ }
299
+ return content;
300
+ } catch {
301
+ // 继续尝试下一个
302
+ }
303
+ }
304
+
305
+ // 3. 任意 .md 文件(最后手段)
306
+ try {
307
+ const entries = await fs.readdir(sourcePath, { withFileTypes: true });
308
+ const mdFile = entries.find(e => e.isFile() && e.name.endsWith('.md'));
309
+ if (mdFile) {
310
+ const content = await fs.readFile(path.join(sourcePath, mdFile.name), 'utf-8');
311
+ if (content.length > MAX_DOC_SIZE) {
312
+ return content.slice(0, MAX_DOC_SIZE);
313
+ }
314
+ return content;
315
+ }
316
+ } catch {
317
+ // 忽略错误
318
+ }
319
+
320
+ return undefined;
321
+ }
237
322
  }
package/src/types.ts CHANGED
@@ -289,6 +289,7 @@ export interface PublishOptions {
289
289
  force?: boolean;
290
290
  visibility?: 'self' | 'public';
291
291
  name?: string;
292
+ type?: PluginType; // 用户指定的类型,覆盖自动推断
292
293
  }
293
294
 
294
295
  export interface PluginMetadata {
@@ -339,6 +340,7 @@ export interface PublishConfirmRequest {
339
340
  description?: string;
340
341
  tags?: string[];
341
342
  visibility: 'self' | 'public';
343
+ docContent?: string;
342
344
  }
343
345
 
344
346
  export interface PublishConfirmResponse {
@@ -0,0 +1,343 @@
1
+ /**
2
+ * 版本更新检查模块
3
+ *
4
+ * 功能:
5
+ * - 启动时后台检查 npm registry 获取最新版本
6
+ * - 24 小时内只检查一次,每天最多提示一次
7
+ * - 3 秒超时,静默失败不影响正常使用
8
+ * - 检测安装方式(Homebrew/npm)显示对应升级命令
9
+ * - 支持 NO_UPDATE_CHECK=1 环境变量禁用
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import chalk from 'chalk';
15
+ import { config } from './config';
16
+
17
+ // ============================================================================
18
+ // 配置
19
+ // ============================================================================
20
+
21
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 小时
22
+ const NOTIFY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 小时内只提示一次
23
+ const FETCH_TIMEOUT_MS = 3000; // 3 秒超时
24
+ const CACHE_FILE = path.join(config.dataDir, '.update-check');
25
+ const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@42ailab/42plugin/latest';
26
+
27
+ // ============================================================================
28
+ // 类型
29
+ // ============================================================================
30
+
31
+ interface UpdateCache {
32
+ lastCheck: number; // 上次检查时间戳
33
+ latestVersion: string | null; // 最新版本
34
+ lastNotified: number; // 上次提示时间戳
35
+ }
36
+
37
+ type InstallSource = 'homebrew' | 'npm';
38
+
39
+ const DEFAULT_CACHE: UpdateCache = { lastCheck: 0, latestVersion: null, lastNotified: 0 };
40
+
41
+ // ============================================================================
42
+ // 缓存管理
43
+ // ============================================================================
44
+
45
+ function readCache(): UpdateCache {
46
+ try {
47
+ if (fs.existsSync(CACHE_FILE)) {
48
+ const content = fs.readFileSync(CACHE_FILE, 'utf-8');
49
+ const parsed = JSON.parse(content);
50
+ // 基本校验:确保必需字段存在
51
+ if (
52
+ typeof parsed.lastCheck === 'number' &&
53
+ typeof parsed.lastNotified === 'number'
54
+ ) {
55
+ return {
56
+ lastCheck: parsed.lastCheck,
57
+ latestVersion: parsed.latestVersion ?? null,
58
+ lastNotified: parsed.lastNotified,
59
+ };
60
+ }
61
+ }
62
+ } catch {
63
+ // 缓存读取/解析失败,返回默认值
64
+ // 可选:在 debug 模式下记录警告
65
+ if (config.debug) {
66
+ console.error(chalk.gray('[update-checker] 缓存读取失败,使用默认值'));
67
+ }
68
+ }
69
+ return { ...DEFAULT_CACHE };
70
+ }
71
+
72
+ function writeCache(cache: UpdateCache): void {
73
+ try {
74
+ fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
75
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(cache));
76
+ } catch {
77
+ // 缓存写入失败,忽略
78
+ }
79
+ }
80
+
81
+ // ============================================================================
82
+ // 版本比较
83
+ // ============================================================================
84
+
85
+ /**
86
+ * 解析版本号,处理预发布版本
87
+ * 例如:'1.2.3-beta.1' -> { parts: [1, 2, 3], prerelease: 'beta.1' }
88
+ */
89
+ function parseVersion(v: string): { parts: number[]; prerelease: string | null } {
90
+ const cleaned = v.replace(/^v/, '');
91
+ const [main, prerelease] = cleaned.split('-', 2);
92
+ const parts = main.split('.').map((part) => {
93
+ const num = parseInt(part, 10);
94
+ return isNaN(num) ? 0 : num;
95
+ });
96
+ return { parts, prerelease: prerelease || null };
97
+ }
98
+
99
+ /**
100
+ * 比较两个版本号
101
+ * @returns -1 表示 a < b, 0 表示 a = b, 1 表示 a > b
102
+ *
103
+ * 规则:
104
+ * - 主版本号按数字比较
105
+ * - 预发布版本 < 正式版本 (1.0.0-beta < 1.0.0)
106
+ * - 预发布版本之间按字符串比较
107
+ */
108
+ function compareVersions(a: string, b: string): number {
109
+ const va = parseVersion(a);
110
+ const vb = parseVersion(b);
111
+
112
+ // 比较主版本号(动态长度)
113
+ const maxLen = Math.max(va.parts.length, vb.parts.length);
114
+ for (let i = 0; i < maxLen; i++) {
115
+ const na = va.parts[i] || 0;
116
+ const nb = vb.parts[i] || 0;
117
+ if (na < nb) return -1;
118
+ if (na > nb) return 1;
119
+ }
120
+
121
+ // 主版本号相同,比较预发布标识
122
+ // 有预发布标识 < 无预发布标识
123
+ if (va.prerelease && !vb.prerelease) return -1;
124
+ if (!va.prerelease && vb.prerelease) return 1;
125
+
126
+ // 都有预发布标识,按字符串比较
127
+ if (va.prerelease && vb.prerelease) {
128
+ return va.prerelease.localeCompare(vb.prerelease);
129
+ }
130
+
131
+ return 0;
132
+ }
133
+
134
+ // ============================================================================
135
+ // 安装方式检测
136
+ // ============================================================================
137
+
138
+ /**
139
+ * 检测 CLI 的安装来源
140
+ */
141
+ function detectInstallSource(): InstallSource {
142
+ const execPath = process.execPath;
143
+
144
+ // Homebrew 路径特征
145
+ // macOS ARM: /opt/homebrew/...
146
+ // macOS Intel: /usr/local/Cellar/...
147
+ // Linux: /home/linuxbrew/.linuxbrew/...
148
+ if (
149
+ execPath.includes('/opt/homebrew') ||
150
+ execPath.includes('/usr/local/Cellar') ||
151
+ execPath.includes('/linuxbrew')
152
+ ) {
153
+ return 'homebrew';
154
+ }
155
+
156
+ return 'npm';
157
+ }
158
+
159
+ /**
160
+ * 获取升级命令
161
+ */
162
+ function getUpgradeCommand(source: InstallSource): string {
163
+ switch (source) {
164
+ case 'homebrew':
165
+ return 'brew upgrade 42plugin';
166
+ case 'npm':
167
+ default:
168
+ // 使用 bun,性能更好
169
+ return 'bun update -g @42ailab/42plugin';
170
+ }
171
+ }
172
+
173
+ // ============================================================================
174
+ // 网络请求
175
+ // ============================================================================
176
+
177
+ /**
178
+ * 从 npm registry 获取最新版本
179
+ */
180
+ async function fetchLatestVersion(): Promise<string | null> {
181
+ try {
182
+ const response = await fetch(NPM_REGISTRY_URL, {
183
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
184
+ headers: {
185
+ Accept: 'application/json',
186
+ },
187
+ });
188
+
189
+ if (response.ok) {
190
+ const data = (await response.json()) as { version?: string };
191
+ return data.version || null;
192
+ }
193
+ } catch {
194
+ // 网络错误、超时等,静默失败
195
+ }
196
+ return null;
197
+ }
198
+
199
+ // ============================================================================
200
+ // 提示显示
201
+ // ============================================================================
202
+
203
+ /**
204
+ * 显示更新提示(简洁单行)
205
+ */
206
+ function showUpdateNotice(currentVersion: string, latestVersion: string): void {
207
+ const source = detectInstallSource();
208
+ const command = getUpgradeCommand(source);
209
+
210
+ console.log();
211
+ console.log(
212
+ chalk.yellow(`✨ 新版本 ${latestVersion} 可用`) +
213
+ chalk.gray(` (当前 ${currentVersion}) `) +
214
+ chalk.cyan(`升级: ${command}`)
215
+ );
216
+ }
217
+
218
+ /**
219
+ * 判断是否需要显示提示并显示
220
+ * @returns 是否显示了提示
221
+ */
222
+ function maybeShowNotice(
223
+ currentVersion: string,
224
+ latestVersion: string,
225
+ lastNotified: number,
226
+ now: number
227
+ ): boolean {
228
+ if (compareVersions(currentVersion, latestVersion) < 0) {
229
+ const needsNotify = now - lastNotified >= NOTIFY_INTERVAL_MS;
230
+ if (needsNotify) {
231
+ showUpdateNotice(currentVersion, latestVersion);
232
+ return true;
233
+ }
234
+ }
235
+ return false;
236
+ }
237
+
238
+ // ============================================================================
239
+ // 主入口
240
+ // ============================================================================
241
+
242
+ /**
243
+ * 检查更新(非阻塞)
244
+ *
245
+ * 在命令执行后调用,后台检查并在需要时显示提示
246
+ *
247
+ * @param currentVersion 当前版本号
248
+ */
249
+ export function checkForUpdates(currentVersion: string): void {
250
+ // 1. 检查是否禁用
251
+ if (process.env.NO_UPDATE_CHECK === '1') {
252
+ return;
253
+ }
254
+
255
+ // 2. 读取缓存
256
+ const cache = readCache();
257
+ const now = Date.now();
258
+
259
+ // 3. 判断是否需要检查
260
+ const needsCheck = now - cache.lastCheck >= CHECK_INTERVAL_MS;
261
+
262
+ if (needsCheck) {
263
+ // 后台检查,使用立即执行的异步函数
264
+ // 注意:这里不使用 setImmediate,因为 CLI 可能在回调执行前退出
265
+ // 改用 Promise,让 Node.js 事件循环保持活跃直到完成
266
+ (async () => {
267
+ try {
268
+ const latestVersion = await fetchLatestVersion();
269
+
270
+ // 重新读取缓存以避免竞态条件
271
+ const freshCache = readCache();
272
+ const freshNow = Date.now();
273
+
274
+ // 更新缓存
275
+ const newCache: UpdateCache = {
276
+ lastCheck: freshNow,
277
+ latestVersion: latestVersion ?? freshCache.latestVersion,
278
+ lastNotified: freshCache.lastNotified,
279
+ };
280
+
281
+ // 判断是否需要提示
282
+ if (latestVersion) {
283
+ const didNotify = maybeShowNotice(
284
+ currentVersion,
285
+ latestVersion,
286
+ freshCache.lastNotified,
287
+ freshNow
288
+ );
289
+ if (didNotify) {
290
+ newCache.lastNotified = freshNow;
291
+ }
292
+ }
293
+
294
+ writeCache(newCache);
295
+ } catch {
296
+ // 静默失败
297
+ }
298
+ })();
299
+ } else {
300
+ // 使用缓存的版本信息判断
301
+ if (cache.latestVersion) {
302
+ const didNotify = maybeShowNotice(
303
+ currentVersion,
304
+ cache.latestVersion,
305
+ cache.lastNotified,
306
+ now
307
+ );
308
+ if (didNotify) {
309
+ writeCache({ ...cache, lastNotified: now });
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ /**
316
+ * 主动检查更新(阻塞,用于 version --check)
317
+ *
318
+ * @param currentVersion 当前版本号
319
+ * @returns 是否有新版本
320
+ */
321
+ export async function checkForUpdatesSync(currentVersion: string): Promise<boolean> {
322
+ const latestVersion = await fetchLatestVersion();
323
+
324
+ if (!latestVersion) {
325
+ console.log(chalk.gray('无法获取最新版本信息'));
326
+ return false;
327
+ }
328
+
329
+ // 更新缓存
330
+ writeCache({
331
+ lastCheck: Date.now(),
332
+ latestVersion,
333
+ lastNotified: Date.now(),
334
+ });
335
+
336
+ if (compareVersions(currentVersion, latestVersion) < 0) {
337
+ showUpdateNotice(currentVersion, latestVersion);
338
+ return true;
339
+ } else {
340
+ console.log(chalk.green('✓ 已是最新版本'));
341
+ return false;
342
+ }
343
+ }