@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 +40 -12
- package/README.md +40 -12
- package/lib/cli.js +9 -0
- package/lib/dshpaths.js +3 -0
- package/lib/i18n.js +63 -0
- package/lib/update.js +423 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|