@42ailab/42plugin 0.1.13 → 0.1.16

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 CHANGED
@@ -200,9 +200,9 @@ CLI API Browser
200
200
  - author/name → TargetType.Plugin
201
201
  - author/kit/slug → TargetType.Kit
202
202
 
203
- 2. 从 API 获取下载信息
204
- - GET /v1/plugins/{author}/{name}/download
205
- - GET /v1/kits/{username}/kit/{slug}/download
203
+ 2. 从 API 获取下载信息(CLI 专用端点)
204
+ - GET /cli/v1/download/plugin/{author}/{name}
205
+ - GET /cli/v1/download/kit/{username}/{slug}
206
206
 
207
207
  3. 检查本地缓存(plugin_cache 表)
208
208
 
@@ -229,13 +229,16 @@ CLI API Browser
229
229
  | `/api/user/installed` | POST | 同步安装记录 |
230
230
  | `/api/user/installed/{author}/{name}` | DELETE | 同步卸载记录 |
231
231
 
232
- ### 业务 API
232
+ ### CLI 专用 API(稳定契约)
233
233
 
234
234
  | 端点 | 方法 | 说明 |
235
235
  |------|------|------|
236
- | `/v1/plugins/{author}/{name}/download` | GET | 获取插件下载信息 |
237
- | `/v1/kits/{username}/kit/{slug}/download` | GET | 获取套包下载信息 |
238
- | `/v1/search?q=` | GET | 搜索 |
236
+ | `/cli/v1/download/plugin/{author}/{name}` | GET | 获取插件下载信息 |
237
+ | `/cli/v1/download/kit/{username}/{slug}` | GET | 获取套包下载信息 |
238
+ | `/cli/v1/search?q=` | GET | 搜索插件 |
239
+ | `/cli/v1/session` | GET | 会话验证 |
240
+
241
+ > CLI 专用端点直接返回 camelCase 格式,扁平化结构,独立版本控制。
239
242
 
240
243
  ## 开发命令
241
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@42ailab/42plugin",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "description": "活水插件",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/api.ts CHANGED
@@ -121,66 +121,150 @@ class ApiClient {
121
121
  }
122
122
 
123
123
  // ==========================================================================
124
- // 插件 API
124
+ // 插件 API(使用 CLI 专用端点)
125
125
  // ==========================================================================
126
126
 
127
+ /**
128
+ * 获取插件下载信息
129
+ * 使用 CLI 专用端点 /cli/v1/download/plugin/:author/:name
130
+ * 响应直接返回 camelCase,无需转换
131
+ */
127
132
  async getPluginDownload(author: string, name: string): Promise<PluginDownload> {
128
- return this.request<PluginDownload>(`/v1/plugins/${author}/${name}/download`);
133
+ // CLI 专用端点直接返回扁平化的下载信息
134
+ const data = await this.request<{
135
+ fullName: string;
136
+ name: string;
137
+ type: PluginType;
138
+ version: string;
139
+ downloadUrl: string;
140
+ checksum: string;
141
+ sizeBytes: number;
142
+ installPath: string;
143
+ expiresAt: string;
144
+ requiresAuth: boolean;
145
+ priceTier: 'free' | 'vip' | 'premium';
146
+ }>(`/cli/v1/download/plugin/${author}/${name}`);
147
+
148
+ // 转换为 PluginDownload 格式(保持与现有代码兼容)
149
+ return {
150
+ plugin: {
151
+ fullName: data.fullName,
152
+ name: data.name,
153
+ title: null,
154
+ description: null,
155
+ author: author,
156
+ type: data.type,
157
+ version: data.version,
158
+ sourceRepo: null,
159
+ },
160
+ download: {
161
+ fullName: data.fullName,
162
+ name: data.name,
163
+ type: data.type,
164
+ version: data.version,
165
+ downloadUrl: data.downloadUrl,
166
+ checksum: data.checksum,
167
+ sizeBytes: data.sizeBytes,
168
+ installPath: data.installPath,
169
+ },
170
+ priceTier: data.priceTier,
171
+ isPaid: data.priceTier !== 'free',
172
+ requiresAuth: data.requiresAuth,
173
+ expiresAt: data.expiresAt,
174
+ };
129
175
  }
130
176
 
131
177
  // ==========================================================================
132
- // 套包 API
178
+ // 套包 API(使用 CLI 专用端点)
133
179
  // ==========================================================================
134
180
 
181
+ /**
182
+ * 获取套包下载信息
183
+ * 使用 CLI 专用端点 /cli/v1/download/kit/:username/:slug
184
+ * 响应直接返回 camelCase,扁平化结构
185
+ */
135
186
  async getKitDownload(username: string, slugOrId: string): Promise<KitDownload> {
136
- const raw = await this.request<KitApiResponse['data']>(
137
- `/v1/kits/${username}/kit/${slugOrId}/download`
187
+ // CLI 专用端点直接返回扁平化的下载信息(camelCase)
188
+ interface CliKitDownloadResponse {
189
+ kit: {
190
+ fullName: string;
191
+ name: string;
192
+ priceTier: 'free' | 'vip' | 'premium';
193
+ };
194
+ plugins: Array<{
195
+ fullName: string;
196
+ name: string;
197
+ type: PluginType;
198
+ version: string;
199
+ downloadUrl: string;
200
+ checksum: string;
201
+ sizeBytes: number;
202
+ installPath: string;
203
+ required: boolean;
204
+ reason: string | null;
205
+ }>;
206
+ summary: {
207
+ totalPlugins: number;
208
+ requiredPlugins: number;
209
+ optionalPlugins: number;
210
+ totalSizeBytes: number;
211
+ expiresAt: string;
212
+ };
213
+ }
214
+
215
+ const data = await this.request<CliKitDownloadResponse>(
216
+ `/cli/v1/download/kit/${username}/${slugOrId}`
138
217
  );
139
218
 
140
- // 转换 snake_case -> camelCase
219
+ // 转换为 KitDownload 格式(保持与现有代码兼容)
141
220
  return {
142
221
  kit: {
143
- fullName: raw.kit.full_name,
144
- name: raw.kit.name,
145
- description: raw.kit.description,
146
- priceTier: raw.kit.price_tier,
147
- effectivePriceTier: raw.kit.effective_price_tier,
222
+ fullName: data.kit.fullName,
223
+ name: data.kit.name,
224
+ description: null,
225
+ priceTier: data.kit.priceTier,
226
+ effectivePriceTier: data.kit.priceTier,
148
227
  },
149
- plugins: raw.plugins.map((p) => ({
228
+ plugins: data.plugins.map((p) => ({
150
229
  plugin: {
151
- fullName: p.plugin.full_name,
152
- name: p.plugin.name,
153
- title: p.plugin.title,
154
- author: p.plugin.author,
155
- type: p.plugin.type,
230
+ fullName: p.fullName,
231
+ name: p.name,
232
+ title: null,
233
+ author: null,
234
+ type: p.type,
156
235
  },
157
236
  download: {
158
- fullName: p.download.full_name,
159
- name: p.download.name,
160
- type: p.download.type,
161
- version: p.download.version,
162
- downloadUrl: p.download.download_url,
163
- checksum: p.download.checksum,
164
- sizeBytes: p.download.size_bytes,
165
- installPath: p.download.install_path,
237
+ fullName: p.fullName,
238
+ name: p.name,
239
+ type: p.type,
240
+ version: p.version,
241
+ downloadUrl: p.downloadUrl,
242
+ checksum: p.checksum,
243
+ sizeBytes: p.sizeBytes,
244
+ installPath: p.installPath,
166
245
  },
167
246
  required: p.required,
168
247
  reason: p.reason,
169
248
  })),
170
249
  summary: {
171
- totalPlugins: raw.summary.total_plugins,
172
- requiredPlugins: raw.summary.required_plugins,
173
- optionalPlugins: raw.summary.optional_plugins,
174
- totalSizeBytes: raw.summary.total_size_bytes,
175
- expiresAt: raw.summary.expires_at,
250
+ totalPlugins: data.summary.totalPlugins,
251
+ requiredPlugins: data.summary.requiredPlugins,
252
+ optionalPlugins: data.summary.optionalPlugins,
253
+ totalSizeBytes: data.summary.totalSizeBytes,
254
+ expiresAt: data.summary.expiresAt,
176
255
  },
177
256
  };
178
257
  }
179
258
 
180
259
  // ==========================================================================
181
- // 搜索 API
260
+ // 搜索 API(使用 CLI 专用端点)
182
261
  // ==========================================================================
183
262
 
263
+ /**
264
+ * 搜索插件
265
+ * 使用 CLI 专用端点 /cli/v1/search
266
+ * 响应直接返回 camelCase,只返回插件不返回套包
267
+ */
184
268
  async search(params: {
185
269
  q: string;
186
270
  pluginType?: string;
@@ -189,11 +273,10 @@ class ApiClient {
189
273
  }): Promise<SearchResponse> {
190
274
  const searchParams = new URLSearchParams();
191
275
  searchParams.set('q', params.q);
192
- if (params.pluginType) searchParams.set('plugin_type', params.pluginType);
276
+ if (params.pluginType) searchParams.set('type', params.pluginType);
193
277
  if (params.page) searchParams.set('page', String(params.page));
194
278
  if (params.perPage) searchParams.set('per_page', String(params.perPage));
195
279
 
196
- // 搜索 API 直接返回 {data, pagination},不需要解包
197
280
  const headers: Record<string, string> = {
198
281
  'Content-Type': 'application/json',
199
282
  'User-Agent': '42plugin-cli/1.0.0',
@@ -203,7 +286,8 @@ class ApiClient {
203
286
  headers['Authorization'] = `Bearer ${this.sessionToken}`;
204
287
  }
205
288
 
206
- const response = await fetch(`${this.baseUrl}/v1/search?${searchParams}`, {
289
+ // 使用 CLI 专用搜索端点
290
+ const response = await fetch(`${this.baseUrl}/cli/v1/search?${searchParams}`, {
207
291
  method: 'GET',
208
292
  headers,
209
293
  });
@@ -213,32 +297,50 @@ class ApiClient {
213
297
  if (!response.ok) {
214
298
  const error = raw?.error;
215
299
  const message = error?.message || `HTTP ${response.status}`;
216
- throw new ApiError(response.status, '/v1/search', message, error?.code);
300
+ throw new ApiError(response.status, '/cli/v1/search', message, error?.code);
217
301
  }
218
302
 
219
- // 转换 API 响应格式为内部格式 (snake_case -> camelCase)
220
- const apiData = raw as SearchApiResponse;
221
- const plugins = apiData.data?.plugins || [];
303
+ // CLI 专用端点直接返回 camelCase 格式
304
+ interface CliSearchResponse {
305
+ data: Array<{
306
+ fullName: string;
307
+ name: string;
308
+ title: string | null;
309
+ type: PluginType;
310
+ version: string;
311
+ author: string;
312
+ downloads: number;
313
+ description: string | null;
314
+ }>;
315
+ pagination: {
316
+ page: number;
317
+ perPage: number;
318
+ total: number;
319
+ totalPages: number;
320
+ };
321
+ }
322
+
323
+ const apiData = raw as CliSearchResponse;
222
324
 
223
325
  return {
224
- data: plugins.map((p) => ({
225
- fullName: p.full_name,
326
+ data: apiData.data.map((p) => ({
327
+ fullName: p.fullName,
226
328
  name: p.name,
227
329
  title: p.title,
228
- slogan: p.slogan || null,
330
+ slogan: null,
229
331
  description: p.description,
230
332
  type: p.type,
231
- version: p.version || '0.0.0',
232
- author: p.author,
233
- tags: p.tags || [],
333
+ version: p.version,
334
+ author: { username: p.author },
335
+ tags: [],
234
336
  downloads: p.downloads,
235
- icon: p.icon || null,
337
+ icon: null,
236
338
  })),
237
339
  pagination: {
238
- page: apiData.pagination?.page || 1,
239
- perPage: apiData.pagination?.per_page || 20,
240
- total: apiData.pagination?.total || 0,
241
- totalPages: Math.ceil((apiData.pagination?.total || 0) / (apiData.pagination?.per_page || 20)),
340
+ page: apiData.pagination.page,
341
+ perPage: apiData.pagination.perPage,
342
+ total: apiData.pagination.total,
343
+ totalPages: apiData.pagination.totalPages,
242
344
  },
243
345
  };
244
346
  }
@@ -254,6 +356,7 @@ class ApiClient {
254
356
  'Content-Type': 'application/json',
255
357
  'User-Agent': '42plugin-cli/1.0.0',
256
358
  'X-Download-Source': 'cli',
359
+ 'Origin': this.baseUrl, // Better Auth 需要 Origin 头进行来源验证
257
360
  },
258
361
  body: JSON.stringify({
259
362
  client_id: '42plugin-cli',
@@ -280,6 +383,7 @@ class ApiClient {
280
383
  'Content-Type': 'application/json',
281
384
  'User-Agent': '42plugin-cli/1.0.0',
282
385
  'X-Download-Source': 'cli',
386
+ 'Origin': this.baseUrl, // Better Auth 需要 Origin 头进行来源验证
283
387
  },
284
388
  body: JSON.stringify({
285
389
  client_id: '42plugin-cli',
@@ -338,7 +442,36 @@ class ApiClient {
338
442
  },
339
443
  };
340
444
  }
341
- return this.request<Session>('/session');
445
+
446
+ // CLI 专用端点返回扁平化格式,需要转换
447
+ interface CliSessionResponse {
448
+ authenticated: boolean;
449
+ user: {
450
+ id: string;
451
+ username: string | null;
452
+ name: string | null;
453
+ } | null;
454
+ expiresAt: string | null;
455
+ }
456
+
457
+ const data = await this.request<CliSessionResponse>('/cli/v1/session');
458
+
459
+ if (!data.authenticated || !data.user) {
460
+ throw new ApiError(401, '/cli/v1/session', 'Not authenticated', 'UNAUTHORIZED');
461
+ }
462
+
463
+ return {
464
+ user: {
465
+ id: data.user.id,
466
+ name: data.user.name,
467
+ username: data.user.username,
468
+ email: null, // CLI 专用端点不返回 email
469
+ },
470
+ session: {
471
+ id: data.user.id, // 使用 user.id 作为 session 标识
472
+ expiresAt: data.expiresAt || new Date(Date.now() + 86400000).toISOString(),
473
+ },
474
+ };
342
475
  }
343
476
 
344
477
  // ==========================================================================
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 认证中间件 - 全局认证检查
3
+ *
4
+ * 使用 Commander.js preAction hook 统一处理认证逻辑
5
+ */
6
+
7
+ import { Command } from 'commander';
8
+ import chalk from 'chalk';
9
+ import { getSessionToken } from './db';
10
+ import { api, ApiError } from './api';
11
+ import { config } from './config';
12
+
13
+ /**
14
+ * 需要强制认证的命令(严格模式)
15
+ * 特征:必须登录且 session 有效,否则报错退出
16
+ */
17
+ const REQUIRE_AUTH_COMMANDS = new Set([
18
+ 'publish',
19
+ 'search',
20
+ 'install',
21
+ 'uninstall',
22
+ ]);
23
+
24
+ /**
25
+ * 不需要认证的命令(跳过模式)
26
+ * 特征:完全不需要 token
27
+ */
28
+ const SKIP_AUTH_COMMANDS = new Set([
29
+ 'auth',
30
+ 'list',
31
+ 'check',
32
+ 'completion',
33
+ 'setup',
34
+ 'version',
35
+ ]);
36
+
37
+ /**
38
+ * 设置全局认证 Hook
39
+ */
40
+ export function setupAuthHook(program: Command): void {
41
+ // 使用 preSubcommand 钩子,在子命令执行前运行
42
+ program.hook('preSubcommand', async (thisCommand, subcommand) => {
43
+ const commandName = subcommand.name();
44
+
45
+ // 跳过帮助命令(--help/-h 应该始终可用)
46
+ const args = process.argv;
47
+ if (args.includes('--help') || args.includes('-h')) {
48
+ return;
49
+ }
50
+
51
+ // 跳过不需要认证的命令
52
+ if (SKIP_AUTH_COMMANDS.has(commandName)) {
53
+ return;
54
+ }
55
+
56
+ // 开发模式:跳过所有认证检查
57
+ if (config.devSkipAuth) {
58
+ console.log(chalk.cyan('[DEV] 跳过认证检查'));
59
+ return;
60
+ }
61
+
62
+ // 获取 token
63
+ const token = await getSessionToken();
64
+
65
+ // 严格模式:必须有 token 且有效
66
+ if (REQUIRE_AUTH_COMMANDS.has(commandName)) {
67
+ if (!token) {
68
+ console.error(chalk.red('请先登录: 42plugin auth'));
69
+ process.exit(1);
70
+ }
71
+
72
+ // 注入 token
73
+ api.setSessionToken(token);
74
+
75
+ // 验证 session 有效性
76
+ try {
77
+ const session = await api.getSession();
78
+
79
+ // 提示建议设置用户名(仅 publish 命令)
80
+ if (commandName === 'publish' && !session.user.name) {
81
+ console.log(chalk.yellow('提示: 建议先设置用户名'));
82
+ }
83
+ } catch (error) {
84
+ // 区分认证错误和网络错误
85
+ if (error instanceof ApiError && (error.statusCode === 401 || error.statusCode === 403)) {
86
+ console.error(chalk.red('登录已过期,请重新登录: 42plugin auth'));
87
+ process.exit(1);
88
+ }
89
+ // 网络错误或其他错误,提供更友好的提示
90
+ const message = error instanceof Error ? error.message : '未知错误';
91
+ if (message.includes('fetch') || message.includes('network') || message.includes('ECONNREFUSED')) {
92
+ console.error(chalk.red('网络连接失败,请检查网络后重试'));
93
+ process.exit(1);
94
+ }
95
+ // 其他错误按认证过期处理(保守策略)
96
+ console.error(chalk.red('登录验证失败,请重新登录: 42plugin auth'));
97
+ process.exit(1);
98
+ }
99
+ } else {
100
+ // 宽松模式:有 token 就注入,没有也不报错(用于未来扩展)
101
+ if (token) {
102
+ api.setSessionToken(token);
103
+ }
104
+ }
105
+ });
106
+ }
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  publishCommand,
15
15
  checkCommand,
16
16
  } from './commands';
17
+ import { setupAuthHook } from './auth-middleware';
17
18
 
18
19
  // 版本号处理:
19
20
  // - Homebrew(编译后):构建时通过 --define __VERSION__ 注入
@@ -63,4 +64,7 @@ program
63
64
  console.log(`42plugin v${VERSION}`);
64
65
  });
65
66
 
67
+ // 注册全局认证 Hook
68
+ setupAuthHook(program);
69
+
66
70
  export { program };
@@ -66,7 +66,7 @@ async function login(): Promise<void> {
66
66
 
67
67
  const tokenResponse = await api.pollDeviceToken(deviceCode.deviceCode);
68
68
 
69
- if (tokenResponse.accessToken && tokenResponse.user) {
69
+ if (tokenResponse.accessToken) {
70
70
  pollSpinner.succeed('授权成功!');
71
71
 
72
72
  // 保存 token
@@ -74,7 +74,12 @@ async function login(): Promise<void> {
74
74
  api.setSessionToken(tokenResponse.accessToken);
75
75
 
76
76
  console.log();
77
- console.log(chalk.green(`欢迎,${tokenResponse.user.name || tokenResponse.user.username || tokenResponse.user.email}!`));
77
+ try {
78
+ const session = await api.getSession();
79
+ console.log(chalk.green(`欢迎,${session.user.name || session.user.username || session.user.email}!`));
80
+ } catch {
81
+ console.log(chalk.green('授权成功!'));
82
+ }
78
83
  return;
79
84
  }
80
85
 
@@ -11,11 +11,12 @@ import { confirm } from '@inquirer/prompts';
11
11
  import { api, QuotaExceededError } from '../api';
12
12
  import fs from 'fs/promises';
13
13
  import {
14
- getSessionToken,
15
14
  getOrCreateProject,
16
15
  addInstallation,
17
16
  createLink,
18
17
  resolveCachePath,
18
+ checkInstallConflict,
19
+ removeInstallation,
19
20
  } from '../db';
20
21
  import { config } from '../config';
21
22
  import { parseTarget, getInstallPath, formatBytes, getTypeIcon } from '../utils';
@@ -29,12 +30,7 @@ export const installCommand = new Command('install')
29
30
  .option('--no-cache', '跳过缓存检查')
30
31
  .option('--optional', '安装套包时包含可选插件')
31
32
  .action(async (target, options) => {
32
- // 初始化 API token
33
- const token = await getSessionToken();
34
- if (token) {
35
- api.setSessionToken(token);
36
- }
37
-
33
+ // token 已在全局 hook 中注入
38
34
  try {
39
35
  // 检测是否在 home 目录
40
36
  const currentPath = path.resolve(process.cwd());
@@ -109,6 +105,33 @@ async function installPlugin(
109
105
 
110
106
  spinner.text = `安装 ${downloadInfo.fullName}...`;
111
107
 
108
+ // 确定安装路径
109
+ const projectPath = options.global ? config.globalDir : process.cwd();
110
+ const linkPath = path.join(projectPath, downloadInfo.installPath);
111
+
112
+ // 检查是否有同名冲突(不同插件安装到同一路径)
113
+ const conflictPlugin = await checkInstallConflict(projectPath, linkPath, downloadInfo.fullName);
114
+ if (conflictPlugin) {
115
+ spinner.stop();
116
+ console.log(chalk.yellow(`\n⚠ 安装路径冲突`));
117
+ console.log(chalk.gray(` 路径: ${downloadInfo.installPath}`));
118
+ console.log(chalk.gray(` 已安装: ${conflictPlugin}`));
119
+ console.log(chalk.gray(` 新插件: ${downloadInfo.fullName}`));
120
+
121
+ const shouldReplace = await confirm({
122
+ message: `是否替换已安装的 ${conflictPlugin}?`,
123
+ default: false,
124
+ });
125
+
126
+ if (!shouldReplace) {
127
+ console.log(chalk.gray('已取消安装'));
128
+ return;
129
+ }
130
+ // 删除冲突的旧安装记录
131
+ await removeInstallation(projectPath, conflictPlugin);
132
+ spinner.start(`安装 ${downloadInfo.fullName}...`);
133
+ }
134
+
112
135
  // 解析缓存
113
136
  const { cachePath, fromCache } = await resolveCachePath(
114
137
  downloadInfo,
@@ -116,11 +139,7 @@ async function installPlugin(
116
139
  options.cache !== false
117
140
  );
118
141
 
119
- // 确定安装路径
120
- const projectPath = options.global ? config.globalDir : process.cwd();
121
142
  const project = await getOrCreateProject(projectPath);
122
- const linkPath = path.join(projectPath, downloadInfo.installPath);
123
-
124
143
  await createLink(cachePath, linkPath);
125
144
 
126
145
  await addInstallation({
@@ -188,6 +207,28 @@ async function installKit(
188
207
 
189
208
  try {
190
209
  const downloadInfo = item.download;
210
+ const linkPath = path.join(projectPath, downloadInfo.installPath);
211
+
212
+ // 检查是否有同名冲突
213
+ const conflictPlugin = await checkInstallConflict(projectPath, linkPath, downloadInfo.fullName);
214
+ if (conflictPlugin) {
215
+ pluginSpinner.stop();
216
+ console.log(chalk.yellow(` ⚠ 路径冲突: ${downloadInfo.installPath}`));
217
+ console.log(chalk.gray(` 已安装: ${conflictPlugin}`));
218
+
219
+ const shouldReplace = await confirm({
220
+ message: `是否替换 ${conflictPlugin}?`,
221
+ default: false,
222
+ });
223
+
224
+ if (!shouldReplace) {
225
+ console.log(chalk.gray(' 已跳过'));
226
+ continue;
227
+ }
228
+ // 删除冲突的旧安装记录
229
+ await removeInstallation(projectPath, conflictPlugin);
230
+ pluginSpinner.start(` ${item.plugin.fullName}...`);
231
+ }
191
232
 
192
233
  const { cachePath, fromCache } = await resolveCachePath(
193
234
  downloadInfo,
@@ -195,7 +236,6 @@ async function installKit(
195
236
  options.cache !== false
196
237
  );
197
238
 
198
- const linkPath = path.join(projectPath, downloadInfo.installPath);
199
239
  await createLink(cachePath, linkPath);
200
240
 
201
241
  await addInstallation({
@@ -42,8 +42,11 @@ export const listCommand = new Command('list')
42
42
  console.log(`${icon} ${chalk.cyan.bold(item.fullName)} ${chalk.gray(`v${item.version}`)}`);
43
43
  console.log(chalk.gray(` → ${item.linkPath}`));
44
44
 
45
- if (item.source === 'kit' && item.sourceKit) {
46
- console.log(chalk.gray(` 来自套包: ${item.sourceKit}`));
45
+ // 解析所属套包:fullName 格式为 author/kit/plugin
46
+ const parts = item.fullName.split('/');
47
+ if (parts.length >= 3) {
48
+ const kitName = `${parts[0]}/${parts[1]}`;
49
+ console.log(chalk.gray(` 所属套包: ${kitName}`));
47
50
  }
48
51
 
49
52
  console.log(chalk.gray(` 安装于 ${time}`));
@@ -5,8 +5,6 @@
5
5
  import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
7
  import { api } from '../api';
8
- import { config } from '../config';
9
- import { getSessionToken } from '../db';
10
8
  import { Publisher } from '../services/publisher';
11
9
  import { ValidationError, UploadError, AuthRequiredError } from '../errors';
12
10
  import { getTypeIcon } from '../utils';
@@ -20,28 +18,7 @@ export const publishCommand = new Command('publish')
20
18
  .option('-n, --name <name>', '覆盖插件名称')
21
19
  .action(async (pluginPath, options) => {
22
20
  try {
23
- // 开发模式跳过认证检查
24
- if (!config.devSkipAuth) {
25
- // 检查登录状态
26
- const token = await getSessionToken();
27
- if (!token) {
28
- throw new AuthRequiredError();
29
- }
30
- api.setSessionToken(token);
31
-
32
- // 验证 session 有效性
33
- try {
34
- const session = await api.getSession();
35
- if (!session.user.name) {
36
- console.log(chalk.yellow('提示: 建议先设置用户名'));
37
- }
38
- } catch {
39
- throw new AuthRequiredError('登录已过期,请重新登录: 42plugin auth');
40
- }
41
- } else {
42
- console.log(chalk.cyan('[DEV] 跳过认证检查'));
43
- }
44
-
21
+ // 认证检查已在全局 hook 中完成
45
22
  // 执行发布
46
23
  const publisher = new Publisher();
47
24
  const result = await publisher.publish({
@@ -5,33 +5,61 @@
5
5
  import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
7
  import ora from 'ora';
8
- import { checkbox } from '@inquirer/prompts';
8
+ import { checkbox, confirm } from '@inquirer/prompts';
9
9
  import { api } from '../api';
10
- import { getSessionToken } from '../db';
11
- import { getTypeIcon, getTypeLabel, parseTarget } from '../utils';
12
- import type { PluginType, SearchResult } from '../types';
10
+ import { getTypeIcon, getTypeLabel } from '../utils';
11
+ import type { SearchResult } from '../types';
12
+
13
+ const DEFAULT_PAGE_SIZE = 7;
14
+
15
+ function displayResults(items: SearchResult[], startIndex: number = 0): void {
16
+ for (const item of items) {
17
+ const icon = getTypeIcon(item.type);
18
+ const typeLabel = getTypeLabel(item.type);
19
+
20
+ console.log(`${icon} ${chalk.cyan.bold(item.fullName)} ${chalk.gray(`[${typeLabel}]`)}`);
21
+
22
+ if (item.title || item.slogan) {
23
+ console.log(` ${item.title || item.slogan}`);
24
+ }
25
+
26
+ if (item.description) {
27
+ const desc = item.description.length > 80
28
+ ? item.description.slice(0, 80) + '...'
29
+ : item.description;
30
+ console.log(chalk.gray(` ${desc}`));
31
+ }
32
+
33
+ console.log(chalk.gray(` v${item.version} · ${item.downloads} 下载`));
34
+ console.log();
35
+ }
36
+ }
13
37
 
14
38
  export const searchCommand = new Command('search')
15
39
  .description('搜索插件')
16
40
  .argument('<keyword>', '搜索关键词')
17
41
  .option('-t, --type <type>', '筛选类型 (skill, agent, command, hook, mcp)')
18
- .option('-l, --limit <n>', '结果数量', '20')
42
+ .option('-l, --limit <n>', '每页数量', String(DEFAULT_PAGE_SIZE))
43
+ .option('-a, --all', '显示所有结果')
19
44
  .option('--json', '输出 JSON 格式')
20
45
  .option('-i, --interactive', '交互式选择安装')
21
46
  .action(async (keyword, options) => {
22
- // 初始化 API token
23
- const token = await getSessionToken();
24
- if (token) {
25
- api.setSessionToken(token);
26
- }
27
-
47
+ // token 已在全局 hook 中注入
28
48
  const spinner = ora('搜索中...').start();
29
49
 
30
50
  try {
51
+ const pageSize = parseInt(options.limit) || DEFAULT_PAGE_SIZE;
52
+ let allResults: SearchResult[] = [];
53
+ let currentPage = 1;
54
+ let totalResults = 0;
55
+ let displayedCount = 0;
56
+
57
+ // 首次搜索
31
58
  const result = await api.search({
32
59
  q: keyword,
33
60
  pluginType: options.type,
34
- perPage: parseInt(options.limit) || 20,
61
+ perPage: pageSize,
62
+ page: currentPage,
35
63
  });
36
64
 
37
65
  spinner.stop();
@@ -46,35 +74,60 @@ export const searchCommand = new Command('search')
46
74
  return;
47
75
  }
48
76
 
49
- console.log(
50
- chalk.gray(`找到 ${result.pagination.total} 个结果,显示第 1-${result.data.length} 个:\n`)
51
- );
77
+ totalResults = result.pagination.total;
78
+ allResults = result.data;
52
79
 
53
- for (const item of result.data) {
54
- const icon = getTypeIcon(item.type);
55
- const typeLabel = getTypeLabel(item.type);
80
+ console.log(chalk.gray(`找到 ${totalResults} 个结果:\n`));
56
81
 
57
- console.log(`${icon} ${chalk.cyan.bold(item.fullName)} ${chalk.gray(`[${typeLabel}]`)}`);
82
+ // 显示首批结果
83
+ displayResults(allResults);
84
+ displayedCount = allResults.length;
58
85
 
59
- if (item.title || item.slogan) {
60
- console.log(` ${item.title || item.slogan}`);
86
+ // 如果有更多结果,根据模式决定是否继续
87
+ while (displayedCount < totalResults) {
88
+ // 非 --all 模式下询问用户
89
+ if (!options.all) {
90
+ const remaining = totalResults - displayedCount;
91
+
92
+ const loadMore = await confirm({
93
+ message: `还有 ${remaining} 个结果,是否继续查看?`,
94
+ default: true,
95
+ });
96
+
97
+ if (!loadMore) {
98
+ break;
99
+ }
61
100
  }
62
101
 
63
- if (item.description) {
64
- const desc = item.description.length > 80
65
- ? item.description.slice(0, 80) + '...'
66
- : item.description;
67
- console.log(chalk.gray(` ${desc}`));
102
+ currentPage++;
103
+ const moreSpinner = ora('加载更多...').start();
104
+
105
+ const moreResult = await api.search({
106
+ q: keyword,
107
+ pluginType: options.type,
108
+ perPage: pageSize,
109
+ page: currentPage,
110
+ });
111
+
112
+ moreSpinner.stop();
113
+
114
+ if (moreResult.data.length === 0) {
115
+ break;
68
116
  }
69
117
 
70
- console.log(chalk.gray(` v${item.version} · ${item.downloads} 下载`));
71
- console.log();
118
+ if (!options.all) {
119
+ console.log();
120
+ }
121
+
122
+ displayResults(moreResult.data);
123
+ allResults = [...allResults, ...moreResult.data];
124
+ displayedCount += moreResult.data.length;
72
125
  }
73
126
 
74
127
  // 交互式选择安装
75
- if (options.interactive && result.data.length > 0) {
128
+ if (options.interactive && allResults.length > 0) {
76
129
  console.log();
77
- const choices = result.data.map((item) => ({
130
+ const choices = allResults.map((item) => ({
78
131
  name: `${getTypeIcon(item.type)} ${item.fullName} - ${item.title || item.slogan || item.description?.slice(0, 30) || ''}`,
79
132
  value: item.fullName,
80
133
  }));
@@ -6,7 +6,7 @@ import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
7
  import ora from 'ora';
8
8
  import { api } from '../api';
9
- import { getSessionToken, getInstallations, removeInstallation, removeLink, removeCache } from '../db';
9
+ import { getInstallations, removeInstallation, removeLink, removeCache } from '../db';
10
10
  import { parseTarget } from '../utils';
11
11
 
12
12
  export const uninstallCommand = new Command('uninstall')
@@ -15,12 +15,7 @@ export const uninstallCommand = new Command('uninstall')
15
15
  .argument('<target>', '插件名 (author/name)')
16
16
  .option('--purge', '同时清除缓存')
17
17
  .action(async (target, options) => {
18
- // 初始化 API token
19
- const token = await getSessionToken();
20
- if (token) {
21
- api.setSessionToken(token);
22
- }
23
-
18
+ // token 已在全局 hook 中注入
24
19
  const spinner = ora(`卸载 ${target}...`).start();
25
20
 
26
21
  try {
package/src/db.ts CHANGED
@@ -252,6 +252,37 @@ export async function removeInstallation(projectPath: string, fullName: string):
252
252
  return result.changes > 0;
253
253
  }
254
254
 
255
+ /**
256
+ * 检查安装路径是否与已安装的其他插件冲突
257
+ * @returns 冲突的插件 fullName,如果无冲突返回 null
258
+ */
259
+ export async function checkInstallConflict(
260
+ projectPath: string,
261
+ linkPath: string,
262
+ newFullName: string
263
+ ): Promise<string | null> {
264
+ const client = await getDb();
265
+ const absPath = path.resolve(projectPath);
266
+ // 规范化路径:去掉尾部斜杠以便比较
267
+ const normalizedLinkPath = path.resolve(linkPath).replace(/\/+$/, '');
268
+
269
+ // 查询该项目下所有安装记录,在应用层比较路径
270
+ const rows = client.prepare(`
271
+ SELECT i.full_name, i.link_path FROM installations i
272
+ JOIN projects p ON i.project_id = p.id
273
+ WHERE p.path = ? AND i.full_name != ?
274
+ `).all(absPath, newFullName) as { full_name: string; link_path: string }[];
275
+
276
+ for (const row of rows) {
277
+ const existingPath = row.link_path.replace(/\/+$/, '');
278
+ if (existingPath === normalizedLinkPath) {
279
+ return row.full_name;
280
+ }
281
+ }
282
+
283
+ return null;
284
+ }
285
+
255
286
  // ============================================================================
256
287
  // 密钥管理
257
288
  // ============================================================================