dsh-ris-yds 0.0.0-stage → 1.1.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -2
  3. package/assets/yds-chrome-resource-override.crx +0 -0
  4. package/bin/check-listing.mjs +247 -0
  5. package/bin/yds.js +19 -0
  6. package/cordis.patch.yml +13 -0
  7. package/lib/browser.js +428 -0
  8. package/lib/build.js +115 -0
  9. package/lib/cli.js +1130 -0
  10. package/lib/confcenter.js +377 -0
  11. package/lib/config.js +552 -0
  12. package/lib/fe-dev.js +206 -0
  13. package/lib/ide-proxy.js +906 -0
  14. package/lib/ide-watch.js +188 -0
  15. package/lib/ide.js +728 -0
  16. package/lib/index.js +267 -0
  17. package/lib/proc.js +270 -0
  18. package/lib/risd-peer.js +353 -0
  19. package/lib/rules.js +193 -0
  20. package/lib/socks.js +279 -0
  21. package/lib/token.js +36 -0
  22. package/lib/yms.js +336 -0
  23. package/lib/yoncode.js +249 -0
  24. package/native/yds-rust.darwin-arm64.node +0 -0
  25. package/native/yds-rust.darwin-x64.node +0 -0
  26. package/native/yds-rust.linux-arm64.node +0 -0
  27. package/native/yds-rust.linux-x64.node +0 -0
  28. package/native/yds-rust.win32-arm64.node +0 -0
  29. package/native/yds-rust.win32-x64.node +0 -0
  30. package/package.json +58 -4
  31. package/skills/dsh-ris-yds/SKILL.md +134 -0
  32. package/test/browser.test.mjs +442 -0
  33. package/test/cli-stop.test.mjs +40 -0
  34. package/test/cli.test.mjs +246 -0
  35. package/test/config.test.mjs +106 -0
  36. package/test/fe-dev.test.mjs +93 -0
  37. package/test/hints.test.mjs +13 -0
  38. package/test/ide-config.test.mjs +155 -0
  39. package/test/ide-proxy.test.mjs +256 -0
  40. package/test/ide-watch.test.mjs +79 -0
  41. package/test/launch-proxy.test.mjs +83 -0
  42. package/test/lease-release.test.mjs +76 -0
  43. package/test/mode-separation.test.mjs +147 -0
  44. package/test/pidfile.test.mjs +105 -0
  45. package/test/proc.test.mjs +172 -0
  46. package/test/proxy-standalone.test.mjs +121 -0
  47. package/test/risd-leases.test.mjs +111 -0
  48. package/test/risd-tier.test.mjs +361 -0
  49. package/test/rules-e2e.test.mjs +122 -0
  50. package/test/rules.test.mjs +154 -0
  51. package/test/yoncode.test.mjs +159 -0
package/lib/index.js ADDED
@@ -0,0 +1,267 @@
1
+ // dsh-ris-yds plugin entry: tools-only (no browser row). Every agent tool
2
+ // funnels into lib/cli.js so the shell CLI, the dsh-ris-ide startCommand and
3
+ // the agent tools behave identically.
4
+
5
+ import { reconcileWorkspaceConfig } from './ide.js'
6
+ import { watchAll } from './ide-watch.js'
7
+
8
+ export const name = 'dsh-ris-yds'
9
+ export const inject = ['sessions', 'tools']
10
+
11
+ export function apply(ctx) {
12
+ // Keep the launcher row in ris-ide's config after it regenerates the file.
13
+ // We only observe (see lib/ide-watch.js): ris-ide is never modified.
14
+ let stopWatching = () => {}
15
+ try {
16
+ stopWatching = watchAll(
17
+ { repair: (workspace) => reconcileWorkspaceConfig(workspace) },
18
+ { log: ctx.logger?.info?.bind(ctx.logger) ?? (() => {}) },
19
+ )
20
+ } catch (error) {
21
+ ctx.logger?.warn?.(`dsh-ris-yds: config watcher not started: ${String(error)}`)
22
+ }
23
+ if (typeof ctx.tools?.register !== 'function') {
24
+ ctx.logger?.warn?.('dsh-ris-yds: ctx.tools missing; tools stay unregistered')
25
+ return
26
+ }
27
+ const disposers = []
28
+ const register = (definition) => {
29
+ try {
30
+ disposers.push(ctx.tools.register(definition))
31
+ } catch (error) {
32
+ ctx.logger?.warn?.(`dsh-ris-yds: tool ${definition.name} not registered: ${String(error)}`)
33
+ }
34
+ }
35
+ const scopeOf = (exec) => {
36
+ const sessionId = exec?.agent?.session?.id
37
+ if (typeof sessionId !== 'string' || sessionId === '') {
38
+ throw new Error('this tool needs a live agent session')
39
+ }
40
+ const session = ctx.sessions?.get?.(sessionId)
41
+ const headerCwd = session?.header?.cwd
42
+ if (typeof headerCwd === 'string' && headerCwd !== '') return headerCwd
43
+ if (typeof exec?.cwd === 'string' && exec.cwd !== '') return exec.cwd
44
+ throw new Error(`no working directory for session "${sessionId}"`)
45
+ }
46
+ const text = (value) => [{ type: 'text', text: value }]
47
+
48
+ const LOOSE_OUTPUT = {
49
+ type: 'object',
50
+ additionalProperties: true,
51
+ properties: { ok: { type: 'boolean' } },
52
+ }
53
+
54
+ const runCli = async (exec, argv) => {
55
+ const { run } = await import('./cli.js')
56
+ const cwd = scopeOf(exec)
57
+ exec?.signal?.throwIfAborted?.()
58
+ const result = await run(argv, { cwd })
59
+ if (result === undefined) return result
60
+ // Tools never touch process.exitCode (that would leak into the dsh host); they
61
+ // report the outcome in the payload instead.
62
+ const { exitCode = 0, ...rest } = result
63
+ return { ...rest, ok: exitCode === 0 }
64
+ }
65
+
66
+ register({
67
+ name: 'yds_doctor',
68
+ description: 'YDS 环境体检:解析项目 settings.json 与可选 .env 的生效配置(SOCKS/直连、回调地址、端口、JDK),'
69
+ + '检查 yms-home 构建状态、YMS 进程/端口/ready、socks 转发器,并列出缺失项。'
70
+ + '新环境装上插件后从这里开始;配置缺失会给出精确的补齐指引。',
71
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
72
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
73
+ execute: async (args, exec) => runCli(exec, ['doctor']),
74
+ })
75
+
76
+ register({
77
+ name: 'yds_config',
78
+ description: '显示 YDS 生效配置(键值+来源:项目 settings.json / workspace .env / 环境变量),凭据打码。'
79
+ + '含 module、workbenchUrl、serverBindAddress/serverPort(回调)、ymsPort(本地监听)、socks、git、maven。',
80
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
81
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
82
+ execute: async (args, exec) => runCli(exec, ['config']),
83
+ })
84
+
85
+ register({
86
+ name: 'yds_token',
87
+ description: '生成调用本地 YMS 或远端平台 API 的 YYCToken(请求头 YYCToken)。token 与 URL 绑定宽松(根 URL token 可打任意路径)。'
88
+ + '凭据自动取自项目 settings.json 的 yds.accessKey/accessSecret。',
89
+ parameters: {
90
+ type: 'object',
91
+ additionalProperties: false,
92
+ required: ['url'],
93
+ properties: { url: { type: 'string', description: '要授权的完整 URL(如 https://<workbench>/c-ass-kk-hans/_yms/ready)' } },
94
+ },
95
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
96
+ execute: async (args, exec) => runCli(exec, ['token', args.url]),
97
+ })
98
+
99
+ register({
100
+ name: 'yds_confcenter',
101
+ description: '拉取(或离线使用缓存)confcenter 的 4 个 YMS 配置文件并适配写入 yms-home/config:'
102
+ + 'SOCKS 模式把 DB/Redis 地址改写为本地转发端口,直连模式保持原地址;ip_node_mapping 指向回调地址。'
103
+ + '--offline 用 .dsh-ris-yds/confcenter 缓存(网络不可达时启动用)。',
104
+ parameters: {
105
+ type: 'object',
106
+ additionalProperties: false,
107
+ properties: { offline: { type: 'boolean', description: '不联网,用上次拉取的缓存' } },
108
+ },
109
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
110
+ execute: async (args, exec) => runCli(exec, ['confcenter', ...(args?.offline === true ? ['--offline'] : [])]),
111
+ })
112
+
113
+ register({
114
+ name: 'yds_build',
115
+ description: '从零构建 yms-home(首次含 77MB kernel 从 jfrog 下载,约 19 分钟;缓存后约 1 分钟):'
116
+ + 'maven yms-module 部署 + confcenter 拉取适配 + JDK 指针。不依赖本机既有产物,新环境可直接跑。'
117
+ + '需要 JDK 与 mvn 在 PATH(或 .env 的 java.path / maven.* 指定)。',
118
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
119
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
120
+ execute: async (args, exec) => runCli(exec, ['build']),
121
+ })
122
+
123
+ register({
124
+ name: 'yds_start',
125
+ description: '后台启动本地 YMS(幂等):socks 已配置则自动起本地转发器,yms-home/config 缺失则自动补(缓存或在线拉取),'
126
+ + '然后以脱离进程启动 Liberty,等端口就绪。返回 pid、端口、rdbga cookie 值(种到 workbench 域即可让网关路由回调本机)。'
127
+ + '已运行时返回 alreadyRunning。需要 JDWP 调试请走 yds_ide + ris_dev_debug。',
128
+ parameters: {
129
+ type: 'object',
130
+ additionalProperties: false,
131
+ properties: { jvmArgs: { type: 'string', description: '额外 JVM 参数(空格分隔)' } },
132
+ },
133
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
134
+ execute: async (args, exec) => runCli(exec, ['start', ...(typeof args?.jvmArgs === 'string' && args.jvmArgs !== '' ? ['--jvm-args', args.jvmArgs] : [])]),
135
+ })
136
+
137
+ register({
138
+ name: 'yds_stop',
139
+ description: '停止本地 YMS(TERM→宽限→KILL,pidfile 验证)。默认同时停 socks 转发器;keepSocks=true 保留(例如共享给其它任务)。',
140
+ parameters: {
141
+ type: 'object',
142
+ additionalProperties: false,
143
+ properties: { keepSocks: { type: 'boolean', description: '保留 socks 转发器' } },
144
+ },
145
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
146
+ execute: async (args, exec) => runCli(exec, ['stop', ...(args?.keepSocks === true ? ['--keep-socks'] : [])]),
147
+ })
148
+
149
+ register({
150
+ name: 'yds_status',
151
+ description: 'YMS 运行状态:进程 pid、端口监听、/_yms/ready HTTP 状态、yms-home 构建与 config 就绪、socks 转发器。',
152
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
153
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
154
+ execute: async (args, exec) => runCli(exec, ['status']),
155
+ })
156
+
157
+ register({
158
+ name: 'yds_logs',
159
+ description: 'tail 本地 YMS 日志(Liberty 控制台 + 应用 yms-application.log),默认 80 行。',
160
+ parameters: {
161
+ type: 'object',
162
+ additionalProperties: false,
163
+ properties: { lines: { type: 'number', description: '行数(默认 80)' } },
164
+ },
165
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
166
+ execute: async (args, exec) => runCli(exec, ['logs', '--lines', String(args?.lines ?? 80)]),
167
+ })
168
+
169
+ register({
170
+ name: 'yds_ide',
171
+ description: '维护 dsh-ris-ide 的项目运行配置 .dsh-ris-ide/config.json:把本地 YMS 作为一个程序条目(startCommand 含 ${JDWP_ARGS} 占位符)'
172
+ + 'upsert 进去,之后可用 ris_dev_start/ris_dev_stop 启停、ris_dev_debug 断点调试(JDWP)。'
173
+ + '同时维护项目根 .vscode/launch.json 的 yms-middleware-cloudnative-jdk-21 条目(与 YonCode 生成物同名同构,YonCode 端 F5 可直接启动/调试 YMS)。'
174
+ + '只增改自己的一条(按名字匹配),其余用户/面板/YonCode 条目原样保留。action=check(默认)/apply/remove。',
175
+ parameters: {
176
+ type: 'object',
177
+ additionalProperties: false,
178
+ properties: { action: { type: 'string', description: 'check(默认)| apply | remove' } },
179
+ },
180
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
181
+ execute: async (args, exec) => runCli(exec, ['ide', args?.action ?? 'check']),
182
+ })
183
+
184
+ register({
185
+ name: 'yds_proxy',
186
+ description: '代理引擎(借用 dsh-ris-ide 的 proxy.js,与 IDE 控制台的 🌐 弹窗共享同一份规则与同一个进程):'
187
+ + 'status 看引擎与 yds 规则;sync/start 按 confcenter 缓存的 DB/Redis/内网端点自动生成 source=yds 的规则并启动引擎'
188
+ + '(literal IPv4 目标会在本机 127.0.0.1:同端口 上做映射,供 JDBC/Redis 这类不支持 SOCKS 的客户端使用);'
189
+ + 'stop 停引擎;git-on/git-off 写入/清除 scope 到配置 URL 主机的 git 代理(普通 git 与 dsh-git 都会生效,无需 -c 参数)。'
190
+ + '未安装 dsh-ris-ide 时 yds start 自动回退内置转发器。',
191
+ parameters: {
192
+ type: 'object',
193
+ additionalProperties: false,
194
+ properties: { action: { type: 'string', description: 'status(默认)| sync | start | stop | git-on | git-off' } },
195
+ },
196
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
197
+ execute: async (args, exec) => runCli(exec, ['proxy', args?.action ?? 'status']),
198
+ })
199
+
200
+ register({
201
+ name: 'yds_browser',
202
+ description: '工作台浏览器(YonCode 内置 Chrome / 任意开了 DevTools 端口的浏览器)的 rdbga cookie 体检与校准:'
203
+ + '网关只在该 cookie 等于当前 JVM 的 -Drdbga 时把请求路由到本机;一个空的 host-only rdbga 会覆盖正确值,'
204
+ + '导致页面看着正常但请求全去远端 pod(本机日志空、断点不进)。'
205
+ + 'status 列出 DevTools 端口/工作台页签/期望值(含来源);fix 删除空值/重复/不匹配的 rdbga 并写入当前值。'
206
+ + 'fix 默认只预览(dry_run=true),确认后才用 dry_run=false 真正写入;只动 rdbga、只动工作台域。'
207
+ + 'yds start 成功后会自动校准一次(后台/前台/已在运行都会,浏览器没开则跳过)。',
208
+ parameters: {
209
+ type: 'object',
210
+ additionalProperties: false,
211
+ properties: {
212
+ action: { type: 'string', description: 'status(默认)| fix' },
213
+ dry_run: { type: 'boolean', description: 'fix 默认 true=只预览;真正写入浏览器 cookie 需显式 false' },
214
+ },
215
+ },
216
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
217
+ execute: async (args, exec) => {
218
+ const action = args?.action ?? 'status'
219
+ const argv = ['browser', action]
220
+ if (action === 'fix' && args?.dry_run !== false) argv.push('--dry-run')
221
+ return runCli(exec, argv)
222
+ },
223
+ })
224
+
225
+ register({
226
+ name: 'yds_yoncode',
227
+ description: 'YonCode 的 serverBindAddress 网卡校验(YonCode 要求启动地址是本机网卡 IP;本插件用回调地址/端口转发时会拒绝启动):'
228
+ + 'status 检测安装位置与状态(patched/unpatched/absent,可用 .env 的 yoncode.home 指定安装目录);'
229
+ + 'patch 把该校验改成告警(写 YonCode 安装目录,先备份 .bak;写不动会提示需要管理员/danger-full-access 提权);'
230
+ + 'revert 从 .bak 还原。只改 YonCode 自己的文件,不动项目。',
231
+ parameters: {
232
+ type: 'object',
233
+ additionalProperties: false,
234
+ properties: { action: { type: 'string', description: 'status(默认)| patch | revert' } },
235
+ },
236
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
237
+ execute: async (args, exec) => runCli(exec, ['yoncode', args?.action ?? 'status']),
238
+ })
239
+
240
+ register({
241
+ name: 'yds_fe',
242
+ description: '前端调试(webpack dev server)的两种接入方式,值全部取自当前项目配置(module/domainKey、workbench 主机、yds.webConfig 里的 dev 端口):'
243
+ + 'rules 生成 Resource Override Chrome 插件的规则文件(写到 <workspace>/.dsh-ris-yds/resource-override-rules.json,'
244
+ + '在插件里导入即可把远端 extend*.min.js/css 换成 127.0.0.1:<dev端口>/static 本地文件);'
245
+ + 'plan 打印同样的规则转换成的「正则 + 替换」清单,agent 可以用 CDP Fetch 拦截自行注入(不需要装扩展);'
246
+ + 'url <page> 给页面 URL 追加 scriptUrl 参数(零配置路径,前端自己支持)。',
247
+ parameters: {
248
+ type: 'object',
249
+ additionalProperties: false,
250
+ properties: {
251
+ action: { type: 'string', description: 'rules(默认)| plan | url' },
252
+ page: { type: 'string', description: 'action=url 时的页面 URL(绝对或站内路径)' },
253
+ },
254
+ },
255
+ output: { schema: LOOSE_OUTPUT, render: (_a, value) => text(value?.text ?? '') },
256
+ execute: async (args, exec) => {
257
+ const argv = ['fe', args?.action ?? 'rules']
258
+ if (typeof args?.page === 'string' && args.page !== '') argv.push(args.page)
259
+ return runCli(exec, argv)
260
+ },
261
+ })
262
+
263
+ return () => {
264
+ try { stopWatching() } catch { /* ignore */ }
265
+ disposers.forEach((dispose) => { try { dispose() } catch { /* ignore */ } })
266
+ }
267
+ }
package/lib/proc.js ADDED
@@ -0,0 +1,270 @@
1
+ // Cross-platform process helpers.
2
+ //
3
+ // The Linux implementations read /proc/<pid>/cmdline to prove a pidfile still
4
+ // points at OUR process; Windows has no /proc, so the same check must come from
5
+ // the Win32 process table (PowerShell CIM). Without this, `yds status` reported
6
+ // "not running" on Windows, `yds start` spawned a second YMS and `yds stop`
7
+ // could never find the forwarder (review: Windows P0).
8
+ //
9
+ // Identity checks are THREE-valued on purpose (review D-P1-6): "ours", "gone" and
10
+ // "could not tell". Collapsing "could not tell" into "not ours" deleted the pidfile
11
+ // after a transient PowerShell/EPERM failure, which is exactly how a second YMS got
12
+ // started on top of a live one. Callers must only act destructively on `dead`, only
13
+ // signal on `ours`, and keep the pidfile when the answer is `unknown`.
14
+
15
+ import { spawnSync } from 'node:child_process'
16
+ import { readFileSync } from 'node:fs'
17
+
18
+ const DEFAULT_PLATFORM = process.platform
19
+ const WINDOWS_PROBE_OK = 'YDS_PROBE_OK'
20
+ const WINDOWS_PROBE_ABSENT = 'YDS_PROBE_ABSENT'
21
+
22
+ // One PowerShell per identity check costs ~0.5 s on Windows, and `yds status` /
23
+ // `yds start` run several checks back to back (pidfile + forwarder). Keep the answers
24
+ // for a few seconds so those paths do not spawn four processes (review D-P2-3). The
25
+ // cache is only used on the real Windows probe (injected executors stay uncached so
26
+ // tests remain deterministic) and `stopPid` invalidates it before/after the kill.
27
+ const WINDOWS_PROBE_CACHE_MS = 5000
28
+ const WINDOWS_PROBE_CACHE_MAX = 200
29
+ const windowsProbeCache = new Map()
30
+
31
+ /** Forget a cached probe (a stop is authoritative; never answer from before it). */
32
+ export function clearProbeCache(pid) {
33
+ const value = Number(pid)
34
+ if (Number.isInteger(value) && value > 0) windowsProbeCache.delete(`win32:${value}`)
35
+ }
36
+
37
+
38
+ /**
39
+ * Run a command once and report everything the caller may need: exit code, both
40
+ * streams and the spawn error. Swallowing the exit code made `taskkill` failures
41
+ * look like successes (review D-P1-7).
42
+ * @returns {{code: number|null, stdout: string, stderr: string, error: Error|null}}
43
+ */
44
+ export function runCommand(command, args, timeout = 8000) {
45
+ try {
46
+ const out = spawnSync(command, args, { encoding: 'utf8', timeout, windowsHide: true })
47
+ return {
48
+ code: Number.isInteger(out.status) ? out.status : null,
49
+ stdout: String(out.stdout ?? ''),
50
+ stderr: String(out.stderr ?? ''),
51
+ error: out.error ?? null,
52
+ }
53
+ } catch (error) {
54
+ return { code: null, stdout: '', stderr: '', error }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The PowerShell argument list used to read a pid's command line. Kept separate so a
60
+ * test can assert the WQL quoting without spawning Windows.
61
+ *
62
+ * The pid is the only interpolated value and is validated as a positive integer by
63
+ * `probePid`, so it cannot inject anything. The WQL predicate is single-quoted: the
64
+ * old form embedded double quotes inside `-Command`, which only survived because of
65
+ * one specific argument-quoting rule on Windows (review D-P2).
66
+ */
67
+ export function windowsProbeArgs(pid) {
68
+ const value = Number(pid)
69
+ const script = [
70
+ `$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${value}'`,
71
+ `if ($null -eq $p) { '${WINDOWS_PROBE_ABSENT}' } else { '${WINDOWS_PROBE_OK}:' + $p.CommandLine }`,
72
+ ].join('; ')
73
+ return ['-NoProfile', '-NonInteractive', '-Command', script]
74
+ }
75
+
76
+ function parseWindowsProbe(result) {
77
+ // Spawn failure/timeout: we cannot tell whether the pid exists.
78
+ if (result?.error !== null && result?.error !== undefined) return { state: 'unknown', commandLine: '' }
79
+ const text = String(result?.stdout ?? '').trim()
80
+ const marker = `${WINDOWS_PROBE_OK}:`
81
+ // The command line is echoed verbatim after the OK marker, so the absent marker
82
+ // must be matched as the WHOLE output: `includes` used to call a live process dead
83
+ // when its own command line contained the marker string (review D-P2-2).
84
+ if (text === WINDOWS_PROBE_ABSENT) return { state: 'dead', commandLine: '' }
85
+ const at = text.indexOf(marker)
86
+ if (at !== -1) return { state: 'alive', commandLine: text.slice(at + marker.length).trim() }
87
+ // Empty output with a non-zero exit code is a permission/availability failure,
88
+ // NOT proof that the process is gone. Keep it unknown.
89
+ return { state: 'unknown', commandLine: '' }
90
+ }
91
+
92
+ /** Default POSIX liveness probe: EPERM means the pid exists but is not ours. */
93
+ export function signalState(pid) {
94
+ try {
95
+ process.kill(pid, 0)
96
+ return 'alive'
97
+ } catch (error) {
98
+ if (error?.code === 'ESRCH') return 'dead'
99
+ if (error?.code === 'EPERM') return 'alive'
100
+ return 'unknown'
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Identify a pid: `state` is `alive`, `dead` or `unknown`, plus its command line when
106
+ * it can be read. Everything else (pidfiles, markers, stop) is built on this.
107
+ * @param {number} pid - process id.
108
+ * @param {{platform?: NodeJS.Platform, exec?: Function, signal?: Function,
109
+ * commandLine?: Function}} [options] - injectable for tests.
110
+ * @returns {{state: 'alive'|'dead'|'unknown', commandLine: string}}
111
+ */
112
+ export function probePid(pid, { platform = DEFAULT_PLATFORM, exec = runCommand, signal = signalState, commandLine = null } = {}) {
113
+ const value = Number(pid)
114
+ if (!Number.isInteger(value) || value <= 0) return { state: 'dead', commandLine: '' }
115
+ if (platform === 'win32') {
116
+ // Cached only for the real probe: tests inject `exec` and must see every call.
117
+ const cacheable = exec === runCommand
118
+ const cacheKey = `win32:${value}`
119
+ if (cacheable) {
120
+ const hit = windowsProbeCache.get(cacheKey)
121
+ if (hit !== undefined && Date.now() - hit.at <= WINDOWS_PROBE_CACHE_MS) {
122
+ return { state: hit.state, commandLine: hit.commandLine }
123
+ }
124
+ }
125
+ const probe = parseWindowsProbe(exec('powershell.exe', windowsProbeArgs(value), 8000))
126
+ if (cacheable) {
127
+ if (windowsProbeCache.size >= WINDOWS_PROBE_CACHE_MAX) windowsProbeCache.clear()
128
+ windowsProbeCache.set(cacheKey, { at: Date.now(), state: probe.state, commandLine: probe.commandLine })
129
+ }
130
+ return probe
131
+ }
132
+ const state = signal(value)
133
+ if (state === 'dead') return { state: 'dead', commandLine: '' }
134
+ if (state !== 'alive') return { state: 'unknown', commandLine: '' }
135
+ let line = ''
136
+ if (typeof commandLine === 'function') {
137
+ line = String(commandLine(value) ?? '').trim()
138
+ } else {
139
+ try { line = readFileSync(`/proc/${value}/cmdline`, 'utf8').replace(/\0/g, ' ').trim() } catch { line = '' }
140
+ }
141
+ return { state: 'alive', commandLine: line }
142
+ }
143
+
144
+ /**
145
+ * The full command line of a live process, or '' when it cannot be read.
146
+ * Windows: one CIM query per call (~0.5 s); Linux/WSL: /proc.
147
+ * @param {number} pid - process id.
148
+ * @returns {string} NUL/space-joined command line.
149
+ */
150
+ export function processCommandLine(pid, options = {}) {
151
+ return probePid(pid, options).commandLine
152
+ }
153
+
154
+ /** Is the pid alive? `false` also covers "could not tell" — use probePid for a
155
+ * three-valued answer. */
156
+ export function pidAlive(pid, options = {}) {
157
+ return probePid(pid, options).state === 'alive'
158
+ }
159
+
160
+ /**
161
+ * Which of OUR processes a pid belongs to, without ever confusing "unreadable" with
162
+ * "not ours":
163
+ * - `ours` - alive and its command line contains one of the markers
164
+ * - `foreign` - alive, command line readable, marker absent (pid was reused or the
165
+ * pidfile is stale; safe to unlink the pidfile)
166
+ * - `dead` - definitely gone
167
+ * - `unknown` - could not probe / could not read the command line: keep the pidfile
168
+ * @returns {{state: 'ours'|'foreign'|'dead'|'unknown', commandLine: string}}
169
+ */
170
+ export function pidIdentity(pid, markers, options = {}) {
171
+ const list = (Array.isArray(markers) ? markers : [markers]).filter((marker) => typeof marker === 'string' && marker !== '')
172
+ const probe = probePid(pid, options)
173
+ if (probe.state === 'dead') return { state: 'dead', commandLine: '' }
174
+ if (probe.state === 'unknown') return { state: 'unknown', commandLine: probe.commandLine }
175
+ if (probe.commandLine === '') return { state: 'unknown', commandLine: '' }
176
+ return {
177
+ state: list.some((marker) => probe.commandLine.includes(marker)) ? 'ours' : 'foreign',
178
+ commandLine: probe.commandLine,
179
+ }
180
+ }
181
+
182
+ /** Default POSIX kill used by `stopPid`; injectable so the signal path is testable. */
183
+ function defaultKill(pid, sig) {
184
+ try {
185
+ process.kill(pid, sig)
186
+ return { ok: true }
187
+ } catch (error) {
188
+ // Already gone is success for a stop request.
189
+ if (error?.code === 'ESRCH') return { ok: true }
190
+ return { ok: false, error: String(error?.message ?? error) }
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Stop a process, preferring a clean signal. Windows has no SIGTERM: use
196
+ * `taskkill /T /F` so the whole tree (java + helpers) goes away and the pidfile
197
+ * cannot be left pointing at an orphan.
198
+ *
199
+ * The result is REPORTED, not assumed: callers must only unlink the pidfile (and
200
+ * tell the user "stopped") when `ok === true` (review D-P1-7).
201
+ * @param {number} pid - process id.
202
+ * @param {{force?: boolean, graceMs?: number, platform?: NodeJS.Platform,
203
+ * exec?: Function, signal?: Function, probe?: Function,
204
+ * kill?: (pid: number, sig: string) => {ok: boolean, error?: string},
205
+ * group?: boolean}} [options]
206
+ * @returns {Promise<{ok: boolean, code: number|null, error?: string}>}
207
+ */
208
+ export async function stopPid(pid, {
209
+ force = false,
210
+ graceMs = 30000,
211
+ platform = DEFAULT_PLATFORM,
212
+ exec = runCommand,
213
+ signal = signalState,
214
+ probe = probePid,
215
+ kill = defaultKill,
216
+ group = false,
217
+ } = {}) {
218
+ const value = Number(pid)
219
+ if (!Number.isInteger(value) || value <= 0) return { ok: false, code: null, error: 'invalid pid' }
220
+ if (platform === 'win32') {
221
+ // A stop invalidates every cached answer: the pre-kill state must not leak into
222
+ // the post-kill verification below.
223
+ clearProbeCache(value)
224
+ const result = exec('taskkill', ['/PID', String(value), '/T', '/F'], 30000)
225
+ clearProbeCache(value)
226
+ if (result?.code === 0) return { ok: true, code: 0 }
227
+ // taskkill also fails when the process exited between probe and kill: that is
228
+ // success for our purposes, so verify instead of trusting the message.
229
+ const after = probe(value, { platform, exec, signal })
230
+ if (after.state === 'dead') return { ok: true, code: result?.code ?? null }
231
+ return {
232
+ ok: false,
233
+ code: result?.code ?? null,
234
+ error: String(result?.stderr ?? '').trim() || `taskkill exited with ${String(result?.code ?? 'no status')}`,
235
+ }
236
+ }
237
+ // A detached child (YMS) is its own group leader: signalling -pid also stops the
238
+ // helpers it spawned. Windows does the same via `taskkill /T`.
239
+ const target = group ? -value : value
240
+ if (force) {
241
+ const killed = kill(target, 'SIGKILL')
242
+ if (killed.ok !== true) return { ok: false, code: null, error: killed.error }
243
+ // SIGKILL is not instantly observable: verify instead of claiming success.
244
+ for (let i = 0; i < 10; i += 1) {
245
+ // eslint-disable-next-line no-await-in-loop
246
+ if (probe(value, { platform, exec, signal }).state === 'dead') return { ok: true, code: null }
247
+ // eslint-disable-next-line no-await-in-loop
248
+ await new Promise((resolve) => setTimeout(resolve, 150))
249
+ }
250
+ return { ok: false, code: null, error: 'SIGKILL 后进程仍存活' }
251
+ }
252
+ const term = kill(target, 'SIGTERM')
253
+ if (term.ok !== true) return { ok: false, code: null, error: term.error }
254
+ const deadline = Date.now() + Math.max(0, Number(graceMs) || 0)
255
+ while (Date.now() < deadline) {
256
+ // eslint-disable-next-line no-await-in-loop
257
+ if (probe(value, { platform, exec, signal }).state === 'dead') return { ok: true, code: null }
258
+ // eslint-disable-next-line no-await-in-loop
259
+ await new Promise((resolve) => setTimeout(resolve, 300))
260
+ }
261
+ const killed = kill(target, 'SIGKILL')
262
+ if (killed.ok !== true) return { ok: false, code: null, error: killed.error }
263
+ for (let i = 0; i < 10; i += 1) {
264
+ // eslint-disable-next-line no-await-in-loop
265
+ if (probe(value, { platform, exec, signal }).state === 'dead') return { ok: true, code: null }
266
+ // eslint-disable-next-line no-await-in-loop
267
+ await new Promise((resolve) => setTimeout(resolve, 150))
268
+ }
269
+ return { ok: false, code: null, error: 'SIGKILL 后进程仍存活' }
270
+ }