@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 +10 -7
- package/package.json +1 -1
- package/src/api.ts +183 -50
- package/src/auth-middleware.ts +106 -0
- package/src/cli.ts +4 -0
- package/src/commands/auth.ts +7 -2
- package/src/commands/install.ts +52 -12
- package/src/commands/list.ts +5 -2
- package/src/commands/publish.ts +1 -24
- package/src/commands/search.ts +83 -30
- package/src/commands/uninstall.ts +2 -7
- package/src/db.ts +31 -0
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/
|
|
205
|
-
- GET /v1/
|
|
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
|
-
###
|
|
232
|
+
### CLI 专用 API(稳定契约)
|
|
233
233
|
|
|
234
234
|
| 端点 | 方法 | 说明 |
|
|
235
235
|
|------|------|------|
|
|
236
|
-
| `/v1/
|
|
237
|
-
| `/v1/
|
|
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
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
//
|
|
219
|
+
// 转换为 KitDownload 格式(保持与现有代码兼容)
|
|
141
220
|
return {
|
|
142
221
|
kit: {
|
|
143
|
-
fullName:
|
|
144
|
-
name:
|
|
145
|
-
description:
|
|
146
|
-
priceTier:
|
|
147
|
-
effectivePriceTier:
|
|
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:
|
|
228
|
+
plugins: data.plugins.map((p) => ({
|
|
150
229
|
plugin: {
|
|
151
|
-
fullName: p.
|
|
152
|
-
name: p.
|
|
153
|
-
title:
|
|
154
|
-
author:
|
|
155
|
-
type: p.
|
|
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.
|
|
159
|
-
name: p.
|
|
160
|
-
type: p.
|
|
161
|
-
version: p.
|
|
162
|
-
downloadUrl: p.
|
|
163
|
-
checksum: p.
|
|
164
|
-
sizeBytes: p.
|
|
165
|
-
installPath: p.
|
|
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:
|
|
172
|
-
requiredPlugins:
|
|
173
|
-
optionalPlugins:
|
|
174
|
-
totalSizeBytes:
|
|
175
|
-
expiresAt:
|
|
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('
|
|
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
|
-
|
|
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
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
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:
|
|
225
|
-
fullName: p.
|
|
326
|
+
data: apiData.data.map((p) => ({
|
|
327
|
+
fullName: p.fullName,
|
|
226
328
|
name: p.name,
|
|
227
329
|
title: p.title,
|
|
228
|
-
slogan:
|
|
330
|
+
slogan: null,
|
|
229
331
|
description: p.description,
|
|
230
332
|
type: p.type,
|
|
231
|
-
version: p.version
|
|
232
|
-
author: p.author,
|
|
233
|
-
tags:
|
|
333
|
+
version: p.version,
|
|
334
|
+
author: { username: p.author },
|
|
335
|
+
tags: [],
|
|
234
336
|
downloads: p.downloads,
|
|
235
|
-
icon:
|
|
337
|
+
icon: null,
|
|
236
338
|
})),
|
|
237
339
|
pagination: {
|
|
238
|
-
page: apiData.pagination
|
|
239
|
-
perPage: apiData.pagination
|
|
240
|
-
total: apiData.pagination
|
|
241
|
-
totalPages:
|
|
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
|
-
|
|
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 };
|
package/src/commands/auth.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/src/commands/install.ts
CHANGED
|
@@ -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
|
-
//
|
|
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({
|
package/src/commands/list.ts
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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}`));
|
package/src/commands/publish.ts
CHANGED
|
@@ -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({
|
package/src/commands/search.ts
CHANGED
|
@@ -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 {
|
|
11
|
-
import {
|
|
12
|
-
|
|
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>', '
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
);
|
|
77
|
+
totalResults = result.pagination.total;
|
|
78
|
+
allResults = result.data;
|
|
52
79
|
|
|
53
|
-
|
|
54
|
-
const icon = getTypeIcon(item.type);
|
|
55
|
-
const typeLabel = getTypeLabel(item.type);
|
|
80
|
+
console.log(chalk.gray(`找到 ${totalResults} 个结果:\n`));
|
|
56
81
|
|
|
57
|
-
|
|
82
|
+
// 显示首批结果
|
|
83
|
+
displayResults(allResults);
|
|
84
|
+
displayedCount = allResults.length;
|
|
58
85
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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 &&
|
|
128
|
+
if (options.interactive && allResults.length > 0) {
|
|
76
129
|
console.log();
|
|
77
|
-
const choices =
|
|
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 {
|
|
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
|
-
//
|
|
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
|
// ============================================================================
|