dsh-github-flow 0.2.1 → 0.3.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.en.md +38 -11
- package/README.md +37 -10
- package/docs/UPGRADE-NOTES.md +9 -9
- package/docs/architecture.md +53 -23
- package/docs/images/2.png +0 -0
- package/docs/npm-publish-guide.md +12 -1
- package/lib/api/routes-overview.js +104 -1
- package/lib/client.js +583 -14
- package/lib/executor.d.ts +118 -1
- package/lib/executor.js +357 -0
- package/lib/main.js +1 -0
- package/lib/renderers/markdown.d.ts +6 -0
- package/lib/renderers/markdown.js +109 -2
- package/lib/tools/help.js +132 -11
- package/lib/tools/issue.d.ts +9 -0
- package/lib/tools/issue.js +88 -4
- package/lib/tools/pr.d.ts +4 -0
- package/lib/tools/pr.js +50 -3
- package/lib/tools/repo.d.ts +8 -0
- package/lib/tools/repo.js +127 -3
- package/lib/tools/run.d.ts +8 -0
- package/lib/tools/run.js +104 -3
- package/lib/tools/secret.d.ts +5 -0
- package/lib/tools/secret.js +108 -23
- package/lib/types.d.ts +73 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
package/lib/tools/repo.js
CHANGED
|
@@ -18,8 +18,8 @@ export function createRepoTool(executor) {
|
|
|
18
18
|
properties: {
|
|
19
19
|
action: {
|
|
20
20
|
type: 'string',
|
|
21
|
-
enum: ['view', 'readme', 'search_code', 'search_repos', 'sync'],
|
|
22
|
-
description: '仓库操作类型。readme 读取 README 原文并附带图片清单(可选 image_urls 下载指定图片,不传下载全部截图;默认过滤徽章小图标)。',
|
|
21
|
+
enum: ['view', 'readme', 'search_code', 'search_repos', 'search_issues', 'search_prs', 'search_commits', 'read_file', 'read_dir', 'sync'],
|
|
22
|
+
description: '仓库操作类型。readme 读取 README 原文并附带图片清单(可选 image_urls 下载指定图片,不传下载全部截图;默认过滤徽章小图标)。read_file/read_dir 免 clone 读取任意路径文件与目录(gh ≥2.95,低版本自动降级 API 链路)。',
|
|
23
23
|
},
|
|
24
24
|
repo: {
|
|
25
25
|
type: 'string',
|
|
@@ -36,7 +36,15 @@ export function createRepoTool(executor) {
|
|
|
36
36
|
},
|
|
37
37
|
query: {
|
|
38
38
|
type: 'string',
|
|
39
|
-
description: '检索关键词(search_code
|
|
39
|
+
description: '检索关键词(search_* 动作使用)。限定符直接写进 query(gh 惯例):如 "repo:owner/name state:open label:bug"。注意 search_code/search_commits 必须包含搜索文本,纯限定符查询(如仅 "repo:x")会被 gh 拒绝。',
|
|
40
|
+
},
|
|
41
|
+
path: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'read_file 必填 / read_dir 可选(默认仓库根目录):目标文件或目录路径(如 "src/tools/pr.ts"、".github/workflows")。',
|
|
44
|
+
},
|
|
45
|
+
ref: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'read_file/read_dir 可选:从指定分支/tag/commit 读取(默认默认分支)。',
|
|
40
48
|
},
|
|
41
49
|
limit: {
|
|
42
50
|
type: 'number',
|
|
@@ -165,6 +173,122 @@ export function createRepoTool(executor) {
|
|
|
165
173
|
throw new Error(res.error);
|
|
166
174
|
return normalizeOutput(res.data || res.rawOutput);
|
|
167
175
|
}
|
|
176
|
+
case 'search_issues':
|
|
177
|
+
case 'search_prs': {
|
|
178
|
+
if (!args.query)
|
|
179
|
+
throw new Error(`${args.action} 必须提供 query`);
|
|
180
|
+
// 限定符(repo:/state:/label: 等)直接写进 query 本体(gh search 惯例),
|
|
181
|
+
// 不做独立参数——参数爆炸 + 与 gh 手册心智模型分叉(D3 决策)。
|
|
182
|
+
// prs 比 issues 多 isDraft 字段(实测合法集 18 vs 17)。
|
|
183
|
+
const repoArgs = args.repo ? ['--repo', args.repo] : [];
|
|
184
|
+
const fields = args.action === 'search_prs'
|
|
185
|
+
? 'number,title,state,author,labels,isDraft,updatedAt,url'
|
|
186
|
+
: 'number,title,state,author,labels,updatedAt,url';
|
|
187
|
+
const res = await executor.run(['search', args.action === 'search_prs' ? 'prs' : 'issues', args.query, ...repoArgs, '--json', fields, '-L', limitStr], runOpts);
|
|
188
|
+
if (!res.ok)
|
|
189
|
+
throw new Error(res.error);
|
|
190
|
+
return normalizeOutput(res.data || res.rawOutput);
|
|
191
|
+
}
|
|
192
|
+
case 'search_commits': {
|
|
193
|
+
if (!args.query)
|
|
194
|
+
throw new Error('search_commits 必须提供 query');
|
|
195
|
+
const repoArgs = args.repo ? ['--repo', args.repo] : [];
|
|
196
|
+
const res = await executor.run(['search', 'commits', args.query, ...repoArgs, '--json', 'sha,commit,author,repository,url', '-L', limitStr], runOpts);
|
|
197
|
+
if (!res.ok)
|
|
198
|
+
throw new Error(res.error);
|
|
199
|
+
return normalizeOutput(res.data || res.rawOutput);
|
|
200
|
+
}
|
|
201
|
+
case 'read_file': {
|
|
202
|
+
if (!args.path)
|
|
203
|
+
throw new Error('read_file 必须提供 path(目标文件路径,如 "package.json")');
|
|
204
|
+
let repoTarget = args.repo;
|
|
205
|
+
if (!repoTarget) {
|
|
206
|
+
const meta = await executor.getRepoMetadata(cwd, sessionId);
|
|
207
|
+
if (meta?.nameWithOwner)
|
|
208
|
+
repoTarget = meta.nameWithOwner;
|
|
209
|
+
}
|
|
210
|
+
if (!repoTarget)
|
|
211
|
+
throw new Error('read_file 必须提供 repo [owner/repo],或在关联 Git 远程的工作区运行');
|
|
212
|
+
const [owner, repoName] = repoTarget.split('/');
|
|
213
|
+
if (!owner || !repoName)
|
|
214
|
+
throw new Error(`read_file 需要 [owner/repo] 形式的仓库名(实际: ${repoTarget})`);
|
|
215
|
+
// 主链路走管道直出(无 --json):gh 把原始内容写 stdout,executor 的
|
|
216
|
+
// 24KB 截断与 UTF-8 处理免费复用;--json 模式 content 是 base64,
|
|
217
|
+
// 中段截断即废且需解码 dance,弃用。
|
|
218
|
+
const refArgs = args.ref ? ['--ref', args.ref] : [];
|
|
219
|
+
const res = await executor.runGuarded(['repo', 'read-file', args.path, '-R', repoTarget, ...refArgs], {
|
|
220
|
+
...runOpts,
|
|
221
|
+
rawText: true,
|
|
222
|
+
timeoutMs: 20_000,
|
|
223
|
+
minVersion: '2.95.0',
|
|
224
|
+
capability: 'gh repo read-file',
|
|
225
|
+
// 降级链:contents 端点 raw Accept(readme 同款技术),与主链路输出同构。
|
|
226
|
+
// ref 必须走 URL query 而非 -f:gh api 在带 -f 时会把 GET 隐式改写为
|
|
227
|
+
// POST(api.ts:66 同坑),contents 端点 POST 直接 404。
|
|
228
|
+
fallback: async () => {
|
|
229
|
+
const encoded = args.path.split('/').map(encodeURIComponent).join('/');
|
|
230
|
+
const refQuery = args.ref ? `?ref=${encodeURIComponent(args.ref)}` : '';
|
|
231
|
+
return executor.run(['api', `repos/${owner}/${repoName}/contents/${encoded}${refQuery}`, '-H', 'Accept: application/vnd.github.raw'], { cwd, sessionId, rawText: true, timeoutMs: 20_000 });
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
if (!res.ok)
|
|
235
|
+
throw new Error(res.error);
|
|
236
|
+
const content = res.rawOutput || '';
|
|
237
|
+
// 二进制防线:NUL 字节是文本内容的可靠否定信号(UTF-8 合法文本不含 NUL)。
|
|
238
|
+
// 不做 base64 直返——那会撑爆 Token 预算,引导 github_api binary channel。
|
|
239
|
+
if (content.includes('\0')) {
|
|
240
|
+
throw new Error(`「${args.path}」是二进制文件(检测到 NUL 字节),不适合文本读取。请改用 github_api(endpoint: 'repos/${owner}/${repoName}/contents/${args.path.split('/').map(encodeURIComponent).join('/')}', raw: true, binary 场景自行落盘) 获取字节流。`);
|
|
241
|
+
}
|
|
242
|
+
return { path: args.path, ref: args.ref, content, size: content.length, ...(res.degraded ? { degraded: res.degraded, degradedReason: res.degradedReason } : {}) };
|
|
243
|
+
}
|
|
244
|
+
case 'read_dir': {
|
|
245
|
+
let repoTarget = args.repo;
|
|
246
|
+
if (!repoTarget) {
|
|
247
|
+
const meta = await executor.getRepoMetadata(cwd, sessionId);
|
|
248
|
+
if (meta?.nameWithOwner)
|
|
249
|
+
repoTarget = meta.nameWithOwner;
|
|
250
|
+
}
|
|
251
|
+
if (!repoTarget)
|
|
252
|
+
throw new Error('read_dir 必须提供 repo [owner/repo],或在关联 Git 远程的工作区运行');
|
|
253
|
+
const [owner, repoName] = repoTarget.split('/');
|
|
254
|
+
if (!owner || !repoName)
|
|
255
|
+
throw new Error(`read_dir 需要 [owner/repo] 形式的仓库名(实际: ${repoTarget})`);
|
|
256
|
+
const refArgs = args.ref ? ['--ref', args.ref] : [];
|
|
257
|
+
const dirPath = args.path || '';
|
|
258
|
+
const res = await executor.runGuarded(['repo', 'read-dir', ...(dirPath ? [dirPath] : []), '-R', repoTarget, ...refArgs], {
|
|
259
|
+
...runOpts,
|
|
260
|
+
rawText: true,
|
|
261
|
+
timeoutMs: 20_000,
|
|
262
|
+
minVersion: '2.95.0',
|
|
263
|
+
capability: 'gh repo read-dir',
|
|
264
|
+
// 降级链:contents 端点返回 JSON 数组(dir 为含 type 字段的对象)。
|
|
265
|
+
// ref 同样走 URL query(-f 会把 GET 改写成 POST,见 read_file 注释)
|
|
266
|
+
fallback: async () => {
|
|
267
|
+
const encoded = dirPath.split('/').filter(Boolean).map(encodeURIComponent).join('/');
|
|
268
|
+
const refQuery = args.ref ? `?ref=${encodeURIComponent(args.ref)}` : '';
|
|
269
|
+
return executor.run(['api', `repos/${owner}/${repoName}/contents/${encoded}${refQuery}`], { cwd, sessionId, timeoutMs: 20_000 });
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok)
|
|
273
|
+
throw new Error(res.error);
|
|
274
|
+
// 双链路归一为 items 数组:
|
|
275
|
+
// 主链路是 TSV 四列(type/name/mode/size),降级链 data 是 contents JSON 数组
|
|
276
|
+
let items;
|
|
277
|
+
if (res.degraded && Array.isArray(res.data)) {
|
|
278
|
+
items = res.data.map((e) => ({ type: String(e.type || 'file'), name: String(e.name || ''), mode: '-', size: typeof e.size === 'number' ? e.size : undefined }));
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const tsv = (res.rawOutput || '').trim();
|
|
282
|
+
if (!tsv) {
|
|
283
|
+
return { message: `「${dirPath || '/'}」是空目录或不存在`, items: [], count: 0, ...(res.degraded ? { degraded: res.degraded, degradedReason: res.degradedReason } : {}) };
|
|
284
|
+
}
|
|
285
|
+
items = tsv.split('\n').map((line) => {
|
|
286
|
+
const cols = line.split('\t');
|
|
287
|
+
return { type: cols[0] || 'file', name: cols[1] || '', mode: cols[2] || '-', size: Number(cols[3]) || 0 };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return { path: dirPath || '/', items, count: items.length, ...(res.degraded ? { degraded: res.degraded, degradedReason: res.degradedReason } : {}) };
|
|
291
|
+
}
|
|
168
292
|
case 'sync': {
|
|
169
293
|
let repoTarget = args.repo;
|
|
170
294
|
if (!repoTarget) {
|
package/lib/tools/run.d.ts
CHANGED
|
@@ -35,6 +35,14 @@ export declare function createRunTool(executor: GhExecutor): {
|
|
|
35
35
|
type: string;
|
|
36
36
|
description: string;
|
|
37
37
|
};
|
|
38
|
+
all: {
|
|
39
|
+
type: string;
|
|
40
|
+
description: string;
|
|
41
|
+
};
|
|
42
|
+
name: {
|
|
43
|
+
type: string;
|
|
44
|
+
description: string;
|
|
45
|
+
};
|
|
38
46
|
ref: {
|
|
39
47
|
type: string;
|
|
40
48
|
description: string;
|
package/lib/tools/run.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { compareGhVersions } from '../executor.js';
|
|
1
2
|
import { renderRunMarkdown } from '../renderers/markdown.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
2
6
|
function normalizeOutput(data) {
|
|
3
7
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
4
8
|
return data;
|
|
@@ -17,8 +21,8 @@ export function createRunTool(executor) {
|
|
|
17
21
|
properties: {
|
|
18
22
|
action: {
|
|
19
23
|
type: 'string',
|
|
20
|
-
enum: ['list', 'view', 'log_failed', 'rerun', 'cancel', 'trigger'],
|
|
21
|
-
description: 'Actions
|
|
24
|
+
enum: ['list', 'view', 'log_failed', 'rerun', 'cancel', 'trigger', 'workflow_list', 'workflow_view', 'workflow_enable', 'workflow_disable', 'download'],
|
|
25
|
+
description: 'Actions 操作类型。trigger 主动触发 workflow_dispatch;workflow_* 查询与启停工作流;download 下载构建产物(落盘插件管理目录)。',
|
|
22
26
|
},
|
|
23
27
|
run_id: {
|
|
24
28
|
type: 'string',
|
|
@@ -43,7 +47,15 @@ export function createRunTool(executor) {
|
|
|
43
47
|
},
|
|
44
48
|
workflow: {
|
|
45
49
|
type: 'string',
|
|
46
|
-
description: '
|
|
50
|
+
description: '工作流名称、ID 或文件名(trigger/workflow_view 动作使用,例如 "pr-check.yml"、"CI" 或 "374382120")。',
|
|
51
|
+
},
|
|
52
|
+
all: {
|
|
53
|
+
type: 'boolean',
|
|
54
|
+
description: 'workflow_list 可选:列出全部工作流(含 disabled)。默认仅 active——排查「工作流为何不跑」时 disabled 状态本身就是答案,排障建议开启。',
|
|
55
|
+
},
|
|
56
|
+
name: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'download 可选:仅下载指定名称的 artifact(可多次过滤,这里取单个名称)。',
|
|
47
59
|
},
|
|
48
60
|
ref: {
|
|
49
61
|
type: 'string',
|
|
@@ -132,6 +144,95 @@ export function createRunTool(executor) {
|
|
|
132
144
|
}
|
|
133
145
|
return { result: res.rawOutput || '未找到失败步骤的日志' };
|
|
134
146
|
}
|
|
147
|
+
case 'workflow_list': {
|
|
148
|
+
// 字段集为 gh 2.96 实测合法全集(仅 4 个,无 updatedAt——勘误 E1)。
|
|
149
|
+
// --all 暴露 disabled(排障语义:默认 active-only 对 Agent 是陷阱,D6 决策)。
|
|
150
|
+
const listCmd = ['workflow', 'list', ...repoArgs, '--json', 'id,name,path,state', '-L', String(typeof args.limit === 'number' && args.limit > 0 ? args.limit : Math.min(executor.defaultListLimit ?? 10, 50))];
|
|
151
|
+
if (args.all === true)
|
|
152
|
+
listCmd.push('--all');
|
|
153
|
+
const res = await executor.run(listCmd, runOpts);
|
|
154
|
+
if (!res.ok)
|
|
155
|
+
throw new Error(res.error);
|
|
156
|
+
return normalizeOutput(res.data || res.rawOutput);
|
|
157
|
+
}
|
|
158
|
+
case 'workflow_view': {
|
|
159
|
+
if (!args.workflow)
|
|
160
|
+
throw new Error('workflow_view 必须提供 workflow(工作流名称、ID 或文件名)');
|
|
161
|
+
// gh workflow view 不支持 --json(勘误 E2,实测 unknown flag)——rawText 直通,
|
|
162
|
+
// 仅头部三行结构化抽取;Recent runs 表保留原文(那是 run list 的投影,
|
|
163
|
+
// Agent 要结构化运行数据走 github_run action:'list',工具各司其职,D4 决策)。
|
|
164
|
+
const res = await executor.run(['workflow', 'view', args.workflow, ...repoArgs], { ...runOpts, rawText: true, timeoutMs: 20_000 });
|
|
165
|
+
if (!res.ok)
|
|
166
|
+
throw new Error(res.error);
|
|
167
|
+
const text = res.rawOutput || '';
|
|
168
|
+
const idMatch = /^ID:\s*(\S+)/m.exec(text);
|
|
169
|
+
const totalMatch = /^Total runs\s+(\S+)/m.exec(text);
|
|
170
|
+
// 首行是「名称 - 文件名」组合行(实测形态),整行保留作 workflow 标识
|
|
171
|
+
const firstLine = text.split('\n', 1)[0]?.trim() || args.workflow;
|
|
172
|
+
const recentIdx = text.indexOf('Recent runs');
|
|
173
|
+
return {
|
|
174
|
+
workflow: firstLine,
|
|
175
|
+
id: idMatch ? idMatch[1] : undefined,
|
|
176
|
+
totalRuns: totalMatch ? totalMatch[1] : undefined,
|
|
177
|
+
recentRuns: recentIdx >= 0 ? text.slice(recentIdx).trim() : text.trim(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
case 'workflow_enable':
|
|
181
|
+
case 'workflow_disable': {
|
|
182
|
+
if (!args.workflow)
|
|
183
|
+
throw new Error(`${args.action} 必须提供 workflow(工作流名称、ID 或文件名)`);
|
|
184
|
+
// 可逆状态开关免 confirm(S3 方案 D4),但 disable 的后果(trigger 静默失败)
|
|
185
|
+
// 必须写进返回——Agent 不知情会把 CI 故障误判为代码问题
|
|
186
|
+
const res = await executor.run(['workflow', args.action === 'workflow_enable' ? 'enable' : 'disable', args.workflow, ...repoArgs], runOpts);
|
|
187
|
+
if (!res.ok)
|
|
188
|
+
throw new Error(res.error);
|
|
189
|
+
return args.action === 'workflow_disable'
|
|
190
|
+
? { message: `工作流「${args.workflow}」已禁用。注意:下次 trigger 将静默失败,直至重新 enable(workflow_enable)。` }
|
|
191
|
+
: { message: `工作流「${args.workflow}」已启用,恢复可触发状态。` };
|
|
192
|
+
}
|
|
193
|
+
case 'download': {
|
|
194
|
+
if (!args.run_id)
|
|
195
|
+
throw new Error('download 操作必须提供 run_id');
|
|
196
|
+
if (args.name && typeof args.name !== 'string')
|
|
197
|
+
throw new Error('name 必须是字符串(artifact 名称过滤)');
|
|
198
|
+
// GHSA-39wj-f2f4-978v(gh<2.102 符号链接路径穿越)防线:落盘目录由插件
|
|
199
|
+
// 固定派生(owner/repo/run_id 三级),不暴露 dir 参数给用户——攻击面
|
|
200
|
+
// 收敛到一个 mkdirSync 调用点;用户无法注入 ../ 或符号链接目标。
|
|
201
|
+
const artifactsRoot = path.join(os.homedir(), '.dsh', 'data', 'dsh-github-flow', 'artifacts', ...repoTarget.split('/'), String(args.run_id));
|
|
202
|
+
fs.mkdirSync(artifactsRoot, { recursive: true });
|
|
203
|
+
const dlCmd = ['run', 'download', String(args.run_id), ...repoArgs, '-D', artifactsRoot];
|
|
204
|
+
if (args.name) {
|
|
205
|
+
dlCmd.push('-n', args.name);
|
|
206
|
+
}
|
|
207
|
+
const res = await executor.run(dlCmd, { ...runOpts, timeoutMs: 120_000 });
|
|
208
|
+
if (!res.ok)
|
|
209
|
+
throw new Error(res.error);
|
|
210
|
+
// 落盘清单:递归枚举产物文件(相对路径+大小),供 Agent 直接引用
|
|
211
|
+
const files = [];
|
|
212
|
+
const walk = (dir, prefix) => {
|
|
213
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
214
|
+
const full = path.join(dir, entry.name);
|
|
215
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
216
|
+
if (entry.isDirectory())
|
|
217
|
+
walk(full, rel);
|
|
218
|
+
else
|
|
219
|
+
files.push({ file: rel, size: fs.statSync(full).size });
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
walk(artifactsRoot, '');
|
|
223
|
+
// GHSA-39wj-f2f4-978v:2.102.0 修复符号链接路径穿越。低版本不硬拒绝
|
|
224
|
+
// (受控仓库下载是合理需求),但安全提示必须进返回值引导升级。
|
|
225
|
+
const ghVersion = await executor.getGhVersion();
|
|
226
|
+
const securityNote = ghVersion && compareGhVersions(ghVersion, '2.102.0') < 0
|
|
227
|
+
? `安全提示:当前 gh ${ghVersion} 低于 2.102.0(修复 GHSA-39wj-f2f4-978v 符号链接路径穿越)。建议升级 gh(Windows: winget upgrade GitHub.cli / macOS: brew upgrade gh)。`
|
|
228
|
+
: undefined;
|
|
229
|
+
return {
|
|
230
|
+
message: `Run #${args.run_id} 产物已下载 ${files.length} 个文件${args.name ? `(过滤: ${args.name})` : ''}`,
|
|
231
|
+
directory: artifactsRoot,
|
|
232
|
+
files,
|
|
233
|
+
...(securityNote ? { securityNote } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
135
236
|
case 'trigger': {
|
|
136
237
|
if (!args.workflow)
|
|
137
238
|
throw new Error('trigger 操作必须提供 workflow(工作流名称或文件名,例如 "pr-check.yml")');
|
package/lib/tools/secret.d.ts
CHANGED
package/lib/tools/secret.js
CHANGED
|
@@ -45,17 +45,63 @@ function renderSecretOutput(args, value) {
|
|
|
45
45
|
}
|
|
46
46
|
return JSON.stringify(value, null, 2);
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* 渲染 variable 工具输出——与 renderSecretOutput 物理分离(S3 D1 决策)。
|
|
50
|
+
* 为什么必须独立函数而非同一渲染器的分支:variable 的 value 是 CI 明文(可回显,
|
|
51
|
+
* 这正是 get action 的用途),secret 的 value 永不显示。若共用渲染逻辑再
|
|
52
|
+
* 「选择性放开」,未来任一侧的重构都可能意外污染另一侧的安全假设——
|
|
53
|
+
* 两个函数的边界比一个函数的分支更可审。
|
|
54
|
+
*/
|
|
55
|
+
function renderVariableOutput(args, value) {
|
|
56
|
+
if (!value)
|
|
57
|
+
return '无返回内容';
|
|
58
|
+
if (value.status === 'AWAITING_CONFIRMATION') {
|
|
59
|
+
return [
|
|
60
|
+
`> ### ${value.warning}`,
|
|
61
|
+
`> - **影响评估**: ${value.impact}`,
|
|
62
|
+
`> - **确认指引**: ${value.requiredAction}`,
|
|
63
|
+
].join('\n');
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(value.items) && args?.action === 'list') {
|
|
66
|
+
const list = value.items.filter(Boolean);
|
|
67
|
+
if (list.length === 0)
|
|
68
|
+
return '📭 当前作用域尚未配置任何 Variable。';
|
|
69
|
+
const rows = list.map((v) => {
|
|
70
|
+
const name = `\`${String(v.name || '-')}\``;
|
|
71
|
+
const date = v.createdAt ? String(v.createdAt).slice(0, 10) : '-';
|
|
72
|
+
return `| ${name} | ${date} |`;
|
|
73
|
+
});
|
|
74
|
+
return [
|
|
75
|
+
`**当前已配置 ${list.length} 个 Variable(值为 CI 明文,可经 get 查看):**\n`,
|
|
76
|
+
'| 变量名 | 创建日期 |',
|
|
77
|
+
'| :--- | :--- |',
|
|
78
|
+
...rows,
|
|
79
|
+
].join('\n');
|
|
80
|
+
}
|
|
81
|
+
if (typeof value.result === 'string') {
|
|
82
|
+
return value.result;
|
|
83
|
+
}
|
|
84
|
+
if (typeof value.message === 'string') {
|
|
85
|
+
return `**${value.message}**`;
|
|
86
|
+
}
|
|
87
|
+
return JSON.stringify(value, null, 2);
|
|
88
|
+
}
|
|
48
89
|
export function createSecretTool(executor) {
|
|
49
90
|
return {
|
|
50
91
|
name: 'github_secret',
|
|
51
|
-
description: '管理 GitHub Actions 仓库密钥 (Secrets)
|
|
92
|
+
description: '管理 GitHub Actions 仓库密钥 (Secrets) 与变量 (Variables):查看名称清单、设置/更新/删除值。Secret 密文经标准输入安全传递,绝不出现在命令行参数或输出中;Variable 为 CI 明文,可经 get 查看值。Secret 删除需 confirm: true。',
|
|
52
93
|
parameters: {
|
|
53
94
|
type: 'object',
|
|
54
95
|
properties: {
|
|
55
96
|
action: {
|
|
56
97
|
type: 'string',
|
|
57
|
-
enum: ['list', 'set', 'delete'],
|
|
58
|
-
description: '
|
|
98
|
+
enum: ['list', 'get', 'set', 'delete'],
|
|
99
|
+
description: '操作类型。get 仅 kind=variable 可用(secret 值写入后 GitHub 侧加密存储,物理不可读取)。',
|
|
100
|
+
},
|
|
101
|
+
kind: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
enum: ['secret', 'variable'],
|
|
104
|
+
description: '操作对象类型,默认 secret(向后兼容)。variable 为 Actions 明文变量:set 走 -b 直传、get 可回显值;secret 走 stdin 密文通道且永不回显。',
|
|
59
105
|
},
|
|
60
106
|
repo: {
|
|
61
107
|
type: 'string',
|
|
@@ -63,33 +109,33 @@ export function createSecretTool(executor) {
|
|
|
63
109
|
},
|
|
64
110
|
name: {
|
|
65
111
|
type: 'string',
|
|
66
|
-
description: '
|
|
112
|
+
description: '名称(set/get/delete 动作使用,如 "NPM_TOKEN"、"DEPLOY_ENV")。',
|
|
67
113
|
},
|
|
68
114
|
value: {
|
|
69
115
|
type: 'string',
|
|
70
|
-
description: '
|
|
116
|
+
description: '值内容(set 动作使用)。secret:经子进程标准输入传递(防进程列表窃取);variable:经 -b 参数直传(CI 明文,无 stdin 红线)。',
|
|
71
117
|
},
|
|
72
118
|
org: {
|
|
73
119
|
type: 'string',
|
|
74
|
-
description: '
|
|
120
|
+
description: '组织级作用域的组织名(可选,映射 --org)。',
|
|
75
121
|
},
|
|
76
122
|
env: {
|
|
77
123
|
type: 'string',
|
|
78
|
-
description: '
|
|
124
|
+
description: '环境级作用域的部署环境名(可选,映射 --env)。',
|
|
79
125
|
},
|
|
80
126
|
app: {
|
|
81
127
|
type: 'string',
|
|
82
128
|
enum: ['actions', 'agents', 'codespaces', 'dependabot'],
|
|
83
|
-
description: '
|
|
129
|
+
description: '密钥归属的应用(可选,仅 secret 的 --app,默认 actions;variable 无此维度)。',
|
|
84
130
|
},
|
|
85
131
|
visibility: {
|
|
86
132
|
type: 'string',
|
|
87
133
|
enum: ['all', 'private', 'selected'],
|
|
88
|
-
description: '组织级密钥的可见范围(可选,仅 org 场景有意义)。',
|
|
134
|
+
description: '组织级密钥的可见范围(可选,仅 secret + org 场景有意义)。',
|
|
89
135
|
},
|
|
90
136
|
confirm: {
|
|
91
137
|
type: 'boolean',
|
|
92
|
-
description: '
|
|
138
|
+
description: '删除操作的确认标志。删除不可逆,未传 true 时仅输出影响评估。',
|
|
93
139
|
},
|
|
94
140
|
},
|
|
95
141
|
required: ['action'],
|
|
@@ -97,13 +143,16 @@ export function createSecretTool(executor) {
|
|
|
97
143
|
output: {
|
|
98
144
|
schema: { type: 'object' },
|
|
99
145
|
render(args, value) {
|
|
100
|
-
|
|
146
|
+
// 渲染分流即安全边界:variable 与 secret 永不共享渲染路径(见函数注释)
|
|
147
|
+
const renderer = args?.kind === 'variable' ? renderVariableOutput : renderSecretOutput;
|
|
148
|
+
return [{ type: 'text', text: renderer(args, value) }];
|
|
101
149
|
},
|
|
102
150
|
},
|
|
103
151
|
async execute(args, execContext) {
|
|
104
152
|
const cwd = execContext?.cwd || process.cwd();
|
|
105
153
|
const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
|
|
106
|
-
|
|
154
|
+
const isVariable = args.kind === 'variable';
|
|
155
|
+
// org 作用域挂在组织上,与具体仓库无关:既不需要仓库识别,
|
|
107
156
|
// 也不能把 -R 与 --org 同时传给 gh(两者语义冲突,gh 会报错)。
|
|
108
157
|
const isOrgScope = Boolean(args.org);
|
|
109
158
|
let repoTarget = args.repo;
|
|
@@ -114,34 +163,67 @@ export function createSecretTool(executor) {
|
|
|
114
163
|
}
|
|
115
164
|
}
|
|
116
165
|
if (!repoTarget && !isOrgScope) {
|
|
117
|
-
throw new Error('未能自动识别当前工作区关联的 GitHub 仓库。请通过 `repo` 参数显式指定目标仓库 [owner/repo],或改用 `org`
|
|
166
|
+
throw new Error('未能自动识别当前工作区关联的 GitHub 仓库。请通过 `repo` 参数显式指定目标仓库 [owner/repo],或改用 `org` 参数操作组织级作用域。');
|
|
167
|
+
}
|
|
168
|
+
// app 限定符仅 secret 域存在(variable 命令无 --app);误传时明确报错而非静默忽略
|
|
169
|
+
if (isVariable && args.app) {
|
|
170
|
+
throw new Error("variable 无 --app 维度(app 仅 secret 域支持),请移除 app 参数。");
|
|
171
|
+
}
|
|
172
|
+
if (isVariable && args.visibility) {
|
|
173
|
+
throw new Error("variable 无 --visibility 维度(visibility 仅组织级 secret 支持),请移除 visibility 参数。");
|
|
118
174
|
}
|
|
119
175
|
const runOpts = { cwd, sessionId };
|
|
120
176
|
// org/env/app 三类作用域标志对所有动作通用;仓库作用域时三项均不追加
|
|
121
177
|
const scopeFlags = [];
|
|
122
178
|
if (args.org) {
|
|
123
179
|
scopeFlags.push('--org', args.org);
|
|
124
|
-
if (args.visibility)
|
|
180
|
+
if (!isVariable && args.visibility)
|
|
125
181
|
scopeFlags.push('--visibility', args.visibility);
|
|
126
182
|
}
|
|
127
183
|
else if (args.env) {
|
|
128
184
|
scopeFlags.push('--env', args.env);
|
|
129
185
|
}
|
|
130
|
-
if (args.app)
|
|
186
|
+
if (!isVariable && args.app)
|
|
131
187
|
scopeFlags.push('--app', args.app);
|
|
132
188
|
const targetArgs = isOrgScope ? [] : ['-R', repoTarget];
|
|
189
|
+
// gh 命令域分流:secret → gh secret …;variable → gh variable …
|
|
190
|
+
// (两级分流:域前缀 + 每个分支内的行为差异——stdin vs -b、get 可用性)
|
|
191
|
+
const domain = isVariable ? 'variable' : 'secret';
|
|
133
192
|
switch (args.action) {
|
|
134
193
|
case 'list': {
|
|
135
|
-
|
|
194
|
+
// 字段集各自实测钉死:secret 有 updatedAt/visibility;variable 仅 4 字段
|
|
195
|
+
// (createdAt,name,numSelectedRepos,selectedReposURL——无 value,get 才返回)
|
|
196
|
+
const fields = isVariable ? 'name,createdAt,numSelectedRepos' : 'name,updatedAt,visibility';
|
|
197
|
+
const res = await executor.run([domain, 'list', ...targetArgs, ...scopeFlags, '--json', fields], runOpts);
|
|
136
198
|
if (!res.ok)
|
|
137
199
|
throw new Error(res.error);
|
|
138
200
|
return normalizeOutput(res.data || res.rawOutput);
|
|
139
201
|
}
|
|
202
|
+
case 'get': {
|
|
203
|
+
if (!args.name)
|
|
204
|
+
throw new Error('get 操作必须提供 name');
|
|
205
|
+
if (!isVariable) {
|
|
206
|
+
throw new Error('secret 的值写入后由 GitHub 侧加密存储,物理上不可读取(这正是 secret 与 variable 的设计区别)。请改用 kind: \'variable\' 或直接 set 覆盖。');
|
|
207
|
+
}
|
|
208
|
+
// gh variable get 输出变量值(纯文本,无 --json)
|
|
209
|
+
const res = await executor.run(['variable', 'get', args.name, ...targetArgs, ...scopeFlags], { ...runOpts, rawText: true });
|
|
210
|
+
if (!res.ok)
|
|
211
|
+
throw new Error(res.error);
|
|
212
|
+
return { name: args.name, value: res.rawOutput || '' };
|
|
213
|
+
}
|
|
140
214
|
case 'set': {
|
|
141
215
|
if (!args.name)
|
|
142
|
-
throw new Error('set 操作必须提供 name
|
|
216
|
+
throw new Error('set 操作必须提供 name');
|
|
143
217
|
if (typeof args.value !== 'string' || args.value.length === 0) {
|
|
144
|
-
throw new Error('set 操作必须提供 value
|
|
218
|
+
throw new Error('set 操作必须提供 value,且不允许为空字符串');
|
|
219
|
+
}
|
|
220
|
+
if (isVariable) {
|
|
221
|
+
// variable 是 CI 明文(workflow 中 env 直接引用可见),无 secret 的
|
|
222
|
+
// stdin 红线;gh variable set 原生支持 -b 直传(实测 help)。
|
|
223
|
+
const res = await executor.run(['variable', 'set', args.name, '--body', args.value, ...targetArgs, ...scopeFlags], runOpts);
|
|
224
|
+
if (!res.ok)
|
|
225
|
+
throw new Error(res.error);
|
|
226
|
+
return { message: `Variable「${args.name}」已更新(值: ${args.value})` };
|
|
145
227
|
}
|
|
146
228
|
// 安全红线:密文只走 stdin。严禁退化为 -b <value> —— 那会把明文
|
|
147
229
|
// 留在进程列表与 shell 历史里,等于密钥泄漏。gh 本地会用仓库公钥
|
|
@@ -154,21 +236,24 @@ export function createSecretTool(executor) {
|
|
|
154
236
|
case 'delete': {
|
|
155
237
|
if (!args.name)
|
|
156
238
|
throw new Error('delete 操作必须提供 name');
|
|
239
|
+
const kindLabel = isVariable ? 'Variable' : 'Secret';
|
|
157
240
|
if (args.confirm !== true) {
|
|
158
241
|
return {
|
|
159
242
|
status: 'AWAITING_CONFIRMATION',
|
|
160
|
-
warning: `⚠️ 高危操作拦截:你即将删除
|
|
161
|
-
impact:
|
|
243
|
+
warning: `⚠️ 高危操作拦截:你即将删除 ${kindLabel}「${args.name}」${args.org ? `(组织 ${args.org})` : ''}。`,
|
|
244
|
+
impact: isVariable
|
|
245
|
+
? '删除后所有引用该变量的工作流将取不到值,运行中的构建立即失败。'
|
|
246
|
+
: '删除后所有引用该密钥的 CI/CD 工作流将立即无法取到值,运行中的构建会失败。',
|
|
162
247
|
requiredAction: '如确认删除,请向用户核实并附加 `confirm: true` 重新调用。',
|
|
163
248
|
};
|
|
164
249
|
}
|
|
165
|
-
const res = await executor.run([
|
|
250
|
+
const res = await executor.run([domain, 'delete', args.name, ...targetArgs, ...scopeFlags], runOpts);
|
|
166
251
|
if (!res.ok)
|
|
167
252
|
throw new Error(res.error);
|
|
168
|
-
return { message:
|
|
253
|
+
return { message: `${kindLabel}「${args.name}」已删除` };
|
|
169
254
|
}
|
|
170
255
|
default:
|
|
171
|
-
throw new Error(`未知的 Secret 操作: ${args.action}
|
|
256
|
+
throw new Error(`未知的 Secret/Variable 操作: ${args.action}(kind: ${args.kind || 'secret'})`);
|
|
172
257
|
}
|
|
173
258
|
},
|
|
174
259
|
};
|
package/lib/types.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface PluginConfig {
|
|
|
24
24
|
updateCheckIntervalMs?: number;
|
|
25
25
|
/** 自定义 npm Registry 镜像地址,留空则自动选用 npmmirror 与 npmjs */
|
|
26
26
|
registryUrl?: string;
|
|
27
|
+
/**
|
|
28
|
+
* 是否向 gh 子进程注入 AI_AGENT='dsh' 环境变量,默认 true。
|
|
29
|
+
* gh ≥2.99 检测该变量后切换 agent 专属行为(text-only spinner、误用时打印完整 help);
|
|
30
|
+
* 关闭后恢复 gh 原生行为(用于对照实验或偏好原生输出的场景)。
|
|
31
|
+
*/
|
|
32
|
+
agentEnvInjection?: boolean;
|
|
27
33
|
/**
|
|
28
34
|
* 代理地址预置默认值(如 "http://127.0.0.1:7890")。
|
|
29
35
|
* 仅在用户未在驾驶舱保存过代理设置时生效;运行时 UI 保存的值优先级更高。
|
|
@@ -96,6 +102,52 @@ export interface RepoMetadata {
|
|
|
96
102
|
isPrivate?: boolean;
|
|
97
103
|
url?: string;
|
|
98
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* 本地 Git 工作区状态快照(ahead/behind 是 remote-tracking ref 的「上次 fetch」视图,
|
|
107
|
+
* 非实时网络状态——UI 渲染时必须带上这一语义提示,否则会误导用户)。
|
|
108
|
+
*/
|
|
109
|
+
export interface GitLocalStatus {
|
|
110
|
+
branch: string;
|
|
111
|
+
/** 领先远程的提交数;无 upstream 时为 -1(未知) */
|
|
112
|
+
ahead: number;
|
|
113
|
+
/** 落后远程的提交数;无 upstream 时为 -1(未知) */
|
|
114
|
+
behind: number;
|
|
115
|
+
/** 未提交变更文件数(含 untracked) */
|
|
116
|
+
dirtyCount: number;
|
|
117
|
+
hasUpstream: boolean;
|
|
118
|
+
/** 未提交文件清单(porcelain 解析,截断前 20 条防快照膨胀) */
|
|
119
|
+
dirtyFiles: Array<{
|
|
120
|
+
status: string;
|
|
121
|
+
path: string;
|
|
122
|
+
}>;
|
|
123
|
+
}
|
|
124
|
+
/** 单条提交记录(结构化解析) */
|
|
125
|
+
export interface GitCommitEntry {
|
|
126
|
+
sha: string;
|
|
127
|
+
fullSha?: string;
|
|
128
|
+
author: string;
|
|
129
|
+
email?: string;
|
|
130
|
+
date?: string;
|
|
131
|
+
/** git 的相对时间原文(如 "2 hours ago")——本地化由渲染层处理 */
|
|
132
|
+
relative: string;
|
|
133
|
+
subject: string;
|
|
134
|
+
body?: string;
|
|
135
|
+
stat?: string;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 本地 Git 提交记录三段视图(与 GitLocalStatus 的 ahead/behind 计数同口径,
|
|
139
|
+
* 均为 remote-tracking 快照)。
|
|
140
|
+
*/
|
|
141
|
+
export interface GitCommitLog {
|
|
142
|
+
/** 本地领先远程的提交(待 push) */
|
|
143
|
+
aheadCommits: GitCommitEntry[];
|
|
144
|
+
/** 远程领先本地的提交(待 pull,快照口径) */
|
|
145
|
+
behindCommits: GitCommitEntry[];
|
|
146
|
+
/** 最近提交(默认 15 条,支持下滚分页) */
|
|
147
|
+
recent: GitCommitEntry[];
|
|
148
|
+
/** 仓库总提交数(rev-list --count HEAD) */
|
|
149
|
+
totalCount?: number;
|
|
150
|
+
}
|
|
99
151
|
export interface PrItem {
|
|
100
152
|
number: number;
|
|
101
153
|
title: string;
|
|
@@ -170,6 +222,25 @@ export interface GitHubOverviewData {
|
|
|
170
222
|
error?: string;
|
|
171
223
|
/** 插件版本与升级信息(由内存缓存快速读取,不阻塞首屏业务) */
|
|
172
224
|
updateInfo?: UpdateInfo;
|
|
225
|
+
/** 本地 Git 状态(ahead/behind/dirty,remote-tracking 快照语义);非 Git 工作区为 undefined */
|
|
226
|
+
gitStatus?: GitLocalStatus | null;
|
|
227
|
+
/** 提交记录三段(本地领先/远程领先/最近);探测失败为 undefined */
|
|
228
|
+
commitLog?: GitCommitLog | null;
|
|
229
|
+
/** 最新 Release 锚定(已过滤 Draft);无 Release 仓库为 null */
|
|
230
|
+
releaseInfo?: {
|
|
231
|
+
tagName: string;
|
|
232
|
+
isPrerelease?: boolean;
|
|
233
|
+
publishedAt?: string;
|
|
234
|
+
url?: string;
|
|
235
|
+
} | null;
|
|
236
|
+
/** 当前分支关联的 PR;无 PR 分支为 null */
|
|
237
|
+
branchPr?: {
|
|
238
|
+
number: number;
|
|
239
|
+
title: string;
|
|
240
|
+
state?: string;
|
|
241
|
+
url?: string;
|
|
242
|
+
isDraft?: boolean;
|
|
243
|
+
} | null;
|
|
173
244
|
}
|
|
174
245
|
export interface UserRepoItem {
|
|
175
246
|
name: string;
|
|
@@ -188,6 +259,8 @@ export interface WorkspaceMatrixItem {
|
|
|
188
259
|
hasGit: boolean;
|
|
189
260
|
repo?: RepoMetadata;
|
|
190
261
|
sessionCount: number;
|
|
262
|
+
/** 本地 Git 状态摘要(可选——探测失败/非 Git 工作区不携带) */
|
|
263
|
+
gitStatus?: GitLocalStatus;
|
|
191
264
|
}
|
|
192
265
|
export interface GlobalIssueItem {
|
|
193
266
|
number: number;
|