+9
-1
@@ -37,2 +37,7 @@ /** | ||
| const RELAUNCH_JS = join(PLUGIN_ROOT, '..', 'scripts', 'relaunch.mjs') | ||
| /** 重启/自启接力日志(relaunch.mjs 写入自校验诊断,避免 EADDRINUSE 等被静默吞掉)。 */ | ||
| function relaunchLogPath() { | ||
| const home = process.env.DSH_HOME | ||
| return home ? join(home, 'dsh-ctl-relaunch.log') : join(process.cwd(), '.dsh-ctl-relaunch.log') | ||
| } | ||
| /** 插件自带版本号(从 package.json 读取,供 status 展示)。 */ | ||
@@ -177,5 +182,7 @@ const PLUGIN_VERSION = (() => { | ||
| const port = currentPort(ctx) | ||
| const logPath = relaunchLogPath() | ||
| const tokens = [process.execPath, RELAUNCH_JS] | ||
| if (port) tokens.push('--port', String(port)) | ||
| tokens.push('--pid', String(process.pid), '--cwd', process.cwd()) | ||
| tokens.push('--log', logPath) | ||
| tokens.push('--', ...currentLaunchTokens()) | ||
@@ -195,3 +202,3 @@ let relaunchPid = null | ||
| scheduleExit(ctx, 400) | ||
| return { ok: true, action: 'restart', relaunchPid, port } | ||
| return { ok: true, action: 'restart', relaunchPid, port, relaunchLog: logPath } | ||
| } | ||
@@ -214,2 +221,3 @@ | ||
| tokens.push('--skip-if-busy', '--cwd', process.cwd()) | ||
| tokens.push('--log', relaunchLogPath()) | ||
| tokens.push('--', ...currentLaunchTokens()) | ||
@@ -216,0 +224,0 @@ const cmdLine = tokens.map(vbsQuote).join(' ') |
+1
-1
| { | ||
| "name": "dsh-ctl", | ||
| "version": "0.1.0", | ||
| "version": "0.1.1", | ||
| "description": "DeepSeek Harness (dsh) lifecycle control plugin: graceful stop, restart, status and Windows logon autostart from inside dsh — slash commands, local HTTP endpoints, and a reusable service.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+2
-1
@@ -95,3 +95,4 @@ # dsh-ctl — DeepSeek Harness 生命周期控制插件 | ||
| - **停止**:调用 dsh boot 提供的 `appExit`(优雅 dispose,先落盘会话与检查点);不可用时回退 `SIGTERM`。 | ||
| - **重启 / 自启**:复用 `scripts/relaunch.mjs` —— 轮询旧 PID / 端口释放后,以 `detached + windowsHide + stdio ignore` 方式拉起与当前完全相同的命令(`node <bin.js> web ...`),进程与任何终端脱离,关 shell 不影响它。 | ||
| - **重启 / 自启**:复用 `scripts/relaunch.mjs` —— 轮询旧 PID / 端口释放后,以 `detached + windowsHide` 方式拉起与当前完全相同的命令(`node <bin.js> web ...`),进程与任何终端脱离,关 shell 不影响它。 | ||
| - **自校验(0.1.1 起)**:`relaunch.mjs` 拉起新进程后不再静默退出,而是把新进程输出追加写入日志(默认 `$DSH_HOME/dsh-ctl-relaunch.log`),在约 45 秒内校验「新进程存活 && 端口已监听」并做 2 秒稳定性确认。若新进程因 `EADDRINUSE` 等提前退出、或端口被别的进程(保活/双开/升级残留)抢占,会读日志尾部诊断、按 `--retries` 重试,重试耗尽后以非零退出码报错——绝不再「拉起失败却假装成功」。 | ||
| - **自启**:`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\dsh-ctl-autostart.vbs`,由插件在 `autostart on` 时生成(含当前端口与工作目录);`--skip-if-busy` 保证登录时已有实例不会重复启动。 | ||
@@ -98,0 +99,0 @@ |
+166
-21
| #!/usr/bin/env node | ||
| /** | ||
| * dsh-ctl / relaunch.mjs — 静默重启与登录自启的辅助脚本。 | ||
| * dsh-ctl / relaunch.mjs — 静默重启与登录自启辅助脚本(自校验版)。 | ||
| * | ||
| * 职责:等待条件满足后,以「脱离终端、隐藏窗口」的方式拉起与旧进程完全相同的 | ||
| * dsh web 命令,然后自己退出(不托管子进程)。 | ||
| * 职责:等待旧进程退出、端口空闲后,以「脱离终端、隐藏窗口」的方式拉起与旧进程 | ||
| * 完全相同的 dsh web 命令,然后【校验】新进程确实存活并绑定端口,而不是被别的进程 | ||
| * (保活/双开/升级残留)抢占端口后静默失败。 | ||
| * | ||
| * 用法: | ||
| * node relaunch.mjs [--pid <pid>] [--port <port>] [--skip-if-busy] | ||
| * [--cwd <dir>] -- <node> <bin.js> [args...] | ||
| * [--cwd <dir>] [--log <file>] [--wait-ms <ms>] [--retries <n>] | ||
| * -- <node> <bin.js> [args...] | ||
| * | ||
@@ -17,4 +19,11 @@ * 等待规则(按顺序): | ||
| * 3. --port:轮询直到端口空闲(最多 30 秒)。 | ||
| * 然后 spawn 命令:detached(脱离父进程组)、windowsHide(隐藏窗口)、 | ||
| * stdio ignore(不占任何控制台),unref 后本进程立即退出。 | ||
| * | ||
| * 启动后校验(本版本新增): | ||
| * - 把新进程的 stdout/stderr 追加写入 --log(默认 $DSH_HOME/dsh-ctl-relaunch.log, | ||
| * 或 <cwd>/.dsh-ctl-relaunch.log),不再用 stdio:'ignore' 静默吞掉 EADDRINUSE 等。 | ||
| * - 在 --wait-ms(默认 45s)内轮询「新进程存活 && 端口已监听」,并做 2 秒稳定性 | ||
| * 确认,避免把「端口被他人占用、新进程即将 EADDRINUSE 退出」误判为成功。 | ||
| * - 新进程提前退出(如 EADDRINUSE)→ 读取日志尾部诊断;若端口仍被别的进程占用 | ||
| * 则点名;按 --retries(默认 2)重新等待端口空闲后重试。 | ||
| * - 校验通过才 exit 0;重试耗尽仍失败 → 非零退出并把完整诊断写入日志。 | ||
| */ | ||
@@ -24,2 +33,4 @@ | ||
| import { connect } from 'node:net' | ||
| import { openSync, closeSync, readFileSync, appendFileSync } from 'node:fs' | ||
| import { join } from 'node:path' | ||
| import process from 'node:process' | ||
@@ -30,3 +41,12 @@ | ||
| function parseArgs(argv) { | ||
| const options = { pid: null, port: null, cwd: null, skipIfBusy: false, command: [] } | ||
| const options = { | ||
| pid: null, | ||
| port: null, | ||
| cwd: null, | ||
| log: null, | ||
| waitMs: 45000, | ||
| retries: 2, | ||
| skipIfBusy: false, | ||
| command: [], | ||
| } | ||
| let passthrough = false | ||
@@ -55,2 +75,14 @@ for (let i = 0; i < argv.length; i++) { | ||
| } | ||
| if (arg === '--log') { | ||
| options.log = argv[++i] | ||
| continue | ||
| } | ||
| if (arg === '--wait-ms') { | ||
| options.waitMs = Number(argv[++i]) || 45000 | ||
| continue | ||
| } | ||
| if (arg === '--retries') { | ||
| options.retries = Number(argv[++i]) || 0 | ||
| continue | ||
| } | ||
| if (arg === '--skip-if-busy') { | ||
@@ -98,11 +130,106 @@ options.skipIfBusy = true | ||
| function defaultLogPath(options) { | ||
| if (options.log) return options.log | ||
| const home = process.env.DSH_HOME | ||
| if (home) return join(home, 'dsh-ctl-relaunch.log') | ||
| return join(options.cwd || process.cwd(), '.dsh-ctl-relaunch.log') | ||
| } | ||
| function logTail(path, lines = 40) { | ||
| try { | ||
| const text = readFileSync(path, 'utf8') | ||
| return text.split(/\r?\n/).filter(Boolean).slice(-lines).join('\n') | ||
| } catch { | ||
| return '' | ||
| } | ||
| } | ||
| /** 拉起一次新进程;stdout/stderr 追加写入 logPath,捕获退出码。 */ | ||
| function spawnOnce(options, logPath) { | ||
| let fd = null | ||
| try { | ||
| fd = openSync(logPath, 'a') | ||
| } catch { | ||
| fd = null | ||
| } | ||
| const stdio = fd === null ? 'ignore' : ['ignore', fd, fd] | ||
| const child = spawn(options.command[0], options.command.slice(1), { | ||
| cwd: options.cwd || process.cwd(), | ||
| detached: true, | ||
| stdio, | ||
| windowsHide: true, | ||
| }) | ||
| if (fd !== null) { | ||
| try { | ||
| closeSync(fd) | ||
| } catch { | ||
| /* ignore */ | ||
| } | ||
| } | ||
| const info = { child, exitCode: null, signal: null } | ||
| child.once('exit', (code, signal) => { | ||
| info.exitCode = code | ||
| info.signal = signal | ||
| }) | ||
| child.unref() | ||
| return info | ||
| } | ||
| /** | ||
| * 校验新进程:在 waitMs 内等待「存活 && 端口监听」,并做 2 秒稳定性确认。 | ||
| * 返回 { ok: true } 或 { ok: false, reason, ... }。 | ||
| */ | ||
| async function verify(info, options, logPath) { | ||
| const deadline = Date.now() + options.waitMs | ||
| let seenUpAt = 0 | ||
| while (Date.now() < deadline) { | ||
| const alive = pidAlive(info.child.pid) | ||
| const listening = options.port === null ? true : !(await portFree(options.port)) | ||
| if (!alive) { | ||
| return { | ||
| ok: false, | ||
| reason: 'exited', | ||
| exitCode: info.exitCode, | ||
| signal: info.signal, | ||
| logTail: logTail(logPath), | ||
| } | ||
| } | ||
| if (alive && listening) { | ||
| if (seenUpAt === 0) { | ||
| seenUpAt = Date.now() | ||
| } else if (Date.now() - seenUpAt >= 2000) { | ||
| return { ok: true } | ||
| } | ||
| } else { | ||
| seenUpAt = 0 | ||
| } | ||
| await sleep(400) | ||
| } | ||
| return { | ||
| ok: false, | ||
| reason: 'timeout', | ||
| detail: `child alive but port not bound within ${options.waitMs}ms`, | ||
| logTail: logTail(logPath), | ||
| } | ||
| } | ||
| async function main() { | ||
| const { pid, port, cwd, skipIfBusy, command } = parseArgs(process.argv.slice(2)) | ||
| if (command.length === 0) { | ||
| const options = parseArgs(process.argv.slice(2)) | ||
| if (options.command.length === 0) { | ||
| process.stderr.write('relaunch: no command after --\n') | ||
| process.exit(1) | ||
| } | ||
| const logPath = defaultLogPath(options) | ||
| const log = (msg) => { | ||
| const line = `${new Date().toISOString()} ${msg}\n` | ||
| try { | ||
| appendFileSync(logPath, line, 'utf8') | ||
| } catch { | ||
| /* ignore */ | ||
| } | ||
| process.stderr.write(line) | ||
| } | ||
| if (skipIfBusy && port) { | ||
| const free = await waitUntil(() => portFree(port), 3000, 250) | ||
| if (options.skipIfBusy && options.port) { | ||
| const free = await waitUntil(() => portFree(options.port), 3000, 250) | ||
| if (!free) { | ||
@@ -114,13 +241,31 @@ // 已有实例在跑:静默退出,不重复启动。 | ||
| if (pid) await waitUntil(() => !pidAlive(pid), 30000, 300) | ||
| if (port) await waitUntil(() => portFree(port), 30000, 300) | ||
| if (options.pid) await waitUntil(() => !pidAlive(options.pid), 30000, 300) | ||
| if (options.port) await waitUntil(() => portFree(options.port), 30000, 300) | ||
| const child = spawn(command[0], command.slice(1), { | ||
| cwd: cwd || process.cwd(), | ||
| detached: true, | ||
| stdio: 'ignore', | ||
| windowsHide: true, | ||
| }) | ||
| child.unref() | ||
| process.exit(0) | ||
| let last = { ok: false, reason: 'unreachable' } | ||
| const totalAttempts = options.retries + 1 | ||
| for (let attempt = 0; attempt < totalAttempts; attempt++) { | ||
| log(`relaunch attempt ${attempt + 1}/${totalAttempts}: ${options.command.join(' ')}`) | ||
| const info = spawnOnce(options, logPath) | ||
| const result = await verify(info, options, logPath) | ||
| if (result.ok) { | ||
| log(`verified: new process PID ${info.child.pid} is serving port ${options.port}`) | ||
| process.exit(0) | ||
| } | ||
| last = result | ||
| const suffix = result.exitCode === null ? '' : ` (exit ${result.exitCode})` | ||
| log(`attempt ${attempt + 1} failed: ${result.reason}${suffix}`) | ||
| if (result.detail) log(` detail: ${result.detail}`) | ||
| if (result.logTail) log(` log tail:\n${result.logTail}`) | ||
| if (result.reason === 'exited' && options.port !== null && !(await portFree(options.port))) { | ||
| log(` note: port ${options.port} is still occupied by another process ` + | ||
| '(possible keep-alive/double-start/upgrade leftover); the new instance could not bind.') | ||
| } | ||
| if (attempt < options.retries) { | ||
| if (options.port) await waitUntil(() => portFree(options.port), 30000, 300) | ||
| } | ||
| } | ||
| log(`relaunch failed after ${totalAttempts} attempts: ${last.reason}`) | ||
| process.exit(1) | ||
| } | ||
@@ -127,0 +272,0 @@ |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
33169
20.87%691
27.26%111
0.91%8
33.33%