dsh-config-manager 0.2.1 → 0.2.3
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 +13 -13
- package/lib/client.js +542 -545
- package/lib/core/messages-core.js +2 -2
- package/lib/core/messages.d.ts +1 -0
- package/lib/core/messages.js +4 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/market/git-file-writer.js +20 -4
- package/lib/routes/messages.d.ts +2 -2
- package/lib/routes/messages.js +4 -4
- package/lib/sync/git/credential-auth.d.ts +14 -0
- package/lib/sync/git/credential-auth.js +84 -0
- package/lib/sync/git/git-transport.d.ts +1 -0
- package/lib/sync/git/git-transport.js +34 -7
- package/package.json +1 -1
- package/src/client/history/history-locales.ts +2 -2
- package/src/client/locales.ts +4 -4
- package/src/client/market/MyConfigsView.tsx +3 -3
- package/src/client/market/market-locales.ts +4 -4
- package/src/client/sync/SyncPanel.tsx +2 -2
- package/src/client/sync/SyncProbeRow.tsx +3 -2
- package/src/client/sync/sync-locales.ts +21 -19
- package/src/core/messages-core.ts +2 -2
- package/src/core/messages.ts +4 -0
- package/src/index.ts +1 -1
- package/src/market/git-file-writer.test.ts +76 -2
- package/src/market/git-file-writer.ts +23 -5
- package/src/routes/messages.ts +4 -4
- package/src/sync/git/credential-auth.ts +84 -0
- package/src/sync/git/git-transport.test.ts +96 -0
- package/src/sync/git/git-transport.ts +34 -8
|
@@ -53,7 +53,7 @@ export const zh = {
|
|
|
53
53
|
'core.journal.notTerminal': 'moveToCompleted 仅接受 terminal journal: {opId} state={state}',
|
|
54
54
|
// ---------- run-registry ----------
|
|
55
55
|
'core.run.autosyncBusy': '自动同步已在进行中',
|
|
56
|
-
'core.run.syncApplyBusy': '
|
|
56
|
+
'core.run.syncApplyBusy': '正在从远端合并',
|
|
57
57
|
'core.run.backupScheduleBusy': '定时备份已在进行中',
|
|
58
58
|
'core.run.restoreBusy': '快照恢复已在进行中(请等待当前恢复完成)',
|
|
59
59
|
'core.run.recoveryBusy': '恢复/回滚已在进行中(请等待当前恢复完成)',
|
|
@@ -349,7 +349,7 @@ export const en = {
|
|
|
349
349
|
'core.journal.notTerminal': 'moveToCompleted only accepts a terminal journal: {opId} state={state}',
|
|
350
350
|
// ---------- run-registry ----------
|
|
351
351
|
'core.run.autosyncBusy': 'Automatic sync is already in progress',
|
|
352
|
-
'core.run.syncApplyBusy': '
|
|
352
|
+
'core.run.syncApplyBusy': 'Merge from remote is already in progress',
|
|
353
353
|
'core.run.backupScheduleBusy': 'Scheduled backup is already in progress',
|
|
354
354
|
'core.run.restoreBusy': 'Snapshot restore is already in progress (wait for the current restore to finish)',
|
|
355
355
|
'core.run.recoveryBusy': 'Recovery/rollback is already in progress (wait for the current recovery to finish)',
|
package/lib/core/messages.d.ts
CHANGED
|
@@ -409,6 +409,7 @@ export declare const zh: {
|
|
|
409
409
|
readonly 'sync.git.cmdFailed': 'git {args} 失败 (exit {code}): {err}';
|
|
410
410
|
readonly 'sync.git.pushTokenRequired': '推送需要访问令牌:远端是 http(s) 仓库,但本机没有可用的令牌(拉取公开仓库可以匿名完成,推送必须认证)。请在「同步 → 通道配置」里登录 GitHub 或填入访问令牌后重试。';
|
|
411
411
|
readonly 'sync.git.pushAuthFailed': '推送认证失败:{err}。请确认令牌有效且对该仓库有写权限(账号密码推送已被远端禁用);拉取成功不代表令牌可用 —— 公开仓库匿名即可拉取。可在「同步 → 通道配置」里重新登录或更换令牌。';
|
|
412
|
+
readonly 'sync.git.credentialHelperBlocked': 'git 在本机无法执行 credential helper(它需要 shell,而当前平台不允许从应用目录执行脚本/二进制),改用 HTTP 认证头重试也没成功。请确认 git 版本不低于 2.31(环境变量注入配置需要),或把仓库地址换成 ssh。原始错误:{err}';
|
|
412
413
|
readonly 'sync.git.repoUrlInvalid': '无法解析 repoUrl(http(s) 仓库必须为合法 URL): {url}';
|
|
413
414
|
readonly 'sync.git.invalidSnapshotId': '非法快照 id: {id}(仅允许字母数字开头,字符限 . _ -)';
|
|
414
415
|
readonly 'sync.webdav.baseUrlRequired': 'baseUrl 必须是非空字符串';
|
package/lib/core/messages.js
CHANGED
|
@@ -408,6 +408,9 @@ export const zh = {
|
|
|
408
408
|
// 推送认证(真机:公开仓库拉取可匿名成功、推送必失败,git 原文只说「Invalid username or token」)
|
|
409
409
|
'sync.git.pushTokenRequired': '推送需要访问令牌:远端是 http(s) 仓库,但本机没有可用的令牌(拉取公开仓库可以匿名完成,推送必须认证)。请在「同步 → 通道配置」里登录 GitHub 或填入访问令牌后重试。',
|
|
410
410
|
'sync.git.pushAuthFailed': '推送认证失败:{err}。请确认令牌有效且对该仓库有写权限(账号密码推送已被远端禁用);拉取成功不代表令牌可用 —— 公开仓库匿名即可拉取。可在「同步 → 通道配置」里重新登录或更换令牌。',
|
|
411
|
+
// issue #120:credential helper 必须经 shell 执行,Android 上 git 的 shell 路径不存在(askpass 又要 exec
|
|
412
|
+
// 权限,Android 10+ 禁执行应用目录文件)→ 已改用 HTTP 认证头重试;仍失败只能点名平台原因,别引向「令牌坏了」。
|
|
413
|
+
'sync.git.credentialHelperBlocked': 'git 在本机无法执行 credential helper(它需要 shell,而当前平台不允许从应用目录执行脚本/二进制),改用 HTTP 认证头重试也没成功。请确认 git 版本不低于 2.31(环境变量注入配置需要),或把仓库地址换成 ssh。原始错误:{err}',
|
|
411
414
|
'sync.git.repoUrlInvalid': '无法解析 repoUrl(http(s) 仓库必须为合法 URL): {url}',
|
|
412
415
|
'sync.git.invalidSnapshotId': '非法快照 id: {id}(仅允许字母数字开头,字符限 . _ -)',
|
|
413
416
|
'sync.webdav.baseUrlRequired': 'baseUrl 必须是非空字符串',
|
|
@@ -896,6 +899,7 @@ export const en = {
|
|
|
896
899
|
'sync.git.cmdFailed': 'git {args} failed (exit {code}): {err}',
|
|
897
900
|
'sync.git.pushTokenRequired': 'Push requires an access token, but none is available on this machine (public repositories can be fetched anonymously; pushing always needs authentication). Sign in to GitHub or paste an access token under Sync → Channel configuration, then retry.',
|
|
898
901
|
'sync.git.pushAuthFailed': 'Push authentication failed: {err}. Check that the token is valid and has write access to this repository (password authentication is disabled by the remote); a successful pull does not prove the token works — public repositories can be fetched anonymously. Sign in again or replace the token under Sync → Channel configuration.',
|
|
902
|
+
'sync.git.credentialHelperBlocked': 'git cannot run the credential helper on this machine (it needs a shell, and this platform does not allow executing scripts/binaries from the app directory), and retrying with an HTTP authorization header did not work either. Make sure git is 2.31 or newer (injecting config through environment variables needs it) or switch the repository address to ssh. Original error: {err}',
|
|
899
903
|
'sync.git.repoUrlInvalid': 'Cannot parse repoUrl (http(s) repositories must be valid URLs): {url}',
|
|
900
904
|
'sync.git.invalidSnapshotId': 'Invalid snapshot id: {id} (must start with an alphanumeric; only . _ - allowed)',
|
|
901
905
|
'sync.webdav.baseUrlRequired': 'baseUrl must be a non-empty string',
|
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.2.
|
|
80
|
+
export declare const PLUGIN_VERSION = "0.2.3";
|
|
81
81
|
/**
|
|
82
82
|
* Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
|
|
83
83
|
* 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
|
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.2.
|
|
139
|
+
export const PLUGIN_VERSION = '0.2.3';
|
|
140
140
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
141
141
|
const PLUGIN_NAME = 'dsh-config-manager';
|
|
142
142
|
/**
|
|
@@ -28,6 +28,7 @@ import { quoteGitValue } from '../utils/git-quote.js';
|
|
|
28
28
|
import { withWorkdirLock, WorkdirLockError } from '../utils/workdir-lock.js';
|
|
29
29
|
import { fastForwardFetchArgs, fastForwardMergeArgs, isMissingRemoteRefError, parseCurrentBranch } from '../utils/git-fast-forward.js';
|
|
30
30
|
import { marketMsg } from './messages-market.js';
|
|
31
|
+
import { clearDirContents, credentialHeaderEnv, isEmptyDir, isCredentialExecFailure } from '../sync/git/credential-auth.js';
|
|
31
32
|
const execFileAsync = promisify(execFile);
|
|
32
33
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
33
34
|
const DEFAULT_AUTHOR = { name: 'DSH Config Manager', email: 'publish@dsh.local' };
|
|
@@ -46,6 +47,8 @@ const defaultExec = async (cmd, args, opts) => {
|
|
|
46
47
|
maxBuffer: 16 * 1024 * 1024,
|
|
47
48
|
windowsHide: true,
|
|
48
49
|
encoding: 'utf8',
|
|
50
|
+
// issue #120:credential helper 走不了 shell 时改用 env 注入认证头重试(追加在继承的 process.env 上)
|
|
51
|
+
env: opts.env === undefined ? undefined : { ...process.env, ...opts.env },
|
|
49
52
|
});
|
|
50
53
|
return { stdout: String(stdout), stderr: String(stderr), code: 0 };
|
|
51
54
|
}
|
|
@@ -169,7 +172,7 @@ export class GitFileWriterClient {
|
|
|
169
172
|
const pushArgs = branch !== ''
|
|
170
173
|
? ['push', '-u', 'origin', branch, ...(call.force === true ? ['--force-with-lease'] : [])]
|
|
171
174
|
: ['push', '-u', 'origin', 'HEAD'];
|
|
172
|
-
const push = await this.runGit(pushArgs, { cwd: call.workDir, withCredential: true, token, mask });
|
|
175
|
+
const push = await this.runGit(pushArgs, { cwd: call.workDir, withCredential: true, token, mask, repoUrl: call.repoUrl });
|
|
173
176
|
if (push.code !== 0) {
|
|
174
177
|
throw new GitFileWriterError(mask(marketMsg('market.gitWriter.pushFailed', { stderr: push.stderr })));
|
|
175
178
|
}
|
|
@@ -189,8 +192,21 @@ export class GitFileWriterClient {
|
|
|
189
192
|
extra = cred.extraArgs;
|
|
190
193
|
cleanup = cred.cleanup;
|
|
191
194
|
}
|
|
195
|
+
// issue #120:只有「命令开跑前目录是空的」才敢在重试前清空 —— 绝不删用户文件。
|
|
196
|
+
const cloneTargetWasEmpty = args[0] === 'clone' ? await isEmptyDir(cwd) : false;
|
|
192
197
|
try {
|
|
193
|
-
|
|
198
|
+
let result = await this.exec(this.gitBin, [...extra, ...args], { cwd, timeoutMs: this.timeoutMs });
|
|
199
|
+
// issue #120:同 GitTransport —— credential helper 必须经 shell 执行,Android 上 git 的 shell 路径
|
|
200
|
+
// 不存在(askpass 又要 exec 权限)→ 改用 env 注入 HTTP 认证头重试一次(不需要 shell)。
|
|
201
|
+
if (result.code !== 0 && opts.withCredential === true && opts.token !== ''
|
|
202
|
+
&& isCredentialExecFailure(result.stderr + ' ' + result.stdout)) {
|
|
203
|
+
const headerEnv = credentialHeaderEnv(opts.repoUrl ?? '', this.credentialUsername, opts.token);
|
|
204
|
+
if (Object.keys(headerEnv).length > 0 && (args[0] !== 'clone' || cloneTargetWasEmpty)) {
|
|
205
|
+
if (args[0] === 'clone')
|
|
206
|
+
await clearDirContents(cwd);
|
|
207
|
+
result = await this.exec(this.gitBin, args, { cwd, timeoutMs: this.timeoutMs, env: headerEnv });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
194
210
|
if (result.code !== 0 && !opts.allowNonZero) {
|
|
195
211
|
throw new GitFileWriterError(opts.mask(marketMsg('market.gitWriter.commandFailed', { args: args.join(' '), stderr: result.stderr })));
|
|
196
212
|
}
|
|
@@ -241,7 +257,7 @@ export class GitFileWriterClient {
|
|
|
241
257
|
if (entries.length > 0) {
|
|
242
258
|
throw new GitFileWriterError(marketMsg('market.gitWriter.workDirNotEmpty', { workDir }));
|
|
243
259
|
}
|
|
244
|
-
const clone = await this.runGit(['clone', repoUrl, '.'], { cwd: workDir, withCredential: true, token, mask });
|
|
260
|
+
const clone = await this.runGit(['clone', repoUrl, '.'], { cwd: workDir, withCredential: true, token, mask, repoUrl });
|
|
245
261
|
if (clone.code !== 0) {
|
|
246
262
|
throw new GitFileWriterError(mask(marketMsg('market.gitWriter.cloneFailed', { stderr: clone.stderr })));
|
|
247
263
|
}
|
|
@@ -265,7 +281,7 @@ export class GitFileWriterClient {
|
|
|
265
281
|
return;
|
|
266
282
|
// 不再用 git pull --ff-only:并发下 FETCH_HEAD 会被写成多行 for-merge、pull 自己 fatal;
|
|
267
283
|
// 显式 refspec fetch + 显式远端跟踪分支 merge 完全不读 FETCH_HEAD(参数见 utils/git-fast-forward.ts)。
|
|
268
|
-
const fetched = await this.runGit(fastForwardFetchArgs(branch), { cwd: workDir, withCredential: true, allowNonZero: true, token, mask });
|
|
284
|
+
const fetched = await this.runGit(fastForwardFetchArgs(branch), { cwd: workDir, withCredential: true, allowNonZero: true, token, mask, repoUrl });
|
|
269
285
|
if (fetched.code !== 0) {
|
|
270
286
|
const combined = fetched.stderr + ' ' + fetched.stdout;
|
|
271
287
|
// 无 upstream / 远端还没有这个分支 → 静默跳过(内容可能已是最新)
|
package/lib/routes/messages.d.ts
CHANGED
|
@@ -21,13 +21,13 @@ export declare const zh: {
|
|
|
21
21
|
readonly 'route.syncPreviewFailed': '同步预览失败';
|
|
22
22
|
readonly 'route.syncSessionExpired': '同步会话不存在或已过期,请重新拉取预览';
|
|
23
23
|
readonly 'route.syncConflictResolutionRequired': '冲突项 {id} 必须提供 resolution(useRemote/keepLocal/skip)';
|
|
24
|
-
readonly 'route.syncApplySummary': '
|
|
24
|
+
readonly 'route.syncApplySummary': '从远端合并:{count} 项{status}{ineffective}';
|
|
25
25
|
readonly 'route.syncRollbackIncomplete': '(回滚未完成)';
|
|
26
26
|
readonly 'route.syncRolledBack': '(已回滚)';
|
|
27
27
|
readonly 'route.syncIneffectiveSuffix': '({count} 项未生效)';
|
|
28
28
|
readonly 'route.syncApplyPartial': '同步应用部分成功:有项未生效(见告警明细)';
|
|
29
29
|
readonly 'route.syncApplyFailed': '同步应用未完全成功';
|
|
30
|
-
readonly 'route.syncRollbackSummary': '
|
|
30
|
+
readonly 'route.syncRollbackSummary': '从远端合并回滚({id})';
|
|
31
31
|
readonly 'route.recoverySessionRepairRollback': '会话修复回滚';
|
|
32
32
|
readonly 'route.recoverySessionRepairDropped': '会话修复(丢弃重放重复行 {count} 行)';
|
|
33
33
|
readonly 'route.recoverySessionRepairRejected': '会话修复被拒绝:{reason}';
|
package/lib/routes/messages.js
CHANGED
|
@@ -39,13 +39,13 @@ export const zh = {
|
|
|
39
39
|
'route.syncPreviewFailed': '同步预览失败',
|
|
40
40
|
'route.syncSessionExpired': '同步会话不存在或已过期,请重新拉取预览',
|
|
41
41
|
'route.syncConflictResolutionRequired': '冲突项 {id} 必须提供 resolution(useRemote/keepLocal/skip)',
|
|
42
|
-
'route.syncApplySummary': '
|
|
42
|
+
'route.syncApplySummary': '从远端合并:{count} 项{status}{ineffective}',
|
|
43
43
|
'route.syncRollbackIncomplete': '(回滚未完成)',
|
|
44
44
|
'route.syncRolledBack': '(已回滚)',
|
|
45
45
|
'route.syncIneffectiveSuffix': '({count} 项未生效)',
|
|
46
46
|
'route.syncApplyPartial': '同步应用部分成功:有项未生效(见告警明细)',
|
|
47
47
|
'route.syncApplyFailed': '同步应用未完全成功',
|
|
48
|
-
'route.syncRollbackSummary': '
|
|
48
|
+
'route.syncRollbackSummary': '从远端合并回滚({id})',
|
|
49
49
|
// ---------- 事故恢复 ----------
|
|
50
50
|
'route.recoverySessionRepairRollback': '会话修复回滚',
|
|
51
51
|
'route.recoverySessionRepairDropped': '会话修复(丢弃重放重复行 {count} 行)',
|
|
@@ -79,13 +79,13 @@ export const en = {
|
|
|
79
79
|
'route.syncPreviewFailed': 'Sync preview failed',
|
|
80
80
|
'route.syncSessionExpired': 'The sync session does not exist or has expired; pull a fresh preview',
|
|
81
81
|
'route.syncConflictResolutionRequired': 'Conflict item {id} must provide a resolution (useRemote/keepLocal/skip)',
|
|
82
|
-
'route.syncApplySummary': '
|
|
82
|
+
'route.syncApplySummary': 'Merge from remote: {count} item(s){status}{ineffective}',
|
|
83
83
|
'route.syncRollbackIncomplete': ' (rollback incomplete)',
|
|
84
84
|
'route.syncRolledBack': ' (rolled back)',
|
|
85
85
|
'route.syncIneffectiveSuffix': ' ({count} item(s) had no effect)',
|
|
86
86
|
'route.syncApplyPartial': 'Sync apply partially succeeded: some items had no effect (see the warning details)',
|
|
87
87
|
'route.syncApplyFailed': 'Sync apply did not fully succeed',
|
|
88
|
-
'route.syncRollbackSummary': '
|
|
88
|
+
'route.syncRollbackSummary': 'Merge from remote rollback ({id})',
|
|
89
89
|
'route.recoverySessionRepairRollback': 'Session repair rollback',
|
|
90
90
|
'route.recoverySessionRepairDropped': 'Session repair (dropped {count} replayed duplicate row(s))',
|
|
91
91
|
'route.recoverySessionRepairRejected': 'Session repair was rejected: {reason}',
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 这条 git 失败是不是「credential helper 根本没能执行」(→ 值得改用 HTTP 认证头重试)。 */
|
|
2
|
+
export declare function isCredentialExecFailure(text: string): boolean;
|
|
3
|
+
/** 目录是否为空(读不到 = 非空:拿不准时绝不删东西,见 runGit 的 clone 重试)。 */
|
|
4
|
+
export declare function isEmptyDir(dir: string): Promise<boolean>;
|
|
5
|
+
/** 清空目录内容但保留目录本身(只用于「命令开跑前确认为空」的 clone 重试路径)。 */
|
|
6
|
+
export declare function clearDirContents(dir: string): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* 构造「用 HTTP 认证头代替 credential helper」的子进程环境变量。
|
|
9
|
+
*
|
|
10
|
+
* - 顺带把 `credential.helper` 置空:否则全局 `.gitconfig` 里配的 helper 仍会被调用并再次 cannot exec。
|
|
11
|
+
* - 明文 http 常被远端重定向到 https,而 git 是按**每条请求的 URL** 匹配配置的 → 两个前缀都挂一份。
|
|
12
|
+
* - 返回空对象 = 注入不了(repoUrl 不是合法 URL,或令牌为空),调用方保持原有失败路径。
|
|
13
|
+
*/
|
|
14
|
+
export declare function credentialHeaderEnv(repoUrl: string, username: string, token: string): Record<string, string>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* issue #120:**不依赖 shell、不依赖可执行文件**的 git 认证兜底。
|
|
5
|
+
*
|
|
6
|
+
* 真机现场(Android,App 内嵌 `dsh web`):git 二进制编译期把 GIT_SHELL_PATH 钉成
|
|
7
|
+
* Termux 的 `/data/data/com.termux/files/usr/bin/sh`,该路径在 App 沙箱里不存在,而 git 执行
|
|
8
|
+
* credential helper 的方式是把 helper 值拼成 `sh -c 'git <helper> ...'` —— **没有**「值里不含空格
|
|
9
|
+
* 就不经 shell」这种写法(本机 git trace 实测:`run_command: 'git credential-store get'` 之后紧接
|
|
10
|
+
* `start_command: '<git>/usr/bin/sh.exe' -c 'git credential-store get'`;裸 `credential.helper=store`
|
|
11
|
+
* 也一样)。于是 helper 恒定失败:
|
|
12
|
+
* `fatal: cannot exec 'git credential-store --file=... credential get': No such file or directory`
|
|
13
|
+
* → 无 tty 时退化成 `fatal: could not read Password for 'https://oauth2@github.com': terminal prompts disabled`。
|
|
14
|
+
*
|
|
15
|
+
* 换成 `core.askpass` 躲不开:askpass 是一个**要被 exec 的脚本**,而 Android 10 起 targetSdk ≥ 29
|
|
16
|
+
* 的应用不允许执行自己数据目录里的文件(SELinux untrusted_app_29+ 对 app_data_file 无 execute 权限)。
|
|
17
|
+
*
|
|
18
|
+
* 唯一不需要 exec 的通道是 git 自己从环境变量读配置(git ≥ 2.31 的 GIT_CONFIG_COUNT 协议):
|
|
19
|
+
* 把 `http.<scope>.extraheader = AUTHORIZATION: basic <base64(user:token)>` 挂进 git 子进程的 env,
|
|
20
|
+
* 凭据既不进 argv、也不落临时文件、更不需要 sh —— token 只出现在 git 子进程的环境里
|
|
21
|
+
* (Android 上 /proc/<pid>/environ 也只有同 uid 可读,比临时凭据文件更窄)。
|
|
22
|
+
*
|
|
23
|
+
* 用法:helper 路线先跑;失败且命中 isCredentialExecFailure() 时换这份 env 重试一次。
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* credential helper 无法被执行时的 git 原文特征:
|
|
27
|
+
* - `cannot exec ...: No such file or directory`:shell 或 helper 二进制不存在;
|
|
28
|
+
* - `could not read Password ...` / `terminal prompts disabled`:helper 没跑成,退化成读终端口令。
|
|
29
|
+
*/
|
|
30
|
+
const CREDENTIAL_EXEC_FAILURE_RE = /cannot exec|could not read Password|terminal prompts disabled/i;
|
|
31
|
+
/** 这条 git 失败是不是「credential helper 根本没能执行」(→ 值得改用 HTTP 认证头重试)。 */
|
|
32
|
+
export function isCredentialExecFailure(text) {
|
|
33
|
+
return CREDENTIAL_EXEC_FAILURE_RE.test(text);
|
|
34
|
+
}
|
|
35
|
+
/** 目录是否为空(读不到 = 非空:拿不准时绝不删东西,见 runGit 的 clone 重试)。 */
|
|
36
|
+
export async function isEmptyDir(dir) {
|
|
37
|
+
try {
|
|
38
|
+
return (await readdir(dir)).length === 0;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** 清空目录内容但保留目录本身(只用于「命令开跑前确认为空」的 clone 重试路径)。 */
|
|
45
|
+
export async function clearDirContents(dir) {
|
|
46
|
+
const entries = await readdir(dir).catch(() => []);
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
await rm(join(dir, entry), { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 构造「用 HTTP 认证头代替 credential helper」的子进程环境变量。
|
|
53
|
+
*
|
|
54
|
+
* - 顺带把 `credential.helper` 置空:否则全局 `.gitconfig` 里配的 helper 仍会被调用并再次 cannot exec。
|
|
55
|
+
* - 明文 http 常被远端重定向到 https,而 git 是按**每条请求的 URL** 匹配配置的 → 两个前缀都挂一份。
|
|
56
|
+
* - 返回空对象 = 注入不了(repoUrl 不是合法 URL,或令牌为空),调用方保持原有失败路径。
|
|
57
|
+
*/
|
|
58
|
+
export function credentialHeaderEnv(repoUrl, username, token) {
|
|
59
|
+
if (token === '')
|
|
60
|
+
return {};
|
|
61
|
+
let origin;
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(repoUrl);
|
|
64
|
+
// 只取 scheme://host[:port]/ 作作用域:路径无关(同主机全部仓库都用这份令牌)
|
|
65
|
+
origin = `${u.protocol}//${u.host}/`;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
const scopes = [origin];
|
|
71
|
+
if (origin.startsWith('http://'))
|
|
72
|
+
scopes.push(`https://${origin.slice('http://'.length)}`);
|
|
73
|
+
const authorization = `AUTHORIZATION: basic ${Buffer.from(`${username}:${token}`, 'utf8').toString('base64')}`;
|
|
74
|
+
const pairs = [['credential.helper', '']];
|
|
75
|
+
for (const scope of scopes)
|
|
76
|
+
pairs.push([`http.${scope}.extraheader`, authorization]);
|
|
77
|
+
const env = { GIT_CONFIG_COUNT: String(pairs.length) };
|
|
78
|
+
pairs.forEach(([key, value], index) => {
|
|
79
|
+
env[`GIT_CONFIG_KEY_${String(index)}`] = key;
|
|
80
|
+
env[`GIT_CONFIG_VALUE_${String(index)}`] = value;
|
|
81
|
+
});
|
|
82
|
+
return env;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=credential-auth.js.map
|
|
@@ -11,6 +11,7 @@ export interface GitExecResult {
|
|
|
11
11
|
export type GitExecFn = (cmd: string, args: string[], opts: {
|
|
12
12
|
cwd?: string;
|
|
13
13
|
timeoutMs?: number;
|
|
14
|
+
env?: Record<string, string>;
|
|
14
15
|
}) => Promise<GitExecResult>;
|
|
15
16
|
/** 工作副本自检事实(issue #79):origin 是否已跟随配置地址、有无未上行的本地提交。 */
|
|
16
17
|
export interface GitWorkDirFacts {
|
|
@@ -30,6 +30,7 @@ import { classifyNetworkErrorText, computeSnapshotMeta, DEFAULT_SYNC_TIMEOUT_MS,
|
|
|
30
30
|
import { parseJsonSafe } from '../../utils/json.js';
|
|
31
31
|
import { atomicWriteFile } from '../../utils/atomic-write.js';
|
|
32
32
|
import { quoteGitValue } from '../../utils/git-quote.js';
|
|
33
|
+
import { clearDirContents, credentialHeaderEnv, isEmptyDir, isCredentialExecFailure } from './credential-auth.js';
|
|
33
34
|
import { SECTION_FILE_PREFIXES } from '../../schema/config.js';
|
|
34
35
|
import { zhMsg } from '../../core/messages.js';
|
|
35
36
|
import { withWorkdirLock, WorkdirLockError } from '../../utils/workdir-lock.js';
|
|
@@ -119,6 +120,9 @@ const defaultExec = async (cmd, args, opts) => {
|
|
|
119
120
|
maxBuffer: 16 * 1024 * 1024,
|
|
120
121
|
windowsHide: true,
|
|
121
122
|
encoding: 'utf8',
|
|
123
|
+
// issue #120:credential helper 走 shell 且本机没有可用 shell 时,改用 env 注入认证头重试 ——
|
|
124
|
+
// 追加在继承的 process.env 上(env 缺省 = 完全继承,不改变既有行为)
|
|
125
|
+
env: opts.env === undefined ? undefined : { ...process.env, ...opts.env },
|
|
122
126
|
});
|
|
123
127
|
return { stdout: String(stdout), stderr: String(stderr), code: 0 };
|
|
124
128
|
}
|
|
@@ -823,8 +827,24 @@ export class GitTransport {
|
|
|
823
827
|
await cleanup();
|
|
824
828
|
throw new GitTransportError(this.msg('sync.git.pushTokenRequired'), { kind: 'auth', retryable: false });
|
|
825
829
|
}
|
|
830
|
+
// issue #120:只有「命令开跑前目录是空的」才敢在重试前清空 —— clone 失败可能留下半成品,
|
|
831
|
+
// 而目录里原本就有别的内容时宁可放弃重试,也绝不删用户文件。
|
|
832
|
+
const cloneTargetWasEmpty = args[0] === 'clone' ? await isEmptyDir(cwd) : false;
|
|
826
833
|
try {
|
|
827
|
-
|
|
834
|
+
let result = await this.o.exec(this.o.gitBin, [...extra, ...args], { cwd, timeoutMs: this.o.timeoutMs });
|
|
835
|
+
// issue #120:credential helper 必须经 shell 执行(值里不含空格也一样,git 统一拼成
|
|
836
|
+
// `sh -c 'git <helper> ...'`),而 Android 上 git 二进制的 GIT_SHELL_PATH 指向 App 沙箱里
|
|
837
|
+
// 不存在的 Termux sh;`core.askpass` 又要 exec 权限(Android 10+ 对 targetSdk ≥ 29 禁执行应用
|
|
838
|
+
// 目录里的文件)。两条路都走不通时,改用 env 注入 HTTP 认证头重试一次(不需要 shell)。
|
|
839
|
+
if (result.code !== 0 && token !== null && token !== ''
|
|
840
|
+
&& isCredentialExecFailure(result.stderr + ' ' + result.stdout)) {
|
|
841
|
+
const headerEnv = credentialHeaderEnv(this.o.repoUrl, this.o.credentialUsername, token);
|
|
842
|
+
if (Object.keys(headerEnv).length > 0 && (args[0] !== 'clone' || cloneTargetWasEmpty)) {
|
|
843
|
+
if (args[0] === 'clone')
|
|
844
|
+
await clearDirContents(cwd);
|
|
845
|
+
result = await this.o.exec(this.o.gitBin, args, { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
828
848
|
if (result.code !== 0 && !opts.allowNonZero) {
|
|
829
849
|
const combined = result.stderr + ' ' + result.stdout;
|
|
830
850
|
// 统一分类(超时 / 网络 / 鉴权 / 5xx…):上层可据 kind / retryable 分流,不必解析 message
|
|
@@ -836,12 +856,19 @@ export class GitTransport {
|
|
|
836
856
|
// 推送 + 鉴权失败:git 原文只说「认证失败」——换成能照做的说明(令牌失效 / 无写权限 / 远端禁用了密码推送)
|
|
837
857
|
// issue #115:clone 的鉴权失败原文(remote: Write access to repository not granted / 403)读不出「缺哪种权限」,
|
|
838
858
|
// 换成点名 Contents: Read and write 的说明 —— 否则用户只会以为令牌坏了(细粒度 PAT 常只勾了 Administration)
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
859
|
+
// issue #120:helper 根本没能执行时,「请确认令牌有效」是错误指引(令牌可能是好的)——
|
|
860
|
+
// 这类失败必须点名平台原因,排在 push/clone 的鉴权文案之前。
|
|
861
|
+
const credentialBlocked = isCredentialExecFailure(combined);
|
|
862
|
+
const message = credentialBlocked
|
|
863
|
+
? this.msg('sync.git.credentialHelperBlocked', { err: this.mask(result.stderr, token) })
|
|
864
|
+
: opts.requirePushToken === true && classified.kind === 'auth'
|
|
865
|
+
? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
|
|
866
|
+
: args[0] === 'clone' && classified.kind === 'auth'
|
|
867
|
+
? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
|
|
868
|
+
: this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
|
|
869
|
+
// 归类同样不能交给原文:`cannot exec ...: No such file or directory` 里的 ENOENT 会被判成
|
|
870
|
+
// notfound(「仓库不存在」),而真实原因是凭据通道不可用 → 钉成 auth(非瞬时、别重试)。
|
|
871
|
+
throw new GitTransportError(message, credentialBlocked ? { kind: 'auth', retryable: false } : classified);
|
|
845
872
|
}
|
|
846
873
|
return result;
|
|
847
874
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-config-manager",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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",
|
|
@@ -71,7 +71,7 @@ export const zh = {
|
|
|
71
71
|
'history.kind.profile-rename': '档案重命名',
|
|
72
72
|
'history.kind.profile-save': '档案保存',
|
|
73
73
|
'history.kind.profile-import': '档案导入',
|
|
74
|
-
'history.kind.sync-apply': '
|
|
74
|
+
'history.kind.sync-apply': '从远端合并',
|
|
75
75
|
'history.kind.autosync': '自动同步',
|
|
76
76
|
'history.kind.recovery': '恢复/回滚编排',
|
|
77
77
|
'history.kind.backup': '定时备份',
|
|
@@ -118,7 +118,7 @@ export const en = {
|
|
|
118
118
|
'history.kind.profile-rename': 'Profile rename',
|
|
119
119
|
'history.kind.profile-save': 'Profile save',
|
|
120
120
|
'history.kind.profile-import': 'Profile import',
|
|
121
|
-
'history.kind.sync-apply': '
|
|
121
|
+
'history.kind.sync-apply': 'Merge from remote',
|
|
122
122
|
'history.kind.autosync': 'Autosync',
|
|
123
123
|
'history.kind.recovery': 'Recovery/rollback',
|
|
124
124
|
'history.kind.backup': 'Scheduled backup',
|
package/src/client/locales.ts
CHANGED
|
@@ -123,7 +123,7 @@ export const zh = {
|
|
|
123
123
|
'overview.kind.other': '其他操作',
|
|
124
124
|
// 状态横幅
|
|
125
125
|
// —— 导出 ——
|
|
126
|
-
'export.hint': '
|
|
126
|
+
'export.hint': '默认导出推荐分区(可迁移、不含设备专属数据)。',
|
|
127
127
|
'export.encrypt': '加密备份',
|
|
128
128
|
'export.encryptHint': '用密码加密整个备份文件(AES-256-GCM)。密码仅用于本次加密,不会写入备份或任何配置文件;导入时必须输入该密码才能解锁。',
|
|
129
129
|
'export.password': '备份加密密码',
|
|
@@ -640,7 +640,7 @@ export const zh = {
|
|
|
640
640
|
'runs.kind.export': '导出',
|
|
641
641
|
'runs.kind.import': '导入',
|
|
642
642
|
'runs.kind.autosync': '自动同步',
|
|
643
|
-
'runs.kind.sync-apply': '
|
|
643
|
+
'runs.kind.sync-apply': '从远端合并',
|
|
644
644
|
'runs.kind.backup-schedule': '定时备份',
|
|
645
645
|
'runs.kind.restore': '快照恢复',
|
|
646
646
|
'runs.kind.profile-switch': '档案切换',
|
|
@@ -1013,7 +1013,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
1013
1013
|
'overview.kind.snapshot-delete': 'Snapshot delete',
|
|
1014
1014
|
'overview.kind.snapshot-prune': 'Snapshot prune',
|
|
1015
1015
|
'overview.kind.other': 'Other operation',
|
|
1016
|
-
'export.hint': 'Exports the recommended sections by default (portable, no device-specific data).
|
|
1016
|
+
'export.hint': 'Exports the recommended sections by default (portable, no device-specific data).',
|
|
1017
1017
|
'export.encrypt': 'Encrypt backup',
|
|
1018
1018
|
'export.encryptHint': 'Encrypt the entire backup file with a password (AES-256-GCM). The password is used once and never written into the backup or any config file; it is required to unlock the backup on import.',
|
|
1019
1019
|
'export.password': 'Backup encryption password',
|
|
@@ -1518,7 +1518,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
1518
1518
|
'runs.kind.export': 'Export',
|
|
1519
1519
|
'runs.kind.import': 'Import',
|
|
1520
1520
|
'runs.kind.autosync': 'Auto sync',
|
|
1521
|
-
'runs.kind.sync-apply': '
|
|
1521
|
+
'runs.kind.sync-apply': 'Merge from remote',
|
|
1522
1522
|
'runs.kind.backup-schedule': 'Scheduled backup',
|
|
1523
1523
|
'runs.kind.restore': 'Snapshot restore',
|
|
1524
1524
|
'runs.kind.profile-switch': 'Profile switch',
|
|
@@ -35,7 +35,6 @@ import type { MyConfigsApi, MyItemEntry } from './my-configs-api.ts'
|
|
|
35
35
|
import type { ListingStatusResponse } from '../../market/my-repo.ts'
|
|
36
36
|
import type { SyncApi, GithubPollResponse } from '../sync/sync-api.ts'
|
|
37
37
|
import { Button, Card, SectionTitle } from '../common/ui.tsx'
|
|
38
|
-
import { InfoHint } from '../common/InfoHint.tsx'
|
|
39
38
|
import { ConfirmDialog } from '../common/ConfirmDialog.tsx'
|
|
40
39
|
import { toast } from '../common/toast-store.ts'
|
|
41
40
|
import { redact } from '../../security/redaction.ts'
|
|
@@ -773,10 +772,11 @@ export function MyConfigsView({
|
|
|
773
772
|
<>
|
|
774
773
|
{/* 一键上传入口:点按钮 → 免责(首次)→ 弹窗向导 */}
|
|
775
774
|
<Card>
|
|
776
|
-
{/*
|
|
775
|
+
{/* 卡片里只有一个动作:按钮本身已写明作用对象(上传配置包),不再重复标题。
|
|
776
|
+
此处不再放 ⓘ:它的「先在导出页生成 zip / 含密钥的备份会被拒绝」已在点开后的
|
|
777
|
+
向导头(MyConfigsWizard 的 Modal.Header trailing)逐字出现,入口再放一遍是同一条信息两次。 */}
|
|
777
778
|
<div className={css.actionRow}>
|
|
778
779
|
<Button variant="primary" onClick={openUpload}>{t('myconfigs.upload.run')}</Button>
|
|
779
|
-
<InfoHint text={t('myconfigs.upload.selectHint')} label={t('common.infoHint')} />
|
|
780
780
|
</div>
|
|
781
781
|
</Card>
|
|
782
782
|
{renderList()}
|
|
@@ -145,11 +145,11 @@ export const zh = {
|
|
|
145
145
|
'myconfigs.upload.mode.migrate': '迁移(保留全部内容)',
|
|
146
146
|
'myconfigs.upload.mode.share': '分享(自动排除敏感内容)',
|
|
147
147
|
'myconfigs.upload.mode.shareHint': '分享模式将自动排除设备/平台相关分区(凭据状态 / 会话 / MCP / 工作区等),并对隐私内容强制拦截——发现任何敏感痕迹将拒绝发布,而不是仅警告。',
|
|
148
|
-
'myconfigs.upload.run': '
|
|
148
|
+
'myconfigs.upload.run': '上传配置包',
|
|
149
149
|
'myconfigs.update.title': '更新配置',
|
|
150
150
|
'myconfigs.update.hint': '版本自动 +1',
|
|
151
151
|
'myconfigs.update.run': '一键更新',
|
|
152
|
-
'myconfigs.update.zipHint': '
|
|
152
|
+
'myconfigs.update.zipHint': '选择新 zip 后自动校验,通过才能更新。',
|
|
153
153
|
'myconfigs.update.selectZip': '选择新 ZIP…',
|
|
154
154
|
// 上传/更新结果卡
|
|
155
155
|
'myconfigs.result.title': '上传/更新完成',
|
|
@@ -324,11 +324,11 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
324
324
|
'myconfigs.upload.mode.migrate': 'Migrate (keep everything)',
|
|
325
325
|
'myconfigs.upload.mode.share': 'Share (auto-exclude sensitive content)',
|
|
326
326
|
'myconfigs.upload.mode.shareHint': 'Share mode auto-excludes device/platform-specific sections (credentials status / sessions / MCP / workspaces, etc.) and enforces privacy scanning — any sensitive trace blocks publishing instead of just warning.',
|
|
327
|
-
'myconfigs.upload.run': 'Upload',
|
|
327
|
+
'myconfigs.upload.run': 'Upload bundle',
|
|
328
328
|
'myconfigs.update.title': 'Update config',
|
|
329
329
|
'myconfigs.update.hint': 'version +1 automatically',
|
|
330
330
|
'myconfigs.update.run': 'Update',
|
|
331
|
-
'myconfigs.update.zipHint': '
|
|
331
|
+
'myconfigs.update.zipHint': 'Pick a new zip and it will be validated automatically; only a passing zip can be installed.',
|
|
332
332
|
'myconfigs.update.selectZip': 'Choose new ZIP…',
|
|
333
333
|
'myconfigs.result.title': 'Upload/update complete',
|
|
334
334
|
'myconfigs.result.version': 'version {version}',
|
|
@@ -1260,8 +1260,8 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
|
|
|
1260
1260
|
)}
|
|
1261
1261
|
{ready && (
|
|
1262
1262
|
<Card>
|
|
1263
|
-
{/* 动作行(方案 A 定案):用户只面对两个方向 ——
|
|
1264
|
-
|
|
1263
|
+
{/* 动作行(方案 A 定案):用户只面对两个方向 —— 下行「从远端合并到本机」/ 上行「推送到远端」。
|
|
1264
|
+
合并裁决并入下行按钮(有基线走三方裁决视图、无基线走老导入确认);并排两个按钮看不出该按哪个(issue 115)⇒ 按已确证远端事实只让一个落在 primary。 */}
|
|
1265
1265
|
<div className={css.actionRow}>
|
|
1266
1266
|
<Button
|
|
1267
1267
|
variant={pickSyncPrimary(state.statusInfo, channel) === 'sync' ? 'primary' : 'ghost'}
|
|
@@ -111,9 +111,10 @@ export function SyncProbeRow({ api, t, body, remoteReady, busy, presentation = '
|
|
|
111
111
|
|
|
112
112
|
return (
|
|
113
113
|
<>
|
|
114
|
-
{/* action 形态:直接摊进调用方自己的动作行(不再套一层 .actionRow),结果走 toast
|
|
114
|
+
{/* action 形态:直接摊进调用方自己的动作行(不再套一层 .actionRow),结果走 toast。
|
|
115
|
+
动作行里只留按钮:与同排其它按钮并排时,ⓘ 会被读成该按钮的开关(issue 121 用户要求移除)。 */}
|
|
115
116
|
{actionMode
|
|
116
|
-
? <>{button}
|
|
117
|
+
? <>{button}</>
|
|
117
118
|
: <div className={css.actionRow}>{button}{hintNode}</div>}
|
|
118
119
|
{!actionMode && error !== null && (
|
|
119
120
|
<ErrorBanner
|