dsh-syn-config 0.7.2 → 0.8.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.md +5 -2
- package/assets/syn-dsh.md +10 -0
- package/client/client.js +214 -32
- package/docs/RELEASE.md +13 -0
- package/lib/core/commands.js +89 -26
- package/lib/core/http.js +243 -36
- package/lib/index.js +30 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
跨电脑同步 DSH 配置(技能 / profile 设置 / AGENTS.md)的 DeepSeek Harness 插件,**macOS 与 Windows 同一份代码**。
|
|
4
4
|
|
|
5
|
-
> 状态:**
|
|
5
|
+
> 状态:**P7(交互)**。六个工具 + 一个斜杠命令 + 一枚状态灯与它的面板 + 随包技能:
|
|
6
6
|
> - `dsh_sync_status`:仓库分支 / 上游 / 领先落后 / 脏文件数(默认不联网)。
|
|
7
7
|
> - `dsh_sync_doctor`:`doctor.sh` 的 Node 移植,六节体检,退出码 0-2-1 与脚本一致。
|
|
8
8
|
> - `dsh_sync_pull`:`git pull --ff-only` + 镜像 skills / agents-skills / weekly;**默认 dry-run**。
|
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
> 引导(新机第一条命令)仍由 `dsh-config/scripts/apply.sh` 负责——插件装不上时它不在。
|
|
15
15
|
>
|
|
16
16
|
> `/syn-dsh status|doctor|pull|push|secrets`:斜杠命令,宿主半执行、不进模型消息;`/syn-dsh push now` 才真推。
|
|
17
|
-
>
|
|
17
|
+
> 输入框上方的状态圆点:绿=已同步、黄=有漂移(↑领先/↓落后/±脏)、红=不可用。
|
|
18
|
+
> 点它打开面板:顶部是体检结论(阻塞/警告数),下面四个动作——**体检 / 拉取计划 / 推送计划 / 密钥扫描**——都是对只读路由的 `GET`,面板只能看、不能写;每个结果旁边有「复制命令」,真正的写操作仍要 `/syn-dsh … now`。
|
|
18
19
|
> `syn-dsh` 技能由插件自带(`assets/syn-dsh.md`,rank 600),不再放在配置仓库里。
|
|
19
20
|
>
|
|
21
|
+
> **密钥只由人注入。** 工具与斜杠命令都拒绝写 `inject`/`refresh`:`/syn-dsh secrets` 只放 `scan`/`fingerprint`,`dsh_sync_secrets` 在 `dry_run:false` 时直接拒绝,并提示自己执行 `bash ~/dsh-config/scripts/apply.sh --yes`。确需让工具代写,本机在插件行加 `allowSecretInject: true`(默认关闭)。
|
|
22
|
+
>
|
|
20
23
|
> 设计理由与后续阶段见主仓库的调研文档:`../research/syn-dsh-plugin/PLAN.md`。
|
|
21
24
|
|
|
22
25
|
## 为什么是插件,而不是又一层 shell
|
package/assets/syn-dsh.md
CHANGED
|
@@ -221,3 +221,13 @@ cd ~/dsh-config && bash scripts/cross-platform-selftest.sh
|
|
|
221
221
|
2. **变化**:改动的技能名(超过 10 个只列前 10 + 共几个);是否动了配置/插件
|
|
222
222
|
3. **生效**:新开会话(技能)/ 重启客户端(插件)
|
|
223
223
|
4. **待办**:缺密钥、冲突未合、推送失败等
|
|
224
|
+
|
|
225
|
+
## 密钥注入只由人执行
|
|
226
|
+
|
|
227
|
+
`/syn-dsh secrets` 只支持 `scan` / `fingerprint`(只读)。`inject` / `refresh` 会写真实密钥进已部署配置,**只能由人执行**:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
bash ~/dsh-config/scripts/apply.sh --yes # 从 ~/.dsh/secrets.env 注入
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
要让模型工具代写,需在本机插件行显式加 `allowSecretInject: true`(默认关闭)。
|
package/client/client.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser half:
|
|
2
|
+
* Browser half: a status chip in the composer dock, and a small panel behind it.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The chip answers "am I in sync?". The panel answers "what should I do next?"
|
|
5
|
+
* without leaving the input box: one health check, the dry-run plan of a pull
|
|
6
|
+
* and of a push, and a secret scan. Every one of those is a `GET` on a
|
|
7
|
+
* read-only route — the panel can *look*, never write. Anything that mutates
|
|
8
|
+
* still goes through `/syn-dsh … now`, which is the only path that writes, and
|
|
9
|
+
* the panel gives you that command on one click (copy).
|
|
8
10
|
*
|
|
9
11
|
* Plain JavaScript, no build step: the module table supplies React.
|
|
10
12
|
*/
|
|
@@ -14,7 +16,25 @@ window.__ModuleLoader__.load({
|
|
|
14
16
|
factory(require) {
|
|
15
17
|
const React = require('react')
|
|
16
18
|
const h = React.createElement
|
|
17
|
-
const
|
|
19
|
+
const BASE = '/plugins/dsh-syn-config'
|
|
20
|
+
const STATUS_URL = `${BASE}/status`
|
|
21
|
+
const DOCTOR_URL = `${BASE}/doctor`
|
|
22
|
+
const PLAN_URL = `${BASE}/plan`
|
|
23
|
+
const SECRETS_URL = `${BASE}/secrets`
|
|
24
|
+
|
|
25
|
+
/** The four things the panel can look at, and the command that acts on it. */
|
|
26
|
+
const ACTIONS = [
|
|
27
|
+
{ id: 'doctor', label: '体检', url: DOCTOR_URL, command: '/syn-dsh doctor' },
|
|
28
|
+
{ id: 'pull', label: '拉取计划', url: `${PLAN_URL}?action=pull`, command: '/syn-dsh pull now' },
|
|
29
|
+
{ id: 'push', label: '推送计划', url: `${PLAN_URL}?action=push`, command: '/syn-dsh push now' },
|
|
30
|
+
{ id: 'secrets', label: '密钥扫描', url: SECRETS_URL, command: '/syn-dsh secrets scan' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
async function getJson(url, signal) {
|
|
34
|
+
const response = await fetch(url, { signal, headers: { accept: 'application/json' } })
|
|
35
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
36
|
+
return response.json()
|
|
37
|
+
}
|
|
18
38
|
|
|
19
39
|
/** Fetch the host status once; abort on unmount. */
|
|
20
40
|
function useStatus() {
|
|
@@ -23,8 +43,7 @@ window.__ModuleLoader__.load({
|
|
|
23
43
|
React.useEffect(() => {
|
|
24
44
|
const controller = new AbortController()
|
|
25
45
|
let alive = true
|
|
26
|
-
|
|
27
|
-
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
46
|
+
getJson(STATUS_URL, controller.signal)
|
|
28
47
|
.then((body) => {
|
|
29
48
|
if (alive) setState({ phase: 'ready', body })
|
|
30
49
|
})
|
|
@@ -59,38 +78,201 @@ window.__ModuleLoader__.load({
|
|
|
59
78
|
return { color: '#51b976', label: `${body.branch || 'HEAD'} 已同步` }
|
|
60
79
|
}
|
|
61
80
|
|
|
81
|
+
/** Turn a payload into a few short lines — the panel is not a report. */
|
|
82
|
+
function describeResult(action, body) {
|
|
83
|
+
if (body?.ok === false && typeof body.error === 'string') return `失败:${body.error}`
|
|
84
|
+
if (action === 'doctor') {
|
|
85
|
+
const head = `体检:${body.fail} 阻塞 / ${body.warn} 警告`
|
|
86
|
+
const rows = (body.sections ?? []).map((section) => {
|
|
87
|
+
const mark = section.blocking > 0 ? '❌' : section.status === 'warn' ? '⚠️' : '✅'
|
|
88
|
+
return `${mark} ${section.title}${section.summary === '' ? '' : ` — ${section.summary}`}`
|
|
89
|
+
})
|
|
90
|
+
return [head, ...rows].join('\n')
|
|
91
|
+
}
|
|
92
|
+
if (action === 'pull') {
|
|
93
|
+
const mirrors = (body.mirrors ?? []).map((m) => `${m.name} +${m.added} ~${m.updated} -${m.deleted}`).join(' · ')
|
|
94
|
+
return [`远端提交:${body.incomingCommits ?? 0}`, `${String(body.before ?? '').slice(0, 7)} → ${String(body.after ?? '').slice(0, 7)}`, mirrors].filter(Boolean).join('\n')
|
|
95
|
+
}
|
|
96
|
+
if (action === 'push') {
|
|
97
|
+
const c = body.changes ?? {}
|
|
98
|
+
return [
|
|
99
|
+
`变更:+${c.added ?? 0} ~${c.updated ?? 0} -${c.deleted ?? 0} · 技能 ${body.skills ?? 0}`,
|
|
100
|
+
`门禁:lint ${body.gates?.lint ? '✅' : '❌'} · redact ${body.gates?.redacted ?? 0} · scan ${body.gates?.scan ? '✅' : '❌'}`,
|
|
101
|
+
body.commit?.created ? `提交:${body.commit.created}` : body.commit?.unchanged ? '无变更可提交' : 'dry run:未写入',
|
|
102
|
+
].join('\n')
|
|
103
|
+
}
|
|
104
|
+
return `明文命中:${body.hits ?? 0}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const panelStyle = {
|
|
108
|
+
position: 'absolute',
|
|
109
|
+
bottom: 'calc(100% + 8px)',
|
|
110
|
+
left: 0,
|
|
111
|
+
zIndex: 40,
|
|
112
|
+
width: '360px',
|
|
113
|
+
maxWidth: '80vw',
|
|
114
|
+
padding: '10px',
|
|
115
|
+
borderRadius: '10px',
|
|
116
|
+
border: '0.5px solid var(--dsw-alias-border-l2, #494949)',
|
|
117
|
+
background: 'var(--dsw-alias-bg-layer-1, #1f1f1f)',
|
|
118
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
|
119
|
+
color: 'var(--dsw-alias-label-primary, #e6e6e6)',
|
|
120
|
+
fontSize: '12px',
|
|
121
|
+
lineHeight: '18px',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const tabStyle = {
|
|
125
|
+
padding: '3px 8px',
|
|
126
|
+
fontSize: '12px',
|
|
127
|
+
borderRadius: '6px',
|
|
128
|
+
border: '0.5px solid var(--dsw-alias-border-l2, #494949)',
|
|
129
|
+
background: 'transparent',
|
|
130
|
+
color: 'inherit',
|
|
131
|
+
cursor: 'pointer',
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ResultBox({ text, command }) {
|
|
135
|
+
const [copied, setCopied] = React.useState(false)
|
|
136
|
+
const copy = async () => {
|
|
137
|
+
try {
|
|
138
|
+
await navigator.clipboard.writeText(command)
|
|
139
|
+
setCopied(true)
|
|
140
|
+
setTimeout(() => setCopied(false), 1500)
|
|
141
|
+
} catch {
|
|
142
|
+
setCopied(false)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return h('div', { style: { marginTop: '8px' } },
|
|
146
|
+
h('pre', {
|
|
147
|
+
style: {
|
|
148
|
+
margin: 0,
|
|
149
|
+
padding: '6px 8px',
|
|
150
|
+
maxHeight: '180px',
|
|
151
|
+
overflow: 'auto',
|
|
152
|
+
whiteSpace: 'pre-wrap',
|
|
153
|
+
wordBreak: 'break-word',
|
|
154
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
155
|
+
fontSize: '11px',
|
|
156
|
+
lineHeight: '16px',
|
|
157
|
+
background: 'rgba(255,255,255,0.04)',
|
|
158
|
+
borderRadius: '6px',
|
|
159
|
+
},
|
|
160
|
+
}, text),
|
|
161
|
+
h('div', { style: { display: 'flex', gap: '6px', marginTop: '6px', alignItems: 'center' } },
|
|
162
|
+
h('button', { type: 'button', onClick: copy, style: tabStyle }, copied ? '已复制' : '复制命令'),
|
|
163
|
+
h('code', { style: { fontSize: '11px', opacity: 0.75 } }, command),
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
62
168
|
function StatusChip() {
|
|
63
169
|
const [state, refresh] = useStatus()
|
|
170
|
+
const [open, setOpen] = React.useState(false)
|
|
171
|
+
const [panel, setPanel] = React.useState({ action: '', text: '点一个动作看看现在该做什么', loading: false })
|
|
172
|
+
const [doctor, setDoctor] = React.useState(null)
|
|
173
|
+
const boxRef = React.useRef(null)
|
|
64
174
|
const { color, label } = describe(state)
|
|
175
|
+
|
|
176
|
+
// Health is the number that decides "should I act today?", so it is
|
|
177
|
+
// fetched when the panel opens rather than on every render.
|
|
178
|
+
React.useEffect(() => {
|
|
179
|
+
if (!open) return undefined
|
|
180
|
+
const controller = new AbortController()
|
|
181
|
+
getJson(DOCTOR_URL, controller.signal)
|
|
182
|
+
.then((body) => setDoctor(body))
|
|
183
|
+
.catch(() => setDoctor(null))
|
|
184
|
+
const onKey = (event) => {
|
|
185
|
+
if (event.key === 'Escape') setOpen(false)
|
|
186
|
+
}
|
|
187
|
+
const onClick = (event) => {
|
|
188
|
+
if (boxRef.current !== null && !boxRef.current.contains(event.target)) setOpen(false)
|
|
189
|
+
}
|
|
190
|
+
window.addEventListener('keydown', onKey)
|
|
191
|
+
window.addEventListener('mousedown', onClick)
|
|
192
|
+
return () => {
|
|
193
|
+
controller.abort()
|
|
194
|
+
window.removeEventListener('keydown', onKey)
|
|
195
|
+
window.removeEventListener('mousedown', onClick)
|
|
196
|
+
}
|
|
197
|
+
}, [open])
|
|
198
|
+
|
|
199
|
+
const run = async (action) => {
|
|
200
|
+
setPanel({ action: action.id, text: '读取中…', loading: true })
|
|
201
|
+
const controller = new AbortController()
|
|
202
|
+
try {
|
|
203
|
+
const body = await getJson(action.url, controller.signal)
|
|
204
|
+
setPanel({ action: action.id, text: describeResult(action.id, body), loading: false })
|
|
205
|
+
} catch (error) {
|
|
206
|
+
setPanel({ action: action.id, text: `读取失败:${String(error?.message ?? error)}`, loading: false })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const health = doctor === null
|
|
211
|
+
? null
|
|
212
|
+
: doctor.fail > 0
|
|
213
|
+
? `体检 ${doctor.fail} 阻塞`
|
|
214
|
+
: doctor.warn > 0 ? `体检 ${doctor.warn} 警告` : '体检通过'
|
|
215
|
+
const active = ACTIONS.find((action) => action.id === panel.action)
|
|
216
|
+
|
|
65
217
|
const title = state.phase === 'ready'
|
|
66
|
-
? `${state.body.repoPath ?? ''}\n${label}\n
|
|
218
|
+
? `${state.body.repoPath ?? ''}\n${label}\n点击查看可做的动作`
|
|
67
219
|
: state.phase === 'error'
|
|
68
220
|
? '状态接口在本组合不可用;用 /syn-dsh status 查看'
|
|
69
221
|
: label
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
222
|
+
|
|
223
|
+
return h('div', { ref: boxRef, style: { position: 'relative', display: 'inline-block' } },
|
|
224
|
+
h('button', {
|
|
225
|
+
type: 'button',
|
|
226
|
+
onClick: () => {
|
|
227
|
+
setOpen((value) => !value)
|
|
228
|
+
refresh()
|
|
229
|
+
},
|
|
230
|
+
title,
|
|
231
|
+
style: {
|
|
232
|
+
display: 'inline-flex',
|
|
233
|
+
alignItems: 'center',
|
|
234
|
+
gap: '6px',
|
|
235
|
+
padding: '3px 9px',
|
|
236
|
+
font: 'inherit',
|
|
237
|
+
fontSize: '12px',
|
|
238
|
+
lineHeight: '16px',
|
|
239
|
+
color: 'var(--dsw-alias-label-secondary, #b9b9b9)',
|
|
240
|
+
background: 'transparent',
|
|
241
|
+
border: '0.5px solid var(--dsw-alias-border-l2, #494949)',
|
|
242
|
+
borderRadius: '999px',
|
|
243
|
+
cursor: 'pointer',
|
|
244
|
+
},
|
|
87
245
|
},
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
246
|
+
h('span', {
|
|
247
|
+
'aria-hidden': true,
|
|
248
|
+
style: { width: '7px', height: '7px', borderRadius: '50%', background: color, display: 'inline-block' },
|
|
249
|
+
}),
|
|
250
|
+
h('span', null, label),
|
|
251
|
+
health === null ? null : h('span', { style: { opacity: 0.7 } }, `· ${health}`),
|
|
252
|
+
),
|
|
253
|
+
open ? h('div', { style: panelStyle },
|
|
254
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px' } },
|
|
255
|
+
h('strong', { style: { fontSize: '12px' } }, 'DSH 配置同步'),
|
|
256
|
+
h('code', { style: { fontSize: '10px', opacity: 0.6 } }, state.phase === 'ready' ? (state.body.branch || 'HEAD') : '—'),
|
|
257
|
+
),
|
|
258
|
+
state.phase === 'ready'
|
|
259
|
+
? h('div', { style: { opacity: 0.75, marginTop: '2px', wordBreak: 'break-all' } }, state.body.repoPath ?? '')
|
|
260
|
+
: null,
|
|
261
|
+
h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '8px' } },
|
|
262
|
+
...ACTIONS.map((action) => h('button', {
|
|
263
|
+
key: action.id,
|
|
264
|
+
type: 'button',
|
|
265
|
+
onClick: () => run(action),
|
|
266
|
+
style: {
|
|
267
|
+
...tabStyle,
|
|
268
|
+
borderColor: panel.action === action.id ? 'var(--dsw-alias-brand-primary, #4f8cff)' : tabStyle.borderColor,
|
|
269
|
+
},
|
|
270
|
+
}, action.label)),
|
|
271
|
+
),
|
|
272
|
+
h(ResultBox, { text: panel.text, command: active === undefined ? '/syn-dsh status' : active.command }),
|
|
273
|
+
h('div', { style: { marginTop: '6px', opacity: 0.6, fontSize: '11px' } },
|
|
274
|
+
'面板只读;真正的写操作要用上面的命令加 now。'),
|
|
275
|
+
) : null,
|
|
94
276
|
)
|
|
95
277
|
}
|
|
96
278
|
|
package/docs/RELEASE.md
CHANGED
|
@@ -93,3 +93,16 @@ icon: https://raw.githubusercontent.com/Dreamaniax/dsh-syn-config/main/assets/ic
|
|
|
93
93
|
- **历史扫描不再因体积失败**:单次 diff 超过缓冲时,自动收窄到 `profiles/ scripts/ AGENTS.md weekly/` 重试。
|
|
94
94
|
- **定时任务标签**:不再硬编码个人反域名——读 `DSH_WEEKLY_LAUNCHD_LABEL` / `learning/schedule.env`,都没有就按 `*.dsh.weekly-retro` 后缀匹配。
|
|
95
95
|
- 复检结果:`publish-check` 0 命中;`dsh_sync_doctor` 0 阻塞、2 警告(都是本机与仓库的正常漂移)。
|
|
96
|
+
|
|
97
|
+
## 8. 只读路由与面板(0.8.0)
|
|
98
|
+
|
|
99
|
+
浏览器这一半只读,所有写操作仍走 `/syn-dsh … now`:
|
|
100
|
+
|
|
101
|
+
| 路由 | 回答 | 缓存 |
|
|
102
|
+
| --- | --- | --- |
|
|
103
|
+
| `GET /plugins/dsh-syn-config/status` | 分支 / 上游 / ahead / behind / dirty | 不缓存 |
|
|
104
|
+
| `GET /plugins/dsh-syn-config/doctor` | 六节体检的计数与最差一条 | 60 s |
|
|
105
|
+
| `GET /plugins/dsh-syn-config/secrets` | 明文命中数与掩码样本 | 60 s |
|
|
106
|
+
| `GET /plugins/dsh-syn-config/plan?action=pull|push` | dry-run 的变更计数(不是 diff 正文) | 60 s |
|
|
107
|
+
|
|
108
|
+
服务端只放行 `GET`/`HEAD`,所以任何跨站请求都写不了东西;每个载荷都不含文件名与密钥值。面板的四个按钮(体检 / 拉取计划 / 推送计划 / 密钥扫描)就是这四条路由,结果旁有「复制命令」——真正的写操作仍是 `/syn-dsh pull now` / `push now`。
|
package/lib/core/commands.js
CHANGED
|
@@ -20,24 +20,82 @@ import { pushRepo } from './push.js'
|
|
|
20
20
|
import { runSecretsAction } from './secrets-ops.js'
|
|
21
21
|
import { readRepoStatus } from './status.js'
|
|
22
22
|
|
|
23
|
-
/**
|
|
23
|
+
/** Status icons: the command output is Markdown, so the scan is visual. */
|
|
24
|
+
const ICON = { ok: '✅', warn: '⚠️', error: '❌', skip: '➖' }
|
|
25
|
+
|
|
26
|
+
/** Escape a cell so a path or a message cannot break the table. */
|
|
27
|
+
function cell(value) {
|
|
28
|
+
return String(value ?? '').replace(/\|/gu, '\\|').replace(/\r?\n/gu, ' ').trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Status as a two-column table.
|
|
33
|
+
*
|
|
34
|
+
* A slash command's output is Markdown, and a table reads faster than a line
|
|
35
|
+
* with five separators in it — the card and the command then agree at a glance.
|
|
36
|
+
*/
|
|
24
37
|
function formatStatus(status) {
|
|
25
|
-
if (!status.repoPresent) return
|
|
26
|
-
if (!status.ok)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
if (!status.repoPresent) return `**${cell(status.repoPath)}**: no repository found`
|
|
39
|
+
if (!status.ok) {
|
|
40
|
+
return `**${cell(status.repoPath)}**\n\n| 项 | 值 |\n| --- | --- |\n| 状态 | ${ICON.error} 无法读取 |\n| 原因 | ${cell(status.notes[0] ?? 'unknown')} |`
|
|
41
|
+
}
|
|
42
|
+
const tracking = status.upstream === '' ? 'no upstream' : cell(status.upstream)
|
|
43
|
+
return [
|
|
44
|
+
'| 项 | 值 |',
|
|
45
|
+
'| --- | --- |',
|
|
46
|
+
`| 仓库 | \`${cell(status.repoPath)}\` |`,
|
|
47
|
+
`| 分支 | ${cell(status.branch) || '(detached)'} |`,
|
|
48
|
+
`| 上游 | ${tracking} |`,
|
|
49
|
+
`| 领先 / 落后 | ${status.ahead} / ${status.behind} |`,
|
|
50
|
+
`| 工作树 | ${status.dirty === 0 ? `${ICON.ok} clean worktree` : `${ICON.warn} ${status.dirty} changed file(s)`} |`,
|
|
51
|
+
].join('\n')
|
|
30
52
|
}
|
|
31
53
|
|
|
32
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Doctor as a table: one row per section, the worst finding in the last column.
|
|
56
|
+
*
|
|
57
|
+
* The blocking line stays above the table because it is the number that decides
|
|
58
|
+
* whether anything else is worth reading.
|
|
59
|
+
*/
|
|
33
60
|
function formatDoctor(result) {
|
|
34
|
-
const
|
|
35
|
-
|
|
61
|
+
const head = result.fail === 0
|
|
62
|
+
? `${ICON.ok} health: 0 blocking / ${result.warn} warnings · exit ${result.exitCode}`
|
|
63
|
+
: `${ICON.error} health: ${result.fail} blocking / ${result.warn} warnings · exit ${result.exitCode}`
|
|
64
|
+
const rows = result.sections.map((section, index) => {
|
|
36
65
|
const finding = section.items.find((entry) => entry.level === 'error')
|
|
37
66
|
?? section.items.find((entry) => entry.level === 'warn')
|
|
38
|
-
|
|
67
|
+
const icon = ICON[section.status] ?? section.status
|
|
68
|
+
return `| ${index + 1} | ${cell(section.title)} | ${icon} ${cell(section.status)} | ${cell(finding?.text ?? '')} |`
|
|
69
|
+
})
|
|
70
|
+
return [
|
|
71
|
+
head,
|
|
72
|
+
'',
|
|
73
|
+
'| # | 检查 | 状态 | 最差一处 |',
|
|
74
|
+
'| --- | --- | --- | --- |',
|
|
75
|
+
...rows,
|
|
76
|
+
].join('\n')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A dry-run plan as a table, so "what would change" is scannable. */
|
|
80
|
+
function formatPlan(action, result) {
|
|
81
|
+
if (action === 'pull') {
|
|
82
|
+
const mirrors = result.mirrors.map((mirror) => `${cell(mirror.name)} +${mirror.planned.added.length} ~${mirror.planned.updated.length} -${mirror.planned.deleted.length}`).join('<br>')
|
|
83
|
+
const rows = [
|
|
84
|
+
['远端提交', `${result.incomingCommits}`],
|
|
85
|
+
['before → after', `${cell(result.before).slice(0, 7)} → ${cell(result.after).slice(0, 7)}`],
|
|
86
|
+
['镜像变更', mirrors || '(none)'],
|
|
87
|
+
]
|
|
88
|
+
if (result.warnings.length > 0) rows.push(['注意', result.warnings.map(cell).join('<br>')])
|
|
89
|
+
return ['| 项 | 值 |', '| --- | --- |', ...rows.map(([key, value]) => `| ${key} | ${value} |`)].join('\n')
|
|
39
90
|
}
|
|
40
|
-
|
|
91
|
+
const rows = [
|
|
92
|
+
['变更', `+${result.changes.added.length} ~${result.changes.updated.length} -${result.changes.deleted.length}`],
|
|
93
|
+
['技能数', `${result.manifest.skills}`],
|
|
94
|
+
['门禁', `lint ${result.gates.lint.ok ? ICON.ok : ICON.error} · redact ${result.gates.redact.changed.length} · scan ${result.gates.scan.ok ? ICON.ok : ICON.error}`],
|
|
95
|
+
['提交', result.commit.created ? `created ${cell(result.commit.sha)}` : result.commit.unchanged ? '无变更可提交' : 'dry run:未写入'],
|
|
96
|
+
]
|
|
97
|
+
if (result.warnings.length > 0) rows.push(['注意', result.warnings.slice(0, 5).map(cell).join('<br>')])
|
|
98
|
+
return ['| 项 | 值 |', '| --- | --- |', ...rows.map(([key, value]) => `| ${key} | ${value} |`)].join('\n')
|
|
41
99
|
}
|
|
42
100
|
|
|
43
101
|
/**
|
|
@@ -86,14 +144,11 @@ export function registerCommands(ctx, settings) {
|
|
|
86
144
|
dryRun: !real,
|
|
87
145
|
timeoutMs: settings.gitTimeoutMs,
|
|
88
146
|
})
|
|
89
|
-
const
|
|
90
|
-
.map((mirror) => `${mirror.name} +${mirror.planned.added.length} ~${mirror.planned.updated.length} -${mirror.planned.deleted.length}`)
|
|
91
|
-
.join(' · ')
|
|
147
|
+
const table = formatPlan('pull', result)
|
|
92
148
|
const head = real
|
|
93
|
-
?
|
|
94
|
-
:
|
|
95
|
-
|
|
96
|
-
return { kind: result.ok ? 'success' : 'error', text: `${head}\n${mirrors}${warnings}` }
|
|
149
|
+
? `**${result.pulled ? 'pulled' : 'already up to date'}** ${result.before.slice(0, 7)} → ${result.after.slice(0, 7)}`
|
|
150
|
+
: `**dry run** — ${result.incomingCommits} incoming commit(s)`
|
|
151
|
+
return { kind: result.ok ? 'success' : 'error', text: `${head}\n\n${table}${real ? '' : '\n\n要执行就加 `now`:`/syn-dsh pull now`'}` }
|
|
97
152
|
}
|
|
98
153
|
|
|
99
154
|
if (subcommand === 'push') {
|
|
@@ -105,20 +160,28 @@ export function registerCommands(ctx, settings) {
|
|
|
105
160
|
dryRun: !real,
|
|
106
161
|
timeoutMs: settings.gitTimeoutMs,
|
|
107
162
|
})
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
?
|
|
111
|
-
:
|
|
112
|
-
const warnings = result.warnings.length === 0 ? '' : `\n${result.warnings.slice(0, 5).join('\n')}`
|
|
163
|
+
const table = formatPlan('push', result)
|
|
164
|
+
const head = real
|
|
165
|
+
? `**${result.commit.created ? `committed ${result.commit.sha}` : 'nothing to commit'}**${result.commit.pushed ? ' · pushed' : ''}`
|
|
166
|
+
: '**dry run** — nothing written'
|
|
113
167
|
return {
|
|
114
168
|
kind: result.ok ? 'success' : 'error',
|
|
115
|
-
text:
|
|
169
|
+
text: `${head}\n\n${table}${real ? '' : '\n\n要执行就加 `now`:`/syn-dsh push now`'}`,
|
|
116
170
|
}
|
|
117
171
|
}
|
|
118
172
|
|
|
119
173
|
if (subcommand === 'secrets') {
|
|
120
174
|
const action = (tokens[1] ?? 'scan').toLowerCase()
|
|
121
|
-
|
|
175
|
+
// The slash command cannot inject: writing a real value into the live
|
|
176
|
+
// configuration stays a human action (`apply.sh`), so a command that
|
|
177
|
+
// is easy to type cannot be the thing that leaks a key.
|
|
178
|
+
if (action !== 'scan' && action !== 'fingerprint') {
|
|
179
|
+
return {
|
|
180
|
+
kind: 'error',
|
|
181
|
+
text: `secrets ${action}: 只读命令只支持 scan / fingerprint;注入真值请自己执行 \`bash ~/dsh-config/scripts/apply.sh --yes\``,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const readOnly = true
|
|
122
185
|
const result = await runSecretsAction({
|
|
123
186
|
action,
|
|
124
187
|
repoPath,
|
|
@@ -143,4 +206,4 @@ export function registerCommands(ctx, settings) {
|
|
|
143
206
|
}
|
|
144
207
|
|
|
145
208
|
/** Re-exported for callers that only need the formatter. */
|
|
146
|
-
export { formatDoctor, formatStatus }
|
|
209
|
+
export { formatDoctor, formatPlan, formatStatus }
|
package/lib/core/http.js
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The read-only HTTP surface the status chip (and its panel) reads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Three GET routes, one shape of answer each. They exist so a browser card can
|
|
5
|
+
* show state without calling a model: `ctx.webServer.register({ resource })`
|
|
6
|
+
* maps a Fetch `Request` to a `Response`, and the server only lets `GET`/`HEAD`
|
|
7
|
+
* through — which is the safety property this whole surface relies on. No
|
|
8
|
+
* route here can write, so no cross-site request can either.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - `/status` branch, upstream, ahead/behind, dirty count
|
|
11
|
+
* - `/doctor` the six-section health check, cached for a minute: it spawns git
|
|
12
|
+
* and reads the filesystem, and a card does not need second-level freshness
|
|
13
|
+
* - `/plan?action=pull|push` the dry-run plan of a real operation: the same
|
|
14
|
+
* computation `pull now` / `push now` would do, with nothing written
|
|
15
|
+
*
|
|
16
|
+
* Every payload is the read-only projection of the tool result minus every
|
|
17
|
+
* secret and minus file names (a config file name can itself be sensitive).
|
|
13
18
|
*
|
|
14
19
|
* @module dsh-syn-config/core/http
|
|
15
20
|
*/
|
|
16
21
|
|
|
22
|
+
import { runDoctor } from './doctor.js'
|
|
23
|
+
import { pullRepo } from './pull.js'
|
|
24
|
+
import { pushRepo } from './push.js'
|
|
25
|
+
import { runSecretsAction } from './secrets-ops.js'
|
|
17
26
|
import { readRepoStatus } from './status.js'
|
|
18
27
|
|
|
19
28
|
export const STATUS_PATH = '/plugins/dsh-syn-config/status'
|
|
29
|
+
export const DOCTOR_PATH = '/plugins/dsh-syn-config/doctor'
|
|
30
|
+
export const PLAN_PATH = '/plugins/dsh-syn-config/plan'
|
|
31
|
+
export const SECRETS_PATH = '/plugins/dsh-syn-config/secrets'
|
|
32
|
+
|
|
33
|
+
/** How long a doctor or plan answer may be reused. */
|
|
34
|
+
const CACHE_MS = 60_000
|
|
20
35
|
|
|
21
36
|
/** JSON response with no-store caching. */
|
|
22
37
|
function json(body, status = 200) {
|
|
@@ -26,45 +41,237 @@ function json(body, status = 200) {
|
|
|
26
41
|
})
|
|
27
42
|
}
|
|
28
43
|
|
|
44
|
+
/** Method gate shared by every route; the server also rejects writes upstream. */
|
|
45
|
+
function methodAllowed(request) {
|
|
46
|
+
return request.method === 'GET' || request.method === 'HEAD'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the cached reader.
|
|
51
|
+
*
|
|
52
|
+
* A card that polls every few seconds must not spawn `git` every time: the
|
|
53
|
+
* doctor alone runs a handful of commands. The cache is per-route and holds
|
|
54
|
+
* the *projection*, so nothing secret lingers in memory longer than the answer.
|
|
55
|
+
*
|
|
56
|
+
* @param {() => Promise<object>} read
|
|
57
|
+
* @param {number} ttlMs
|
|
58
|
+
*/
|
|
59
|
+
export function cached(read, ttlMs = CACHE_MS) {
|
|
60
|
+
let at = 0
|
|
61
|
+
let value
|
|
62
|
+
let inflight
|
|
63
|
+
return async () => {
|
|
64
|
+
const now = Date.now()
|
|
65
|
+
if (value !== undefined && now - at < ttlMs) return { body: value, cached: true }
|
|
66
|
+
if (inflight !== undefined) return { body: await inflight, cached: true }
|
|
67
|
+
inflight = read().then((body) => {
|
|
68
|
+
value = body
|
|
69
|
+
at = Date.now()
|
|
70
|
+
inflight = undefined
|
|
71
|
+
return body
|
|
72
|
+
}, (error) => {
|
|
73
|
+
inflight = undefined
|
|
74
|
+
throw error
|
|
75
|
+
})
|
|
76
|
+
return { body: await inflight, cached: false }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Project a doctor run down to what a card may see. */
|
|
81
|
+
export function doctorPayload(result) {
|
|
82
|
+
return {
|
|
83
|
+
ok: result.fail === 0,
|
|
84
|
+
fail: result.fail,
|
|
85
|
+
warn: result.warn,
|
|
86
|
+
exitCode: result.exitCode,
|
|
87
|
+
profile: result.profile,
|
|
88
|
+
checkedAt: new Date().toISOString(),
|
|
89
|
+
sections: result.sections.map((section) => {
|
|
90
|
+
const blocking = section.items.filter((entry) => entry.level === 'error')
|
|
91
|
+
const warnings = section.items.filter((entry) => entry.level === 'warn')
|
|
92
|
+
const worst = blocking[0] ?? warnings[0]
|
|
93
|
+
return {
|
|
94
|
+
title: section.title,
|
|
95
|
+
status: section.status,
|
|
96
|
+
blocking: blocking.length,
|
|
97
|
+
warnings: warnings.length,
|
|
98
|
+
// The message text is already sanitised by the doctor: paths are
|
|
99
|
+
// shortened and secret values are masked.
|
|
100
|
+
summary: worst === undefined ? '' : String(worst.text).trim().slice(0, 160),
|
|
101
|
+
}
|
|
102
|
+
}),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Project a dry-run push/pull down to a plan a card may see. */
|
|
107
|
+
export function planPayload(action, result) {
|
|
108
|
+
if (action === 'pull') {
|
|
109
|
+
return {
|
|
110
|
+
action,
|
|
111
|
+
ok: result.ok,
|
|
112
|
+
incomingCommits: result.incomingCommits,
|
|
113
|
+
before: String(result.before ?? '').slice(0, 12),
|
|
114
|
+
after: String(result.after ?? '').slice(0, 12),
|
|
115
|
+
mirrors: result.mirrors.map((mirror) => ({
|
|
116
|
+
name: mirror.name,
|
|
117
|
+
added: mirror.planned.added.length,
|
|
118
|
+
updated: mirror.planned.updated.length,
|
|
119
|
+
deleted: mirror.planned.deleted.length,
|
|
120
|
+
})),
|
|
121
|
+
warnings: result.warnings.slice(0, 5).map((line) => String(line).slice(0, 160)),
|
|
122
|
+
checkedAt: new Date().toISOString(),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
action,
|
|
127
|
+
ok: result.ok,
|
|
128
|
+
changes: {
|
|
129
|
+
added: result.changes.added.length,
|
|
130
|
+
updated: result.changes.updated.length,
|
|
131
|
+
deleted: result.changes.deleted.length,
|
|
132
|
+
},
|
|
133
|
+
gates: {
|
|
134
|
+
lint: result.gates.lint.ok,
|
|
135
|
+
redacted: result.gates.redact.changed.length,
|
|
136
|
+
scan: result.gates.scan.ok,
|
|
137
|
+
},
|
|
138
|
+
commit: { created: result.commit.created, unchanged: result.commit.unchanged === true },
|
|
139
|
+
skills: result.manifest.skills,
|
|
140
|
+
warnings: result.warnings.slice(0, 5).map((line) => String(line).slice(0, 160)),
|
|
141
|
+
checkedAt: new Date().toISOString(),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
29
145
|
/**
|
|
30
|
-
* Register
|
|
146
|
+
* Register every read-only route when the composition has a web server.
|
|
31
147
|
*
|
|
32
148
|
* @param {{ get?: (name: string) => unknown, webServer?: object }} ctx
|
|
33
|
-
* @param {{ repoPath: string, gitTimeoutMs: number }} settings
|
|
149
|
+
* @param {{ repoPath: string, profile: string, dshHome?: string, gitTimeoutMs: number }} settings
|
|
150
|
+
* @returns {boolean} whether the routes were registered
|
|
34
151
|
*/
|
|
35
152
|
export function registerStatusRoute(ctx, settings) {
|
|
36
153
|
const server = ctx.webServer ?? (typeof ctx.get === 'function' ? ctx.get('webServer') : undefined)
|
|
37
154
|
if (server === undefined || server === null || typeof server.register !== 'function') return false
|
|
38
155
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
156
|
+
const readStatus = async () => {
|
|
157
|
+
const status = await readRepoStatus({
|
|
158
|
+
repoPath: settings.repoPath,
|
|
159
|
+
timeoutMs: settings.gitTimeoutMs,
|
|
160
|
+
fetch: false,
|
|
161
|
+
})
|
|
162
|
+
return {
|
|
163
|
+
ok: status.ok,
|
|
164
|
+
repoPath: status.repoPath,
|
|
165
|
+
repoPresent: status.repoPresent,
|
|
166
|
+
branch: status.branch,
|
|
167
|
+
upstream: status.upstream,
|
|
168
|
+
ahead: status.ahead,
|
|
169
|
+
behind: status.behind,
|
|
170
|
+
dirty: status.dirty,
|
|
171
|
+
checkedAt: new Date().toISOString(),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const readDoctor = cached(async () => doctorPayload(await runDoctor({
|
|
176
|
+
repoPath: settings.repoPath,
|
|
177
|
+
dshHome: settings.dshHome,
|
|
178
|
+
profile: settings.profile,
|
|
179
|
+
offline: true,
|
|
180
|
+
timeoutMs: settings.gitTimeoutMs,
|
|
181
|
+
})))
|
|
182
|
+
|
|
183
|
+
const readPlan = (action) => {
|
|
184
|
+
if (action !== 'pull' && action !== 'push') return undefined
|
|
185
|
+
return cached(async () => {
|
|
186
|
+
const result = action === 'pull'
|
|
187
|
+
? await pullRepo({
|
|
47
188
|
repoPath: settings.repoPath,
|
|
189
|
+
dshHome: settings.dshHome,
|
|
190
|
+
dryRun: true,
|
|
48
191
|
timeoutMs: settings.gitTimeoutMs,
|
|
49
|
-
fetch: false,
|
|
50
192
|
})
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
ahead: status.ahead,
|
|
58
|
-
behind: status.behind,
|
|
59
|
-
dirty: status.dirty,
|
|
60
|
-
// File names stay out of the browser payload: any local process can
|
|
61
|
-
// read this route, and a config file name can itself be sensitive.
|
|
62
|
-
checkedAt: new Date().toISOString(),
|
|
193
|
+
: await pushRepo({
|
|
194
|
+
repoPath: settings.repoPath,
|
|
195
|
+
dshHome: settings.dshHome,
|
|
196
|
+
profile: settings.profile,
|
|
197
|
+
dryRun: true,
|
|
198
|
+
timeoutMs: settings.gitTimeoutMs,
|
|
63
199
|
})
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
200
|
+
return planPayload(action, result)
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const plans = { pull: readPlan('pull'), push: readPlan('push') }
|
|
205
|
+
|
|
206
|
+
// Read-only: a scan never mutates, and it returns counts plus masked previews.
|
|
207
|
+
const readSecrets = cached(async () => {
|
|
208
|
+
const result = await runSecretsAction({
|
|
209
|
+
action: 'scan',
|
|
210
|
+
repoPath: settings.repoPath,
|
|
211
|
+
dshHome: settings.dshHome,
|
|
212
|
+
profile: settings.profile,
|
|
213
|
+
dryRun: true,
|
|
214
|
+
})
|
|
215
|
+
return {
|
|
216
|
+
ok: result.ok,
|
|
217
|
+
hits: result.hits.length,
|
|
218
|
+
samples: result.hits.slice(0, 5).map((hit) => ({
|
|
219
|
+
file: String(hit.file).split('/').slice(-2).join('/'),
|
|
220
|
+
line: hit.line,
|
|
221
|
+
masked: String(hit.match).slice(0, 4) + '…',
|
|
222
|
+
})),
|
|
223
|
+
note: result.note ?? '',
|
|
224
|
+
checkedAt: new Date().toISOString(),
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const route = (path, resource) => {
|
|
229
|
+
server.register({ path, resource })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
route(STATUS_PATH, async (request) => {
|
|
233
|
+
if (!methodAllowed(request)) return new Response('method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } })
|
|
234
|
+
try {
|
|
235
|
+
return json(await readStatus())
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return json({ ok: false, error: String(error?.message ?? error) }, 500)
|
|
238
|
+
}
|
|
68
239
|
})
|
|
240
|
+
|
|
241
|
+
route(DOCTOR_PATH, async (request) => {
|
|
242
|
+
if (!methodAllowed(request)) return new Response('method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } })
|
|
243
|
+
try {
|
|
244
|
+
const answer = await readDoctor()
|
|
245
|
+
return json({ ...answer.body, cached: answer.cached })
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return json({ ok: false, error: String(error?.message ?? error) }, 500)
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
route(SECRETS_PATH, async (request) => {
|
|
252
|
+
if (!methodAllowed(request)) return new Response('method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } })
|
|
253
|
+
try {
|
|
254
|
+
const answer = await readSecrets()
|
|
255
|
+
return json({ ...answer.body, cached: answer.cached })
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return json({ ok: false, error: String(error?.message ?? error) }, 500)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
route(PLAN_PATH, async (request) => {
|
|
262
|
+
if (!methodAllowed(request)) return new Response('method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } })
|
|
263
|
+
const action = new URL(request.url).searchParams.get('action') ?? ''
|
|
264
|
+
const reader = plans[action]
|
|
265
|
+
if (reader === undefined) {
|
|
266
|
+
return json({ ok: false, error: 'action must be pull or push' }, 400)
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const answer = await reader()
|
|
270
|
+
return json({ ...answer.body, cached: answer.cached })
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return json({ ok: false, error: String(error?.message ?? error) }, 500)
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
69
276
|
return true
|
|
70
277
|
}
|
package/lib/index.js
CHANGED
|
@@ -172,7 +172,11 @@ export function resolveSettings(config = {}) {
|
|
|
172
172
|
const dshHome = typeof config.dshHome === 'string' && config.dshHome.trim() !== ''
|
|
173
173
|
? config.dshHome.trim()
|
|
174
174
|
: undefined
|
|
175
|
-
|
|
175
|
+
// Injecting a real secret into the live configuration is a human action.
|
|
176
|
+
// The model-callable tool refuses it unless this machine explicitly opts in,
|
|
177
|
+
// so an agent can never turn "sync my config" into "write my API key".
|
|
178
|
+
const allowSecretInject = config.allowSecretInject === true
|
|
179
|
+
return { repoPath, gitTimeoutMs, fetchOnStatus: config.fetchOnStatus === true, profile, dshHome, allowSecretInject }
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
/** Result shape of the health-check tool. */
|
|
@@ -519,7 +523,7 @@ function apply(ctx, config = {}) {
|
|
|
519
523
|
if (skills === undefined || skills === null) throw new Error('skills service not provided')
|
|
520
524
|
registerSkill({ skills })
|
|
521
525
|
})
|
|
522
|
-
tryRegister('
|
|
526
|
+
tryRegister('http routes', ctx, () => {
|
|
523
527
|
if (webServer === undefined || webServer === null) throw new Error('webServer service not provided')
|
|
524
528
|
if (registerStatusRoute({ webServer }, settings) !== true) throw new Error('webServer.register is missing')
|
|
525
529
|
})
|
|
@@ -741,13 +745,35 @@ function apply(ctx, config = {}) {
|
|
|
741
745
|
async execute(args, exec) {
|
|
742
746
|
const requested = typeof args?.repo === 'string' && args.repo.trim() !== '' ? args.repo.trim() : settings.repoPath
|
|
743
747
|
const profile = typeof args?.profile === 'string' && args.profile.trim() !== '' ? args.profile.trim() : settings.profile
|
|
748
|
+
const action = String(args?.action ?? '')
|
|
749
|
+
const dryRun = typeof args?.dry_run === 'boolean' ? args.dry_run : true
|
|
750
|
+
// Human-only gate: `inject` and `refresh` write the real value into the
|
|
751
|
+
// deployed configuration. Refuse them from the tool unless the machine
|
|
752
|
+
// opted in, and say where the human path is.
|
|
753
|
+
if ((action === 'inject' || action === 'refresh') && dryRun === false && settings.allowSecretInject !== true) {
|
|
754
|
+
return {
|
|
755
|
+
action,
|
|
756
|
+
ok: false,
|
|
757
|
+
dryRun: false,
|
|
758
|
+
changed: [],
|
|
759
|
+
leftover: [],
|
|
760
|
+
hits: [],
|
|
761
|
+
fingerprints: [],
|
|
762
|
+
historyHits: [],
|
|
763
|
+
notes: [
|
|
764
|
+
'refused: secrets are injected by the human, not by the agent',
|
|
765
|
+
'run it yourself: `bash ~/dsh-config/scripts/apply.sh --yes`',
|
|
766
|
+
'or set `allowSecretInject: true` in this plugin row if you want the tool to do it',
|
|
767
|
+
],
|
|
768
|
+
}
|
|
769
|
+
}
|
|
744
770
|
try {
|
|
745
771
|
const result = await runSecretsAction({
|
|
746
|
-
action
|
|
772
|
+
action,
|
|
747
773
|
repoPath: requested,
|
|
748
774
|
dshHome: settings.dshHome,
|
|
749
775
|
profile,
|
|
750
|
-
dryRun
|
|
776
|
+
dryRun,
|
|
751
777
|
deep: args?.deep === true,
|
|
752
778
|
})
|
|
753
779
|
return { ...result, historyHits: result.historyHits ?? [] }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-syn-config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Cross-platform (macOS + Windows) DeepSeek Harness plugin that syncs DSH config — skills, profile settings, AGENTS.md — with a private dsh-config repository.",
|
|
6
6
|
"type": "module",
|