dsh-config-manager 0.1.86 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/adapters/self.d.ts +28 -0
- package/lib/adapters/self.js +14 -3
- package/lib/cli/web/page.js +2 -2
- package/lib/client.d.ts +86 -1
- package/lib/client.js +2368 -775
- package/lib/core/native-path-picker.d.ts +49 -0
- package/lib/core/native-path-picker.js +475 -0
- package/lib/core/path-browse.d.ts +42 -0
- package/lib/core/path-browse.js +134 -0
- package/lib/foreign/claude-sessions.d.ts +1 -1
- package/lib/foreign/claude-sessions.js +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.js +3 -3
- package/lib/market/security.js +1 -1
- package/lib/market/types.d.ts +1 -1
- package/lib/market/types.js +1 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/index.js +2 -0
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/path-browse.d.ts +3 -0
- package/lib/routes/path-browse.js +63 -0
- package/lib/security/secret-scanner.js +1 -1
- package/lib/ui/sync-settings-view.d.ts +18 -0
- package/lib/ui/sync-settings-view.js +20 -0
- package/package.json +2 -2
- package/src/adapters/self.test.ts +54 -0
- package/src/adapters/self.ts +40 -3
- package/src/cli/web/page.ts +2 -2
- package/src/client/ConfigManagerSection.tsx +20 -17
- package/src/client/api.ts +60 -1
- package/src/client/bundle-selfcontained.test.ts +9 -0
- package/src/client/chat/chat-entry-view.test.ts +1 -1
- package/src/client/chat/rollback-entry-view.test.ts +14 -0
- package/src/client/chat/rollback-entry-view.ts +2 -1
- package/src/client/common/CopyButton.tsx +21 -2
- package/src/client/common/Icon.tsx +7 -0
- package/src/client/common/routes.ts +5 -0
- package/src/client/config-manager.module.css +70 -0
- package/src/client/import/ImportWizardView.tsx +1 -0
- package/src/client/import/PathMappingForm.tsx +62 -8
- package/src/client/import/PathPickerDialog.tsx +172 -0
- package/src/client/import/dsh-directory-pick.test.ts +55 -0
- package/src/client/import/dsh-directory-pick.ts +55 -0
- package/src/client/import/import-wizard-steps.tsx +5 -2
- package/src/client/index.ts +12 -1
- package/src/client/locales.ts +25 -0
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/sync/SyncPanel.tsx +4 -4
- package/src/client/sync/sync-view.ts +1 -1
- package/src/core/native-path-picker.test.ts +118 -0
- package/src/core/native-path-picker.ts +517 -0
- package/src/core/path-browse.test.ts +88 -0
- package/src/core/path-browse.ts +166 -0
- package/src/foreign/claude-sessions.ts +3 -3
- package/src/index.ts +3 -3
- package/src/market/security.ts +1 -1
- package/src/market/types.ts +1 -1
- package/src/routes/index.ts +3 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/path-browse.ts +70 -0
- package/src/security/secret-scanner.test.ts +1 -1
- package/src/security/secret-scanner.ts +1 -1
- package/src/ui/session-issue-labels.ts +1 -1
- package/src/ui/sync-settings-view.test.ts +18 -1
- package/src/ui/sync-settings-view.ts +21 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机目录列举(宿主半)—— 「路径映射 → 选择本机路径」弹窗的唯一数据源。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有它
|
|
5
|
+
* ------------
|
|
6
|
+
* 导入向导的路径映射要求用户为一堆**绝对路径**指定本机新位置,而此前只能凭记忆手打
|
|
7
|
+
* (浏览器拿不到真实路径:File System Access API 只给 handle,`<input type="file">` 只给文件名)。
|
|
8
|
+
* 于是「路径选不对 = 导不进」全落在用户的打字准确性上。这里把「列目录」做成宿主能力,
|
|
9
|
+
* 浏览器只负责画界面。
|
|
10
|
+
*
|
|
11
|
+
* 边界
|
|
12
|
+
* ----
|
|
13
|
+
* - **只读**:只 readdir/stat,绝不创建、改写、删除任何东西;
|
|
14
|
+
* - **不产出文案**:`error` 是枚举码(not-found / denied / failed / not-absolute),
|
|
15
|
+
* 用户可见句子由客户端按字典解析(i18n 硬规则:引擎不写中文);
|
|
16
|
+
* - 有界:单次列举最多 {@link BROWSE_ENTRY_LIMIT} 条并回报 `truncated`,盘符探测逐根带硬超时
|
|
17
|
+
* (离线光驱/断开的网络盘 stat 会挂住,路由不许被一个 panic 盘符拖死)。
|
|
18
|
+
*/
|
|
19
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
20
|
+
import { dirname, join, parse } from 'node:path';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
/** 单次列举上限:够翻一个源码目录,又不至于把响应体撑成几 MB。 */
|
|
23
|
+
export const BROWSE_ENTRY_LIMIT = 500;
|
|
24
|
+
/** 盘符/根探测的单项硬超时(ms):断开的网络盘 stat 会长时间不返回。 */
|
|
25
|
+
export const ROOT_PROBE_TIMEOUT_MS = 400;
|
|
26
|
+
/** 带硬超时的 Promise(只为本模块的盘符探测服务;超时后原 promise 的迟到结果被丢弃)。 */
|
|
27
|
+
function withDeadline(promise, ms) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const timer = setTimeout(() => { reject(new Error('timeout')); }, ms);
|
|
30
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** 一个路径是不是「根」(Windows 盘符根 / POSIX 的 `/`)。 */
|
|
34
|
+
function isRoot(target) {
|
|
35
|
+
return parse(target).root === target;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 本机根列表:Windows 上是**探测得到的**盘符根(不硬编码 C:),其余平台是 `/`。
|
|
39
|
+
* 探测逐根 400ms 超时;全部失败时至少给出 `parse(process.cwd()).root`,界面不至于空成白板。
|
|
40
|
+
*/
|
|
41
|
+
export async function listRoots() {
|
|
42
|
+
if (process.platform !== 'win32') {
|
|
43
|
+
return [{ name: '/', path: '/', kind: 'dir' }];
|
|
44
|
+
}
|
|
45
|
+
const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split('');
|
|
46
|
+
const probed = await Promise.all(letters.map(async (letter) => {
|
|
47
|
+
const root = `${letter}:\\`;
|
|
48
|
+
try {
|
|
49
|
+
const info = await withDeadline(stat(root), ROOT_PROBE_TIMEOUT_MS);
|
|
50
|
+
return info.isDirectory() ? root : null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
const roots = probed.filter((root) => root !== null);
|
|
57
|
+
const fallback = parse(process.cwd()).root;
|
|
58
|
+
const ordered = roots.length > 0 ? roots : (fallback === '' ? [] : [fallback]);
|
|
59
|
+
return ordered.map((root) => ({ name: root, path: root, kind: 'dir' }));
|
|
60
|
+
}
|
|
61
|
+
/** 文件系统错误 → 原因码(只有 ENOENT 才叫「不存在」)。 */
|
|
62
|
+
function errorCodeOf(error) {
|
|
63
|
+
const code = error?.code;
|
|
64
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
65
|
+
return 'not-found';
|
|
66
|
+
if (code === 'EACCES' || code === 'EPERM')
|
|
67
|
+
return 'denied';
|
|
68
|
+
return 'failed';
|
|
69
|
+
}
|
|
70
|
+
/** 名字排序:目录在前、同类按名字字典序(不依赖 locale,测试可复现)。 */
|
|
71
|
+
function compareEntries(a, b) {
|
|
72
|
+
if (a.kind !== b.kind)
|
|
73
|
+
return a.kind === 'dir' ? -1 : 1;
|
|
74
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 列出一个目录(缺省 = 根列表)。
|
|
78
|
+
*
|
|
79
|
+
* `target` 缺省或空串 = 根列表;凡不是绝对路径一律 `not-absolute`(不猜基准目录 —— 猜错就列出别人的东西)。
|
|
80
|
+
* @param options.limit 覆盖 {@link BROWSE_ENTRY_LIMIT}。
|
|
81
|
+
*/
|
|
82
|
+
export async function browseDirectory(target, options) {
|
|
83
|
+
const limit = Math.max(1, Math.trunc(options?.limit ?? BROWSE_ENTRY_LIMIT));
|
|
84
|
+
const home = homedir();
|
|
85
|
+
const wanted = (target ?? '').trim();
|
|
86
|
+
if (wanted === '') {
|
|
87
|
+
const roots = await listRoots();
|
|
88
|
+
return { path: '', parent: null, home, entries: roots.slice(0, limit), truncated: roots.length > limit, error: null };
|
|
89
|
+
}
|
|
90
|
+
if (!parse(wanted).root) {
|
|
91
|
+
return { path: wanted, parent: null, home, entries: [], truncated: false, error: 'not-absolute' };
|
|
92
|
+
}
|
|
93
|
+
const current = parse(wanted).root === wanted ? wanted : wanted.replace(/[\\/]+$/, '');
|
|
94
|
+
let dirents;
|
|
95
|
+
try {
|
|
96
|
+
dirents = await readdir(current, { withFileTypes: true });
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return { path: current, parent: isRoot(current) ? null : dirname(current), home, entries: [], truncated: false, error: errorCodeOf(error) };
|
|
100
|
+
}
|
|
101
|
+
const entries = [];
|
|
102
|
+
for (const dirent of dirents) {
|
|
103
|
+
const entryPath = join(current, dirent.name);
|
|
104
|
+
if (dirent.isDirectory()) {
|
|
105
|
+
entries.push({ name: dirent.name, path: entryPath, kind: 'dir' });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (dirent.isFile()) {
|
|
109
|
+
entries.push({ name: dirent.name, path: entryPath, kind: 'file' });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// 符号链接 / junction / 其它特殊项:跟随一次(读不到就整条丢掉,绝不猜类型)
|
|
113
|
+
try {
|
|
114
|
+
const info = await stat(entryPath);
|
|
115
|
+
if (info.isDirectory())
|
|
116
|
+
entries.push({ name: dirent.name, path: entryPath, kind: 'dir' });
|
|
117
|
+
else if (info.isFile())
|
|
118
|
+
entries.push({ name: dirent.name, path: entryPath, kind: 'file' });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// 断链:不展示(点了也进不去)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
entries.sort(compareEntries);
|
|
125
|
+
return {
|
|
126
|
+
path: current,
|
|
127
|
+
parent: isRoot(current) ? null : dirname(current),
|
|
128
|
+
home,
|
|
129
|
+
entries: entries.slice(0, limit),
|
|
130
|
+
truncated: entries.length > limit,
|
|
131
|
+
error: null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=path-browse.js.map
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* ③ **sessionId 不一致**:`input.recordSessionId` 存在且 ≠ `input.id` → 非致命发现
|
|
38
38
|
* `session-id-mismatch`(会话 id 仍以文件名 stem 为准,见 `TranscodeResult.findings`)。
|
|
39
39
|
* ④ **推理块**(Claude Code 的 `thinking` / `redacted_thinking`):共享 IR 没有 reasoning
|
|
40
|
-
* 承载位(docs/
|
|
40
|
+
* 承载位(docs/maintenance/known-gaps.md F-01)→ 计报 `thinking`,**绝不伪装成正文**;非 Claude 原生的
|
|
41
41
|
* `{type:'reasoning',text:…}` 刻意保持既有口径(理由与切换方式见 `REASONING_BLOCK_TYPES`)。
|
|
42
42
|
*/
|
|
43
43
|
import type { Stats } from 'node:fs';
|
|
@@ -28,7 +28,7 @@ function usageField(usage, key) {
|
|
|
28
28
|
* 沿「对象带 text 才算文本」的兜底把它变成一个**空文本块** —— 内容静默丢失,且那个空块还会多插入
|
|
29
29
|
* 一个换行。本仓其余 20+ 来源一律「推理不伪装成正文、只计数」(read-crush / read-opencode /
|
|
30
30
|
* read-qwen / read-pi / read-openclaw …),这里对齐同一口径;共享 IR 无 reasoning 承载位这件事
|
|
31
|
-
* 记在 docs/
|
|
31
|
+
* 记在 docs/maintenance/known-gaps.md 的 F-01。
|
|
32
32
|
*
|
|
33
33
|
* **为什么不把 `{type:'reasoning', text:'…'}` 一并纳入**(如实记明,别当成漏掉):
|
|
34
34
|
* ① Claude Code 不写这种块(它是别的导出器的归一命名,各来源自己的解析器已各自计报);
|
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.2.1";
|
|
81
81
|
/**
|
|
82
82
|
* Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
|
|
83
83
|
* 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
|
|
@@ -130,7 +130,7 @@ export interface Config {
|
|
|
130
130
|
/**
|
|
131
131
|
* 本文件保留的 7 条路由的路径(被源码级守卫按文件窗口钉住,见下方 routesList 注释)。
|
|
132
132
|
*
|
|
133
|
-
* 其余
|
|
133
|
+
* 其余 76 条路由的路径**声明在各自的组文件里**(src/routes/*.ts 的 endpoint({ path, methods }))——
|
|
134
134
|
* 新增一条 API 只需那一条声明,不再有「常量表 + 路由对象」两处要同步。
|
|
135
135
|
*/
|
|
136
136
|
export declare const API: {
|
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.2.1';
|
|
140
140
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
141
141
|
const PLUGIN_NAME = 'dsh-config-manager';
|
|
142
142
|
/**
|
|
@@ -158,7 +158,7 @@ export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liq4i7n8UsylGRfb';
|
|
|
158
158
|
/**
|
|
159
159
|
* 本文件保留的 7 条路由的路径(被源码级守卫按文件窗口钉住,见下方 routesList 注释)。
|
|
160
160
|
*
|
|
161
|
-
* 其余
|
|
161
|
+
* 其余 76 条路由的路径**声明在各自的组文件里**(src/routes/*.ts 的 endpoint({ path, methods }))——
|
|
162
162
|
* 新增一条 API 只需那一条声明,不再有「常量表 + 路由对象」两处要同步。
|
|
163
163
|
*/
|
|
164
164
|
export const API = {
|
|
@@ -3027,7 +3027,7 @@ function makeRoutes(deps) {
|
|
|
3027
3027
|
/**
|
|
3028
3028
|
* 路由表:本文件只保留被源码级守卫按**文件窗口**钉住的 7 条(host-entry 审计 F-04 记录的
|
|
3029
3029
|
* tests/host/**、src/core/model-tools.test.ts、src/core/phase1-wiring.test.ts 的窗口断言)。
|
|
3030
|
-
* 其余
|
|
3030
|
+
* 其余 76 条已按域拆到 src/routes/*.ts,由 buildRoutes 组装。
|
|
3031
3031
|
* 注册顺序不影响匹配:命名路由必须互不相同(webServer 契约)。
|
|
3032
3032
|
*/
|
|
3033
3033
|
const routeEnv = makeRouteEnv();
|
package/lib/market/security.js
CHANGED
|
@@ -102,7 +102,7 @@ export function validateMarketItem(itemId, manifestRaw, zipBytes) {
|
|
|
102
102
|
if (!enabled)
|
|
103
103
|
continue;
|
|
104
104
|
// 市场条目禁止分区(BANNED_MARKET_SECTIONS:sessions 历史会话 / pluginFiles 任意文件 / self 本地环境):
|
|
105
|
-
// 与 secrets 同级硬约束(产品决策 2026-08-19,guide docs/
|
|
105
|
+
// 与 secrets 同级硬约束(产品决策 2026-08-19,guide docs/spec/market-index-v1.md)。
|
|
106
106
|
if (BANNED_MARKET_SECTIONS.includes(sectionId)) {
|
|
107
107
|
return { status: 'invalid', errors: [marketMsg('market.security.bannedSection', { section: sectionId })], warnings, manifest, internalManifest, sections: [], checksumsOk: true };
|
|
108
108
|
}
|
package/lib/market/types.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export type MarketPublishMode = 'migrate' | 'share';
|
|
|
26
26
|
*/
|
|
27
27
|
export declare const MAX_MARKET_ZIP_BYTES: number;
|
|
28
28
|
/**
|
|
29
|
-
* 市场条目禁止携带的分区(产品决策 2026-08-19,guide docs/
|
|
29
|
+
* 市场条目禁止携带的分区(产品决策 2026-08-19,guide docs/spec/market-index-v1.md):
|
|
30
30
|
* - sessions:历史会话,含个人交互记录/上下文;
|
|
31
31
|
* - pluginFiles:任意文件直通(目录放啥带啥),无内容过滤,最易泄漏 token/密钥文件;
|
|
32
32
|
* - self:本地环境专属(sync 通道 URL / WebDAV 地址 / 市场配置 / UI 偏好),无分享价值且泄漏环境信息。
|
package/lib/market/types.js
CHANGED
|
@@ -8,7 +8,7 @@ export const MARKET_ITEM_SCHEMA_VERSION = 1;
|
|
|
8
8
|
*/
|
|
9
9
|
export const MAX_MARKET_ZIP_BYTES = 64 * 1024 * 1024;
|
|
10
10
|
/**
|
|
11
|
-
* 市场条目禁止携带的分区(产品决策 2026-08-19,guide docs/
|
|
11
|
+
* 市场条目禁止携带的分区(产品决策 2026-08-19,guide docs/spec/market-index-v1.md):
|
|
12
12
|
* - sessions:历史会话,含个人交互记录/上下文;
|
|
13
13
|
* - pluginFiles:任意文件直通(目录放啥带啥),无内容过滤,最易泄漏 token/密钥文件;
|
|
14
14
|
* - self:本地环境专属(sync 通道 URL / WebDAV 地址 / 市场配置 / UI 偏好),无分享价值且泄漏环境信息。
|
package/lib/routes/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 顺序对匹配无影响:DSH webServer 的契约是「命名路由必须互不相同,注册顺序不影响请求」
|
|
5
5
|
* (见 @deepseek-ai/dsh-host-webserver 的 WebServer 文档)。src/index.ts 保留的 7 条被源码级
|
|
6
|
-
* 守卫按文件窗口钉住,其余
|
|
6
|
+
* 守卫按文件窗口钉住,其余 76 条在这里组装(总数 83:含 path-browse)。
|
|
7
7
|
*/
|
|
8
8
|
import type { WebRoute } from './kit.ts';
|
|
9
9
|
import type { RoutesEnv } from './context.ts';
|
package/lib/routes/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { meRoutes } from './me.js';
|
|
|
11
11
|
import { historyRoutes } from './history.js';
|
|
12
12
|
import { recoveryRoutes } from './recovery.js';
|
|
13
13
|
import { sessionExportRoutes } from './session-export.js';
|
|
14
|
+
import { pathBrowseRoutes } from './path-browse.js';
|
|
14
15
|
export function buildRoutes(env) {
|
|
15
16
|
return [
|
|
16
17
|
...importRoutes(env),
|
|
@@ -26,6 +27,7 @@ export function buildRoutes(env) {
|
|
|
26
27
|
...historyRoutes(env),
|
|
27
28
|
...recoveryRoutes(env),
|
|
28
29
|
...sessionExportRoutes(env),
|
|
30
|
+
...pathBrowseRoutes(env),
|
|
29
31
|
];
|
|
30
32
|
}
|
|
31
33
|
//# sourceMappingURL=index.js.map
|
package/lib/routes/kit.d.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* - 「每条路由首行 guard」这条安全不变量此前**只写在注释里**,零测试覆盖;
|
|
9
9
|
* - 3 条 prefix 路由自造围栏(裸 isLoopbackRequest + 自判方法)。
|
|
10
10
|
*
|
|
11
|
-
* 计数口径(三路一致 =
|
|
12
|
-
* 路由快照
|
|
11
|
+
* 计数口径(三路一致 = 83,勿再照抄旧审计初稿的计数 —— 那是计数口径 overcount):
|
|
12
|
+
* 路由快照 83 条 = 现行 `endpoint()` 声明 83 条 = 运行期 `buildRoutes()` 76 条 + `src/index.ts` 保留的 7 条。
|
|
13
13
|
* 增删路由后必须同步这段计数、`src/routes/index.ts` 的头注释与 parity 快照清单;`tests/route/route-parity.test.ts`
|
|
14
14
|
* 的「路由计数散文」守卫会拿真实声明数逐条对账(写错即红灯)。
|
|
15
15
|
*
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路由组:本机路径浏览(导入向导「路径映射」→ 选择本机路径弹窗的唯一数据源)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要一条宿主路由:浏览器拿不到用户机器的真实目录树(File System Access API 只给 handle、
|
|
5
|
+
* `<input type="file">` 只给文件名),而路径映射要求为一堆绝对路径逐个指定本机新位置。
|
|
6
|
+
* 没有这条路由,用户只能凭记忆手打,打错就是「导不进」。
|
|
7
|
+
*
|
|
8
|
+
* 语义与边界
|
|
9
|
+
* ----------
|
|
10
|
+
* - **只读**:readdir/stat,绝不创建/改写/删除;不读文件内容;
|
|
11
|
+
* - POST 形态只**弹系统对话框**(core/native-path-picker.ts),同样不碰盘面;不可用时回
|
|
12
|
+
* `reason:'unsupported'|'failed'`,客户端回落 GET 的内置浏览器 —— 对话框不是用户的操作死路;
|
|
13
|
+
* - 空 `path` = 「此电脑」根列表(Windows 盘符探测,见 core/path-browse.ts);
|
|
14
|
+
* - 失败用枚举码回报(`error: 'not-found'|'denied'|'failed'|'not-absolute'`,成功为 null),
|
|
15
|
+
* 用户可见句子由客户端按字典解析 —— 引擎不写中文;
|
|
16
|
+
* - 有界:单次列举 500 条上限 + truncated 标记;整体套 withTimeout,不让一个 panic 盘符拖住请求。
|
|
17
|
+
*/
|
|
18
|
+
import { endpoint, errorMessage, queryParam, readJsonBody, writeJson } from './kit.js';
|
|
19
|
+
import { browseDirectory } from '../core/path-browse.js';
|
|
20
|
+
import { pickNativeDirectory } from '../core/native-path-picker.js';
|
|
21
|
+
import { withTimeout } from './route-support.js';
|
|
22
|
+
/** 单次列举的整体时限:正常目录远快于此;网络盘悬挂时宁可报错也不让请求挂着。 */
|
|
23
|
+
const BROWSE_TIMEOUT_MS = 10_000;
|
|
24
|
+
/** POST 可透传文本的长度上限:只用来当对话框的**起点**与**标题**,都不该是长文本。 */
|
|
25
|
+
const PICK_TEXT_MAX = 512;
|
|
26
|
+
/** 取请求体里的短文本字段:非字符串/空串/超长一律视为未提供(对话框走缺省值,不报错)。 */
|
|
27
|
+
function pickText(body, key) {
|
|
28
|
+
const value = body?.[key];
|
|
29
|
+
if (typeof value !== 'string')
|
|
30
|
+
return undefined;
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
return trimmed === '' || trimmed.length > PICK_TEXT_MAX ? undefined : trimmed;
|
|
33
|
+
}
|
|
34
|
+
export function pathBrowseRoutes(_env) {
|
|
35
|
+
return [
|
|
36
|
+
// ---------------------------------------------------- path-browse
|
|
37
|
+
// GET ?path=<绝对路径>:列一层目录(目录在前)。path 缺省 = 根列表。
|
|
38
|
+
// POST { startPath?, title? }:弹**系统原生**目录选择器(Windows IFileOpenDialog / macOS
|
|
39
|
+
// choose folder / Linux zenity·kdialog)。回 `{ ok:false, reason:'unsupported'|'failed' }`
|
|
40
|
+
// 时客户端回落到 GET 的内置浏览器 —— 同一条路径承载两种能力,避免为一个对话框新开一条路由。
|
|
41
|
+
// 标题由客户端按当前语言传进来(引擎不产中文)。
|
|
42
|
+
endpoint({ path: '/api/dsh-config-manager/path-browse', methods: ['GET', 'POST'] }, async (req, res) => {
|
|
43
|
+
if (req.method === 'POST') {
|
|
44
|
+
// 用户可能在系统对话框里逛几分钟 —— 时限由 core 的口子兜(10 分钟),此处不再叠加。
|
|
45
|
+
const body = await readJsonBody(req);
|
|
46
|
+
writeJson(res, 200, await pickNativeDirectory({
|
|
47
|
+
startPath: pickText(body, 'startPath'),
|
|
48
|
+
title: pickText(body, 'title'),
|
|
49
|
+
}));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
53
|
+
try {
|
|
54
|
+
const result = await withTimeout(browseDirectory(queryParam(url, 'path')), BROWSE_TIMEOUT_MS, 'path browse timed out');
|
|
55
|
+
writeJson(res, 200, result);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
writeJson(res, 500, { error: errorMessage(error) });
|
|
59
|
+
}
|
|
60
|
+
}),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=path-browse.js.map
|
|
@@ -72,7 +72,7 @@ const BEARER_TOKEN_CHARS = '[A-Za-z0-9._~+/=-]';
|
|
|
72
72
|
* 于是 `bearer credentials are required` / `bearer authentication failed` / `BEARER HEADER NOT SET`
|
|
73
73
|
* 这类纯小写或纯大写的英文词**不命中**;而 `bearer abc12345`、`BEARER AbCdEfGh`、`bearer <24+ 字符>` 命中。
|
|
74
74
|
*
|
|
75
|
-
* 已知残余(登记在 `docs/
|
|
75
|
+
* 已知残余(登记在 `docs/maintenance/known-gaps.md` G-36):scheme 用非规范大小写**且** token 是 8–23 个**纯字母、
|
|
76
76
|
* 单一大小写**时本条不命中;规范 `Bearer …` 形态不受此限(上面那条 `bearer-token` 只要求 8+ 字符,行为不变)。
|
|
77
77
|
*/
|
|
78
78
|
const BEARER_TOKEN_ANY_CASE = new RegExp(BEARER_SCHEME_ANY_CASE +
|
|
@@ -511,4 +511,22 @@ export declare function workdirBacklog(workDir: {
|
|
|
511
511
|
};
|
|
512
512
|
error?: string;
|
|
513
513
|
} | undefined): WorkdirBacklog;
|
|
514
|
+
/**
|
|
515
|
+
* 「这一步该按哪个方向」—— 动作行只留一个 primary(issue 115:两个按钮并排、首次使用看不出该按哪个)。
|
|
516
|
+
*
|
|
517
|
+
* 判定只用**已确证**的远端事实,没证据就回落下行:一键同步先拉远端再三方合并、逐项确认,
|
|
518
|
+
* 不会拿本机默认值覆盖远端历史;上行推送才是产生新快照、改写远端历史的那个动作。
|
|
519
|
+
* 唯一让上行成为 primary 的情形是「远端确证零快照」(git 工作副本自检 remoteSnapshots === 0):
|
|
520
|
+
* 此时同步无事可做,用户要的就是第一次备份。判定入口收在本函数里(channel 一并传入),
|
|
521
|
+
* 组件不用在调用点再判一次通道 —— 判据只有一处。
|
|
522
|
+
*/
|
|
523
|
+
export declare function pickSyncPrimary(statusInfo: {
|
|
524
|
+
workDir?: {
|
|
525
|
+
facts?: {
|
|
526
|
+
ahead?: number | null;
|
|
527
|
+
remoteSnapshots?: number | null;
|
|
528
|
+
};
|
|
529
|
+
error?: string;
|
|
530
|
+
};
|
|
531
|
+
} | null | undefined, channel: string): 'push' | 'sync';
|
|
514
532
|
export declare function formatSyncUrlPreview(url: string, max?: number): string;
|
|
@@ -562,6 +562,26 @@ export function workdirBacklog(workDir) {
|
|
|
562
562
|
return { kind: 'never-synced', ahead };
|
|
563
563
|
return { kind: 'ahead', ahead, remoteSnapshots };
|
|
564
564
|
}
|
|
565
|
+
/**
|
|
566
|
+
* 「这一步该按哪个方向」—— 动作行只留一个 primary(issue 115:两个按钮并排、首次使用看不出该按哪个)。
|
|
567
|
+
*
|
|
568
|
+
* 判定只用**已确证**的远端事实,没证据就回落下行:一键同步先拉远端再三方合并、逐项确认,
|
|
569
|
+
* 不会拿本机默认值覆盖远端历史;上行推送才是产生新快照、改写远端历史的那个动作。
|
|
570
|
+
* 唯一让上行成为 primary 的情形是「远端确证零快照」(git 工作副本自检 remoteSnapshots === 0):
|
|
571
|
+
* 此时同步无事可做,用户要的就是第一次备份。判定入口收在本函数里(channel 一并传入),
|
|
572
|
+
* 组件不用在调用点再判一次通道 —— 判据只有一处。
|
|
573
|
+
*/
|
|
574
|
+
export function pickSyncPrimary(statusInfo, channel) {
|
|
575
|
+
// 只有 git 通道会做工作副本自检;别的通道没有这份事实,一律回落下行。
|
|
576
|
+
if (channel !== 'git')
|
|
577
|
+
return 'sync';
|
|
578
|
+
const backlog = workdirBacklog(statusInfo?.workDir);
|
|
579
|
+
if (backlog.kind === 'never-synced')
|
|
580
|
+
return 'push';
|
|
581
|
+
if (backlog.kind === 'clean' && backlog.remoteSnapshots === 0)
|
|
582
|
+
return 'push';
|
|
583
|
+
return 'sync';
|
|
584
|
+
}
|
|
565
585
|
export function formatSyncUrlPreview(url, max = 60) {
|
|
566
586
|
return url.slice(0, max);
|
|
567
587
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-config-manager",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
|
|
6
6
|
"icon": "./icon.svg",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"lightningcss": "1.33.0",
|
|
200
200
|
"lucide": "^1.61.0",
|
|
201
201
|
"lucide-react": "^1.61.0",
|
|
202
|
-
"morphicons": "^
|
|
202
|
+
"morphicons": "^2.0.0",
|
|
203
203
|
"react": "^18.3.1",
|
|
204
204
|
"react-dom": "^18.3.1",
|
|
205
205
|
"schemastery": "^3.18.0",
|
|
@@ -284,4 +284,58 @@ test('self: 运行态改写不改变分区 hash(自动同步不再每轮误判
|
|
|
284
284
|
await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-25T12:02:00.000Z' }, 'https://y/repo.git'), 'utf8'));
|
|
285
285
|
const third = await adapter.export(src, { includeSecrets: false });
|
|
286
286
|
assert.notEqual(hashSection(third.data), hashSection(second.data), '真实配置变化必须让分区 hash 变化');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* issue #119 回归:跳过码翻转不得改变分区 hash。
|
|
291
|
+
*
|
|
292
|
+
* 现场(0.2.0 / macOS desktop / git 通道 + 自动同步 60s 远端轮询):配置一字未改,远端仓库
|
|
293
|
+
* 每分钟多 1~2 个 commit,15 分钟 24 个。根因 = 调度器「跳过」轮写
|
|
294
|
+
* lastRunSkipReason='upToDate'(冲突轮写 'conflict' + lastRunSkipCount),而「真推」轮收尾
|
|
295
|
+
* 又把它们清空(toChannelPayload 空值不写);两个字段不在剔除表里 → self 分区 hash 每轮翻新
|
|
296
|
+
* → hasLocalChanges() 恒真 → 每轮都真推一个新快照。
|
|
297
|
+
*/
|
|
298
|
+
test('self: 跳过码翻转不改变分区 hash(issue #119:自动同步不再每轮真推快照)', async () => {
|
|
299
|
+
const rel = 'dsh-config-manager/sync/sync-autosync.json';
|
|
300
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
301
|
+
const adapter = new SelfAdapter();
|
|
302
|
+
const config = (runState: Record<string, unknown>, repoUrl = 'https://x/repo.git') => JSON.stringify({
|
|
303
|
+
schemaVersion: 2,
|
|
304
|
+
channels: {
|
|
305
|
+
git: { enabled: true, intervalMinutes: 30, realtime: true, remoteCheckMs: 60000, repoUrl, branch: 'main', ...runState },
|
|
306
|
+
webdav: { enabled: false },
|
|
307
|
+
},
|
|
308
|
+
}, null, 2);
|
|
309
|
+
|
|
310
|
+
// 轮次 A:跳过(无本地改动)
|
|
311
|
+
await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-29T09:00:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'upToDate', consecutiveFailures: 0 }), 'utf8'));
|
|
312
|
+
const skipRound = await adapter.export(src, { includeSecrets: false });
|
|
313
|
+
|
|
314
|
+
// 轮次 B:真推一次 —— 跳过码被清空,其余与 A 相同
|
|
315
|
+
await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-29T09:01:00.000Z', lastRunStatus: 'success', consecutiveFailures: 0 }), 'utf8'));
|
|
316
|
+
const pushRound = await adapter.export(src, { includeSecrets: false });
|
|
317
|
+
|
|
318
|
+
// 轮次 C:跳过(冲突,带待确认分区数)
|
|
319
|
+
await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-29T09:02:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'conflict', lastRunSkipCount: 3, consecutiveFailures: 0 }), 'utf8'));
|
|
320
|
+
const conflictRound = await adapter.export(src, { includeSecrets: false });
|
|
321
|
+
|
|
322
|
+
const base = hashSection(skipRound.data);
|
|
323
|
+
assert.equal(hashSection(pushRound.data), base, '跳过码被清空不得改变分区 hash(否则每轮都判「本地有改动」)');
|
|
324
|
+
assert.equal(hashSection(conflictRound.data), base, '跳过码换值 / 带分区数不得改变分区 hash');
|
|
325
|
+
|
|
326
|
+
const payload = Buffer.from(pushRound.data.files.find((f) => f.relativePath === 'sync/sync-autosync.json')!.data).toString('utf8');
|
|
327
|
+
assert.ok(!payload.includes('lastRunSkip'), '导出载荷不含跳过码:远端不该接收本机执行记录');
|
|
328
|
+
assert.ok(payload.includes('"repoUrl":"https://x/repo.git"'), '配置本体仍在载荷里');
|
|
329
|
+
|
|
330
|
+
// 同一份远端载荷回灌本机(本机多出跳过码)→ Skip,不是 Conflict
|
|
331
|
+
const dst = makeContext('linux', '/home/bob');
|
|
332
|
+
await dst.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-29T09:05:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'upToDate' }), 'utf8'));
|
|
333
|
+
const sections = new Map([['self', pushRound.data]]);
|
|
334
|
+
let items = await adapter.analyzeImport(pushRound.data, makeImportContext(dst, sections));
|
|
335
|
+
assert.equal(items[0]?.kind, 'Skip', '仅跳过码不同 → Skip(此前每轮判 Conflict 并重复推送)');
|
|
336
|
+
|
|
337
|
+
// 配置本体变化仍必须被识别(不能把剔除做成「什么都判不变」)
|
|
338
|
+
await dst.fs.writeFile(rel, Buffer.from(config({ lastRunSkipReason: 'upToDate' }, 'https://y/repo.git'), 'utf8'));
|
|
339
|
+
items = await adapter.analyzeImport(pushRound.data, makeImportContext(dst, sections));
|
|
340
|
+
assert.equal(items[0]?.kind, 'Conflict', '真实配置变化必须仍判 Conflict');
|
|
287
341
|
});
|
package/src/adapters/self.ts
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
* 且文件类分区不进 SecretScanner(与 pluginFiles/skills 同语义)。
|
|
46
46
|
*/
|
|
47
47
|
import type { HostContext } from '../core/types.ts';
|
|
48
|
+
import type { AutosyncConfig } from '../sync/autosync-config.ts';
|
|
49
|
+
import type { BackupScheduleConfig } from '../sync/backup-schedule-config.ts';
|
|
48
50
|
import type { RecursiveListing } from '../utils/recursive-walk.ts';
|
|
49
51
|
import { sectionMeta } from '../schema/section-registry.ts';
|
|
50
52
|
import { FileCollectionAdapter } from './file-collection.ts';
|
|
@@ -62,13 +64,48 @@ export const SELF_CONFIG_FILES: readonly string[] = [
|
|
|
62
64
|
'exports/.backup-notes.json',
|
|
63
65
|
];
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 调度配置里「运行态字段」的**命名口径**:调度器每轮收尾都会改写的字段名形状
|
|
69
|
+
* (`lastRun*` 一族 + `consecutiveFailures`)。剔除表必须覆盖两个调度配置的并集,
|
|
70
|
+
* 少一个都会让 self 分区的 hashSection 每轮翻新(issue #119)。
|
|
71
|
+
*/
|
|
72
|
+
type RunStateKeyOf<T> = {
|
|
73
|
+
[K in keyof T]-?: K extends 'consecutiveFailures' | `lastRun${string}` ? K : never;
|
|
74
|
+
}[keyof T];
|
|
75
|
+
|
|
76
|
+
type SchedulerRunStateKey = RunStateKeyOf<AutosyncConfig> | RunStateKeyOf<BackupScheduleConfig>;
|
|
77
|
+
|
|
65
78
|
/**
|
|
66
79
|
* 调度配置里的**运行态字段**:每次执行收尾都会被调度器改写,不属于「配置身份」。
|
|
67
80
|
* 仅比对时剔除(见 comparableContent);写盘仍写完整文件。
|
|
81
|
+
*
|
|
82
|
+
* 逐项列全而不是靠前缀过滤:前缀过滤会把**将来**新增的运行态字段静默吞掉(看不见的假 Skip),
|
|
83
|
+
* 漏列则让 hash 每轮翻新(issue #119)。所以列全 + 下面的穷尽断言,让编译器守住漂移。
|
|
68
84
|
*/
|
|
69
|
-
const
|
|
70
|
-
'lastRunAt',
|
|
71
|
-
|
|
85
|
+
const VOLATILE_RUN_STATE_KEY_LIST = [
|
|
86
|
+
'lastRunAt',
|
|
87
|
+
'lastRunStatus',
|
|
88
|
+
'lastRunMessage',
|
|
89
|
+
'lastRunHistoryId',
|
|
90
|
+
'consecutiveFailures',
|
|
91
|
+
// issue #119:跳过码每轮在「写入 / 清空」之间翻转,与 lastRunAt 同性质,漏剔即每轮真推快照
|
|
92
|
+
'lastRunSkipReason',
|
|
93
|
+
'lastRunSkipCount',
|
|
94
|
+
] as const satisfies readonly SchedulerRunStateKey[];
|
|
95
|
+
|
|
96
|
+
const VOLATILE_RUN_STATE_KEYS: ReadonlySet<string> = new Set(VOLATILE_RUN_STATE_KEY_LIST);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 编译期穷尽断言(零运行时开销):`AutosyncConfig` / `BackupScheduleConfig` 将来新增任何
|
|
100
|
+
* `lastRun*` 字段而没进上表时,`Exclude` 不再是 `never`,**这一行 typecheck 报错**。
|
|
101
|
+
* 只写 `satisfies keyof T` 挡不住「新增字段忘了加」——那正是 issue #119 的失效模式。
|
|
102
|
+
*/
|
|
103
|
+
type AssertExhaustive<T extends never> = T;
|
|
104
|
+
|
|
105
|
+
/** 见 AssertExhaustive 的注释;导出只为让断言参与类型检查(无需被引用)。 */
|
|
106
|
+
export type SchedulerRunStateKeysExhaustive = AssertExhaustive<
|
|
107
|
+
Exclude<SchedulerRunStateKey, (typeof VOLATILE_RUN_STATE_KEY_LIST)[number]>
|
|
108
|
+
>;
|
|
72
109
|
|
|
73
110
|
/** 含运行态字段的调度配置(相对 baseDir)。 */
|
|
74
111
|
const SCHEDULER_CONFIG_RELS: ReadonlySet<string> = new Set([
|
package/src/cli/web/page.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* HTML:**零脚本、零外部资源、零构建产物**,配合 http.ts 里收紧的 CSP。
|
|
6
6
|
* 样式内联在 <style> 里(独立于插件 UI 的 CSS Modules;该新 pattern 已登记进 DESIGN.md)。
|
|
7
7
|
* 这套样式表**有一份外部出处**(亮色 IBM Carbon / 暗色 Raycast 阶梯)与逐条对照表:
|
|
8
|
-
* docs/
|
|
8
|
+
* docs/components/cli-rescue-console.md —— 改 STYLE 前先读它,改完跑 web.test.ts 的 R3-02。
|
|
9
9
|
*
|
|
10
10
|
* 呈现纪律:每个数字都要能回答「你怎么知道的」—— 离线拿不到的(结构化分区/凭据/同步令牌)
|
|
11
11
|
* 一律显式列出并说明原因,绝不显示为 0 或留空。
|
|
@@ -32,7 +32,7 @@ export interface PageChrome {
|
|
|
32
32
|
|
|
33
33
|
const STYLE = [
|
|
34
34
|
// ---- 设计 token:IBM Carbon(亮色语义四色)+ Raycast(暗色 surface 阶梯)----
|
|
35
|
-
// 映射表、取的章节与全部偏离记录:docs/
|
|
35
|
+
// 映射表、取的章节与全部偏离记录:docs/components/cli-rescue-console.md
|
|
36
36
|
// 文字色一律取「能读」的那一档:Carbon 的 #24a148 / #f1c21b 只做填充,正文用 #198038 / #8e6a00
|
|
37
37
|
// (AA 对比度由 web.test.ts 的 R3-02 逐对钉住,改色先过那条测试)。
|
|
38
38
|
':root{color-scheme:light dark;--font:system-ui,-apple-system,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans SC",sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--fg:#161616;--muted:#525252;--bg:#f4f4f4;--card:#fff;--surface:#f4f4f4;--line:#e0e0e0;--accent:#0f62fe;--accent-hover:#0043ce;--on-accent:#fff;--ok:#198038;--warn:#8e6a00;--bad:#da1e28;--scrim:rgba(22,22,22,.45)}',
|