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.
@@ -20,8 +20,8 @@ export const zh = {
20
20
  'config.tokenMissing': '未配置令牌:可拉取公开仓库,无法推送',
21
21
  'config.tokenPlaceholder': 'ghp_…(可选)',
22
22
  // 配置保存(自动保存 + 显式保存按钮)
23
- 'config.save': '保存配置',
24
- 'config.saveHint': '表单改动会自动保存(密码/token 安全写入 DSH credentials,不会写入同步文件或日志);也可点击按钮立即保存。',
23
+ 'config.save': '立即保存',
24
+ 'config.saveHint': '表单改动会自动保存:密码/token 安全写入 DSH credentials,不会写入同步文件或日志。',
25
25
  // 通道选择器
26
26
  'channel.title': '同步通道',
27
27
  'channel.git': 'Git 私有仓库',
@@ -31,7 +31,7 @@ export const zh = {
31
31
  'channel.perChannelHint': '每条通道(Git / WebDAV / 对象存储 / Gist)的自动同步、同步模式、加密与远端快照各自独立配置。',
32
32
  // 通道入口卡(弹窗驱动:点按钮 → 弹窗内配置通道)
33
33
  'channel.open': '配置…',
34
- 'channel.openHint': '远程同步通过「同步通道」进行:Git 私有仓库或 WebDAV 服务器。点击按钮在弹窗中配置或修改。',
34
+ 'channel.openHint': '远程同步通过「同步通道」进行:Git 私有仓库或 WebDAV 服务器。',
35
35
  // 断开通道配置(用户实测要求:配置过就必须能删掉,否则打不通的通道会永久占位)
36
36
  'channel.expand': '展开通道详情',
37
37
  'channel.collapse': '收起通道详情',
@@ -125,9 +125,11 @@ export const zh = {
125
125
  'push.title': '推送结果',
126
126
  'sections.title': '同步分区',
127
127
  'warnings.title': '分区告警',
128
- // 一键同步(方案 A:拉取 → 逐项确认 → apply-items)
129
- 'syncflow.title': '一键同步',
130
- 'syncflow.button': '一键同步',
128
+ // 从远端合并到本机(方案 A:拉取 → 逐项确认 → apply-items)
129
+ // 名字必须写明方向(issue 121):这条链路只下行、绝不上传 —— 叫「同步」会被读成双向。
130
+ 'syncflow.title': '从远端合并到本机',
131
+ 'syncflow.button': '从远端合并到本机',
132
+ // 方向说明(ⓘ,挂在动作行):只下行 + 双向该去哪儿;常驻成行会与按钮名重复(DESIGN.md §7)
131
133
  // 方案 A:有基线且三方合并全部 skip = 远端与本机一致,不必同步
132
134
  'syncflow.upToDate': '已是最新,无需同步',
133
135
  'syncflow.syncing': '正在同步…',
@@ -177,7 +179,7 @@ export const zh = {
177
179
  'syncflow.remoteEmptyGuide': '远端还没有任何快照:这是首次同步,先点「推送到远端」把本机配置推上去(推送会创建第一个快照),之后才能拉取。',
178
180
  // 同步模式(默认快速导出 / 高级自定义导出)
179
181
  'mode.title': '同步分区',
180
- 'mode.hint': '点按钮在弹窗里勾选要同步的分区:勾选集合就是同步范围(默认已勾选推荐分区,可随时改)。',
182
+ 'mode.hint': '同步范围 = 勾选的分区集合(默认已勾选推荐分区,可随时改)。',
181
183
  'mode.selectedCount': '已勾选 {n} 个',
182
184
  'mode.sectionsHint': '默认可同步的是可移植分区;平台相关分区(MCP 服务器)与设备相关分区(插件文件、凭据状态、历史会话)只有你在这里主动勾选才参与同步。',
183
185
  'mode.atLeastOne': '请至少勾选一个同步分区',
@@ -216,7 +218,7 @@ export const zh = {
216
218
  'mode.includeSecretsHint': '把真实凭据值(~/.dsh/.credentials.yaml)写入加密快照,可在另一台机器上随同步一并写回本机凭据;勾选时自动选中加密,密钥绝不进入未加密快照。',
217
219
  'mode.encryptAutosyncNotice': '加密快照仅通过手动推送/拉取使用;自动同步无密码,遇到加密快照会跳过并在历史中提示。',
218
220
  'mode.decryptPassword': '解密密码(加密快照拉取/同步用,可选)',
219
- 'mode.decryptPasswordHint': '输入后保存在本机凭据库,下次拉取自动使用;只有拉取的快照确实加密时才会用到 —— 未加密的备份不会使用密码。删除请点「删除已保存密码」。',
221
+ 'mode.decryptPasswordHint': '输入后保存在本机凭据库,下次拉取自动使用;只有拉取的快照确实加密时才会用到 —— 未加密的备份不会使用密码。',
220
222
  'mode.decryptPasswordSaved': '已保存到本机',
221
223
  'mode.decryptPasswordPlaceholder': '已保存(留空即沿用)',
222
224
  'mode.clearSavedPassword': '删除已保存密码',
@@ -257,7 +259,7 @@ export const zh = {
257
259
  // 同步历史(含自动同步记录)
258
260
  'history.title': '同步历史',
259
261
  'history.empty': '尚无同步历史',
260
- 'history.emptyHint': '完成首次 push / 一键同步 / 自动同步后,这里会显示记录。',
262
+ 'history.emptyHint': '完成首次推送 / 从远端合并 / 自动同步后,这里会显示记录。',
261
263
  'history.colTime': '时间',
262
264
  'history.colDetail': '详情',
263
265
  'history.kindAutosync': '自动同步',
@@ -304,7 +306,7 @@ export const zh = {
304
306
  'toast.pushPreviewFailed': '推送预览失败',
305
307
  'toast.pushFailed': '推送失败',
306
308
  'toast.pushDone': '推送完成',
307
- 'toast.syncStartFailed': '一键同步失败',
309
+ 'toast.syncStartFailed': '从远端合并失败',
308
310
  'toast.autosyncUpdateFailed': '自动同步设置更新失败',
309
311
  'toast.autosyncUpdated': '自动同步设置已更新',
310
312
  'toast.snapshotsLoadFailed': '读取远端快照失败',
@@ -474,8 +476,8 @@ export const en: Record<keyof typeof zh, string> = {
474
476
  'config.tokenMissing': 'No token: public repos can be pulled, but pushing will fail',
475
477
  'config.tokenPlaceholder': 'ghp_… (optional)',
476
478
  // Config save (auto-save + explicit save button)
477
- 'config.save': 'Save config',
478
- 'config.saveHint': 'Form changes are saved automatically (password/token written securely into DSH credentials, never into sync files or logs); you can also save explicitly with the button.',
479
+ 'config.save': 'Save now',
480
+ 'config.saveHint': 'Form changes are saved automatically: password/token goes securely into DSH credentials, never into sync files or logs.',
479
481
  'channel.title': 'Sync Channel',
480
482
  'channel.git': 'Git private repo',
481
483
  'channel.webdav': 'WebDAV server',
@@ -484,7 +486,7 @@ export const en: Record<keyof typeof zh, string> = {
484
486
  'channel.perChannelHint': 'Auto sync, sync mode, encryption and remote snapshots are configured independently for each channel (Git / WebDAV / object storage / Gist).',
485
487
  // Channel entry card (dialog-driven: click the button → configure the channel in the dialog)
486
488
  'channel.open': 'Configure…',
487
- 'channel.openHint': 'Remote sync runs through a sync channel: a private Git repository or a WebDAV server. Click the button to configure or change it in the dialog.',
489
+ 'channel.openHint': 'Remote sync runs through a sync channel: a private Git repository or a WebDAV server.',
488
490
  // Disconnect channel config (user report: once configured there was no way out)
489
491
  'channel.expand': 'Expand channel details',
490
492
  'channel.collapse': 'Collapse channel details',
@@ -573,8 +575,8 @@ export const en: Record<keyof typeof zh, string> = {
573
575
  'push.title': 'Push Result',
574
576
  'sections.title': 'Synced Sections',
575
577
  'warnings.title': 'Section Warnings',
576
- 'syncflow.title': 'One-Click Sync',
577
- 'syncflow.button': 'One-click sync',
578
+ 'syncflow.title': 'Merge from remote',
579
+ 'syncflow.button': 'Merge from remote',
578
580
  // Plan A: with a baseline, an all-skip three-way merge means local and remote already match
579
581
  'syncflow.upToDate': 'Already up to date; nothing to sync',
580
582
  'syncflow.syncing': 'Syncing…',
@@ -623,7 +625,7 @@ export const en: Record<keyof typeof zh, string> = {
623
625
  'syncflow.remoteEmptyGuide': 'No remote snapshots yet: this is the first sync — click "Push to remote" to upload your local config (that push creates the first snapshot); pulling only works afterwards.',
624
626
  // Sync mode (default quick export / advanced custom export)
625
627
  'mode.title': 'Sync Sections',
626
- 'mode.hint': 'Click the button to tick the sections to sync: the ticked set is the sync scope (recommended sections are ticked by default and can be changed any time).',
628
+ 'mode.hint': 'Sync scope = the ticked set of sections (recommended sections are ticked by default and can be changed any time).',
627
629
  'mode.selectedCount': '{n} selected',
628
630
  'mode.sectionsHint': 'Portable sections sync by default; platform-dependent sections (MCP servers) and device-specific sections (plugin files, credentials status, sessions) only participate when you explicitly tick them here.',
629
631
  'mode.atLeastOne': 'Select at least one section to sync',
@@ -662,7 +664,7 @@ export const en: Record<keyof typeof zh, string> = {
662
664
  'mode.includeSecretsHint': 'Write real credential values (~/.dsh/.credentials.yaml) into the encrypted snapshot so another machine can restore them with the sync; checking this auto-selects encryption, and secrets never enter a plaintext snapshot.',
663
665
  'mode.encryptAutosyncNotice': 'Encrypted snapshots are only produced/consumed by manual push/pull; auto sync has no password, so it skips encrypted snapshots and reports them in history.',
664
666
  'mode.decryptPassword': 'Decryption password (for encrypted snapshot pull/sync, optional)',
665
- 'mode.decryptPasswordHint': 'Once entered it is saved in the local credential store and reused automatically; it is only ever used when the pulled snapshot is actually encrypted — plaintext backups never use the password. Use "Delete saved password" to remove it.',
667
+ 'mode.decryptPasswordHint': 'Once entered it is saved in the local credential store and reused automatically; it is only ever used when the pulled snapshot is actually encrypted — plaintext backups never use the password.',
666
668
  'mode.decryptPasswordSaved': 'Saved locally',
667
669
  'mode.decryptPasswordPlaceholder': 'Saved (leave empty to reuse)',
668
670
  'mode.clearSavedPassword': 'Delete saved password',
@@ -701,7 +703,7 @@ export const en: Record<keyof typeof zh, string> = {
701
703
  'autosync.lastMessage': 'Last message',
702
704
  'history.title': 'Sync History',
703
705
  'history.empty': 'No sync history yet',
704
- 'history.emptyHint': 'Records appear here after your first push / one-click sync / auto sync.',
706
+ 'history.emptyHint': 'Records appear here after your first push / merge from remote / auto sync.',
705
707
  'history.colTime': 'Time',
706
708
  'history.colDetail': 'Detail',
707
709
  'history.kindAutosync': 'Auto Sync',
@@ -745,7 +747,7 @@ export const en: Record<keyof typeof zh, string> = {
745
747
  'toast.pushPreviewFailed': 'Failed to build push preview',
746
748
  'toast.pushFailed': 'Push failed',
747
749
  'toast.pushDone': 'Push completed',
748
- 'toast.syncStartFailed': 'One-click sync failed',
750
+ 'toast.syncStartFailed': 'Merge from remote failed',
749
751
  'toast.autosyncUpdateFailed': 'Failed to update auto-sync settings',
750
752
  'toast.autosyncUpdated': 'Auto-sync settings updated',
751
753
  'toast.snapshotsLoadFailed': 'Failed to load remote snapshots',
@@ -67,7 +67,7 @@ export const zh = {
67
67
 
68
68
  // ---------- run-registry ----------
69
69
  'core.run.autosyncBusy': '自动同步已在进行中',
70
- 'core.run.syncApplyBusy': '一键同步已在进行中',
70
+ 'core.run.syncApplyBusy': '正在从远端合并',
71
71
  'core.run.backupScheduleBusy': '定时备份已在进行中',
72
72
  'core.run.restoreBusy': '快照恢复已在进行中(请等待当前恢复完成)',
73
73
  'core.run.recoveryBusy': '恢复/回滚已在进行中(请等待当前恢复完成)',
@@ -381,7 +381,7 @@ export const en: Record<keyof typeof zh, string> = {
381
381
 
382
382
  // ---------- run-registry ----------
383
383
  'core.run.autosyncBusy': 'Automatic sync is already in progress',
384
- 'core.run.syncApplyBusy': 'One-click sync is already in progress',
384
+ 'core.run.syncApplyBusy': 'Merge from remote is already in progress',
385
385
  'core.run.backupScheduleBusy': 'Scheduled backup is already in progress',
386
386
  'core.run.restoreBusy': 'Snapshot restore is already in progress (wait for the current restore to finish)',
387
387
  'core.run.recoveryBusy': 'Recovery/rollback is already in progress (wait for the current recovery to finish)',
@@ -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.1'
183
+ export const PLUGIN_VERSION = '0.2.3'
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: { cwd?: string; withCredential?: boolean; allowNonZero?: boolean; token: string; mask: (text: string) => string },
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
- const result = await this.exec(this.gitBin, [...extra, ...args], { cwd, timeoutMs: this.timeoutMs });
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 / 远端还没有这个分支 → 静默跳过(内容可能已是最新)
@@ -41,13 +41,13 @@ export const zh = {
41
41
  'route.syncPreviewFailed': '同步预览失败',
42
42
  'route.syncSessionExpired': '同步会话不存在或已过期,请重新拉取预览',
43
43
  'route.syncConflictResolutionRequired': '冲突项 {id} 必须提供 resolution(useRemote/keepLocal/skip)',
44
- 'route.syncApplySummary': '一键同步应用:{count} 项{status}{ineffective}',
44
+ 'route.syncApplySummary': '从远端合并:{count} 项{status}{ineffective}',
45
45
  'route.syncRollbackIncomplete': '(回滚未完成)',
46
46
  'route.syncRolledBack': '(已回滚)',
47
47
  'route.syncIneffectiveSuffix': '({count} 项未生效)',
48
48
  'route.syncApplyPartial': '同步应用部分成功:有项未生效(见告警明细)',
49
49
  'route.syncApplyFailed': '同步应用未完全成功',
50
- 'route.syncRollbackSummary': '一键同步回滚({id})',
50
+ 'route.syncRollbackSummary': '从远端合并回滚({id})',
51
51
  // ---------- 事故恢复 ----------
52
52
  'route.recoverySessionRepairRollback': '会话修复回滚',
53
53
  'route.recoverySessionRepairDropped': '会话修复(丢弃重放重复行 {count} 行)',
@@ -82,13 +82,13 @@ export const en: Record<keyof typeof zh, string> = {
82
82
  'route.syncPreviewFailed': 'Sync preview failed',
83
83
  'route.syncSessionExpired': 'The sync session does not exist or has expired; pull a fresh preview',
84
84
  'route.syncConflictResolutionRequired': 'Conflict item {id} must provide a resolution (useRemote/keepLocal/skip)',
85
- 'route.syncApplySummary': 'One-click sync apply: {count} item(s){status}{ineffective}',
85
+ 'route.syncApplySummary': 'Merge from remote: {count} item(s){status}{ineffective}',
86
86
  'route.syncRollbackIncomplete': ' (rollback incomplete)',
87
87
  'route.syncRolledBack': ' (rolled back)',
88
88
  'route.syncIneffectiveSuffix': ' ({count} item(s) had no effect)',
89
89
  'route.syncApplyPartial': 'Sync apply partially succeeded: some items had no effect (see the warning details)',
90
90
  'route.syncApplyFailed': 'Sync apply did not fully succeed',
91
- 'route.syncRollbackSummary': 'One-click sync rollback ({id})',
91
+ 'route.syncRollbackSummary': 'Merge from remote rollback ({id})',
92
92
  'route.recoverySessionRepairRollback': 'Session repair rollback',
93
93
  'route.recoverySessionRepairDropped': 'Session repair (dropped {count} replayed duplicate row(s))',
94
94
  'route.recoverySessionRepairRejected': 'Session repair was rejected: {reason}',
@@ -0,0 +1,84 @@
1
+ import { readdir, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * issue #120:**不依赖 shell、不依赖可执行文件**的 git 认证兜底。
6
+ *
7
+ * 真机现场(Android,App 内嵌 `dsh web`):git 二进制编译期把 GIT_SHELL_PATH 钉成
8
+ * Termux 的 `/data/data/com.termux/files/usr/bin/sh`,该路径在 App 沙箱里不存在,而 git 执行
9
+ * credential helper 的方式是把 helper 值拼成 `sh -c 'git <helper> ...'` —— **没有**「值里不含空格
10
+ * 就不经 shell」这种写法(本机 git trace 实测:`run_command: 'git credential-store get'` 之后紧接
11
+ * `start_command: '<git>/usr/bin/sh.exe' -c 'git credential-store get'`;裸 `credential.helper=store`
12
+ * 也一样)。于是 helper 恒定失败:
13
+ * `fatal: cannot exec 'git credential-store --file=... credential get': No such file or directory`
14
+ * → 无 tty 时退化成 `fatal: could not read Password for 'https://oauth2@github.com': terminal prompts disabled`。
15
+ *
16
+ * 换成 `core.askpass` 躲不开:askpass 是一个**要被 exec 的脚本**,而 Android 10 起 targetSdk ≥ 29
17
+ * 的应用不允许执行自己数据目录里的文件(SELinux untrusted_app_29+ 对 app_data_file 无 execute 权限)。
18
+ *
19
+ * 唯一不需要 exec 的通道是 git 自己从环境变量读配置(git ≥ 2.31 的 GIT_CONFIG_COUNT 协议):
20
+ * 把 `http.<scope>.extraheader = AUTHORIZATION: basic <base64(user:token)>` 挂进 git 子进程的 env,
21
+ * 凭据既不进 argv、也不落临时文件、更不需要 sh —— token 只出现在 git 子进程的环境里
22
+ * (Android 上 /proc/<pid>/environ 也只有同 uid 可读,比临时凭据文件更窄)。
23
+ *
24
+ * 用法:helper 路线先跑;失败且命中 isCredentialExecFailure() 时换这份 env 重试一次。
25
+ */
26
+
27
+ /**
28
+ * credential helper 无法被执行时的 git 原文特征:
29
+ * - `cannot exec ...: No such file or directory`:shell 或 helper 二进制不存在;
30
+ * - `could not read Password ...` / `terminal prompts disabled`:helper 没跑成,退化成读终端口令。
31
+ */
32
+ const CREDENTIAL_EXEC_FAILURE_RE = /cannot exec|could not read Password|terminal prompts disabled/i;
33
+
34
+ /** 这条 git 失败是不是「credential helper 根本没能执行」(→ 值得改用 HTTP 认证头重试)。 */
35
+ export function isCredentialExecFailure(text: string): boolean {
36
+ return CREDENTIAL_EXEC_FAILURE_RE.test(text);
37
+ }
38
+
39
+ /** 目录是否为空(读不到 = 非空:拿不准时绝不删东西,见 runGit 的 clone 重试)。 */
40
+ export async function isEmptyDir(dir: string): Promise<boolean> {
41
+ try {
42
+ return (await readdir(dir)).length === 0;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** 清空目录内容但保留目录本身(只用于「命令开跑前确认为空」的 clone 重试路径)。 */
49
+ export async function clearDirContents(dir: string): Promise<void> {
50
+ const entries = await readdir(dir).catch(() => [] as string[]);
51
+ for (const entry of entries) {
52
+ await rm(join(dir, entry), { recursive: true, force: true });
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 构造「用 HTTP 认证头代替 credential helper」的子进程环境变量。
58
+ *
59
+ * - 顺带把 `credential.helper` 置空:否则全局 `.gitconfig` 里配的 helper 仍会被调用并再次 cannot exec。
60
+ * - 明文 http 常被远端重定向到 https,而 git 是按**每条请求的 URL** 匹配配置的 → 两个前缀都挂一份。
61
+ * - 返回空对象 = 注入不了(repoUrl 不是合法 URL,或令牌为空),调用方保持原有失败路径。
62
+ */
63
+ export function credentialHeaderEnv(repoUrl: string, username: string, token: string): Record<string, string> {
64
+ if (token === '') return {};
65
+ let origin: string;
66
+ try {
67
+ const u = new URL(repoUrl);
68
+ // 只取 scheme://host[:port]/ 作作用域:路径无关(同主机全部仓库都用这份令牌)
69
+ origin = `${u.protocol}//${u.host}/`;
70
+ } catch {
71
+ return {};
72
+ }
73
+ const scopes = [origin];
74
+ if (origin.startsWith('http://')) scopes.push(`https://${origin.slice('http://'.length)}`);
75
+ const authorization = `AUTHORIZATION: basic ${Buffer.from(`${username}:${token}`, 'utf8').toString('base64')}`;
76
+ const pairs: Array<[string, string]> = [['credential.helper', '']];
77
+ for (const scope of scopes) pairs.push([`http.${scope}.extraheader`, authorization]);
78
+ const env: Record<string, string> = { GIT_CONFIG_COUNT: String(pairs.length) };
79
+ pairs.forEach(([key, value], index) => {
80
+ env[`GIT_CONFIG_KEY_${String(index)}`] = key;
81
+ env[`GIT_CONFIG_VALUE_${String(index)}`] = value;
82
+ });
83
+ return env;
84
+ }
@@ -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[] = [];