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,166 @@
|
|
|
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
|
+
|
|
23
|
+
/** 单次列举上限:够翻一个源码目录,又不至于把响应体撑成几 MB。 */
|
|
24
|
+
export const BROWSE_ENTRY_LIMIT = 500
|
|
25
|
+
|
|
26
|
+
/** 盘符/根探测的单项硬超时(ms):断开的网络盘 stat 会长时间不返回。 */
|
|
27
|
+
export const ROOT_PROBE_TIMEOUT_MS = 400
|
|
28
|
+
|
|
29
|
+
/** 列举失败的原因码(客户端按它选字典键)。 */
|
|
30
|
+
export type BrowseErrorCode = 'not-found' | 'denied' | 'failed' | 'not-absolute'
|
|
31
|
+
|
|
32
|
+
/** 一条条目:目录(可进入)或文件(可选中)。 */
|
|
33
|
+
export interface BrowseEntry {
|
|
34
|
+
/** 末段名字(驱动器根即盘符本身,如 `D:\\`)。 */
|
|
35
|
+
name: string
|
|
36
|
+
/** 绝对路径(客户端原样回填输入框)。 */
|
|
37
|
+
path: string
|
|
38
|
+
kind: 'dir' | 'file'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 列举结果。 */
|
|
42
|
+
export interface BrowseResult {
|
|
43
|
+
/** 当前列出的目录;空串 = 「此电脑」(Windows 的盘符根列表)。 */
|
|
44
|
+
path: string
|
|
45
|
+
/** 上一级;已是根时为 null。 */
|
|
46
|
+
parent: string | null
|
|
47
|
+
/** 用户主目录(界面上的快捷入口;不随 path 变化)。 */
|
|
48
|
+
home: string
|
|
49
|
+
entries: BrowseEntry[]
|
|
50
|
+
/** 命中 {@link BROWSE_ENTRY_LIMIT} 被截断。 */
|
|
51
|
+
truncated: boolean
|
|
52
|
+
/** 失败原因码;成功为 null。 */
|
|
53
|
+
error: BrowseErrorCode | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 带硬超时的 Promise(只为本模块的盘符探测服务;超时后原 promise 的迟到结果被丢弃)。 */
|
|
57
|
+
function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
58
|
+
return new Promise<T>((resolve, reject) => {
|
|
59
|
+
const timer = setTimeout(() => { reject(new Error('timeout')) }, ms)
|
|
60
|
+
promise.then(
|
|
61
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
62
|
+
(error: unknown) => { clearTimeout(timer); reject(error) },
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 一个路径是不是「根」(Windows 盘符根 / POSIX 的 `/`)。 */
|
|
68
|
+
function isRoot(target: string): boolean {
|
|
69
|
+
return parse(target).root === target
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 本机根列表:Windows 上是**探测得到的**盘符根(不硬编码 C:),其余平台是 `/`。
|
|
74
|
+
* 探测逐根 400ms 超时;全部失败时至少给出 `parse(process.cwd()).root`,界面不至于空成白板。
|
|
75
|
+
*/
|
|
76
|
+
export async function listRoots(): Promise<BrowseEntry[]> {
|
|
77
|
+
if (process.platform !== 'win32') {
|
|
78
|
+
return [{ name: '/', path: '/', kind: 'dir' }]
|
|
79
|
+
}
|
|
80
|
+
const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split('')
|
|
81
|
+
const probed = await Promise.all(letters.map(async (letter) => {
|
|
82
|
+
const root = `${letter}:\\`
|
|
83
|
+
try {
|
|
84
|
+
const info = await withDeadline(stat(root), ROOT_PROBE_TIMEOUT_MS)
|
|
85
|
+
return info.isDirectory() ? root : null
|
|
86
|
+
} catch {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
}))
|
|
90
|
+
const roots = probed.filter((root): root is string => root !== null)
|
|
91
|
+
const fallback = parse(process.cwd()).root
|
|
92
|
+
const ordered = roots.length > 0 ? roots : (fallback === '' ? [] : [fallback])
|
|
93
|
+
return ordered.map((root) => ({ name: root, path: root, kind: 'dir' }))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 文件系统错误 → 原因码(只有 ENOENT 才叫「不存在」)。 */
|
|
97
|
+
function errorCodeOf(error: unknown): BrowseErrorCode {
|
|
98
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
99
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return 'not-found'
|
|
100
|
+
if (code === 'EACCES' || code === 'EPERM') return 'denied'
|
|
101
|
+
return 'failed'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 名字排序:目录在前、同类按名字字典序(不依赖 locale,测试可复现)。 */
|
|
105
|
+
function compareEntries(a: BrowseEntry, b: BrowseEntry): number {
|
|
106
|
+
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
|
107
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 列出一个目录(缺省 = 根列表)。
|
|
112
|
+
*
|
|
113
|
+
* `target` 缺省或空串 = 根列表;凡不是绝对路径一律 `not-absolute`(不猜基准目录 —— 猜错就列出别人的东西)。
|
|
114
|
+
* @param options.limit 覆盖 {@link BROWSE_ENTRY_LIMIT}。
|
|
115
|
+
*/
|
|
116
|
+
export async function browseDirectory(
|
|
117
|
+
target: string | undefined,
|
|
118
|
+
options?: { limit?: number },
|
|
119
|
+
): Promise<BrowseResult> {
|
|
120
|
+
const limit = Math.max(1, Math.trunc(options?.limit ?? BROWSE_ENTRY_LIMIT))
|
|
121
|
+
const home = homedir()
|
|
122
|
+
const wanted = (target ?? '').trim()
|
|
123
|
+
if (wanted === '') {
|
|
124
|
+
const roots = await listRoots()
|
|
125
|
+
return { path: '', parent: null, home, entries: roots.slice(0, limit), truncated: roots.length > limit, error: null }
|
|
126
|
+
}
|
|
127
|
+
if (!parse(wanted).root) {
|
|
128
|
+
return { path: wanted, parent: null, home, entries: [], truncated: false, error: 'not-absolute' }
|
|
129
|
+
}
|
|
130
|
+
const current = parse(wanted).root === wanted ? wanted : wanted.replace(/[\\/]+$/, '')
|
|
131
|
+
let dirents
|
|
132
|
+
try {
|
|
133
|
+
dirents = await readdir(current, { withFileTypes: true })
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { path: current, parent: isRoot(current) ? null : dirname(current), home, entries: [], truncated: false, error: errorCodeOf(error) }
|
|
136
|
+
}
|
|
137
|
+
const entries: BrowseEntry[] = []
|
|
138
|
+
for (const dirent of dirents) {
|
|
139
|
+
const entryPath = join(current, dirent.name)
|
|
140
|
+
if (dirent.isDirectory()) {
|
|
141
|
+
entries.push({ name: dirent.name, path: entryPath, kind: 'dir' })
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (dirent.isFile()) {
|
|
145
|
+
entries.push({ name: dirent.name, path: entryPath, kind: 'file' })
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
// 符号链接 / junction / 其它特殊项:跟随一次(读不到就整条丢掉,绝不猜类型)
|
|
149
|
+
try {
|
|
150
|
+
const info = await stat(entryPath)
|
|
151
|
+
if (info.isDirectory()) entries.push({ name: dirent.name, path: entryPath, kind: 'dir' })
|
|
152
|
+
else if (info.isFile()) entries.push({ name: dirent.name, path: entryPath, kind: 'file' })
|
|
153
|
+
} catch {
|
|
154
|
+
// 断链:不展示(点了也进不去)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
entries.sort(compareEntries)
|
|
158
|
+
return {
|
|
159
|
+
path: current,
|
|
160
|
+
parent: isRoot(current) ? null : dirname(current),
|
|
161
|
+
home,
|
|
162
|
+
entries: entries.slice(0, limit),
|
|
163
|
+
truncated: entries.length > limit,
|
|
164
|
+
error: null,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -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';
|
|
@@ -195,7 +195,7 @@ export interface TranscodeResult {
|
|
|
195
195
|
interface ParsedBlock {
|
|
196
196
|
/**
|
|
197
197
|
* `thinking` = 推理块(Claude Code 的 `thinking` / `redacted_thinking`):共享 IR 的 `IrBlock`
|
|
198
|
-
* **没有 reasoning 承载位**(docs/
|
|
198
|
+
* **没有 reasoning 承载位**(docs/maintenance/known-gaps.md F-01),所以它既不进 IR 也不伪装成正文,
|
|
199
199
|
* 只**逐条计数**(ignored['thinking'])。判定口径与「`reasoning` 为何不在此列」见
|
|
200
200
|
* `REASONING_BLOCK_TYPES` 的注释。
|
|
201
201
|
*/
|
|
@@ -219,7 +219,7 @@ function usageField(usage: unknown, key: string): unknown {
|
|
|
219
219
|
* 沿「对象带 text 才算文本」的兜底把它变成一个**空文本块** —— 内容静默丢失,且那个空块还会多插入
|
|
220
220
|
* 一个换行。本仓其余 20+ 来源一律「推理不伪装成正文、只计数」(read-crush / read-opencode /
|
|
221
221
|
* read-qwen / read-pi / read-openclaw …),这里对齐同一口径;共享 IR 无 reasoning 承载位这件事
|
|
222
|
-
* 记在 docs/
|
|
222
|
+
* 记在 docs/maintenance/known-gaps.md 的 F-01。
|
|
223
223
|
*
|
|
224
224
|
* **为什么不把 `{type:'reasoning', text:'…'}` 一并纳入**(如实记明,别当成漏掉):
|
|
225
225
|
* ① Claude Code 不写这种块(它是别的导出器的归一命名,各来源自己的解析器已各自计报);
|
package/src/index.ts
CHANGED
|
@@ -180,7 +180,7 @@ export const name = 'config-manager'
|
|
|
180
180
|
export const inject = ['settings', 'credentials']
|
|
181
181
|
|
|
182
182
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
183
|
-
export const PLUGIN_VERSION = '0.1
|
|
183
|
+
export const PLUGIN_VERSION = '0.2.1'
|
|
184
184
|
|
|
185
185
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
186
186
|
const PLUGIN_NAME = 'dsh-config-manager'
|
|
@@ -245,7 +245,7 @@ export interface Config {
|
|
|
245
245
|
/**
|
|
246
246
|
* 本文件保留的 7 条路由的路径(被源码级守卫按文件窗口钉住,见下方 routesList 注释)。
|
|
247
247
|
*
|
|
248
|
-
* 其余
|
|
248
|
+
* 其余 76 条路由的路径**声明在各自的组文件里**(src/routes/*.ts 的 endpoint({ path, methods }))——
|
|
249
249
|
* 新增一条 API 只需那一条声明,不再有「常量表 + 路由对象」两处要同步。
|
|
250
250
|
*/
|
|
251
251
|
export const API = {
|
|
@@ -3350,7 +3350,7 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
|
|
|
3350
3350
|
/**
|
|
3351
3351
|
* 路由表:本文件只保留被源码级守卫按**文件窗口**钉住的 7 条(host-entry 审计 F-04 记录的
|
|
3352
3352
|
* tests/host/**、src/core/model-tools.test.ts、src/core/phase1-wiring.test.ts 的窗口断言)。
|
|
3353
|
-
* 其余
|
|
3353
|
+
* 其余 76 条已按域拆到 src/routes/*.ts,由 buildRoutes 组装。
|
|
3354
3354
|
* 注册顺序不影响匹配:命名路由必须互不相同(webServer 契约)。
|
|
3355
3355
|
*/
|
|
3356
3356
|
const routeEnv: RouteEnvInferred = makeRouteEnv()
|
package/src/market/security.ts
CHANGED
|
@@ -128,7 +128,7 @@ export function validateMarketItem(
|
|
|
128
128
|
for (const [sectionId, enabled] of Object.entries(internalManifest.sections) as [SectionId, boolean][]) {
|
|
129
129
|
if (!enabled) continue;
|
|
130
130
|
// 市场条目禁止分区(BANNED_MARKET_SECTIONS:sessions 历史会话 / pluginFiles 任意文件 / self 本地环境):
|
|
131
|
-
// 与 secrets 同级硬约束(产品决策 2026-08-19,guide docs/
|
|
131
|
+
// 与 secrets 同级硬约束(产品决策 2026-08-19,guide docs/spec/market-index-v1.md)。
|
|
132
132
|
if (BANNED_MARKET_SECTIONS.includes(sectionId)) {
|
|
133
133
|
return { status: 'invalid', errors: [marketMsg('market.security.bannedSection', { section: sectionId })], warnings, manifest, internalManifest, sections: [], checksumsOk: true };
|
|
134
134
|
}
|
package/src/market/types.ts
CHANGED
|
@@ -31,7 +31,7 @@ export type MarketPublishMode = 'migrate' | 'share';
|
|
|
31
31
|
export const MAX_MARKET_ZIP_BYTES = 64 * 1024 * 1024;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* 市场条目禁止携带的分区(产品决策 2026-08-19,guide docs/
|
|
34
|
+
* 市场条目禁止携带的分区(产品决策 2026-08-19,guide docs/spec/market-index-v1.md):
|
|
35
35
|
* - sessions:历史会话,含个人交互记录/上下文;
|
|
36
36
|
* - pluginFiles:任意文件直通(目录放啥带啥),无内容过滤,最易泄漏 token/密钥文件;
|
|
37
37
|
* - self:本地环境专属(sync 通道 URL / WebDAV 地址 / 市场配置 / UI 偏好),无分享价值且泄漏环境信息。
|
package/src/routes/index.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'
|
|
@@ -20,6 +20,7 @@ import { meRoutes } from './me.ts'
|
|
|
20
20
|
import { historyRoutes } from './history.ts'
|
|
21
21
|
import { recoveryRoutes } from './recovery.ts'
|
|
22
22
|
import { sessionExportRoutes } from './session-export.ts'
|
|
23
|
+
import { pathBrowseRoutes } from './path-browse.ts'
|
|
23
24
|
|
|
24
25
|
export function buildRoutes(env: RoutesEnv): WebRoute[] {
|
|
25
26
|
return [
|
|
@@ -36,5 +37,6 @@ export function buildRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
36
37
|
...historyRoutes(env),
|
|
37
38
|
...recoveryRoutes(env),
|
|
38
39
|
...sessionExportRoutes(env),
|
|
40
|
+
...pathBrowseRoutes(env),
|
|
39
41
|
]
|
|
40
42
|
}
|
package/src/routes/kit.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,70 @@
|
|
|
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.ts'
|
|
19
|
+
import type { WebRoute } from './kit.ts'
|
|
20
|
+
import type { RoutesEnv } from './context.ts'
|
|
21
|
+
import { browseDirectory } from '../core/path-browse.ts'
|
|
22
|
+
import { pickNativeDirectory } from '../core/native-path-picker.ts'
|
|
23
|
+
import { withTimeout } from './route-support.ts'
|
|
24
|
+
|
|
25
|
+
/** 单次列举的整体时限:正常目录远快于此;网络盘悬挂时宁可报错也不让请求挂着。 */
|
|
26
|
+
const BROWSE_TIMEOUT_MS = 10_000
|
|
27
|
+
|
|
28
|
+
/** POST 可透传文本的长度上限:只用来当对话框的**起点**与**标题**,都不该是长文本。 */
|
|
29
|
+
const PICK_TEXT_MAX = 512
|
|
30
|
+
|
|
31
|
+
/** 取请求体里的短文本字段:非字符串/空串/超长一律视为未提供(对话框走缺省值,不报错)。 */
|
|
32
|
+
function pickText(body: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
33
|
+
const value = body?.[key]
|
|
34
|
+
if (typeof value !== 'string') return undefined
|
|
35
|
+
const trimmed = value.trim()
|
|
36
|
+
return trimmed === '' || trimmed.length > PICK_TEXT_MAX ? undefined : trimmed
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function pathBrowseRoutes(_env: RoutesEnv): WebRoute[] {
|
|
40
|
+
return [
|
|
41
|
+
// ---------------------------------------------------- path-browse
|
|
42
|
+
// GET ?path=<绝对路径>:列一层目录(目录在前)。path 缺省 = 根列表。
|
|
43
|
+
// POST { startPath?, title? }:弹**系统原生**目录选择器(Windows IFileOpenDialog / macOS
|
|
44
|
+
// choose folder / Linux zenity·kdialog)。回 `{ ok:false, reason:'unsupported'|'failed' }`
|
|
45
|
+
// 时客户端回落到 GET 的内置浏览器 —— 同一条路径承载两种能力,避免为一个对话框新开一条路由。
|
|
46
|
+
// 标题由客户端按当前语言传进来(引擎不产中文)。
|
|
47
|
+
endpoint({ path: '/api/dsh-config-manager/path-browse', methods: ['GET', 'POST'] }, async (req, res) => {
|
|
48
|
+
if (req.method === 'POST') {
|
|
49
|
+
// 用户可能在系统对话框里逛几分钟 —— 时限由 core 的口子兜(10 分钟),此处不再叠加。
|
|
50
|
+
const body = await readJsonBody(req)
|
|
51
|
+
writeJson(res, 200, await pickNativeDirectory({
|
|
52
|
+
startPath: pickText(body, 'startPath'),
|
|
53
|
+
title: pickText(body, 'title'),
|
|
54
|
+
}))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
58
|
+
try {
|
|
59
|
+
const result = await withTimeout(
|
|
60
|
+
browseDirectory(queryParam(url, 'path')),
|
|
61
|
+
BROWSE_TIMEOUT_MS,
|
|
62
|
+
'path browse timed out',
|
|
63
|
+
)
|
|
64
|
+
writeJson(res, 200, result)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
writeJson(res, 500, { error: errorMessage(error) })
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
]
|
|
70
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 这类**合法**(RFC 7235 / 6750:auth-scheme 名大小写不敏感)的授权头在 env / headers / url 通道上
|
|
6
6
|
* 明文进包且**完全不可见**(refs / skipped 皆空)。
|
|
7
7
|
*
|
|
8
|
-
* 两条口径(改这条判定的任何人先读 `docs/
|
|
8
|
+
* 两条口径(改这条判定的任何人先读 `docs/maintenance/known-gaps.md` **G-36**):
|
|
9
9
|
* ① 规范形态 `Bearer …` 的**行为与历史逐字一致**(命中名仍是 `bearer-token`):本模块只**补**非规范大小写;
|
|
10
10
|
* ② 厂商前缀(`sk-` / `AKIA` / `ghp_` / `github_pat_`)**刻意保持大小写敏感** —— `SK-…` 不是真实厂商形态,
|
|
11
11
|
* 全改 `i` 会连带放宽整个脱敏面(日志 / 导出 / 界面渲染前 redact),代价大于收益 ⇒ 登记为**已知边界**
|
|
@@ -81,7 +81,7 @@ const BEARER_TOKEN_CHARS = '[A-Za-z0-9._~+/=-]';
|
|
|
81
81
|
* 于是 `bearer credentials are required` / `bearer authentication failed` / `BEARER HEADER NOT SET`
|
|
82
82
|
* 这类纯小写或纯大写的英文词**不命中**;而 `bearer abc12345`、`BEARER AbCdEfGh`、`bearer <24+ 字符>` 命中。
|
|
83
83
|
*
|
|
84
|
-
* 已知残余(登记在 `docs/
|
|
84
|
+
* 已知残余(登记在 `docs/maintenance/known-gaps.md` G-36):scheme 用非规范大小写**且** token 是 8–23 个**纯字母、
|
|
85
85
|
* 单一大小写**时本条不命中;规范 `Bearer …` 形态不受此限(上面那条 `bearer-token` 只要求 8+ 字符,行为不变)。
|
|
86
86
|
*/
|
|
87
87
|
const BEARER_TOKEN_ANY_CASE = new RegExp(
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 为什么需要它:体检列表此前把内部机器码直接拼上屏(location-mismatch / duplicate-id /
|
|
5
5
|
* missing-message-id / format-newer …),中英用户看到的都是英文枚举 —— 既违反
|
|
6
6
|
* AGENTS.md「禁止硬编码用户可见字符串」,也让用户无法判断这一行到底出了什么问题
|
|
7
|
-
* (已知缺口登记:docs/
|
|
7
|
+
* (已知缺口登记:docs/maintenance/known-gaps.md 的 G-24 剩余未做项 ③)。
|
|
8
8
|
*
|
|
9
9
|
* 口径与既有的 sessionLayoutReasonKey / 同步页的 cloudIssueKey 完全一致:
|
|
10
10
|
* **未知码回落 sessions.issue.unknown,绝不裸渲染机器码**。
|
|
@@ -12,7 +12,7 @@ import test from 'node:test'
|
|
|
12
12
|
import assert from 'node:assert/strict'
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
-
workdirBacklog,
|
|
15
|
+
workdirBacklog, pickSyncPrimary,
|
|
16
16
|
autosyncIntervalKey, buildSelectionRequest, buildSyncChannelBody, buildSyncConfigBody, buildSyncPushBody,
|
|
17
17
|
channelBackfillFromStatus, computeEncryptInvalid, encryptToggle, formatSyncStatusTimestamp,
|
|
18
18
|
formatSyncUrlPreview, githubPollDecision, hasSelectedSections, includeSecretsToggle,
|
|
@@ -575,3 +575,20 @@ test('issue #104 建议 3:workdirBacklog —— 本地领先且远端零快照
|
|
|
575
575
|
// 远端有过快照 ⇒ 只是没推完,语气不同
|
|
576
576
|
assert.deepEqual(workdirBacklog({ facts: { ahead: 3, remoteSnapshots: 5 } }), { kind: 'ahead', ahead: 3, remoteSnapshots: 5 });
|
|
577
577
|
});
|
|
578
|
+
|
|
579
|
+
test('issue #115:pickSyncPrimary —— 只有远端确证零快照才让「推送到远端」当主按钮', () => {
|
|
580
|
+
// 远端确实空(自检给出 0)→ 同步无事可做,第一次备份就是上传
|
|
581
|
+
assert.equal(pickSyncPrimary({ workDir: { facts: { ahead: 0, remoteSnapshots: 0 } } }, 'git'), 'push');
|
|
582
|
+
assert.equal(pickSyncPrimary({ workDir: { facts: { ahead: 14, remoteSnapshots: 0 } } }, 'git'), 'push', '本地攒着提交 + 远端零快照同样是首次备份');
|
|
583
|
+
// 远端有快照(哪怕本地领先)→ 回落下行:同步会先合并,不覆盖远端历史
|
|
584
|
+
assert.equal(pickSyncPrimary({ workDir: { facts: { ahead: 0, remoteSnapshots: 14 } } }, 'git'), 'sync');
|
|
585
|
+
assert.equal(pickSyncPrimary({ workDir: { facts: { ahead: 3, remoteSnapshots: 5 } } }, 'git'), 'sync');
|
|
586
|
+
// 没有事实(没走 detail 自检 / 自检失败 / 无状态)→ 一律回落下行,绝不把用户推向推送
|
|
587
|
+
assert.equal(pickSyncPrimary(undefined, 'git'), 'sync');
|
|
588
|
+
assert.equal(pickSyncPrimary(null, 'git'), 'sync');
|
|
589
|
+
assert.equal(pickSyncPrimary({ workDir: { error: 'git not found' } }, 'git'), 'sync');
|
|
590
|
+
assert.equal(pickSyncPrimary({ workDir: { facts: { ahead: null, remoteSnapshots: null } } }, 'git'), 'sync');
|
|
591
|
+
// 非 git 通道没有工作副本自检事实 ⇒ 即使 statusInfo 里带了别人的 workDir 也一律下行
|
|
592
|
+
assert.equal(pickSyncPrimary({ workDir: { facts: { ahead: 0, remoteSnapshots: 0 } } }, 'webdav'), 'sync');
|
|
593
|
+
assert.equal(pickSyncPrimary({ workDir: { facts: { ahead: 0, remoteSnapshots: 0 } } }, 's3'), 'sync');
|
|
594
|
+
});
|
|
@@ -913,6 +913,27 @@ export function workdirBacklog(
|
|
|
913
913
|
return { kind: 'ahead', ahead, remoteSnapshots }
|
|
914
914
|
}
|
|
915
915
|
|
|
916
|
+
/**
|
|
917
|
+
* 「这一步该按哪个方向」—— 动作行只留一个 primary(issue 115:两个按钮并排、首次使用看不出该按哪个)。
|
|
918
|
+
*
|
|
919
|
+
* 判定只用**已确证**的远端事实,没证据就回落下行:一键同步先拉远端再三方合并、逐项确认,
|
|
920
|
+
* 不会拿本机默认值覆盖远端历史;上行推送才是产生新快照、改写远端历史的那个动作。
|
|
921
|
+
* 唯一让上行成为 primary 的情形是「远端确证零快照」(git 工作副本自检 remoteSnapshots === 0):
|
|
922
|
+
* 此时同步无事可做,用户要的就是第一次备份。判定入口收在本函数里(channel 一并传入),
|
|
923
|
+
* 组件不用在调用点再判一次通道 —— 判据只有一处。
|
|
924
|
+
*/
|
|
925
|
+
export function pickSyncPrimary(
|
|
926
|
+
statusInfo: { workDir?: { facts?: { ahead?: number | null; remoteSnapshots?: number | null }; error?: string } } | null | undefined,
|
|
927
|
+
channel: string,
|
|
928
|
+
): 'push' | 'sync' {
|
|
929
|
+
// 只有 git 通道会做工作副本自检;别的通道没有这份事实,一律回落下行。
|
|
930
|
+
if (channel !== 'git') return 'sync';
|
|
931
|
+
const backlog = workdirBacklog(statusInfo?.workDir)
|
|
932
|
+
if (backlog.kind === 'never-synced') return 'push';
|
|
933
|
+
if (backlog.kind === 'clean' && backlog.remoteSnapshots === 0) return 'push';
|
|
934
|
+
return 'sync';
|
|
935
|
+
}
|
|
936
|
+
|
|
916
937
|
export function formatSyncUrlPreview(url: string, max = 60): string {
|
|
917
938
|
return url.slice(0, max)
|
|
918
939
|
}
|