dsh-config-manager 0.2.0 → 0.2.2
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/client.js +519 -519
- 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/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/adapters/self.test.ts +54 -0
- package/src/adapters/self.ts +40 -3
- 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/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
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.2";
|
|
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.2';
|
|
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 / 远端还没有这个分支 → 静默跳过(内容可能已是最新)
|
|
@@ -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.2",
|
|
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",
|
|
@@ -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/core/messages.ts
CHANGED
|
@@ -439,6 +439,9 @@ export const zh = {
|
|
|
439
439
|
// 推送认证(真机:公开仓库拉取可匿名成功、推送必失败,git 原文只说「Invalid username or token」)
|
|
440
440
|
'sync.git.pushTokenRequired': '推送需要访问令牌:远端是 http(s) 仓库,但本机没有可用的令牌(拉取公开仓库可以匿名完成,推送必须认证)。请在「同步 → 通道配置」里登录 GitHub 或填入访问令牌后重试。',
|
|
441
441
|
'sync.git.pushAuthFailed': '推送认证失败:{err}。请确认令牌有效且对该仓库有写权限(账号密码推送已被远端禁用);拉取成功不代表令牌可用 —— 公开仓库匿名即可拉取。可在「同步 → 通道配置」里重新登录或更换令牌。',
|
|
442
|
+
// issue #120:credential helper 必须经 shell 执行,Android 上 git 的 shell 路径不存在(askpass 又要 exec
|
|
443
|
+
// 权限,Android 10+ 禁执行应用目录文件)→ 已改用 HTTP 认证头重试;仍失败只能点名平台原因,别引向「令牌坏了」。
|
|
444
|
+
'sync.git.credentialHelperBlocked': 'git 在本机无法执行 credential helper(它需要 shell,而当前平台不允许从应用目录执行脚本/二进制),改用 HTTP 认证头重试也没成功。请确认 git 版本不低于 2.31(环境变量注入配置需要),或把仓库地址换成 ssh。原始错误:{err}',
|
|
442
445
|
'sync.git.repoUrlInvalid': '无法解析 repoUrl(http(s) 仓库必须为合法 URL): {url}',
|
|
443
446
|
'sync.git.invalidSnapshotId': '非法快照 id: {id}(仅允许字母数字开头,字符限 . _ -)',
|
|
444
447
|
'sync.webdav.baseUrlRequired': 'baseUrl 必须是非空字符串',
|
|
@@ -940,6 +943,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
940
943
|
'sync.git.cmdFailed': 'git {args} failed (exit {code}): {err}',
|
|
941
944
|
'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.',
|
|
942
945
|
'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.',
|
|
946
|
+
'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}',
|
|
943
947
|
'sync.git.repoUrlInvalid': 'Cannot parse repoUrl (http(s) repositories must be valid URLs): {url}',
|
|
944
948
|
'sync.git.invalidSnapshotId': 'Invalid snapshot id: {id} (must start with an alphanumeric; only . _ - allowed)',
|
|
945
949
|
'sync.webdav.baseUrlRequired': 'baseUrl must be a non-empty string',
|
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.2.
|
|
183
|
+
export const PLUGIN_VERSION = '0.2.2'
|
|
184
184
|
|
|
185
185
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
186
186
|
const PLUGIN_NAME = 'dsh-config-manager'
|
|
@@ -19,7 +19,7 @@ const TEST_TOKEN = 'gho_abcdefghijklmnopqrstuvwxyz123456';
|
|
|
19
19
|
interface ExecCall {
|
|
20
20
|
cmd: string;
|
|
21
21
|
args: string[];
|
|
22
|
-
opts: { cwd?: string; timeoutMs?: number };
|
|
22
|
+
opts: { cwd?: string; timeoutMs?: number; env?: Record<string, string> };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** 记录 exec 调用并按命令返回脚本化结果的 mock */
|
|
@@ -286,4 +286,78 @@ test('git-file-writer: 工作副本非空且非 git 仓库 → 拒绝', async (t
|
|
|
286
286
|
GitFileWriterError,
|
|
287
287
|
);
|
|
288
288
|
assert.ok(!calls.some((c) => c.args[0] === 'clone'), '非空非仓库目录不得 clone');
|
|
289
|
-
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// issue #120:Android 上 credential helper 走 `sh -c`,helper 无法执行 ⇒ 换 env 注入认证头重试。
|
|
292
|
+
test('git-file-writer: credential helper 无法执行 → 用 env 注入认证头重试 push 成功', async (t) => {
|
|
293
|
+
const workDir = await tempWorkDir(t);
|
|
294
|
+
const { exec, calls } = installExec((call) => {
|
|
295
|
+
if (gitCommandOf(call) === 'diff') return { stdout: '', stderr: '', code: 1 }; // 有变化 → commit + push
|
|
296
|
+
if (gitCommandOf(call) === 'push' && call.opts.env === undefined) {
|
|
297
|
+
return {
|
|
298
|
+
stdout: '',
|
|
299
|
+
stderr: "fatal: cannot exec 'git credential-store --file=/data/user/0/x/cache/tmp/dsh-git-cred-1/credential get': No such file or directory",
|
|
300
|
+
code: 128,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
304
|
+
});
|
|
305
|
+
const writer = createGitFileWriter({ credentials: { getToken: async () => TEST_TOKEN }, exec });
|
|
306
|
+
|
|
307
|
+
const result = await writer.writeFiles({
|
|
308
|
+
repoUrl: 'https://github.com/xiaojun/dsh-configs',
|
|
309
|
+
workDir,
|
|
310
|
+
entries: [{ path: 'a.txt', content: '1' }],
|
|
311
|
+
commitMessage: 'publish',
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
assert.equal(result.pushed, true);
|
|
315
|
+
const pushes = calls.filter((c) => gitCommandOf(c) === 'push');
|
|
316
|
+
assert.equal(pushes.length, 2, 'helper 失败后必须重试一次');
|
|
317
|
+
const retry = pushes[1]!;
|
|
318
|
+
assert.ok(!retry.args.some((a) => a.startsWith('credential.helper=store')), '重试不得再走 helper');
|
|
319
|
+
assert.equal(retry.opts.env?.['GIT_CONFIG_COUNT'], '2');
|
|
320
|
+
assert.equal(retry.opts.env?.['GIT_CONFIG_KEY_0'], 'credential.helper');
|
|
321
|
+
assert.equal(retry.opts.env?.['GIT_CONFIG_VALUE_0'], '');
|
|
322
|
+
assert.equal(retry.opts.env?.['GIT_CONFIG_KEY_1'], 'http.https://github.com/.extraheader');
|
|
323
|
+
assert.equal(
|
|
324
|
+
retry.opts.env?.['GIT_CONFIG_VALUE_1'],
|
|
325
|
+
'AUTHORIZATION: basic ' + Buffer.from('oauth2:' + TEST_TOKEN).toString('base64'),
|
|
326
|
+
);
|
|
327
|
+
assert.ok(!retry.args.join(' ').includes(TEST_TOKEN), 'token 不得进 argv');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('git-file-writer: clone 遇 helper 无法执行 → 清掉半成品目录后重试', async (t) => {
|
|
331
|
+
const workDir = await tempWorkDir(t);
|
|
332
|
+
let cloneAttempts = 0;
|
|
333
|
+
const { exec } = installExec((call) => {
|
|
334
|
+
if (gitCommandOf(call) === 'clone') {
|
|
335
|
+
cloneAttempts += 1;
|
|
336
|
+
if (cloneAttempts === 1) {
|
|
337
|
+
return { stdout: '', stderr: "fatal: cannot exec 'git credential-store get': No such file or directory", code: 128 };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (gitCommandOf(call) === 'diff') return { stdout: '', stderr: '', code: 0 };
|
|
341
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
342
|
+
});
|
|
343
|
+
// 第一次 clone 前造一个半成品目录:真实 git 失败会留下 .git 残骸,重试前必须清空
|
|
344
|
+
const scriptedExec: GitExecFn = async (cmd, args, opts) => {
|
|
345
|
+
if (args.includes('clone') && cloneAttempts === 0) {
|
|
346
|
+
await fs.mkdir(path.join(workDir, '.git', 'partial'), { recursive: true });
|
|
347
|
+
}
|
|
348
|
+
return exec(cmd, args, opts);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const writer = createGitFileWriter({ credentials: { getToken: async () => TEST_TOKEN }, exec: scriptedExec });
|
|
352
|
+
const result = await writer.writeFiles({
|
|
353
|
+
repoUrl: 'https://github.com/xiaojun/dsh-configs',
|
|
354
|
+
workDir,
|
|
355
|
+
entries: [{ path: 'a.txt', content: '1' }],
|
|
356
|
+
commitMessage: 'noop',
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
assert.equal(cloneAttempts, 2, 'clone 失败后必须重试一次');
|
|
360
|
+
assert.equal(result.pushed, false);
|
|
361
|
+
// 第一轮留下的 .git 残骸必须被清掉,目录里只剩本轮写入的文件
|
|
362
|
+
assert.deepEqual(await fs.readdir(workDir), ['a.txt']);
|
|
363
|
+
});
|
|
@@ -30,6 +30,7 @@ import { withWorkdirLock, WorkdirLockError } from '../utils/workdir-lock.ts';
|
|
|
30
30
|
import { fastForwardFetchArgs, fastForwardMergeArgs, isMissingRemoteRefError, parseCurrentBranch } from '../utils/git-fast-forward.ts';
|
|
31
31
|
import { marketMsg } from './messages-market.ts';
|
|
32
32
|
import type { GitAuthor, GitCredentialProvider, GitExecFn, GitExecResult } from '../sync/git/git-transport.ts';
|
|
33
|
+
import { clearDirContents, credentialHeaderEnv, isEmptyDir, isCredentialExecFailure } from '../sync/git/credential-auth.ts';
|
|
33
34
|
|
|
34
35
|
const execFileAsync = promisify(execFile);
|
|
35
36
|
|
|
@@ -102,6 +103,8 @@ const defaultExec: GitExecFn = async (cmd, args, opts) => {
|
|
|
102
103
|
maxBuffer: 16 * 1024 * 1024,
|
|
103
104
|
windowsHide: true,
|
|
104
105
|
encoding: 'utf8',
|
|
106
|
+
// issue #120:credential helper 走不了 shell 时改用 env 注入认证头重试(追加在继承的 process.env 上)
|
|
107
|
+
env: opts.env === undefined ? undefined : { ...process.env, ...opts.env },
|
|
105
108
|
});
|
|
106
109
|
return { stdout: String(stdout), stderr: String(stderr), code: 0 };
|
|
107
110
|
} catch (err) {
|
|
@@ -231,7 +234,7 @@ export class GitFileWriterClient implements GitFileWriter {
|
|
|
231
234
|
const pushArgs = branch !== ''
|
|
232
235
|
? ['push', '-u', 'origin', branch, ...(call.force === true ? ['--force-with-lease'] : [])]
|
|
233
236
|
: ['push', '-u', 'origin', 'HEAD'];
|
|
234
|
-
const push = await this.runGit(pushArgs, { cwd: call.workDir, withCredential: true, token, mask });
|
|
237
|
+
const push = await this.runGit(pushArgs, { cwd: call.workDir, withCredential: true, token, mask, repoUrl: call.repoUrl });
|
|
235
238
|
if (push.code !== 0) {
|
|
236
239
|
throw new GitFileWriterError(mask(marketMsg('market.gitWriter.pushFailed', { stderr: push.stderr })));
|
|
237
240
|
}
|
|
@@ -246,7 +249,10 @@ export class GitFileWriterClient implements GitFileWriter {
|
|
|
246
249
|
*/
|
|
247
250
|
private async runGit(
|
|
248
251
|
args: string[],
|
|
249
|
-
opts: {
|
|
252
|
+
opts: {
|
|
253
|
+
cwd?: string; withCredential?: boolean; allowNonZero?: boolean; token: string;
|
|
254
|
+
mask: (text: string) => string; repoUrl?: string;
|
|
255
|
+
},
|
|
250
256
|
): Promise<GitExecResult> {
|
|
251
257
|
const cwd = opts.cwd ?? '';
|
|
252
258
|
let extra: string[] = [];
|
|
@@ -256,8 +262,20 @@ export class GitFileWriterClient implements GitFileWriter {
|
|
|
256
262
|
extra = cred.extraArgs;
|
|
257
263
|
cleanup = cred.cleanup;
|
|
258
264
|
}
|
|
265
|
+
// issue #120:只有「命令开跑前目录是空的」才敢在重试前清空 —— 绝不删用户文件。
|
|
266
|
+
const cloneTargetWasEmpty = args[0] === 'clone' ? await isEmptyDir(cwd) : false;
|
|
259
267
|
try {
|
|
260
|
-
|
|
268
|
+
let result = await this.exec(this.gitBin, [...extra, ...args], { cwd, timeoutMs: this.timeoutMs });
|
|
269
|
+
// issue #120:同 GitTransport —— credential helper 必须经 shell 执行,Android 上 git 的 shell 路径
|
|
270
|
+
// 不存在(askpass 又要 exec 权限)→ 改用 env 注入 HTTP 认证头重试一次(不需要 shell)。
|
|
271
|
+
if (result.code !== 0 && opts.withCredential === true && opts.token !== ''
|
|
272
|
+
&& isCredentialExecFailure(result.stderr + ' ' + result.stdout)) {
|
|
273
|
+
const headerEnv = credentialHeaderEnv(opts.repoUrl ?? '', this.credentialUsername, opts.token);
|
|
274
|
+
if (Object.keys(headerEnv).length > 0 && (args[0] !== 'clone' || cloneTargetWasEmpty)) {
|
|
275
|
+
if (args[0] === 'clone') await clearDirContents(cwd);
|
|
276
|
+
result = await this.exec(this.gitBin, args, { cwd, timeoutMs: this.timeoutMs, env: headerEnv });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
261
279
|
if (result.code !== 0 && !opts.allowNonZero) {
|
|
262
280
|
throw new GitFileWriterError(opts.mask(marketMsg('market.gitWriter.commandFailed', { args: args.join(' '), stderr: result.stderr })));
|
|
263
281
|
}
|
|
@@ -309,7 +327,7 @@ export class GitFileWriterClient implements GitFileWriter {
|
|
|
309
327
|
if (entries.length > 0) {
|
|
310
328
|
throw new GitFileWriterError(marketMsg('market.gitWriter.workDirNotEmpty', { workDir }));
|
|
311
329
|
}
|
|
312
|
-
const clone = await this.runGit(['clone', repoUrl, '.'], { cwd: workDir, withCredential: true, token, mask });
|
|
330
|
+
const clone = await this.runGit(['clone', repoUrl, '.'], { cwd: workDir, withCredential: true, token, mask, repoUrl });
|
|
313
331
|
if (clone.code !== 0) {
|
|
314
332
|
throw new GitFileWriterError(mask(marketMsg('market.gitWriter.cloneFailed', { stderr: clone.stderr })));
|
|
315
333
|
}
|
|
@@ -331,7 +349,7 @@ export class GitFileWriterClient implements GitFileWriter {
|
|
|
331
349
|
if (branch === '') return;
|
|
332
350
|
// 不再用 git pull --ff-only:并发下 FETCH_HEAD 会被写成多行 for-merge、pull 自己 fatal;
|
|
333
351
|
// 显式 refspec fetch + 显式远端跟踪分支 merge 完全不读 FETCH_HEAD(参数见 utils/git-fast-forward.ts)。
|
|
334
|
-
const fetched = await this.runGit(fastForwardFetchArgs(branch), { cwd: workDir, withCredential: true, allowNonZero: true, token, mask });
|
|
352
|
+
const fetched = await this.runGit(fastForwardFetchArgs(branch), { cwd: workDir, withCredential: true, allowNonZero: true, token, mask, repoUrl });
|
|
335
353
|
if (fetched.code !== 0) {
|
|
336
354
|
const combined = fetched.stderr + ' ' + fetched.stdout;
|
|
337
355
|
// 无 upstream / 远端还没有这个分支 → 静默跳过(内容可能已是最新)
|