dsh-api-runner 0.2.0 → 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.
@@ -1,334 +1,350 @@
1
- /**
2
- * 图形界面维护的配置存储(与 YAML 配置分开)。
3
- *
4
- * YAML 配置是「手写种子」,本文件是「面板里点出来的配置」,两者按 id 合并,
5
- * 面板里的同 id 定义覆盖 YAML。存在 ${DSH_HOME}/api-runner-ui.json。
6
- *
7
- * 结构:
8
- * {
9
- * version: 1,
10
- * projects: {
11
- * "<normalizeProjectKey(cwd)>": {
12
- * updatedAt, baseUrl,
13
- * apis: [ApiDef], // 面板新建/编辑的接口
14
- * logins: [LoginDef], // 由某个接口派生出来的登录定义
15
- * tokens: { <loginId>: { value, masked, savedAt } }, // 登录得到的 token(明文,本机)
16
- * bindings: [JavaBinding] // 关联的本机 Java 服务(pid/端口/主类)
17
- * }
18
- * }
19
- * }
20
- */
21
- import fs from 'node:fs';
22
- import path from 'node:path';
23
- import { isPlainObject, maskSecret, normalizeProjectKey } from './util.js';
24
-
25
- const EMPTY_PROJECT = () => ({
26
- updatedAt: '',
27
- baseUrl: '',
28
- apis: [],
29
- logins: [],
30
- tokens: {},
31
- bindings: [],
32
- ignored: [],
33
- saved: [],
34
- });
35
-
36
- export class ProjectStore {
37
- constructor(options = {}) {
38
- this.homeDir = options.homeDir || process.env.DSH_HOME || path.join(process.cwd(), '.dsh');
39
- this.file = path.join(this.homeDir, 'api-runner-ui.json');
40
- this._doc = undefined;
41
- this._mtimeMs = -1;
42
- }
43
-
44
- /** 项目键:会话工作目录归一化;没有会话时用 default */
45
- keyOf(cwd) {
46
- return normalizeProjectKey(cwd) || 'default';
47
- }
48
-
49
- _load() {
50
- let stat;
51
- try {
52
- stat = fs.statSync(this.file);
53
- } catch {
54
- this._doc = { version: 1, projects: {} };
55
- this._mtimeMs = -1;
56
- return this._doc;
57
- }
58
- if (this._doc !== undefined && stat.mtimeMs === this._mtimeMs) return this._doc;
59
- try {
60
- const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
61
- this._doc = isPlainObject(parsed) && isPlainObject(parsed.projects) ? parsed : { version: 1, projects: {} };
62
- } catch {
63
- this._doc = { version: 1, projects: {} };
64
- }
65
- this._mtimeMs = stat.mtimeMs;
66
- return this._doc;
67
- }
68
-
69
- _save(doc) {
70
- try {
71
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
72
- const tmp = `${this.file}.tmp`;
73
- fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
74
- fs.renameSync(tmp, this.file);
75
- this._doc = doc;
76
- this._mtimeMs = fs.statSync(this.file).mtimeMs;
77
- } catch (error) {
78
- throw new Error(`保存面板配置失败:${error.message}`);
79
- }
80
- }
81
-
82
- /** 读一个项目的面板配置(永远返回完整结构,不返回 undefined) */
83
- project(cwd) {
84
- const doc = this._load();
85
- const key = this.keyOf(cwd);
86
- const raw = doc.projects[key];
87
- const project = isPlainObject(raw) ? raw : EMPTY_PROJECT();
88
- return {
89
- key,
90
- updatedAt: String(project.updatedAt || ''),
91
- baseUrl: String(project.baseUrl || ''),
92
- apis: Array.isArray(project.apis) ? project.apis.filter(isPlainObject) : [],
93
- logins: Array.isArray(project.logins) ? project.logins.filter(isPlainObject) : [],
94
- tokens: isPlainObject(project.tokens) ? project.tokens : {},
95
- bindings: Array.isArray(project.bindings) ? project.bindings.filter(isPlainObject) : [],
96
- ignored: Array.isArray(project.ignored) ? project.ignored.filter(isPlainObject) : [],
97
- saved: Array.isArray(project.saved) ? project.saved.filter(isPlainObject) : [],
98
- };
99
- }
100
-
101
- /**
102
- * 所有项目的原始配置(含接口列表)。
103
- *
104
- * 面板要按项目列出接口时用这个;调用方负责裁剪字段,
105
- * 不要把 token 明文往响应里带(这里只带 tokens 的脱敏记录,取明文走 tokenValue)。
106
- */
107
- allProjects() {
108
- const doc = this._load();
109
- return Object.keys(doc.projects || {}).map((key) => {
110
- const project = isPlainObject(doc.projects[key]) ? doc.projects[key] : {};
111
- return {
112
- key,
113
- updatedAt: String(project.updatedAt || ''),
114
- baseUrl: String(project.baseUrl || ''),
115
- apis: Array.isArray(project.apis) ? project.apis.filter(isPlainObject) : [],
116
- saved: Array.isArray(project.saved) ? project.saved.filter(isPlainObject) : [],
117
- tokens: isPlainObject(project.tokens) ? project.tokens : {},
118
- };
119
- }).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
120
- }
121
-
122
- /** 在项目上做一次原子修改:mutate(project) 直接改副本,返回结果写回 */
123
- update(cwd, mutate) {
124
- const doc = this._load();
125
- const key = this.keyOf(cwd);
126
- const current = this.project(cwd);
127
- const project = { ...current };
128
- delete project.key;
129
- mutate(project);
130
- project.updatedAt = new Date().toISOString();
131
- doc.version = 1;
132
- doc.projects[key] = project;
133
- this._save(doc);
134
- return this.project(cwd);
135
- }
136
-
137
- // ---- 接口 ----
138
- /** 项目级 baseUrl:面板顶部一个输入框,接口没单独填时用它 */
139
- setBaseUrl(cwd, baseUrl) {
140
- const value = String(baseUrl || '').trim();
141
- return this.update(cwd, (project) => {
142
- project.baseUrl = value.replace(/\/+$/, '');
143
- });
144
- }
145
-
146
- saveApi(cwd, api) {
147
- const id = String(api.id || '').trim();
148
- if (!id) throw new Error('接口 id 不能为空');
149
- return this.update(cwd, (project) => {
150
- const list = project.apis.filter((item) => String(item.id) !== id);
151
- list.push({ ...api, id, source: 'ui' });
152
- project.apis = list;
153
- });
154
- }
155
-
156
- /**
157
- * 面板接口的 login.tokenPath 自动纠正:登录成功但配置的 tokenPath 取不到值时,
158
- * 把探测到的真实路径写回接口定义,避免每次登录都重复警告。
159
- */
160
- setApiTokenPath(cwd, id, tokenPath) {
161
- return this.update(cwd, (project) => {
162
- project.apis = project.apis.map((item) => (
163
- String(item.id) === String(id)
164
- ? { ...item, login: { ...(item.login || {}), tokenPath } }
165
- : item
166
- ));
167
- });
168
- }
169
-
170
- deleteApi(cwd, id) {
171
- return this.update(cwd, (project) => {
172
- project.apis = project.apis.filter((item) => String(item.id) !== String(id));
173
- });
174
- }
175
-
176
- // ---- 登录定义 ----
177
- saveLogin(cwd, login) {
178
- const id = String(login.id || '').trim();
179
- if (!id) throw new Error('登录 id 不能为空');
180
- return this.update(cwd, (project) => {
181
- const list = project.logins.filter((item) => String(item.id) !== id);
182
- list.push({ ...login, id });
183
- project.logins = list;
184
- });
185
- }
186
-
187
- deleteLogin(cwd, id) {
188
- return this.update(cwd, (project) => {
189
- project.logins = project.logins.filter((item) => String(item.id) !== String(id));
190
- delete project.tokens[String(id)];
191
- });
192
- }
193
-
194
- // ---- token ----
195
- /** 保存 token(只在这里落明文;对外一律给脱敏值) */
196
- saveToken(cwd, loginId, token) {
197
- const value = String(token || '');
198
- return this.update(cwd, (project) => {
199
- project.tokens[String(loginId)] = {
200
- value,
201
- masked: maskSecret(value),
202
- savedAt: new Date().toISOString(),
203
- };
204
- });
205
- }
206
-
207
- clearToken(cwd, loginId) {
208
- return this.update(cwd, (project) => {
209
- delete project.tokens[String(loginId)];
210
- });
211
- }
212
-
213
- /** 对外展示用的 token 状态:绝不返回明文 */
214
- tokenStatus(cwd) {
215
- const project = this.project(cwd);
216
- const out = {};
217
- for (const [id, record] of Object.entries(project.tokens)) {
218
- out[id] = { hasToken: true, masked: record?.masked || '', savedAt: record?.savedAt || '' };
219
- }
220
- return out;
221
- }
222
-
223
- /** 内部用:取 token 明文(只在真正发请求时调用,不要往响应里带) */
224
- tokenValue(cwd, loginId) {
225
- const project = this.project(cwd);
226
- const record = project.tokens[String(loginId)];
227
- return record?.value ? String(record.value) : '';
228
- }
229
-
230
- /** 当前已保存 token 的登录 id 列表 */
231
- tokenIds(cwd) {
232
- return Object.keys(this.project(cwd).tokens);
233
- }
234
-
235
- // ---- Java 服务关联 ----
236
- bindJava(cwd, binding) {
237
- const pid = Number(binding.pid);
238
- if (!Number.isInteger(pid) || pid <= 0) throw new Error('pid 非法');
239
- return this.update(cwd, (project) => {
240
- const list = project.bindings.filter((item) => Number(item.pid) !== pid);
241
- list.push({
242
- pid,
243
- port: Number(binding.port) || 0,
244
- mainClass: String(binding.mainClass || ''),
245
- commandLine: String(binding.commandLine || ''),
246
- boundAt: new Date().toISOString(),
247
- });
248
- project.bindings = list;
249
- });
250
- }
251
-
252
- unbindJava(cwd, pid) {
253
- return this.update(cwd, (project) => {
254
- project.bindings = project.bindings.filter((item) => Number(item.pid) !== Number(pid));
255
- });
256
- }
257
-
258
- // ---- 保存的服务(不依赖 IDEA 也能启动) ----
259
- //
260
- // 存的是「怎么把这个服务拉起来」的完整配方:javaExe / classpath / 主类 /
261
- // JVM 参数 / 程序参数 / 工作目录 / 模块。有了它,插件不必先等 IDEA 起一个进程
262
- // 才能接管——直接按配方编译 + 拉起就行。
263
- saveJava(cwd, recipe = {}) {
264
- const id = String(recipe.id || '').trim();
265
- if (!id) throw new Error('保存服务需要 id');
266
- if (!recipe.mainClass || !recipe.classPath) throw new Error('保存服务需要主类与 classpath(先接管一次再保存)');
267
- return this.update(cwd, (project) => {
268
- const list = project.saved.filter((item) => String(item.id) !== id);
269
- list.push({
270
- id,
271
- name: String(recipe.name || id),
272
- mainClass: String(recipe.mainClass),
273
- classPath: String(recipe.classPath),
274
- jvmArgs: Array.isArray(recipe.jvmArgs) ? recipe.jvmArgs.map(String) : [],
275
- programArgs: Array.isArray(recipe.programArgs) ? recipe.programArgs.map(String) : [],
276
- javaExe: String(recipe.javaExe || ''),
277
- javaHome: String(recipe.javaHome || ''),
278
- cwd: String(recipe.cwd || ''),
279
- port: Number(recipe.port) || 0,
280
- debugPort: Number(recipe.debugPort) || 0,
281
- module: isPlainObject(recipe.module) ? {
282
- rootDir: String(recipe.module.rootDir || ''),
283
- artifactId: String(recipe.module.artifactId || ''),
284
- } : null,
285
- savedAt: new Date().toISOString(),
286
- });
287
- project.saved = list;
288
- });
289
- }
290
-
291
- deleteJava(cwd, id) {
292
- return this.update(cwd, (project) => {
293
- project.saved = project.saved.filter((item) => String(item.id) !== String(id));
294
- });
295
- }
296
-
297
- savedJava(cwd, id) {
298
- const list = this.project(cwd).saved;
299
- if (id === undefined) return list;
300
- return list.find((item) => String(item.id) === String(id));
301
- }
302
-
303
- // ---- 忽略的服务 ----
304
- //
305
- // 按 mainClass(拿不到时用名字)记,pid 每次重启都会变,不能拿 pid 当 key。
306
- // 忽略只是「不在列表里显示」,不影响进程本身。
307
- ignoreJava(cwd, target) {
308
- const key = javaKeyOf(target);
309
- if (!key) throw new Error('缺少服务标识');
310
- return this.update(cwd, (project) => {
311
- const list = project.ignored.filter((item) => javaKeyOf(item) !== key);
312
- list.push({
313
- key,
314
- name: String(target.name || ''),
315
- mainClass: String(target.mainClass || ''),
316
- kind: String(target.kind || ''),
317
- ignoredAt: new Date().toISOString(),
318
- });
319
- project.ignored = list;
320
- });
321
- }
322
-
323
- unignoreJava(cwd, target) {
324
- const key = javaKeyOf(target);
325
- return this.update(cwd, (project) => {
326
- project.ignored = project.ignored.filter((item) => javaKeyOf(item) !== key);
327
- });
328
- }
329
- }
330
-
331
- /** 忽略项的身份键:主类优先,其次名字(两者都能跨重启保持不变) */
332
- export function javaKeyOf(target = {}) {
333
- return String(target.mainClass || target.name || target.key || '').trim();
334
- }
1
+ /**
2
+ * 图形界面维护的配置存储(与 YAML 配置分开)。
3
+ *
4
+ * YAML 配置是「手写种子」,本文件是「面板里点出来的配置」,两者按 id 合并,
5
+ * 面板里的同 id 定义覆盖 YAML。存在 ${DSH_HOME}/api-runner-ui.json。
6
+ *
7
+ * 结构:
8
+ * {
9
+ * version: 1,
10
+ * projects: {
11
+ * "<normalizeProjectKey(cwd)>": {
12
+ * updatedAt, baseUrl,
13
+ * apis: [ApiDef], // 面板新建/编辑的接口
14
+ * logins: [LoginDef], // 由某个接口派生出来的登录定义
15
+ * tokens: { <loginId>: { value, masked, savedAt } }, // 登录得到的 token(明文,本机)
16
+ * bindings: [JavaBinding] // 关联的本机 Java 服务(pid/端口/主类)
17
+ * }
18
+ * }
19
+ * }
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { isPlainObject, maskSecret, normalizeProjectKey } from './util.js';
24
+
25
+ const EMPTY_PROJECT = () => ({
26
+ updatedAt: '',
27
+ baseUrl: '',
28
+ apis: [],
29
+ logins: [],
30
+ tokens: {},
31
+ bindings: [],
32
+ ignored: [],
33
+ saved: [],
34
+ });
35
+
36
+ export class ProjectStore {
37
+ constructor(options = {}) {
38
+ this.homeDir = options.homeDir || process.env.DSH_HOME || path.join(process.cwd(), '.dsh');
39
+ this.file = path.join(this.homeDir, 'api-runner-ui.json');
40
+ this._doc = undefined;
41
+ this._mtimeMs = -1;
42
+ }
43
+
44
+ /** 项目键:会话工作目录归一化;没有会话时用 default */
45
+ keyOf(cwd) {
46
+ return normalizeProjectKey(cwd) || 'default';
47
+ }
48
+
49
+ _load() {
50
+ let stat;
51
+ try {
52
+ stat = fs.statSync(this.file);
53
+ } catch {
54
+ this._doc = { version: 1, projects: {} };
55
+ this._mtimeMs = -1;
56
+ return this._doc;
57
+ }
58
+ if (this._doc !== undefined && stat.mtimeMs === this._mtimeMs) return this._doc;
59
+ try {
60
+ const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
61
+ this._doc = isPlainObject(parsed) && isPlainObject(parsed.projects) ? parsed : { version: 1, projects: {} };
62
+ } catch {
63
+ this._doc = { version: 1, projects: {} };
64
+ }
65
+ this._mtimeMs = stat.mtimeMs;
66
+ return this._doc;
67
+ }
68
+
69
+ _save(doc) {
70
+ try {
71
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
72
+ const tmp = `${this.file}.tmp`;
73
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
74
+ fs.renameSync(tmp, this.file);
75
+ this._doc = doc;
76
+ this._mtimeMs = fs.statSync(this.file).mtimeMs;
77
+ } catch (error) {
78
+ throw new Error(`保存面板配置失败:${error.message}`);
79
+ }
80
+ }
81
+
82
+ /** 读一个项目的面板配置(永远返回完整结构,不返回 undefined) */
83
+ project(cwd) {
84
+ const doc = this._load();
85
+ const key = this.keyOf(cwd);
86
+ const raw = doc.projects[key];
87
+ const project = isPlainObject(raw) ? raw : EMPTY_PROJECT();
88
+ return {
89
+ key,
90
+ updatedAt: String(project.updatedAt || ''),
91
+ baseUrl: String(project.baseUrl || ''),
92
+ apis: Array.isArray(project.apis) ? project.apis.filter(isPlainObject) : [],
93
+ logins: Array.isArray(project.logins) ? project.logins.filter(isPlainObject) : [],
94
+ tokens: isPlainObject(project.tokens) ? project.tokens : {},
95
+ bindings: Array.isArray(project.bindings) ? project.bindings.filter(isPlainObject) : [],
96
+ ignored: Array.isArray(project.ignored) ? project.ignored.filter(isPlainObject) : [],
97
+ saved: Array.isArray(project.saved) ? project.saved.filter(isPlainObject) : [],
98
+ };
99
+ }
100
+
101
+ /**
102
+ * 所有项目的原始配置(含接口列表)。
103
+ *
104
+ * 面板要按项目列出接口时用这个;调用方负责裁剪字段,
105
+ * 不要把 token 明文往响应里带(这里只带 tokens 的脱敏记录,取明文走 tokenValue)。
106
+ */
107
+ allProjects() {
108
+ const doc = this._load();
109
+ return Object.keys(doc.projects || {}).map((key) => {
110
+ const project = isPlainObject(doc.projects[key]) ? doc.projects[key] : {};
111
+ return {
112
+ key,
113
+ updatedAt: String(project.updatedAt || ''),
114
+ baseUrl: String(project.baseUrl || ''),
115
+ apis: Array.isArray(project.apis) ? project.apis.filter(isPlainObject) : [],
116
+ saved: Array.isArray(project.saved) ? project.saved.filter(isPlainObject) : [],
117
+ tokens: isPlainObject(project.tokens) ? project.tokens : {},
118
+ };
119
+ }).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
120
+ }
121
+
122
+ /** 在项目上做一次原子修改:mutate(project) 直接改副本,返回结果写回 */
123
+ update(cwd, mutate) {
124
+ const doc = this._load();
125
+ const key = this.keyOf(cwd);
126
+ const current = this.project(cwd);
127
+ const project = { ...current };
128
+ delete project.key;
129
+ mutate(project);
130
+ project.updatedAt = new Date().toISOString();
131
+ doc.version = 1;
132
+ doc.projects[key] = project;
133
+ this._save(doc);
134
+ return this.project(cwd);
135
+ }
136
+
137
+ // ---- 接口 ----
138
+ /** 项目级 baseUrl:面板顶部一个输入框,接口没单独填时用它 */
139
+ setBaseUrl(cwd, baseUrl) {
140
+ const value = String(baseUrl || '').trim();
141
+ return this.update(cwd, (project) => {
142
+ project.baseUrl = value.replace(/\/+$/, '');
143
+ });
144
+ }
145
+
146
+ saveApi(cwd, api) {
147
+ const id = String(api.id || '').trim();
148
+ if (!id) throw new Error('接口 id 不能为空');
149
+ return this.update(cwd, (project) => {
150
+ const previous = project.apis.find((item) => String(item.id) === id);
151
+ const list = project.apis.filter((item) => String(item.id) !== id);
152
+ /**
153
+ * 面板表单里没有的字段要沿用旧值,否则「保存」= 静默删数据。
154
+ *
155
+ * 面板只编辑 id/名称/方法/路径/baseUrl/说明/query/请求头/body/示例参数/token。
156
+ * 早期 query 不在表单里,于是「面板保存一次就把 YAML 里的 query 抹掉」。
157
+ *
158
+ * 关键语义:字段**存在**(哪怕是 {} / '')表示用户明确设过,以新值为准;
159
+ * 字段**不存在**(undefined)才沿用旧值。这样用户也能把 query 清空。
160
+ */
161
+ const carried = {};
162
+ if (previous) {
163
+ for (const key of ['query', 'queryText', 'sampleText', 'sampleParams']) {
164
+ if (api[key] === undefined && previous[key] !== undefined) carried[key] = previous[key];
165
+ }
166
+ }
167
+ list.push({ ...carried, ...api, id, source: 'ui' });
168
+ project.apis = list;
169
+ });
170
+ }
171
+
172
+ /**
173
+ * 面板接口的 login.tokenPath 自动纠正:登录成功但配置的 tokenPath 取不到值时,
174
+ * 把探测到的真实路径写回接口定义,避免每次登录都重复警告。
175
+ */
176
+ setApiTokenPath(cwd, id, tokenPath) {
177
+ return this.update(cwd, (project) => {
178
+ project.apis = project.apis.map((item) => (
179
+ String(item.id) === String(id)
180
+ ? { ...item, login: { ...(item.login || {}), tokenPath } }
181
+ : item
182
+ ));
183
+ });
184
+ }
185
+
186
+ deleteApi(cwd, id) {
187
+ return this.update(cwd, (project) => {
188
+ project.apis = project.apis.filter((item) => String(item.id) !== String(id));
189
+ });
190
+ }
191
+
192
+ // ---- 登录定义 ----
193
+ saveLogin(cwd, login) {
194
+ const id = String(login.id || '').trim();
195
+ if (!id) throw new Error('登录 id 不能为空');
196
+ return this.update(cwd, (project) => {
197
+ const list = project.logins.filter((item) => String(item.id) !== id);
198
+ list.push({ ...login, id });
199
+ project.logins = list;
200
+ });
201
+ }
202
+
203
+ deleteLogin(cwd, id) {
204
+ return this.update(cwd, (project) => {
205
+ project.logins = project.logins.filter((item) => String(item.id) !== String(id));
206
+ delete project.tokens[String(id)];
207
+ });
208
+ }
209
+
210
+ // ---- token ----
211
+ /** 保存 token(只在这里落明文;对外一律给脱敏值) */
212
+ saveToken(cwd, loginId, token) {
213
+ const value = String(token || '');
214
+ return this.update(cwd, (project) => {
215
+ project.tokens[String(loginId)] = {
216
+ value,
217
+ masked: maskSecret(value),
218
+ savedAt: new Date().toISOString(),
219
+ };
220
+ });
221
+ }
222
+
223
+ clearToken(cwd, loginId) {
224
+ return this.update(cwd, (project) => {
225
+ delete project.tokens[String(loginId)];
226
+ });
227
+ }
228
+
229
+ /** 对外展示用的 token 状态:绝不返回明文 */
230
+ tokenStatus(cwd) {
231
+ const project = this.project(cwd);
232
+ const out = {};
233
+ for (const [id, record] of Object.entries(project.tokens)) {
234
+ out[id] = { hasToken: true, masked: record?.masked || '', savedAt: record?.savedAt || '' };
235
+ }
236
+ return out;
237
+ }
238
+
239
+ /** 内部用:取 token 明文(只在真正发请求时调用,不要往响应里带) */
240
+ tokenValue(cwd, loginId) {
241
+ const project = this.project(cwd);
242
+ const record = project.tokens[String(loginId)];
243
+ return record?.value ? String(record.value) : '';
244
+ }
245
+
246
+ /** 当前已保存 token 的登录 id 列表 */
247
+ tokenIds(cwd) {
248
+ return Object.keys(this.project(cwd).tokens);
249
+ }
250
+
251
+ // ---- Java 服务关联 ----
252
+ bindJava(cwd, binding) {
253
+ const pid = Number(binding.pid);
254
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error('pid 非法');
255
+ return this.update(cwd, (project) => {
256
+ const list = project.bindings.filter((item) => Number(item.pid) !== pid);
257
+ list.push({
258
+ pid,
259
+ port: Number(binding.port) || 0,
260
+ mainClass: String(binding.mainClass || ''),
261
+ commandLine: String(binding.commandLine || ''),
262
+ boundAt: new Date().toISOString(),
263
+ });
264
+ project.bindings = list;
265
+ });
266
+ }
267
+
268
+ unbindJava(cwd, pid) {
269
+ return this.update(cwd, (project) => {
270
+ project.bindings = project.bindings.filter((item) => Number(item.pid) !== Number(pid));
271
+ });
272
+ }
273
+
274
+ // ---- 保存的服务(不依赖 IDEA 也能启动) ----
275
+ //
276
+ // 存的是「怎么把这个服务拉起来」的完整配方:javaExe / classpath / 主类 /
277
+ // JVM 参数 / 程序参数 / 工作目录 / 模块。有了它,插件不必先等 IDEA 起一个进程
278
+ // 才能接管——直接按配方编译 + 拉起就行。
279
+ saveJava(cwd, recipe = {}) {
280
+ const id = String(recipe.id || '').trim();
281
+ if (!id) throw new Error('保存服务需要 id');
282
+ if (!recipe.mainClass || !recipe.classPath) throw new Error('保存服务需要主类与 classpath(先接管一次再保存)');
283
+ return this.update(cwd, (project) => {
284
+ const list = project.saved.filter((item) => String(item.id) !== id);
285
+ list.push({
286
+ id,
287
+ name: String(recipe.name || id),
288
+ mainClass: String(recipe.mainClass),
289
+ classPath: String(recipe.classPath),
290
+ jvmArgs: Array.isArray(recipe.jvmArgs) ? recipe.jvmArgs.map(String) : [],
291
+ programArgs: Array.isArray(recipe.programArgs) ? recipe.programArgs.map(String) : [],
292
+ javaExe: String(recipe.javaExe || ''),
293
+ javaHome: String(recipe.javaHome || ''),
294
+ cwd: String(recipe.cwd || ''),
295
+ port: Number(recipe.port) || 0,
296
+ debugPort: Number(recipe.debugPort) || 0,
297
+ module: isPlainObject(recipe.module) ? {
298
+ rootDir: String(recipe.module.rootDir || ''),
299
+ artifactId: String(recipe.module.artifactId || ''),
300
+ } : null,
301
+ savedAt: new Date().toISOString(),
302
+ });
303
+ project.saved = list;
304
+ });
305
+ }
306
+
307
+ deleteJava(cwd, id) {
308
+ return this.update(cwd, (project) => {
309
+ project.saved = project.saved.filter((item) => String(item.id) !== String(id));
310
+ });
311
+ }
312
+
313
+ savedJava(cwd, id) {
314
+ const list = this.project(cwd).saved;
315
+ if (id === undefined) return list;
316
+ return list.find((item) => String(item.id) === String(id));
317
+ }
318
+
319
+ // ---- 忽略的服务 ----
320
+ //
321
+ // 按 mainClass(拿不到时用名字)记,pid 每次重启都会变,不能拿 pid 当 key。
322
+ // 忽略只是「不在列表里显示」,不影响进程本身。
323
+ ignoreJava(cwd, target) {
324
+ const key = javaKeyOf(target);
325
+ if (!key) throw new Error('缺少服务标识');
326
+ return this.update(cwd, (project) => {
327
+ const list = project.ignored.filter((item) => javaKeyOf(item) !== key);
328
+ list.push({
329
+ key,
330
+ name: String(target.name || ''),
331
+ mainClass: String(target.mainClass || ''),
332
+ kind: String(target.kind || ''),
333
+ ignoredAt: new Date().toISOString(),
334
+ });
335
+ project.ignored = list;
336
+ });
337
+ }
338
+
339
+ unignoreJava(cwd, target) {
340
+ const key = javaKeyOf(target);
341
+ return this.update(cwd, (project) => {
342
+ project.ignored = project.ignored.filter((item) => javaKeyOf(item) !== key);
343
+ });
344
+ }
345
+ }
346
+
347
+ /** 忽略项的身份键:主类优先,其次名字(两者都能跨重启保持不变) */
348
+ export function javaKeyOf(target = {}) {
349
+ return String(target.mainClass || target.name || target.key || '').trim();
350
+ }