dsh-syn-config 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  跨电脑同步 DSH 配置(技能 / profile 设置 / AGENTS.md)的 DeepSeek Harness 插件,**macOS 与 Windows 同一份代码**。
4
4
 
5
- > 状态:**P6(隐私加固)**。六个工具 + 一个斜杠命令 + 一个状态卡 + 随包技能:
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,7 +14,8 @@
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
  >
20
21
  > 设计理由与后续阶段见主仓库的调研文档:`../research/syn-dsh-plugin/PLAN.md`。
package/client/client.js CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Browser half: one compact chip in the composer dock.
2
+ * Browser half: a status chip in the composer dock, and a small panel behind it.
3
3
  *
4
- * It reads the host route registered by `lib/core/http.js` — no websocket, no
5
- * shared state, no second data channel to keep consistent. The chip is
6
- * deliberately small: a dot whose colour encodes "clean / drift / error", the
7
- * branch, and the ahead/behind counts. Clicking it re-fetches.
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 STATUS_URL = '/plugins/dsh-syn-config/status'
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
- fetch(STATUS_URL, { signal: controller.signal, headers: { accept: 'application/json' } })
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
- return h('button', {
71
- type: 'button',
72
- onClick: refresh,
73
- title,
74
- style: {
75
- display: 'inline-flex',
76
- alignItems: 'center',
77
- gap: '6px',
78
- padding: '3px 9px',
79
- font: 'inherit',
80
- fontSize: '12px',
81
- lineHeight: '16px',
82
- color: 'var(--dsw-alias-label-secondary, #b9b9b9)',
83
- background: 'transparent',
84
- border: '0.5px solid var(--dsw-alias-border-l2, #494949)',
85
- borderRadius: '999px',
86
- cursor: 'pointer',
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
- h('span', {
90
- 'aria-hidden': true,
91
- style: { width: '7px', height: '7px', borderRadius: '50%', background: color, display: 'inline-block' },
92
- }),
93
- h('span', null, label),
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
@@ -14,12 +14,19 @@ node scripts/verify.mjs # 安装形态
14
14
 
15
15
  ## 1. 发布包(任选其一;推荐两个都做)
16
16
 
17
- **A. npm(安装更顺,免 `allowBuilds`)**
17
+ **A. npm(已发布)**
18
+
19
+ `dsh-syn-config@0.7.1` 已在 registry 上:<https://www.npmjs.com/package/dsh-syn-config>(34 个文件,234 KB unpacked)。
20
+
21
+ 首次发布踩到的两个坑,记下来免得下台机器重踩:
22
+
23
+ 1. granular token 若用「Only select packages」选一个**还不存在**的包,作用域是空集 → 403 `You may not perform that action with these credentials`;
24
+ 2. 首次发布时 registry 会先挂一个 `0.0.0-stage` 占位版本(staged publishing 机制),真实版本在几分钟后出现,`npm view <name> version` 一开始会看到占位号,不要慌。
18
25
 
19
26
  ```bash
20
- npm login # 本机当前未登录
21
- # package.json: "private": false, 补 "publishConfig": { "access": "public" }
22
- npm publish --access public # 名称 dsh-syn-config(已确认未被占用)
27
+ npm login
28
+ npm publish --access public
29
+ npm view dsh-syn-config version # 0.7.1
23
30
  ```
24
31
 
25
32
  **B. GitHub Release tarball(不发 npm 时的兜底,市场会优先展示它)**
@@ -86,3 +93,16 @@ icon: https://raw.githubusercontent.com/Dreamaniax/dsh-syn-config/main/assets/ic
86
93
  - **历史扫描不再因体积失败**:单次 diff 超过缓冲时,自动收窄到 `profiles/ scripts/ AGENTS.md weekly/` 重试。
87
94
  - **定时任务标签**:不再硬编码个人反域名——读 `DSH_WEEKLY_LAUNCHD_LABEL` / `learning/schedule.env`,都没有就按 `*.dsh.weekly-retro` 后缀匹配。
88
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`。
@@ -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
- /** One-line status summary. */
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 `${status.repoPath}: no repository found`
26
- if (!status.ok) return `${status.repoPath}: ${status.notes[0] ?? 'cannot read status'}`
27
- const tracking = status.upstream === '' ? 'no upstream' : `${status.ahead} ahead / ${status.behind} behind ${status.upstream}`
28
- const tree = status.dirty === 0 ? 'clean worktree' : `${status.dirty} changed file(s)`
29
- return `${status.branch || '(detached)'} · ${tracking} · ${tree}`
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
- /** Doctor summary: one line per section, only the worst finding. */
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 lines = [`health: ${result.fail} blocking / ${result.warn} warnings · exit ${result.exitCode}`]
35
- for (const section of result.sections) {
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
- lines.push(`${section.status.toUpperCase().padEnd(5)} ${section.title}${finding === undefined ? '' : ` — ${finding.text.trim()}`}`)
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
- return lines.join('\n')
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 mirrors = result.mirrors
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
- ? `${result.pulled ? 'pulled' : 'already up to date'} ${result.before.slice(0, 7)} → ${result.after.slice(0, 7)}`
94
- : `dry run: ${result.incomingCommits} incoming commit(s)`
95
- const warnings = result.warnings.length === 0 ? '' : `\n${result.warnings.join('\n')}`
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,14 +160,13 @@ export function registerCommands(ctx, settings) {
105
160
  dryRun: !real,
106
161
  timeoutMs: settings.gitTimeoutMs,
107
162
  })
108
- const gates = `lint ${result.gates.lint.ok ? 'ok' : 'FAIL'} · redact ${result.gates.redact.changed.length} · scan ${result.gates.scan.ok ? 'ok' : 'FAIL'}`
109
- const summary = result.commit.created
110
- ? `commit ${result.commit.sha}${result.commit.pushed ? ' pushed' : ' NOT pushed'}`
111
- : result.commit.unchanged ? 'no changes to commit' : (real ? 'not committed' : 'dry run: nothing written')
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: `changes +${result.changes.added.length} ~${result.changes.updated.length} -${result.changes.deleted.length} · skills ${result.manifest.skills}\n${gates}\n${summary}${warnings}`,
169
+ text: `${head}\n\n${table}${real ? '' : '\n\n要执行就加 `now`:`/syn-dsh push now`'}`,
116
170
  }
117
171
  }
118
172
 
@@ -143,4 +197,4 @@ export function registerCommands(ctx, settings) {
143
197
  }
144
198
 
145
199
  /** Re-exported for callers that only need the formatter. */
146
- export { formatDoctor, formatStatus }
200
+ export { formatDoctor, formatPlan, formatStatus }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { findGit } from './env.js'
17
17
  import { runCommand } from './exec.js'
18
- import { SECRET_PATTERNS } from './files.js'
18
+ import { isPlaceholderMatch, SECRET_PATTERNS } from './files.js'
19
19
 
20
20
  /** Mask the middle of a matched value so a hit is recognisable but not usable. */
21
21
  export function maskValue(value) {
@@ -41,9 +41,10 @@ export async function scanHistory(options) {
41
41
 
42
42
  const base = ['-C', repoPath, 'log', '-p', '--no-color', '--no-merges', '-n', String(maxCommits), '--format=%x01%H']
43
43
  let result = await runCommand(gitPath, base, { timeoutMs, signal })
44
- // A repository with large binary-ish skills can overflow one buffer; retry
45
- // over the paths that actually matter for credentials before giving up.
46
- if (!result.ok && /ENOBUFS|maxBuffer|ERR_CHILD_PROCESS_STDIO_MAXBUFFER/i.test(result.stderr ?? '')) {
44
+ // A repository with large binary-ish skills overflows one buffer (the error
45
+ // text is not guaranteed: Node reports it in `code`, not `stderr`). Retry on
46
+ // any failure, restricted to the paths that can actually carry credentials.
47
+ if (!result.ok) {
47
48
  result = await runCommand(
48
49
  gitPath,
49
50
  [...base, '--', 'profiles', 'scripts', 'AGENTS.md', 'weekly'],
@@ -81,6 +82,10 @@ export async function scanHistory(options) {
81
82
  SECRET_PATTERNS[index].lastIndex = 0
82
83
  const match = SECRET_PATTERNS[index].exec(added)
83
84
  if (match === null) continue
85
+ // The same placeholder rule as the working-tree scan: a fixture that
86
+ // writes `API_KEY=dummy_test_value_0001` is not a leaked credential, and
87
+ // reporting it in history trains people to ignore the whole section.
88
+ if (isPlaceholderMatch(match[0])) continue
84
89
  hits.push({
85
90
  commit: current,
86
91
  pattern: index,
package/lib/core/http.js CHANGED
@@ -1,22 +1,37 @@
1
1
  /**
2
- * The one HTTP route the status card reads.
2
+ * The read-only HTTP surface the status chip (and its panel) reads.
3
3
  *
4
- * `ctx.webServer.register({ resource })` maps a Fetch `Request` to a
5
- * `Response`; the server only lets `GET`/`HEAD` through, so one function is
6
- * all that is needed. The payload is the same read-only status the model tool
7
- * returns minus every secret: branch, upstream, ahead/behind, dirty count and
8
- * the file names (which are configuration paths, not secrets).
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
- * The route answers only on the loopback-bound GUI server and sets
11
- * `Cache-Control: no-store`, so a stale card cannot outlive a real state
12
- * change.
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 the status route when the composition has a web server.
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
- server.register({
40
- path: STATUS_PATH,
41
- resource: async (request) => {
42
- if (request.method !== 'GET' && request.method !== 'HEAD') {
43
- return new Response('method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } })
44
- }
45
- try {
46
- const status = await readRepoStatus({
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
- return json({
52
- ok: status.ok,
53
- repoPath: status.repoPath,
54
- repoPresent: status.repoPresent,
55
- branch: status.branch,
56
- upstream: status.upstream,
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
- } catch (error) {
65
- return json({ ok: false, error: String(error?.message ?? error) }, 500)
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
@@ -519,7 +519,7 @@ function apply(ctx, config = {}) {
519
519
  if (skills === undefined || skills === null) throw new Error('skills service not provided')
520
520
  registerSkill({ skills })
521
521
  })
522
- tryRegister('status route', ctx, () => {
522
+ tryRegister('http routes', ctx, () => {
523
523
  if (webServer === undefined || webServer === null) throw new Error('webServer service not provided')
524
524
  if (registerStatusRoute({ webServer }, settings) !== true) throw new Error('webServer.register is missing')
525
525
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-syn-config",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
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",
@@ -24,7 +24,7 @@ const here = resolve(dirname(fileURLToPath(import.meta.url)), '..')
24
24
  const argv = process.argv.slice(2)
25
25
  let repo = here
26
26
  // Documented examples that are intentionally generic.
27
- const allowed = ['/Users/you/', 'Dreamaniax', 'awesome-dsh-plugin']
27
+ const allowed = ['/Users/you/', 'Dreamaniax', 'awesome-dsh-plugin', 'users.noreply.github.com']
28
28
  for (let index = 0; index < argv.length; index += 1) {
29
29
  if (argv[index] === '--repo') {
30
30
  repo = resolve(argv[index + 1] ?? '')