@mzzsfy/dsh-maintain 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -9,8 +9,8 @@ DeepSeek Harness 设置页插件:版本与进程运维一体化——监测 npm
9
9
  - 追踪通道:在 npm dist-tags 间切换(latest / next / alpha 等,选项由检查结果动态生成),切换后按新通道判定。
10
10
  - 一键安装:面板"安装"按钮全版本状态可点(升级、重装修复、回退均从面板直达,不再有"已是最新"禁用与 409 拒绝),点击弹出确认窗,窗内展示版本变更与将执行的实际命令(`{tag}` 已替换为追踪通道)及"安装完成后自动重启宿主"勾选项(默认勾选,本次安装会话级生效,不持久化),确认后才触发。版本变更按安装意图自动表述:命令模板含精确版本号(如 `@0.1.4`)即按"安装指定版本"表述并附改回 `{tag}` 的指引;否则按通道目标与运行版本分类为升级/重装/回退,信息不足(通道最新版未知或版本无法比较)时窗内明示无法判定,仍可确认安装。通道目标等于运行版本即重装(用于本体损坏或异常安装的修复);需要回退时把模板中的 `@{tag}` 改为指定版本号(如 `@0.1.4`),回退完成后改回 `{tag}` 形态,否则后续安装持续钉在该版本。安装期间串行化(含跨进程锁文件:宿主被外部重启后,残留安装子进程的锁在有效期内仍拒绝新安装),单次尝试超时 10 分钟强杀;安装后台执行,触发后可离开页面,右下角浮条持续提示进度,完成或失败时替换为结果提示,长时间无进展转提示刷新,回到页面查看结果并按提示重启生效;继续运行路径下安装完成后自动重新检查版本。
11
11
  - 失败分类与自动重试:升级命令失败按输出特征分类(文件锁 EBUSY/EPERM 等 Windows 全局目录占用、网络瞬断与 registry 5xx、命令未找到、超时强杀、其他未知),文件锁与网络类自动重试,最多 3 次尝试,退避序列文件锁 5s/15s、网络 3s/9s,重试时升级锁有效期顺延;命令未找到、超时与未知形态不重试直接落定。重试链的整体观察窗口与宿主侧重试上限同源锁定(parity),浮条不会在宿主仍在重试时抢跑转"状态未知"。
12
- - 安装结果校验:安装命令成功且宿主继续运行(手动直跑、本次未勾选自动重启或宿主不具备自动退出能力)时复读磁盘版本,按安装意图判定是否达成——钉定版本(命令含精确版本号)时恰装到该版本即达成;通道语义下目标高于来版(升级)时要求版本前进且达通道目标,版本未前进(镜像滞后或静默失败)或未达目标即标注 stale;目标等于来版(重装)时版本不变属预期;目标低于来版(回退,如切通道或装旧版)时恰落到通道目标即达成,静默未动同样拦下。未达成时面板与浮条标注 stale 提示;复读失败不标 stale(版本状态由启动检查兜底),通道目标未知时仅豁免未达目标判定(版本未前进仍拦下)。自动重启路径不做复读与 stale 判定(安装落定钩子禁止网络请求与磁盘读取),版本变化由重启后的启动检查呈现。残留升级锁(宿主被外部重启后的无主锁,疑似安装子进程仍在写全局目录)在有效期内拒绝新安装(409),等待锁过期或手动删除锁文件后重试。
13
- - 运行环境检测与升级后自动重启:host 启动时检测运行环境(pm2 / systemd / supervisord / Kubernetes / 容器,依据进程环境变量与 POSIX 容器特征,win32 跳过容器探测;stdin+stdout 双 TTY 判定为手动直跑;环境变量 DSH_MAINTAIN_RUNTIME_ENV=managed|manual 可强制指定,最高优先)。升级确认弹窗勾选自动重启且命令成功(退出码 0)时,托管环境下延迟 3 秒自动重启宿主——与面板"确认重启"共用同一退出调度入口(同一互斥、同一审计、同一延迟退出,仅审计行不同),落定钩子零网络请求零磁盘读取(npm 刚替换过宿主文件,避免半写状态故障);浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新;手动直跑环境不自动退出,浮条提示手动重启(弹窗内同步提示该环境约束);检测为未知形态按托管处理(维持自动重启,由重启等待总超时兜底提示)。升级失败或未勾选自动重启时不调度,后者升级完成仅提示,需在面板手动重启。
12
+ - 安装结果校验:安装命令成功且宿主继续运行(本次未勾选自动重启或宿主不具备自动退出能力)时复读磁盘版本,按安装意图判定是否达成——钉定版本(命令含精确版本号)时恰装到该版本即达成;通道语义下目标高于来版(升级)时要求版本前进且达通道目标,版本未前进(镜像滞后或静默失败)或未达目标即标注 stale;目标等于来版(重装)时版本不变属预期;目标低于来版(回退,如切通道或装旧版)时恰落到通道目标即达成,静默未动同样拦下。未达成时面板与浮条标注 stale 提示;复读失败不标 stale(版本状态由启动检查兜底),通道目标未知时仅豁免未达目标判定(版本未前进仍拦下)。自动重启路径不做复读与 stale 判定(安装落定钩子禁止网络请求与磁盘读取),版本变化由重启后的启动检查呈现。残留升级锁(宿主被外部重启后的无主锁,疑似安装子进程仍在写全局目录)在有效期内拒绝新安装(409),等待锁过期或手动删除锁文件后重试。
13
+ - 升级后自动重启:升级确认弹窗勾选自动重启且命令成功(退出码 0)时,延迟 3 秒自动重启宿主——与面板"确认重启"共用同一退出调度入口(同一互斥、同一审计、同一延迟退出,仅审计行不同),动作与运行环境无关:托管启动(pm2 / systemd / 容器等)由进程管理器拉起,手动终端启动则需你重新运行宿主(等同点击重启按钮后的流程)。落定钩子零网络请求零磁盘读取(npm 刚替换过宿主文件,避免半写状态故障);浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新,手动终端启动下该等待按 30 秒总时长超时转人工(见重启须知)。升级失败或未勾选自动重启时不调度,后者升级完成仅提示,需在面板手动重启;宿主不具备就地退出能力(appExit 缺失)时同样不调度。
14
14
  - 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm 作用域解析,共享根探测仅兜底,无 agent 时服务面不可见属常态不计降级;降级按服务留痕限频一次)。部分降级时已实证的活跃计数仍生效(计数>0 照常拒绝);仅当完全不可用(计数为 0 且检测不可用)时放行,不因安全网缺失死锁操作。存在活跃工作时升级被拒绝(409,不可越过);重启被拒但确认态显示活跃计数,再次点击以 force 越过;概要随 status 提供给面板。
15
15
  - 审计日志:升级/重启的触发、落定与拒绝各输出一行结构化日志(`audit endpoint=… outcome=…`),动态串单行化,便于事后排查。
16
16
  - 面板设置编辑:升级命令、轮询间隔、镜像地址均可在面板直接编辑保存,与 settings.yaml 等效(轮询间隔保存即重排,镜像地址保存即重查);host 侧拒绝空命令、非 http(s) 地址与带查询串/锚点的镜像地址(拼接 dist-tags 路径时 query 会吞掉路径),面板清空输入即恢复对应默认值。
@@ -57,3 +57,7 @@ maintain:
57
57
  ```
58
58
  cd packages/dsh-maintain && npm test
59
59
  ```
60
+
61
+ ## dsh 版本兼容
62
+
63
+ 三版本全部通过:版本与运维页控件完整、检查/升级/重启入口可用、激活 live。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-maintain",
3
3
  "description": "DSH 自身的版本管理:追踪 npm dist-tag 新版本、一键升级、安全重启",
4
- "version": "0.9.0",
4
+ "version": "0.10.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -160,8 +160,6 @@ function restartPostLost(error) {
160
160
  const VERDICT_OUTDATED = 'outdated'
161
161
  const VERDICT_UP_TO_DATE = 'up-to-date'
162
162
  const VERDICT_UNKNOWN = 'unknown'
163
- // 与 host runtime.mjs RUNTIME_KINDS.MANUAL_START 镜像(parity 对拍,单侧改名即测试失败)
164
- const RUNTIME_KIND_MANUAL = 'manual-start-likely'
165
163
  // 与 host AUTO_RESTART_DELAY_MS 镜像(parity 对拍):弹窗文案引用,勿手抄数字
166
164
  const AUTO_RESTART_DELAY_SEC = 3
167
165
 
@@ -227,8 +225,9 @@ async function recheckUpgradeSettle(previousLast) {
227
225
  if (upgradeWatch.generation !== null) return
228
226
  if (final !== null) broadcastUpgradeStatus(final)
229
227
  const last = final !== null && final.upgrade ? final.upgrade.last : null
230
- if (last !== null && (last.autoRestartScheduled === true || last.requiresManualRestart === true)) showUpgradeFloat(last)
231
- else showUpgradeFloat(previousLast)
228
+ if (last !== null && last.autoRestartScheduled === true) showUpgradeFloat(last)
229
+ // 补查仍无落定结果(观察窗错过且宿主已换代):转状态未知,不再停留进行中文案
230
+ else showUpgradeFloat(previousLast !== null ? previousLast : 'unknown')
232
231
  }
233
232
 
234
233
  function subscribeUpgradeStatus(listener) {
@@ -258,10 +257,11 @@ function ensureUpgradeWatch() {
258
257
  const upgrade = next ? next.upgrade : null
259
258
  if (!upgrade || upgrade.running !== true) {
260
259
  upgradeWatch.generation = null
261
- // 快照归零(last 为空)不做失败渲染,保持进行中文案,由重启流程接管
260
+ // 落定拍(last 在)但未见调度标记不立即终判:继续运行路径的 stale 复读在
261
+ // 落定后异步完成,延迟宽限补查一拍再取终态;快照归零(last 空,宿主已换代)
262
+ // 同样补查,仍无结果即转状态未知,不留永久"进行中"
262
263
  if (upgrade && upgrade.last) {
263
- // 落定拍可能早于宿主置调度标记:未见标记不立即终判,延迟宽限后补查一拍
264
- if (upgrade.last.autoRestartScheduled === true || upgrade.last.requiresManualRestart === true) {
264
+ if (upgrade.last.autoRestartScheduled === true) {
265
265
  showUpgradeFloat(upgrade.last)
266
266
  return
267
267
  }
@@ -269,6 +269,11 @@ function ensureUpgradeWatch() {
269
269
  if (upgradeWatch.generation !== null) return
270
270
  void recheckUpgradeSettle(upgrade.last)
271
271
  }, UPGRADE_AUTO_RESTART_GRACE_MS)
272
+ } else if (upgrade) {
273
+ setTimeout(() => {
274
+ if (upgradeWatch.generation !== null) return
275
+ void recheckUpgradeSettle(null)
276
+ }, UPGRADE_AUTO_RESTART_GRACE_MS)
272
277
  }
273
278
  return
274
279
  }
@@ -306,7 +311,6 @@ function ensureUpgradeFloatStyle() {
306
311
  // LOGIC-BEGIN upgradeFinalText
307
312
  function upgradeFinalText(state) {
308
313
  if (state.stale === true) return '升级命令执行完成,但结果与安装意图不符,详情见版本与运维页'
309
- if (state.requiresManualRestart === true) return '升级完成,当前为手动直跑环境,请手动重启宿主后生效'
310
314
  if (state.autoRestartScheduled === true) return '升级完成,宿主将自动重启,页面恢复后可直接使用'
311
315
  return '升级完成,重启宿主后生效(版本与运维页可重启)'
312
316
  }
@@ -661,14 +665,14 @@ function UpgradeDialog(props) {
661
665
  label: '安装完成后自动重启宿主',
662
666
  checked: autoRestart,
663
667
  onChange: setAutoRestart,
668
+ disabled: status.canRestart !== true,
664
669
  }),
665
670
  h('div', { className: 'dm-dialog__outcome' },
666
- autoRestart
667
- ? '已勾选:安装命令执行成功后,宿主将在 ' + AUTO_RESTART_DELAY_SEC + ' 秒后自动退出,由你的进程管理器(docker / pm2 / systemd 等)拉起,本页随宿主恢复自动刷新。安装落定后宿主不再执行任何网络请求与磁盘读取。'
668
- : '未勾选:安装完成后宿主继续运行当前版本,面板提示「重启宿主后生效」,需要你手动点击重启按钮才切换到目标版本。'),
669
- status.runtimeEnv && status.runtimeEnv.kind === RUNTIME_KIND_MANUAL
670
- ? h('div', { className: 'dm-dialog__outcome dm-warn' }, '当前为手动终端直跑环境:没有进程管理器拉起宿主,勾选自动重启也不会自动退出,完成后仍需手动重启。')
671
- : null,
671
+ status.canRestart !== true
672
+ ? '当前启动方式不支持就地退出:无论是否勾选,安装完成后都需在宿主侧重启生效。'
673
+ : autoRestart
674
+ ? '已勾选:安装命令执行成功后,宿主将在 ' + AUTO_RESTART_DELAY_SEC + ' 秒后自动退出——等同点击重启按钮:托管启动时由进程管理器拉起,手动终端启动时需你重新运行宿主;本页随宿主恢复自动刷新。安装落定后宿主不再执行任何网络请求与磁盘读取。'
675
+ : '未勾选:安装完成后宿主继续运行当前版本,面板提示「重启宿主后生效」,需要你手动点击重启按钮才切换到目标版本。'),
672
676
  ),
673
677
  h('div', { className: 'dm-row' },
674
678
  h('span', { className: 'dm-spacer' }),
package/src/index.js CHANGED
@@ -26,7 +26,6 @@ import {
26
26
  UPGRADE_FAIL_TRANSIENT_NETWORK,
27
27
  } from './core.mjs'
28
28
  import { runUpgrade } from './upgrade.mjs'
29
- import { detectRuntimeEnv, RUNTIME_KINDS } from './runtime.mjs'
30
29
 
31
30
  export const name = 'dsh-maintain'
32
31
 
@@ -208,16 +207,15 @@ export async function runUpgradeWithRetry({ command, timeoutMs = UPGRADE_TIMEOUT
208
207
  return { attempts, ok: false, kind: null, stillRunning: false, stdoutTail: '', stderrTail: '', error: null }
209
208
  }
210
209
 
211
- // 升级后自动重启守卫:四条件缺一不可——成功、非手动直跑、本次勾选 enabled、appExit 可用。
212
- // 手动直跑(双 TTY)进程退出后无人拉起,只标 requiresManualRestart 交面板指引,
213
- // 该指引是环境约束事实,不受勾选影响;enabled 由升级路由严格 boolean 校验保证
210
+ // 升级后自动重启守卫:三条件缺一不可——成功、本次勾选 enabled、appExit 可用。
211
+ // 宿主退出动作与面板"确认重启"完全同一调度入口,拉起责任归启动方式:
212
+ // 托管环境由进程管理器拉起,手动直跑等同点了重启按钮后自行再启动
214
213
  // 无 stale 条件:版本复读属升级后磁盘读取,关机路径禁止;版本是否前进由重启后的启动检查呈现
215
- export function judgeAutoRestart({ ok, runtimeKind, hasExit, enabled }) {
216
- if (ok !== true) return { schedule: false, requiresManualRestart: false }
217
- if (runtimeKind === RUNTIME_KINDS.MANUAL_START) return { schedule: false, requiresManualRestart: true }
218
- if (enabled !== true) return { schedule: false, requiresManualRestart: false }
219
- if (hasExit !== true) return { schedule: false, requiresManualRestart: false }
220
- return { schedule: true, requiresManualRestart: false }
214
+ export function judgeAutoRestart({ ok, hasExit, enabled }) {
215
+ if (ok !== true) return { schedule: false }
216
+ if (enabled !== true) return { schedule: false }
217
+ if (hasExit !== true) return { schedule: false }
218
+ return { schedule: true }
221
219
  }
222
220
 
223
221
  // 浏览器半区调用的 API 路径清单:client.js 同名常量与之对拍(parity),防单侧改路径生产 404
@@ -386,16 +384,6 @@ export function apply(ctx) {
386
384
  const runningVersionReady = resolveCurrentHostVersion()
387
385
  .then((version) => { runningVersion = version })
388
386
  .catch(() => {})
389
-
390
- // 运行环境:apply 时一次检测;探测异步,以 ready 链收口,异常回退 unknown(维持自动重启)
391
- let runtimeEnv = { kind: RUNTIME_KINDS.UNKNOWN, declared: false }
392
- const runtimeEnvReady = detectRuntimeEnv({
393
- env: process.env,
394
- platform: process.platform,
395
- isTTY: { stdin: process.stdin.isTTY === true, stdout: process.stdout.isTTY === true },
396
- })
397
- .then((env) => { runtimeEnv = env })
398
- .catch(() => {})
399
387
  let upgrade = { running: false, last: null }
400
388
  let checkInFlight = null
401
389
  let nextDueAt = null
@@ -460,8 +448,8 @@ export function apply(ctx) {
460
448
  }
461
449
 
462
450
  async function currentStatus() {
463
- // 等运行版本首读与运行环境检测落定:apply 即发起,此处仅吸收启动窗口的微小延迟
464
- await Promise.all([runningVersionReady, runtimeEnvReady])
451
+ // 等运行版本首读落定:apply 即发起,此处仅吸收启动窗口的微小延迟
452
+ await runningVersionReady
465
453
  const config = readSettings(ctx)
466
454
  const judged = judgeNow()
467
455
  // running 即视为持锁:省一次盘读,且窗口期语义与 upgrade 路由的门闩一致
@@ -486,7 +474,6 @@ export function apply(ctx) {
486
474
  checkedAt: snapshot.checkedAt,
487
475
  checkError: snapshot.error,
488
476
  upgrade,
489
- runtimeEnv,
490
477
  autoRestartScheduled,
491
478
  activeWork: collectActiveWork(ctx),
492
479
  canRestart: typeof exit === 'function',
@@ -592,11 +579,9 @@ export function apply(ctx) {
592
579
  + (last.stale === true ? ' stale=' + singleLine(last.reason) : ''))
593
580
  audit('upgrade', last.ok === true ? 'ok' : 'failed',
594
581
  'durationMs=' + (last.finishedAt - last.startedAt) + ' code=' + last.code + ' kind=' + singleLine(last.kind))
595
- // 关机前置判定只取内存输入(启动期缓存的运行环境 + 本次会话勾选):升级命令刚
582
+ // 关机前置判定只取内存输入(本次会话勾选 + 启动器退出能力):升级命令刚
596
583
  // 替换过宿主磁盘文件,落定钩子发起网络请求或读取磁盘都是半写状态下的故障源
597
- await runtimeEnvReady
598
- const decision = judgeAutoRestart({ ok: last.ok === true, runtimeKind: runtimeEnv.kind, hasExit: typeof exit === 'function', enabled: autoRestart === true })
599
- if (decision.requiresManualRestart === true) last.requiresManualRestart = true
584
+ const decision = judgeAutoRestart({ ok: last.ok === true, hasExit: typeof exit === 'function', enabled: autoRestart === true })
600
585
  if (decision.schedule === true) {
601
586
  // 关机路径零收尾:与确认重启同一内部动作,直接进入延迟退出
602
587
  // last 镜像调度标记:浮条终态文案按其分流(与 status.autoRestartScheduled 同值)
@@ -604,7 +589,7 @@ export function apply(ctx) {
604
589
  scheduleHostExit({ detail: 'reason=upgrade-ok', autoRestart: true, delayMs: AUTO_RESTART_DELAY_MS })
605
590
  return
606
591
  }
607
- // 继续运行路径(失败/手动直跑/本次未勾选自动重启):复读磁盘版本标 stale,版本未前进提示保留
592
+ // 继续运行路径(失败/本次未勾选自动重启/appExit 缺失):复读磁盘版本标 stale,版本未前进提示保留
608
593
  if (settle !== null && settle.ok) {
609
594
  // 复读属磁盘消费点,拒绝不得以 unhandledRejection 形式逃逸(void 触发即崩宿主):
610
595
  // 失败时放弃 stale 判定,保留已装版本 null 由启动检查兜底呈现
@@ -138,11 +138,11 @@ test('渲染冒烟: UpgradeDialog 工厂化执行不抛错且含版本变更行'
138
138
  const useState = (init) => [init, () => {}]
139
139
  const Switch = (propsArg) => h('label', propsArg)
140
140
  const dialog = new Function('h', 'useState', 'Switch', 'DEFAULT_UPGRADE_TEMPLATE', 'AUTO_RESTART_DELAY_SEC',
141
- 'RUNTIME_KIND_MANUAL', 'buildInstallChangeLine', 'buildInstallVersionHint',
141
+ 'buildInstallChangeLine', 'buildInstallVersionHint',
142
142
  'return (' + match[0] + ')')(
143
- h, useState, Switch, 'npm install -g @deepseek-ai/dsh@{tag}', 3, 'manual-start-likely',
143
+ h, useState, Switch, 'npm install -g @deepseek-ai/dsh@{tag}', 3,
144
144
  (status, command) => '变更:' + command, (status) => '提示:' + status.runningVersion)
145
- const status = { channel: 'latest', upgradeTemplate: '', runningVersion: '0.1.5', installedVersion: '0.1.5', runtimeEnv: null }
145
+ const status = { channel: 'latest', upgradeTemplate: '', runningVersion: '0.1.5', installedVersion: '0.1.5' }
146
146
  const tree = dialog({ status, onCancel: () => {}, onConfirm: () => {} })
147
147
  assert.equal(tree.type, 'div', '弹窗根为遮罩 div')
148
148
  assert.ok(calls.some((type) => type === 'pre'), '命令 pre 节点应在树中')
@@ -200,13 +200,6 @@ test('parity: VERDICT 三常量 client 与 core 一致', () => {
200
200
  assert.equal(extractConst('VERDICT_UNKNOWN'), VERDICT_UNKNOWN)
201
201
  })
202
202
 
203
- // host 侧锚点直接 import runtime.mjs 实现,防测试内手抄字面量漂移假绿
204
- import { RUNTIME_KINDS } from '../src/runtime.mjs'
205
-
206
- test('parity: 手动直跑运行环境常量 client 与 host 一致', () => {
207
- assert.equal(extractConst('RUNTIME_KIND_MANUAL'), RUNTIME_KINDS.MANUAL_START)
208
- })
209
-
210
203
  test('parity: 弹窗文案自动重启延迟秒与 host AUTO_RESTART_DELAY_MS 换算一致', () => {
211
204
  assert.equal(extractNumberConst('AUTO_RESTART_DELAY_SEC'), AUTO_RESTART_DELAY_MS / 1000)
212
205
  })
@@ -255,12 +248,13 @@ test('parity: client API 路径常量与 host 路由清单逐条一致', () => {
255
248
  }
256
249
  })
257
250
 
258
- // 窗口关系对拍:重启等待总时长必须大于宿主退出延迟,否则宿主还在延迟退出窗口内客户端已报超时
251
+ // 窗口关系对拍:重启等待总时长必须大于任一退出延迟,否则宿主还在延迟退出窗口内客户端已报超时
259
252
  import { RESTART_DELAY_MS } from '../src/index.js'
260
253
 
261
- test('parity: 重启等待总时长大于宿主退出延迟', () => {
254
+ test('parity: 重启等待总时长大于宿主退出延迟(按钮与自动重启取大)', () => {
262
255
  const restartTimeoutMs = extractNumberConst('RESTART_TIMEOUT_MS')
263
- assert.ok(restartTimeoutMs > RESTART_DELAY_MS, 'RESTART_TIMEOUT_MS 必须大于 RESTART_DELAY_MS')
256
+ assert.ok(restartTimeoutMs > Math.max(RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS),
257
+ 'RESTART_TIMEOUT_MS 必须大于 max(RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS)')
264
258
  })
265
259
 
266
260
  test('parity: 升级观察上限覆盖宿主重试链上限(防抢跑转状态未知)', () => {
@@ -286,13 +280,12 @@ test('parity: GitHub releases 兜底链接与 host 拉取仓库同源', () => {
286
280
  assert.equal(extractConst('RELEASES_PAGE_URL'), 'https://github.com/' + RELEASE_REPO + '/releases')
287
281
  })
288
282
 
289
- test('parity: 落定补查宽限覆盖宿主自动重启调度延迟', () => {
290
- // 落定拍与调度置位之间存在宿主侧 await 窗口:client 补查宽限必须不小于调度延迟 + 观察裕量
283
+ test('parity: 落定补查宽限不小于观察轮询拍间隔', () => {
284
+ // 关机路径调度标记与 running=false 同拍置位,宽限不再竞态调度延迟;现职责是
285
+ // 给继续运行路径的 stale 异步复读留余量,下限锁定为不小于轮询拍间隔(补查不早于一拍)
291
286
  const graceMs = extractNumberConst('UPGRADE_AUTO_RESTART_GRACE_MS')
292
- assert.ok(
293
- graceMs >= AUTO_RESTART_DELAY_MS + 1 * 1000,
294
- 'UPGRADE_AUTO_RESTART_GRACE_MS(' + graceMs + ') 必须不小于 AUTO_RESTART_DELAY_MS(' + AUTO_RESTART_DELAY_MS + ') 加观察裕量',
295
- )
287
+ assert.ok(graceMs >= 2 * 1000,
288
+ 'UPGRADE_AUTO_RESTART_GRACE_MS(' + graceMs + ') 必须不小于观察轮询拍间隔(2s)')
296
289
  })
297
290
 
298
291
  test('源码契约: 重启轮询与升级观察器不得回退 setInterval 重叠拍形态', () => {
@@ -197,24 +197,23 @@ test('restartTick 调用点实参完整性:轮询 effect 必须传 readyStreak=p
197
197
  assert.match(callSite[1], /readyStreak:\s*prev\.readyStreak/, 'restartTick 调用点缺少 readyStreak: prev.readyStreak 实参')
198
198
  })
199
199
 
200
- // 升级浮条终态文案:ok 落定后 stale / 手动直跑 / 自动重启三条终态分流,失败与未知不在此列
200
+ // 升级浮条终态文案:ok 落定后 stale / 自动重启两条终态分流,失败与未知不在此列
201
201
  const clientUpgradeFinalText = extractLogic('upgradeFinalText')
202
202
 
203
203
  // 重启确认态文案:armed 且有活跃计数时改「仍要重启」
204
204
  const clientRestartConfirmLabel = extractLogic('restartConfirmLabel')
205
205
 
206
- test('upgradeFinalText: stale 优先于两种重启指引,不得引导重启', () => {
207
- const text = clientUpgradeFinalText({ ok: true, stale: true, requiresManualRestart: true, autoRestartScheduled: true })
206
+ test('upgradeFinalText: stale 优先于重启指引,不得引导重启', () => {
207
+ const text = clientUpgradeFinalText({ ok: true, stale: true, autoRestartScheduled: true })
208
208
  // stale 口径泛化:未前进/未达目标/未落到目标/未装到指定版本统称与安装意图不符
209
209
  assert.match(text, /意图不符/)
210
210
  assert.doesNotMatch(text, /重启/)
211
211
  })
212
212
 
213
- test('upgradeFinalText: 手动直跑终态指向手动重启,自动重启终态指向页面自恢复', () => {
214
- assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: true, autoRestartScheduled: false }), /手动重启/)
215
- assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: false, autoRestartScheduled: true }), /自动重启/)
213
+ test('upgradeFinalText: 自动重启终态指向页面自恢复,默认终态指向手动重启', () => {
214
+ assert.match(clientUpgradeFinalText({ ok: true, stale: false, autoRestartScheduled: true }), /自动重启/)
216
215
  // 默认成功终态
217
- assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: false, autoRestartScheduled: false }), /重启宿主/)
216
+ assert.match(clientUpgradeFinalText({ ok: true, stale: false, autoRestartScheduled: false }), /重启宿主/)
218
217
  })
219
218
 
220
219
  test('restartConfirmLabel: armed 且活跃计数>0 才显示仍要重启文案', () => {
@@ -240,7 +239,7 @@ test('client 自动重启接管:单一守卫函数,订阅回调与初始加载
240
239
 
241
240
  test('client 落定补查:未见自动重启标记时延迟宽限补查一拍再终判', () => {
242
241
  const source = clientSource()
243
- // 落定拍 running 翻转与宿主置调度标记之间存在 await 窗口:未见标记不得立即终判
242
+ // 落定拍可见时继续运行路径的 stale 复读可能尚未完成:未见调度标记不得立即终判
244
243
  assert.match(source, /setTimeout\(\(\)\s*=>\s*\{[\s\S]*?recheckUpgradeSettle\(/, '落定拍未见标记必须延迟补查')
245
244
  assert.match(source, /async function recheckUpgradeSettle\([\s\S]*?broadcastUpgradeStatus\(final\)/, '补查必须广播以驱动接管守卫')
246
245
  assert.match(source, /upgradeWatch\.generation !== null\) return[\s\S]*?recheckUpgradeSettle|recheckUpgradeSettle\([\s\S]*?upgradeWatch\.generation !== null\) return/, '补查前后必须验让位新观察')
@@ -424,10 +424,8 @@ test('upgrade:托管+勾选自动重启,命令成功即调度关机且落定钩
424
424
  // 关机路径零复读:版本复读属升级后磁盘读取,是明确的故障源,砍掉
425
425
  assert.equal(settled.upgrade.last.installedVersion, null, '关机路径禁止复读磁盘版本')
426
426
  assert.equal(settled.upgrade.last.stale, null, '关机路径无 stale 判定')
427
- assert.equal(settled.upgrade.last.requiresManualRestart, undefined)
428
427
  assert.equal(settled.autoRestartScheduled, true, '命令成功即调度关机(stale 不再抑制)')
429
428
  assert.equal(settled.upgradeLockHeld, false, '升级结束后锁文件应删除')
430
- assert.ok(typeof settled.runtimeEnv === 'object' && typeof settled.runtimeEnv.kind === 'string', 'runtimeEnv 必须进 status')
431
429
  // 落定钩子零网络:观察窗口内 fetch 计数不得增长(runCheck 已随关机路径移除)
432
430
  await realSleep(300)
433
431
  assert.equal(fetchCalls, baseline, '落定钩子禁止网络请求')
@@ -581,7 +579,6 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
581
579
  assert.equal(settled.upgrade.last.stale, true, '继续运行路径必须复读并标注 stale')
582
580
  assert.ok(typeof settled.upgrade.last.reason === 'string' && settled.upgrade.last.reason.length > 0, 'stale 必须带原因')
583
581
  assert.equal(settled.autoRestartScheduled, false, '未勾选不得置调度标记')
584
- assert.equal(settled.upgrade.last.requiresManualRestart, undefined, '托管环境未勾选不标手动指引')
585
582
  // 推过完整调度延迟:若误调度,延迟窗口内 exit 必被调用
586
583
  t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
587
584
  assert.deepEqual(exits, [], '未勾选自动重启时禁止调度任何宿主退出')
@@ -647,9 +644,8 @@ test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', asyn
647
644
  assert.ok(settled, '升级应在假命令退出后落定')
648
645
  assert.equal(settled.upgrade.last.ok, true)
649
646
  assert.equal(settled.upgrade.last.stale, null, '关机路径零复读,stale 不再判定')
650
- // 落定可见与调度置位之间隔 runtimeEnvReady 的 await:await 一次 status 让微任务链走完再断言
651
- const scheduled = await get(routes, '/api/maintain/status').then((r) => r.payload)
652
- assert.equal(scheduled.autoRestartScheduled, true, '勾选自动重启时升级成功必须调度')
647
+ // 调度标记与 running=false 同拍置位,落定快照直接可断言
648
+ assert.equal(settled.autoRestartScheduled, true, '勾选自动重启时升级成功必须调度')
653
649
  assert.deepEqual(exits, [], '调度延迟窗口内不得提前退出')
654
650
  t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
655
651
  assert.deepEqual(exits, [0], '延迟窗口过后必须调度宿主退出')
@@ -858,16 +854,17 @@ test('restart:null 体按空体处理,内部形态不泄漏', async (t) => {
858
854
  assert.deepEqual(exits, [0], 'null 体等价空体:正常调度退出')
859
855
  })
860
856
 
861
- test('自动重启接线:落定链消费运行环境并分流调度与手动指引(源码形态锁定)', () => {
857
+ test('自动重启接线:落定链只取内存输入并直接分流调度(源码形态锁定)', () => {
862
858
  const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8')
863
859
  const settle = source.match(/const decision = judgeAutoRestart\(\{([\s\S]*?)\}\)/)
864
860
  assert.ok(settle, '落定链缺少 judgeAutoRestart 判定')
865
- assert.match(settle[1], /runtimeKind: runtimeEnv\.kind/, '落定判定必须消费运行环境检测结果')
866
- assert.match(source, /if \(decision\.requiresManualRestart === true\) last\.requiresManualRestart = true/, '手动直跑指引必须回写 last')
861
+ assert.doesNotMatch(settle[1], /runtimeKind|runtimeEnv/, '落定判定禁止消费运行环境:升级即调度与启动形态无关')
862
+ assert.doesNotMatch(source, /requiresManualRestart/, '手动直跑指引链必须整体移除,不得残留回写')
867
863
  assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?scheduleHostExit\(\{ detail: 'reason=upgrade-ok', autoRestart: true/, '调度链必须置位标记并经统一退出入口关机')
868
864
  })
869
865
 
870
- test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', async () => {
866
+ test('upgrade:运行环境不参与自动重启判定(env 注入 manual 仍调度关机)', async () => {
867
+ // DSH_MAINTAIN_RUNTIME_ENV 已无消费者:注入历史值证明判定与运行环境彻底解耦
871
868
  process.env.DSH_MAINTAIN_RUNTIME_ENV = 'manual'
872
869
  try {
873
870
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
@@ -882,11 +879,9 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
882
879
  if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
883
880
  }
884
881
  assert.ok(settled, '升级应在假命令退出后落定')
885
- assert.equal(settled.runtimeEnv.kind, 'manual-start-likely', 'env 注入必须经 apply 检测进 status')
886
- assert.equal(settled.autoRestartScheduled, false, '手动直跑禁止调度自动重启')
887
- assert.deepEqual(exits, [], '手动直跑禁止任何宿主退出')
888
- // 手动直跑标环境指引:命令成功即提示手动重启(指引不再被 stale 抑制)
889
- assert.equal(settled.upgrade.last.requiresManualRestart, true, '手动直跑必须标手动重启指引')
882
+ assert.equal(settled.autoRestartScheduled, true, '升级成功勾选即调度,运行环境不得抑制')
883
+ assert.equal(settled.upgrade.last.requiresManualRestart, undefined, '手动指引链已移除')
884
+ assert.deepEqual(exits, [], '调度延迟窗口内不得提前退出')
890
885
  } finally {
891
886
  delete process.env.DSH_MAINTAIN_RUNTIME_ENV
892
887
  }
@@ -12,7 +12,6 @@ import {
12
12
  UPGRADE_FAIL_NPM_MISSING,
13
13
  UPGRADE_FAIL_TIMEOUT,
14
14
  } from '../src/core.mjs'
15
- import { RUNTIME_KINDS } from '../src/runtime.mjs'
16
15
 
17
16
  // 脚本体一律单引号:Windows shell 化 spawn 经 cmd.exe,双层双引号会被截断。
18
17
  const NODE = 'node'
@@ -219,29 +218,20 @@ test('重试:假命令真实进程 文件锁失败后重试成功', async () =>
219
218
  }
220
219
  })
221
220
 
222
- test('自动重启:四条件守卫(成功/非手动直跑/enabled/appExit 可用)', () => {
223
- // 成功+托管(含 unknown)+enabled=true → 调度
224
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
225
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
226
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.PM2, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
221
+ test('自动重启:三条件守卫(成功/enabled/appExit 可用),运行环境不参与判定', () => {
222
+ // 成功+enabled → 调度:宿主退出动作与重启按钮同一入口,拉起责任在启动方式
223
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: true }), { schedule: true })
227
224
  // 失败不调度
228
- assert.deepEqual(judgeAutoRestart({ ok: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: true }), { schedule: false, requiresManualRestart: false })
229
- // 手动直跑 → 手动指引,不调度
230
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true, enabled: true }), { schedule: false, requiresManualRestart: true })
225
+ assert.deepEqual(judgeAutoRestart({ ok: false, hasExit: true, enabled: true }), { schedule: false })
231
226
  // appExit 缺失不调度
232
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: false, enabled: true }), { schedule: false, requiresManualRestart: false })
233
- // 手动直跑与 appExit 缺失并存 → 仍以手动指引标记(指引面板,与退出能力无关)
234
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: false, enabled: true }), { schedule: false, requiresManualRestart: true })
227
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: false, enabled: true }), { schedule: false })
235
228
  })
236
229
 
237
230
  test('自动重启:enabled 严格 boolean,仅 true 调度', () => {
238
- // 显式 false:托管(含 unknown)不调度、不标手动指引
239
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: false })
240
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: false })
241
- // 手动直跑指引是环境约束事实,不受勾选影响
242
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: true })
231
+ // 显式 false 不调度
232
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: false }), { schedule: false })
243
233
  // 非 true 一律不调度:调用点经升级路由严格 boolean 校验,此处锁定纯函数自身契约
244
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: undefined }), { schedule: false, requiresManualRestart: false })
245
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: null }), { schedule: false, requiresManualRestart: false })
246
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: 'true' }), { schedule: false, requiresManualRestart: false })
234
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: undefined }), { schedule: false })
235
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: null }), { schedule: false })
236
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: 'true' }), { schedule: false })
247
237
  })
package/src/runtime.mjs DELETED
@@ -1,69 +0,0 @@
1
- // 运行环境检测:判定宿主进程是否处于托管启动形态,决定升级后可否自动重启。
2
- // 纯函数,探测点经参数注入;顺序:env 显式覆盖 > 进程环境表 > POSIX 容器探测 > 双 TTY > unknown。
3
- import { stat, readFile } from 'node:fs/promises'
4
-
5
- export const RUNTIME_KINDS = Object.freeze({
6
- DECLARED_MANAGED: 'declared-managed',
7
- PM2: 'pm2',
8
- SYSTEMD: 'systemd',
9
- SUPERVISORD: 'supervisord',
10
- KUBERNETES: 'kubernetes',
11
- DOCKER: 'docker',
12
- CONTAINER: 'container',
13
- MANUAL_START: 'manual-start-likely',
14
- UNKNOWN: 'unknown',
15
- })
16
-
17
- // 环境覆盖键:managed|manual,测试与用户显式纠偏用
18
- const RUNTIME_ENV_OVERRIDE = 'DSH_MAINTAIN_RUNTIME_ENV'
19
- const OVERRIDE_MANAGED = 'managed'
20
- const OVERRIDE_MANUAL = 'manual'
21
-
22
- // 各托管形态的特征环境变量表:命中任一即声明
23
- const MANAGED_ENV_KEYS = Object.freeze([
24
- [RUNTIME_KINDS.PM2, Object.freeze(['pm_id', 'PM2_HOME', 'pm_uptime'])],
25
- [RUNTIME_KINDS.SYSTEMD, Object.freeze(['INVOCATION_ID', 'JOURNAL_STREAM', 'NOTIFY_SOCKET'])],
26
- [RUNTIME_KINDS.SUPERVISORD, Object.freeze(['SUPERVISOR_ENABLED', 'SUPERVISOR_PROCESS_NAME'])],
27
- [RUNTIME_KINDS.KUBERNETES, Object.freeze(['KUBERNETES_SERVICE_HOST'])],
28
- ])
29
-
30
- const DOCKERENV_PATH = '/.dockerenv'
31
- const CGROUP_PATH = '/proc/1/cgroup'
32
- // cgroup 归类:/docker/ 与 dockerenv 同为 docker;kubepods 即 k8s;其余容器运行时归 container
33
- const CGROUP_DOCKER_PATTERN = /\/docker\//
34
- const CGROUP_K8S_PATTERN = /kubepods/
35
- const CGROUP_CONTAINER_PATTERN = /containerd|lxc|podman/
36
-
37
- const fallbackExists = async (path) => {
38
- try {
39
- await stat(path)
40
- return true
41
- } catch {
42
- return false
43
- }
44
- }
45
-
46
- const fallbackRead = async (path) => readFile(path, 'utf8')
47
-
48
- export async function detectRuntimeEnv({ env, platform, isTTY, existsImpl = fallbackExists, readFileImpl = fallbackRead }) {
49
- const override = env[RUNTIME_ENV_OVERRIDE]
50
- if (override === OVERRIDE_MANAGED) return { kind: RUNTIME_KINDS.DECLARED_MANAGED, declared: true }
51
- if (override === OVERRIDE_MANUAL) return { kind: RUNTIME_KINDS.MANUAL_START, declared: false }
52
-
53
- for (const [kind, keys] of MANAGED_ENV_KEYS) {
54
- if (keys.some((key) => env[key])) return { kind, declared: true }
55
- }
56
-
57
- if (platform !== 'win32') {
58
- try {
59
- if (await existsImpl(DOCKERENV_PATH)) return { kind: RUNTIME_KINDS.DOCKER, declared: true }
60
- const cgroup = await readFileImpl(CGROUP_PATH)
61
- if (CGROUP_DOCKER_PATTERN.test(cgroup)) return { kind: RUNTIME_KINDS.DOCKER, declared: true }
62
- if (CGROUP_K8S_PATTERN.test(cgroup)) return { kind: RUNTIME_KINDS.KUBERNETES, declared: true }
63
- if (CGROUP_CONTAINER_PATTERN.test(cgroup)) return { kind: RUNTIME_KINDS.CONTAINER, declared: true }
64
- } catch { /* 探测失败按未声明继续 */ }
65
- }
66
-
67
- if (isTTY.stdin === true && isTTY.stdout === true) return { kind: RUNTIME_KINDS.MANUAL_START, declared: false }
68
- return { kind: RUNTIME_KINDS.UNKNOWN, declared: false }
69
- }
@@ -1,67 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import { test } from 'node:test'
3
- import { detectRuntimeEnv, RUNTIME_KINDS } from '../src/runtime.mjs'
4
-
5
- const POSIX = 'linux'
6
- const WIN32 = 'win32'
7
- const BOTH_TTY = { stdin: true, stdout: true }
8
- const NO_TTY = { stdin: false, stdout: false }
9
- const noProbes = { existsImpl: async () => false, readFileImpl: async () => { throw new Error('ENOENT') } }
10
-
11
- test('环境检测:env 覆盖最高优先', async () => {
12
- const managed = await detectRuntimeEnv({ env: { DSH_MAINTAIN_RUNTIME_ENV: 'managed', pm_id: '0' }, platform: POSIX, isTTY: BOTH_TTY, ...noProbes })
13
- assert.deepEqual(managed, { kind: RUNTIME_KINDS.DECLARED_MANAGED, declared: true })
14
- const manual = await detectRuntimeEnv({ env: { DSH_MAINTAIN_RUNTIME_ENV: 'manual', pm_id: '0' }, platform: POSIX, isTTY: NO_TTY, ...noProbes })
15
- assert.deepEqual(manual, { kind: RUNTIME_KINDS.MANUAL_START, declared: false })
16
- // 非法覆盖值不生效,继续走常规判定
17
- const bogus = await detectRuntimeEnv({ env: { DSH_MAINTAIN_RUNTIME_ENV: 'sideways' }, platform: POSIX, isTTY: NO_TTY, ...noProbes })
18
- assert.deepEqual(bogus, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
19
- })
20
-
21
- test('环境检测:env 表命中各托管形态', async () => {
22
- const table = [
23
- [{ pm_id: '0' }, RUNTIME_KINDS.PM2],
24
- [{ PM2_HOME: 'C:/pm2' }, RUNTIME_KINDS.PM2],
25
- [{ pm_uptime: '123' }, RUNTIME_KINDS.PM2],
26
- [{ INVOCATION_ID: 'x' }, RUNTIME_KINDS.SYSTEMD],
27
- [{ JOURNAL_STREAM: '9' }, RUNTIME_KINDS.SYSTEMD],
28
- [{ NOTIFY_SOCKET: '/run/notify' }, RUNTIME_KINDS.SYSTEMD],
29
- [{ SUPERVISOR_ENABLED: '1' }, RUNTIME_KINDS.SUPERVISORD],
30
- [{ SUPERVISOR_PROCESS_NAME: 'dsh' }, RUNTIME_KINDS.SUPERVISORD],
31
- [{ KUBERNETES_SERVICE_HOST: '10.0.0.1' }, RUNTIME_KINDS.KUBERNETES],
32
- ]
33
- for (const [env, kind] of table) {
34
- const result = await detectRuntimeEnv({ env, platform: POSIX, isTTY: NO_TTY, ...noProbes })
35
- assert.deepEqual(result, { kind, declared: true }, JSON.stringify(env))
36
- }
37
- // 空串环境值不构成声明
38
- const empty = await detectRuntimeEnv({ env: { pm_id: '' }, platform: POSIX, isTTY: NO_TTY, ...noProbes })
39
- assert.deepEqual(empty, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
40
- })
41
-
42
- test('环境检测:POSIX 容器探测', async () => {
43
- const docker = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: NO_TTY, existsImpl: async (p) => p === '/.dockerenv', readFileImpl: async () => { throw new Error('ENOENT') } })
44
- assert.deepEqual(docker, { kind: RUNTIME_KINDS.DOCKER, declared: true })
45
- for (const [marker, kind] of [['/docker/', RUNTIME_KINDS.DOCKER], ['kubepods', RUNTIME_KINDS.KUBERNETES], ['containerd', RUNTIME_KINDS.CONTAINER], ['lxc', RUNTIME_KINDS.CONTAINER], ['podman', RUNTIME_KINDS.CONTAINER]]) {
46
- const result = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: NO_TTY, existsImpl: async () => false, readFileImpl: async () => '12:pids:/sys/fs/cgroup/' + marker + 'abc' })
47
- assert.deepEqual(result, { kind, declared: true }, marker)
48
- }
49
- // 探测器异常不致命,继续后续判定
50
- const broken = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: NO_TTY, existsImpl: async () => { throw new Error('EACCES') }, readFileImpl: async () => { throw new Error('EACCES') } })
51
- assert.deepEqual(broken, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
52
- })
53
-
54
- test('环境检测:win32 跳过 POSIX 探测', async () => {
55
- const win = await detectRuntimeEnv({ env: {}, platform: WIN32, isTTY: NO_TTY, existsImpl: async () => true, readFileImpl: async () => '/docker/xyz' })
56
- assert.deepEqual(win, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
57
- })
58
-
59
- test('环境检测:双 TTY 判手动直跑,单 TTY 不构成', async () => {
60
- const both = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: BOTH_TTY, ...noProbes })
61
- assert.deepEqual(both, { kind: RUNTIME_KINDS.MANUAL_START, declared: false })
62
- const stdinOnly = await detectRuntimeEnv({ env: {}, platform: POSIX, isTTY: { stdin: true, stdout: false }, ...noProbes })
63
- assert.deepEqual(stdinOnly, { kind: RUNTIME_KINDS.UNKNOWN, declared: false })
64
- // 托管声明优先于 TTY
65
- const declaredWithTty = await detectRuntimeEnv({ env: { pm_id: '0' }, platform: POSIX, isTTY: BOTH_TTY, ...noProbes })
66
- assert.deepEqual(declaredWithTty, { kind: RUNTIME_KINDS.PM2, declared: true })
67
- })