@shiplens/cli 1.3.0 → 1.4.0
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 +38 -62
- package/docs/02_SHIPLENS_CLI_/345/205/250/345/221/275/344/273/244/345/217/202/350/200/203/346/211/213/345/206/214.md +205 -0
- package/lib/api.js +59 -35
- package/lib/assets/skill.js +164 -87
- package/lib/cli.js +55 -55
- package/lib/commands/auth.js +41 -36
- package/lib/commands/context.js +18 -13
- package/lib/commands/dashboards.js +24 -16
- package/lib/commands/doctor.js +34 -43
- package/lib/commands/heatmap.js +9 -14
- package/lib/commands/init.js +54 -62
- package/lib/commands/mcp.js +2 -2
- package/lib/commands/pages.js +23 -19
- package/lib/commands/projects.js +13 -13
- package/lib/commands/query.js +12 -10
- package/lib/commands/sql.js +5 -4
- package/lib/commands/summary.js +7 -7
- package/lib/injector.js +22 -36
- package/lib/mcp-config.js +5 -5
- package/lib/taxonomy.js +25 -22
- package/package.json +5 -4
- package/prompts/README.md +11 -5
- package/prompts/prompts_cli_zh.md +743 -0
- package/prompts/prompts_cli_en.md +0 -671
package/lib/commands/auth.js
CHANGED
|
@@ -11,17 +11,17 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
11
11
|
const deviceEnv = getDeviceEnv();
|
|
12
12
|
if (deviceEnv) {
|
|
13
13
|
return ctx.output({ ok: true, authenticated: false, device_env: 'present', mcp_url: deviceEnv.SHIPLENS_MCP_URL, next: 'Use shiplens auth mcp-config, then complete OAuth in your Agent.' }, () => {
|
|
14
|
-
console.log(`✅ shiplens.env
|
|
15
|
-
console.log('
|
|
14
|
+
console.log(`✅ 已发现 shiplens.env;MCP: ${deviceEnv.SHIPLENS_MCP_URL}`);
|
|
15
|
+
console.log('请在 Agent 中完成 OAuth 登录后使用 MCP。');
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
18
|
if (!ctx.resolvedAuth.is_present) {
|
|
19
19
|
return ctx.output({
|
|
20
20
|
ok: false,
|
|
21
21
|
authenticated: false,
|
|
22
|
-
message: '
|
|
22
|
+
message: '未检测到鉴权密钥,请使用 shiplens auth set 配置',
|
|
23
23
|
}, () => {
|
|
24
|
-
console.log('⚠️
|
|
24
|
+
console.log('⚠️ 当前未配置有效凭证。请执行: shiplens auth set');
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -36,9 +36,9 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
36
36
|
email: meResp.email,
|
|
37
37
|
last_verified_at: new Date().toISOString(),
|
|
38
38
|
}, () => {
|
|
39
|
-
console.log('✅
|
|
40
|
-
console.log(`👤
|
|
41
|
-
console.log(`🔑
|
|
39
|
+
console.log('✅ 鉴权状态有效');
|
|
40
|
+
console.log(`👤 用户 ID: ${meResp.user_id} | 邮箱: ${meResp.email}`);
|
|
41
|
+
console.log(`🔑 密钥来源: ${ctx.resolvedAuth.source} (${ctx.resolvedAuth.masked_secret})`);
|
|
42
42
|
});
|
|
43
43
|
} catch (err) {
|
|
44
44
|
ctx.output({
|
|
@@ -48,7 +48,7 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
48
48
|
masked_secret: ctx.resolvedAuth.masked_secret,
|
|
49
49
|
error: err.message,
|
|
50
50
|
}, () => {
|
|
51
|
-
console.log(`❌
|
|
51
|
+
console.log(`❌ 凭证校验失败: ${err.message}`);
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
break;
|
|
@@ -56,9 +56,9 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
56
56
|
|
|
57
57
|
case 'mcp-config': {
|
|
58
58
|
const deviceEnv = getDeviceEnv();
|
|
59
|
-
if (!deviceEnv) throw new Error('shiplens.env
|
|
59
|
+
if (!deviceEnv) throw new Error('未找到 shiplens.env,请先执行 shiplens auth bind 并完成邮件激活');
|
|
60
60
|
const client = flags.client || 'manual';
|
|
61
|
-
if (!CLIENTS.includes(client)) throw new Error(
|
|
61
|
+
if (!CLIENTS.includes(client)) throw new Error(`不支持的 MCP 客户端: ${client}。支持 ${CLIENTS.join(', ')}`);
|
|
62
62
|
const config = getMcpConfig(client, deviceEnv.SHIPLENS_MCP_URL);
|
|
63
63
|
ctx.output({ ok: true, mcp_config: config, oauth_required: true }, () => console.log(JSON.stringify(config, null, 2)));
|
|
64
64
|
break;
|
|
@@ -66,17 +66,17 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
66
66
|
|
|
67
67
|
case 'configure': {
|
|
68
68
|
const deviceEnv = getDeviceEnv();
|
|
69
|
-
if (!deviceEnv) throw new Error('shiplens.env
|
|
69
|
+
if (!deviceEnv) throw new Error('未找到 shiplens.env,请先执行 shiplens auth bind 并完成邮件激活');
|
|
70
70
|
const client = flags.client || args[0];
|
|
71
|
-
if (!CLIENTS.includes(client)) throw new Error(
|
|
71
|
+
if (!CLIENTS.includes(client)) throw new Error(`请通过 --client 指定 MCP 客户端:${CLIENTS.join(', ')}`);
|
|
72
72
|
const result = configureMcpClient(client, deviceEnv.SHIPLENS_MCP_URL);
|
|
73
73
|
ctx.output({ ok: true, client, oauth_required: true, ...result }, () => {
|
|
74
74
|
if (client === 'manual') {
|
|
75
75
|
console.log(JSON.stringify(result.config, null, 2));
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
|
-
console.log(result.written ? `✅
|
|
79
|
-
console.log('
|
|
78
|
+
console.log(result.written ? `✅ 已写入 ${client} 的 Shiplens MCP 配置。` : 'ℹ️ Shiplens MCP 配置已存在,未覆盖。');
|
|
79
|
+
console.log('请在 Agent 的 MCP 面板中完成 OAuth 登录。');
|
|
80
80
|
});
|
|
81
81
|
break;
|
|
82
82
|
}
|
|
@@ -96,7 +96,7 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
96
96
|
} else {
|
|
97
97
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
98
98
|
secret = await new Promise((resolve) => {
|
|
99
|
-
rl.question('
|
|
99
|
+
rl.question('请输入 Shiplens Access Secret: ', (ans) => {
|
|
100
100
|
rl.close();
|
|
101
101
|
resolve(ans.trim());
|
|
102
102
|
});
|
|
@@ -104,7 +104,7 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
if (!secret) {
|
|
107
|
-
throw new Error('
|
|
107
|
+
throw new Error('密钥不能为空');
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
const tempClient = new APIClient(ctx.client.baseURL, secret);
|
|
@@ -123,17 +123,17 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
123
123
|
masked_secret: maskSecret(secret),
|
|
124
124
|
email: email || undefined,
|
|
125
125
|
user_id: userId || undefined,
|
|
126
|
-
message: '
|
|
126
|
+
message: '鉴权密钥已安全保存至 ~/.shiplens/config.json',
|
|
127
127
|
}, () => {
|
|
128
|
-
console.log(`✅
|
|
129
|
-
if (email) console.log(`👤
|
|
128
|
+
console.log(`✅ 鉴权密钥已保存: ${maskSecret(secret)}`);
|
|
129
|
+
if (email) console.log(`👤 绑定用户: ${email} (${userId})`);
|
|
130
130
|
});
|
|
131
131
|
break;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
case 'secret': {
|
|
135
135
|
const action = args[0] || 'list';
|
|
136
|
-
if (!ctx.resolvedAuth.is_present) throw new Error('
|
|
136
|
+
if (!ctx.resolvedAuth.is_present) throw new Error('请先用 auth set 配置可管理 Access Secret');
|
|
137
137
|
if (action === 'list') {
|
|
138
138
|
const response = await ctx.client.listAccessSecrets();
|
|
139
139
|
return ctx.output({ ok: true, access_secrets: response.api_keys || [] }, () => console.log(JSON.stringify(response.api_keys || [], null, 2)));
|
|
@@ -143,25 +143,25 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
143
143
|
const response = await ctx.client.createAccessSecret({ app_id: flags['app-id'], scopes, expires_at: flags.expires });
|
|
144
144
|
saveSecretToGlobal(response.api_key);
|
|
145
145
|
return ctx.output({ ok: true, api_key_id: response.api_key_id, api_key_preview: response.api_key_preview, app_id: response.app_id, scopes: response.scopes, expires_at: response.expires_at, saved_to_global_config: true }, () => {
|
|
146
|
-
console.log(`✅
|
|
146
|
+
console.log(`✅ 离线 Access Secret 已创建并保存(${response.api_key_preview})。`);
|
|
147
147
|
});
|
|
148
148
|
}
|
|
149
149
|
if (action === 'revoke') {
|
|
150
150
|
const apiKeyId = flags['key-id'] || args[1];
|
|
151
|
-
if (!apiKeyId) throw new Error('
|
|
151
|
+
if (!apiKeyId) throw new Error('请提供 --key-id <api_key_id>');
|
|
152
152
|
const response = await ctx.client.revokeAccessSecret(apiKeyId);
|
|
153
|
-
return ctx.output({ ok: true, ...response }, () => console.log(`✅
|
|
153
|
+
return ctx.output({ ok: true, ...response }, () => console.log(`✅ 已撤销 Access Secret: ${response.api_key_id}`));
|
|
154
154
|
}
|
|
155
|
-
throw new Error('
|
|
155
|
+
throw new Error('支持的 auth secret 子命令: list, create, revoke');
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
case 'whoami': {
|
|
159
159
|
const meResp = await ctx.client.me();
|
|
160
160
|
ctx.output(meResp, () => {
|
|
161
|
-
console.log(`👤
|
|
162
|
-
console.log(`📧
|
|
161
|
+
console.log(`👤 用户 ID: ${meResp.user_id}`);
|
|
162
|
+
console.log(`📧 邮箱: ${meResp.email}`);
|
|
163
163
|
if (meResp.project_count) {
|
|
164
|
-
console.log(`📦
|
|
164
|
+
console.log(`📦 拥有项目数: ${meResp.project_count}`);
|
|
165
165
|
}
|
|
166
166
|
});
|
|
167
167
|
break;
|
|
@@ -171,17 +171,21 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
171
171
|
clearGlobalSecret();
|
|
172
172
|
ctx.output({
|
|
173
173
|
ok: true,
|
|
174
|
-
message: '
|
|
174
|
+
message: '已成功退出登录并清除本地存储凭证',
|
|
175
175
|
}, () => {
|
|
176
|
-
console.log('🚪
|
|
176
|
+
console.log('🚪 已退出登录并清除本地凭证。');
|
|
177
177
|
});
|
|
178
178
|
break;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
case 'bind': {
|
|
182
|
+
// ── auth bind:零弹窗邮箱绑定 ──────────────────────────────────────────
|
|
183
|
+
// 调用方式:shiplens auth bind --email <email> --json
|
|
184
|
+
// 自动从 .shiplens.json 读取 app_id / project_name / api_url
|
|
185
|
+
// 调用 POST /api/auth/email/start 发送 Magic Link,最多重试 2 次
|
|
182
186
|
const email = flags.email || args[0];
|
|
183
187
|
if (!email || !email.includes('@')) {
|
|
184
|
-
const err = new Error('
|
|
188
|
+
const err = new Error('请提供有效的邮箱地址,例如:shiplens auth bind --email you@company.com');
|
|
185
189
|
err.code = 'INVALID_EMAIL';
|
|
186
190
|
throw err;
|
|
187
191
|
}
|
|
@@ -202,6 +206,7 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
202
206
|
device_name: require('os').hostname(),
|
|
203
207
|
});
|
|
204
208
|
} catch (err) {
|
|
209
|
+
// 网络 / 服务端失败时返回标准错误 JSON,不挂起
|
|
205
210
|
ctx.output({
|
|
206
211
|
ok: false,
|
|
207
212
|
magic_link_sent: false,
|
|
@@ -210,7 +215,7 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
210
215
|
code: err.code || 'NETWORK_FAILED',
|
|
211
216
|
message: err.message,
|
|
212
217
|
}, () => {
|
|
213
|
-
console.error(`❌
|
|
218
|
+
console.error(`❌ 邮件发送失败 [${err.code || 'NETWORK_FAILED'}]: ${err.message}`);
|
|
214
219
|
});
|
|
215
220
|
process.exitCode = 1;
|
|
216
221
|
return;
|
|
@@ -225,17 +230,17 @@ async function handleAuth(subcommand, args, flags, ctx) {
|
|
|
225
230
|
app_id: appId || undefined,
|
|
226
231
|
project_name: projectName || undefined,
|
|
227
232
|
shiplens_env_written: Boolean(envPath),
|
|
228
|
-
message:
|
|
233
|
+
message: `激活邮件已发送至 ${email},请点击邮件中的链接完成注册与激活。`,
|
|
229
234
|
}, () => {
|
|
230
|
-
console.log(`📧
|
|
231
|
-
if (appId) console.log(`📦
|
|
232
|
-
console.log('👉
|
|
235
|
+
console.log(`📧 激活邮件已发送至: ${email}`);
|
|
236
|
+
if (appId) console.log(`📦 绑定项目 ID: ${appId}`);
|
|
237
|
+
console.log('👉 请点击邮件中的链接完成注册,每月可获得 50,000 事件免费统计额度。');
|
|
233
238
|
});
|
|
234
239
|
break;
|
|
235
240
|
}
|
|
236
241
|
|
|
237
242
|
default:
|
|
238
|
-
throw new Error(
|
|
243
|
+
throw new Error(`未知的 auth 子命令: ${subcommand}。支持 status, set, secret, whoami, logout, bind, mcp-config, configure`);
|
|
239
244
|
}
|
|
240
245
|
}
|
|
241
246
|
|
package/lib/commands/context.js
CHANGED
|
@@ -3,7 +3,7 @@ const path = require('path');
|
|
|
3
3
|
const { getLocalConfig } = require('../config');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* 获取指定 app_id 的业务上下文标准存储路径: .shiplens/contexts/<app_id>.md
|
|
7
7
|
*/
|
|
8
8
|
function getContextFilePath(appId, customFile = null, dir = process.cwd()) {
|
|
9
9
|
if (customFile) return path.resolve(dir, customFile);
|
|
@@ -11,7 +11,7 @@ function getContextFilePath(appId, customFile = null, dir = process.cwd()) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* 从 Markdown 内容中提取头部声明的 app_id
|
|
15
15
|
*/
|
|
16
16
|
function extractAppIdFromMarkdown(content) {
|
|
17
17
|
const match = content.match(/app_id[^\w\n]*[:=]\s*([a-zA-Z0-9_.\-]+)/i);
|
|
@@ -19,7 +19,7 @@ function extractAppIdFromMarkdown(content) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* 带有最多 2 次重试与优雅跳过的网络执行器 (非阻塞容错)
|
|
23
23
|
*/
|
|
24
24
|
async function executeWithSafeFallback(fn, maxRetries = 2, baseMs = 500) {
|
|
25
25
|
let lastErr = null;
|
|
@@ -37,7 +37,7 @@ async function executeWithSafeFallback(fn, maxRetries = 2, baseMs = 500) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
40
|
+
* 处理 context 命令 (push, pull, show)
|
|
41
41
|
*/
|
|
42
42
|
async function handleContext(subcommand, args, flags, ctx) {
|
|
43
43
|
const targetSub = subcommand || 'show';
|
|
@@ -50,7 +50,7 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
50
50
|
case 'push': {
|
|
51
51
|
if (!fs.existsSync(filePath)) {
|
|
52
52
|
const displayPath = path.relative(process.cwd(), filePath) || filePath;
|
|
53
|
-
const err = new Error(
|
|
53
|
+
const err = new Error(`未找到本地上下文文件: ${displayPath}。请先生成对应产品的业务上下文。`);
|
|
54
54
|
err.code = 'FILE_NOT_FOUND';
|
|
55
55
|
throw err;
|
|
56
56
|
}
|
|
@@ -58,12 +58,14 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
58
58
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
59
59
|
const declaredAppId = extractAppIdFromMarkdown(content);
|
|
60
60
|
|
|
61
|
+
// 多项目防冲突校验:若文件中显式声明了 app_id,必须与当前目标 app_id 一致
|
|
61
62
|
if (declaredAppId && declaredAppId !== appId) {
|
|
62
|
-
const err = new Error(
|
|
63
|
+
const err = new Error(`上下文文件绑定的 app_id (${declaredAppId}) 与当前项目 app_id (${appId}) 不一致,禁止推送!请修正文件头部声明。`);
|
|
63
64
|
err.code = 'PROJECT_ID_MISMATCH';
|
|
64
65
|
throw err;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// 最多重试 2 次,失败则优雅跳过,不阻断流程
|
|
67
69
|
const syncResult = await executeWithSafeFallback(async () => {
|
|
68
70
|
return ctx.client.uploadProjectContext(appId, {
|
|
69
71
|
context_markdown: content,
|
|
@@ -78,7 +80,7 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
78
80
|
action: 'push',
|
|
79
81
|
app_id: appId,
|
|
80
82
|
file: filePath,
|
|
81
|
-
message: '
|
|
83
|
+
message: '连接服务器超时或未成功 (已尝试2次),已自动跳过云端同步步骤,暂未上传业务上下文。',
|
|
82
84
|
});
|
|
83
85
|
return;
|
|
84
86
|
}
|
|
@@ -89,7 +91,7 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
89
91
|
app_id: appId,
|
|
90
92
|
file: filePath,
|
|
91
93
|
bytes: Buffer.byteLength(content, 'utf8'),
|
|
92
|
-
message:
|
|
94
|
+
message: `项目业务上下文 (${path.relative(process.cwd(), filePath)}) 已成功同步至云端。`,
|
|
93
95
|
updated_at: syncResult.result.updated_at || new Date().toISOString(),
|
|
94
96
|
});
|
|
95
97
|
break;
|
|
@@ -98,11 +100,12 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
98
100
|
case 'pull': {
|
|
99
101
|
if (fs.existsSync(filePath) && !flags.force) {
|
|
100
102
|
const displayPath = path.relative(process.cwd(), filePath) || filePath;
|
|
101
|
-
const err = new Error(
|
|
103
|
+
const err = new Error(`本地已存在 ${displayPath}。若需强制从云端覆盖请追加 --force 参数。`);
|
|
102
104
|
err.code = 'FILE_EXISTS';
|
|
103
105
|
throw err;
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
// 最多重试 2 次,失败则优雅跳过,不阻断流程
|
|
106
109
|
const pullResult = await executeWithSafeFallback(async () => {
|
|
107
110
|
return ctx.client.getProjectContext(appId);
|
|
108
111
|
}, 2, 500);
|
|
@@ -113,7 +116,7 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
113
116
|
skipped: true,
|
|
114
117
|
action: 'pull',
|
|
115
118
|
app_id: appId,
|
|
116
|
-
message: '
|
|
119
|
+
message: '连接服务器超时或未成功 (已尝试2次),已自动跳过云端拉取步骤,暂未下载业务上下文。',
|
|
117
120
|
});
|
|
118
121
|
return;
|
|
119
122
|
}
|
|
@@ -127,7 +130,7 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
127
130
|
app_id: appId,
|
|
128
131
|
file: filePath,
|
|
129
132
|
bytes: Buffer.byteLength(pullResult.result.context_markdown, 'utf8'),
|
|
130
|
-
message:
|
|
133
|
+
message: `已从云端成功拉取业务上下文并保存至 ${path.relative(process.cwd(), filePath)}。`,
|
|
131
134
|
updated_at: pullResult.result.updated_at,
|
|
132
135
|
});
|
|
133
136
|
break;
|
|
@@ -137,10 +140,12 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
137
140
|
let content = null;
|
|
138
141
|
let source = 'local';
|
|
139
142
|
|
|
143
|
+
// 若未加 --cloud 且本地文件存在,优先读取本地
|
|
140
144
|
if (!flags.cloud && fs.existsSync(filePath)) {
|
|
141
145
|
content = fs.readFileSync(filePath, 'utf8');
|
|
142
146
|
source = 'local';
|
|
143
147
|
} else {
|
|
148
|
+
// 本地不存在或指定 --cloud 时,从云端拉取 (最多尝试 2 次)
|
|
144
149
|
const fetchResult = await executeWithSafeFallback(async () => {
|
|
145
150
|
return ctx.client.getProjectContext(appId);
|
|
146
151
|
}, 2, 500);
|
|
@@ -157,7 +162,7 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
157
162
|
ok: true,
|
|
158
163
|
app_id: appId,
|
|
159
164
|
source: 'none',
|
|
160
|
-
message:
|
|
165
|
+
message: `本地 (${displayPath}) 与云端暂无该产品的业务上下文。连接服务器超时或未成功 (已尝试2次),已跳过云端查询。`,
|
|
161
166
|
});
|
|
162
167
|
return;
|
|
163
168
|
}
|
|
@@ -173,7 +178,7 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
default:
|
|
176
|
-
throw new Error(
|
|
181
|
+
throw new Error(`未知的 context 子命令: ${targetSub}。支持: push, pull, show`);
|
|
177
182
|
}
|
|
178
183
|
}
|
|
179
184
|
|
|
@@ -10,14 +10,14 @@ async function handleDashboards(subcommand, args, flags, ctx) {
|
|
|
10
10
|
dashboards: list,
|
|
11
11
|
}, () => {
|
|
12
12
|
if (!list || list.length === 0) {
|
|
13
|
-
console.log('
|
|
13
|
+
console.log('暂无自定义看板。可以使用 shiplens dashboards create 创建。');
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
|
-
console.log(`📊
|
|
16
|
+
console.log(`📊 当前项目共有 ${list.length} 个看板:\n`);
|
|
17
17
|
list.forEach((d, i) => {
|
|
18
|
-
const defTag = d.is_default ? ' [
|
|
18
|
+
const defTag = d.is_default ? ' [默认]' : '';
|
|
19
19
|
console.log(`[${i + 1}] ${d.title} (ID: ${d.dashboard_id})${defTag}`);
|
|
20
|
-
if (d.url) console.log(`
|
|
20
|
+
if (d.url) console.log(` 看板链接: ${d.url}`);
|
|
21
21
|
console.log('');
|
|
22
22
|
});
|
|
23
23
|
});
|
|
@@ -25,16 +25,23 @@ async function handleDashboards(subcommand, args, flags, ctx) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
case 'create': {
|
|
28
|
+
// ── 支持两种模式 ──────────────────────────────────────────────────────
|
|
29
|
+
// 模式 A(默认):POST /api/apps/{app_id}/dashboards
|
|
30
|
+
// 适用:传入 widgets / query 结构体,支持 --file 与 --prompt
|
|
31
|
+
// 模式 B(--ai):POST /api/dashboard
|
|
32
|
+
// 适用:自然语言 Prompt 驱动,AI 引擎根据 prompt 自动生成看板
|
|
33
|
+
// 调用示例:shiplens dashboards create --ai --prompt "分析注册流失" --json
|
|
28
34
|
const useAI = Boolean(flags.ai);
|
|
29
|
-
const title = flags.title || 'AI
|
|
35
|
+
const title = flags.title || 'AI 行为分析看板';
|
|
30
36
|
const prompt = flags.prompt || '';
|
|
31
37
|
|
|
32
38
|
if (useAI) {
|
|
39
|
+
// 模式 B:AI 自然语言看板
|
|
33
40
|
let appId = '';
|
|
34
41
|
try { appId = ctx.resolveAppId(); } catch (_) {}
|
|
35
42
|
|
|
36
43
|
if (!prompt) {
|
|
37
|
-
throw new Error('
|
|
44
|
+
throw new Error('使用 --ai 模式时,请通过 --prompt 提供自然语言看板描述');
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
const reqData = { title, prompt };
|
|
@@ -42,38 +49,39 @@ async function handleDashboards(subcommand, args, flags, ctx) {
|
|
|
42
49
|
|
|
43
50
|
const resp = await ctx.client.createAIDashboard(reqData);
|
|
44
51
|
ctx.output(resp, () => {
|
|
45
|
-
console.log('🎉 AI
|
|
46
|
-
console.log(`📌
|
|
47
|
-
if (resp.dashboard_url) console.log(`🔗
|
|
52
|
+
console.log('🎉 AI 看板生成成功!');
|
|
53
|
+
console.log(`📌 看板标题: ${resp.title || title} (ID: ${resp.dashboard_id})`);
|
|
54
|
+
if (resp.dashboard_url) console.log(`🔗 访问链接: ${resp.dashboard_url}`);
|
|
48
55
|
});
|
|
49
56
|
} else {
|
|
57
|
+
// 模式 A:标准看板创建
|
|
50
58
|
const appId = ctx.resolveAppId();
|
|
51
|
-
const reqData = { title, prompt
|
|
59
|
+
const reqData = { title, prompt };
|
|
52
60
|
|
|
53
61
|
if (flags.file) {
|
|
54
62
|
if (!fs.existsSync(flags.file)) {
|
|
55
|
-
throw new Error(
|
|
63
|
+
throw new Error(`看板配置文件不存在: ${flags.file}`);
|
|
56
64
|
}
|
|
57
65
|
const fileContent = JSON.parse(fs.readFileSync(flags.file, 'utf8'));
|
|
58
66
|
Object.assign(reqData, fileContent);
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
if (!reqData.prompt && (!reqData.widgets || reqData.widgets.length === 0)) {
|
|
62
|
-
throw new Error('
|
|
70
|
+
throw new Error('请通过 --prompt 提供生成提示词,或通过 --file 提供看板配置文件');
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
const resp = await ctx.client.createDashboard(appId, reqData);
|
|
66
74
|
ctx.output(resp, () => {
|
|
67
|
-
console.log('🎉
|
|
68
|
-
console.log(`📌
|
|
69
|
-
if (resp.dashboard_url) console.log(`🔗
|
|
75
|
+
console.log('🎉 看板生成成功!');
|
|
76
|
+
console.log(`📌 看板标题: ${resp.title || reqData.title} (ID: ${resp.dashboard_id})`);
|
|
77
|
+
if (resp.dashboard_url) console.log(`🔗 访问链接: ${resp.dashboard_url}`);
|
|
70
78
|
});
|
|
71
79
|
}
|
|
72
80
|
break;
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
default:
|
|
76
|
-
throw new Error(
|
|
84
|
+
throw new Error(`未知的 dashboards 子命令: ${subcommand}。支持 list, create`);
|
|
77
85
|
}
|
|
78
86
|
}
|
|
79
87
|
|
package/lib/commands/doctor.js
CHANGED
|
@@ -9,7 +9,7 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
9
9
|
let hasFail = false;
|
|
10
10
|
let hasWarn = false;
|
|
11
11
|
|
|
12
|
-
// 1. local_config
|
|
12
|
+
// 1. local_config 检查
|
|
13
13
|
const localCfg = getLocalConfig(wd);
|
|
14
14
|
if (localCfg && localCfg.app_id) {
|
|
15
15
|
checks.local_config = {
|
|
@@ -20,11 +20,11 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
20
20
|
hasWarn = true;
|
|
21
21
|
checks.local_config = {
|
|
22
22
|
status: 'fail',
|
|
23
|
-
message: '
|
|
23
|
+
message: '未在当前目录找到 .shiplens.json 或缺少 app_id',
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// 2. sdk_dependency
|
|
27
|
+
// 2. sdk_dependency 检查
|
|
28
28
|
const sdkCheck = { status: 'pass' };
|
|
29
29
|
const pkgPath = path.join(wd, 'package.json');
|
|
30
30
|
if (fs.existsSync(pkgPath)) {
|
|
@@ -35,27 +35,26 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
35
35
|
sdkCheck.version = deps['@shiplens/sdk'];
|
|
36
36
|
} else {
|
|
37
37
|
sdkCheck.status = 'warn';
|
|
38
|
-
sdkCheck.message = '@shiplens/sdk
|
|
38
|
+
sdkCheck.message = 'package.json 中未显式包含 @shiplens/sdk 依赖';
|
|
39
39
|
hasWarn = true;
|
|
40
40
|
}
|
|
41
41
|
} catch (e) {
|
|
42
42
|
sdkCheck.status = 'warn';
|
|
43
|
-
sdkCheck.message = '
|
|
43
|
+
sdkCheck.message = '无法解析 package.json';
|
|
44
44
|
}
|
|
45
45
|
} else {
|
|
46
46
|
sdkCheck.status = 'warn';
|
|
47
|
-
sdkCheck.message = 'package.json
|
|
47
|
+
sdkCheck.message = '未发现 package.json (可能是纯静态或非 Node 项目)';
|
|
48
48
|
}
|
|
49
49
|
checks.sdk_dependency = sdkCheck;
|
|
50
50
|
|
|
51
|
-
// 3. code_injection
|
|
51
|
+
// 3. code_injection 检查
|
|
52
52
|
const detected = detectProject(wd);
|
|
53
53
|
let injectedFile = '';
|
|
54
54
|
const candidates = [
|
|
55
55
|
'src/app/layout.tsx', 'app/layout.tsx', 'src/app/layout.js', 'app/layout.js',
|
|
56
56
|
'src/pages/_app.tsx', 'pages/_app.tsx', 'src/pages/_app.js', 'pages/_app.js',
|
|
57
57
|
'src/main.tsx', 'src/main.ts', 'src/main.jsx', 'src/main.js',
|
|
58
|
-
'src/index.tsx', 'src/index.ts', 'src/index.jsx', 'src/index.js',
|
|
59
58
|
'src/App.vue', 'index.html', 'public/index.html', 'shiplens.config.ts',
|
|
60
59
|
'src/components/ShiplensTracker.tsx', 'components/ShiplensTracker.tsx',
|
|
61
60
|
];
|
|
@@ -79,34 +78,26 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
79
78
|
hasWarn = true;
|
|
80
79
|
checks.code_injection = {
|
|
81
80
|
status: 'warn',
|
|
82
|
-
message:
|
|
81
|
+
message: `未在常规入口检测到 initShiplens 调用 (框架探测: ${detected.framework})`,
|
|
83
82
|
};
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
// 4. ingestion_connectivity
|
|
85
|
+
// 4. ingestion_connectivity 检查
|
|
87
86
|
try {
|
|
88
|
-
const
|
|
89
|
-
await ctx.client.me();
|
|
87
|
+
const latency = await ctx.client.testConnectivity();
|
|
90
88
|
checks.ingestion_connectivity = {
|
|
91
89
|
status: 'pass',
|
|
92
|
-
latency_ms:
|
|
90
|
+
latency_ms: latency,
|
|
93
91
|
};
|
|
94
92
|
} catch (err) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
} else {
|
|
101
|
-
hasFail = true;
|
|
102
|
-
checks.ingestion_connectivity = {
|
|
103
|
-
status: 'fail',
|
|
104
|
-
message: `API connection failed: ${err.message}`,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
93
|
+
hasFail = true;
|
|
94
|
+
checks.ingestion_connectivity = {
|
|
95
|
+
status: 'fail',
|
|
96
|
+
message: `连接后端 API 失败: ${err.message}`,
|
|
97
|
+
};
|
|
107
98
|
}
|
|
108
99
|
|
|
109
|
-
// 5. auth_credential
|
|
100
|
+
// 5. auth_credential 检查
|
|
110
101
|
if (ctx.resolvedAuth.is_present) {
|
|
111
102
|
checks.auth_credential = {
|
|
112
103
|
status: 'pass',
|
|
@@ -115,11 +106,11 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
115
106
|
} else {
|
|
116
107
|
checks.auth_credential = {
|
|
117
108
|
status: 'warn',
|
|
118
|
-
message: '
|
|
109
|
+
message: '当前未配置 Access Secret (如需使用 projects/sql/dashboards 命令需登录)',
|
|
119
110
|
};
|
|
120
111
|
}
|
|
121
112
|
|
|
122
|
-
// 6. email_binding
|
|
113
|
+
// 6. email_binding 检查(读取本地状态机中的 bound_email 字段)
|
|
123
114
|
if (localCfg && localCfg.bound_email) {
|
|
124
115
|
checks.email_binding = {
|
|
125
116
|
status: 'pass',
|
|
@@ -129,11 +120,11 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
129
120
|
hasWarn = true;
|
|
130
121
|
checks.email_binding = {
|
|
131
122
|
status: 'warn',
|
|
132
|
-
message: '
|
|
123
|
+
message: '未检测到绑定邮箱,统计数据未激活。执行: shiplens auth bind --email <you@example.com>',
|
|
133
124
|
};
|
|
134
125
|
}
|
|
135
126
|
|
|
136
|
-
// 7. schema_freshness
|
|
127
|
+
// 7. schema_freshness 检查(本地 schema_last_modified_at 是否超过 7 天未同步)
|
|
137
128
|
if (localCfg && localCfg.schema_last_modified_at) {
|
|
138
129
|
const ageMs = Date.now() - localCfg.schema_last_modified_at * 1000;
|
|
139
130
|
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
@@ -142,7 +133,7 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
142
133
|
checks.schema_freshness = {
|
|
143
134
|
status: 'warn',
|
|
144
135
|
age_days: Math.floor(ageDays),
|
|
145
|
-
message:
|
|
136
|
+
message: `本地 Schema 已 ${Math.floor(ageDays)} 天未同步,建议执行 shiplens projects list 刷新`,
|
|
146
137
|
};
|
|
147
138
|
} else {
|
|
148
139
|
checks.schema_freshness = {
|
|
@@ -154,7 +145,7 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
154
145
|
} else {
|
|
155
146
|
checks.schema_freshness = {
|
|
156
147
|
status: 'warn',
|
|
157
|
-
message: '
|
|
148
|
+
message: '本地未记录 schema_last_modified_at,请重新执行 shiplens init 或 projects list',
|
|
158
149
|
};
|
|
159
150
|
hasWarn = true;
|
|
160
151
|
}
|
|
@@ -174,7 +165,7 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
174
165
|
};
|
|
175
166
|
|
|
176
167
|
ctx.output(report, () => {
|
|
177
|
-
console.log('🏥 Shiplens
|
|
168
|
+
console.log('🏥 Shiplens 项目环境与链路体检报告');
|
|
178
169
|
console.log('='.repeat(50));
|
|
179
170
|
for (const [name, item] of Object.entries(checks)) {
|
|
180
171
|
let icon = '✅';
|
|
@@ -182,18 +173,18 @@ async function handleDoctor(args, flags, ctx) {
|
|
|
182
173
|
else if (item.status === 'fail') icon = '❌';
|
|
183
174
|
|
|
184
175
|
console.log(`${icon} [${name}]: ${item.status}`);
|
|
185
|
-
if (item.app_id) console.log(`
|
|
186
|
-
if (item.email) console.log(`
|
|
187
|
-
if (item.version) console.log(`
|
|
188
|
-
if (item.file) console.log(`
|
|
189
|
-
if (item.latency_ms !== undefined) console.log(`
|
|
190
|
-
if (item.source) console.log(`
|
|
191
|
-
if (item.age_days !== undefined) console.log(` Schema
|
|
192
|
-
if (item.message) console.log(`
|
|
176
|
+
if (item.app_id) console.log(` app_id: ${item.app_id}`);
|
|
177
|
+
if (item.email) console.log(` 邮箱: ${item.email}`);
|
|
178
|
+
if (item.version) console.log(` 版本: ${item.version}`);
|
|
179
|
+
if (item.file) console.log(` 文件: ${item.file}`);
|
|
180
|
+
if (item.latency_ms !== undefined) console.log(` 网络延迟: ${item.latency_ms} ms`);
|
|
181
|
+
if (item.source) console.log(` 凭据来源: ${item.source}`);
|
|
182
|
+
if (item.age_days !== undefined) console.log(` Schema 距上次同步: ${item.age_days} 天`);
|
|
183
|
+
if (item.message) console.log(` 说明: ${item.message}`);
|
|
193
184
|
}
|
|
194
185
|
console.log('-'.repeat(50));
|
|
195
|
-
console.log(`🩺
|
|
196
|
-
console.log(`⏱
|
|
186
|
+
console.log(`🩺 总体体检结果: ${overall}`);
|
|
187
|
+
console.log(`⏱ 体检时间: ${report.doctor_ran_at}`);
|
|
197
188
|
});
|
|
198
189
|
}
|
|
199
190
|
|