@pandablessing/dsh-user-sync 1.0.4 → 1.0.5

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.
Files changed (3) hide show
  1. package/README.md +21 -3
  2. package/lib/client.js +517 -505
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -26,6 +26,10 @@ pnpm add @pandablessing/dsh-user-sync
26
26
 
27
27
  macOS / Linux 用同一组命令(`cd ~/.dsh/profiles/desktop && pnpm add @pandablessing/dsh-user-sync`);profile 的 `package.json` 与 `cordis.patch.yml` 里必须写 npm 包名 `@pandablessing/dsh-user-sync`(见 [v1.0.2 修复](CHANGELOG.md))。
28
28
 
29
+ > **升级/安装前先清旧条目**:如果 profile 里还留着旧名 `dsh-user-sync` 的 junction、`dependencies` 或 bundles 条目,先删掉再装。旧名与新名会各自注册一次 client bundle,同一面板 id 冲突——1.0.4 遇到这种情况会直接把 `web boot` 打挂(`duplicate factory registration`),1.0.5 起只是多一条 warn,但画面上会出现两个「设置同步」。
30
+ >
31
+ > 建议顺序:① 备份 `~/.dsh/profiles/desktop/{package.json,cordis.patch.yml}`;② 移除旧名条目;③ `pnpm add @pandablessing/dsh-user-sync@1.0.5`;④ 在 profile 的 `dsh.profile.bundles` 里确认只出现一次 `@pandablessing/dsh-user-sync`;⑤ 重启 DSH。
32
+
29
33
  安装后在 DSH 设置页启用「设置同步」。
30
34
 
31
35
  ### 开发期(junction 到本仓库)
@@ -74,13 +78,27 @@ pnpm install # 仅开发:安装 typescript 与 @types/node
74
78
  pnpm run build # 插件:src/*.ts → lib/*.js
75
79
  pnpm run build:server # 服务端:server/src/*.ts → server/dist/*.js
76
80
 
77
- node --test test/*.test.js # 150 条:单元 / 组件 / 时序(直接跑源码,macOS 全绿)
78
- node scripts/audit-cases.mjs # 用例清单与结果记录一致性审计(145/145)
81
+ node --test test/*.test.js # 154 条:单元 / 组件 / 时序(直接跑源码,macOS 全绿)
82
+ node scripts/audit-cases.mjs # 用例清单与结果记录一致性审计(149/149)
79
83
  node scripts/e2e-clone.mjs # 替代换机演练(默认本地模式,临时起服务端,不写真实 WebDAV)
80
84
  # node scripts/e2e-clone.mjs --remote https://dsh-user-sync-test.wenzhi.info # 真实服务端(会留测试目录)
81
85
  powershell -File scripts/smoke.ps1 # 真实 DSH 路由冒烟
82
86
  ```
83
87
 
88
+ ## 发布
89
+
90
+ token 只放本地文件,绝不进仓库(`.env.local` 已在 `.gitignore`):
91
+
92
+ ```bash
93
+ cp .env.local.example .env.local && chmod 600 .env.local # 填 NPM_TOKEN=(发布 token)
94
+ node scripts/publish.mjs --dry-run # 先看打包内容(24 个文件)
95
+ node scripts/publish.mjs --otp=123456 # 账号开 2FA 时带当次验证码
96
+ ```
97
+
98
+ - 脚本把 token 写进 `0600` 的临时 npmrc,发布结束立即删除;token 不出现在命令行参数、日志与 git 历史里。
99
+ - CI 用 secret 注入 `NPM_TOKEN` 环境变量即可,优先级高于 `.env.local`。
100
+ - 发布前记得先 `pnpm run build && pnpm run build:server`,产物与源码不一致时 tarball 里会是旧代码。
101
+
84
102
  ## 仓库结构
85
103
 
86
104
  ```text
@@ -89,7 +107,7 @@ docs/test-cases.md 测试用例清单(结果单独放 docs/test-results/
89
107
  docs/deploy.md 服务端部署与验证
90
108
  lib/ 插件(宿主 + client + 传输 + 同步编排)
91
109
  server/ 运营方 API(纯 node:http)
92
- scripts/ 开发安装 / 卸载 / 冒烟 / 替代换机
110
+ scripts/ 开发安装 / 卸载 / 冒烟 / 替代换机 / 发布
93
111
  test/ 单元、组件、时序测试与 fixtures
94
112
  legacy/ 需求反馈稿与原型脚本
95
113
  ```
package/lib/client.js CHANGED
@@ -1,538 +1,550 @@
1
1
  "use strict";
2
2
  // client.ts —— 浏览器 bundle,由 DSH 的 module loader 执行;不是 ESM 模块。
3
3
  // React 由宿主运行时注入(factory 的 require);宿主服务的形状在下面用最小接口声明。
4
+ /** client 注册 id 必须等于 npm 包名(module-loader 契约,见 design §9.1)。 */
5
+ const PACKAGE_NAME = '@pandablessing/dsh-user-sync';
4
6
  // 浏览器内由 DSH 注入 __ModuleLoader__ 后注册面板;
5
7
  // 在 Node 侧(DSH 启动时预检 client 入口)import 时不抛错,避免 web boot 失败。
6
8
  if (typeof window !== 'undefined' && window.__ModuleLoader__) {
7
- window.__ModuleLoader__.load({
8
- id: 'dsh-user-sync',
9
- factory: (require) => {
10
- const module = { exports: {} };
11
- const exports = module.exports;
12
- let React = null;
13
- try {
14
- React = require('react');
15
- }
16
- catch (error) {
17
- console.error('[dsh-user-sync] 无法加载 react,面板已禁用', error);
18
- }
19
- // 最坏情况只失去面板,绝不让 client entry 失败拖垮 web boot。
20
- if (!React) {
21
- exports.apply = () => { };
22
- exports.inject = [];
23
- return module.exports;
24
- }
25
- const R = React;
26
- const API = '/api/dsh-user-sync';
27
- const MODULE_LABELS = {
28
- skills: '技能',
29
- plugins: '插件清单',
30
- config: '配置',
31
- userMemory: '用户记忆',
32
- projectMemory: '项目记忆',
33
- sessions: '会话',
34
- };
35
- const USER_LEVEL = ['skills', 'plugins', 'config', 'userMemory'];
36
- /** 每个模块同步的内容简介(按钮悬停与下方小字)。 */
37
- const MODULE_HINTS = {
38
- skills: '~/.agents/skills 下的技能包(需含 SKILL.md)',
39
- plugins: 'profile 的 package.json 与依赖锁文件',
40
- config: 'cordis.patch.yml 与 AGENTS.md',
41
- userMemory: 'AGENTS.md 与 memories/ 下的用户级记忆(MEMORY/USER/KEY)',
42
- projectMemory: 'memories/projects 与 daily 下的项目记忆',
43
- sessions: 'sessions/ 下的会话记录',
44
- };
45
- const styles = {
46
- section: { display: 'flex', flexDirection: 'column', gap: 12 },
47
- heading: { margin: 0, fontSize: 18, fontWeight: 600 },
48
- card: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: 12, display: 'flex', flexDirection: 'column', gap: 6 },
49
- row: { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 },
50
- muted: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' },
51
- moduleRow: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 },
52
- moduleWrap: { display: 'flex', flexWrap: 'wrap', gap: 8 },
53
- moduleGrid: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8 },
54
- moduleItem: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 },
55
- moduleHint: { fontSize: 11, lineHeight: 1.4, color: 'var(--dsw-alias-label-tertiary)' },
56
- linkButton: { alignSelf: 'flex-start', padding: 0, border: 0, background: 'transparent', color: 'var(--dsw-alias-state-business-primary)', fontSize: 11, cursor: 'pointer', textDecoration: 'underline' },
57
- warn: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-warning-primary, #b26b00)' },
58
- fileList: { margin: 0, padding: '4px 0 0 16px', listStyle: 'disc', maxHeight: 160, overflowY: 'auto' },
59
- fileItem: { fontSize: 11, lineHeight: 1.5, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', wordBreak: 'break-all' },
60
- moduleButton: {
61
- padding: '5px 12px',
62
- borderRadius: 999,
63
- border: '1px solid var(--dsw-alias-border-l2)',
64
- background: 'transparent',
65
- color: 'inherit',
66
- fontSize: 13,
67
- cursor: 'pointer',
68
- userSelect: 'none',
69
- WebkitUserSelect: 'none',
70
- },
71
- moduleButtonActive: {
72
- padding: '5px 12px',
73
- borderRadius: 999,
74
- border: '1px solid var(--dsw-alias-state-business-primary)',
75
- background: 'var(--dsw-alias-state-business-primary)',
76
- color: '#fff',
77
- fontSize: 13,
78
- cursor: 'pointer',
79
- userSelect: 'none',
80
- WebkitUserSelect: 'none',
81
- },
82
- actions: { display: 'flex', gap: 8, flexWrap: 'wrap' },
83
- primary: { padding: '6px 14px', borderRadius: 6, border: 0, cursor: 'pointer', background: 'var(--dsw-alias-state-business-primary)', color: '#fff', fontSize: 13 },
84
- secondary: { padding: '6px 14px', borderRadius: 6, cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'inherit', fontSize: 13 },
85
- error: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-state-error-primary, #d33)' },
86
- overlay: {
87
- position: 'fixed',
88
- inset: 0,
89
- background: 'rgba(0, 0, 0, 0.4)',
90
- display: 'flex',
91
- alignItems: 'center',
92
- justifyContent: 'center',
93
- zIndex: 1000,
94
- },
95
- dialog: {
96
- width: 360,
97
- maxWidth: 'calc(100vw - 48px)',
98
- background: 'var(--dsw-alias-bg-elevated, #fff)',
99
- color: 'inherit',
100
- borderRadius: 10,
101
- padding: 16,
102
- display: 'flex',
103
- flexDirection: 'column',
104
- gap: 10,
105
- boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
106
- },
107
- };
108
- async function fetchJson(url, options = {}) {
109
- // no-store:GET 结果可能被渲染进程缓存,导致「刚写入的状态读回旧值」
110
- const response = await fetch(url, { cache: 'no-store', ...options });
111
- const text = await response.text();
9
+ try {
10
+ window.__ModuleLoader__.load({
11
+ id: PACKAGE_NAME,
12
+ factory: (require) => {
13
+ const module = { exports: {} };
14
+ const exports = module.exports;
15
+ let React = null;
112
16
  try {
113
- return JSON.parse(text);
114
- }
115
- catch {
116
- return { error: `HTTP ${response.status}`, detail: text.slice(0, 200) };
17
+ React = require('react');
117
18
  }
118
- }
119
- function fmtBytes(bytes) {
120
- const n = Number(bytes ?? 0);
121
- if (!Number.isFinite(n))
122
- return '-';
123
- if (n < 1024)
124
- return `${n} B`;
125
- if (n < 1024 * 1024)
126
- return `${(n / 1024).toFixed(1)} KB`;
127
- return `${(n / (1024 * 1024)).toFixed(1)} MB`;
128
- }
129
- /** ISO 时间 → 本地 `MM-DD HH:mm`。 */
130
- function fmtTime(iso) {
131
- if (!iso)
132
- return '—';
133
- const date = new Date(iso);
134
- if (Number.isNaN(date.getTime()))
135
- return String(iso);
136
- const pad = (value) => String(value).padStart(2, '0');
137
- return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
138
- }
139
- const DECISION_LABELS = {
140
- pull: '发现更新',
141
- push: '本机有更新',
142
- skip: '已是最新',
143
- confirm: '需人工确认',
144
- 'remote-missing': '远端无包',
145
- };
146
- /** 最近一次同步的摘要:上传 / 应用 / 检查。 */
147
- function lastSyncSummary(status) {
148
- if (!status)
149
- return '尚未同步';
150
- const parts = [];
151
- if (status.lastUploaded?.createdAt)
152
- parts.push(`上次上传 ${fmtTime(status.lastUploaded.createdAt)}`);
153
- if (status.lastApplied?.createdAt)
154
- parts.push(`上次应用 ${fmtTime(status.lastApplied.createdAt)}`);
155
- if (status.scheduler?.lastCheckAt) {
156
- const decision = status.scheduler.lastDecision;
157
- const label = decision?.action && DECISION_LABELS[decision.action] ? DECISION_LABELS[decision.action] : '';
158
- parts.push(`上次检查 ${fmtTime(status.scheduler.lastCheckAt)}${label ? `(${label})` : ''}`);
159
- }
160
- return parts.length > 0 ? parts.join(' · ') : '尚未同步';
161
- }
162
- function ResultCard({ notice, onApply, onConfirmModules, onDismiss, onInstallPlugins, onForceUpload }) {
163
- if (!notice)
164
- return null;
165
- const { kind, data } = notice;
166
- if (kind === 'check') {
167
- const decision = data.decision ?? {};
168
- if (decision.action === 'pull' || decision.action === 'confirm') {
169
- const plan = data.plan ?? { added: [], overwritten: [], skipped: [] };
170
- const missing = data.plugins?.missing ?? [];
171
- const linkDeps = data.plugins?.linkDeps ?? [];
172
- return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'confirm' ? '两侧都有更新,需要你决定' : '远端有更新'), R.createElement('p', { style: styles.muted }, `远端包 ${data.latest?.name ?? ''}(${data.meta?.createdAt ?? '时间未知'}),新增 ${(plan.added ?? []).length} / 覆盖 ${(plan.overwritten ?? []).length} / 跳过 ${(plan.skipped ?? []).length} 个文件。`), missing.length > 0
173
- ? R.createElement('p', { style: styles.muted }, `缺失插件:${missing.map((item) => `${item.name}@${item.spec}`).join('、')}`)
174
- : null, linkDeps.length > 0
175
- ? R.createElement('p', { style: styles.muted }, `本地包需手动放回:${linkDeps.map((item) => item.name).join('、')}`)
176
- : null, R.createElement('div', { style: styles.actions }, missing.length > 0
177
- ? R.createElement('button', { style: styles.primary, onClick: () => onInstallPlugins(missing) }, '先安装缺失插件')
178
- : null, R.createElement('button', { style: styles.primary, onClick: () => onApply(data.latest?.name ?? '') }, '用远端覆盖本机'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消')));
179
- }
180
- return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'skip' ? '本机已是最新' : '本机有改动'), R.createElement('p', { style: styles.muted }, `判定:${decision.reason ?? decision.action}`));
19
+ catch (error) {
20
+ console.error('[dsh-user-sync] 无法加载 react,面板已禁用', error);
181
21
  }
182
- if (kind === 'error') {
183
- const remoteNewer = data.code === 'E_REMOTE_NEWER';
184
- return R.createElement('div', { style: styles.card }, R.createElement('strong', { style: styles.error }, remoteNewer ? '远端有更新的包' : '操作失败'), R.createElement('p', { style: styles.muted }, data.detail ?? data.error ?? '未知错误'), data.hits
185
- ? R.createElement('p', { style: styles.error }, `敏感扫描命中 ${data.hits.length} 处:${data.hits.map((h) => `${h.file}:${h.line}(${h.pattern})`).join(';')}`)
186
- : null,
187
- // 上传前发现远端更新:默认不覆盖,用户确认后才用本机覆盖远端(G2)
188
- remoteNewer
189
- ? R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => onForceUpload() }, '用本机覆盖远端'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消'))
190
- : null);
22
+ // 最坏情况只失去面板,绝不让 client entry 失败拖垮 web boot。
23
+ if (!React) {
24
+ exports.apply = () => { };
25
+ exports.inject = [];
26
+ return module.exports;
191
27
  }
192
- return R.createElement('div', { style: styles.card }, R.createElement('strong', null, '完成'), R.createElement('p', { style: styles.muted }, data.detail
193
- ? String(data.detail)
194
- : data.packageId
195
- ? `包 ${data.packageId} · ${fmtBytes(data.size ?? 0)}`
196
- : `写入 ${data.written?.length ?? 0} 个文件,跳过 ${data.skipped?.length ?? 0} 个`));
197
- }
198
- function UserSyncSection() {
199
- const [status, setStatus] = R.useState(null);
200
- const [scan, setScan] = R.useState(null);
201
- // 默认即「四类用户级选中、项目/会话不选」,不依赖 scan 是否成功(G1)。
202
- const [modules, setModules] = R.useState(['skills', 'plugins', 'config', 'userMemory']);
203
- const [settings, setSettings] = R.useState({ transport: 'folder', baseUrl: '', autoCheck: true, intervalHours: 6 });
204
- const [busy, setBusy] = R.useState(false);
205
- const [notice, setNotice] = R.useState(null);
206
- // 非用户级模块确认走弹窗(dialog),不再用底部结果卡
207
- const [confirmDialog, setConfirmDialog] = R.useState(null);
208
- // 当前展开文件清单的模块(null 表示都收起)
209
- const [expandedModule, setExpandedModule] = R.useState(null);
210
- // 设置改动后自动保存(600ms 防抖),不再提供「保存设置」按钮
211
- const [settingsDirty, setSettingsDirty] = R.useState(false);
212
- // 本地兜底:status 读不到时也能记住「已同意」,不再重复显示同意卡
213
- const [localConsent, setLocalConsent] = R.useState(() => {
28
+ const R = React;
29
+ const API = '/api/dsh-user-sync';
30
+ const MODULE_LABELS = {
31
+ skills: '技能',
32
+ plugins: '插件清单',
33
+ config: '配置',
34
+ userMemory: '用户记忆',
35
+ projectMemory: '项目记忆',
36
+ sessions: '会话',
37
+ };
38
+ const USER_LEVEL = ['skills', 'plugins', 'config', 'userMemory'];
39
+ /** 每个模块同步的内容简介(按钮悬停与下方小字)。 */
40
+ const MODULE_HINTS = {
41
+ skills: '~/.agents/skills 下的技能包(需含 SKILL.md)',
42
+ plugins: 'profile 的 package.json 与依赖锁文件',
43
+ config: 'cordis.patch.yml 与 AGENTS.md',
44
+ userMemory: 'AGENTS.md 与 memories/ 下的用户级记忆(MEMORY/USER/KEY)',
45
+ projectMemory: 'memories/projects 与 daily 下的项目记忆',
46
+ sessions: 'sessions/ 下的会话记录',
47
+ };
48
+ const styles = {
49
+ section: { display: 'flex', flexDirection: 'column', gap: 12 },
50
+ heading: { margin: 0, fontSize: 18, fontWeight: 600 },
51
+ card: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: 12, display: 'flex', flexDirection: 'column', gap: 6 },
52
+ row: { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 13 },
53
+ muted: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' },
54
+ moduleRow: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 },
55
+ moduleWrap: { display: 'flex', flexWrap: 'wrap', gap: 8 },
56
+ moduleGrid: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8 },
57
+ moduleItem: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 },
58
+ moduleHint: { fontSize: 11, lineHeight: 1.4, color: 'var(--dsw-alias-label-tertiary)' },
59
+ linkButton: { alignSelf: 'flex-start', padding: 0, border: 0, background: 'transparent', color: 'var(--dsw-alias-state-business-primary)', fontSize: 11, cursor: 'pointer', textDecoration: 'underline' },
60
+ warn: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-warning-primary, #b26b00)' },
61
+ fileList: { margin: 0, padding: '4px 0 0 16px', listStyle: 'disc', maxHeight: 160, overflowY: 'auto' },
62
+ fileItem: { fontSize: 11, lineHeight: 1.5, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', wordBreak: 'break-all' },
63
+ moduleButton: {
64
+ padding: '5px 12px',
65
+ borderRadius: 999,
66
+ border: '1px solid var(--dsw-alias-border-l2)',
67
+ background: 'transparent',
68
+ color: 'inherit',
69
+ fontSize: 13,
70
+ cursor: 'pointer',
71
+ userSelect: 'none',
72
+ WebkitUserSelect: 'none',
73
+ },
74
+ moduleButtonActive: {
75
+ padding: '5px 12px',
76
+ borderRadius: 999,
77
+ border: '1px solid var(--dsw-alias-state-business-primary)',
78
+ background: 'var(--dsw-alias-state-business-primary)',
79
+ color: '#fff',
80
+ fontSize: 13,
81
+ cursor: 'pointer',
82
+ userSelect: 'none',
83
+ WebkitUserSelect: 'none',
84
+ },
85
+ actions: { display: 'flex', gap: 8, flexWrap: 'wrap' },
86
+ primary: { padding: '6px 14px', borderRadius: 6, border: 0, cursor: 'pointer', background: 'var(--dsw-alias-state-business-primary)', color: '#fff', fontSize: 13 },
87
+ secondary: { padding: '6px 14px', borderRadius: 6, cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'inherit', fontSize: 13 },
88
+ error: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-state-error-primary, #d33)' },
89
+ overlay: {
90
+ position: 'fixed',
91
+ inset: 0,
92
+ background: 'rgba(0, 0, 0, 0.4)',
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ justifyContent: 'center',
96
+ zIndex: 1000,
97
+ },
98
+ dialog: {
99
+ width: 360,
100
+ maxWidth: 'calc(100vw - 48px)',
101
+ background: 'var(--dsw-alias-bg-elevated, #fff)',
102
+ color: 'inherit',
103
+ borderRadius: 10,
104
+ padding: 16,
105
+ display: 'flex',
106
+ flexDirection: 'column',
107
+ gap: 10,
108
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
109
+ },
110
+ };
111
+ async function fetchJson(url, options = {}) {
112
+ // no-store:GET 结果可能被渲染进程缓存,导致「刚写入的状态读回旧值」
113
+ const response = await fetch(url, { cache: 'no-store', ...options });
114
+ const text = await response.text();
214
115
  try {
215
- return localStorage.getItem('dsh-user-sync:consent') === '1';
116
+ return JSON.parse(text);
216
117
  }
217
118
  catch {
218
- return false;
119
+ return { error: `HTTP ${response.status}`, detail: text.slice(0, 200) };
219
120
  }
220
- });
221
- const consentGiven = Boolean(status?.consent?.acceptedAt) || localConsent;
222
- const refresh = () => {
223
- fetchJson(`${API}/status`)
224
- .then((data) => {
225
- if (data?.error) {
226
- // 保留已乐观更新的 consent,只补充错误信息
227
- setStatus((previous) => ({ ...(previous ?? {}), error: data.error, detail: data.detail }));
228
- return;
229
- }
230
- setStatus(data);
231
- setSettings({
232
- transport: data.transportSetting ?? 'folder',
233
- baseUrl: data.baseUrl ?? '',
234
- autoCheck: data.scheduler?.autoCheck !== false,
235
- intervalHours: data.scheduler?.intervalHours ?? 6,
236
- });
237
- })
238
- .catch((error) => setStatus((previous) => ({ ...(previous ?? {}), error: 'status-fetch-failed', detail: String(error) })));
239
- fetchJson(`${API}/scan`)
240
- .then((data) => {
241
- if (data?.error) {
242
- setScan({ error: data.error, detail: data.detail });
243
- return;
244
- }
245
- setScan(data);
246
- if (Array.isArray(data.modules))
247
- setModules(data.modules);
248
- })
249
- .catch((error) => setScan({ error: 'scan-fetch-failed', detail: String(error) }));
121
+ }
122
+ function fmtBytes(bytes) {
123
+ const n = Number(bytes ?? 0);
124
+ if (!Number.isFinite(n))
125
+ return '-';
126
+ if (n < 1024)
127
+ return `${n} B`;
128
+ if (n < 1024 * 1024)
129
+ return `${(n / 1024).toFixed(1)} KB`;
130
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
131
+ }
132
+ /** ISO 时间 → 本地 `MM-DD HH:mm`。 */
133
+ function fmtTime(iso) {
134
+ if (!iso)
135
+ return '—';
136
+ const date = new Date(iso);
137
+ if (Number.isNaN(date.getTime()))
138
+ return String(iso);
139
+ const pad = (value) => String(value).padStart(2, '0');
140
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
141
+ }
142
+ const DECISION_LABELS = {
143
+ pull: '发现更新',
144
+ push: '本机有更新',
145
+ skip: '已是最新',
146
+ confirm: '需人工确认',
147
+ 'remote-missing': '远端无包',
250
148
  };
251
- R.useEffect(() => {
252
- refresh();
253
- }, []);
254
- // 设置改动后 600ms 自动保存(输入间隔时不会每敲一下都发请求)
255
- R.useEffect(() => {
256
- if (!settingsDirty)
257
- return undefined;
258
- const timer = setTimeout(() => {
259
- void saveSettings();
260
- }, 600);
261
- return () => clearTimeout(timer);
262
- }, [settings, settingsDirty]);
263
- // 操作提示(完成 / 已保存)3 秒后自动消失;覆盖确认与错误卡保留
264
- R.useEffect(() => {
265
- if (notice?.kind !== 'result')
266
- return undefined;
267
- const timer = setTimeout(() => setNotice(null), 3000);
268
- return () => clearTimeout(timer);
269
- }, [notice]);
270
- // 自动保存不占用 busy:否则底部「上传/同步」按钮会因 disabled 切换而闪烁
271
- const saveSettings = async () => {
272
- try {
273
- const result = await fetchJson(`${API}/config`, {
274
- method: 'POST',
275
- headers: { 'content-type': 'application/json' },
276
- body: JSON.stringify({
277
- autoCheck: settings.autoCheck,
278
- intervalHours: Number(settings.intervalHours) || 6,
279
- }),
280
- });
281
- if (result.error) {
282
- setNotice({ kind: 'error', data: { detail: `设置保存失败:${result.error}` } });
283
- }
284
- else {
285
- setNotice({ kind: 'result', data: { detail: '自动检查设置已保存' } });
149
+ /** 最近一次同步的摘要:上传 / 应用 / 检查。 */
150
+ function lastSyncSummary(status) {
151
+ if (!status)
152
+ return '尚未同步';
153
+ const parts = [];
154
+ if (status.lastUploaded?.createdAt)
155
+ parts.push(`上次上传 ${fmtTime(status.lastUploaded.createdAt)}`);
156
+ if (status.lastApplied?.createdAt)
157
+ parts.push(`上次应用 ${fmtTime(status.lastApplied.createdAt)}`);
158
+ if (status.scheduler?.lastCheckAt) {
159
+ const decision = status.scheduler.lastDecision;
160
+ const label = decision?.action && DECISION_LABELS[decision.action] ? DECISION_LABELS[decision.action] : '';
161
+ parts.push(`上次检查 ${fmtTime(status.scheduler.lastCheckAt)}${label ? `(${label})` : ''}`);
162
+ }
163
+ return parts.length > 0 ? parts.join(' · ') : '尚未同步';
164
+ }
165
+ function ResultCard({ notice, onApply, onConfirmModules, onDismiss, onInstallPlugins, onForceUpload }) {
166
+ if (!notice)
167
+ return null;
168
+ const { kind, data } = notice;
169
+ if (kind === 'check') {
170
+ const decision = data.decision ?? {};
171
+ if (decision.action === 'pull' || decision.action === 'confirm') {
172
+ const plan = data.plan ?? { added: [], overwritten: [], skipped: [] };
173
+ const missing = data.plugins?.missing ?? [];
174
+ const linkDeps = data.plugins?.linkDeps ?? [];
175
+ return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'confirm' ? '两侧都有更新,需要你决定' : '远端有更新'), R.createElement('p', { style: styles.muted }, `远端包 ${data.latest?.name ?? ''}(${data.meta?.createdAt ?? '时间未知'}),新增 ${(plan.added ?? []).length} / 覆盖 ${(plan.overwritten ?? []).length} / 跳过 ${(plan.skipped ?? []).length} 个文件。`), missing.length > 0
176
+ ? R.createElement('p', { style: styles.muted }, `缺失插件:${missing.map((item) => `${item.name}@${item.spec}`).join('、')}`)
177
+ : null, linkDeps.length > 0
178
+ ? R.createElement('p', { style: styles.muted }, `本地包需手动放回:${linkDeps.map((item) => item.name).join('、')}`)
179
+ : null, R.createElement('div', { style: styles.actions }, missing.length > 0
180
+ ? R.createElement('button', { style: styles.primary, onClick: () => onInstallPlugins(missing) }, '先安装缺失插件')
181
+ : null, R.createElement('button', { style: styles.primary, onClick: () => onApply(data.latest?.name ?? '') }, '用远端覆盖本机'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消')));
286
182
  }
287
- setSettingsDirty(false);
288
- refresh();
183
+ return R.createElement('div', { style: styles.card }, R.createElement('strong', null, decision.action === 'skip' ? '本机已是最新' : '本机有改动'), R.createElement('p', { style: styles.muted }, `判定:${decision.reason ?? decision.action}`));
289
184
  }
290
- catch (error) {
291
- setNotice({ kind: 'error', data: { detail: String(error) } });
185
+ if (kind === 'error') {
186
+ const remoteNewer = data.code === 'E_REMOTE_NEWER';
187
+ return R.createElement('div', { style: styles.card }, R.createElement('strong', { style: styles.error }, remoteNewer ? '远端有更新的包' : '操作失败'), R.createElement('p', { style: styles.muted }, data.detail ?? data.error ?? '未知错误'), data.hits
188
+ ? R.createElement('p', { style: styles.error }, `敏感扫描命中 ${data.hits.length} 处:${data.hits.map((h) => `${h.file}:${h.line}(${h.pattern})`).join(';')}`)
189
+ : null,
190
+ // 上传前发现远端更新:默认不覆盖,用户确认后才用本机覆盖远端(G2)
191
+ remoteNewer
192
+ ? R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => onForceUpload() }, '用本机覆盖远端'), R.createElement('button', { style: styles.secondary, onClick: () => onDismiss() }, '取消'))
193
+ : null);
292
194
  }
293
- };
294
- const toggleModule = async (id, checked) => {
295
- const base = modules ?? [];
296
- const next = checked ? [...new Set([...base, id])] : base.filter((item) => item !== id);
297
- try {
298
- const result = await fetchJson(`${API}/config`, {
299
- method: 'POST',
300
- headers: { 'content-type': 'application/json' },
301
- body: JSON.stringify({ modules: next }),
302
- });
303
- if (result.warning) {
304
- setConfirmDialog({ modules: next, warning: result.warning });
305
- return;
195
+ return R.createElement('div', { style: styles.card }, R.createElement('strong', null, '完成'), R.createElement('p', { style: styles.muted }, data.detail
196
+ ? String(data.detail)
197
+ : data.packageId
198
+ ? `包 ${data.packageId} · ${fmtBytes(data.size ?? 0)}`
199
+ : `写入 ${data.written?.length ?? 0} 个文件,跳过 ${data.skipped?.length ?? 0} 个`));
200
+ }
201
+ function UserSyncSection() {
202
+ const [status, setStatus] = R.useState(null);
203
+ const [scan, setScan] = R.useState(null);
204
+ // 默认即「四类用户级选中、项目/会话不选」,不依赖 scan 是否成功(G1)。
205
+ const [modules, setModules] = R.useState(['skills', 'plugins', 'config', 'userMemory']);
206
+ const [settings, setSettings] = R.useState({ transport: 'folder', baseUrl: '', autoCheck: true, intervalHours: 6 });
207
+ const [busy, setBusy] = R.useState(false);
208
+ const [notice, setNotice] = R.useState(null);
209
+ // 非用户级模块确认走弹窗(dialog),不再用底部结果卡
210
+ const [confirmDialog, setConfirmDialog] = R.useState(null);
211
+ // 当前展开文件清单的模块(null 表示都收起)
212
+ const [expandedModule, setExpandedModule] = R.useState(null);
213
+ // 设置改动后自动保存(600ms 防抖),不再提供「保存设置」按钮
214
+ const [settingsDirty, setSettingsDirty] = R.useState(false);
215
+ // 本地兜底:status 读不到时也能记住「已同意」,不再重复显示同意卡
216
+ const [localConsent, setLocalConsent] = R.useState(() => {
217
+ try {
218
+ return localStorage.getItem('dsh-user-sync:consent') === '1';
306
219
  }
307
- setModules(next);
220
+ catch {
221
+ return false;
222
+ }
223
+ });
224
+ const consentGiven = Boolean(status?.consent?.acceptedAt) || localConsent;
225
+ const refresh = () => {
226
+ fetchJson(`${API}/status`)
227
+ .then((data) => {
228
+ if (data?.error) {
229
+ // 保留已乐观更新的 consent,只补充错误信息
230
+ setStatus((previous) => ({ ...(previous ?? {}), error: data.error, detail: data.detail }));
231
+ return;
232
+ }
233
+ setStatus(data);
234
+ setSettings({
235
+ transport: data.transportSetting ?? 'folder',
236
+ baseUrl: data.baseUrl ?? '',
237
+ autoCheck: data.scheduler?.autoCheck !== false,
238
+ intervalHours: data.scheduler?.intervalHours ?? 6,
239
+ });
240
+ })
241
+ .catch((error) => setStatus((previous) => ({ ...(previous ?? {}), error: 'status-fetch-failed', detail: String(error) })));
242
+ fetchJson(`${API}/scan`)
243
+ .then((data) => {
244
+ if (data?.error) {
245
+ setScan({ error: data.error, detail: data.detail });
246
+ return;
247
+ }
248
+ setScan(data);
249
+ if (Array.isArray(data.modules))
250
+ setModules(data.modules);
251
+ })
252
+ .catch((error) => setScan({ error: 'scan-fetch-failed', detail: String(error) }));
253
+ };
254
+ R.useEffect(() => {
308
255
  refresh();
309
- }
310
- catch (error) {
311
- setNotice({ kind: 'error', data: { detail: String(error) } });
312
- }
313
- };
314
- const confirmModules = async (next) => {
315
- try {
316
- await fetchJson(`${API}/config`, {
317
- method: 'POST',
318
- headers: { 'content-type': 'application/json' },
319
- body: JSON.stringify({ modules: next, confirmNonUserLevel: true }),
320
- });
321
- setModules(next);
256
+ }, []);
257
+ // 设置改动后 600ms 自动保存(输入间隔时不会每敲一下都发请求)
258
+ R.useEffect(() => {
259
+ if (!settingsDirty)
260
+ return undefined;
261
+ const timer = setTimeout(() => {
262
+ void saveSettings();
263
+ }, 600);
264
+ return () => clearTimeout(timer);
265
+ }, [settings, settingsDirty]);
266
+ // 操作提示(完成 / 已保存)3 秒后自动消失;覆盖确认与错误卡保留
267
+ R.useEffect(() => {
268
+ if (notice?.kind !== 'result')
269
+ return undefined;
270
+ const timer = setTimeout(() => setNotice(null), 3000);
271
+ return () => clearTimeout(timer);
272
+ }, [notice]);
273
+ // 自动保存不占用 busy:否则底部「上传/同步」按钮会因 disabled 切换而闪烁
274
+ const saveSettings = async () => {
275
+ try {
276
+ const result = await fetchJson(`${API}/config`, {
277
+ method: 'POST',
278
+ headers: { 'content-type': 'application/json' },
279
+ body: JSON.stringify({
280
+ autoCheck: settings.autoCheck,
281
+ intervalHours: Number(settings.intervalHours) || 6,
282
+ }),
283
+ });
284
+ if (result.error) {
285
+ setNotice({ kind: 'error', data: { detail: `设置保存失败:${result.error}` } });
286
+ }
287
+ else {
288
+ setNotice({ kind: 'result', data: { detail: '自动检查设置已保存' } });
289
+ }
290
+ setSettingsDirty(false);
291
+ refresh();
292
+ }
293
+ catch (error) {
294
+ setNotice({ kind: 'error', data: { detail: String(error) } });
295
+ }
296
+ };
297
+ const toggleModule = async (id, checked) => {
298
+ const base = modules ?? [];
299
+ const next = checked ? [...new Set([...base, id])] : base.filter((item) => item !== id);
300
+ try {
301
+ const result = await fetchJson(`${API}/config`, {
302
+ method: 'POST',
303
+ headers: { 'content-type': 'application/json' },
304
+ body: JSON.stringify({ modules: next }),
305
+ });
306
+ if (result.warning) {
307
+ setConfirmDialog({ modules: next, warning: result.warning });
308
+ return;
309
+ }
310
+ setModules(next);
311
+ refresh();
312
+ }
313
+ catch (error) {
314
+ setNotice({ kind: 'error', data: { detail: String(error) } });
315
+ }
316
+ };
317
+ const confirmModules = async (next) => {
318
+ try {
319
+ await fetchJson(`${API}/config`, {
320
+ method: 'POST',
321
+ headers: { 'content-type': 'application/json' },
322
+ body: JSON.stringify({ modules: next, confirmNonUserLevel: true }),
323
+ });
324
+ setModules(next);
325
+ setNotice(null);
326
+ setConfirmDialog(null);
327
+ refresh();
328
+ }
329
+ catch (error) {
330
+ setNotice({ kind: 'error', data: { detail: String(error) } });
331
+ }
332
+ };
333
+ const acceptConsent = async () => {
334
+ setBusy(true);
335
+ try {
336
+ const result = await fetchJson(`${API}/consent`, {
337
+ method: 'POST',
338
+ headers: { 'content-type': 'application/json' },
339
+ body: JSON.stringify({ accepted: true }),
340
+ });
341
+ if (result?.error) {
342
+ setNotice({ kind: 'error', data: result });
343
+ return;
344
+ }
345
+ // 乐观更新:不等 status 刷新,卡片立即消失
346
+ const consent = result?.consent ?? { acceptedAt: new Date().toISOString() };
347
+ setStatus((previous) => ({ ...(previous ?? {}), consent }));
348
+ try {
349
+ localStorage.setItem('dsh-user-sync:consent', '1');
350
+ }
351
+ catch {
352
+ // 隐私模式等场景写不进去,不影响本次会话
353
+ }
354
+ setLocalConsent(true);
355
+ setNotice({ kind: 'result', data: { detail: '已同意' } });
356
+ refresh();
357
+ }
358
+ catch (error) {
359
+ setNotice({ kind: 'error', data: { detail: String(error) } });
360
+ }
361
+ finally {
362
+ setBusy(false);
363
+ }
364
+ };
365
+ /** force=true 表示用户已确认「用本机覆盖远端」(远端更新时才需要)。 */
366
+ const upload = async (force = false) => {
367
+ setBusy(true);
322
368
  setNotice(null);
323
- setConfirmDialog(null);
324
- refresh();
325
- }
326
- catch (error) {
327
- setNotice({ kind: 'error', data: { detail: String(error) } });
328
- }
329
- };
330
- const acceptConsent = async () => {
331
- setBusy(true);
332
- try {
333
- const result = await fetchJson(`${API}/consent`, {
334
- method: 'POST',
335
- headers: { 'content-type': 'application/json' },
336
- body: JSON.stringify({ accepted: true }),
337
- });
338
- if (result?.error) {
339
- setNotice({ kind: 'error', data: result });
340
- return;
369
+ try {
370
+ const result = await fetchJson(`${API}/upload`, {
371
+ method: 'POST',
372
+ headers: { 'content-type': 'application/json' },
373
+ body: JSON.stringify({ force }),
374
+ });
375
+ setNotice({ kind: result.ok ? 'result' : 'error', data: result });
376
+ refresh();
377
+ }
378
+ catch (error) {
379
+ setNotice({ kind: 'error', data: { detail: String(error) } });
341
380
  }
342
- // 乐观更新:不等 status 刷新,卡片立即消失
343
- const consent = result?.consent ?? { acceptedAt: new Date().toISOString() };
344
- setStatus((previous) => ({ ...(previous ?? {}), consent }));
381
+ finally {
382
+ setBusy(false);
383
+ }
384
+ };
385
+ const installPlugins = async (packages) => {
386
+ setBusy(true);
345
387
  try {
346
- localStorage.setItem('dsh-user-sync:consent', '1');
388
+ const result = await fetchJson(`${API}/install`, {
389
+ method: 'POST',
390
+ headers: { 'content-type': 'application/json' },
391
+ body: JSON.stringify({ packages }),
392
+ });
393
+ if (result.ok) {
394
+ setNotice({ kind: 'result', data: { detail: `已安装 ${result.results?.length ?? 0} 个插件,重启 DSH 后生效` } });
395
+ }
396
+ else {
397
+ setNotice({ kind: 'error', data: { detail: `安装失败:${(result.results ?? []).filter((item) => !item.ok).map((item) => `${item.name}(${item.error})`).join(';') || result.error}` } });
398
+ }
399
+ refresh();
347
400
  }
348
- catch {
349
- // 隐私模式等场景写不进去,不影响本次会话
401
+ catch (error) {
402
+ setNotice({ kind: 'error', data: { detail: String(error) } });
350
403
  }
351
- setLocalConsent(true);
352
- setNotice({ kind: 'result', data: { detail: '已同意' } });
353
- refresh();
354
- }
355
- catch (error) {
356
- setNotice({ kind: 'error', data: { detail: String(error) } });
357
- }
358
- finally {
359
- setBusy(false);
360
- }
361
- };
362
- /** force=true 表示用户已确认「用本机覆盖远端」(远端更新时才需要)。 */
363
- const upload = async (force = false) => {
364
- setBusy(true);
365
- setNotice(null);
366
- try {
367
- const result = await fetchJson(`${API}/upload`, {
368
- method: 'POST',
369
- headers: { 'content-type': 'application/json' },
370
- body: JSON.stringify({ force }),
371
- });
372
- setNotice({ kind: result.ok ? 'result' : 'error', data: result });
373
- refresh();
374
- }
375
- catch (error) {
376
- setNotice({ kind: 'error', data: { detail: String(error) } });
377
- }
378
- finally {
379
- setBusy(false);
380
- }
381
- };
382
- const installPlugins = async (packages) => {
383
- setBusy(true);
384
- try {
385
- const result = await fetchJson(`${API}/install`, {
386
- method: 'POST',
387
- headers: { 'content-type': 'application/json' },
388
- body: JSON.stringify({ packages }),
389
- });
390
- if (result.ok) {
391
- setNotice({ kind: 'result', data: { detail: `已安装 ${result.results?.length ?? 0} 个插件,重启 DSH 后生效` } });
404
+ finally {
405
+ setBusy(false);
392
406
  }
393
- else {
394
- setNotice({ kind: 'error', data: { detail: `安装失败:${(result.results ?? []).filter((item) => !item.ok).map((item) => `${item.name}(${item.error})`).join(';') || result.error}` } });
407
+ };
408
+ const checkRemote = async () => {
409
+ setBusy(true);
410
+ setNotice(null);
411
+ try {
412
+ const result = await fetchJson(`${API}/check`);
413
+ setNotice(result.error ? { kind: 'error', data: result } : { kind: 'check', data: result });
395
414
  }
396
- refresh();
397
- }
398
- catch (error) {
399
- setNotice({ kind: 'error', data: { detail: String(error) } });
400
- }
401
- finally {
402
- setBusy(false);
403
- }
404
- };
405
- const checkRemote = async () => {
406
- setBusy(true);
407
- setNotice(null);
408
- try {
409
- const result = await fetchJson(`${API}/check`);
410
- setNotice(result.error ? { kind: 'error', data: result } : { kind: 'check', data: result });
411
- }
412
- catch (error) {
413
- setNotice({ kind: 'error', data: { detail: String(error) } });
414
- }
415
- finally {
416
- setBusy(false);
417
- }
418
- };
419
- const applyRemote = async (name) => {
420
- if (!name)
421
- return;
422
- setBusy(true);
415
+ catch (error) {
416
+ setNotice({ kind: 'error', data: { detail: String(error) } });
417
+ }
418
+ finally {
419
+ setBusy(false);
420
+ }
421
+ };
422
+ const applyRemote = async (name) => {
423
+ if (!name)
424
+ return;
425
+ setBusy(true);
426
+ try {
427
+ const result = await fetchJson(`${API}/apply`, {
428
+ method: 'POST',
429
+ headers: { 'content-type': 'application/json' },
430
+ body: JSON.stringify({ name }),
431
+ });
432
+ setNotice({ kind: result.ok ? 'result' : 'error', data: result });
433
+ refresh();
434
+ }
435
+ catch (error) {
436
+ setNotice({ kind: 'error', data: { detail: String(error) } });
437
+ }
438
+ finally {
439
+ setBusy(false);
440
+ }
441
+ };
442
+ // 会话不参与同步:同步范围内不展示会话模块
443
+ const moduleIds = (scan?.allModules ?? [...USER_LEVEL, 'projectMemory', 'sessions']).filter((id) => id !== 'sessions');
444
+ // 计数优先用 status 的宿主探针(与 /scan 同一条扫描路径),
445
+ // 这样 /scan 请求失败时面板仍能显示真实数量。
446
+ const counts = status?.probe?.counts ?? scan?.counts ?? {};
447
+ return R.createElement('div', { style: styles.section }, R.createElement('h2', { style: styles.heading }, '设置同步'), status?.scheduler?.lastDecision?.action === 'pull' && consentGiven
448
+ ? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '远端有更新'), R.createElement('p', { style: styles.muted }, `检测到 ${fmtTime(status.scheduler.lastCheckAt)} 的远端更新(来自 ${status.scheduler.lastDecision.packageId ?? '远端'})。`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '立即同步')))
449
+ : null, !consentGiven
450
+ ? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '启用前请确认'), R.createElement('p', { style: styles.muted }, '上传内容:技能、插件清单、配置、用户记忆。数据经运营方服务中转并存放在运营方账号(非 DSH 官方服务器);远端只保留 1 份,上传即覆盖。'), R.createElement('div', { style: styles.actions },
451
+ // 用普通按钮:主色按钮看起来像「已选中」,同意是主动作但不应暗示状态
452
+ R.createElement('button', { style: styles.secondary, disabled: busy, onClick: acceptConsent }, '同意并启用上传')))
453
+ : null, R.createElement('div', { style: styles.card }, R.createElement('div', { style: styles.row }, R.createElement('span', null, 'DSH 主目录'), R.createElement('code', null, status?.dshHome ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, 'Profile'), R.createElement('span', null, status?.profile ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '账号'), R.createElement('span', null, status?.account?.hint ? `已配对 · ${status.account.hint}` : '未登录官方账号')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '传输'), R.createElement('span', null, status?.transport ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '版本'), R.createElement('span', null, status?.version ? `v${status.version}` : '加载中…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '接口'), R.createElement('span', { style: status?.error ? styles.error : undefined }, status?.error ? `错误:${status.error}${status.detail ? `(${status.detail})` : ''}` : status?.installed ? '正常' : '加载中…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '扫描'), R.createElement('span', { style: scan?.error ? styles.error : undefined }, scan?.error
454
+ ? `失败:${scan.error}${scan.detail ? `:${String(scan.detail).slice(0, 160)}` : ''}`
455
+ : `已选 ${scan?.selectedFiles ?? scan?.totalFiles ?? 0} 个文件 · ${fmtBytes(scan?.selectedBytes ?? scan?.totalBytes ?? 0)}(同步范围 ${scan?.syncableFiles ?? scan?.totalFiles ?? 0} 个 · ${fmtBytes(scan?.syncableBytes ?? scan?.totalBytes ?? 0)})`))), R.createElement('div', { style: styles.card }, R.createElement('strong', null, '同步范围'), (scan?.selectedBytes ?? 0) > 10 * 1024 * 1024
456
+ ? R.createElement('p', { style: styles.warn }, `预计 ${fmtBytes(scan?.selectedBytes)},超过 10 MiB 单包上限;上传会被拒绝,请取消「会话」或减少同步范围。`)
457
+ : null, R.createElement('div', { style: styles.moduleGrid }, ...moduleIds.map((id) => {
458
+ const checked = (modules ?? []).includes(id);
459
+ // 插件清单展示「装了什么插件」(profile dependencies);无 plugins 数据时退回文件数
460
+ const usePluginList = id === 'plugins' && Array.isArray(scan?.plugins);
461
+ const moduleCount = usePluginList ? (scan?.plugins?.length ?? 0) : counts[id] ?? 0;
462
+ const moduleFiles = (scan?.files ?? []).filter((file) => file.module === id);
463
+ // 插件清单展示「装了什么插件」,其余模块展示文件路径;会话模块优先展示标题
464
+ const moduleEntries = usePluginList
465
+ ? (scan?.plugins ?? []).map((plugin) => `${plugin.name}@${plugin.spec}`)
466
+ : moduleFiles.map((file) => (id === 'sessions' && file.title ? file.title : file.path));
467
+ return R.createElement('div', { key: id, style: styles.moduleItem }, R.createElement('button', {
468
+ type: 'button',
469
+ 'aria-pressed': checked,
470
+ title: MODULE_HINTS[id] ?? '',
471
+ onClick: () => toggleModule(id, !checked),
472
+ style: checked ? styles.moduleButtonActive : styles.moduleButton,
473
+ }, `${MODULE_LABELS[id] ?? id}(${moduleCount})${USER_LEVEL.includes(id) ? '' : ' · 非用户级'}`), R.createElement('span', { style: styles.moduleHint }, MODULE_HINTS[id] ?? ''), moduleEntries.length > 0
474
+ ? R.createElement('button', {
475
+ type: 'button',
476
+ style: styles.linkButton,
477
+ onClick: () => setExpandedModule(expandedModule === id ? null : id),
478
+ }, expandedModule === id
479
+ ? '收起清单'
480
+ : usePluginList
481
+ ? `查看 ${moduleEntries.length} 个插件`
482
+ : id === 'sessions'
483
+ ? `查看 ${moduleEntries.length} 个会话`
484
+ : `查看 ${moduleEntries.length} 个文件`)
485
+ : null, expandedModule === id
486
+ ? R.createElement('ul', { style: styles.fileList }, ...moduleEntries.map((entry) => R.createElement('li', { key: entry, style: styles.fileItem }, entry)))
487
+ : null);
488
+ }))), R.createElement('div', { style: styles.card }, R.createElement('strong', null, '自动检查'), R.createElement('label', { style: styles.moduleRow }, R.createElement('input', {
489
+ type: 'checkbox',
490
+ checked: settings.autoCheck,
491
+ onChange: (event) => {
492
+ setSettings({ ...settings, autoCheck: Boolean(event.target.checked) });
493
+ setSettingsDirty(true);
494
+ },
495
+ }), '启动与定时检查远端(只提醒,不自动覆盖)'), R.createElement('label', { style: styles.moduleRow }, R.createElement('span', null, '间隔(小时)'), R.createElement('input', {
496
+ type: 'number',
497
+ min: 1,
498
+ max: 168,
499
+ value: settings.intervalHours,
500
+ onChange: (event) => {
501
+ setSettings({ ...settings, intervalHours: Number(event.target.value ?? 6) });
502
+ setSettingsDirty(true);
503
+ },
504
+ style: { width: 72, padding: '2px 6px', fontSize: 13 },
505
+ }))), R.createElement('div', { style: styles.actions },
506
+ // 两个操作都用普通按钮:主色高亮会被看成「已选中」
507
+ R.createElement('button', { style: styles.secondary, disabled: busy || !consentGiven, onClick: () => upload() }, busy ? '处理中…' : '上传用户配置'), R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '从远端同步')), R.createElement('p', { style: styles.muted }, lastSyncSummary(status)),
508
+ // 服务地址只读展示:默认内置,用户覆盖后标注来源(面板不再提供输入框)
509
+ R.createElement('p', { style: styles.muted }, `服务地址:${status?.baseUrl ?? '(未解析)'}${status?.baseUrlSource === 'user' ? '(自定义)' : '(内置)'}`), R.createElement(ResultCard, {
510
+ notice,
511
+ onApply: applyRemote,
512
+ onConfirmModules: confirmModules,
513
+ onDismiss: () => setNotice(null),
514
+ onInstallPlugins: installPlugins,
515
+ onForceUpload: () => upload(true),
516
+ }), confirmDialog
517
+ ? R.createElement('div', { style: styles.overlay, onClick: () => setConfirmDialog(null) }, R.createElement('div', { style: styles.dialog, onClick: (event) => event.stopPropagation?.() }, R.createElement('strong', null, '同步项目/会话内容?'), R.createElement('p', { style: styles.muted }, `${confirmDialog.warning.map((id) => MODULE_LABELS[id] ?? id).join('、')} 属于项目/会话级,可能包含具体工作内容。确认要跨机器同步吗?`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => confirmModules(confirmDialog.modules) }, '仍要同步'), R.createElement('button', { style: styles.secondary, onClick: () => setConfirmDialog(null) }, '取消'))))
518
+ : null);
519
+ }
520
+ function apply(ctx) {
423
521
  try {
424
- const result = await fetchJson(`${API}/apply`, {
425
- method: 'POST',
426
- headers: { 'content-type': 'application/json' },
427
- body: JSON.stringify({ name }),
428
- });
429
- setNotice({ kind: result.ok ? 'result' : 'error', data: result });
430
- refresh();
522
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
523
+ name: 'settings.section',
524
+ id: 'user-sync',
525
+ order: 50,
526
+ label: '设置同步',
527
+ }, UserSyncSection));
431
528
  }
432
529
  catch (error) {
433
- setNotice({ kind: 'error', data: { detail: String(error) } });
434
- }
435
- finally {
436
- setBusy(false);
530
+ // 面板注册失败只影响自己,不抛给 fiber,避免 web boot 失败。
531
+ console.error('[dsh-user-sync] 面板注册失败', error);
437
532
  }
438
- };
439
- // 会话不参与同步:同步范围内不展示会话模块
440
- const moduleIds = (scan?.allModules ?? [...USER_LEVEL, 'projectMemory', 'sessions']).filter((id) => id !== 'sessions');
441
- // 计数优先用 status 的宿主探针(与 /scan 同一条扫描路径),
442
- // 这样 /scan 请求失败时面板仍能显示真实数量。
443
- const counts = status?.probe?.counts ?? scan?.counts ?? {};
444
- return R.createElement('div', { style: styles.section }, R.createElement('h2', { style: styles.heading }, '设置同步'), status?.scheduler?.lastDecision?.action === 'pull' && consentGiven
445
- ? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '远端有更新'), R.createElement('p', { style: styles.muted }, `检测到 ${fmtTime(status.scheduler.lastCheckAt)} 的远端更新(来自 ${status.scheduler.lastDecision.packageId ?? '远端'})。`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '立即同步')))
446
- : null, !consentGiven
447
- ? R.createElement('div', { style: styles.card }, R.createElement('strong', null, '启用前请确认'), R.createElement('p', { style: styles.muted }, '上传内容:技能、插件清单、配置、用户记忆。数据经运营方服务中转并存放在运营方账号(非 DSH 官方服务器);远端只保留 1 份,上传即覆盖。'), R.createElement('div', { style: styles.actions },
448
- // 用普通按钮:主色按钮看起来像「已选中」,同意是主动作但不应暗示状态
449
- R.createElement('button', { style: styles.secondary, disabled: busy, onClick: acceptConsent }, '同意并启用上传')))
450
- : null, R.createElement('div', { style: styles.card }, R.createElement('div', { style: styles.row }, R.createElement('span', null, 'DSH 主目录'), R.createElement('code', null, status?.dshHome ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, 'Profile'), R.createElement('span', null, status?.profile ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '账号'), R.createElement('span', null, status?.account?.hint ? `已配对 · ${status.account.hint}` : '未登录官方账号')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '传输'), R.createElement('span', null, status?.transport ?? '…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '版本'), R.createElement('span', null, status?.version ? `v${status.version}` : '加载中…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '接口'), R.createElement('span', { style: status?.error ? styles.error : undefined }, status?.error ? `错误:${status.error}${status.detail ? `(${status.detail})` : ''}` : status?.installed ? '正常' : '加载中…')), R.createElement('div', { style: styles.row }, R.createElement('span', null, '扫描'), R.createElement('span', { style: scan?.error ? styles.error : undefined }, scan?.error
451
- ? `失败:${scan.error}${scan.detail ? `:${String(scan.detail).slice(0, 160)}` : ''}`
452
- : `已选 ${scan?.selectedFiles ?? scan?.totalFiles ?? 0} 个文件 · ${fmtBytes(scan?.selectedBytes ?? scan?.totalBytes ?? 0)}(同步范围 ${scan?.syncableFiles ?? scan?.totalFiles ?? 0} 个 · ${fmtBytes(scan?.syncableBytes ?? scan?.totalBytes ?? 0)})`))), R.createElement('div', { style: styles.card }, R.createElement('strong', null, '同步范围'), (scan?.selectedBytes ?? 0) > 10 * 1024 * 1024
453
- ? R.createElement('p', { style: styles.warn }, `预计 ${fmtBytes(scan?.selectedBytes)},超过 10 MiB 单包上限;上传会被拒绝,请取消「会话」或减少同步范围。`)
454
- : null, R.createElement('div', { style: styles.moduleGrid }, ...moduleIds.map((id) => {
455
- const checked = (modules ?? []).includes(id);
456
- // 插件清单展示「装了什么插件」(profile dependencies);无 plugins 数据时退回文件数
457
- const usePluginList = id === 'plugins' && Array.isArray(scan?.plugins);
458
- const moduleCount = usePluginList ? (scan?.plugins?.length ?? 0) : counts[id] ?? 0;
459
- const moduleFiles = (scan?.files ?? []).filter((file) => file.module === id);
460
- // 插件清单展示「装了什么插件」,其余模块展示文件路径;会话模块优先展示标题
461
- const moduleEntries = usePluginList
462
- ? (scan?.plugins ?? []).map((plugin) => `${plugin.name}@${plugin.spec}`)
463
- : moduleFiles.map((file) => (id === 'sessions' && file.title ? file.title : file.path));
464
- return R.createElement('div', { key: id, style: styles.moduleItem }, R.createElement('button', {
465
- type: 'button',
466
- 'aria-pressed': checked,
467
- title: MODULE_HINTS[id] ?? '',
468
- onClick: () => toggleModule(id, !checked),
469
- style: checked ? styles.moduleButtonActive : styles.moduleButton,
470
- }, `${MODULE_LABELS[id] ?? id}(${moduleCount})${USER_LEVEL.includes(id) ? '' : ' · 非用户级'}`), R.createElement('span', { style: styles.moduleHint }, MODULE_HINTS[id] ?? ''), moduleEntries.length > 0
471
- ? R.createElement('button', {
472
- type: 'button',
473
- style: styles.linkButton,
474
- onClick: () => setExpandedModule(expandedModule === id ? null : id),
475
- }, expandedModule === id
476
- ? '收起清单'
477
- : usePluginList
478
- ? `查看 ${moduleEntries.length} 个插件`
479
- : id === 'sessions'
480
- ? `查看 ${moduleEntries.length} 个会话`
481
- : `查看 ${moduleEntries.length} 个文件`)
482
- : null, expandedModule === id
483
- ? R.createElement('ul', { style: styles.fileList }, ...moduleEntries.map((entry) => R.createElement('li', { key: entry, style: styles.fileItem }, entry)))
484
- : null);
485
- }))), R.createElement('div', { style: styles.card }, R.createElement('strong', null, '自动检查'), R.createElement('label', { style: styles.moduleRow }, R.createElement('input', {
486
- type: 'checkbox',
487
- checked: settings.autoCheck,
488
- onChange: (event) => {
489
- setSettings({ ...settings, autoCheck: Boolean(event.target.checked) });
490
- setSettingsDirty(true);
491
- },
492
- }), '启动与定时检查远端(只提醒,不自动覆盖)'), R.createElement('label', { style: styles.moduleRow }, R.createElement('span', null, '间隔(小时)'), R.createElement('input', {
493
- type: 'number',
494
- min: 1,
495
- max: 168,
496
- value: settings.intervalHours,
497
- onChange: (event) => {
498
- setSettings({ ...settings, intervalHours: Number(event.target.value ?? 6) });
499
- setSettingsDirty(true);
500
- },
501
- style: { width: 72, padding: '2px 6px', fontSize: 13 },
502
- }))), R.createElement('div', { style: styles.actions },
503
- // 两个操作都用普通按钮:主色高亮会被看成「已选中」
504
- R.createElement('button', { style: styles.secondary, disabled: busy || !consentGiven, onClick: () => upload() }, busy ? '处理中…' : '上传用户配置'), R.createElement('button', { style: styles.secondary, disabled: busy, onClick: checkRemote }, '从远端同步')), R.createElement('p', { style: styles.muted }, lastSyncSummary(status)),
505
- // 服务地址只读展示:默认内置,用户覆盖后标注来源(面板不再提供输入框)
506
- R.createElement('p', { style: styles.muted }, `服务地址:${status?.baseUrl ?? '(未解析)'}${status?.baseUrlSource === 'user' ? '(自定义)' : '(内置)'}`), R.createElement(ResultCard, {
507
- notice,
508
- onApply: applyRemote,
509
- onConfirmModules: confirmModules,
510
- onDismiss: () => setNotice(null),
511
- onInstallPlugins: installPlugins,
512
- onForceUpload: () => upload(true),
513
- }), confirmDialog
514
- ? R.createElement('div', { style: styles.overlay, onClick: () => setConfirmDialog(null) }, R.createElement('div', { style: styles.dialog, onClick: (event) => event.stopPropagation?.() }, R.createElement('strong', null, '同步项目/会话内容?'), R.createElement('p', { style: styles.muted }, `${confirmDialog.warning.map((id) => MODULE_LABELS[id] ?? id).join('、')} 属于项目/会话级,可能包含具体工作内容。确认要跨机器同步吗?`), R.createElement('div', { style: styles.actions }, R.createElement('button', { style: styles.primary, onClick: () => confirmModules(confirmDialog.modules) }, '仍要同步'), R.createElement('button', { style: styles.secondary, onClick: () => setConfirmDialog(null) }, '取消'))))
515
- : null);
516
- }
517
- function apply(ctx) {
518
- try {
519
- ctx.slots.inject('settings.section', () => ctx.slots.register({
520
- name: 'settings.section',
521
- id: 'user-sync',
522
- order: 50,
523
- label: '设置同步',
524
- }, UserSyncSection));
525
533
  }
526
- catch (error) {
527
- // 面板注册失败只影响自己,不抛给 fiber,避免 web boot 失败。
528
- console.error('[dsh-user-sync] 面板注册失败', error);
529
- }
530
- }
531
- exports.apply = apply;
532
- // client 半边的 inject 是服务名(slots / locale),不是包名;
533
- // 不声明 slots 时 ctx.slots 为 undefined,apply 会拿不到服务。
534
- exports.inject = ['slots'];
535
- return module.exports;
536
- },
537
- });
534
+ exports.apply = apply;
535
+ // client 半边的 inject 是服务名(slots / locale),不是包名;
536
+ // 不声明 slots 时 ctx.slots 为 undefined,apply 会拿不到服务。
537
+ exports.inject = ['slots'];
538
+ return module.exports;
539
+ },
540
+ });
541
+ }
542
+ catch (error) {
543
+ // 同一 bundle 被两个 owner 加载(旧 junction 与 npm 包并存)时,loader 会抛
544
+ // duplicate factory registration;第二次注册忽略即可,绝不能让 web boot 失败。
545
+ const message = String(error?.message ?? error);
546
+ if (!message.includes('duplicate factory registration'))
547
+ throw error;
548
+ console.warn('[dsh-user-sync] client bundle 已注册过,忽略重复注册');
549
+ }
538
550
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pandablessing/dsh-user-sync",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "DeepSeek Harness 用户级配置同步插件",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -34,7 +34,9 @@
34
34
  "scripts": {
35
35
  "build": "tsc -p tsconfig.json",
36
36
  "build:server": "tsc -p server/tsconfig.json",
37
- "test": "node --test test/*.test.js"
37
+ "test": "node --test test/*.test.js",
38
+ "publish:npm": "node scripts/publish.mjs",
39
+ "publish:dry": "node scripts/publish.mjs --dry-run"
38
40
  },
39
41
  "license": "MulanPSL-2.0",
40
42
  "keywords": [