@chendaleiq/dsh-plugin-auto-update 0.0.0-stage → 0.1.16
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/LICENSE +21 -0
- package/README.en.md +76 -0
- package/README.md +66 -2
- package/cordis.patch.yml +25 -0
- package/icon.svg +9 -0
- package/index.js +174 -0
- package/lib/client.js +410 -0
- package/lib/plan.js +386 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chendaleiQ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# dsh-plugin-auto-update
|
|
2
|
+
|
|
3
|
+
Automatic, per-package updates for the DSH plugins you choose: switch it on in the
|
|
4
|
+
plugin page and tick the plugins that may upgrade themselves.
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
- Looks up the **newest published version** of installed plugin bundles on npm
|
|
9
|
+
- Only touches packages you ticked; local `link:` / `file:` installs, pinned
|
|
10
|
+
versions and bundles with a manager error are skipped
|
|
11
|
+
- Never jumps a **major** version and never installs a **prerelease** by default
|
|
12
|
+
- Updates go through the profile's running `pluginManager` — profile write lock,
|
|
13
|
+
pnpm run, bundle-patch check and HMR reload stay with it. A failed install
|
|
14
|
+
**reinstalls the previous version**
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Plugins → Install → paste the package name:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
@chendaleiq/dsh-plugin-auto-update
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Restart DSH afterwards: a host half only loads on a fresh generation. The GitHub
|
|
25
|
+
address and an absolute local directory work the same way.
|
|
26
|
+
|
|
27
|
+
## Settings: a panel on the plugin's page
|
|
28
|
+
|
|
29
|
+
Plugins → **DSH Plugin Auto Update**, at the bottom of the detail page:
|
|
30
|
+
|
|
31
|
+
- **Enable auto update** — the master switch, off by default
|
|
32
|
+
- **Version policy** — allow a new major, allow prereleases
|
|
33
|
+
- **Plugins that may update** — every registry-installed bundle, each with a
|
|
34
|
+
checkbox; the first row means "all of them"
|
|
35
|
+
- A bundle that cannot update is greyed out with its reason (local install,
|
|
36
|
+
pinned, or has an error)
|
|
37
|
+
|
|
38
|
+
Edits apply immediately; no restart.
|
|
39
|
+
|
|
40
|
+
## Language
|
|
41
|
+
|
|
42
|
+
The copy follows DSH's language setting: Chinese by default, English once the app
|
|
43
|
+
is switched.
|
|
44
|
+
|
|
45
|
+
## Log
|
|
46
|
+
|
|
47
|
+
`~/.dsh/plugin-auto-update.log`, one JSON object per line (`schedule`, `cycle`,
|
|
48
|
+
`notify`, `plugin-started`; actions `current`, `would-update`, `updated`,
|
|
49
|
+
`failed`, `error`).
|
|
50
|
+
|
|
51
|
+
## Deliberate limits
|
|
52
|
+
|
|
53
|
+
- Only registry-installed bundles whose dependency is a range (`^`, `~`, `*`,
|
|
54
|
+
`latest`); `link:` / `file:` and exact pins are never touched
|
|
55
|
+
- Bundles shipped with the app are never touched; your other configuration stays
|
|
56
|
+
as it is
|
|
57
|
+
- Registry or manager failures are logged and never interrupt the other packages
|
|
58
|
+
- A host half needs an app restart; a renderer half is swapped by HMR
|
|
59
|
+
|
|
60
|
+
## Advanced
|
|
61
|
+
|
|
62
|
+
Fields the panel leaves out (`intervalHours`, six hours by default,
|
|
63
|
+
`initialDelayMinutes`, `maxUpdatesPerRun`, `dryRun`, `registry`, `timeoutMs`,
|
|
64
|
+
`notify`, `logPath`) live in this bundle's `config` in the profile patch, or as an
|
|
65
|
+
override in `~/.dsh/profiles/<profile>/cordis.patch.yml`. Those need a restart.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
node test/index.test.mjs # the host half's update cycle
|
|
71
|
+
node test/client.test.mjs # the renderer half: slot, remote envelope, labels
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT · https://github.com/chendaleiQ/dsh-plugin-auto-update
|
package/README.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-plugin-auto-update
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
给 DSH 插件做**按包白名单的自动更新**:在插件页打开开关、勾选允许自动升级的插件即可。
|
|
4
|
+
|
|
5
|
+
## 它做什么
|
|
6
|
+
|
|
7
|
+
- 按计划去 npm 查已安装插件的**最新版本**
|
|
8
|
+
- 只更新你勾选的包;本地 `link:` / `file:` 安装、锁定版本、报错的包一律跳过
|
|
9
|
+
- 默认**不跨大版本**、**不装预发布**
|
|
10
|
+
- 更新走 profile 里运行中的官方 `pluginManager`:profile 写锁、pnpm 执行、bundle 补丁校验、HMR 重载都由它负责;安装失败会把**旧版本装回去**
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
插件页 → 安装 → 填包名:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
@chendaleiq/dsh-plugin-auto-update
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
装完**重启 DSH**(宿主半边需要新代际才加载)。也可以填 GitHub 地址或本地目录的绝对路径。
|
|
21
|
+
|
|
22
|
+
## 配置:插件页里的面板
|
|
23
|
+
|
|
24
|
+
插件 → **DSH 插件自动更新** → 详情页底部:
|
|
25
|
+
|
|
26
|
+
- **启用自动更新** —— 总开关,默认关
|
|
27
|
+
- **升级范围** —— 允许跨大版本 / 允许预发布
|
|
28
|
+
- **自动更新的插件** —— 列出所有从注册表安装的插件,逐个勾选;「全部可更新的插件」= 通配
|
|
29
|
+
- 不能更新的包会置灰并说明原因(本地安装 / 锁定版本 / 存在错误)
|
|
30
|
+
|
|
31
|
+
改动即时生效,不需要重启。
|
|
32
|
+
|
|
33
|
+
## 语言
|
|
34
|
+
|
|
35
|
+
跟随 DSH 的语言设置:默认中文,切英文后插件页和面板文案同步变英文。
|
|
36
|
+
|
|
37
|
+
## 日志
|
|
38
|
+
|
|
39
|
+
`~/.dsh/plugin-auto-update.log`,每行一个 JSON:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{"at":"…","kind":"cycle","decisions":[{"name":"dsh-plugin-desktop-notify","action":"updated","installed":"0.5.6","latest":"0.5.7"}]}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`action` 有 `current` / `would-update` / `updated` / `failed` / `error`。
|
|
46
|
+
|
|
47
|
+
## 边界(有意为之)
|
|
48
|
+
|
|
49
|
+
- 只碰「从注册表安装、且依赖写成范围(`^` `~` `*` `latest`)」的包
|
|
50
|
+
- 随 DSH 自带的 bundle 不动;不修改你的其它配置
|
|
51
|
+
- 网络或插件管理器出错只记录,不中断其它包
|
|
52
|
+
- 宿主半边更新后需要重启应用;渲染层由 HMR 直接换掉
|
|
53
|
+
|
|
54
|
+
## 高级配置
|
|
55
|
+
|
|
56
|
+
面板没覆盖的字段(`intervalHours` 默认 6 小时、`initialDelayMinutes`、`maxUpdatesPerRun`、`dryRun`、`registry`、`timeoutMs`、`notify`、`logPath`)写在 profile patch 的 `config` 里,或覆盖 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的同一行。改完需要重启。
|
|
57
|
+
|
|
58
|
+
## 开发
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
node test/index.test.mjs # 宿主半边:检查与更新循环
|
|
62
|
+
node test/client.test.mjs # 渲染层:插槽注册、远端信封、置灰原因
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 协议
|
|
66
|
+
|
|
67
|
+
MIT · 仓库 https://github.com/chendaleiQ/dsh-plugin-auto-update
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# The dsh-plugin-auto-update bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Host half (`index.js`) runs the update cycle through the profile's own
|
|
4
|
+
# pluginManager service. Browser half (`lib/client.js`) puts a panel on this
|
|
5
|
+
# bundle's page in the Plugins tab; that panel writes the volatile fields below
|
|
6
|
+
# through the settings service, so edits apply without a restart.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: auto-update
|
|
9
|
+
name: '@chendaleiq/dsh-plugin-auto-update'
|
|
10
|
+
config:
|
|
11
|
+
# Nothing is updated until this is switched on in the panel.
|
|
12
|
+
enabled: false
|
|
13
|
+
# ["*"] = every registry-installed bundle; or name the packages.
|
|
14
|
+
packages:
|
|
15
|
+
- '*'
|
|
16
|
+
allowMajor: false
|
|
17
|
+
allowPrerelease: false
|
|
18
|
+
dryRun: false
|
|
19
|
+
intervalHours: 6
|
|
20
|
+
initialDelayMinutes: 1
|
|
21
|
+
maxUpdatesPerRun: 5
|
|
22
|
+
registry: 'https://registry.npmjs.org'
|
|
23
|
+
timeoutMs: 20000
|
|
24
|
+
notify: true
|
|
25
|
+
logPath: '~/.dsh/plugin-auto-update.log'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Plugin auto update">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#4D6BFE"/>
|
|
3
|
+
<g fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
+
<path d="M18.5 31a13.5 13.5 0 0 1 22.6-9.1"/>
|
|
5
|
+
<path d="M44.5 15.6v7.2h-7.2"/>
|
|
6
|
+
<path d="M45.5 33a13.5 13.5 0 0 1-22.6 9.1"/>
|
|
7
|
+
<path d="M19.5 48.4v-7.2h7.2"/>
|
|
8
|
+
</g>
|
|
9
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-auto-update — host half.
|
|
3
|
+
*
|
|
4
|
+
* Settings live in the profile Cordis patch as volatile fields, so the page this
|
|
5
|
+
* bundle registers in the Plugins tab can edit them live. The update cycle
|
|
6
|
+
* itself lives in `./lib/plan.js`, which stays testable without the app.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import z from "@deepseek-ai/schemastery"
|
|
10
|
+
|
|
11
|
+
import { DEFAULTS, createUpdater, resolveSettings } from "./lib/plan.js"
|
|
12
|
+
|
|
13
|
+
export * from "./lib/plan.js"
|
|
14
|
+
|
|
15
|
+
/** Settings namespace: the profile entry id this plugin's row is addressed by. */
|
|
16
|
+
const NAMESPACE = "auto-update"
|
|
17
|
+
|
|
18
|
+
/** Settings the plugin page edits; every field is volatile, so edits apply live. */
|
|
19
|
+
export const Config = z.object({
|
|
20
|
+
enabled: z.boolean().default(false).volatile(),
|
|
21
|
+
packages: z.array(z.string()).default(["*"]).volatile(),
|
|
22
|
+
allowMajor: z.boolean().default(false).volatile(),
|
|
23
|
+
allowPrerelease: z.boolean().default(false).volatile(),
|
|
24
|
+
dryRun: z.boolean().default(false).volatile(),
|
|
25
|
+
intervalHours: z.number().default(6).volatile(),
|
|
26
|
+
initialDelayMinutes: z.number().default(1).volatile(),
|
|
27
|
+
maxUpdatesPerRun: z.number().default(5).volatile(),
|
|
28
|
+
registry: z.string().default("https://registry.npmjs.org").volatile(),
|
|
29
|
+
timeoutMs: z.number().default(20000).volatile(),
|
|
30
|
+
notify: z.boolean().default(true).volatile(),
|
|
31
|
+
logPath: z.string().default("~/.dsh/plugin-auto-update.log").volatile(),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mount the plugin into a profile.
|
|
36
|
+
*
|
|
37
|
+
* The page in the Plugins tab writes the same fields this half reads, through
|
|
38
|
+
* the settings service: every field is volatile, so an edit is committed into
|
|
39
|
+
* the running config object without remounting this plugin.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} ctx Cordis context of the host.
|
|
42
|
+
* @param {object} rawConfig Bundle config from cordis.patch.yml.
|
|
43
|
+
*/
|
|
44
|
+
export function apply(ctx, rawConfig) {
|
|
45
|
+
/** The live config object the loader keeps updated in place. */
|
|
46
|
+
const live = () => ctx.fiber?.config ?? ctx.config ?? rawConfig ?? {}
|
|
47
|
+
/**
|
|
48
|
+
* A volatile field is a reference the loader keeps current (`{ get(), [write] }`,
|
|
49
|
+
* frozen, carrying a symbol), not the value itself; read through it so a
|
|
50
|
+
* settings-page edit is visible here at once. Detected structurally rather
|
|
51
|
+
* than through cosmokit: a link-installed plugin may only import the packages
|
|
52
|
+
* the runtime actually ships, and cosmokit is not one of them.
|
|
53
|
+
*/
|
|
54
|
+
const isVolatileRef = (value) => typeof value === "object"
|
|
55
|
+
&& value !== null
|
|
56
|
+
&& typeof value.get === "function"
|
|
57
|
+
&& Object.getOwnPropertySymbols(value).length > 0
|
|
58
|
+
const pluck = (config, key) => {
|
|
59
|
+
const value = config?.[key]
|
|
60
|
+
return isVolatileRef(value) ? value.get() : value
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The settings document is the source of truth: it merges the bundle defaults
|
|
64
|
+
* with the profile override the page writes, while the config object this
|
|
65
|
+
* plugin was mounted with can still be the bundle layer alone.
|
|
66
|
+
*/
|
|
67
|
+
const settingsFromDocument = () => {
|
|
68
|
+
try {
|
|
69
|
+
const forms = ctx.get?.("settings")
|
|
70
|
+
const row = forms?.describe?.().find?.((item) => item.ns === NAMESPACE)
|
|
71
|
+
if (row === undefined) return undefined
|
|
72
|
+
return resolveSettings(row.value ?? {})
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const settingsOf = () => settingsFromDocument() ?? resolveSettings(
|
|
78
|
+
Object.fromEntries(Object.keys(DEFAULTS).map((key) => [key, pluck(live(), key)])),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const updater = createUpdater({
|
|
82
|
+
readSettings: settingsOf,
|
|
83
|
+
logPath: settingsOf().logPath,
|
|
84
|
+
manager: () => {
|
|
85
|
+
try {
|
|
86
|
+
return ctx.get?.("pluginManager")
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// The page is ours; keep the settings service from generating one.
|
|
94
|
+
ctx.inject(["settings"], (child) => {
|
|
95
|
+
child.effect(() => child.settings.configure({ auto: false }, ctx.fiber), "auto-update: settings presentation")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One timeout drives everything: when it fires the settings are read again,
|
|
100
|
+
* so switching the feature on takes effect without a restart (a disabled
|
|
101
|
+
* plugin wakes up once a minute to look).
|
|
102
|
+
*/
|
|
103
|
+
let timer
|
|
104
|
+
let stopped = false
|
|
105
|
+
const POLL_MS = 60_000
|
|
106
|
+
const arm = (delayMs) => {
|
|
107
|
+
if (stopped) return
|
|
108
|
+
timer = setTimeout(fire, Math.max(1000, delayMs))
|
|
109
|
+
timer.unref?.()
|
|
110
|
+
}
|
|
111
|
+
const fire = async () => {
|
|
112
|
+
if (stopped) return
|
|
113
|
+
let enabled = false
|
|
114
|
+
let intervalMs = DEFAULTS.intervalHours * 3_600_000
|
|
115
|
+
try {
|
|
116
|
+
const settings = settingsOf()
|
|
117
|
+
enabled = settings.enabled === true
|
|
118
|
+
intervalMs = Math.max(0.05, settings.intervalHours) * 3_600_000
|
|
119
|
+
if (enabled) await updater.runOnce()
|
|
120
|
+
} catch (error) {
|
|
121
|
+
await updater.log({ kind: "cycle", error: String(error?.message ?? error) })
|
|
122
|
+
}
|
|
123
|
+
arm(enabled ? intervalMs : POLL_MS)
|
|
124
|
+
}
|
|
125
|
+
const schedule = async () => {
|
|
126
|
+
const settings = settingsOf()
|
|
127
|
+
await updater.log({
|
|
128
|
+
kind: "schedule",
|
|
129
|
+
enabled: settings.enabled === true,
|
|
130
|
+
firstCheckMinutes: settings.initialDelayMinutes,
|
|
131
|
+
intervalHours: settings.intervalHours,
|
|
132
|
+
})
|
|
133
|
+
arm(Math.max(0, settings.initialDelayMinutes) * 60_000)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ctx.effect(() => {
|
|
137
|
+
void schedule()
|
|
138
|
+
return () => {
|
|
139
|
+
stopped = true
|
|
140
|
+
clearTimeout(timer)
|
|
141
|
+
}
|
|
142
|
+
}, "auto-update: schedule")
|
|
143
|
+
|
|
144
|
+
// A settings edit is a deliberate "look now", and so is a management change;
|
|
145
|
+
// without these the next look would wait for the interval to pass.
|
|
146
|
+
ctx.effect(() => {
|
|
147
|
+
// The loader emits this one filtered to the edited plugin's own fiber, so it
|
|
148
|
+
// must be heard here rather than on the root.
|
|
149
|
+
const listener = () => {
|
|
150
|
+
void updater.runOnce({ force: true }).catch(() => {})
|
|
151
|
+
}
|
|
152
|
+
ctx.on?.("loader/volatile-update", listener)
|
|
153
|
+
return () => {
|
|
154
|
+
ctx.off?.("loader/volatile-update", listener)
|
|
155
|
+
}
|
|
156
|
+
}, "auto-update: settings changes")
|
|
157
|
+
|
|
158
|
+
// A management change is the natural moment to look again; the next tick
|
|
159
|
+
// would otherwise wait for the interval to pass.
|
|
160
|
+
ctx.effect(() => {
|
|
161
|
+
// The manager emits on its own context, which bubbles to the root — not to
|
|
162
|
+
// a sibling row. Listen on the root so a management change is noticed.
|
|
163
|
+
const host = ctx.root ?? ctx
|
|
164
|
+
const listener = () => {
|
|
165
|
+
void updater.runOnce({ force: true }).catch(() => {})
|
|
166
|
+
}
|
|
167
|
+
host.on?.("plugin-manager/changed", listener)
|
|
168
|
+
return () => {
|
|
169
|
+
host.off?.("plugin-manager/changed", listener)
|
|
170
|
+
}
|
|
171
|
+
}, "auto-update: manager changes")
|
|
172
|
+
|
|
173
|
+
void updater.log({ kind: "plugin-started", pid: process.pid, platform: process.platform })
|
|
174
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-auto-update — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Registers one section on this bundle's page in the Plugins tab: a switch that
|
|
5
|
+
* turns automatic updates on, and checkbox lists for what may update and how
|
|
6
|
+
* far. Everything it shows and writes goes through the settings service
|
|
7
|
+
* (`configForms`), which persists the change in the profile Cordis patch and
|
|
8
|
+
* applies it to the running host plugin; the installed-bundle list comes from
|
|
9
|
+
* the plugin manager's own remote namespace.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: "@chendaleiq/dsh-plugin-auto-update",
|
|
14
|
+
factory: (require) => {
|
|
15
|
+
const React = require("react")
|
|
16
|
+
const { jsx, jsxs } = require("react/jsx-runtime")
|
|
17
|
+
const primitives = require("@deepseek-ai/dsh-client-ui-primitives")
|
|
18
|
+
|
|
19
|
+
/** Dictionary namespace for this page. */
|
|
20
|
+
const NS = "auto-update"
|
|
21
|
+
/** Settings namespace: the host row id this plugin's patch inserts. */
|
|
22
|
+
const NAMESPACE = "auto-update"
|
|
23
|
+
/** Package this section belongs to; other subjects render nothing. */
|
|
24
|
+
const BUNDLE = "@chendaleiq/dsh-plugin-auto-update"
|
|
25
|
+
/** Required services. */
|
|
26
|
+
const inject = ["slots", "locale", "configForms", "remote", "remote.pluginManager", "remote.settings"]
|
|
27
|
+
|
|
28
|
+
const CSS_ID = "dsh-plugin-auto-update/section.css"
|
|
29
|
+
const CSS = `
|
|
30
|
+
.dshAutoUpdate{display:flex;flex-direction:column;gap:10px}
|
|
31
|
+
.dshAutoUpdateTitle{margin:0;font-size:14px;font-weight:500;line-height:20px;color:var(--dsw-alias-label-primary)}
|
|
32
|
+
.dshAutoUpdateHint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
|
|
33
|
+
.dshAutoUpdateRow{display:flex;align-items:center;justify-content:space-between;gap:16px}
|
|
34
|
+
.dshAutoUpdateLabel{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}
|
|
35
|
+
.dshAutoUpdateSub{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
|
|
36
|
+
.dshAutoUpdateGroup{display:flex;flex-direction:column;gap:8px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);padding:9px 12px}
|
|
37
|
+
.dshAutoUpdateGroupTitle{margin:0;font-size:12px;font-weight:500;line-height:18px;color:var(--dsw-alias-label-secondary)}
|
|
38
|
+
.dshAutoUpdateList{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}
|
|
39
|
+
.dshAutoUpdatePolicies{display:flex;flex-wrap:wrap;gap:6px 18px;margin:0;padding:0;list-style:none}
|
|
40
|
+
.dshAutoUpdateItem{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}
|
|
41
|
+
.dshAutoUpdateItem input{accent-color:var(--dsw-alias-brand-primary);width:14px;height:14px;margin:0;flex:none}
|
|
42
|
+
.dshAutoUpdateName{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:12px;overflow-wrap:anywhere}
|
|
43
|
+
.dshAutoUpdateVersion{color:var(--dsw-alias-label-tertiary);font-size:12px;margin-left:auto;flex:none}
|
|
44
|
+
.dshAutoUpdateEmpty{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
|
|
45
|
+
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
/** Dictionaries registered with the app's own locale service. */
|
|
49
|
+
const DICTS = {
|
|
50
|
+
zh: {
|
|
51
|
+
title: "插件自动更新",
|
|
52
|
+
intro: "让选中的插件自动保持最新版本。更新由插件管理器完成,失败会回滚到原版本。",
|
|
53
|
+
enabled: "启用自动更新",
|
|
54
|
+
enabledHint: "关闭时插件什么都不做",
|
|
55
|
+
packages: "自动更新的插件",
|
|
56
|
+
all: "全部可更新的插件",
|
|
57
|
+
emptyAll: "还没有从注册表安装的插件。",
|
|
58
|
+
emptyNone: "已安装的插件都是本地安装或锁定版本,不参与自动更新。",
|
|
59
|
+
reasonLocal: "本地安装",
|
|
60
|
+
reasonPinned: "锁定版本",
|
|
61
|
+
reasonError: "存在错误",
|
|
62
|
+
summary: "共 {n} 个插件 · {m} 个可自动更新",
|
|
63
|
+
writeFailed: "保存失败:{reason}",
|
|
64
|
+
listFailed: "读取已安装插件列表失败,请刷新后重试。",
|
|
65
|
+
loading: "读取中…",
|
|
66
|
+
policies: "升级范围",
|
|
67
|
+
allowMajor: "允许跨大版本",
|
|
68
|
+
allowPrerelease: "允许预发布版本(rc / beta)",
|
|
69
|
+
dryRun: "仅演练(只写日志,不安装)",
|
|
70
|
+
readOnly: "本部署的设置为只读。",
|
|
71
|
+
unavailable: "设置服务还没有提供这个插件的配置,请重启后再试。",
|
|
72
|
+
stateLine: "设置状态:{status}",
|
|
73
|
+
},
|
|
74
|
+
en: {
|
|
75
|
+
title: "Auto update",
|
|
76
|
+
intro: "Keep the selected plugins on their newest release. Updates run through the plugin manager; a failed install rolls back.",
|
|
77
|
+
enabled: "Enable auto update",
|
|
78
|
+
enabledHint: "Nothing happens while this is off",
|
|
79
|
+
packages: "Plugins that may update",
|
|
80
|
+
all: "Every updatable plugin",
|
|
81
|
+
emptyAll: "No plugin has been installed from a registry yet.",
|
|
82
|
+
emptyNone: "Every installed plugin is local or pinned, so none can update.",
|
|
83
|
+
reasonLocal: "local install",
|
|
84
|
+
reasonPinned: "pinned",
|
|
85
|
+
reasonError: "has an error",
|
|
86
|
+
summary: "{n} installed · {m} updatable",
|
|
87
|
+
writeFailed: "Save failed: {reason}",
|
|
88
|
+
listFailed: "Could not read the installed plugins; refresh and try again.",
|
|
89
|
+
loading: "Loading…",
|
|
90
|
+
policies: "Version policy",
|
|
91
|
+
allowMajor: "Allow a new major version",
|
|
92
|
+
allowPrerelease: "Allow prereleases (rc / beta)",
|
|
93
|
+
dryRun: "Dry run (log only, install nothing)",
|
|
94
|
+
readOnly: "Settings are read-only on this deployment.",
|
|
95
|
+
unavailable: "The settings service does not serve this plugin's configuration yet; restart and try again.",
|
|
96
|
+
stateLine: "Settings state: {status}",
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A dependency value that names a mutable range rather than a pin or a path. */
|
|
101
|
+
function isFloatingSpec(spec) {
|
|
102
|
+
const text = String(spec ?? "").trim()
|
|
103
|
+
if (text === "") return false
|
|
104
|
+
if (/^(link|file|workspace|portal|git|github|https?):/.test(text)) return false
|
|
105
|
+
if (/^\.{1,2}\//.test(text) || text.startsWith("/") || text.startsWith("~/")) return false
|
|
106
|
+
if (/^(npm|jsr):/.test(text)) return true
|
|
107
|
+
return !/^=?v?\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$/.test(text)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Why a bundle cannot take part, or null when it can. */
|
|
111
|
+
function blockReason(bundle) {
|
|
112
|
+
if (bundle.error !== undefined) return "error"
|
|
113
|
+
const source = String(bundle.source ?? "")
|
|
114
|
+
if (/^(link|file|workspace|portal)/.test(source)) return "local"
|
|
115
|
+
if (!isFloatingSpec(source)) return "pinned"
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every bundle the profile installs, each with the reason it cannot update
|
|
121
|
+
* (null when it can). Local and pinned installs stay visible, so the list
|
|
122
|
+
* explains itself instead of looking empty.
|
|
123
|
+
*/
|
|
124
|
+
function installedBundles(bundles) {
|
|
125
|
+
return (Array.isArray(bundles) ? bundles : []).filter((bundle) => {
|
|
126
|
+
if (bundle === null || typeof bundle !== "object") return false
|
|
127
|
+
if (bundle.availability !== "profile") return false
|
|
128
|
+
if (bundle.installed !== true) return false
|
|
129
|
+
return typeof bundle.name === "string" && typeof bundle.version === "string"
|
|
130
|
+
}).map((bundle) => ({ ...bundle, blocked: blockReason(bundle) }))
|
|
131
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read one remote answer: this deployment answers `{ ok, value }`, and a
|
|
136
|
+
* refusal carries `{ ok: false, error }`. Anything else is a bug worth
|
|
137
|
+
* surfacing rather than an empty list.
|
|
138
|
+
*/
|
|
139
|
+
function listFromResponse(response) {
|
|
140
|
+
if (response?.ok !== true) {
|
|
141
|
+
const reason = response?.error?.message ?? response?.error?.code ?? "refused"
|
|
142
|
+
throw new Error(String(reason))
|
|
143
|
+
}
|
|
144
|
+
return Array.isArray(response.value) ? response.value : []
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Install the section's stylesheet once per document. */
|
|
148
|
+
function installStyles() {
|
|
149
|
+
if (typeof document === "undefined") return () => {}
|
|
150
|
+
const existing = document.querySelector(`style[data-plugin-css=${JSON.stringify(CSS_ID)}]`)
|
|
151
|
+
if (existing !== null) return () => {}
|
|
152
|
+
const tag = document.createElement("style")
|
|
153
|
+
tag.dataset.plugin = BUNDLE
|
|
154
|
+
tag.dataset.pluginCss = CSS_ID
|
|
155
|
+
tag.textContent = CSS
|
|
156
|
+
document.head.appendChild(tag)
|
|
157
|
+
return () => tag.remove()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Mount the plugin page section.
|
|
162
|
+
* @param ctx - the browser plugin context.
|
|
163
|
+
*/
|
|
164
|
+
function apply(ctx) {
|
|
165
|
+
const css = installStyles()
|
|
166
|
+
ctx.effect(() => css, "auto-update: section styles")
|
|
167
|
+
|
|
168
|
+
// The app's locale service owns the language; registering here keeps the
|
|
169
|
+
// section in step with Settings → General rather than the window language.
|
|
170
|
+
ctx.effect(() => ctx.locale.register(NS, DICTS), "auto-update: dictionaries")
|
|
171
|
+
const t = ctx.locale.bind(NS)
|
|
172
|
+
|
|
173
|
+
const form = ctx.configForms.get(NAMESPACE)
|
|
174
|
+
|
|
175
|
+
/** Re-render when the active language — or a late dictionary — changes. */
|
|
176
|
+
const useLocale = () => React.useSyncExternalStore(
|
|
177
|
+
(listener) => ctx.locale.subscribe(listener),
|
|
178
|
+
() => ctx.locale.getSnapshot().revision,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const useForm = () => React.useSyncExternalStore(
|
|
182
|
+
(listener) => form.subscribe(listener),
|
|
183
|
+
() => form.getSnapshot(),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
/** The shared settings document: holds the revision a write needs. */
|
|
187
|
+
const mirror = ctx.configForms.describe?.()
|
|
188
|
+
|
|
189
|
+
/** The updatable bundles, read once per mount from the manager's remote. */
|
|
190
|
+
const useUpdatableBundles = () => {
|
|
191
|
+
const [state, setState] = React.useState({ status: "loading", bundles: [] })
|
|
192
|
+
React.useEffect(() => {
|
|
193
|
+
let alive = true
|
|
194
|
+
const read = () => {
|
|
195
|
+
Promise.resolve()
|
|
196
|
+
.then(() => ctx.remote.pluginManager.listBundles())
|
|
197
|
+
.then((response) => {
|
|
198
|
+
if (alive) setState({ status: "ready", bundles: installedBundles(listFromResponse(response)) })
|
|
199
|
+
})
|
|
200
|
+
.catch((error) => {
|
|
201
|
+
if (alive) setState({ status: "failed", bundles: [], error: String(error?.message ?? error) })
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
read()
|
|
205
|
+
return () => {
|
|
206
|
+
alive = false
|
|
207
|
+
}
|
|
208
|
+
}, [])
|
|
209
|
+
return state
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One section on this bundle's page; other subjects get nothing. */
|
|
213
|
+
function AutoUpdateSection({ subject }) {
|
|
214
|
+
const snapshot = useForm()
|
|
215
|
+
const [write, setWrite] = React.useState(null)
|
|
216
|
+
const bundles = useUpdatableBundles()
|
|
217
|
+
useLocale()
|
|
218
|
+
if (subject === undefined || subject === null || subject.kind !== "bundle") return null
|
|
219
|
+
if (subject.pkg?.name !== BUNDLE) return null
|
|
220
|
+
|
|
221
|
+
const updatable = bundles.bundles.filter((bundle) => bundle.blocked === null)
|
|
222
|
+
const value = snapshot?.value ?? {}
|
|
223
|
+
const writable = snapshot?.writable !== false
|
|
224
|
+
const unavailable = snapshot?.status === "unavailable"
|
|
225
|
+
const enabled = value.enabled === true
|
|
226
|
+
const packages = Array.isArray(value.packages) ? value.packages : ["*"]
|
|
227
|
+
const everything = packages.includes("*")
|
|
228
|
+
const selected = (name) => everything || packages.includes(name)
|
|
229
|
+
/** The namespace's revision in the shared document, or undefined while it is unread. */
|
|
230
|
+
const revisionOf = () => {
|
|
231
|
+
const view = mirror?.getSnapshot?.()?.view
|
|
232
|
+
return view?.namespaces?.find((row) => row.ns === NAMESPACE)?.revision
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Write one field through the settings remote, retrying once after a
|
|
237
|
+
* refresh: a stale revision is the common failure, and the reason the
|
|
238
|
+
* remote gives is worth showing instead of a bare "refused".
|
|
239
|
+
*/
|
|
240
|
+
const persist = async (field, value) => {
|
|
241
|
+
let lastError
|
|
242
|
+
const ops = [{ op: "set", path: [field], value }]
|
|
243
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
244
|
+
const response = await ctx.remote.settings.mutate(NAMESPACE, ops, attempt === 0 ? revisionOf() : await freshRevision())
|
|
245
|
+
if (response?.ok === true) {
|
|
246
|
+
mirror?.acceptView?.(response.value)
|
|
247
|
+
return { ok: true }
|
|
248
|
+
}
|
|
249
|
+
if (response?.error !== undefined) lastError = response.error
|
|
250
|
+
if (attempt === 0) await mirror?.load?.()
|
|
251
|
+
}
|
|
252
|
+
const code = lastError?.code ?? lastError?.message ?? "refused"
|
|
253
|
+
return { ok: false, reason: String(code) }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Reload the document and read this namespace's revision again. */
|
|
257
|
+
const freshRevision = async () => {
|
|
258
|
+
await mirror?.load?.()
|
|
259
|
+
return revisionOf()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const set = (field, next) => {
|
|
263
|
+
if (!writable) {
|
|
264
|
+
setWrite({ ok: false, reason: "read-only" })
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
Promise.resolve()
|
|
268
|
+
.then(() => persist(field, next))
|
|
269
|
+
.then((result) => setWrite(result))
|
|
270
|
+
.catch((error) => setWrite({ ok: false, reason: String(error?.message ?? error) }))
|
|
271
|
+
}
|
|
272
|
+
const writePackages = (names) => set("packages", names)
|
|
273
|
+
|
|
274
|
+
/** Checking "all" stores the wildcard; unchecking stores an empty allowlist. */
|
|
275
|
+
const onAll = (checked) => writePackages(checked ? ["*"] : [])
|
|
276
|
+
/** A single toggle keeps the explicit list when the wildcard was not in play. */
|
|
277
|
+
const onOne = (name, checked) => {
|
|
278
|
+
const base = everything ? updatable.map((item) => item.name) : packages.filter((item) => item !== "*")
|
|
279
|
+
const next = checked ? [...new Set([...base, name])] : base.filter((item) => item !== name)
|
|
280
|
+
writePackages(next)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return jsxs("section", {
|
|
284
|
+
className: "dshAutoUpdate",
|
|
285
|
+
"data-plugin-auto-update": true,
|
|
286
|
+
children: [
|
|
287
|
+
jsx("h4", { className: "dshAutoUpdateTitle", children: t("title") }),
|
|
288
|
+
unavailable ? jsx("p", { className: "dshAutoUpdateHint", children: t("unavailable") }) : null,
|
|
289
|
+
!writable ? jsx("p", { className: "dshAutoUpdateHint", children: t("readOnly") }) : null,
|
|
290
|
+
write === null || write.ok !== false
|
|
291
|
+
? null
|
|
292
|
+
: jsx("p", {
|
|
293
|
+
className: "dshAutoUpdateEmpty",
|
|
294
|
+
"data-auto-update-write": "failed",
|
|
295
|
+
children: t("writeFailed", { reason: String(write.reason) }),
|
|
296
|
+
}),
|
|
297
|
+
jsx("div", { className: "dshAutoUpdateRow", children: [
|
|
298
|
+
jsxs("div", { children: [
|
|
299
|
+
jsx("div", { className: "dshAutoUpdateLabel", children: t("enabled") }),
|
|
300
|
+
jsx("div", { className: "dshAutoUpdateSub", children: t("enabledHint") }),
|
|
301
|
+
] }),
|
|
302
|
+
jsx(primitives.Switch, {
|
|
303
|
+
checked: enabled,
|
|
304
|
+
label: t("enabled"),
|
|
305
|
+
disabled: !writable || unavailable,
|
|
306
|
+
onChange: (next) => set("enabled", next === true),
|
|
307
|
+
}),
|
|
308
|
+
] }),
|
|
309
|
+
jsxs("div", { className: "dshAutoUpdateGroup", children: [
|
|
310
|
+
jsx("h5", { className: "dshAutoUpdateGroupTitle", children: t("policies") }),
|
|
311
|
+
jsx("ul", { className: "dshAutoUpdatePolicies", children: ["allowMajor", "allowPrerelease"].map((field) => jsx("li", { className: "dshAutoUpdateItem", children: [
|
|
312
|
+
jsx("input", {
|
|
313
|
+
type: "checkbox",
|
|
314
|
+
id: `dsh-auto-update-${field}`,
|
|
315
|
+
checked: value[field] === true,
|
|
316
|
+
disabled: !writable,
|
|
317
|
+
onChange: (event) => set(field, event.target.checked === true),
|
|
318
|
+
}),
|
|
319
|
+
jsx("label", { htmlFor: `dsh-auto-update-${field}`, children: t(field) }),
|
|
320
|
+
] }, field)) }),
|
|
321
|
+
] }),
|
|
322
|
+
enabled
|
|
323
|
+
? jsxs("div", { className: "dshAutoUpdateGroup", children: [
|
|
324
|
+
jsxs("div", { className: "dshAutoUpdateRow", children: [
|
|
325
|
+
jsx("h5", { className: "dshAutoUpdateGroupTitle", children: t("packages") }),
|
|
326
|
+
jsx("span", { className: "dshAutoUpdateSub", "data-auto-update-count": `${bundles.bundles.length}/${updatable.length}`, children: bundles.status === "ready" ? t("summary", { n: bundles.bundles.length, m: updatable.length }) : "" }),
|
|
327
|
+
] }),
|
|
328
|
+
bundles.status === "failed"
|
|
329
|
+
? jsx("p", { className: "dshAutoUpdateEmpty", children: `${t("listFailed")} ${bundles.error ?? ""}`.trim() })
|
|
330
|
+
: null,
|
|
331
|
+
bundles.status === "ready" && bundles.bundles.length === 0
|
|
332
|
+
? jsx("p", { className: "dshAutoUpdateEmpty", children: t("emptyAll") })
|
|
333
|
+
: null,
|
|
334
|
+
bundles.status === "ready" && bundles.bundles.length > 0 && updatable.length === 0
|
|
335
|
+
? jsx("p", { className: "dshAutoUpdateEmpty", children: t("emptyNone") })
|
|
336
|
+
: null,
|
|
337
|
+
bundles.status === "ready" && bundles.bundles.length > 0
|
|
338
|
+
? jsxs("ul", { className: "dshAutoUpdateList", children: [
|
|
339
|
+
updatable.length === 0
|
|
340
|
+
? null
|
|
341
|
+
: jsx("li", { className: "dshAutoUpdateItem", children: [
|
|
342
|
+
jsx("input", {
|
|
343
|
+
type: "checkbox",
|
|
344
|
+
id: "dsh-auto-update-all",
|
|
345
|
+
checked: everything,
|
|
346
|
+
disabled: !writable,
|
|
347
|
+
onChange: (event) => onAll(event.target.checked === true),
|
|
348
|
+
}),
|
|
349
|
+
jsx("label", { htmlFor: "dsh-auto-update-all", children: t("all") }),
|
|
350
|
+
] }),
|
|
351
|
+
...bundles.bundles.map((bundle) => {
|
|
352
|
+
const id = `dsh-auto-update-${bundle.name}`
|
|
353
|
+
const reason = bundle.blocked === null
|
|
354
|
+
? null
|
|
355
|
+
: bundle.blocked === "local"
|
|
356
|
+
? t("reasonLocal")
|
|
357
|
+
: bundle.blocked === "pinned"
|
|
358
|
+
? t("reasonPinned")
|
|
359
|
+
: t("reasonError")
|
|
360
|
+
return jsxs("li", { className: "dshAutoUpdateItem", children: [
|
|
361
|
+
jsx("input", {
|
|
362
|
+
type: "checkbox",
|
|
363
|
+
id,
|
|
364
|
+
checked: bundle.blocked === null && selected(bundle.name),
|
|
365
|
+
disabled: !writable || bundle.blocked !== null,
|
|
366
|
+
onChange: (event) => onOne(bundle.name, event.target.checked === true),
|
|
367
|
+
}),
|
|
368
|
+
jsx("label", { htmlFor: id, className: "dshAutoUpdateName", children: bundle.name }),
|
|
369
|
+
jsx("span", { className: "dshAutoUpdateVersion", children: reason === null ? bundle.version : `${bundle.version} · ${reason}` }),
|
|
370
|
+
] })
|
|
371
|
+
}),
|
|
372
|
+
] })
|
|
373
|
+
: null,
|
|
374
|
+
] })
|
|
375
|
+
: null,
|
|
376
|
+
],
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
ctx.effect(() => ctx.slots.inject("plugins.detail.section", () => ctx.slots.register({
|
|
381
|
+
name: "plugins.detail.section",
|
|
382
|
+
id: "auto-update",
|
|
383
|
+
order: 40,
|
|
384
|
+
}, AutoUpdateSection)), "auto-update: plugin page section")
|
|
385
|
+
|
|
386
|
+
// Console handle for support: `__DSH_AUTO_UPDATE__.state()` says whether
|
|
387
|
+
// the settings document is loaded and what this plugin's namespace holds.
|
|
388
|
+
try {
|
|
389
|
+
window.__DSH_AUTO_UPDATE__ = {
|
|
390
|
+
version: "0.1.16",
|
|
391
|
+
namespace: NAMESPACE,
|
|
392
|
+
state: () => ({
|
|
393
|
+
form: form.getSnapshot(),
|
|
394
|
+
document: mirror?.getSnapshot?.(),
|
|
395
|
+
}),
|
|
396
|
+
set: (field, value) => form.set(field, value),
|
|
397
|
+
retry: () => mirror?.load?.(),
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
/* a frozen window simply has no console handle */
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
ctx.effect(() => () => {
|
|
404
|
+
form.dispose?.()
|
|
405
|
+
}, "auto-update: form disposal")
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { inject, apply, internals: { installedBundles, listFromResponse, isFloatingSpec, blockReason } }
|
|
409
|
+
},
|
|
410
|
+
})
|
package/lib/plan.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-auto-update
|
|
3
|
+
*
|
|
4
|
+
* Host half only: keep selected plugin bundles on their newest compatible
|
|
5
|
+
* release, without going through "uninstall, reinstall, done by hand".
|
|
6
|
+
*
|
|
7
|
+
* Design notes
|
|
8
|
+
*
|
|
9
|
+
* - Updates go through the running profile's own `pluginManager` service
|
|
10
|
+
* (`listBundles` / `removeBundle` / `installBundle`). That service owns the
|
|
11
|
+
* profile write lock, the pnpm run, the bundle-patch check and the HMR
|
|
12
|
+
* recompose, so this plugin never touches `package.json` or `node_modules`
|
|
13
|
+
* itself. If the service is missing (a headless profile), the plugin does
|
|
14
|
+
* nothing at all.
|
|
15
|
+
* - Nothing is updated unless the user switched it on AND named the package:
|
|
16
|
+
* `packages: ["*"]` means every registry-installed bundle, an explicit list
|
|
17
|
+
* means exactly those. Local `link:` / `file:` installs are never touched.
|
|
18
|
+
* - Pinned installs stay pinned. A dependency recorded as an exact version
|
|
19
|
+
* (`"1.2.3"`) is left alone, because the person who wrote it chose a pin; a
|
|
20
|
+
* range (`^1.2.3`, `~1.2.3`, `*`) is what this plugin advances.
|
|
21
|
+
* - The settings file is re-read on every cycle, so editing
|
|
22
|
+
* `~/.dsh/plugin-auto-update.json` takes effect without a restart.
|
|
23
|
+
* - Replacing a host half needs a restart to take effect; the cycle reports
|
|
24
|
+
* `restartRequired` and the desktop decides when to run it. A renderer half
|
|
25
|
+
* is picked up by HMR on its own.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFile } from "node:child_process"
|
|
29
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
30
|
+
import { homedir } from "node:os"
|
|
31
|
+
import { dirname, join } from "node:path"
|
|
32
|
+
|
|
33
|
+
/** Defaults written to the settings file the first time the plugin runs. */
|
|
34
|
+
export const DEFAULTS = {
|
|
35
|
+
/** Master switch. Off until the person turns it on. */
|
|
36
|
+
enabled: false,
|
|
37
|
+
/** `["*"]` = every registry-installed bundle; otherwise an allowlist of package names. */
|
|
38
|
+
packages: ["*"],
|
|
39
|
+
/** A newer major is offered only when this is true. */
|
|
40
|
+
allowMajor: false,
|
|
41
|
+
/** `1.0.0-rc.1` is offered only when this is true. */
|
|
42
|
+
allowPrerelease: false,
|
|
43
|
+
/** How often to look, in hours. */
|
|
44
|
+
intervalHours: 6,
|
|
45
|
+
/** First look after this many minutes, so startup is not disturbed. */
|
|
46
|
+
initialDelayMinutes: 1,
|
|
47
|
+
/** Log what would happen without changing anything. */
|
|
48
|
+
dryRun: false,
|
|
49
|
+
/** Stop after this many updates in one cycle. */
|
|
50
|
+
maxUpdatesPerRun: 5,
|
|
51
|
+
/** Registry used to look up the newest version. */
|
|
52
|
+
registry: "https://registry.npmjs.org",
|
|
53
|
+
/** Per-request timeout for the registry lookup. */
|
|
54
|
+
timeoutMs: 20000,
|
|
55
|
+
/** A desktop banner (macOS) after a cycle that changed something. */
|
|
56
|
+
notify: true,
|
|
57
|
+
/** Where the log lives. */
|
|
58
|
+
logPath: "~/.dsh/plugin-auto-update.log",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PLUGIN_LOG_LIMIT = 4000
|
|
62
|
+
|
|
63
|
+
/** Expand a leading `~` against the current home. */
|
|
64
|
+
export function expandHome(path) {
|
|
65
|
+
const text = String(path ?? "")
|
|
66
|
+
if (text === "~") return homedir()
|
|
67
|
+
if (text.startsWith("~/")) return join(homedir(), text.slice(2))
|
|
68
|
+
return text
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse `X.Y.Z[-prerelease][+build]`.
|
|
73
|
+
* @returns {{major:number, minor:number, patch:number, prerelease:string}|null}
|
|
74
|
+
*/
|
|
75
|
+
export function parseVersion(value) {
|
|
76
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
|
|
77
|
+
String(value ?? "").trim(),
|
|
78
|
+
)
|
|
79
|
+
if (match === null) return null
|
|
80
|
+
return {
|
|
81
|
+
major: Number(match[1]),
|
|
82
|
+
minor: Number(match[2]),
|
|
83
|
+
patch: Number(match[3]),
|
|
84
|
+
prerelease: match[4] ?? "",
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Compare two release identifiers; numeric segments compare as numbers. */
|
|
89
|
+
function comparePrerelease(left, right) {
|
|
90
|
+
if (left === right) return 0
|
|
91
|
+
if (left === "") return 1
|
|
92
|
+
if (right === "") return -1
|
|
93
|
+
const a = left.split(".")
|
|
94
|
+
const b = right.split(".")
|
|
95
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
96
|
+
const one = a[index]
|
|
97
|
+
const two = b[index]
|
|
98
|
+
if (one === undefined) return -1
|
|
99
|
+
if (two === undefined) return 1
|
|
100
|
+
const numericOne = /^\d+$/.test(one)
|
|
101
|
+
const numericTwo = /^\d+$/.test(two)
|
|
102
|
+
if (numericOne && numericTwo) {
|
|
103
|
+
const difference = Number(one) - Number(two)
|
|
104
|
+
if (difference !== 0) return difference < 0 ? -1 : 1
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (numericOne !== numericTwo) return numericOne ? -1 : 1
|
|
108
|
+
if (one !== two) return one < two ? -1 : 1
|
|
109
|
+
}
|
|
110
|
+
return 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Compare two versions the way semver orders releases.
|
|
115
|
+
* @returns {-1|0|1} negative when `left` is older.
|
|
116
|
+
*/
|
|
117
|
+
export function compareVersions(left, right) {
|
|
118
|
+
const a = typeof left === "string" ? parseVersion(left) : left
|
|
119
|
+
const b = typeof right === "string" ? parseVersion(right) : right
|
|
120
|
+
if (a === null || b === null) return 0
|
|
121
|
+
for (const field of ["major", "minor", "patch"]) {
|
|
122
|
+
if (a[field] !== b[field]) return a[field] < b[field] ? -1 : 1
|
|
123
|
+
}
|
|
124
|
+
return comparePrerelease(a.prerelease, b.prerelease)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether `latest` is an update this configuration accepts for `installed`.
|
|
129
|
+
* @param {string} installed Version currently on disk.
|
|
130
|
+
* @param {string} latest Version offered by the registry.
|
|
131
|
+
* @param {{allowMajor?:boolean, allowPrerelease?:boolean}} policy
|
|
132
|
+
*/
|
|
133
|
+
export function acceptsUpdate(installed, latest, policy = {}) {
|
|
134
|
+
const current = parseVersion(installed)
|
|
135
|
+
const next = parseVersion(latest)
|
|
136
|
+
if (current === null || next === null) return false
|
|
137
|
+
if (compareVersions(current, next) >= 0) return false
|
|
138
|
+
if (next.prerelease !== "" && policy.allowPrerelease !== true) return false
|
|
139
|
+
if (current.prerelease === "" && next.major !== current.major && policy.allowMajor !== true) return false
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A dependency value that names a mutable range rather than a pin or a local path. */
|
|
144
|
+
export function isFloatingSpec(spec) {
|
|
145
|
+
const text = String(spec ?? "").trim()
|
|
146
|
+
if (text === "") return false
|
|
147
|
+
if (/^(link|file|workspace|portal|git|github|https?):/.test(text)) return false
|
|
148
|
+
// Path specs point at a checkout, whose version is whatever that checkout says.
|
|
149
|
+
if (/^\.{1,2}\//.test(text) || text.startsWith("/") || text.startsWith("~/")) return false
|
|
150
|
+
if (/^(npm|jsr):/.test(text)) return true
|
|
151
|
+
// `1.2.3` / `=1.2.3` are pins; `^1.2.3`, `~1.2.3`, `>=1`, `*`, `latest` are ranges.
|
|
152
|
+
return !/^=?v?\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$/.test(text)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Pick the bundles worth looking at: installed through the profile, version
|
|
157
|
+
* known, no manager error, not a local path, and named by the allowlist.
|
|
158
|
+
*/
|
|
159
|
+
export function selectCandidates(bundles, packages) {
|
|
160
|
+
const allow = Array.isArray(packages) ? packages : ["*"]
|
|
161
|
+
const everything = allow.includes("*")
|
|
162
|
+
return (bundles ?? []).filter((bundle) => {
|
|
163
|
+
if (bundle === null || typeof bundle !== "object") return false
|
|
164
|
+
if (bundle.availability !== "profile") return false
|
|
165
|
+
if (bundle.installed !== true) return false
|
|
166
|
+
if (typeof bundle.name !== "string" || typeof bundle.version !== "string") return false
|
|
167
|
+
if (bundle.error !== undefined) return false
|
|
168
|
+
if (!isFloatingSpec(bundle.source)) return false
|
|
169
|
+
return everything || allow.includes(bundle.name)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Read the newest published version of one package. */
|
|
174
|
+
export async function newestVersion(registry, name, options = {}) {
|
|
175
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
176
|
+
const timeoutMs = options.timeoutMs ?? DEFAULTS.timeoutMs
|
|
177
|
+
if (typeof fetchImpl !== "function") return { error: "fetch is unavailable" }
|
|
178
|
+
const base = String(registry ?? DEFAULTS.registry).replace(/\/+$/, "")
|
|
179
|
+
const encoded = name.startsWith("@") ? name.replace("/", "%2F") : encodeURIComponent(name)
|
|
180
|
+
const url = `${base}/${encoded}/latest`
|
|
181
|
+
try {
|
|
182
|
+
const response = await fetchImpl(url, {
|
|
183
|
+
headers: { accept: "application/json" },
|
|
184
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
185
|
+
})
|
|
186
|
+
if (response.ok !== true) return { error: `registry answered ${String(response.status)}` }
|
|
187
|
+
const body = await response.json()
|
|
188
|
+
if (typeof body?.version !== "string") return { error: "registry answer has no version" }
|
|
189
|
+
return { version: body.version }
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return { error: String(error?.message ?? error) }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Merge the stored settings over the defaults, ignoring unknown keys. */
|
|
196
|
+
export function resolveSettings(stored) {
|
|
197
|
+
const resolved = { ...DEFAULTS }
|
|
198
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
199
|
+
const value = stored?.[key]
|
|
200
|
+
if (value === undefined) continue
|
|
201
|
+
if (Array.isArray(DEFAULTS[key])) {
|
|
202
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) resolved[key] = value
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
if (typeof DEFAULTS[key] === "number") {
|
|
206
|
+
if (typeof value === "number" && Number.isFinite(value)) resolved[key] = value
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (typeof DEFAULTS[key] === "boolean") {
|
|
210
|
+
if (typeof value === "boolean") resolved[key] = value
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (typeof value === "string" && value !== "") resolved[key] = value
|
|
214
|
+
}
|
|
215
|
+
return resolved
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One line per action, JSON so it stays greppable. */
|
|
219
|
+
export async function appendLog(path, entry) {
|
|
220
|
+
const target = expandHome(path)
|
|
221
|
+
await mkdir(dirname(target), { recursive: true })
|
|
222
|
+
await appendFile(target, `${JSON.stringify(entry)}\n`, "utf8")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A desktop banner on macOS; other platforms keep the log line only. */
|
|
226
|
+
function announce(title, body) {
|
|
227
|
+
if (process.platform !== "darwin") return
|
|
228
|
+
try {
|
|
229
|
+
execFile(
|
|
230
|
+
"/usr/bin/osascript",
|
|
231
|
+
[
|
|
232
|
+
"-e",
|
|
233
|
+
'on run argv\n display notification (item 1 of argv) with title (item 2 of argv)\nend run',
|
|
234
|
+
body,
|
|
235
|
+
title,
|
|
236
|
+
],
|
|
237
|
+
{ timeout: 10000 },
|
|
238
|
+
() => {},
|
|
239
|
+
)
|
|
240
|
+
} catch {
|
|
241
|
+
/* a banner is best effort */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The outcome of handing the update to the plugin manager. */
|
|
246
|
+
function outcomeOf(result, fallback) {
|
|
247
|
+
if (result === null || typeof result !== "object") return { application: fallback }
|
|
248
|
+
if (result.application === "failed") {
|
|
249
|
+
const reason = result.error?.code ?? result.error?.message ?? "unknown"
|
|
250
|
+
return { application: "failed", reason: String(reason) }
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
application: typeof result.application === "string" ? result.application : fallback,
|
|
254
|
+
...result.restartReason === undefined ? {} : { restartReason: result.restartReason },
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* One updater instance: a settings file, a plugin manager, and a log.
|
|
260
|
+
* Everything external is injected so the cycle can be tested without a desktop.
|
|
261
|
+
*/
|
|
262
|
+
export function createUpdater(options = {}) {
|
|
263
|
+
const managerOf = typeof options.manager === "function" ? options.manager : () => options.manager
|
|
264
|
+
const readSettings = typeof options.readSettings === "function"
|
|
265
|
+
? options.readSettings
|
|
266
|
+
: () => resolveSettings(options.config ?? {})
|
|
267
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
268
|
+
const logPath = options.logPath ?? DEFAULTS.logPath
|
|
269
|
+
let running = false
|
|
270
|
+
let lastRunAt = 0
|
|
271
|
+
// `plugin-manager/changed` fires around our own install, so a short cooldown
|
|
272
|
+
// keeps one update from turning into a burst of registry lookups.
|
|
273
|
+
const cooldownMs = options.cooldownMs ?? 5000
|
|
274
|
+
|
|
275
|
+
const log = async (entry) => {
|
|
276
|
+
try {
|
|
277
|
+
await appendLog(logPath, { at: new Date().toISOString(), ...entry })
|
|
278
|
+
} catch {
|
|
279
|
+
/* the log must never break a cycle */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const updateOne = async (bundle, latest) => {
|
|
284
|
+
const manager = managerOf()
|
|
285
|
+
if (manager === undefined) return { ok: false, error: "pluginManager is gone" }
|
|
286
|
+
let removed
|
|
287
|
+
try {
|
|
288
|
+
removed = await manager.removeBundle(bundle.name)
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return { ok: false, error: String(error?.message ?? error) }
|
|
291
|
+
}
|
|
292
|
+
const removal = outcomeOf(removed, "applied")
|
|
293
|
+
if (removal.application === "failed") return { ok: false, error: `remove failed: ${removal.reason}` }
|
|
294
|
+
let installed
|
|
295
|
+
try {
|
|
296
|
+
installed = await manager.installBundle(`${bundle.name}@${latest}`, { enabled: true })
|
|
297
|
+
} catch (error) {
|
|
298
|
+
// Put the previous version back: leaving the bundle uninstalled would be
|
|
299
|
+
// worse than the failed update.
|
|
300
|
+
try {
|
|
301
|
+
await manager.installBundle(`${bundle.name}@${bundle.version}`, { enabled: true })
|
|
302
|
+
} catch {
|
|
303
|
+
/* the rollback is best effort; the log names it either way */
|
|
304
|
+
}
|
|
305
|
+
return { ok: false, error: String(error?.message ?? error), rolledBack: true }
|
|
306
|
+
}
|
|
307
|
+
const installing = outcomeOf(installed, "applied")
|
|
308
|
+
if (installing.application === "failed") {
|
|
309
|
+
try {
|
|
310
|
+
await manager.installBundle(`${bundle.name}@${bundle.version}`, { enabled: true })
|
|
311
|
+
} catch {
|
|
312
|
+
/* see above */
|
|
313
|
+
}
|
|
314
|
+
return { ok: false, error: `install failed: ${installing.reason}`, rolledBack: true }
|
|
315
|
+
}
|
|
316
|
+
return { ok: true, application: installing.application, restartReason: installing.restartReason }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const runOnce = async (runOptions = {}) => {
|
|
320
|
+
if (running) return { skipped: "busy" }
|
|
321
|
+
if (runOptions.force !== true && Date.now() - lastRunAt < cooldownMs) return { skipped: "cooldown" }
|
|
322
|
+
running = true
|
|
323
|
+
lastRunAt = Date.now()
|
|
324
|
+
try {
|
|
325
|
+
const resolved = readSettings()
|
|
326
|
+
const manager = managerOf()
|
|
327
|
+
if (manager === undefined) {
|
|
328
|
+
await log({ kind: "cycle", skipped: "no-plugin-manager" })
|
|
329
|
+
return { skipped: "no-plugin-manager" }
|
|
330
|
+
}
|
|
331
|
+
const bundles = await manager.listBundles()
|
|
332
|
+
const candidates = selectCandidates(bundles, resolved.packages)
|
|
333
|
+
if (resolved.enabled !== true) {
|
|
334
|
+
await log({ kind: "cycle", skipped: "disabled", candidates: candidates.map((item) => item.name) })
|
|
335
|
+
return { skipped: "disabled", candidates: candidates.length }
|
|
336
|
+
}
|
|
337
|
+
const decisions = []
|
|
338
|
+
let changed = 0
|
|
339
|
+
for (const bundle of candidates) {
|
|
340
|
+
if (changed >= resolved.maxUpdatesPerRun) break
|
|
341
|
+
const newest = await newestVersion(resolved.registry, bundle.name, {
|
|
342
|
+
fetchImpl,
|
|
343
|
+
timeoutMs: resolved.timeoutMs,
|
|
344
|
+
})
|
|
345
|
+
if (newest.error !== undefined) {
|
|
346
|
+
decisions.push({ name: bundle.name, action: "error", reason: newest.error })
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
if (!acceptsUpdate(bundle.version, newest.version, resolved)) {
|
|
350
|
+
decisions.push({ name: bundle.name, action: "current", installed: bundle.version, latest: newest.version })
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
if (resolved.dryRun === true) {
|
|
354
|
+
decisions.push({ name: bundle.name, action: "would-update", installed: bundle.version, latest: newest.version })
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
const outcome = await updateOne(bundle, newest.version)
|
|
358
|
+
changed += outcome.ok ? 1 : 0
|
|
359
|
+
decisions.push({
|
|
360
|
+
name: bundle.name,
|
|
361
|
+
action: outcome.ok ? "updated" : "failed",
|
|
362
|
+
installed: bundle.version,
|
|
363
|
+
latest: newest.version,
|
|
364
|
+
...outcome.ok
|
|
365
|
+
? { application: outcome.application, ...outcome.restartReason === undefined ? {} : { restartReason: outcome.restartReason } }
|
|
366
|
+
: { reason: outcome.error, rolledBack: outcome.rolledBack === true },
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
await log({ kind: "cycle", decisions })
|
|
370
|
+
const updated = decisions.filter((item) => item.action === "updated")
|
|
371
|
+
if (updated.length > 0) {
|
|
372
|
+
const names = updated.map((item) => `${item.name} → ${item.latest}`).join(", ")
|
|
373
|
+
await log({ kind: "notify", names })
|
|
374
|
+
if (options.announce ?? true) {
|
|
375
|
+
announce("DSH 插件已自动更新", `${names}${updated.some((item) => item.restartReason !== undefined) ? ",重启后生效" : ""}`)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { decisions, updated: updated.length }
|
|
379
|
+
} finally {
|
|
380
|
+
running = false
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { runOnce, log, settings: () => readSettings() }
|
|
385
|
+
}
|
|
386
|
+
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chendaleiq/dsh-plugin-auto-update",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.16",
|
|
4
|
+
"description": "Automatic updates for the plugin bundles you choose in DeepSeek Harness, per package, through the plugin manager",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"author": "chendaleiQ",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/chendaleiQ/dsh-plugin-auto-update.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/chendaleiQ/dsh-plugin-auto-update#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/chendaleiQ/dsh-plugin-auto-update/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"dsh",
|
|
19
|
+
"dsh-plugin",
|
|
20
|
+
"plugin-updater",
|
|
21
|
+
"auto-update"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"lib/",
|
|
26
|
+
"locale/",
|
|
27
|
+
"icon.svg",
|
|
28
|
+
"cordis.patch.yml",
|
|
29
|
+
"README.md",
|
|
30
|
+
"README.en.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node test/index.test.mjs"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
},
|
|
40
|
+
"client": {
|
|
41
|
+
"platform": "web",
|
|
42
|
+
"inject": [
|
|
43
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"exports": {
|
|
49
|
+
".": "./index.js",
|
|
50
|
+
"./client": "./lib/client.js",
|
|
51
|
+
"./icon": "./icon.svg",
|
|
52
|
+
"./locale/*.json": "./locale/*.json",
|
|
53
|
+
"./package.json": "./package.json"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/schemastery": "*"
|
|
57
|
+
},
|
|
58
|
+
"icon": "./icon.svg",
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|