@hyzyn/dsh-safe 0.2.0 → 0.3.1

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.en.md CHANGED
@@ -16,13 +16,16 @@ Requires Node >= 20 and a local `dsh` command. Zero runtime dependencies.
16
16
 
17
17
  ## Quick Start
18
18
 
19
- Just replace `dsh` with `dsh-safe`:
19
+ Just swap `dsh` for `dsh-safe` — `-u` (update-and-boot) is recommended: when dsh has a new version it upgrades and restores quarantined plugins first; when dsh is already latest it behaves exactly like a plain start:
20
20
 
21
21
  ```bash
22
- dsh-safe web # same as dsh web, with auto-quarantine
22
+ dsh-safe -u web # recommended: update then boot (with auto-quarantine)
23
+ dsh-safe web # no update check, boot with auto-quarantine
23
24
  dsh-safe --profile tui --patch ./extra.yml
24
25
  ```
25
26
 
27
+ `-u` adds one version check per boot (needs network; on check failure it just warns and boots anyway) — offline or scripted environments can use the second line.
28
+
26
29
  Sample output (shown with a zh locale: a broken plugin is quarantined, then startup retries):
27
30
 
28
31
  ```
@@ -32,17 +35,23 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
32
35
  [dsh-safe] 重试启动…
33
36
  ```
34
37
 
35
- ## Commands
38
+ ## Commands & Options
36
39
 
37
- ```
38
- dsh-safe <dsh args…> wrap and run dsh
39
- dsh-safe list [--profile <name>] show quarantined plugins (defaults to all profiles)
40
- dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
41
- re-enable auto-disabled plugins (after a fixed plugin upgrade)
42
- dsh-safe help
43
- ```
40
+ ### Subcommands
44
41
 
45
- Wrapper-mode options (must come before the profile / subcommand):
42
+ | Command | Description |
43
+ | --- | --- |
44
+ | `dsh-safe <dsh args…>` | wrap and run dsh (swap `dsh` for `dsh-safe`) |
45
+ | `dsh-safe -u [update options] [dsh args…]` | upgrade dsh and dsh-safe itself first (skip if latest), then boot in wrap mode; `--update` is an alias |
46
+ | `dsh-safe update [options]` | upgrade only, no boot — options below |
47
+ | `dsh-safe list [--profile <name>]` | show quarantined plugins (defaults to all profiles) |
48
+ | `dsh-safe restore --profile <name> (--id <id> \| --all) [--dry-run]` | re-enable auto-disabled plugins (after a fixed plugin upgrade) |
49
+ | `dsh-safe help` (`-h` / `--help`) | show help |
50
+ | `dsh-safe --version` (`-V`) | show version |
51
+
52
+ Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`, `-h` = `--help`, `-V` = `--version`); single letters use `-`, words use `--`.
53
+
54
+ ### Wrapper-mode options (must come before the first positional argument)
46
55
 
47
56
  | Option | Description |
48
57
  | --- | --- |
@@ -50,7 +59,25 @@ Wrapper-mode options (must come before the profile / subcommand):
50
59
  | `--max-retries <n>` | Max startup retries after an auto-quarantine (default 2; `0` means pass through without quarantining) |
51
60
  | `--allow-first-party` | Allow auto-disabling first-party `@deepseek-ai/*` plugins (skipped by default; handle manually) |
52
61
 
53
- Messages follow `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE` (`zh*` Chinese, otherwise English); force with `DSH_SAFE_LANG=zh|en`.
62
+ ### update / -u options (after `-u` or `update`; wrapper flags before the dsh args still apply)
63
+
64
+ | Option | Description |
65
+ | --- | --- |
66
+ | `-y` / `--yes` | Skip the upgrade confirmation (required in non-interactive terminals) |
67
+ | `--to <version>` | Target dsh version, also how you roll back (explicit downgrades allowed); dsh-safe itself always upgrades to the latest |
68
+ | `--self` | Update dsh-safe itself only; dsh and quarantine state untouched |
69
+ | `--no-restore` | Do not auto-restore quarantined plugins after upgrading dsh |
70
+ | `--pm <npm\|pnpm>` | Force the package manager (auto-detected by default) |
71
+
72
+ ### Environment variables
73
+
74
+ | Variable | Description |
75
+ | --- | --- |
76
+ | `DSH_SAFE_LANG=zh\|en` | Force message language (defaults to `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
77
+ | `DSH_SAFE_NO_UPDATE_CHECK=1` | Disable the at-most-daily dsh-safe new-version notice on boot |
78
+ | `DSH_HOME` | dsh home directory (dsh's own variable; the quarantine ledger and patch paths follow it) |
79
+
80
+ How upgrading works: `dsh-safe update` auto-detects the dsh package name and install method (npm / pnpm global installs), compares against the latest version and runs the upgrade for you, then automatically restores all quarantined plugins — any still incompatible under the new dsh will be auto-quarantined again on the next start. For daily use, just make `dsh-safe -u web` your start command: boots immediately when dsh is already latest (one version check), upgrades + restores first when an update is available, and only warns (still boots) if the update check itself fails. `-u` accepts update options (e.g. `-u -y web`) and wrapper flags (e.g. `-u --max-retries 0 web`).
54
81
 
55
82
  ## How It Works
56
83
 
@@ -72,6 +99,7 @@ Messages follow `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE` (`zh*` → Chines
72
99
  - Rows inserted via `--patch` overlay layers are not part of the mapping (only the profile patch, the home patch and bundle patches are scanned).
73
100
  - To capture stderr, the wrapper pipes dsh's stderr (content is still echoed to the terminal in real time); stdout/stdin pass through unaffected.
74
101
  - Match patterns target the dsh 0.1.x error formats; a major dsh upgrade that changes them requires updating the parser.
102
+ - Windows is best-effort: update / --self / list / restore are adapted (.cmd shim parsing, shelled npm/pnpm invocations), but the wrapped dsh boot's child-process spawn is unverified on Windows.
75
103
 
76
104
  ## Development
77
105
 
package/README.md CHANGED
@@ -16,13 +16,16 @@ npm install -g @hyzyn/dsh-safe
16
16
 
17
17
  ## 快速开始
18
18
 
19
- 把平时的 `dsh` 换成 `dsh-safe` 即可:
19
+ 把平时的 `dsh` 换成 `dsh-safe` 即可,推荐直接用 `-u`(更新并启动):dsh 有新版本时先升级并恢复被隔离的插件再启动,已最新时和普通启动完全一样:
20
20
 
21
21
  ```bash
22
- dsh-safe web # 等价于 dsh web,带自动隔离
22
+ dsh-safe -u web # 推荐:更新并启动(含自动隔离)
23
+ dsh-safe web # 不检查更新,直接带自动隔离启动
23
24
  dsh-safe --profile tui --patch ./extra.yml
24
25
  ```
25
26
 
27
+ `-u` 每次启动多做一次版本检查(需要联网,检查失败只告警、照常启动);离线或脚本环境用第二行即可。
28
+
26
29
  输出示例(坏插件被自动隔离后重试):
27
30
 
28
31
  ```
@@ -32,17 +35,23 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
32
35
  [dsh-safe] 重试启动…
33
36
  ```
34
37
 
35
- ## 命令
38
+ ## 命令与参数
36
39
 
37
- ```
38
- dsh-safe <dsh 参数…> 包装运行 dsh
39
- dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
40
- dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
41
- 恢复被自动禁用的插件(升级修复后使用)
42
- dsh-safe help
43
- ```
40
+ ### 子命令
44
41
 
45
- 包装模式选项(必须写在 profile / 子命令之前):
42
+ | 命令 | 说明 |
43
+ | --- | --- |
44
+ | `dsh-safe <dsh 参数…>` | 包装运行 dsh(把平时的 `dsh` 换成 `dsh-safe`) |
45
+ | `dsh-safe -u [update 选项] [dsh 参数…]` | 先升级 dsh 与 dsh-safe 自身(已最新则跳过),再按包装模式启动;`--update` 等价 |
46
+ | `dsh-safe update [选项]` | 只升级不启动,选项见下 |
47
+ | `dsh-safe list [--profile <名>]` | 查看隔离名单(缺省列出全部 profile) |
48
+ | `dsh-safe restore --profile <名> (--id <id> \| --all) [--dry-run]` | 恢复被自动禁用的插件(升级修复后使用) |
49
+ | `dsh-safe help`(`-h` / `--help`) | 显示帮助 |
50
+ | `dsh-safe --version`(`-V`) | 显示版本 |
51
+
52
+ 短选项都有等价的长形式(`-u` = `--update`、`-y` = `--yes`、`-h` = `--help`、`-V` = `--version`);单字母用 `-`,多字母用 `--`。
53
+
54
+ ### 包装模式选项(必须写在第一个位置参数之前)
46
55
 
47
56
  | 选项 | 说明 |
48
57
  | --- | --- |
@@ -50,7 +59,25 @@ dsh-safe help
50
59
  | `--max-retries <n>` | 自动隔离后最多重试启动的次数(默认 2;`0` 表示不隔离只透传) |
51
60
  | `--allow-first-party` | 允许自动禁用 `@deepseek-ai/*` 第一方插件(默认跳过,需手动处理) |
52
61
 
53
- 提示信息语言跟随 `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`(`zh*` 为中文,其余英文),也可用环境变量 `DSH_SAFE_LANG=zh|en` 强制指定。
62
+ ### update / -u 选项(写在 `-u` `update` 之后;其前的包装旗标照常生效)
63
+
64
+ | 选项 | 说明 |
65
+ | --- | --- |
66
+ | `-y` / `--yes` | 跳过升级确认(非交互终端必须显式加 `-y`) |
67
+ | `--to <版本>` | 指定 dsh 的目标版本,也是回滚方式(显式允许降级);dsh-safe 自身始终升到最新 |
68
+ | `--self` | 只更新 dsh-safe 自身,不动 dsh 与隔离状态 |
69
+ | `--no-restore` | 升级 dsh 后不自动恢复被隔离的插件 |
70
+ | `--pm <npm\|pnpm>` | 强制指定包管理器(缺省自动探测) |
71
+
72
+ ### 环境变量
73
+
74
+ | 变量 | 说明 |
75
+ | --- | --- |
76
+ | `DSH_SAFE_LANG=zh\|en` | 强制提示信息语言(缺省跟随 `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
77
+ | `DSH_SAFE_NO_UPDATE_CHECK=1` | 关闭启动时每天最多一次的 dsh-safe 新版提示 |
78
+ | `DSH_HOME` | dsh 的 home 目录(dsh 自己的环境变量;隔离台账与各 patch 路径随之) |
79
+
80
+ 升级行为:`dsh-safe update` 自动探测 dsh 的包名与安装方式(npm / pnpm 全局安装)、对比最新版本后代跑升级,完成后自动恢复所有被隔离的插件——新 dsh 下仍不兼容的会在下次启动时再次被自动隔离。日常把 `dsh-safe -u web` 当启动命令即可:dsh 已是最新时直接启动(仅一次版本检查),有更新时先升级并恢复隔离再启动,更新检查失败只告警、照常启动。`-u` 后可接 update 的选项(如 `-u -y web`)与包装旗标(如 `-u --max-retries 0 web`)。
54
81
 
55
82
  ## 工作原理
56
83
 
@@ -72,6 +99,7 @@ dsh-safe help
72
99
  - `--patch` 覆盖层里插入的行不参与对照表(对照表只扫 profile patch、home patch 与 bundle patch)。
73
100
  - 为了捕获 stderr,包装器把 dsh 的 stderr 接到管道(内容仍实时回显到终端);stdout/stdin 直通不受影响。
74
101
  - 本项目针对 dsh 0.1.x 的报错格式做匹配;dsh 大版本升级后格式变化时需要同步更新解析器。
102
+ - Windows 为尽力支持:update / --self / list / restore 已适配(.cmd shim 解析、shell 方式调用 npm/pnpm),包装启动 dsh 的子进程方式未在 Windows 验证。
75
103
 
76
104
  ## 开发
77
105
 
package/lib/cli.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { createRequire } from 'node:module'
11
11
  import { loadLedger, restoreQuarantine } from './quarantine.js'
12
12
  import { runWrapped } from './wrap.js'
13
+ import { cmdUpdate, cmdUpdateAndBoot, maybeNotifySelfUpdate } from './update.js'
13
14
  import { t } from './i18n.js'
14
15
 
15
16
  const require = createRequire(import.meta.url)
@@ -119,9 +120,17 @@ export async function main(argv) {
119
120
  }
120
121
  if (cmd === 'list') return cmdList(argv.slice(1))
121
122
  if (cmd === 'restore') return cmdRestore(argv.slice(1))
123
+ if (cmd === 'update') return cmdUpdate(argv.slice(1))
124
+ if (cmd === '-u' || cmd === '--update') return cmdUpdateAndBoot(argv.slice(1), { boot: runWrapperMode })
122
125
 
123
126
  // 包装模式:剥掉 dsh-safe 自己的旗标(必须出现在第一个位置参数之前),
124
127
  // 其余原样转发给 dsh。
128
+ return runWrapperMode(argv)
129
+ }
130
+
131
+ /** 包装模式:剥离 dsh-safe 的旗标后运行 dsh;-u 升级完成后的启动也走这里。 */
132
+ async function runWrapperMode(argv) {
133
+ maybeNotifySelfUpdate()
125
134
  const forwardArgs = []
126
135
  let dryRun = false
127
136
  let maxRetries = 2
package/lib/dshpaths.js CHANGED
@@ -28,6 +28,9 @@ export const homePatchPath = () => join(dshHome(), 'cordis.patch.yml')
28
28
  /** dsh-safe 的隔离台账。 */
29
29
  export const ledgerFile = () => join(dshHome(), 'dsh-safe', 'quarantine.json')
30
30
 
31
+ /** dsh-safe 的更新检查时间戳缓存(每天最多提示一次新版)。 */
32
+ export const updateCheckFile = () => join(dshHome(), 'dsh-safe', 'update-check.json')
33
+
31
34
  /** 读文件,不存在或读不了返回 undefined。 */
32
35
  export const readIfExists = (path) => {
33
36
  try {
package/lib/i18n.js CHANGED
@@ -16,9 +16,12 @@ const ZH = {
16
16
 
17
17
  用法:
18
18
  dsh-safe <dsh 参数…> 包装运行 dsh,例: dsh-safe web
19
+ dsh-safe -u [<dsh 参数…>] 先升级 dsh(已最新则跳过),再启动
19
20
  dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
20
21
  dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
21
22
  恢复被自动禁用的插件(升级修复后使用)
23
+ dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--pm npm|pnpm]
24
+ 升级 dsh 与 dsh-safe 自身,并自动恢复被隔离的插件
22
25
  dsh-safe help 显示本帮助
23
26
  dsh-safe --version 显示版本
24
27
 
@@ -32,6 +35,8 @@ const ZH = {
32
35
  恢复用 dsh-safe restore,或手动删除区块。
33
36
  - 只隔离"启动期"失败(模块解析失败 / apply 抛错 / 等不到注入服务);
34
37
  运行期的未捕获异常仍由 dsh 自身的 fail-loud 策略处理。
38
+ - update/-u 会同时检查 dsh 与 dsh-safe 自身;--self 只更新 dsh-safe。
39
+ - 启动时每天最多提示一次 dsh-safe 新版;DSH_SAFE_NO_UPDATE_CHECK=1 关闭。
35
40
  - 输出语言跟随 LC_ALL / LC_MESSAGES / LANG / LANGUAGE(zh* 中文,其余英文);
36
41
  可用环境变量 DSH_SAFE_LANG=zh|en 强制指定。
37
42
  `,
@@ -64,6 +69,32 @@ const ZH = {
64
69
  willDisable: '(dry-run)将禁用',
65
70
  reasonIndent: ' 原因: {reason}',
66
71
  retrying: '[dsh-safe] 重试启动…',
72
+
73
+ // ---- update.js ----
74
+ updateDshNotFound: '[dsh-safe] 在 PATH 上找不到 dsh 命令,无法升级。',
75
+ updatePlan: '[dsh-safe] {label} {name} {old} → {target} ({pm})',
76
+ updateInstallCmd: '[dsh-safe] 即将执行: {command}',
77
+ updateConfirm: '继续? [y/N] ',
78
+ updateNonInteractive: '[dsh-safe] 当前不是交互终端,无法确认;加 -y 跳过确认后重试。',
79
+ updateAborted: '[dsh-safe] 已取消。',
80
+ updateInstalling: '[dsh-safe] 正在升级 dsh…',
81
+ updateInstallFail: '[dsh-safe] 升级失败(退出码 {code}),未恢复任何隔离插件。',
82
+ updateLatestFetchFail: '[dsh-safe] 无法获取 {name} 的最新版本({pm} view 失败);可用 --to <版本> 指定目标版本。',
83
+ updateSkipCheckWarn: '[dsh-safe] 无法检查 dsh 更新({pm} view 失败),跳过升级直接启动。',
84
+ updateAlreadyLatest: '[dsh-safe] 已是最新,无需更新(dsh {dsh} / dsh-safe {self})。',
85
+ updateDone: '[dsh-safe] dsh 已更新: {old} → {new}',
86
+ updateVerifyWarn: '[dsh-safe] 警告:升级后无法重新解析 dsh 版本,请自行确认。',
87
+ updateNothingToRestore: '[dsh-safe] 没有需要恢复的隔离记录。',
88
+ updateRestoredProfile: '[dsh-safe] 已恢复 {count} 个被隔离的插件 (profile: {profile})',
89
+ updateRestoreSkipped: '[dsh-safe] 已按 --no-restore 跳过恢复;可稍后用 dsh-safe restore --profile <名> --all 恢复。',
90
+ updateVerifyHint: '[dsh-safe] 请启动 dsh 验证;仍不兼容的插件会自动再次隔离。',
91
+ updateRollbackHint: '[dsh-safe] 如需回滚: dsh-safe update --to {version}',
92
+ selfUpdateLagHint: '[dsh-safe] dsh-safe 已更新: {old} → {new}(本次运行仍是旧版,下次启动生效)',
93
+ updateNotify: '[dsh-safe] 提示: dsh-safe 有新版本 {new}(当前 {old})',
94
+ updateNotifyHow: ' 升级: npm i -g {name} 或 dsh-safe update --self',
95
+ updateUnknownFlag: '[dsh-safe] update 无法识别的参数: {arg}',
96
+ updatePmInvalid: '[dsh-safe] --pm 只支持 npm 或 pnpm。',
97
+ updateToInvalid: '[dsh-safe] --to 需要合法的版本号(如 1.2.3 或 1.2.3-rc.1)。',
67
98
  }
68
99
 
69
100
  const EN = {
@@ -77,9 +108,12 @@ ledger), and retries automatically.
77
108
 
78
109
  Usage:
79
110
  dsh-safe <dsh args…> wrap and run dsh, e.g. dsh-safe web
111
+ dsh-safe -u [<dsh args…>] upgrade dsh first (skip if latest), then boot
80
112
  dsh-safe list [--profile <name>] show quarantined plugins (defaults to all profiles)
81
113
  dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
82
114
  re-enable auto-disabled plugins (after a fixed plugin upgrade)
115
+ dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--pm npm|pnpm]
116
+ upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
83
117
  dsh-safe help show this help
84
118
  dsh-safe --version show version
85
119
 
@@ -94,6 +128,9 @@ Notes:
94
128
  - Only startup-phase failures are quarantined (module resolution / apply throw /
95
129
  missing injected service); runtime uncaught exceptions stay under dsh's own
96
130
  fail-loud policy.
131
+ - update/-u checks both dsh and dsh-safe itself; --self updates dsh-safe only.
132
+ - A new-version notice for dsh-safe is shown at most once a day on boot;
133
+ disable it with DSH_SAFE_NO_UPDATE_CHECK=1.
97
134
  - Output language follows LC_ALL / LC_MESSAGES / LANG / LANGUAGE (zh* → Chinese,
98
135
  otherwise English); force it with DSH_SAFE_LANG=zh|en.
99
136
  `,
@@ -127,6 +164,32 @@ Notes:
127
164
  willDisable: '(dry-run) would disable',
128
165
  reasonIndent: ' reason: {reason}',
129
166
  retrying: '[dsh-safe] retrying…',
167
+
168
+ // ---- update.js ----
169
+ updateDshNotFound: '[dsh-safe] could not find the `dsh` command on PATH; nothing to upgrade.',
170
+ updatePlan: '[dsh-safe] {label} {name} {old} → {target} ({pm})',
171
+ updateInstallCmd: '[dsh-safe] about to run: {command}',
172
+ updateConfirm: 'continue? [y/N] ',
173
+ updateNonInteractive: '[dsh-safe] not an interactive terminal; re-run with -y to skip the confirmation.',
174
+ updateAborted: '[dsh-safe] cancelled.',
175
+ updateInstalling: '[dsh-safe] upgrading dsh…',
176
+ updateInstallFail: '[dsh-safe] upgrade failed (exit code {code}); no quarantined plugins were restored.',
177
+ updateLatestFetchFail: '[dsh-safe] could not fetch the latest version of {name} ({pm} view failed); specify a target with --to <version>.',
178
+ updateSkipCheckWarn: '[dsh-safe] could not check for dsh updates ({pm} view failed); starting without upgrading.',
179
+ updateAlreadyLatest: '[dsh-safe] already up to date (dsh {dsh} / dsh-safe {self}); nothing to update.',
180
+ updateDone: '[dsh-safe] dsh updated: {old} → {new}',
181
+ updateVerifyWarn: '[dsh-safe] warning: could not re-resolve the dsh version after the upgrade; please verify manually.',
182
+ updateNothingToRestore: '[dsh-safe] no quarantine records to restore.',
183
+ updateRestoredProfile: '[dsh-safe] restored {count} quarantined plugin(s) (profile: {profile})',
184
+ updateRestoreSkipped: '[dsh-safe] restore skipped due to --no-restore; restore later with dsh-safe restore --profile <name> --all.',
185
+ updateVerifyHint: '[dsh-safe] start dsh to verify; plugins still incompatible will be auto-quarantined again.',
186
+ updateRollbackHint: '[dsh-safe] to roll back: dsh-safe update --to {version}',
187
+ selfUpdateLagHint: '[dsh-safe] dsh-safe updated: {old} → {new} (this run still uses the old version; takes effect on the next run)',
188
+ updateNotify: '[dsh-safe] notice: a new dsh-safe version is available: {new} (current {old})',
189
+ updateNotifyHow: ' to upgrade: npm i -g {name} or dsh-safe update --self',
190
+ updateUnknownFlag: '[dsh-safe] unrecognized argument for update: {arg}',
191
+ updatePmInvalid: '[dsh-safe] --pm only accepts npm or pnpm.',
192
+ updateToInvalid: '[dsh-safe] --to requires a valid version (e.g. 1.2.3 or 1.2.3-rc.1).',
130
193
  }
131
194
 
132
195
  const CATALOG = { zh: ZH, en: EN }
package/lib/update.js ADDED
@@ -0,0 +1,423 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — `dsh-safe update`:升级 dsh 并自动恢复被隔离的插件。
3
+ *
4
+ * 自动探测:PATH 上的 dsh 可执行文件 realpath → 向上找最近的 package.json
5
+ * 得到包名与当前版本;包管理器按 realpath 是否落在 pnpm 全局根下判定
6
+ * (PATH 上有 pnpm 时探测,否则默认 npm),--pm 可强制指定。
7
+ * 升级完成后遍历台账恢复全部被隔离的插件(新 dsh 下仍不兼容的会在
8
+ * 下次启动时再次被自动隔离),并提示回滚方式。
9
+ */
10
+ import { accessSync, constants, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
11
+ import { spawnSync } from 'node:child_process'
12
+ import { createInterface } from 'node:readline/promises'
13
+ import { delimiter, dirname, join } from 'node:path'
14
+ import { loadLedger, restoreQuarantine } from './quarantine.js'
15
+ import { readIfExists, updateCheckFile } from './dshpaths.js'
16
+ import { t } from './i18n.js'
17
+
18
+ const err = (line) => process.stderr.write(`${line}\n`)
19
+ const out = (line) => process.stdout.write(`${line}\n`)
20
+
21
+ const WIN32 = process.platform === 'win32'
22
+
23
+ /**
24
+ * 运行包管理器命令并捕获输出(view / pnpm root -g)。
25
+ * Windows 上 npm/pnpm 是 .cmd 批处理,Node 禁止无 shell 地 spawn(EINVAL),
26
+ * 必须走 cmd.exe;传参只含已校验的包名与版本号(--to 有 ^[\w.+-]+$ 校验)。
27
+ */
28
+ function spawnPmCapture(bin, args) {
29
+ return WIN32
30
+ ? spawnSync(bin, args, { encoding: 'utf8', shell: true })
31
+ : spawnSync(bin, args, { encoding: 'utf8' })
32
+ }
33
+
34
+ /** 运行包管理器命令并透传 stdio(install)。Windows 处理同上。 */
35
+ function spawnPmInherit(bin, args) {
36
+ return WIN32
37
+ ? spawnSync(bin, args, { shell: true, stdio: 'inherit' })
38
+ : spawnSync(bin, args, { stdio: 'inherit' })
39
+ }
40
+
41
+ /** Windows 上全局 bin 的可执行是 .cmd/.exe 拷贝而非 symlink,候选名不同。 */
42
+ function binCandidates(name) {
43
+ return WIN32 ? [`${name}.cmd`, `${name}.exe`, `${name}.ps1`, name] : [name]
44
+ }
45
+
46
+ const readJsonIfExists = (path) => {
47
+ const raw = readIfExists(path)
48
+ if (raw === undefined) return undefined
49
+ try {
50
+ return JSON.parse(raw)
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ /** 在 PATH 上找可执行文件(Windows 展开为 .cmd/.exe/.ps1/裸名多候选),返回绝对路径或 null。 */
57
+ function whichCmd(name) {
58
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
59
+ if (!dir) continue
60
+ for (const candidate of binCandidates(name).map((n) => join(dir, n))) {
61
+ try {
62
+ accessSync(candidate, constants.X_OK)
63
+ return candidate
64
+ } catch {}
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ /** 从某个目录向上找最近的 package.json,返回 { name, version, pkgDir }。 */
71
+ function resolvePackageFromDir(dir) {
72
+ for (;;) {
73
+ const pkg = readJsonIfExists(join(dir, 'package.json'))
74
+ if (pkg && typeof pkg.name === 'string' && pkg.name) {
75
+ return { name: pkg.name, version: typeof pkg.version === 'string' ? pkg.version : '0.0.0', pkgDir: dir }
76
+ }
77
+ const parent = dirname(dir)
78
+ if (parent === dir) return null
79
+ dir = parent
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 从可执行文件解析所属包:symlink → realpath 后向上找 package.json(macOS/Linux
85
+ * 的 npm/pnpm 全局 bin 都是链接);独立 shim 文件(Windows 的 .cmd/sh 拷贝)→
86
+ * 解析其内容里内嵌的 node_modules 入口路径。
87
+ */
88
+ function resolvePackageFromBinFile(file) {
89
+ let real
90
+ try {
91
+ real = realpathSync(file)
92
+ } catch {
93
+ return null
94
+ }
95
+ const viaWalkUp = resolvePackageFromDir(dirname(real))
96
+ if (viaWalkUp) return viaWalkUp
97
+ return resolvePackageFromShim(file)
98
+ }
99
+
100
+ /** 解析 npm/pnpm 的 cmd/ps1/sh shim:内容里都内嵌 node_modules/<pkg> 的入口路径。 */
101
+ export function resolvePackageFromShim(shimPath) {
102
+ const content = readIfExists(shimPath)
103
+ if (!content) return null
104
+ const m = /node_modules[\\\/]((?:@[^\\\/"'`\s]+[\\\/])?[^\\\/"'`\s]+)/.exec(content)
105
+ if (!m) return null
106
+ const name = m[1].replace(/[\\\/]/g, '/')
107
+ // 候选 pkgDir:npm 全局布局(shim 同级的 node_modules)与 pnpm 全局根
108
+ const candidates = [join(dirname(shimPath), 'node_modules', ...name.split('/'))]
109
+ const pnpmBin = whichCmd('pnpm')
110
+ if (pnpmBin) {
111
+ const { status, stdout } = spawnPmCapture(pnpmBin, ['root', '-g'])
112
+ const root = status === 0 ? (stdout ?? '').trim() : ''
113
+ if (root) candidates.push(join(root, ...name.split('/')))
114
+ }
115
+ for (const dir of candidates) {
116
+ const pkg = readJsonIfExists(join(dir, 'package.json'))
117
+ if (pkg?.name === name) {
118
+ return { name, version: typeof pkg.version === 'string' ? pkg.version : '0.0.0', pkgDir: dir }
119
+ }
120
+ }
121
+ return null
122
+ }
123
+
124
+ /**
125
+ * 解析 PATH 上的 dsh。
126
+ * @returns {{ name: string, version: string, pkgDir: string } | null}
127
+ */
128
+ export function resolveDshPackage() {
129
+ const bin = whichCmd('dsh')
130
+ if (!bin) return null
131
+ return resolvePackageFromBinFile(bin)
132
+ }
133
+
134
+ /**
135
+ * 解析正在运行的 dsh-safe 自身(argv[1],npm/pnpm 全局 bin symlink 或仓库内直跑均适用)。
136
+ * @returns {{ name: string, version: string, pkgDir: string } | null}
137
+ */
138
+ export function resolveSelfPackage() {
139
+ const entry = process.argv[1]
140
+ if (!entry) return null
141
+ return resolvePackageFromBinFile(entry)
142
+ }
143
+
144
+ /** 判断 dsh 的安装方式:落在 pnpm 全局根下则为 pnpm,否则 npm。 */
145
+ export function detectPm(pkgDir) {
146
+ const pnpmBin = whichCmd('pnpm')
147
+ if (!pnpmBin) return 'npm'
148
+ const { status, stdout } = spawnPmCapture(pnpmBin, ['root', '-g'])
149
+ const root = status === 0 ? (stdout ?? '').trim() : ''
150
+ if (root && (pkgDir === root || pkgDir.startsWith(`${root}/`) || pkgDir.startsWith(`${root}\\`))) return 'pnpm'
151
+ return 'npm'
152
+ }
153
+
154
+ /** npm view 查最新版本(只查 registry,与安装方式无关),失败返回 null。 */
155
+ function fetchLatestVersion(name) {
156
+ const { status, stdout } = spawnPmCapture(whichCmd('npm') ?? 'npm', ['view', name, 'version'])
157
+ const version = status === 0 ? (stdout ?? '').trim() : ''
158
+ return /^[\w.+-]+$/.test(version) ? version : null
159
+ }
160
+
161
+ /**
162
+ * 比较 semver 风格版本:a > b 返回 true(零依赖的够用版)。
163
+ * 核心段按数字逐段比较;核心相同时正式版 > 预发布,预发布逐段比较(数字段按数值)。
164
+ * 自动检查用它避免"registry 落后于本地开发版"时的自降级;--to 显式指定不走这里,允许降级回滚。
165
+ */
166
+ export function isNewerVersion(a, b) {
167
+ if (a === b) return false
168
+ const [aCore, aPre] = String(a).split('-', 2)
169
+ const [bCore, bPre] = String(b).split('-', 2)
170
+ const pa = aCore.split('.').map((n) => parseInt(n, 10) || 0)
171
+ const pb = bCore.split('.').map((n) => parseInt(n, 10) || 0)
172
+ for (let i = 0; i < 3; i++) {
173
+ const x = pa[i] ?? 0
174
+ const y = pb[i] ?? 0
175
+ if (x !== y) return x > y
176
+ }
177
+ if (aPre && !bPre) return false
178
+ if (!aPre && bPre) return true
179
+ const qa = (aPre ?? '').split('.')
180
+ const qb = (bPre ?? '').split('.')
181
+ for (let i = 0; i < Math.max(qa.length, qb.length); i++) {
182
+ const x = qa[i]
183
+ const y = qb[i]
184
+ if (x === undefined) return false
185
+ if (y === undefined) return true
186
+ const numericX = /^\d+$/.test(x)
187
+ const numericY = /^\d+$/.test(y)
188
+ if (numericX && numericY) {
189
+ const d = Number(x) - Number(y)
190
+ if (d) return d > 0
191
+ } else if (x !== y) {
192
+ return x > y
193
+ }
194
+ }
195
+ return false
196
+ }
197
+
198
+ /**
199
+ * @param {string[]} args update 子命令参数
200
+ * @returns {Promise<number>} 退出码
201
+ */
202
+ export async function cmdUpdate(args) {
203
+ if (args.includes('-h') || args.includes('--help')) {
204
+ out(t('helpText', {}))
205
+ return 0
206
+ }
207
+ const { opts, error } = parseUpdateArgs(args)
208
+ if (error) {
209
+ err(t(error.key, error.params))
210
+ return 2
211
+ }
212
+ return updateAndMaybeBoot(opts, null, {})
213
+ }
214
+
215
+ /**
216
+ * `-u`/`--update` 模式:前缀解析 update 选项(-y/--to/--pm/--no-restore),
217
+ * 第一个不属于 update 的参数起就是 dsh 启动参数(可再带包装旗标)。
218
+ * 启动优先:更新检查失败只告警并照常启动;升级失败/用户取消则不启动。
219
+ * @param {string[]} args
220
+ * @param {{ boot?: (args: string[]) => Promise<number> }} hooks cli 传入的包装启动
221
+ */
222
+ export async function cmdUpdateAndBoot(args, { boot } = {}) {
223
+ if (args.includes('-h') || args.includes('--help')) {
224
+ out(t('helpText', {}))
225
+ return 0
226
+ }
227
+ const { opts, rest, error } = parseLeadingUpdateArgs(args)
228
+ if (error) {
229
+ err(t(error.key, error.params))
230
+ return 2
231
+ }
232
+ return updateAndMaybeBoot(opts, rest.length ? rest : null, { boot })
233
+ }
234
+
235
+ /**
236
+ * 更新主体;bootArgs 非空时更新成功(或已最新/检查失败)后继续启动。
237
+ * 默认同时检查 dsh 与 dsh-safe 自身,谁旧升谁(一条安装命令);--self 只更新自身。
238
+ * @param {{ to?: string, yes?: boolean, restore?: boolean, pm?: string, selfOnly?: boolean }} opts
239
+ * @param {string[] | null} bootArgs
240
+ * @param {{ boot?: (args: string[]) => Promise<number> }} hooks
241
+ */
242
+ async function updateAndMaybeBoot(opts, bootArgs, { boot } = {}) {
243
+ const dshPkg = opts.selfOnly ? null : resolveDshPackage()
244
+ const selfPkg = resolveSelfPackage()
245
+ if (!dshPkg && !selfPkg) {
246
+ err(t('updateDshNotFound'))
247
+ return 1
248
+ }
249
+ const pm = opts.pm ?? (dshPkg ? detectPm(dshPkg.pkgDir) : detectPm(selfPkg.pkgDir))
250
+
251
+ // 组装更新计划:[{ pkg, target }]
252
+ const plans = []
253
+ if (dshPkg) {
254
+ if (opts.to) {
255
+ if (opts.to !== dshPkg.version) plans.push({ pkg: dshPkg, target: opts.to })
256
+ } else {
257
+ const latest = fetchLatestVersion(dshPkg.name)
258
+ if (!latest) {
259
+ if (bootArgs) {
260
+ err(t('updateSkipCheckWarn', { pm }))
261
+ return boot(bootArgs)
262
+ }
263
+ err(t('updateLatestFetchFail', { name: dshPkg.name, pm }))
264
+ return 1
265
+ }
266
+ if (isNewerVersion(latest, dshPkg.version)) plans.push({ pkg: dshPkg, target: latest })
267
+ }
268
+ }
269
+ if (selfPkg && !opts.to) {
270
+ const selfLatest = fetchLatestVersion(selfPkg.name)
271
+ if (selfLatest && isNewerVersion(selfLatest, selfPkg.version)) plans.push({ pkg: selfPkg, target: selfLatest })
272
+ }
273
+
274
+ if (!plans.length) {
275
+ if (bootArgs) return boot(bootArgs)
276
+ err(t('updateAlreadyLatest', { dsh: dshPkg?.version ?? '未安装', self: selfPkg?.version ?? '未知' }))
277
+ return 0
278
+ }
279
+
280
+ const installCmd = pm === 'pnpm' ? 'pnpm' : 'npm'
281
+ const specs = plans.map((p) => `${p.pkg.name}@${p.target}`)
282
+ const installArgs =
283
+ pm === 'pnpm' ? ['add', '-g', ...specs] : ['install', '-g', ...specs]
284
+ for (const p of plans) {
285
+ err(t('updatePlan', { label: p.pkg === dshPkg ? 'dsh' : 'dsh-safe', name: p.pkg.name, old: p.pkg.version, target: p.target, pm }))
286
+ }
287
+ err(t('updateInstallCmd', { command: `${installCmd} ${installArgs.join(' ')}` }))
288
+ if (!opts.yes) {
289
+ if (!process.stdin.isTTY) {
290
+ err(t('updateNonInteractive'))
291
+ return 1
292
+ }
293
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
294
+ const answer = (await rl.question(t('updateConfirm'))).trim().toLowerCase()
295
+ rl.close()
296
+ if (answer !== 'y' && answer !== 'yes') {
297
+ err(t('updateAborted'))
298
+ return 0
299
+ }
300
+ }
301
+
302
+ err(t('updateInstalling'))
303
+ const { status } = spawnPmInherit(whichCmd(installCmd) ?? installCmd, installArgs)
304
+ if (status !== 0) {
305
+ err(t('updateInstallFail', { code: status ?? '?' }))
306
+ return status ?? 1
307
+ }
308
+
309
+ let dshUpdated = false
310
+ for (const p of plans) {
311
+ if (p.pkg === dshPkg) {
312
+ dshUpdated = true
313
+ const fresh = resolveDshPackage()
314
+ if (!fresh?.version) err(t('updateVerifyWarn'))
315
+ else err(t('updateDone', { old: p.pkg.version, new: fresh.version }))
316
+ } else {
317
+ err(t('selfUpdateLagHint', { old: p.pkg.version, new: p.target }))
318
+ }
319
+ }
320
+
321
+ if (opts.restore && dshUpdated) {
322
+ const ledger = loadLedger()
323
+ const profiles = Object.keys(ledger.profiles).filter((p) => (ledger.profiles[p] ?? []).length)
324
+ if (!profiles.length) {
325
+ out(t('updateNothingToRestore'))
326
+ } else {
327
+ for (const profile of profiles) {
328
+ const { restored } = restoreQuarantine(profile, 'all', false)
329
+ if (restored.length) out(t('updateRestoredProfile', { profile, count: restored.length }))
330
+ }
331
+ err(t('updateVerifyHint'))
332
+ }
333
+ } else if (dshUpdated && !opts.restore) {
334
+ err(t('updateRestoreSkipped'))
335
+ }
336
+ if (dshUpdated) {
337
+ const oldDsh = plans.find((p) => p.pkg === dshPkg)?.pkg.version
338
+ if (oldDsh) err(t('updateRollbackHint', { version: oldDsh }))
339
+ }
340
+ if (bootArgs) return boot(bootArgs)
341
+ return 0
342
+ }
343
+
344
+ /** 解析 update 子命令参数;出错返回 { error: { key, params? } } 供 i18n。 */
345
+ function parseUpdateArgs(args) {
346
+ const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false }
347
+ for (let i = 0; i < args.length; i++) {
348
+ const a = args[i]
349
+ if (a === '-y' || a === '--yes') opts.yes = true
350
+ else if (a === '--no-restore') opts.restore = false
351
+ else if (a === '--self') opts.selfOnly = true
352
+ else if (a === '--to') {
353
+ const v = args[++i]
354
+ if (v === undefined) return { error: { key: 'updateUnknownFlag', params: { arg: '--to' } } }
355
+ opts.to = v
356
+ } else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
357
+ else if (a === '--pm') {
358
+ const v = args[++i]
359
+ if (v === undefined) return { error: { key: 'updateUnknownFlag', params: { arg: '--pm' } } }
360
+ opts.pm = v
361
+ } else if (a.startsWith('--pm=')) opts.pm = a.slice('--pm='.length)
362
+ else return { error: { key: 'updateUnknownFlag', params: { arg: a } } }
363
+ }
364
+ if (opts.pm !== undefined && opts.pm !== 'npm' && opts.pm !== 'pnpm') {
365
+ return { error: { key: 'updatePmInvalid' } }
366
+ }
367
+ if (opts.to !== undefined && !/^[\w.+-]+$/.test(opts.to)) {
368
+ return { error: { key: 'updateToInvalid' } }
369
+ }
370
+ return { opts }
371
+ }
372
+
373
+ /**
374
+ * `-u` 模式的前缀解析:从头吃掉 update 选项,第一个不认识的参数起就是
375
+ * dsh 启动参数(宽松处理——不像子命令那样对未知参数报错)。
376
+ */
377
+ function parseLeadingUpdateArgs(args) {
378
+ const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false }
379
+ let i = 0
380
+ for (; i < args.length; i++) {
381
+ const a = args[i]
382
+ if (a === '-y' || a === '--yes') opts.yes = true
383
+ else if (a === '--no-restore') opts.restore = false
384
+ else if (a === '--self') opts.selfOnly = true
385
+ else if (a === '--to' && args[i + 1] !== undefined) opts.to = args[++i]
386
+ else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
387
+ else if (a === '--pm' && args[i + 1] !== undefined) opts.pm = args[++i]
388
+ else if (a.startsWith('--pm=')) opts.pm = a.slice('--pm='.length)
389
+ else break
390
+ }
391
+ const rest = args.slice(i)
392
+ if (opts.pm !== undefined && opts.pm !== 'npm' && opts.pm !== 'pnpm') {
393
+ return { error: { key: 'updatePmInvalid' } }
394
+ }
395
+ if (opts.to !== undefined && !/^[\w.+-]+$/.test(opts.to)) {
396
+ return { error: { key: 'updateToInvalid' } }
397
+ }
398
+ return { opts, rest }
399
+ }
400
+
401
+ /**
402
+ * 每日一次的 dsh-safe 新版提示(只在包装启动路径调用;update/-u 有自己的检查)。
403
+ * 时间戳缓存于 $DSH_HOME/dsh-safe/update-check.json;任何失败都完全静默。
404
+ */
405
+ export function maybeNotifySelfUpdate() {
406
+ try {
407
+ if (process.env.DSH_SAFE_NO_UPDATE_CHECK === '1') return
408
+ const selfPkg = resolveSelfPackage()
409
+ if (!selfPkg) return
410
+ const file = updateCheckFile()
411
+ const last = readJsonIfExists(file)
412
+ if (last?.lastCheckAt && Date.now() - Date.parse(last.lastCheckAt) < 24 * 60 * 60 * 1000) return
413
+ try {
414
+ mkdirSync(dirname(file), { recursive: true })
415
+ writeFileSync(file, `${JSON.stringify({ lastCheckAt: new Date().toISOString() }, null, 2)}\n`)
416
+ } catch {}
417
+ const latest = fetchLatestVersion(selfPkg.name)
418
+ if (latest && isNewerVersion(latest, selfPkg.version)) {
419
+ err(t('updateNotify', { new: latest, old: selfPkg.version }))
420
+ err(t('updateNotifyHow', { name: selfPkg.name }))
421
+ }
422
+ } catch {}
423
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-safe",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",