dsh-config-manager 0.1.84 → 0.1.85
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/lib/client.d.ts +153 -399
- package/lib/client.js +1522 -2225
- package/lib/core/messages.d.ts +1 -0
- package/lib/core/messages.js +2 -0
- package/lib/index.d.ts +4 -2
- package/lib/index.js +5 -5
- package/lib/routes/sync.js +92 -70
- package/lib/sync/git/git-transport.d.ts +2 -0
- package/lib/sync/git/git-transport.js +75 -22
- package/lib/sync/messages.d.ts +2 -0
- package/lib/sync/messages.js +4 -0
- package/lib/sync/sync-engine.d.ts +4 -4
- package/lib/sync/sync-engine.js +3 -4
- package/lib/ui/i18n.d.ts +2 -84
- package/lib/ui/i18n.js +4 -168
- package/package.json +1 -1
- package/src/client/ConfigManagerSection.tsx +1 -18
- package/src/client/about/AboutPanel.tsx +4 -11
- package/src/client/about/ReleaseNotesDialog.tsx +1 -2
- package/src/client/chat/chat-locales.ts +0 -8
- package/src/client/common/CommandPalette.tsx +0 -1
- package/src/client/common/ContentPicker.tsx +10 -1
- package/src/client/common/info-hint-guard.test.ts +4 -4
- package/src/client/config-manager.module.css +2 -32
- package/src/client/environment/EnvironmentPanel.tsx +10 -9
- package/src/client/environment/maintenance/DiskUsageCard.tsx +10 -10
- package/src/client/export/ExportView.tsx +10 -13
- package/src/client/history/HistoryPanel.tsx +9 -11
- package/src/client/history/history-locales.ts +4 -40
- package/src/client/home/BackupScheduleCard.tsx +0 -2
- package/src/client/home/HomePanel.tsx +75 -49
- package/src/client/import/ForeignImportView.tsx +6 -7
- package/src/client/import/ImportWizardView.tsx +8 -9
- package/src/client/import/import-wizard-steps.tsx +20 -15
- package/src/client/library/BackupInspectView.tsx +3 -11
- package/src/client/library/LibraryActions.tsx +2 -1
- package/src/client/library/LibraryPanel.tsx +6 -3
- package/src/client/locales.ts +122 -278
- package/src/client/market/MarketImportReview.tsx +6 -5
- package/src/client/market/MarketPanel.tsx +2 -2
- package/src/client/market/MyConfigsLoginCard.tsx +0 -3
- package/src/client/market/MyConfigsView.tsx +3 -4
- package/src/client/market/MyConfigsWizard.tsx +8 -4
- package/src/client/market/market-locales.ts +20 -60
- package/src/client/recovery/IncompleteCopiesSection.tsx +6 -8
- package/src/client/recovery/RecoveryPanel.tsx +48 -73
- package/src/client/recovery/RollbackCard.tsx +27 -20
- package/src/client/recovery/recovery-locales.test.ts +1 -3
- package/src/client/recovery/recovery-locales.ts +71 -133
- package/src/client/recovery/recovery-panel-layout.test.ts +4 -2
- package/src/client/sync/DecryptPasswordCard.tsx +2 -2
- package/src/client/sync/MergeModal.tsx +3 -1
- package/src/client/sync/MergePreviewView.tsx +14 -16
- package/src/client/sync/PushPreviewCard.tsx +0 -1
- package/src/client/sync/SecurityOptionsCard.tsx +5 -1
- package/src/client/sync/SyncChannelEntryCard.tsx +3 -10
- package/src/client/sync/SyncConfirmView.tsx +23 -20
- package/src/client/sync/SyncLogList.tsx +5 -2
- package/src/client/sync/SyncPanel.tsx +10 -9
- package/src/client/sync/SyncProbeRow.tsx +6 -8
- package/src/client/sync/SyncSectionPickerDialog.tsx +3 -3
- package/src/client/sync/WorkDirCard.tsx +6 -7
- package/src/client/sync/preview-failure.test.ts +58 -0
- package/src/client/sync/preview-failure.ts +47 -0
- package/src/client/sync/sync-api.ts +19 -3
- package/src/client/sync/sync-entry.test.ts +6 -0
- package/src/client/sync/sync-entry.ts +4 -1
- package/src/client/sync/sync-locales.ts +31 -147
- package/src/client/sync/sync-push-preview.test.ts +1 -1
- package/src/client/sync/sync-view.ts +1 -4
- package/src/core/messages.ts +2 -0
- package/src/index.ts +6 -6
- package/src/routes/sync.ts +91 -69
- package/src/sync/git/git-clone-auth.test.ts +61 -0
- package/src/sync/git/git-transport.test.ts +75 -1
- package/src/sync/git/git-transport.ts +74 -20
- package/src/sync/messages.ts +4 -0
- package/src/sync/sync-engine.ts +6 -6
- package/src/sync/sync-preview-empty.test.ts +58 -0
- package/src/ui/i18n.ts +4 -168
package/lib/core/messages.d.ts
CHANGED
|
@@ -172,6 +172,7 @@ export declare const zh: {
|
|
|
172
172
|
readonly 'host.exportTimeout': '导出超时(5 分钟):导出过程未完成,请重试;若反复超时请检查 profile 依赖状态';
|
|
173
173
|
readonly 'host.syncPushTimeout': '同步推送超时(5 分钟):请检查网络与仓库可达性后重试';
|
|
174
174
|
readonly 'host.syncPullTimeout': '同步拉取超时(5 分钟):请检查网络与仓库可达性后重试';
|
|
175
|
+
readonly 'host.syncStatusTimeout': '同步状态读取超时(10 秒):DSH 凭据服务或工作副本自检没有在预算内返回,请重试';
|
|
175
176
|
readonly 'host.workspaceUnavailable': 'workspaceRegistry 服务不可用,无法写入工作区';
|
|
176
177
|
readonly 'host.workspaceMissingTarget': '目标机不存在工作区 {id}(无法登记会话)';
|
|
177
178
|
readonly 'host.sessionStoreShapeUnknown': '宿主会话存储列举(sessionPersistence.list())的元素形状无法解析(探测形状 {shape},{count} 个元素全部认不出):父子关系判定放弃,绝不当作「本机没有父子关系」(请升级 DSH,或把该形状反馈给插件)';
|
package/lib/core/messages.js
CHANGED
|
@@ -161,6 +161,7 @@ export const zh = {
|
|
|
161
161
|
'host.exportTimeout': '导出超时(5 分钟):导出过程未完成,请重试;若反复超时请检查 profile 依赖状态',
|
|
162
162
|
'host.syncPushTimeout': '同步推送超时(5 分钟):请检查网络与仓库可达性后重试',
|
|
163
163
|
'host.syncPullTimeout': '同步拉取超时(5 分钟):请检查网络与仓库可达性后重试',
|
|
164
|
+
'host.syncStatusTimeout': '同步状态读取超时(10 秒):DSH 凭据服务或工作副本自检没有在预算内返回,请重试',
|
|
164
165
|
'host.workspaceUnavailable': 'workspaceRegistry 服务不可用,无法写入工作区',
|
|
165
166
|
'host.workspaceMissingTarget': '目标机不存在工作区 {id}(无法登记会话)',
|
|
166
167
|
'host.sessionStoreShapeUnknown': '宿主会话存储列举(sessionPersistence.list())的元素形状无法解析(探测形状 {shape},{count} 个元素全部认不出):父子关系判定放弃,绝不当作「本机没有父子关系」(请升级 DSH,或把该形状反馈给插件)',
|
|
@@ -650,6 +651,7 @@ export const en = {
|
|
|
650
651
|
'host.exportTimeout': 'Export timed out (5 min): the export did not complete, please retry; if it keeps timing out, check the profile dependency state',
|
|
651
652
|
'host.syncPushTimeout': 'Sync push timed out (5 min): check network and repository reachability, then retry',
|
|
652
653
|
'host.syncPullTimeout': 'Sync pull timed out (5 min): check network and repository reachability, then retry',
|
|
654
|
+
'host.syncStatusTimeout': 'Sync status timed out (10 s): the DSH credential service or work-copy self-check did not return within budget; please retry',
|
|
653
655
|
'host.workspaceUnavailable': 'workspaceRegistry service unavailable; cannot write the workspace',
|
|
654
656
|
'host.workspaceMissingTarget': 'Workspace {id} does not exist on this machine (cannot attach the session)',
|
|
655
657
|
'host.sessionStoreShapeUnknown': 'Cannot parse the elements returned by the host session store (sessionPersistence.list(); probed shape {shape}, none of the {count} elements is recognizable): parent/child detection is abandoned rather than treated as "no parent relations on this machine" (please upgrade DSH or report this shape to the plugin)',
|
package/lib/index.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export declare const name = "config-manager";
|
|
|
77
77
|
/** Services required before the engine can mount (present in every profile). */
|
|
78
78
|
export declare const inject: string[];
|
|
79
79
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
80
|
-
export declare const PLUGIN_VERSION = "0.1.
|
|
80
|
+
export declare const PLUGIN_VERSION = "0.1.85";
|
|
81
81
|
/**
|
|
82
82
|
* Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
|
|
83
83
|
* 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
|
|
@@ -812,7 +812,9 @@ export type RouteEnvInferred = {
|
|
|
812
812
|
syncDir: string;
|
|
813
813
|
syncPasswordConfigured: (ref: string) => Promise<boolean>;
|
|
814
814
|
syncWorkDir: {
|
|
815
|
-
inspect: (repoUrl: string
|
|
815
|
+
inspect: (repoUrl: string, opts?: {
|
|
816
|
+
detail?: boolean;
|
|
817
|
+
}) => Promise<{
|
|
816
818
|
facts: GitWorkDirFacts;
|
|
817
819
|
} | {
|
|
818
820
|
error: string;
|
package/lib/index.js
CHANGED
|
@@ -136,7 +136,7 @@ export const name = 'config-manager';
|
|
|
136
136
|
/** Services required before the engine can mount (present in every profile). */
|
|
137
137
|
export const inject = ['settings', 'credentials'];
|
|
138
138
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
139
|
-
export const PLUGIN_VERSION = '0.1.
|
|
139
|
+
export const PLUGIN_VERSION = '0.1.85';
|
|
140
140
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
141
141
|
const PLUGIN_NAME = 'dsh-config-manager';
|
|
142
142
|
/**
|
|
@@ -2981,12 +2981,12 @@ function makeRoutes(deps) {
|
|
|
2981
2981
|
syncWorkDir: {
|
|
2982
2982
|
// 自检失败(git 不可用 / 工作副本异常)→ 返回 { error },**不让整个状态接口 500**,
|
|
2983
2983
|
// 也不伪装成「工作副本不存在」。
|
|
2984
|
-
inspect: async (repoUrl) => {
|
|
2984
|
+
inspect: async (repoUrl, opts = {}) => {
|
|
2985
2985
|
try {
|
|
2986
2986
|
// issue #104 建议 3:`/sync/status` 要回传「领先远端提交数 / 远端快照数」,
|
|
2987
|
-
//
|
|
2988
|
-
//
|
|
2989
|
-
return { facts: await makeGitTransport(repoUrl).inspectWorkDir({ detail:
|
|
2987
|
+
// 用户看不见积压就会在「以为在备份」的状态下一直失败。detail 那几条是本地只读但各起
|
|
2988
|
+
// 一次 git 进程(Windows 约 80 ms)⇒ 缺省完整(WorkDirCard 要),路由可传 false。
|
|
2989
|
+
return { facts: await makeGitTransport(repoUrl).inspectWorkDir({ detail: opts.detail !== false }) };
|
|
2990
2990
|
}
|
|
2991
2991
|
catch (err) {
|
|
2992
2992
|
return { error: err instanceof Error ? err.message : String(err) };
|
package/lib/routes/sync.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* W1(host-entry#F-02/#F-03):路由只在这里声明一次 —— endpoint({ path, methods }, handler) 的
|
|
5
5
|
* path/methods 就是唯一声明处;围栏、方法判定与顶层异常处理由 src/routes/kit.ts 在注册点统一提供。
|
|
6
6
|
*/
|
|
7
|
-
import { endpoint, requireJsonObject, readJsonBody, writeJson } from './kit.js';
|
|
7
|
+
import { endpoint, queryParam, requireJsonObject, readJsonBody, writeJson } from './kit.js';
|
|
8
8
|
import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/env-lock.js';
|
|
9
9
|
import { FileSnapshotStore } from '../core/index.js';
|
|
10
10
|
import { rollback as performRollback } from '../core/rollback.js';
|
|
@@ -129,81 +129,103 @@ export function syncRoutes(env) {
|
|
|
129
129
|
}
|
|
130
130
|
return out;
|
|
131
131
|
};
|
|
132
|
+
/**
|
|
133
|
+
* /sync/status 的取值体。
|
|
134
|
+
*
|
|
135
|
+
* 只读端点也碰两处**不可控延迟**:DSH credentials 服务(describe/resolve)与 git 工作副本自检
|
|
136
|
+
* (子进程 + 工作副本锁)。任一处永不返回时,裸 await 会让 HTTP 响应永不被写出 ——
|
|
137
|
+
* 首页首屏会永久停在骨架(2026-10 现场:/sync/status 300 s 无响应,其余只读端点均毫秒级)。
|
|
138
|
+
* ⇒ 整个组装套总预算,超时如实报 503,保证请求有界返回。
|
|
139
|
+
*/
|
|
140
|
+
const SYNC_STATUS_BUDGET_MS = 10_000;
|
|
141
|
+
/**
|
|
142
|
+
* @param workDirDetail 是否连「领先远端提交数 / 未提交改动 / 远端快照数」一起自检。
|
|
143
|
+
* 那三项多起 5 条 git 子进程(Windows 每次约 80 ms;实测完整自检 585 ms、端点 730 ms)。
|
|
144
|
+
* 只有同步页的 WorkDirCard 渲染它们 —— 首页与产物库只问「工作副本是否跟随配置地址」。
|
|
145
|
+
*/
|
|
146
|
+
const collectSyncStatus = async (opts) => {
|
|
147
|
+
// 完整双命名空间配置:repoUrl / webdav.url 无论当前通道都回填,
|
|
148
|
+
// 保证 UI 在 git ↔ webdav 间切换时另一通道的地址不丢失
|
|
149
|
+
const full = await readFullSyncConfig(syncDir);
|
|
150
|
+
const state = await loadSyncState(syncDir);
|
|
151
|
+
const [cred, webdavCred] = await Promise.all([
|
|
152
|
+
credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
|
|
153
|
+
credentials.describe(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF)),
|
|
154
|
+
]);
|
|
155
|
+
const transport = parseSyncChannel(full?.transport) ?? 'git';
|
|
156
|
+
// m-self:插件 UI 偏好(上次选择的同步通道;ui-prefs.json,随 self 分区进备份)
|
|
157
|
+
const uiPrefs = await readUiPrefs(syncDir);
|
|
158
|
+
// webdav 配置视图(配置过即返回,与当前通道无关:供表单在 git ↔ webdav 切换时回填)
|
|
159
|
+
const webdav = full?.webdav !== undefined
|
|
160
|
+
? {
|
|
161
|
+
url: full.webdav.url,
|
|
162
|
+
// username 非敏感可回显,供表单回填
|
|
163
|
+
username: full.webdav.username,
|
|
164
|
+
usernameConfigured: typeof full.webdav.username === 'string' && full.webdav.username !== '',
|
|
165
|
+
passwordConfigured: webdavCred.configured,
|
|
166
|
+
}
|
|
167
|
+
: undefined;
|
|
168
|
+
// 云端点配置视图(只回 secretStored 布尔;与当前通道无关,配置过即回填)
|
|
169
|
+
const cloud = await cloudChannelViews(full, credentials);
|
|
170
|
+
// issue #79 / #104 建议 3:git 工作副本是否已跟随 repoUrl,外加「领先远端提交数 /
|
|
171
|
+
// 远端快照数」(只读自检;后者是「本地一直没真正备份」的预警依据)。
|
|
172
|
+
// 读不出来时**如实回传错误**(workDirError),不让状态接口静默少一块信息。
|
|
173
|
+
const gitRepoUrl = full?.git?.repoUrl ?? '';
|
|
174
|
+
let workDir = null;
|
|
175
|
+
if (gitRepoUrl !== '')
|
|
176
|
+
workDir = await syncWorkDir.inspect(gitRepoUrl, { detail: opts.workDirDetail });
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
configured: full !== null,
|
|
180
|
+
transport,
|
|
181
|
+
repoUrl: full?.git?.repoUrl,
|
|
182
|
+
credentialConfigured: cred.configured,
|
|
183
|
+
credentialWritable: cred.writable === true,
|
|
184
|
+
// webdav 配置状态(无 secret 值:口令用 passwordConfigured 布尔标记)
|
|
185
|
+
...(webdav !== undefined ? { webdav } : {}),
|
|
186
|
+
// 云端点:当前活动 s3 兼容商 / gist 的非密配置(无任何密钥值)
|
|
187
|
+
...(cloud.s3 === undefined ? {} : { s3: cloud.s3 }),
|
|
188
|
+
...(cloud.gist === undefined ? {} : { gist: cloud.gist }),
|
|
189
|
+
// 已保存的云端点 provider 清单(切换 provider 时回填用;只有名字,无任何值)
|
|
190
|
+
cloudSavedProviders: await listCloudConfiguredProviders(syncDir),
|
|
191
|
+
lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
|
|
192
|
+
// 方案 A(同步页按钮瘦身):是否存在同步基线(本地祖先指针)。**只回布尔,绝不回快照 id** ——
|
|
193
|
+
// 界面据此在「有基线 → 三方裁决视图 / 无基线 → 导入确认」之间分派;缺省 undefined 按无基线处理。
|
|
194
|
+
hasBaseline: state.lastSnapshotId !== '',
|
|
195
|
+
sectionCount: Object.keys(state.sections).length,
|
|
196
|
+
lastTransport: state.transport,
|
|
197
|
+
// 上次选择的同步通道(磁盘 ui-prefs;UI 回填优先于此,localStorage 仅兜底)
|
|
198
|
+
lastSyncChannel: uiPrefs.lastSyncChannel,
|
|
199
|
+
// 可同步分区目录(「高级/自定义导出」勾选列表;只含 portable,无 secret 值)
|
|
200
|
+
syncSections: syncSectionCatalog,
|
|
201
|
+
// 当前分区选择(默认/高级模式 + 勾选分区;当前激活通道;UI 回填用,自动同步共用)
|
|
202
|
+
syncSelection: await selectionView(transport),
|
|
203
|
+
// 全部通道的分区选择(git/webdav 各自独立;UI 按当前 tab 取对应通道)
|
|
204
|
+
syncSelectionByChannel: await selectionViewByChannel(),
|
|
205
|
+
// 全部通道的同步密码保存状态(加密/解密;只回布尔,值永不回传浏览器)
|
|
206
|
+
syncCredentialsByChannel: await syncCredentialsByChannelView(),
|
|
207
|
+
// 自动同步当前状态(当前激活通道;供 UI 顶部开关回填;§3.9)
|
|
208
|
+
autosync: await buildAutosyncStatus(syncDir, transport),
|
|
209
|
+
// 全部通道的自动同步状态(git/webdav 各自独立;UI 按当前 tab 取对应通道)
|
|
210
|
+
autosyncByChannel: await buildAutosyncStatusByChannel(syncDir),
|
|
211
|
+
// 工作副本事实(仅 git 且配置了地址时返回;'facts' = 自检成功,'error' = 自检读不出来)
|
|
212
|
+
...(workDir === null ? {} : { workDir }),
|
|
213
|
+
};
|
|
214
|
+
};
|
|
132
215
|
return [
|
|
133
216
|
// ------------------------------------------------------ sync/status
|
|
134
217
|
// m-sync-ui:同步状态(通道配置 / 凭据状态 / 上次同步 / 分区数)。只读,无 secret 值。
|
|
135
218
|
endpoint({ path: '/api/dsh-config-manager/sync/status', methods: ['GET'] }, async (req, res) => {
|
|
136
219
|
try {
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
const [cred, webdavCred] = await Promise.all([
|
|
142
|
-
credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
|
|
143
|
-
credentials.describe(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF)),
|
|
144
|
-
]);
|
|
145
|
-
const transport = parseSyncChannel(full?.transport) ?? 'git';
|
|
146
|
-
// m-self:插件 UI 偏好(上次选择的同步通道;ui-prefs.json,随 self 分区进备份)
|
|
147
|
-
const uiPrefs = await readUiPrefs(syncDir);
|
|
148
|
-
// webdav 配置视图(配置过即返回,与当前通道无关:供表单在 git ↔ webdav 切换时回填)
|
|
149
|
-
const webdav = full?.webdav !== undefined
|
|
150
|
-
? {
|
|
151
|
-
url: full.webdav.url,
|
|
152
|
-
// username 非敏感可回显,供表单回填
|
|
153
|
-
username: full.webdav.username,
|
|
154
|
-
usernameConfigured: typeof full.webdav.username === 'string' && full.webdav.username !== '',
|
|
155
|
-
passwordConfigured: webdavCred.configured,
|
|
156
|
-
}
|
|
157
|
-
: undefined;
|
|
158
|
-
// 云端点配置视图(只回 secretStored 布尔;与当前通道无关,配置过即回填)
|
|
159
|
-
const cloud = await cloudChannelViews(full, credentials);
|
|
160
|
-
// issue #79 / #104 建议 3:git 工作副本是否已跟随 repoUrl,外加「领先远端提交数 /
|
|
161
|
-
// 远端快照数」(只读自检;后者是「本地一直没真正备份」的预警依据)。
|
|
162
|
-
// 读不出来时**如实回传错误**(workDirError),不让状态接口静默少一块信息。
|
|
163
|
-
const gitRepoUrl = full?.git?.repoUrl ?? '';
|
|
164
|
-
let workDir = null;
|
|
165
|
-
if (gitRepoUrl !== '')
|
|
166
|
-
workDir = await syncWorkDir.inspect(gitRepoUrl);
|
|
167
|
-
writeJson(res, 200, {
|
|
168
|
-
ok: true,
|
|
169
|
-
configured: full !== null,
|
|
170
|
-
transport,
|
|
171
|
-
repoUrl: full?.git?.repoUrl,
|
|
172
|
-
credentialConfigured: cred.configured,
|
|
173
|
-
credentialWritable: cred.writable === true,
|
|
174
|
-
// webdav 配置状态(无 secret 值:口令用 passwordConfigured 布尔标记)
|
|
175
|
-
...(webdav !== undefined ? { webdav } : {}),
|
|
176
|
-
// 云端点:当前活动 s3 兼容商 / gist 的非密配置(无任何密钥值)
|
|
177
|
-
...(cloud.s3 === undefined ? {} : { s3: cloud.s3 }),
|
|
178
|
-
...(cloud.gist === undefined ? {} : { gist: cloud.gist }),
|
|
179
|
-
// 已保存的云端点 provider 清单(切换 provider 时回填用;只有名字,无任何值)
|
|
180
|
-
cloudSavedProviders: await listCloudConfiguredProviders(syncDir),
|
|
181
|
-
lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
|
|
182
|
-
// 方案 A(同步页按钮瘦身):是否存在同步基线(本地祖先指针)。**只回布尔,绝不回快照 id** ——
|
|
183
|
-
// 界面据此在「有基线 → 三方裁决视图 / 无基线 → 导入确认」之间分派;缺省 undefined 按无基线处理。
|
|
184
|
-
hasBaseline: state.lastSnapshotId !== '',
|
|
185
|
-
sectionCount: Object.keys(state.sections).length,
|
|
186
|
-
lastTransport: state.transport,
|
|
187
|
-
// 上次选择的同步通道(磁盘 ui-prefs;UI 回填优先于此,localStorage 仅兜底)
|
|
188
|
-
lastSyncChannel: uiPrefs.lastSyncChannel,
|
|
189
|
-
// 可同步分区目录(「高级/自定义导出」勾选列表;只含 portable,无 secret 值)
|
|
190
|
-
syncSections: syncSectionCatalog,
|
|
191
|
-
// 当前分区选择(默认/高级模式 + 勾选分区;当前激活通道;UI 回填用,自动同步共用)
|
|
192
|
-
syncSelection: await selectionView(transport),
|
|
193
|
-
// 全部通道的分区选择(git/webdav 各自独立;UI 按当前 tab 取对应通道)
|
|
194
|
-
syncSelectionByChannel: await selectionViewByChannel(),
|
|
195
|
-
// 全部通道的同步密码保存状态(加密/解密;只回布尔,值永不回传浏览器)
|
|
196
|
-
syncCredentialsByChannel: await syncCredentialsByChannelView(),
|
|
197
|
-
// 自动同步当前状态(当前激活通道;供 UI 顶部开关回填;§3.9)
|
|
198
|
-
autosync: await buildAutosyncStatus(syncDir, transport),
|
|
199
|
-
// 全部通道的自动同步状态(git/webdav 各自独立;UI 按当前 tab 取对应通道)
|
|
200
|
-
autosyncByChannel: await buildAutosyncStatusByChannel(syncDir),
|
|
201
|
-
// 工作副本事实(仅 git 且配置了地址时返回;'facts' = 自检成功,'error' = 自检读不出来)
|
|
202
|
-
...(workDir === null ? {} : { workDir }),
|
|
203
|
-
});
|
|
220
|
+
// detail=0:跳过 workDir 的 ahead / dirty / remoteSnapshots(省 5 次 git 子进程启动)。
|
|
221
|
+
// 缺省仍做完整自检 —— 老调用方按原语义继续拿到全部字段,关掉时那三项如实回 null。
|
|
222
|
+
const workDirDetail = queryParam(new URL(req.url ?? '/', 'http://localhost'), 'detail') !== '0';
|
|
223
|
+
writeJson(res, 200, await withTimeout(collectSyncStatus({ workDirDetail }), SYNC_STATUS_BUDGET_MS, msg('host.syncStatusTimeout')));
|
|
204
224
|
}
|
|
205
225
|
catch (error) {
|
|
206
|
-
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
227
|
+
// 预算耗尽 = 暂不可用(503);其余异常仍是 500。两者都让客户端拿到明确错误而不是空等。
|
|
228
|
+
writeJson(res, message === msg('host.syncStatusTimeout') ? 503 : 500, { error: message });
|
|
207
229
|
}
|
|
208
230
|
}),
|
|
209
231
|
// ------------------------------------------------------ sync/workdir/reset
|
|
@@ -819,7 +841,7 @@ export function syncRoutes(env) {
|
|
|
819
841
|
...(pathMappings.length === 0 ? {} : { pathMappings }),
|
|
820
842
|
}), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'));
|
|
821
843
|
if (!preview.ok || preview.plan === null || preview.analysis === null) {
|
|
822
|
-
writeJson(res, 200, { ok: false, syncSessionId: '', snapshotId: preview.snapshotId, items: [], needsReview: false, compatibility: 'unsupported', message: preview.message ?? msg('route.syncPreviewFailed') });
|
|
844
|
+
writeJson(res, 200, { ok: false, syncSessionId: '', snapshotId: preview.snapshotId, items: [], needsReview: false, compatibility: 'unsupported', message: preview.message ?? msg('route.syncPreviewFailed'), reason: preview.reason });
|
|
823
845
|
return;
|
|
824
846
|
}
|
|
825
847
|
// T1:abort 处置在**预览阶段**就阻断(此刻仍零写入:临时 ZIP 与内存会话都还没登记)。
|
|
@@ -37,6 +37,8 @@ export interface GitWorkDirFacts {
|
|
|
37
37
|
*/
|
|
38
38
|
remoteSnapshots: number | null;
|
|
39
39
|
}
|
|
40
|
+
/** 清空自检缓存(测试隔离用;生产路径由 `withWorkdir` 逐 key 失效)。 */
|
|
41
|
+
export declare function clearInspectFactsCache(): void;
|
|
40
42
|
/** 凭据提供者:token 只从这里读取,绝不在任何地方持久化 */
|
|
41
43
|
export interface GitCredentialProvider {
|
|
42
44
|
getToken(): Promise<string>;
|
|
@@ -35,6 +35,31 @@ import { zhMsg } from '../../core/messages.js';
|
|
|
35
35
|
import { withWorkdirLock, WorkdirLockError } from '../../utils/workdir-lock.js';
|
|
36
36
|
import { fastForwardFetchArgs, fastForwardMergeArgs, isMissingRemoteRefError, parseCurrentBranch } from '../../utils/git-fast-forward.js';
|
|
37
37
|
const execFileAsync = promisify(execFile);
|
|
38
|
+
/**
|
|
39
|
+
* 工作副本自检结果的短 TTL 缓存(`inspectWorkDir`,2026-10-27)。
|
|
40
|
+
*
|
|
41
|
+
* 为什么需要:自检的每个字段都是一次 `git.exe` 进程启动,Windows 上每次 ~80 ms 固定开销
|
|
42
|
+
* (实测 detail 版 7 条命令 = 585 ms,`GET /sync/status` 整体 730 ms)。它挂在状态接口上,
|
|
43
|
+
* 首页 / 同步页 / 切页 / 轮询会在几秒内重复索取,而工作副本状态不会在 10 秒里反复变化。
|
|
44
|
+
*
|
|
45
|
+
* 为什么放模块级而不是实例字段:`makeGitTransport()` 每次请求都新建实例(src/index.ts:3313),
|
|
46
|
+
* 实例级缓存等于没有缓存。
|
|
47
|
+
*
|
|
48
|
+
* 「完整事实」判定:`detail: true` 的结果是默认结果的超集 ⇒ 完整条目能满足任何请求,默认条目
|
|
49
|
+
* 不能满足 detail 请求;写回时 detail 结果总是覆盖,默认结果只在还没有完整条目时写入 ——
|
|
50
|
+
* 否则一次默认请求会把刚拿到的 ahead/dirty/remoteSnapshots 冲掉。
|
|
51
|
+
*
|
|
52
|
+
* 失效:`withWorkdir` 在除自检之外的每条路径收尾后都删掉本 key(写操作必然可能改变事实)。
|
|
53
|
+
*/
|
|
54
|
+
const INSPECT_FACTS_TTL_MS = 10_000;
|
|
55
|
+
const inspectFactsCache = new Map();
|
|
56
|
+
function inspectFactsCacheKey(workDir, repoUrl) {
|
|
57
|
+
return workDir + '\u0000' + repoUrl;
|
|
58
|
+
}
|
|
59
|
+
/** 清空自检缓存(测试隔离用;生产路径由 `withWorkdir` 逐 key 失效)。 */
|
|
60
|
+
export function clearInspectFactsCache() {
|
|
61
|
+
inspectFactsCache.clear();
|
|
62
|
+
}
|
|
38
63
|
/**
|
|
39
64
|
* git 通道错误:继承统一错误基类(kind / retryable / status 分类对上层可见)。
|
|
40
65
|
* 分类口径见 transport.ts 的 classifyNetworkErrorText / classifyHttpStatus。
|
|
@@ -370,7 +395,10 @@ export class GitTransport {
|
|
|
370
395
|
*/
|
|
371
396
|
async probe() {
|
|
372
397
|
await this.checkIsPrivate();
|
|
373
|
-
|
|
398
|
+
// issue #115:ls-remote 只证明「能读」。写权限是**另一项**授权(细粒度 PAT 的 Contents: Read and write、
|
|
399
|
+
// SSH 部署密钥的 Allow write access),而传输层没有 REST 通道去查它 —— 宁可明说「写权限未验证」,
|
|
400
|
+
// 也不给一句「连接正常」让用户以为推送必然成功(真正暴露问题的地方是他的第一次推送)。
|
|
401
|
+
return { message: this.msg('sync.git.probeOk'), hint: this.msg('sync.git.probeReadOnlyHint') };
|
|
374
402
|
}
|
|
375
403
|
/* ---------------- 内部实现 ---------------- */
|
|
376
404
|
/**
|
|
@@ -383,18 +411,9 @@ export class GitTransport {
|
|
|
383
411
|
*
|
|
384
412
|
* 可重入:内层(ensureRepo/pullFromRemote/adoptRemoteHistory 等)不重复取锁。
|
|
385
413
|
*/
|
|
386
|
-
async withWorkdir(fn) {
|
|
387
|
-
if (this.workdirLockDepth > 0) {
|
|
388
|
-
this.workdirLockDepth += 1;
|
|
389
|
-
try {
|
|
390
|
-
return await fn();
|
|
391
|
-
}
|
|
392
|
-
finally {
|
|
393
|
-
this.workdirLockDepth -= 1;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
414
|
+
async withWorkdir(fn, opts = {}) {
|
|
396
415
|
try {
|
|
397
|
-
|
|
416
|
+
if (this.workdirLockDepth > 0) {
|
|
398
417
|
this.workdirLockDepth += 1;
|
|
399
418
|
try {
|
|
400
419
|
return await fn();
|
|
@@ -402,15 +421,32 @@ export class GitTransport {
|
|
|
402
421
|
finally {
|
|
403
422
|
this.workdirLockDepth -= 1;
|
|
404
423
|
}
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
catch (err) {
|
|
408
|
-
// 等锁超时 = 别人正在同步(另一个 DSH 实例 / 另一个同步任务),不是通道故障:
|
|
409
|
-
// 给可操作文案并标记 retryable,别让它伪装成「git 命令失败」。
|
|
410
|
-
if (err instanceof WorkdirLockError) {
|
|
411
|
-
throw new GitTransportError(this.msg('sync.git.workdirBusy', { dir: this.o.workDir }), { kind: 'conflict', retryable: true });
|
|
412
424
|
}
|
|
413
|
-
|
|
425
|
+
try {
|
|
426
|
+
return await withWorkdirLock(this.o.workDir, async () => {
|
|
427
|
+
this.workdirLockDepth += 1;
|
|
428
|
+
try {
|
|
429
|
+
return await fn();
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
this.workdirLockDepth -= 1;
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
// 等锁超时 = 别人正在同步(另一个 DSH 实例 / 另一个同步任务),不是通道故障:
|
|
438
|
+
// 给可操作文案并标记 retryable,别让它伪装成「git 命令失败」。
|
|
439
|
+
if (err instanceof WorkdirLockError) {
|
|
440
|
+
throw new GitTransportError(this.msg('sync.git.workdirBusy', { dir: this.o.workDir }), { kind: 'conflict', retryable: true });
|
|
441
|
+
}
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
// 除纯只读自检外,任何操作都可能改变工作副本状态(push 改 ahead、fetch 改远端引用、
|
|
447
|
+
// 重建删目录、失败也可能已留下 commit)→ 收尾即丢弃自检缓存,绝不让「写后读」看到旧事实。
|
|
448
|
+
if (opts.readonly !== true)
|
|
449
|
+
inspectFactsCache.delete(inspectFactsCacheKey(this.o.workDir, this.o.repoUrl));
|
|
414
450
|
}
|
|
415
451
|
}
|
|
416
452
|
async ensureRepo() {
|
|
@@ -499,8 +535,21 @@ export class GitTransport {
|
|
|
499
535
|
* 因此放进 `detail: true`(供「重建工作副本」前评估数据量等场景显式索取)。
|
|
500
536
|
*/
|
|
501
537
|
async inspectWorkDir(opts = {}) {
|
|
538
|
+
const key = inspectFactsCacheKey(this.o.workDir, this.o.repoUrl);
|
|
539
|
+
const detail = opts.detail === true;
|
|
540
|
+
const hit = inspectFactsCache.get(key);
|
|
541
|
+
// 命中即返回:不必排队等锁,也不必再 spawn 一条 git(这是首页/切页/轮询的常态路径)。
|
|
542
|
+
// 返回浅拷贝 —— 缓存条目绝不外泄给调用方修改。
|
|
543
|
+
if (hit !== undefined && Date.now() - hit.at < INSPECT_FACTS_TTL_MS && (hit.detail || !detail)) {
|
|
544
|
+
return { ...hit.facts };
|
|
545
|
+
}
|
|
502
546
|
// 只读命令也要排队:与并发的 fetch/checkout 交错会让 status/rev-list 读到半更新状态
|
|
503
|
-
|
|
547
|
+
const facts = await this.withWorkdir(() => this.inspectWorkDirLocked(opts), { readonly: true });
|
|
548
|
+
const prev = inspectFactsCache.get(key);
|
|
549
|
+
if (detail || prev === undefined || !prev.detail) {
|
|
550
|
+
inspectFactsCache.set(key, { at: Date.now(), detail, facts });
|
|
551
|
+
}
|
|
552
|
+
return { ...facts };
|
|
504
553
|
}
|
|
505
554
|
async inspectWorkDirLocked(opts = {}) {
|
|
506
555
|
const fsx = createSnapshotFs();
|
|
@@ -770,9 +819,13 @@ export class GitTransport {
|
|
|
770
819
|
throw new GitTransportError(this.hostKeyMessage(), { kind: 'auth', retryable: false });
|
|
771
820
|
}
|
|
772
821
|
// 推送 + 鉴权失败:git 原文只说「认证失败」——换成能照做的说明(令牌失效 / 无写权限 / 远端禁用了密码推送)
|
|
822
|
+
// issue #115:clone 的鉴权失败原文(remote: Write access to repository not granted / 403)读不出「缺哪种权限」,
|
|
823
|
+
// 换成点名 Contents: Read and write 的说明 —— 否则用户只会以为令牌坏了(细粒度 PAT 常只勾了 Administration)
|
|
773
824
|
const message = opts.requirePushToken === true && classified.kind === 'auth'
|
|
774
825
|
? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
|
|
775
|
-
:
|
|
826
|
+
: args[0] === 'clone' && classified.kind === 'auth'
|
|
827
|
+
? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
|
|
828
|
+
: this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
|
|
776
829
|
throw new GitTransportError(message, classified);
|
|
777
830
|
}
|
|
778
831
|
return result;
|
package/lib/sync/messages.d.ts
CHANGED
|
@@ -66,6 +66,8 @@ export declare const zh: {
|
|
|
66
66
|
readonly 'sync.webdav.collectionCreateFailed': '创建远端目录失败(HTTP {status}):{path}';
|
|
67
67
|
readonly 'sync.deviceNameFallback': '未命名设备';
|
|
68
68
|
readonly 'sync.git.probeOk': '连接正常(远端可达、凭据可用)';
|
|
69
|
+
readonly 'sync.git.probeReadOnlyHint': '只验证了「能读」:推送还需要写权限(细粒度 PAT 的 Contents: Read and write;SSH 部署密钥需勾 Allow write access)。';
|
|
70
|
+
readonly 'sync.git.cloneAuthFailed': '读不到这个仓库(HTTP 401/403):当前凭据对它没有读权限。细粒度 PAT 只勾 Administration 不够,必须勾 Contents: Read and write;classic PAT 需要 repo 域。';
|
|
69
71
|
};
|
|
70
72
|
export declare const en: Record<keyof typeof zh, string>;
|
|
71
73
|
/** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
|
package/lib/sync/messages.js
CHANGED
|
@@ -96,6 +96,8 @@ export const zh = {
|
|
|
96
96
|
'sync.deviceNameFallback': '未命名设备',
|
|
97
97
|
// ---------- git 通道连通性探针(cloud-sync 借鉴项 ②,补齐四通道) ----------
|
|
98
98
|
'sync.git.probeOk': '连接正常(远端可达、凭据可用)',
|
|
99
|
+
'sync.git.probeReadOnlyHint': '只验证了「能读」:推送还需要写权限(细粒度 PAT 的 Contents: Read and write;SSH 部署密钥需勾 Allow write access)。',
|
|
100
|
+
'sync.git.cloneAuthFailed': '读不到这个仓库(HTTP 401/403):当前凭据对它没有读权限。细粒度 PAT 只勾 Administration 不够,必须勾 Contents: Read and write;classic PAT 需要 repo 域。'
|
|
99
101
|
};
|
|
100
102
|
export const en = {
|
|
101
103
|
'sync.ancestorSnapshotIdRequired': 'loadAncestor: snapshotId must not be empty',
|
|
@@ -164,6 +166,8 @@ export const en = {
|
|
|
164
166
|
'sync.webdav.collectionCreateFailed': 'Failed to create the remote directory (HTTP {status}): {path}',
|
|
165
167
|
'sync.deviceNameFallback': 'Unnamed device',
|
|
166
168
|
'sync.git.probeOk': 'Connection OK (remote reachable, credentials accepted)',
|
|
169
|
+
'sync.git.probeReadOnlyHint': 'Only read access was verified: pushing also needs write access (Contents: Read and write for a fine-grained PAT; Allow write access for an SSH deploy key).',
|
|
170
|
+
'sync.git.cloneAuthFailed': 'Cannot read this repository (HTTP 401/403): the current credential has no read access to it. A fine-grained PAT needs Contents: Read and write — Administration alone is not enough; a classic PAT needs the repo scope.'
|
|
167
171
|
};
|
|
168
172
|
registerMessageCatalog({ id: 'sync-domain', zh, en });
|
|
169
173
|
/** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
|
|
@@ -198,6 +198,7 @@ export interface SyncPreviewResult {
|
|
|
198
198
|
*/
|
|
199
199
|
credentials?: Map<string, string>;
|
|
200
200
|
message?: string;
|
|
201
|
+
reason?: 'remote-empty';
|
|
201
202
|
}
|
|
202
203
|
/** 自动应用执行器(P2b)报告 */
|
|
203
204
|
export interface ApplyReport {
|
|
@@ -300,8 +301,7 @@ export interface MergePreview {
|
|
|
300
301
|
/** 是否拿到了本地祖先副本(false = 退化为两方合并 → 所有双侧差异都会呈现为 conflict)。 */
|
|
301
302
|
hasAncestor: boolean;
|
|
302
303
|
sections: MergePreviewSectionView[];
|
|
303
|
-
/** 与 risk.ts 的 classifyMergePlan 同口径计数:
|
|
304
|
-
* auto = 可自动应用的非冲突项,review = 需人工,conflicts = decision === 'conflict',skipped = 'skip'。 */
|
|
304
|
+
/** 与 risk.ts 的 classifyMergePlan 同口径计数:auto = 可自动应用,review = 需人工,conflicts = 冲突,skipped = 跳过。 */
|
|
305
305
|
summary: {
|
|
306
306
|
auto: number;
|
|
307
307
|
review: number;
|
|
@@ -309,6 +309,7 @@ export interface MergePreview {
|
|
|
309
309
|
skipped: number;
|
|
310
310
|
};
|
|
311
311
|
message?: string;
|
|
312
|
+
reason?: 'remote-empty';
|
|
312
313
|
}
|
|
313
314
|
/**
|
|
314
315
|
* 用户对**单个分区**的裁决(分区粒度:没有冲突项粒度 / 复合 id)。
|
|
@@ -607,8 +608,7 @@ export declare class SyncEngine {
|
|
|
607
608
|
hasLocalChanges(): Promise<boolean>;
|
|
608
609
|
/**
|
|
609
610
|
* 一键同步预览:拉取远端(最新或指定历史快照)→ 转临时 ZIP → Importer 分析出计划。
|
|
610
|
-
* 与 pull 的区别:临时 ZIP **不清理**(由调用方 / 会话持有,供 apply-items
|
|
611
|
-
* 并返回完整 plan/analysis/snapshotId 供会话登记。
|
|
611
|
+
* 与 pull 的区别:临时 ZIP **不清理**(由调用方 / 会话持有,供 apply-items 复用),并返回完整 plan/analysis/snapshotId 供会话登记。
|
|
612
612
|
* 调用方负责在会话消费或取消后清理 zipPath 所在目录。
|
|
613
613
|
*/
|
|
614
614
|
preview(opts?: SyncPullOptions): Promise<SyncPreviewResult>;
|
package/lib/sync/sync-engine.js
CHANGED
|
@@ -1064,8 +1064,7 @@ export class SyncEngine {
|
|
|
1064
1064
|
}
|
|
1065
1065
|
/**
|
|
1066
1066
|
* 一键同步预览:拉取远端(最新或指定历史快照)→ 转临时 ZIP → Importer 分析出计划。
|
|
1067
|
-
* 与 pull 的区别:临时 ZIP **不清理**(由调用方 / 会话持有,供 apply-items
|
|
1068
|
-
* 并返回完整 plan/analysis/snapshotId 供会话登记。
|
|
1067
|
+
* 与 pull 的区别:临时 ZIP **不清理**(由调用方 / 会话持有,供 apply-items 复用),并返回完整 plan/analysis/snapshotId 供会话登记。
|
|
1069
1068
|
* 调用方负责在会话消费或取消后清理 zipPath 所在目录。
|
|
1070
1069
|
*/
|
|
1071
1070
|
async preview(opts = {}) {
|
|
@@ -1074,7 +1073,7 @@ export class SyncEngine {
|
|
|
1074
1073
|
}
|
|
1075
1074
|
const metas = await this.transport.list();
|
|
1076
1075
|
if (metas.length === 0) {
|
|
1077
|
-
return { ok: false, zipPath: '', plan: null, analysis: null, snapshotId: '', message: this.msg('sync.remoteEmpty') };
|
|
1076
|
+
return { ok: false, zipPath: '', plan: null, analysis: null, snapshotId: '', message: this.msg('sync.remoteEmpty'), reason: 'remote-empty' };
|
|
1078
1077
|
}
|
|
1079
1078
|
const targetId = opts.snapshotId ?? metas[metas.length - 1].id;
|
|
1080
1079
|
const snapshot = await this.transport.download(targetId);
|
|
@@ -1213,7 +1212,7 @@ export class SyncEngine {
|
|
|
1213
1212
|
const snapshotId = this.lastMergedRemoteSnapshotId;
|
|
1214
1213
|
if (snapshotId === null) {
|
|
1215
1214
|
// merge() 已清掉全部缓存;这里如实报「远端无快照」(ok:false,而不是一个空成功)
|
|
1216
|
-
return { ok: false, snapshotId: '', hasAncestor: false, sections: [], summary, message: this.msg('sync.remoteEmpty') };
|
|
1215
|
+
return { ok: false, snapshotId: '', hasAncestor: false, sections: [], summary, reason: 'remote-empty', message: this.msg('sync.remoteEmpty') };
|
|
1217
1216
|
}
|
|
1218
1217
|
const split = classifyMergePlan(plan, { firstSync: false });
|
|
1219
1218
|
summary.auto = split.autoApply.length;
|