dsh-git-branch-manage 1.1.0 → 1.2.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  本文件记录 `dsh-git-branch-manage` 的重要变更。版本号遵循语义化版本。
4
4
 
5
+ ## [1.2.0] - 2026-11-02
6
+
7
+ ### 新增
8
+
9
+ - **凭据管理直接读写本机 `~/.git-credentials`**(决策 0037):面板里存一次,任何 shell、任何子进程的 `git push/fetch/pull` 都用同一账号;文件里不属于本插件的行(注释、`ssh://` 等)原样保留,卸载插件后凭据仍属于用户。
10
+ - **从本机导入凭据,启动时默认执行**(决策 0038):按用户 gitconfig 里声明过的 credential 域名逐个探测(`git credential fill`,串行 + 20 秒超时),答出令牌即导入面板;面板另有手动「从本机导入」入口(`POST /credentials-import`),toast 汇总导入/跳过/失败数量。`gh`、GCM、`store` 里的账号一次导入即可接管。
11
+
12
+ ### 变更
13
+
14
+ - 凭据生效通道改用 git 原生机制:`~/.gitconfig` 幂等追加一行 `[include]` 指向生成的 URL 作用域配置(每个已存域名重置 helper 链后装内置 `store`),不再向进程环境注入(废弃 0032/0034 的 `GIT_CONFIG_*` / `GIT_CONFIG_GLOBAL` 通道),令牌也不再进入命令行参数。
15
+ - 未管理域名的机器原有 helper(gh、GCM 等)保持原样,不再被全局清空;未存域名依旧 fail-fast 不弹窗。
16
+ - 令牌明文落在 `~/.git-credentials`(0600)——与 git 自带 `store` helper 一致的安全取舍;面板列表元数据(`method`/`updatedAt`)另存不含 secret 的旁文件。
17
+
18
+ ### 移除
19
+
20
+ - 删除自研 `git-credential-bridge` helper,改用 git 内置 `credential-store`,不再为每次 git 调用拉起 node 进程。
21
+
5
22
  ## [1.1.0] - 2026-11-02
6
23
 
7
24
  ### 修复
package/lib/client.js CHANGED
@@ -144,6 +144,9 @@ window.__ModuleLoader__.load({
144
144
  /** POST ROUTE_PREFIX/credentials-oauth-poll — poll a started browser sign-in;
145
145
  * stores the token and answers the fresh masked list once the user approves. */
146
146
  const ROUTE_CREDENTIALS_OAUTH_POLL = `${ROUTE_PREFIX}/credentials-oauth-poll`;
147
+ /** POST ROUTE_PREFIX/credentials-import — copy the credentials the machine's
148
+ * own helpers already answer for into the managed store (decision 0038). */
149
+ const ROUTE_CREDENTIALS_IMPORT = `${ROUTE_PREFIX}/credentials-import`;
147
150
  /** POST ROUTE_PREFIX/remotes — the repository's configured remotes. */
148
151
  const ROUTE_REMOTES = `${ROUTE_PREFIX}/remotes`;
149
152
  /** POST ROUTE_PREFIX/remote — add, re-point or remove ONE remote. */
@@ -508,6 +511,16 @@ window.__ModuleLoader__.load({
508
511
  return post(ROUTE_CREDENTIALS_OAUTH_POLL, body);
509
512
  }
510
513
  /**
514
+ * Adopt the credentials this machine's own git helpers already answer for
515
+ * (gh, Windows Credential Manager, Keychain) into the plugin's store. Hosts
516
+ * the panel already manages are skipped; a host whose helper had nothing is
517
+ * reported per host, never as a request failure.
518
+ * @param body - `{}` (probe what the gitconfig names) or `{ hosts }`.
519
+ */
520
+ function requestImportCredentials(body = {}) {
521
+ return post(ROUTE_CREDENTIALS_IMPORT, body);
522
+ }
523
+ /**
511
524
  * The repository's configured remotes (fetch/push URLs).
512
525
  * @param repoPath - absolute directory inside the repository.
513
526
  */
@@ -4784,6 +4797,10 @@ window.__ModuleLoader__.load({
4784
4797
  const [seed, setSeed] = (0, react.useState)();
4785
4798
  const [formError, setFormError] = (0, react.useState)();
4786
4799
  const [busy, setBusy] = (0, react.useState)(false);
4800
+ /** The one-click machine import runs on its own flag: it is read-only on the
4801
+ * form, so it must not disable editing, and a slow helper (a cold keychain
4802
+ * prompt) must not look like a hung save. */
4803
+ const [importing, setImporting] = (0, react.useState)(false);
4787
4804
  /** Two-step delete: the first click only arms the row. */
4788
4805
  const [confirmHost, setConfirmHost] = (0, react.useState)();
4789
4806
  /** The editor starts collapsed; the "+" icon in the header row opens it,
@@ -5006,6 +5023,34 @@ window.__ModuleLoader__.load({
5006
5023
  seed,
5007
5024
  t
5008
5025
  ]);
5026
+ /** Adopt what this machine's own git helpers already answer for. The host
5027
+ * decides which hosts to probe (the user's gitconfig names them) and skips
5028
+ * the ones the panel already manages, so this is always safe to click. */
5029
+ const importLocal = (0, react.useCallback)(async () => {
5030
+ if (busy || importing) return;
5031
+ setImporting(true);
5032
+ const result = await requestImportCredentials();
5033
+ setImporting(false);
5034
+ if (!result.ok) {
5035
+ pushToast(result.error, "error");
5036
+ return;
5037
+ }
5038
+ setEntries(result.credentials);
5039
+ if (result.imported.length === 0) {
5040
+ pushToast(t("cred.importNone"), result.failed.length === 0 ? "success" : "error");
5041
+ return;
5042
+ }
5043
+ pushToast(t("cred.importDone", {
5044
+ count: result.imported.length,
5045
+ skipped: result.skipped.length,
5046
+ failed: result.failed.length
5047
+ }), "success");
5048
+ }, [
5049
+ busy,
5050
+ importing,
5051
+ pushToast,
5052
+ t
5053
+ ]);
5009
5054
  const title = seed === void 0 ? t("cred.add") : t("cred.edit");
5010
5055
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
5011
5056
  open: true,
@@ -5021,21 +5066,34 @@ window.__ModuleLoader__.load({
5021
5066
  children: [
5022
5067
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5023
5068
  className: GitHistoryPanel_module_default.credTop,
5024
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5025
- className: GitHistoryPanel_module_default.credHint,
5026
- children: t("cred.hint")
5027
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5028
- type: "button",
5029
- className: GitHistoryPanel_module_default.credAdd,
5030
- title: t("cred.add"),
5031
- "aria-label": t("cred.add"),
5032
- "aria-expanded": editorOpen,
5033
- onClick: openNew,
5034
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5035
- "aria-hidden": "true",
5036
- children: "+"
5069
+ children: [
5070
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5071
+ className: GitHistoryPanel_module_default.credHint,
5072
+ children: t("cred.hint")
5073
+ }),
5074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
5075
+ size: "sm",
5076
+ variant: "ghost",
5077
+ disabled: busy || importing,
5078
+ title: t("cred.import"),
5079
+ onClick: () => {
5080
+ importLocal();
5081
+ },
5082
+ children: importing ? t("cred.importing") : t("cred.import")
5083
+ }),
5084
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5085
+ type: "button",
5086
+ className: GitHistoryPanel_module_default.credAdd,
5087
+ title: t("cred.add"),
5088
+ "aria-label": t("cred.add"),
5089
+ "aria-expanded": editorOpen,
5090
+ onClick: openNew,
5091
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5092
+ "aria-hidden": "true",
5093
+ children: "+"
5094
+ })
5037
5095
  })
5038
- })]
5096
+ ]
5039
5097
  }),
5040
5098
  loadError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5041
5099
  className: GitHistoryPanel_module_default.formError,
@@ -8432,7 +8490,7 @@ window.__ModuleLoader__.load({
8432
8490
  "accel.mirrorDownHint": "If the mirror is down, disable acceleration or change the URL",
8433
8491
  "panel.credentials": "Credentials",
8434
8492
  "cred.title": "Git credentials",
8435
- "cred.hint": "Global, one entry per host: a token saved for github.com serves every repository. Stored only on this machine in git-credentials.json.",
8493
+ "cred.hint": "One entry per host, shared by all repos. Stored in ~/.git-credentials, so plain git uses it too.",
8436
8494
  "cred.empty": "No credentials saved yet",
8437
8495
  "cred.hostLabel": "Host",
8438
8496
  "cred.hostPlaceholder": "github.com",
@@ -8447,6 +8505,10 @@ window.__ModuleLoader__.load({
8447
8505
  "cred.methodPassword": "Password",
8448
8506
  "cred.getToken": "Get a token",
8449
8507
  "cred.add": "Add credential",
8508
+ "cred.import": "Import from this machine",
8509
+ "cred.importing": "Importing…",
8510
+ "cred.importDone": "Imported {count} host(s) — skipped {skipped}, failed {failed}",
8511
+ "cred.importNone": "Nothing new to import from this machine",
8450
8512
  "cred.edit": "Edit",
8451
8513
  "cred.save": "Save",
8452
8514
  "cred.saving": "Saving…",
@@ -8718,7 +8780,7 @@ window.__ModuleLoader__.load({
8718
8780
  "accel.mirrorDownHint": "若加速地址不可用,可停用国内加速或更换地址",
8719
8781
  "panel.credentials": "密钥配置",
8720
8782
  "cred.title": "Git 密钥配置",
8721
- "cred.hint": "全局生效:同一域名只需配置一次,该域名的所有仓库共用。密钥只保存在本机 git-credentials.json 中。",
8783
+ "cred.hint": "按域名保存,所有仓库共用;密钥写入本机 ~/.git-credentials,命令行 git 同样生效。",
8722
8784
  "cred.empty": "还没有配置任何密钥",
8723
8785
  "cred.hostLabel": "域名",
8724
8786
  "cred.hostPlaceholder": "github.com",
@@ -8733,6 +8795,10 @@ window.__ModuleLoader__.load({
8733
8795
  "cred.methodPassword": "密码",
8734
8796
  "cred.getToken": "获取令牌",
8735
8797
  "cred.add": "新增密钥",
8798
+ "cred.import": "从本机导入",
8799
+ "cred.importing": "导入中…",
8800
+ "cred.importDone": "已导入 {count} 个域名(跳过 {skipped},失败 {failed})",
8801
+ "cred.importNone": "本机没有新的凭据可导入",
8736
8802
  "cred.edit": "编辑",
8737
8803
  "cred.save": "保存",
8738
8804
  "cred.saving": "保存中…",
@@ -1,49 +1,41 @@
1
1
  /**
2
- * The one entry point every credential channel goes through (decision 0034).
2
+ * The one entry point every credential channel goes through (decision 0037).
3
3
  *
4
- * Before this module the store, the `-c http.<url>.extraHeader` injection
5
- * (`authConfigArgs`, 0021/0029) and the shell environment bridge
6
- * (`shell-bridge.ts`, 0032/0034) were assembled in three places — the routes,
7
- * the two model-facing tools and the plugin entry — each resolving the store
8
- * path itself. Every future plugin that needs git credentials would have
9
- * repeated that wiring and drifted from it.
4
+ * The store is the machine's own `~/.git-credentials` (git's
5
+ * `credential-store` format), plus a secret-free metadata sidecar for the
6
+ * panel's "method / updated" columns. Every channel — the panel routes, both
7
+ * model-facing tools, and a plain `git` command in any shell — authenticates
8
+ * through the same `credential-store` helper, because `createShellBridge`
9
+ * installs a URL-scoped `helper = store` include into `~/.gitconfig`.
10
10
  *
11
- * `createCredentialService` owns the store instance and both injection
12
- * channels, so callers share one contract:
11
+ * `createCredentialService` owns the store instance and the include setup, so
12
+ * callers share one contract:
13
13
  *
14
- * - {@link CredentialService.authConfigArgs} — the `-c extraHeader` pairs for
15
- * git commands the plugin itself runs (panel routes, `git_network`). The
16
- * token rides the argv of that one command and never an environment.
17
- * - {@link CredentialService.refreshShellEnv} — re-derive the session
18
- * gitconfig + `GIT_CONFIG_GLOBAL` environment so a plain shell `git push`
19
- * authenticates as configured. Call it after every store change.
20
- * - {@link CredentialService.dispose} — undo the environment injection and
21
- * delete the session config file (plugin teardown).
14
+ * - {@link CredentialService.refreshShellEnv} — re-derive the include after a
15
+ * store change and run the one-time legacy import. Call it after every save
16
+ * / delete / sign-in; it never throws.
17
+ * - {@link CredentialService.dispose} — remove the include line and the
18
+ * generated file on plugin teardown. The credentials themselves stay in
19
+ * `~/.git-credentials`: they belong to the user, not to the plugin.
22
20
  *
23
21
  * A store that cannot be read degrades to "no credentials" on every channel
24
22
  * (an unauthenticated command runs as it always did; the credentials route is
25
23
  * where the file problem surfaces) — a broken file must never break git.
26
24
  */
27
25
  import { type CredentialStore } from './credentials.js';
26
+ import { type LocalImportReport } from './local-import.js';
28
27
  /** Host-side seams the service reads through (tests substitute all of them). */
29
28
  export interface CredentialServiceDeps {
30
29
  /** User home directory seam. */
31
30
  readonly home: () => string;
32
31
  /** `$DSH_HOME` seam (blank/undefined = unset). */
33
32
  readonly envHome: () => string | undefined;
34
- /** Store seam; the fs-backed store at the resolved path when absent. */
33
+ /** Store seam; the fs-backed local store when absent. */
35
34
  readonly store?: CredentialStore;
36
35
  /** The environment object to mutate; `process.env` when absent. */
37
36
  readonly env?: Record<string, string | undefined>;
38
- /** Absolute helper script path; the compiled sibling under `helpers/` when absent. */
39
- readonly helperPath?: string;
40
- /** Node executable that runs the helper; `process.execPath` when absent. */
41
- readonly nodePath?: string;
42
- /** Directory for the session config; the OS temp dir when absent. */
43
- readonly configDir?: () => string;
44
- /** Exact session config path (tests); derived from the pid when absent. */
45
- readonly configPath?: string;
46
- /** User global config the session file includes; `<home>/.gitconfig` when absent. */
37
+ /** User global config the generated include is installed into;
38
+ * `<home>/.gitconfig` when absent. */
47
39
  readonly userGitconfig?: string;
48
40
  }
49
41
  /** The single credential entry point. */
@@ -51,24 +43,26 @@ export interface CredentialService {
51
43
  /** The shared global store (one row per remote host). */
52
44
  readonly store: CredentialStore;
53
45
  /**
54
- * `-c http.<url>.extraHeader` pairs for every stored credential, ready to
55
- * prepend to a git network command the plugin runs itself. A broken store
56
- * degrades to "no credentials" rather than throwing.
46
+ * Import the retired plugin-private file (once) and re-derive the git
47
+ * include after a credential changed, so a message sent in the same session
48
+ * can `git push` with the token the user just saved. Never throws: the worst
49
+ * case is a stale include until the next refresh.
57
50
  */
58
- readonly authConfigArgs: () => Promise<readonly string[]>;
51
+ readonly refreshShellEnv: () => Promise<void>;
59
52
  /**
60
- * Re-derive the shell credential environment after a credential changed, so
61
- * a message sent in the same session can `git push` with the token the user
62
- * just saved. Never throws: the worst case is a stale environment until the
63
- * next refresh.
53
+ * Copy the credentials the machine's own helpers already answer for into
54
+ * the store, once per process (decision 0038). Runs on startup so a machine
55
+ * already signed in through gh / GCM / Keychain keeps working without the
56
+ * user re-entering a token here. Idempotent, never throws; the hosts that
57
+ * did import trigger one include refresh.
64
58
  */
65
- readonly refreshShellEnv: () => Promise<void>;
66
- /** Remove the environment injection and delete the session config file. */
59
+ readonly autoImport: () => Promise<LocalImportReport>;
60
+ /** Remove the fail-fast switches, the include line and the generated file. */
67
61
  readonly dispose: () => Promise<void>;
68
62
  }
69
63
  /**
70
64
  * Build the credential service for one host process: one store instance, one
71
- * session-gitconfig bridge, shared by the routes and every tool.
65
+ * gitconfig include, shared by the routes and every tool.
72
66
  * @param deps - host seams (see {@link CredentialServiceDeps}).
73
67
  */
74
68
  export declare function createCredentialService(deps: CredentialServiceDeps): CredentialService;
@@ -1,53 +1,82 @@
1
1
  /**
2
- * The one entry point every credential channel goes through (decision 0034).
2
+ * The one entry point every credential channel goes through (decision 0037).
3
3
  *
4
- * Before this module the store, the `-c http.<url>.extraHeader` injection
5
- * (`authConfigArgs`, 0021/0029) and the shell environment bridge
6
- * (`shell-bridge.ts`, 0032/0034) were assembled in three places — the routes,
7
- * the two model-facing tools and the plugin entry — each resolving the store
8
- * path itself. Every future plugin that needs git credentials would have
9
- * repeated that wiring and drifted from it.
4
+ * The store is the machine's own `~/.git-credentials` (git's
5
+ * `credential-store` format), plus a secret-free metadata sidecar for the
6
+ * panel's "method / updated" columns. Every channel — the panel routes, both
7
+ * model-facing tools, and a plain `git` command in any shell — authenticates
8
+ * through the same `credential-store` helper, because `createShellBridge`
9
+ * installs a URL-scoped `helper = store` include into `~/.gitconfig`.
10
10
  *
11
- * `createCredentialService` owns the store instance and both injection
12
- * channels, so callers share one contract:
11
+ * `createCredentialService` owns the store instance and the include setup, so
12
+ * callers share one contract:
13
13
  *
14
- * - {@link CredentialService.authConfigArgs} — the `-c extraHeader` pairs for
15
- * git commands the plugin itself runs (panel routes, `git_network`). The
16
- * token rides the argv of that one command and never an environment.
17
- * - {@link CredentialService.refreshShellEnv} — re-derive the session
18
- * gitconfig + `GIT_CONFIG_GLOBAL` environment so a plain shell `git push`
19
- * authenticates as configured. Call it after every store change.
20
- * - {@link CredentialService.dispose} — undo the environment injection and
21
- * delete the session config file (plugin teardown).
14
+ * - {@link CredentialService.refreshShellEnv} — re-derive the include after a
15
+ * store change and run the one-time legacy import. Call it after every save
16
+ * / delete / sign-in; it never throws.
17
+ * - {@link CredentialService.dispose} — remove the include line and the
18
+ * generated file on plugin teardown. The credentials themselves stay in
19
+ * `~/.git-credentials`: they belong to the user, not to the plugin.
22
20
  *
23
21
  * A store that cannot be read degrades to "no credentials" on every channel
24
22
  * (an unauthenticated command runs as it always did; the credentials route is
25
23
  * where the file problem surfaces) — a broken file must never break git.
26
24
  */
27
- import { authConfigArgs, createCredentialStore, credentialFilePath, } from './credentials.js';
25
+ import { createLocalCredentialStore, migrateLegacyCredentialFile, } from './credentials.js';
28
26
  import { createShellBridge } from './shell-bridge.js';
27
+ import { importLocalCredentials } from './local-import.js';
29
28
  /**
30
29
  * Build the credential service for one host process: one store instance, one
31
- * session-gitconfig bridge, shared by the routes and every tool.
30
+ * gitconfig include, shared by the routes and every tool.
32
31
  * @param deps - host seams (see {@link CredentialServiceDeps}).
33
32
  */
34
33
  export function createCredentialService(deps) {
35
- const store = deps.store
36
- ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
37
- const bridgeDeps = { ...deps, store };
34
+ const store = deps.store ?? createLocalCredentialStore(deps.home(), deps.envHome());
35
+ const bridgeDeps = {
36
+ home: deps.home,
37
+ envHome: deps.envHome,
38
+ store,
39
+ ...(deps.env === undefined ? {} : { env: deps.env }),
40
+ ...(deps.userGitconfig === undefined ? {} : { userGitconfig: deps.userGitconfig }),
41
+ };
38
42
  const shellBridge = createShellBridge(bridgeDeps);
43
+ let autoImportTask;
39
44
  return {
40
45
  store,
41
- async authConfigArgs() {
46
+ async refreshShellEnv() {
42
47
  try {
43
- return authConfigArgs(await store.load());
48
+ await migrateLegacyCredentialFile(deps.home(), deps.envHome(), store);
44
49
  }
45
50
  catch {
46
- return [];
51
+ // A damaged legacy file must not block the include refresh; the
52
+ // credentials route reports it when the user opens the panel.
47
53
  }
54
+ await shellBridge.apply();
48
55
  },
49
- refreshShellEnv() {
50
- return shellBridge.apply();
56
+ autoImport() {
57
+ autoImportTask ??= (async () => {
58
+ try {
59
+ // The legacy file migrates first so an imported host cannot
60
+ // duplicate a row the panel already had.
61
+ await migrateLegacyCredentialFile(deps.home(), deps.envHome(), store);
62
+ }
63
+ catch {
64
+ // Same contract as refreshShellEnv: a broken legacy file is not
65
+ // worth failing the startup import over.
66
+ }
67
+ try {
68
+ const report = await importLocalCredentials(store, { env: deps.env ?? process.env });
69
+ if (report.imported.length > 0)
70
+ await shellBridge.apply();
71
+ return report;
72
+ }
73
+ catch {
74
+ // The import is opportunistic: a missing git, an unreadable store
75
+ // or a helper that misbehaves just leaves the panel as it was.
76
+ return { imported: [], skipped: [], failed: [] };
77
+ }
78
+ })();
79
+ return autoImportTask;
51
80
  },
52
81
  dispose() {
53
82
  return shellBridge.dispose();