dsh-config-manager 0.1.12 → 0.1.13
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 +30 -0
- package/README.zh-CN.md +30 -0
- package/lib/adapters/test-helpers.d.ts +4 -2
- package/lib/adapters/test-helpers.js +11 -3
- package/lib/adapters/test-helpers.js.map +1 -1
- package/lib/cli/index.d.ts +44 -0
- package/lib/cli/index.js +230 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client.d.ts +260 -5
- package/lib/client.js +2519 -820
- package/lib/client.js.map +1 -1
- package/lib/core/analyzer.d.ts +18 -1
- package/lib/core/analyzer.js +29 -0
- package/lib/core/analyzer.js.map +1 -1
- package/lib/core/backup.d.ts +3 -1
- package/lib/core/backup.js +70 -0
- package/lib/core/backup.js.map +1 -1
- package/lib/core/exporter.d.ts +11 -0
- package/lib/core/exporter.js +11 -0
- package/lib/core/exporter.js.map +1 -1
- package/lib/core/importer.d.ts +3 -0
- package/lib/core/importer.js +1 -0
- package/lib/core/importer.js.map +1 -1
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +1 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/restore.d.ts +92 -0
- package/lib/core/restore.js +505 -0
- package/lib/core/restore.js.map +1 -0
- package/lib/core/run-registry.d.ts +65 -0
- package/lib/core/run-registry.js +120 -0
- package/lib/core/run-registry.js.map +1 -0
- package/lib/core/types.d.ts +20 -0
- package/lib/core/types.js.map +1 -1
- package/lib/index.d.ts +45 -0
- package/lib/index.js +442 -8
- package/lib/index.js.map +1 -1
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +47 -0
- package/lib/sync/fs.js.map +1 -0
- package/lib/sync/git/git-transport.d.ts +87 -0
- package/lib/sync/git/git-transport.js +333 -0
- package/lib/sync/git/git-transport.js.map +1 -0
- package/lib/sync/layout.d.ts +29 -0
- package/lib/sync/layout.js +166 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/sync-config.d.ts +16 -0
- package/lib/sync/sync-config.js +67 -0
- package/lib/sync/sync-config.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +96 -0
- package/lib/sync/sync-engine.js +236 -0
- package/lib/sync/sync-engine.js.map +1 -0
- package/lib/sync/sync-state.d.ts +31 -0
- package/lib/sync/sync-state.js +103 -0
- package/lib/sync/sync-state.js.map +1 -0
- package/lib/sync/transport.d.ts +50 -0
- package/lib/sync/transport.js +22 -0
- package/lib/sync/transport.js.map +1 -0
- package/package.json +4 -1
- package/src/adapters/test-helpers.ts +10 -3
- package/src/cli/index.ts +278 -0
- package/src/client/ConfigManagerSection.tsx +49 -14
- package/src/client/api.ts +53 -5
- package/src/client/client-types.ts +0 -6
- package/src/client/common/ProgressBar.tsx +31 -19
- package/src/client/common/progress-view-edge.test.ts +60 -0
- package/src/client/common/progress-view.test.ts +79 -0
- package/src/client/common/progress-view.ts +94 -0
- package/src/client/config-manager.module.css +99 -20
- package/src/client/export/ExportView.tsx +63 -49
- package/src/client/import/ImportWizardView.tsx +107 -66
- package/src/client/index.ts +22 -0
- package/src/client/locales.ts +53 -0
- package/src/client/run-store.test.ts +500 -0
- package/src/client/run-store.ts +772 -0
- package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
- package/src/client/sync/SyncSettingsView.tsx +261 -0
- package/src/client/sync/sync-api.test.ts +117 -0
- package/src/client/sync/sync-api.ts +132 -0
- package/src/client/sync/sync-locales.ts +77 -0
- package/src/client/sync/sync-view.test.ts +211 -0
- package/src/client/sync/sync-view.ts +202 -0
- package/src/core/analyzer.ts +50 -1
- package/src/core/backup.ts +78 -2
- package/src/core/exporter.ts +21 -0
- package/src/core/importer.ts +4 -0
- package/src/core/index.ts +5 -0
- package/src/core/restore.ts +587 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +139 -0
- package/src/core/run-registry.ts +166 -0
- package/src/core/smoke.test.ts +5 -0
- package/src/core/types.ts +22 -0
- package/src/index.ts +476 -8
- package/src/security/security.test.ts +5 -0
- package/src/sync/fs.ts +46 -0
- package/src/sync/git/git-transport.test.ts +456 -0
- package/src/sync/git/git-transport.ts +390 -0
- package/src/sync/layout.test.ts +226 -0
- package/src/sync/layout.ts +183 -0
- package/src/sync/sync-config.ts +72 -0
- package/src/sync/sync-engine.test.ts +299 -0
- package/src/sync/sync-engine.ts +318 -0
- package/src/sync/sync-state.test.ts +118 -0
- package/src/sync/sync-state.ts +123 -0
- package/src/sync/transport.test.ts +110 -0
- package/src/sync/transport.ts +73 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程同步浏览器半 —— `/api/dsh-config-manager/sync/*` 的类型化 fetch 封装。
|
|
3
|
+
*
|
|
4
|
+
* 独立于 `../api.ts`(ConfigManagerApi 为并行会话已改文件,禁碰):本文件自持
|
|
5
|
+
* 端点常量与 readJson/postJson 小工具(与 api.ts 同款模式),只新增不修改。
|
|
6
|
+
*
|
|
7
|
+
* 端点契约(Host 半 src/index.ts 的 makeRoutes 按此实现):
|
|
8
|
+
* ```
|
|
9
|
+
* GET /api/dsh-config-manager/sync/status → SyncStatusResponse (配置/凭据/上次同步)
|
|
10
|
+
* POST /api/dsh-config-manager/sync/push → SyncPushReport (body: { repoUrl, gitBin?, token?, snapshotId? })
|
|
11
|
+
* POST /api/dsh-config-manager/sync/pull → SyncPullReport (body: { repoUrl, gitBin?, token?, strategy?, snapshotId? })
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* 安全约束:
|
|
15
|
+
* - token 只存在于请求体内(同源 loopback,与导入 secretInputs 同策略),由 Host 写入
|
|
16
|
+
* DSH credentials(credentialRef),绝不落同步文件/日志/URL;响应永不回传 token;
|
|
17
|
+
* - 错误消息由 Host 侧已脱敏(GitTransport 统一 [REDACTED]),UI 侧再经 ErrorBanner redact 兜底;
|
|
18
|
+
* - 本文件不 import 任何 node 模块(纯浏览器 bundle;sync-engine 仅作 type-only 引用)。
|
|
19
|
+
*/
|
|
20
|
+
import type { SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts';
|
|
21
|
+
import { ConfigManagerApiError } from '../api.ts';
|
|
22
|
+
|
|
23
|
+
/** 同步端点常量(与 Host 半 src/index.ts API 常量保持一致) */
|
|
24
|
+
export const SYNC_API = {
|
|
25
|
+
base: '/api/dsh-config-manager/sync',
|
|
26
|
+
status: '/api/dsh-config-manager/sync/status',
|
|
27
|
+
push: '/api/dsh-config-manager/sync/push',
|
|
28
|
+
pull: '/api/dsh-config-manager/sync/pull',
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
/** DSH credentials 中的同步 token 引用名(Host 半同值;仅供提示文案使用,值由 Host 读写) */
|
|
32
|
+
export const SYNC_CREDENTIAL_REF = 'DSH_CONFIG_MANAGER_SYNC_TOKEN';
|
|
33
|
+
|
|
34
|
+
/** GET /sync/status 响应:配置/凭据/上次同步的只读事实(无任何 secret 值) */
|
|
35
|
+
export interface SyncStatusResponse {
|
|
36
|
+
ok: boolean;
|
|
37
|
+
/** 是否已保存过仓库配置(sync-config.json) */
|
|
38
|
+
configured: boolean;
|
|
39
|
+
/** 上次使用的仓库地址(不含 token,可回显) */
|
|
40
|
+
repoUrl?: string;
|
|
41
|
+
gitBin?: string;
|
|
42
|
+
/** DSH credentials 中是否已存在 token(describe 只报状态,值永不返回) */
|
|
43
|
+
credentialConfigured: boolean;
|
|
44
|
+
credentialWritable: boolean;
|
|
45
|
+
/** sync-state.lastSyncAt;'' = 从未同步 */
|
|
46
|
+
lastSyncAt?: string;
|
|
47
|
+
/** sync-state.sections 条目数 */
|
|
48
|
+
sectionCount: number;
|
|
49
|
+
transport?: { type: string; ref: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** push 请求体(token 可选:非空则 Host 先写入 DSH credentials 再使用) */
|
|
53
|
+
export interface SyncPushPayload {
|
|
54
|
+
repoUrl: string;
|
|
55
|
+
gitBin?: string;
|
|
56
|
+
token?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** pull 请求体(strategy 缺省 merge:冲突保留待决策;snapshotId 缺省 = 最新) */
|
|
60
|
+
export interface SyncPullPayload extends SyncPushPayload {
|
|
61
|
+
strategy?: 'merge' | 'replace' | 'skipExisting';
|
|
62
|
+
snapshotId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 同步请求超时(ms):与 Host 半 ROUTE_TIMEOUT_MS 对齐(git 网络操作可能较慢) */
|
|
66
|
+
const SYNC_TIMEOUT_MS = 5 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
/** 解析 JSON 响应;非 2xx 时抛出带路由 error 消息的 ConfigManagerApiError(与 api.ts 同款) */
|
|
69
|
+
async function readJson<T>(response: Response): Promise<T> {
|
|
70
|
+
const notMountedMessage =
|
|
71
|
+
'config-manager 服务未挂载(插件未加载):请确认 profile 中已安装 dsh-config-manager 并重启 DSH';
|
|
72
|
+
let body: unknown;
|
|
73
|
+
try {
|
|
74
|
+
body = await response.json();
|
|
75
|
+
} catch {
|
|
76
|
+
if (response.status === 404) throw new ConfigManagerApiError(notMountedMessage);
|
|
77
|
+
throw new ConfigManagerApiError(`HTTP ${response.status}: invalid JSON response`);
|
|
78
|
+
}
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const message =
|
|
81
|
+
typeof body === 'object' && body !== null && typeof (body as { error?: unknown }).error === 'string'
|
|
82
|
+
? (body as { error: string }).error
|
|
83
|
+
: response.status === 404
|
|
84
|
+
? notMountedMessage
|
|
85
|
+
: `HTTP ${response.status}`;
|
|
86
|
+
throw new ConfigManagerApiError(message);
|
|
87
|
+
}
|
|
88
|
+
return body as T;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** POST JSON 请求(带超时:宿主卡死时 UI 拿到明确错误而不是永远转圈) */
|
|
92
|
+
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(path, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'content-type': 'application/json' },
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
signal: controller.signal,
|
|
101
|
+
});
|
|
102
|
+
return await readJson<T>(response);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (controller.signal.aborted) {
|
|
105
|
+
throw new ConfigManagerApiError(
|
|
106
|
+
`同步请求超时(${Math.round(SYNC_TIMEOUT_MS / 60000)} 分钟):请检查网络与仓库可达性后重试`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 远程同步浏览器半数据入口(settings.section: config-manager-sync 的注入业务面) */
|
|
116
|
+
export class SyncApi {
|
|
117
|
+
/** 读取同步状态(配置 / 凭据 / 上次同步时间 / 分区数) */
|
|
118
|
+
async status(): Promise<SyncStatusResponse> {
|
|
119
|
+
const response = await fetch(SYNC_API.status);
|
|
120
|
+
return readJson<SyncStatusResponse>(response);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 推送:导出 portable 分区 → 提交到私有 Git 仓库 → 更新 sync-state */
|
|
124
|
+
async push(payload: SyncPushPayload): Promise<SyncPushReport> {
|
|
125
|
+
return postJson<SyncPushReport>(SYNC_API.push, payload);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 拉取差异预览:拉取远端最新快照 → 只读分析(绝不执行导入) */
|
|
129
|
+
async pull(payload: SyncPullPayload): Promise<SyncPullReport> {
|
|
130
|
+
return postJson<SyncPullReport>(SYNC_API.pull, payload);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程同步设置区块(config-manager-sync)表面文案:zh 为源语言,en 镜像每个键。
|
|
3
|
+
* 独立命名空间、独立文件:不触碰共享的 locales.ts(并行会话已改),零冲突。
|
|
4
|
+
* 键集合经 `SyncKey` 类型在 client/index.ts 注册处做编译期校验。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const zh = {
|
|
8
|
+
// 入口
|
|
9
|
+
'section.label': '远程同步',
|
|
10
|
+
'section.description': '通过 Git 私有仓库在设备间同步可移植配置(secret 永不参与同步)',
|
|
11
|
+
// 私有仓库强制提示(常驻警示横幅)
|
|
12
|
+
'privateRepoHint': '安全要求:同步仓库必须为私有仓库(public 仓库会公开你的配置内容)。认证 token 仅用于仓库访问,绝不写入同步文件、提交内容或日志。',
|
|
13
|
+
// 仓库配置表单
|
|
14
|
+
'config.title': '仓库配置',
|
|
15
|
+
'config.repoUrl': '仓库地址',
|
|
16
|
+
'config.repoUrlHint': 'Git 私有仓库地址(https / ssh / 本地路径)。认证 token 请使用下方凭据字段,不要拼入地址。',
|
|
17
|
+
'config.token': '认证 token',
|
|
18
|
+
'config.tokenHint': '将安全写入 DSH credentials(引用名 {ref}),不会写入同步文件或日志。留空表示沿用已保存的凭据。',
|
|
19
|
+
'config.tokenSaved': '凭据已配置',
|
|
20
|
+
'config.gitBin': 'git 可执行文件(可选)',
|
|
21
|
+
'config.gitBinHint': '缺省使用 PATH 中的 git。',
|
|
22
|
+
// 状态
|
|
23
|
+
'status.title': '同步状态',
|
|
24
|
+
'status.never': '从未同步',
|
|
25
|
+
// 操作
|
|
26
|
+
'action.push': '推送到远端',
|
|
27
|
+
'action.pull': '拉取差异预览',
|
|
28
|
+
'action.pushing': '正在推送…',
|
|
29
|
+
'action.pulling': '正在拉取…',
|
|
30
|
+
// 报告
|
|
31
|
+
'push.title': '推送结果',
|
|
32
|
+
'pull.title': '拉取差异预览',
|
|
33
|
+
'pull.previewHint': '以上为只读差异预览,不会执行导入。v1 暂不提供一键导入接线;如需应用远端配置,请使用「导入恢复」向导手动导入导出的备份。',
|
|
34
|
+
'pull.needsReview': '包含需要人工决策的项(冲突 / 密钥 / 依赖 / 安装)',
|
|
35
|
+
'pull.empty': '远端快照与本地一致(无变更)',
|
|
36
|
+
'change.total': '共 {total} 项变更',
|
|
37
|
+
'sections.title': '同步分区',
|
|
38
|
+
'warnings.title': '分区告警',
|
|
39
|
+
// 公共
|
|
40
|
+
'common.close': '关闭',
|
|
41
|
+
'common.retry': '重试',
|
|
42
|
+
'common.loading': '加载中…',
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export const en: Record<keyof typeof zh, string> = {
|
|
46
|
+
'section.label': 'Remote Sync',
|
|
47
|
+
'section.description': 'Sync portable configuration across devices via a private Git repository (secrets never sync)',
|
|
48
|
+
'privateRepoHint': 'Security requirement: the sync repository MUST be private (a public repo would expose your configuration). The auth token is only used for repository access and is never written into sync files, commit content, or logs.',
|
|
49
|
+
'config.title': 'Repository',
|
|
50
|
+
'config.repoUrl': 'Repository URL',
|
|
51
|
+
'config.repoUrlHint': 'Private Git repository URL (https / ssh / local path). Use the credential field below for the auth token; never embed it in the URL.',
|
|
52
|
+
'config.token': 'Auth token',
|
|
53
|
+
'config.tokenHint': 'Securely written into DSH credentials (ref {ref}); never written into sync files or logs. Leave blank to reuse the saved credential.',
|
|
54
|
+
'config.tokenSaved': 'Credential configured',
|
|
55
|
+
'config.gitBin': 'git executable (optional)',
|
|
56
|
+
'config.gitBinHint': 'Defaults to git on PATH.',
|
|
57
|
+
'status.title': 'Sync Status',
|
|
58
|
+
'status.never': 'Never synced',
|
|
59
|
+
'action.push': 'Push to remote',
|
|
60
|
+
'action.pull': 'Pull diff preview',
|
|
61
|
+
'action.pushing': 'Pushing…',
|
|
62
|
+
'action.pulling': 'Pulling…',
|
|
63
|
+
'push.title': 'Push Result',
|
|
64
|
+
'pull.title': 'Pull Diff Preview',
|
|
65
|
+
'pull.previewHint': 'Read-only diff preview above; nothing is imported. v1 does not wire one-click import yet — use the Import wizard to apply a downloaded backup if needed.',
|
|
66
|
+
'pull.needsReview': 'Contains items that need human decisions (conflicts / secrets / dependencies / installs)',
|
|
67
|
+
'pull.empty': 'Remote snapshot matches local (no changes)',
|
|
68
|
+
'change.total': '{total} change(s)',
|
|
69
|
+
'sections.title': 'Synced Sections',
|
|
70
|
+
'warnings.title': 'Section Warnings',
|
|
71
|
+
'common.close': 'Close',
|
|
72
|
+
'common.retry': 'Retry',
|
|
73
|
+
'common.loading': 'Loading…',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** 字典键联合(注册处 compile-time 校验) */
|
|
77
|
+
export type SyncKey = keyof typeof zh;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-ui:远程同步区块渲染模型单测(纯函数,node 可测,无需 DOM)。
|
|
3
|
+
* 覆盖验收:报告渲染(push/pull)、按钮状态、私有仓库提示、状态行。
|
|
4
|
+
*/
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
|
|
8
|
+
import type { PullChange, SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts'
|
|
9
|
+
import type { SyncStatusResponse } from './sync-api.ts'
|
|
10
|
+
import {
|
|
11
|
+
PRIVATE_REPO_HINT, computeSyncButtons, computeSyncStatus, formatDateTime, formatLastSync,
|
|
12
|
+
kindLabel, pullReportView, pushReportView, severityLabel, summarizePullChanges,
|
|
13
|
+
} from './sync-view.ts'
|
|
14
|
+
|
|
15
|
+
/* ---------------------------------------------------------------- 私有仓库提示 */
|
|
16
|
+
|
|
17
|
+
test('sync-view: 私有仓库强制提示文案存在且强调私有', () => {
|
|
18
|
+
assert.match(PRIVATE_REPO_HINT, /私有/)
|
|
19
|
+
assert.match(PRIVATE_REPO_HINT, /public/)
|
|
20
|
+
assert.match(PRIVATE_REPO_HINT, /token/)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/* ---------------------------------------------------------------- 按钮状态 */
|
|
24
|
+
|
|
25
|
+
test('sync-view: 空闲 + 仓库地址为空 → 两个按钮都禁用', () => {
|
|
26
|
+
const b = computeSyncButtons(null, '')
|
|
27
|
+
assert.equal(b.canPush, false)
|
|
28
|
+
assert.equal(b.canPull, false)
|
|
29
|
+
assert.equal(b.pushLabel, '推送到远端')
|
|
30
|
+
assert.equal(b.pullLabel, '拉取差异预览')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('sync-view: 空闲 + 有效仓库地址 → 两个按钮可用', () => {
|
|
34
|
+
const b = computeSyncButtons(null, ' https://github.com/u/r.git ')
|
|
35
|
+
assert.equal(b.canPush, true)
|
|
36
|
+
assert.equal(b.canPull, true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('sync-view: push 进行中 → 按钮禁用且文案切换为正在推送(防并发)', () => {
|
|
40
|
+
const b = computeSyncButtons('push', 'https://github.com/u/r.git')
|
|
41
|
+
assert.equal(b.canPush, false)
|
|
42
|
+
assert.equal(b.canPull, false)
|
|
43
|
+
assert.equal(b.pushLabel, '正在推送…')
|
|
44
|
+
assert.equal(b.pullLabel, '拉取差异预览')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('sync-view: pull 进行中 → 两个按钮都禁用,pull 文案切换', () => {
|
|
48
|
+
const b = computeSyncButtons('pull', 'https://github.com/u/r.git')
|
|
49
|
+
assert.equal(b.canPush, false)
|
|
50
|
+
assert.equal(b.canPull, false)
|
|
51
|
+
assert.equal(b.pullLabel, '正在拉取…')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/* ---------------------------------------------------------------- 变更摘要 */
|
|
55
|
+
|
|
56
|
+
function change(overrides: Partial<PullChange>): PullChange {
|
|
57
|
+
return { id: 'x', adapter: 'settings', kind: 'Update', description: 'd', severity: 'info', ...overrides }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('sync-view: summarizePullChanges 按 severity 计数', () => {
|
|
61
|
+
const summary = summarizePullChanges([
|
|
62
|
+
change({ severity: 'info' }),
|
|
63
|
+
change({ severity: 'info' }),
|
|
64
|
+
change({ severity: 'warning' }),
|
|
65
|
+
change({ severity: 'error' }),
|
|
66
|
+
])
|
|
67
|
+
assert.equal(summary.total, 4)
|
|
68
|
+
assert.equal(summary.info, 2)
|
|
69
|
+
assert.equal(summary.warning, 1)
|
|
70
|
+
assert.equal(summary.error, 1)
|
|
71
|
+
assert.equal(summary.needsReview, false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('sync-view: summarizePullChanges 对冲突/安装/密钥/依赖项标记 needsReview', () => {
|
|
75
|
+
const summary = summarizePullChanges([
|
|
76
|
+
change({ kind: 'Conflict' }),
|
|
77
|
+
change({ kind: 'Install' }),
|
|
78
|
+
])
|
|
79
|
+
assert.equal(summary.needsReview, true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('sync-view: summarizePullChanges 空数组 → total 0 且不需决策', () => {
|
|
83
|
+
const summary = summarizePullChanges([])
|
|
84
|
+
assert.equal(summary.total, 0)
|
|
85
|
+
assert.equal(summary.needsReview, false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('sync-view: kindLabel / severityLabel 覆盖关键类型', () => {
|
|
89
|
+
assert.equal(kindLabel('Conflict'), '冲突')
|
|
90
|
+
assert.equal(kindLabel('Install'), '安装')
|
|
91
|
+
assert.equal(kindLabel('MissingSecret'), '缺密钥')
|
|
92
|
+
assert.equal(severityLabel('error'), '错误')
|
|
93
|
+
assert.equal(severityLabel('warning'), '警告')
|
|
94
|
+
assert.equal(severityLabel('info'), '信息')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/* ---------------------------------------------------------------- push 报告渲染 */
|
|
98
|
+
|
|
99
|
+
test('sync-view: push 成功报告 → ok 头部含快照 id + 分区透传', () => {
|
|
100
|
+
const report: SyncPushReport = { ok: true, snapshotId: 'sync-1', sections: ['settings', 'plugins'], warnings: [] }
|
|
101
|
+
const view = pushReportView(report)
|
|
102
|
+
assert.notEqual(view, null)
|
|
103
|
+
assert.equal(view?.kind, 'ok')
|
|
104
|
+
assert.match(view?.headline ?? '', /sync-1/)
|
|
105
|
+
assert.deepEqual(view?.sections, ['settings', 'plugins'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('sync-view: push 失败报告 → error 显示引擎 message', () => {
|
|
109
|
+
const report: SyncPushReport = { ok: false, snapshotId: '', sections: [], warnings: [], message: '全部导出失败' }
|
|
110
|
+
const view = pushReportView(report)
|
|
111
|
+
assert.equal(view?.kind, 'error')
|
|
112
|
+
assert.equal(view?.headline, '全部导出失败')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('sync-view: null 报告 → null(不渲染卡片)', () => {
|
|
116
|
+
assert.equal(pushReportView(null), null)
|
|
117
|
+
assert.equal(pullReportView(null), null)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/* ---------------------------------------------------------------- pull 报告渲染 */
|
|
121
|
+
|
|
122
|
+
test('sync-view: pull 无变更 → empty 渲染(无差异列表)', () => {
|
|
123
|
+
const report: SyncPullReport = { ok: true, snapshotId: 'sync-1', changes: [], needsReview: false }
|
|
124
|
+
const view = pullReportView(report)
|
|
125
|
+
assert.equal(view?.kind, 'empty')
|
|
126
|
+
assert.equal(view?.summary, null)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('sync-view: pull 差异报告 → ok + 摘要计数 + needsReview + 只读预览提示', () => {
|
|
130
|
+
const report: SyncPullReport = {
|
|
131
|
+
ok: true,
|
|
132
|
+
snapshotId: 'sync-9',
|
|
133
|
+
changes: [
|
|
134
|
+
change({ id: 'settings:a', kind: 'Update', description: '更新设置 a', severity: 'info' }),
|
|
135
|
+
change({ id: 'plugin:x', kind: 'Conflict', adapter: 'plugins', description: '插件 x 冲突', severity: 'warning' }),
|
|
136
|
+
],
|
|
137
|
+
needsReview: true,
|
|
138
|
+
}
|
|
139
|
+
const view = pullReportView(report)
|
|
140
|
+
assert.equal(view?.kind, 'ok')
|
|
141
|
+
assert.match(view?.headline ?? '', /sync-9/)
|
|
142
|
+
assert.equal(view?.summary?.total, 2)
|
|
143
|
+
assert.equal(view?.summary?.needsReview, true)
|
|
144
|
+
assert.equal(view?.summary?.items[0]?.description, '更新设置 a')
|
|
145
|
+
assert.equal(view?.summary?.items[1]?.kind, 'Conflict')
|
|
146
|
+
assert.notEqual(view?.previewHint, '')
|
|
147
|
+
assert.match(view?.previewHint, /不会执行导入/)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('sync-view: pull 失败 → error 渲染', () => {
|
|
151
|
+
const report: SyncPullReport = { ok: false, snapshotId: '', changes: [], needsReview: false, message: '认证失败' }
|
|
152
|
+
const view = pullReportView(report)
|
|
153
|
+
assert.equal(view?.kind, 'error')
|
|
154
|
+
assert.equal(view?.headline, '认证失败')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/* ---------------------------------------------------------------- 状态行 */
|
|
158
|
+
|
|
159
|
+
test('sync-view: 加载中 → loading 状态', () => {
|
|
160
|
+
const s = computeSyncStatus(null, true, null)
|
|
161
|
+
assert.equal(s.kind, 'loading')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('sync-view: 加载失败 → error 状态带消息', () => {
|
|
165
|
+
const s = computeSyncStatus(null, false, '网络错误')
|
|
166
|
+
assert.equal(s.kind, 'error')
|
|
167
|
+
assert.equal(s.text, '网络错误')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('sync-view: 未配置仓库 → unconfigured 提示', () => {
|
|
171
|
+
const s = computeSyncStatus(null, false, null)
|
|
172
|
+
assert.equal(s.kind, 'unconfigured')
|
|
173
|
+
assert.match(s.text, /尚未配置/)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('sync-view: 已配置但缺凭据 → ready 文案提示未配置凭据', () => {
|
|
177
|
+
const info: SyncStatusResponse = {
|
|
178
|
+
ok: true, configured: true, repoUrl: 'https://github.com/u/r.git',
|
|
179
|
+
credentialConfigured: false, credentialWritable: true, lastSyncAt: undefined, sectionCount: 0,
|
|
180
|
+
}
|
|
181
|
+
const s = computeSyncStatus(info, false, null)
|
|
182
|
+
assert.equal(s.kind, 'ready')
|
|
183
|
+
assert.match(s.text, /未配置凭据/)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('sync-view: 已配置 + 凭据就绪 + 上次同步 → ready 文案含日期与通道', () => {
|
|
187
|
+
const info: SyncStatusResponse = {
|
|
188
|
+
ok: true, configured: true, repoUrl: 'https://github.com/u/r.git',
|
|
189
|
+
credentialConfigured: true, credentialWritable: true,
|
|
190
|
+
lastSyncAt: '2026-08-16T10:30:00.000Z', sectionCount: 3,
|
|
191
|
+
transport: { type: 'git', ref: 'main' },
|
|
192
|
+
}
|
|
193
|
+
const s = computeSyncStatus(info, false, null)
|
|
194
|
+
assert.equal(s.kind, 'ready')
|
|
195
|
+
assert.match(s.text, /凭据已配置/)
|
|
196
|
+
assert.match(s.text, /上次同步/)
|
|
197
|
+
assert.match(s.text, /git\/main/)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
/* ---------------------------------------------------------------- 时间格式化 */
|
|
201
|
+
|
|
202
|
+
test('sync-view: formatLastSync 空值 → 从未同步', () => {
|
|
203
|
+
assert.equal(formatLastSync(undefined), '从未同步')
|
|
204
|
+
assert.equal(formatLastSync(''), '从未同步')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('sync-view: formatLastSync / formatDateTime 合法 ISO → 本地可读格式', () => {
|
|
208
|
+
const text = formatLastSync('2026-08-16T10:30:00.000Z')
|
|
209
|
+
assert.match(text, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
|
210
|
+
assert.equal(formatDateTime('not-a-date'), 'not-a-date')
|
|
211
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程同步区块的纯渲染模型(m-sync-ui)。
|
|
3
|
+
*
|
|
4
|
+
* 与 src/ui/progress.ts → progress-view.ts 同模式:把「报告怎么渲染 / 按钮什么状态 /
|
|
5
|
+
* 状态行写什么 / 私有仓库提示怎么展示」做成无副作用纯函数,node --test 直接测,
|
|
6
|
+
* React 组件只做装配。文案直接用中文(项目源语言),组件不重复造。
|
|
7
|
+
*/
|
|
8
|
+
import type { PlanItem, PlanItemKind } from '../../core/types.ts';
|
|
9
|
+
import type { PullChange, SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts';
|
|
10
|
+
import type { SyncStatusResponse } from './sync-api.ts';
|
|
11
|
+
|
|
12
|
+
/* ---------------------------------------------------------------- 私有仓库提示 */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 私有仓库强制提示文案(Settings 区块常驻警示横幅)。
|
|
16
|
+
* 安全约束:同步内容为可移植配置,public 仓库会公开配置 → 必须私有;
|
|
17
|
+
* token 仅用于认证,绝不写入同步文件/提交内容/日志。
|
|
18
|
+
*/
|
|
19
|
+
export const PRIVATE_REPO_HINT =
|
|
20
|
+
'安全要求:同步仓库必须为私有仓库(public 仓库会公开你的配置内容)。认证 token 仅用于仓库访问,绝不写入同步文件、提交内容或日志。';
|
|
21
|
+
|
|
22
|
+
/* ---------------------------------------------------------------- 变更摘要 */
|
|
23
|
+
|
|
24
|
+
/** 需要人工决策的 PlanItem 类型(与 SyncEngine.pull 的 needsReview 判定一致) */
|
|
25
|
+
const REVIEW_KINDS: ReadonlySet<PlanItemKind> = new Set([
|
|
26
|
+
'Conflict', 'MissingSecret', 'MissingDependency', 'Install', 'Error',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export interface PullChangeSummary {
|
|
30
|
+
total: number;
|
|
31
|
+
info: number;
|
|
32
|
+
warning: number;
|
|
33
|
+
error: number;
|
|
34
|
+
/** 是否包含需要人工决策的项(冲突/密钥/依赖/安装/错误) */
|
|
35
|
+
needsReview: boolean;
|
|
36
|
+
items: PullChange[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 差异摘要:按 severity 计数 + 需人工决策标记(UI 统计徽章与警示横幅的数据源) */
|
|
40
|
+
export function summarizePullChanges(changes: readonly PullChange[]): PullChangeSummary {
|
|
41
|
+
let info = 0;
|
|
42
|
+
let warning = 0;
|
|
43
|
+
let error = 0;
|
|
44
|
+
let needsReview = false;
|
|
45
|
+
for (const c of changes) {
|
|
46
|
+
if (c.severity === 'error') error += 1;
|
|
47
|
+
else if (c.severity === 'warning') warning += 1;
|
|
48
|
+
else info += 1;
|
|
49
|
+
if (REVIEW_KINDS.has(c.kind)) needsReview = true;
|
|
50
|
+
}
|
|
51
|
+
return { total: changes.length, info, warning, error, needsReview, items: [...changes] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** PlanItemKind → 短标签(列表徽章) */
|
|
55
|
+
export function kindLabel(kind: PlanItemKind): string {
|
|
56
|
+
switch (kind) {
|
|
57
|
+
case 'Create': return '新增';
|
|
58
|
+
case 'Update': return '更新';
|
|
59
|
+
case 'Skip': return '跳过';
|
|
60
|
+
case 'Conflict': return '冲突';
|
|
61
|
+
case 'Install': return '安装';
|
|
62
|
+
case 'MissingSecret': return '缺密钥';
|
|
63
|
+
case 'MissingDependency': return '缺依赖';
|
|
64
|
+
case 'PathMapping': return '路径映射';
|
|
65
|
+
case 'Warning': return '警告';
|
|
66
|
+
case 'Error': return '错误';
|
|
67
|
+
default: return kind;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** severity → 短标签 */
|
|
72
|
+
export function severityLabel(severity: PlanItem['severity']): string {
|
|
73
|
+
switch (severity) {
|
|
74
|
+
case 'error': return '错误';
|
|
75
|
+
case 'warning': return '警告';
|
|
76
|
+
default: return '信息';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ---------------------------------------------------------------- 按钮状态 */
|
|
81
|
+
|
|
82
|
+
export interface SyncButtons {
|
|
83
|
+
canPush: boolean;
|
|
84
|
+
canPull: boolean;
|
|
85
|
+
pushLabel: string;
|
|
86
|
+
pullLabel: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 按钮可用性与文案:
|
|
91
|
+
* - 任一操作进行中(busy)→ 两个按钮都禁用(防并发 push/pull);
|
|
92
|
+
* - 仓库地址为空 → 禁用(无仓库无从同步);
|
|
93
|
+
* - busy 时按钮文案切换为「正在推送/拉取…」(配 Spinner)。
|
|
94
|
+
*/
|
|
95
|
+
export function computeSyncButtons(busy: 'push' | 'pull' | null, repoUrl: string): SyncButtons {
|
|
96
|
+
const idle = busy === null;
|
|
97
|
+
const repoOk = repoUrl.trim() !== '';
|
|
98
|
+
const enabled = idle && repoOk;
|
|
99
|
+
return {
|
|
100
|
+
canPush: enabled,
|
|
101
|
+
canPull: enabled,
|
|
102
|
+
pushLabel: busy === 'push' ? '正在推送…' : '推送到远端',
|
|
103
|
+
pullLabel: busy === 'pull' ? '正在拉取…' : '拉取差异预览',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---------------------------------------------------------------- 状态行 */
|
|
108
|
+
|
|
109
|
+
export type SyncStatusKind = 'loading' | 'unconfigured' | 'ready' | 'error';
|
|
110
|
+
|
|
111
|
+
export interface SyncStatusSummary {
|
|
112
|
+
kind: SyncStatusKind;
|
|
113
|
+
text: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 状态行渲染模型:加载 / 未配置 / 就绪(凭据 + 上次同步 + 通道)/ 错误 */
|
|
117
|
+
export function computeSyncStatus(
|
|
118
|
+
statusInfo: SyncStatusResponse | null,
|
|
119
|
+
loading: boolean,
|
|
120
|
+
error: string | null,
|
|
121
|
+
): SyncStatusSummary {
|
|
122
|
+
if (loading) return { kind: 'loading', text: '正在读取同步状态…' };
|
|
123
|
+
if (error !== null) return { kind: 'error', text: error };
|
|
124
|
+
if (statusInfo === null || !statusInfo.configured) {
|
|
125
|
+
return { kind: 'unconfigured', text: '尚未配置同步仓库(请填写仓库地址并推送一次以保存配置)' };
|
|
126
|
+
}
|
|
127
|
+
const cred = statusInfo.credentialConfigured
|
|
128
|
+
? '凭据已配置'
|
|
129
|
+
: '未配置凭据(token 将在首次推送/拉取时写入 DSH credentials)';
|
|
130
|
+
const last =
|
|
131
|
+
statusInfo.lastSyncAt !== undefined && statusInfo.lastSyncAt !== ''
|
|
132
|
+
? `上次同步:${formatDateTime(statusInfo.lastSyncAt)}`
|
|
133
|
+
: '尚未同步过';
|
|
134
|
+
const transport =
|
|
135
|
+
statusInfo.transport !== undefined
|
|
136
|
+
? ` · ${statusInfo.transport.type}${statusInfo.transport.ref !== '' ? `/${statusInfo.transport.ref}` : ''}`
|
|
137
|
+
: '';
|
|
138
|
+
return { kind: 'ready', text: `${cred} · ${last}${transport}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** ISO-8601 → 本地可读时间(YYYY-MM-DD HH:mm;非法输入原样返回) */
|
|
142
|
+
export function formatDateTime(iso: string): string {
|
|
143
|
+
const d = new Date(iso);
|
|
144
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
145
|
+
const pad = (n: number): string => String(n).padStart(2, '0');
|
|
146
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 上次同步时间的展示文本('' / undefined = 从未同步) */
|
|
150
|
+
export function formatLastSync(iso: string | undefined): string {
|
|
151
|
+
if (iso === undefined || iso === '') return '从未同步';
|
|
152
|
+
return formatDateTime(iso);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ---------------------------------------------------------------- 报告渲染模型 */
|
|
156
|
+
|
|
157
|
+
export interface PushReportView {
|
|
158
|
+
kind: 'ok' | 'error';
|
|
159
|
+
headline: string;
|
|
160
|
+
sections: string[];
|
|
161
|
+
warnings: string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** push 报告 → 渲染模型(ok 头部带快照 id;失败显示引擎 message;分区与告警透传) */
|
|
165
|
+
export function pushReportView(report: SyncPushReport | null): PushReportView | null {
|
|
166
|
+
if (report === null) return null;
|
|
167
|
+
if (!report.ok) {
|
|
168
|
+
return { kind: 'error', headline: report.message ?? '推送失败', sections: report.sections, warnings: report.warnings };
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
kind: 'ok',
|
|
172
|
+
headline: `推送成功(快照 ${report.snapshotId})`,
|
|
173
|
+
sections: report.sections,
|
|
174
|
+
warnings: report.warnings,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface PullReportView {
|
|
179
|
+
kind: 'ok' | 'empty' | 'error';
|
|
180
|
+
headline: string;
|
|
181
|
+
summary: PullChangeSummary | null;
|
|
182
|
+
/** 只读预览提示(ok 时非空:明确「预览不执行导入」) */
|
|
183
|
+
previewHint: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** pull 报告 → 渲染模型(差异预览;empty = 无变更;error = 拉取失败) */
|
|
187
|
+
export function pullReportView(report: SyncPullReport | null): PullReportView | null {
|
|
188
|
+
if (report === null) return null;
|
|
189
|
+
if (!report.ok) {
|
|
190
|
+
return { kind: 'error', headline: report.message ?? '拉取失败', summary: null, previewHint: '' };
|
|
191
|
+
}
|
|
192
|
+
if (report.changes.length === 0) {
|
|
193
|
+
return { kind: 'empty', headline: report.message ?? '远端快照与本地一致(无变更)', summary: null, previewHint: '' };
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
kind: 'ok',
|
|
197
|
+
headline: `远端快照 ${report.snapshotId} 差异预览:共 ${report.changes.length} 项变更`,
|
|
198
|
+
summary: summarizePullChanges(report.changes),
|
|
199
|
+
previewHint:
|
|
200
|
+
'以上为只读差异预览,不会执行导入。v1 暂不提供一键导入接线;如需应用远端配置,请使用「导入恢复」向导手动导入导出的备份。',
|
|
201
|
+
};
|
|
202
|
+
}
|