dsh-config-manager 0.2.1 → 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.
@@ -401,6 +401,102 @@ test('推送鉴权失败(GitHub 文案)→ 分类 auth + 可操作提示,
401
401
  );
402
402
  });
403
403
 
404
+ test('issue #120:credential helper 无法执行(Android 没有可用 shell)→ 改用 env 注入认证头重试一次', async (t) => {
405
+ const dir = await makeGitWorkDir(t);
406
+ const calls: { args: string[]; env?: Record<string, string> }[] = [];
407
+ const exec: GitExecFn = async (_cmd, args, opts) => {
408
+ calls.push({ args, env: opts.env });
409
+ const joined = args.join(' ');
410
+ if (joined.includes('diff') && joined.includes('--quiet')) return { stdout: '', stderr: '', code: 1 };
411
+ if (joined.includes('push -u')) {
412
+ // 第一次:credential helper 走 shell,而本机没有可用的 sh(真机原文)
413
+ if (opts.env === undefined || opts.env['GIT_CONFIG_KEY_0'] === undefined) {
414
+ return {
415
+ stdout: '',
416
+ 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",
417
+ code: 128,
418
+ };
419
+ }
420
+ return { stdout: '', stderr: '', code: 0 };
421
+ }
422
+ return { stdout: '', stderr: '', code: 0 };
423
+ };
424
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
425
+ await transport.upload(sampleSnapshot());
426
+ const pushes = calls.filter((c) => c.args.join(' ').includes('push -u'));
427
+ assert.equal(pushes.length, 2, 'helper 无法执行时必须重试一次');
428
+ const retry = pushes[1]!;
429
+ assert.ok(!retry.args.some((a) => a.startsWith('credential.helper=store')), '重试不得再注入 store helper');
430
+ const env = retry.env!;
431
+ assert.equal(env['GIT_CONFIG_COUNT'], '2', '一条清 helper、一条挂认证头');
432
+ assert.equal(env['GIT_CONFIG_KEY_0'], 'credential.helper');
433
+ assert.equal(env['GIT_CONFIG_VALUE_0'], '', '必须清掉全局 helper,否则它会被再次调用并再次 cannot exec');
434
+ assert.equal(env['GIT_CONFIG_KEY_1'], 'http.https://github.com/.extraheader');
435
+ assert.equal(
436
+ env['GIT_CONFIG_VALUE_1'],
437
+ 'AUTHORIZATION: basic ' + Buffer.from('oauth2:' + TEST_TOKEN, 'utf8').toString('base64'),
438
+ );
439
+ for (const arg of retry.args) assert.ok(!arg.includes(TEST_TOKEN), '重试的 argv 不得含 token');
440
+ });
441
+
442
+ test('issue #120:认证头重试也失败 → 错误点名平台原因,不再把用户引向「令牌坏了」', async (t) => {
443
+ const dir = await makeGitWorkDir(t);
444
+ const calls: CallRecord[] = [];
445
+ const exec: GitExecFn = async (_cmd, args) => {
446
+ calls.push({ cmd: 'git', args });
447
+ const joined = args.join(' ');
448
+ if (joined.includes('diff') && joined.includes('--quiet')) return { stdout: '', stderr: '', code: 1 };
449
+ if (joined.includes('push -u')) {
450
+ return {
451
+ stdout: '',
452
+ stderr: "fatal: cannot exec 'git credential-store get': No such file or directory\n"
453
+ + 'fatal: could not read Password for ' + "'" + 'https://oauth2@github.com' + "'" + ': terminal prompts disabled',
454
+ code: 128,
455
+ };
456
+ }
457
+ return { stdout: '', stderr: '', code: 0 };
458
+ };
459
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
460
+ await assert.rejects(
461
+ transport.upload(sampleSnapshot()),
462
+ (err: unknown) => {
463
+ assert.ok(err instanceof GitTransportError);
464
+ assert.equal(err.kind, 'auth');
465
+ assert.match(err.message, /credential helper/, '必须点名 credential helper 执行不了');
466
+ assert.match(err.message, /2\.31/, '必须给出可验证的下限(env 注入配置需要 git ≥ 2.31)');
467
+ assert.ok(!/推送认证失败/.test(err.message), '不得把平台问题写成「令牌/权限」问题');
468
+ assert.ok(!err.message.includes(TEST_TOKEN), '错误消息不得泄漏 token');
469
+ return true;
470
+ },
471
+ );
472
+ assert.equal(calls.filter((c) => c.args.join(' ').includes('push -u')).length, 2, '只重试一次,不无限重试');
473
+ });
474
+
475
+ test('issue #120:clone 遇到 helper 无法执行 → 清掉半成品目录后用认证头重试成功', async (t) => {
476
+ const dir = await makeTempDir(t); // 空目录 → 走 clone 分支
477
+ let cloneAttempts = 0;
478
+ const exec: GitExecFn = async (_cmd, args, opts) => {
479
+ const joined = args.join(' ');
480
+ if (joined.includes('clone')) {
481
+ cloneAttempts += 1;
482
+ if (cloneAttempts === 1) {
483
+ // git 认证失败前已建出半成品(.git + 部分对象)
484
+ await fs.mkdir(path.join(dir, '.git'), { recursive: true });
485
+ await fs.writeFile(path.join(dir, '.git', 'partial'), 'half');
486
+ return { stdout: '', stderr: "fatal: cannot exec 'git credential-store get': No such file or directory", code: 128 };
487
+ }
488
+ assert.deepEqual(await fs.readdir(dir), [], '重试前必须清掉半成品,否则 clone 会因目录非空失败');
489
+ assert.ok(opts.env !== undefined, '重试必须走 env 注入认证头');
490
+ await fs.mkdir(path.join(dir, '.git'), { recursive: true });
491
+ return { stdout: '', stderr: '', code: 0 };
492
+ }
493
+ return { stdout: '', stderr: '', code: 0 };
494
+ };
495
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
496
+ await transport.list();
497
+ assert.equal(cloneAttempts, 2, '应恰好重试一次 clone');
498
+ });
499
+
404
500
  test('checkIsPrivate:匿名可达 → false;匿名失败+认证可达 → true;都失败 → 抛错', async (t) => {
405
501
  // 匿名成功
406
502
  const c1: CallRecord[] = [];
@@ -40,6 +40,7 @@ import type {
40
40
  import { parseJsonSafe } from '../../utils/json.ts';
41
41
  import { atomicWriteFile } from '../../utils/atomic-write.ts';
42
42
  import { quoteGitValue } from '../../utils/git-quote.ts';
43
+ import { clearDirContents, credentialHeaderEnv, isEmptyDir, isCredentialExecFailure } from './credential-auth.ts';
43
44
  import { SECTION_FILE_PREFIXES } from '../../schema/config.ts';
44
45
  import type { FilesSection, SectionData, SectionId } from '../../schema/types.ts';
45
46
  import { zhMsg } from '../../core/messages.ts';
@@ -60,7 +61,7 @@ export interface GitExecResult {
60
61
  export type GitExecFn = (
61
62
  cmd: string,
62
63
  args: string[],
63
- opts: { cwd?: string; timeoutMs?: number },
64
+ opts: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
64
65
  ) => Promise<GitExecResult>;
65
66
 
66
67
  /** 工作副本自检事实(issue #79):origin 是否已跟随配置地址、有无未上行的本地提交。 */
@@ -208,6 +209,9 @@ const defaultExec: GitExecFn = async (cmd, args, opts) => {
208
209
  maxBuffer: 16 * 1024 * 1024,
209
210
  windowsHide: true,
210
211
  encoding: 'utf8',
212
+ // issue #120:credential helper 走 shell 且本机没有可用 shell 时,改用 env 注入认证头重试 ——
213
+ // 追加在继承的 process.env 上(env 缺省 = 完全继承,不改变既有行为)
214
+ env: opts.env === undefined ? undefined : { ...process.env, ...opts.env },
211
215
  });
212
216
  return { stdout: String(stdout), stderr: String(stderr), code: 0 };
213
217
  } catch (err) {
@@ -948,8 +952,23 @@ export class GitTransport implements SyncTransport {
948
952
  if (cleanup) await cleanup();
949
953
  throw new GitTransportError(this.msg('sync.git.pushTokenRequired'), { kind: 'auth', retryable: false });
950
954
  }
955
+ // issue #120:只有「命令开跑前目录是空的」才敢在重试前清空 —— clone 失败可能留下半成品,
956
+ // 而目录里原本就有别的内容时宁可放弃重试,也绝不删用户文件。
957
+ const cloneTargetWasEmpty = args[0] === 'clone' ? await isEmptyDir(cwd) : false;
951
958
  try {
952
- const result = await this.o.exec(this.o.gitBin, [...extra, ...args], { cwd, timeoutMs: this.o.timeoutMs });
959
+ let result = await this.o.exec(this.o.gitBin, [...extra, ...args], { cwd, timeoutMs: this.o.timeoutMs });
960
+ // issue #120:credential helper 必须经 shell 执行(值里不含空格也一样,git 统一拼成
961
+ // `sh -c 'git <helper> ...'`),而 Android 上 git 二进制的 GIT_SHELL_PATH 指向 App 沙箱里
962
+ // 不存在的 Termux sh;`core.askpass` 又要 exec 权限(Android 10+ 对 targetSdk ≥ 29 禁执行应用
963
+ // 目录里的文件)。两条路都走不通时,改用 env 注入 HTTP 认证头重试一次(不需要 shell)。
964
+ if (result.code !== 0 && token !== null && token !== ''
965
+ && isCredentialExecFailure(result.stderr + ' ' + result.stdout)) {
966
+ const headerEnv = credentialHeaderEnv(this.o.repoUrl, this.o.credentialUsername, token);
967
+ if (Object.keys(headerEnv).length > 0 && (args[0] !== 'clone' || cloneTargetWasEmpty)) {
968
+ if (args[0] === 'clone') await clearDirContents(cwd);
969
+ result = await this.o.exec(this.o.gitBin, args, { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
970
+ }
971
+ }
953
972
  if (result.code !== 0 && !opts.allowNonZero) {
954
973
  const combined = result.stderr + ' ' + result.stdout;
955
974
  // 统一分类(超时 / 网络 / 鉴权 / 5xx…):上层可据 kind / retryable 分流,不必解析 message
@@ -961,12 +980,19 @@ export class GitTransport implements SyncTransport {
961
980
  // 推送 + 鉴权失败:git 原文只说「认证失败」——换成能照做的说明(令牌失效 / 无写权限 / 远端禁用了密码推送)
962
981
  // issue #115:clone 的鉴权失败原文(remote: Write access to repository not granted / 403)读不出「缺哪种权限」,
963
982
  // 换成点名 Contents: Read and write 的说明 —— 否则用户只会以为令牌坏了(细粒度 PAT 常只勾了 Administration)
964
- const message = opts.requirePushToken === true && classified.kind === 'auth'
965
- ? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
966
- : args[0] === 'clone' && classified.kind === 'auth'
967
- ? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
968
- : this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
969
- throw new GitTransportError(message, classified);
983
+ // issue #120:helper 根本没能执行时,「请确认令牌有效」是错误指引(令牌可能是好的)——
984
+ // 这类失败必须点名平台原因,排在 push/clone 的鉴权文案之前。
985
+ const credentialBlocked = isCredentialExecFailure(combined);
986
+ const message = credentialBlocked
987
+ ? this.msg('sync.git.credentialHelperBlocked', { err: this.mask(result.stderr, token) })
988
+ : opts.requirePushToken === true && classified.kind === 'auth'
989
+ ? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
990
+ : args[0] === 'clone' && classified.kind === 'auth'
991
+ ? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
992
+ : this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
993
+ // 归类同样不能交给原文:`cannot exec ...: No such file or directory` 里的 ENOENT 会被判成
994
+ // notfound(「仓库不存在」),而真实原因是凭据通道不可用 → 钉成 auth(非瞬时、别重试)。
995
+ throw new GitTransportError(message, credentialBlocked ? { kind: 'auth', retryable: false } : classified);
970
996
  }
971
997
  return result;
972
998
  } catch (err) {