dsh-config-manager 0.1.85 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cli/web/page.js +2 -2
- package/lib/client.d.ts +86 -1
- package/lib/client.js +2365 -772
- 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/sync/git/git-transport.js +18 -3
- package/lib/ui/sync-settings-view.d.ts +18 -0
- package/lib/ui/sync-settings-view.js +20 -0
- package/lib/utils/workdir-lock.d.ts +26 -2
- package/lib/utils/workdir-lock.js +133 -5
- package/package.json +2 -2
- 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/sync/git/git-transport.test.ts +32 -0
- package/src/sync/git/git-transport.ts +20 -4
- 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
- package/src/utils/workdir-lock.test.ts +77 -4
- package/src/utils/workdir-lock.ts +122 -5
|
@@ -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.
|
|
80
|
+
export declare const PLUGIN_VERSION = "0.2.0";
|
|
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.
|
|
139
|
+
export const PLUGIN_VERSION = '0.2.0';
|
|
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 +
|
|
@@ -53,6 +53,15 @@ const execFileAsync = promisify(execFile);
|
|
|
53
53
|
*/
|
|
54
54
|
const INSPECT_FACTS_TTL_MS = 10_000;
|
|
55
55
|
const inspectFactsCache = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* 只读自检等锁的上限(2026-10-27)。
|
|
58
|
+
*
|
|
59
|
+
* 为什么和写路径不同:写操作本来就该等(一次 push 十几秒是正常的),但自检挂在
|
|
60
|
+
* `GET /sync/status` 上,同步任务持锁期间它等待是**纯损失** —— 界面要的只是「工作副本有没有跟随
|
|
61
|
+
* 当前地址」,拿不到锁就如实报忙,让请求有界返回(再退一步还有 10 s 的接口预算兜底)。此前它沿用
|
|
62
|
+
* 缺省的 300 s:一次自动同步持锁就能让同步页每次打开都白等 10 s(真机 2026-10-27)。
|
|
63
|
+
*/
|
|
64
|
+
const INSPECT_WORKDIR_LOCK_TIMEOUT_MS = 1_500;
|
|
56
65
|
function inspectFactsCacheKey(workDir, repoUrl) {
|
|
57
66
|
return workDir + '\u0000' + repoUrl;
|
|
58
67
|
}
|
|
@@ -423,6 +432,8 @@ export class GitTransport {
|
|
|
423
432
|
}
|
|
424
433
|
}
|
|
425
434
|
try {
|
|
435
|
+
// 只读自检传短上限:它挂在状态接口上,等不起一次同步(见 INSPECT_WORKDIR_LOCK_TIMEOUT_MS);
|
|
436
|
+
// 写路径不传,沿用缺省的 300 s。
|
|
426
437
|
return await withWorkdirLock(this.o.workDir, async () => {
|
|
427
438
|
this.workdirLockDepth += 1;
|
|
428
439
|
try {
|
|
@@ -431,7 +442,7 @@ export class GitTransport {
|
|
|
431
442
|
finally {
|
|
432
443
|
this.workdirLockDepth -= 1;
|
|
433
444
|
}
|
|
434
|
-
});
|
|
445
|
+
}, opts.lockTimeoutMs === undefined ? {} : { timeoutMs: opts.lockTimeoutMs });
|
|
435
446
|
}
|
|
436
447
|
catch (err) {
|
|
437
448
|
// 等锁超时 = 别人正在同步(另一个 DSH 实例 / 另一个同步任务),不是通道故障:
|
|
@@ -543,8 +554,12 @@ export class GitTransport {
|
|
|
543
554
|
if (hit !== undefined && Date.now() - hit.at < INSPECT_FACTS_TTL_MS && (hit.detail || !detail)) {
|
|
544
555
|
return { ...hit.facts };
|
|
545
556
|
}
|
|
546
|
-
// 只读命令也要排队:与并发的 fetch/checkout 交错会让 status/rev-list
|
|
547
|
-
|
|
557
|
+
// 只读命令也要排队:与并发的 fetch/checkout 交错会让 status/rev-list 读到半更新状态。
|
|
558
|
+
// 但排队必须有界(短上限):拿不到锁就报「工作副本忙」,绝不把状态接口拖进 300 s 的等待。
|
|
559
|
+
const facts = await this.withWorkdir(() => this.inspectWorkDirLocked(opts), {
|
|
560
|
+
readonly: true,
|
|
561
|
+
lockTimeoutMs: INSPECT_WORKDIR_LOCK_TIMEOUT_MS,
|
|
562
|
+
});
|
|
548
563
|
const prev = inspectFactsCache.get(key);
|
|
549
564
|
if (detail || prev === undefined || !prev.detail) {
|
|
550
565
|
inspectFactsCache.set(key, { at: Date.now(), detail, facts });
|
|
@@ -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
|
}
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* - 锁文件放 `os.tmpdir()`(按工作目录绝对路径哈希命名),**不写进工作副本**:
|
|
20
20
|
* 工作副本还没 clone 出来也能先锁住,且绝不让 `git status` 看到脏文件;
|
|
21
21
|
* - 跨进程独占用 `open(path,'wx')`(O_EXCL,原子);等待方轮询,超时抛 WorkdirLockError;
|
|
22
|
-
* - holder
|
|
22
|
+
* - holder 崩溃留下的陈旧锁:先问 holder pid 是否还存在(ESRCH ⇒ 已被杀,立即回收),
|
|
23
|
+
* 读不出 pid 或进程仍存活时才按 mtime 超过 staleMs 回收(避免误抢仍在跑的长时间任务);
|
|
24
|
+
* - 等待方是**有界**的:等文件锁与进程内链上排队共用同一个 timeoutMs 上限,超时即抛,
|
|
25
|
+
* 绝不在界面早已放弃之后仍去驱动工作副本;
|
|
23
26
|
* - 进程内再串一条 Promise 链:同进程两个 GitTransport 实例(autosync / 路由)共用同一条链,
|
|
24
27
|
* 不依赖文件锁的轮询延迟。
|
|
25
28
|
*/
|
|
@@ -47,6 +50,27 @@ export interface WorkdirLockOptions {
|
|
|
47
50
|
export declare function workdirLockKey(workDir: string): string;
|
|
48
51
|
/** 锁文件路径(导出仅为测试与诊断;不要手工删一把可能被人持有的锁) */
|
|
49
52
|
export declare function workdirLockPath(workDir: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* holder 进程是否还活着(三态:true = 存活、false = 确定已死、null = 判不了)。
|
|
55
|
+
*
|
|
56
|
+
* 为什么必须问:holder 被强杀时(DSH 重启 / 崩溃 / 任务管理器结束进程)`finally` 不会执行,
|
|
57
|
+
* 锁文件就留在 tmpdir 里;只按 mtime 判定要等满 staleMs(15 分钟)才接管。这段窗口里**所有**
|
|
58
|
+
* 同步与只读自检都排在锁上 —— 真机 2026-10-27:DSH 重启踩在一次 push 中间,pid 28888 的锁留在
|
|
59
|
+
* %TEMP%,之后每次打开同步页 `GET /sync/status` 都要等满 10 s 预算再 503。
|
|
60
|
+
*
|
|
61
|
+
* 误差方向:只有 `ESRCH`(pid 完全不存在)才判死;`EPERM`(进程存在但无权发信号,如 Linux 跨用户)
|
|
62
|
+
* 判活;pid 复用也会把「别人的锁」判成活 —— 两种误判都偏向保守(最多退化成 mtime 规则),
|
|
63
|
+
* 绝不会把仍在跑的 holder 的锁抢走。
|
|
64
|
+
*/
|
|
65
|
+
export declare function workdirLockHolderAlive(pid: unknown): boolean | null;
|
|
66
|
+
/**
|
|
67
|
+
* 一次性清扫本机所有「holder 已死」的工作副本锁(每进程只跑一次)。
|
|
68
|
+
*
|
|
69
|
+
* 为什么需要:pid 回收只在**自己被这把锁挡住时**生效,而残留锁可能属于早已不再使用的工作目录
|
|
70
|
+
* (换过仓库、换过数据目录、临时实例)—— 它们永远不会再被查询,却一直躺在 %TEMP%(真机 2026-10-27
|
|
71
|
+
* 见到 8 个,最久 3.4 天)。只删 pid 确定不存在的锁:读不出 holder 的不动(保守,宁可留下)。
|
|
72
|
+
*/
|
|
73
|
+
export declare function pruneDeadWorkdirLocksOnce(): Promise<void>;
|
|
50
74
|
/**
|
|
51
75
|
* 在指定工作副本上串行执行 fn(同一目录的调用排队,跨进程同理)。
|
|
52
76
|
*
|
|
@@ -54,5 +78,5 @@ export declare function workdirLockPath(workDir: string): string;
|
|
|
54
78
|
* 不要在里面再调用会等这把锁的其他工作副本操作 —— 可重入请由调用方用自己的 depth 计数处理。
|
|
55
79
|
*/
|
|
56
80
|
export declare function withWorkdirLock<T>(workDir: string, fn: () => Promise<T>, options?: WorkdirLockOptions): Promise<T>;
|
|
57
|
-
/**
|
|
81
|
+
/** 仅供测试:清空进程内串行链与一次性清扫标记(不影响磁盘上的锁文件) */
|
|
58
82
|
export declare function resetWorkdirLockChainsForTest(): void;
|