New:Socket for Asana Is Now Available.Learn more
Get Started

dsh-ctl

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

dsh-ctl - npm Package Compare versions

Comparing version
0.1.0
to
0.1.1
+9
-1
lib/index.js

@@ -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",

@@ -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 @@

#!/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 @@