dsh-api-runner 0.0.0-stage → 0.2.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.
@@ -0,0 +1,9 @@
1
+ # dsh-api-runner bundle patch(dsh.bundle.patch:顶层数组 loader patch entries)
2
+ # 安装:dsh plugin --profile desktop add "link:/绝对路径/到/dsh-api-runner"
3
+ #
4
+ # name 写包名时由 DSH 从安装目录解析入口,这是发布形态(提交到仓库、别人装得上的就是这一份)。
5
+ # 开发期改源码后必须让入口 URL 变化(Node 按模块 URL 缓存 ESM),此时跑 `node scripts/stage.mjs`
6
+ # 会把下面这行改成本机 build/vN/main.js 的 file URL;提交前跑 `node scripts/stage.mjs --release` 改回来。
7
+ - insert:
8
+ - id: api-runner
9
+ name: dsh-api-runner
Binary file
Binary file
Binary file
@@ -0,0 +1,92 @@
1
+ # dsh-api-runner 配置示例
2
+ # 位置:${DSH_HOME}/api-runner.yml(全局),或 <项目>/.dsh/api-runner.yml(项目级,覆盖全局)
3
+ # 改完保存即生效,不需要重启 DSH。
4
+ #
5
+ # 引用环境变量:${SOME_ENV}(在 url/headers/command 里都可以用)
6
+ # 调用期占位符:{name}(模型调接口时通过 params 传入,会替换 URL 与 body 里的同名占位)
7
+
8
+ defaults:
9
+ timeoutMs: 30000
10
+
11
+ env:
12
+ # 可以在这里定义命名常量,例如:
13
+ # API_HOST: http://127.0.0.1:8080
14
+ # TOKEN: ${DEV_TOKEN}
15
+
16
+ apis:
17
+ # 最小配置
18
+ - id: health
19
+ name: 健康检查
20
+ method: GET
21
+ url: http://127.0.0.1:8080/actuator/health
22
+
23
+ # 路径占位 + query 参数
24
+ - id: get-user
25
+ name: 查询用户
26
+ method: GET
27
+ url: http://127.0.0.1:8080/api/users/{id}
28
+ headers:
29
+ Authorization: "Bearer ${DEV_TOKEN}"
30
+ query:
31
+ verbose: "true"
32
+
33
+ # POST JSON:body 里的 {name} 由模型传参替换
34
+ - id: create-order
35
+ name: 创建订单
36
+ method: POST
37
+ url: http://127.0.0.1:8080/api/orders
38
+ headers:
39
+ Content-Type: application/json
40
+ Authorization: "Bearer ${DEV_TOKEN}"
41
+ body:
42
+ userId: "{userId}"
43
+ amount: "{amount}"
44
+ remark: "{remark}"
45
+
46
+ # POST 表单
47
+ - id: login
48
+ name: 登录
49
+ method: POST
50
+ url: http://127.0.0.1:8080/api/login
51
+ headers:
52
+ Content-Type: application/x-www-form-urlencoded
53
+ body:
54
+ username: "{username}"
55
+ password: "{password}"
56
+
57
+ services:
58
+ # managed:插件托管进程(启动/停止/重启都由插件做,DSH 重启后服务仍在跑)
59
+ - id: app
60
+ name: 本地后端
61
+ mode: managed
62
+ cwd: ./ # 相对配置文件所在目录解析
63
+ # Windows 下 .cmd/.bat 会自动用 shell 启动
64
+ command: ./mvnw.cmd
65
+ args: ["spring-boot:run"]
66
+ env:
67
+ SPRING_PROFILES_ACTIVE: dev
68
+ logPath: ./logs/app.log # 子进程 stdout/stderr 追加到这里
69
+ startupTimeoutMs: 90000 # 等健康检查通过的最长时间
70
+ stopTimeoutMs: 15000
71
+ health:
72
+ url: http://127.0.0.1:8080/actuator/health
73
+ expectStatus: 200
74
+ intervalMs: 1000
75
+ timeoutMs: 2000
76
+
77
+ # observe:进程由 IDEA 启动(用 IDEA 的 restart 按钮),插件只观测
78
+ - id: idea-app
79
+ name: IDEA 里跑的 Spring Boot
80
+ mode: observe
81
+ health:
82
+ url: http://127.0.0.1:8080/actuator/health
83
+ expectStatus: 200
84
+ ports: [8080]
85
+
86
+ logs:
87
+ - id: app-log
88
+ name: 后端日志
89
+ path: ./logs/app.log
90
+ - id: idea-console
91
+ name: IDEA 控制台重定向文件(若你在 Run Configuration 里配了)
92
+ path: ./logs/idea-console.log
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
- {
2
- "name": "dsh-api-runner",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "dsh-api-runner",
3
+ "version": "0.2.0",
4
+ "description": "DSH 插件:按配置调用后端 HTTP 接口、启停/重启本地开发服务、增量读取日志并等待日志特征,让模型能\"改完自己验\"。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Moz (lhhzmz229)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/lhhzmz229/dsh-api-runner.git"
11
+ },
12
+ "homepage": "https://github.com/lhhzmz229/dsh-api-runner#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/lhhzmz229/dsh-api-runner/issues"
15
+ },
16
+ "keywords": [
17
+ "dsh",
18
+ "dsh-plugin",
19
+ "deepseek-harness",
20
+ "api-runner",
21
+ "http-client",
22
+ "log-tail",
23
+ "spring-boot",
24
+ "java",
25
+ "devtools"
26
+ ],
27
+ "main": "src/main.js",
28
+ "exports": {
29
+ ".": "./src/main.js",
30
+ "./runner": "./src/main.js",
31
+ "./client": "./client/client.js",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "engines": {
35
+ "node": ">=20",
36
+ "dsh": ">=0.1.0-rc.6 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0"
37
+ },
38
+ "scripts": {
39
+ "test": "node test/smoke.mjs && node test/plugin.mjs",
40
+ "test:all": "node test/smoke.mjs && node test/plugin.mjs && node test/client.mjs && node test/managed.mjs && node test/java.mjs",
41
+ "stage": "node scripts/stage.mjs",
42
+ "release": "node scripts/stage.mjs --release"
43
+ },
44
+ "dependencies": {
45
+ "yaml": "^2.8.0"
46
+ },
47
+ "dsh": {
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ },
51
+ "client": {
52
+ "platform": "web",
53
+ "inject": [
54
+ "@deepseek-ai/dsh-client-ui-layout",
55
+ "@deepseek-ai/dsh-client-ui-sidebar",
56
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
57
+ ]
58
+ }
59
+ },
60
+ "files": [
61
+ "cordis.patch.yml",
62
+ "src",
63
+ "client",
64
+ "docs",
65
+ "scripts",
66
+ "test",
67
+ "README.md",
68
+ "LICENSE",
69
+ "dsh-api-runner.example.yml"
70
+ ]
71
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stage.mjs —— 把 src/ 打包到一个全新的 build/v<序号>/ 并用它改写 cordis.patch.yml 的入口。
4
+ *
5
+ * 为什么需要它:DSH 的插件 loader 用 ESM 动态 import,Node 按 URL 缓存模块。
6
+ * 同一个页面进程里 toggle bundle 只会重跑 apply,不会重新 import 模块,
7
+ * 所以改了 src/ 之后必须让入口 URL 变化,否则继续跑旧代码。
8
+ *
9
+ * 用法:
10
+ * node scripts/stage.mjs # 递增版本号,自动改写 patch
11
+ * node scripts/stage.mjs --clean # 只保留最新一版 build
12
+ * node scripts/stage.mjs --release # 把 patch 的入口改回包名(提交/发布用)
13
+ * 之后到 DSH 里把 dsh-api-runner 这个 bundle 关掉再打开。
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath, pathToFileURL } from 'node:url';
18
+
19
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const srcDir = path.join(root, 'src');
21
+ const buildDir = path.join(root, 'build');
22
+ const PACKAGE_NAME = 'dsh-api-runner';
23
+ const patchPath = path.join(root, 'cordis.patch.yml');
24
+
25
+ /** 把 patch 里的入口行改写成给定的 name,返回是否改动过 */
26
+ function rewriteEntry(name) {
27
+ const patch = fs.readFileSync(patchPath, 'utf8');
28
+ const rewritten = patch.replace(/^(\s+name:\s*).*$/m, `$1${name}`);
29
+ if (rewritten === patch && !patch.includes(name)) {
30
+ console.error('cordis.patch.yml 里没找到 name 行,未改写');
31
+ process.exit(1);
32
+ }
33
+ if (rewritten !== patch) fs.writeFileSync(patchPath, rewritten, 'utf8');
34
+ return rewritten !== patch;
35
+ }
36
+
37
+ // 发布形态:入口写包名,DSH 从安装目录解析;提交到仓库前跑这个
38
+ if (process.argv.includes('--release')) {
39
+ rewriteEntry(PACKAGE_NAME);
40
+ console.log(`cordis.patch.yml 入口 → ${PACKAGE_NAME}(发布形态,不含本机路径)`);
41
+ process.exit(0);
42
+ }
43
+
44
+ function nextVersion() {
45
+ if (!fs.existsSync(buildDir)) return 1;
46
+ const used = fs.readdirSync(buildDir)
47
+ .map((name) => /^v(\d+)$/.exec(name))
48
+ .filter(Boolean)
49
+ .map((m) => Number(m[1]));
50
+ return used.length ? Math.max(...used) + 1 : 1;
51
+ }
52
+
53
+ const version = `v${nextVersion()}`;
54
+ const outDir = path.join(buildDir, version);
55
+ fs.mkdirSync(outDir, { recursive: true });
56
+
57
+ const files = fs.readdirSync(srcDir).filter((name) => name.endsWith('.js'));
58
+ for (const name of files) fs.copyFileSync(path.join(srcDir, name), path.join(outDir, name));
59
+
60
+ // 入口统一叫 main.js(与 src/main.js 对齐),loader 只需一个入口 URL。
61
+ const entry = path.join(outDir, 'main.js');
62
+ if (!fs.existsSync(entry)) {
63
+ console.error(`src/main.js 不存在,无法确定插件入口`);
64
+ process.exit(1);
65
+ }
66
+
67
+ const entryUrl = pathToFileURL(entry).href;
68
+ rewriteEntry(entryUrl);
69
+
70
+ if (process.argv.includes('--clean')) {
71
+ for (const name of fs.readdirSync(buildDir)) {
72
+ if (name !== version) fs.rmSync(path.join(buildDir, name), { recursive: true, force: true });
73
+ }
74
+ }
75
+
76
+ console.log(`已发布 ${files.length} 个文件 → ${outDir}`);
77
+ console.log(`cordis.patch.yml 入口 → ${entryUrl}`);
78
+ console.log('下一步:在 DSH 里把 dsh-api-runner bundle 关掉再打开(set_bundle false → true)');
package/src/api.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 接口调用:按配置里的接口定义发 HTTP 请求。
3
+ *
4
+ * 约定:
5
+ * - url 支持 {param} 路径占位,params 里的键会同时用于路径替换与 query 拼接;
6
+ * - body 支持 {param} 模板展开;若 body 是对象且定义了 json 类型,序列化为 JSON;
7
+ * - 超时用 AbortSignal.timeout,失败统一返回结构化结果而不是抛异常(工具层好渲染)。
8
+ */
9
+ import { expandBraces, expandDeep, isPlainObject, truncateText, tryJson } from './util.js';
10
+
11
+ const DEFAULT_TIMEOUT = 30_000;
12
+
13
+ /** 把 params 里未被 url 占位消耗的键拼成 query string */
14
+ function buildQuery(query, params, url) {
15
+ const search = new URLSearchParams();
16
+ for (const [key, value] of Object.entries(query || {})) {
17
+ if (value === undefined || value === null) continue;
18
+ search.append(key, String(expandBraces(value, params)));
19
+ }
20
+ for (const [key, value] of Object.entries(params || {})) {
21
+ if (value === undefined || value === null) continue;
22
+ // url 占位已用的键不再重复进 query
23
+ if (url.includes(`{${key}}`)) continue;
24
+ if (key in (query || {})) continue;
25
+ search.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
26
+ }
27
+ const text = search.toString();
28
+ return text === '' ? '' : `?${text}`;
29
+ }
30
+
31
+ /** 拼接完整 URL:baseUrl + url(+ query),并对展开后的地址做合法性校验 */
32
+ function buildUrl(api, params) {
33
+ let target = expandBraces(api.url, params);
34
+ if (api.baseUrl) {
35
+ const base = api.baseUrl.endsWith('/') ? api.baseUrl.slice(0, -1) : api.baseUrl;
36
+ if (!/^https?:\/\//i.test(target)) target = `${base}/${target.replace(/^\//, '')}`;
37
+ }
38
+ const queryText = buildQuery(api.query, params, api.url).replace(/^\?/, '');
39
+ let normalized = target;
40
+ if (queryText !== '') normalized = `${target}${target.includes('?') ? '&' : '?'}${queryText}`;
41
+ try {
42
+ const parsed = new URL(normalized);
43
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
44
+ throw new Error(`不支持的协议 ${parsed.protocol}`);
45
+ }
46
+ return parsed.toString();
47
+ } catch (error) {
48
+ throw new Error(`接口 ${api.id} 的 URL 非法:${normalized}(${error.message})`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 依据 method 决定是否带 body,并按 content-type 处理。
54
+ *
55
+ * 注意:headers 是**真实要发出去的**请求头对象,这里必须原地补 content-type。
56
+ * 早期实现内部又拷贝了一份,导致 JSON body 实际以 fetch 默认的
57
+ * `text/plain;charset=UTF-8` 发出,Spring 端直接 500:
58
+ * `Content type 'text/plain;charset=UTF-8' not supported`。
59
+ */
60
+ function buildBody(api, params, headers) {
61
+ const method = api.method;
62
+ if (method === 'GET' || method === 'HEAD') return undefined;
63
+ if (api.body === undefined || api.body === null) return undefined;
64
+
65
+ const expanded = expandDeep(api.body, {}, params);
66
+ const contentType = findHeader(headers, 'content-type');
67
+ if (typeof expanded === 'string') {
68
+ // body 是字符串时,如果内容看起来是 JSON 就按 JSON 发;否则明确用 text/plain。
69
+ // 两者都必须**显式**设置:fetch 在没设 content-type 时默认 text/plain,
70
+ // 对 Spring 的 @RequestBody JSON 接口会直接 500。
71
+ if (!contentType) {
72
+ setHeader(headers, 'content-type', tryJson(expanded) !== undefined
73
+ ? 'application/json; charset=utf-8'
74
+ : 'text/plain; charset=utf-8');
75
+ }
76
+ return expanded;
77
+ }
78
+ if (contentType && /x-www-form-urlencoded/i.test(contentType)) {
79
+ return new URLSearchParams(flattenPairs(expanded)).toString();
80
+ }
81
+ if (!contentType) setHeader(headers, 'content-type', 'application/json; charset=utf-8');
82
+ return JSON.stringify(expanded);
83
+ }
84
+
85
+ function flattenPairs(value) {
86
+ const out = [];
87
+ for (const [key, item] of Object.entries(value || {})) {
88
+ if (item === undefined || item === null) continue;
89
+ out.push([key, typeof item === 'object' ? JSON.stringify(item) : String(item)]);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function normalizeHeaders(headers) {
95
+ const out = {};
96
+ for (const [key, value] of Object.entries(headers || {})) {
97
+ if (value === undefined || value === null) continue;
98
+ out[key] = String(value);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function findHeader(headers, name) {
104
+ const key = Object.keys(headers).find((item) => item.toLowerCase() === name.toLowerCase());
105
+ return key ? headers[key] : undefined;
106
+ }
107
+
108
+ function setHeader(headers, name, value) {
109
+ const existing = Object.keys(headers).find((item) => item.toLowerCase() === name.toLowerCase());
110
+ if (existing) headers[existing] = value;
111
+ else headers[name] = value;
112
+ }
113
+
114
+ /**
115
+ * 执行一次接口调用。
116
+ * @returns {Promise<{ok:boolean,apiId:string,method:string,url:string,status:number,statusText:string,
117
+ * durationMs:number,headers:Record<string,string>,body:string,json?:unknown,error?:string}>}
118
+ */
119
+ export async function callApi(api, params = {}, options = {}) {
120
+ const started = Date.now();
121
+ const timeoutMs = Number(options.timeoutMs || api.timeoutMs || DEFAULT_TIMEOUT);
122
+ const result = {
123
+ ok: false,
124
+ apiId: api.id,
125
+ name: api.name,
126
+ method: api.method,
127
+ url: '',
128
+ status: 0,
129
+ statusText: '',
130
+ durationMs: 0,
131
+ headers: {},
132
+ body: '',
133
+ };
134
+
135
+ let url;
136
+ let headers;
137
+ let body;
138
+ try {
139
+ url = buildUrl(api, params);
140
+ headers = normalizeHeaders(expandDeep(api.headers || {}, {}, params));
141
+ body = buildBody(api, params, headers);
142
+ } catch (error) {
143
+ result.error = error.message;
144
+ result.durationMs = Date.now() - started;
145
+ return result;
146
+ }
147
+ result.url = url;
148
+
149
+ if (api.baseUrl) setHeader(headers, 'accept', findHeader(headers, 'accept') || 'application/json, text/plain, */*');
150
+
151
+ try {
152
+ const response = await fetch(url, {
153
+ method: api.method,
154
+ headers,
155
+ body,
156
+ signal: AbortSignal.timeout(timeoutMs),
157
+ redirect: 'follow',
158
+ });
159
+ result.status = response.status;
160
+ result.statusText = response.statusText;
161
+ result.ok = response.ok;
162
+ for (const [key, value] of response.headers.entries()) result.headers[key] = value;
163
+ const text = await response.text();
164
+ const maxBytes = Number(options.maxBodyBytes || 32 * 1024);
165
+ result.body = truncateText(text, maxBytes);
166
+ const parsed = tryJson(text);
167
+ if (parsed !== undefined) result.json = parsed;
168
+ if (text.length > (result.body || '').length) result.truncated = true;
169
+ } catch (error) {
170
+ result.error = error.name === 'TimeoutError'
171
+ ? `请求超时(${timeoutMs}ms)`
172
+ : String(error?.message || error);
173
+ }
174
+ result.durationMs = Date.now() - started;
175
+ return result;
176
+ }
177
+
178
+ /** 从响应 JSON 里按简单路径取值(a.b[0].c),用于校验 */
179
+ export function pickPath(value, dotted) {
180
+ if (!dotted || !isPlainObject(Object(value)) && !Array.isArray(value)) return undefined;
181
+ let cursor = value;
182
+ for (const raw of String(dotted).split('.')) {
183
+ if (cursor === undefined || cursor === null) return undefined;
184
+ const match = /^([^[\]]*)((\[\d+\])*)$/.exec(raw);
185
+ if (!match) return undefined;
186
+ const [, head, indexes] = match;
187
+ if (head) cursor = cursor[head];
188
+ for (const idx of indexes.match(/\d+/g) || []) {
189
+ if (!Array.isArray(cursor)) return undefined;
190
+ cursor = cursor[Number(idx)];
191
+ }
192
+ }
193
+ return cursor;
194
+ }