dsh-git-branch-manage 1.0.10 → 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 +41 -0
- package/lib/client.js +82 -16
- package/lib/credential-service.d.ts +68 -0
- package/lib/credential-service.js +85 -0
- package/lib/credentials.d.ts +145 -40
- package/lib/credentials.js +398 -70
- package/lib/index.js +33 -21
- package/lib/local-import.d.ts +68 -0
- package/lib/local-import.js +151 -0
- package/lib/network-tool.d.ts +9 -8
- package/lib/network-tool.js +14 -18
- package/lib/routes.d.ts +23 -2
- package/lib/routes.js +65 -18
- package/lib/shell-bridge.d.ts +61 -69
- package/lib/shell-bridge.js +113 -90
- package/lib/tool.d.ts +4 -0
- package/lib/tool.js +2 -2
- package/lib/wire.d.ts +29 -0
- package/lib/wire.js +3 -0
- package/package.json +2 -1
- package/lib/helpers/git-credential-bridge.d.ts +0 -32
- package/lib/helpers/git-credential-bridge.js +0 -114
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# 变更日志
|
|
2
|
+
|
|
3
|
+
本文件记录 `dsh-git-branch-manage` 的重要变更。版本号遵循语义化版本。
|
|
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
|
+
|
|
22
|
+
## [1.1.0] - 2026-11-02
|
|
23
|
+
|
|
24
|
+
### 修复
|
|
25
|
+
|
|
26
|
+
- **宿主里的 `git` 命令全部 `fatal`**(`fatal: unable to parse command-line config`):0032 的 `GIT_CONFIG_KEY/VALUE_*` 环境注入被 DeepSeek Harness 的子进程环境清洗(按 `/KEY|PASSWORD|SECRET|TOKEN/i` 过滤变量名、并剥掉 `DSH_*` 前缀)半剥离——`KEY_n` 被删、`COUNT/VALUE_n` 幸存。改用 `GIT_CONFIG_GLOBAL` 指向一份会话临时 gitconfig 注入凭据(决策 0034)。
|
|
27
|
+
- **shell 里的 `git` 走本机 gh 账号而非面板令牌**:会话配置用 URL 作用域的空 `helper` 重置,清掉 `~/.gitconfig` 里 `[credential "https://github.com"]` 的 gh helper,让已配置域名命中面板令牌。
|
|
28
|
+
- **未配置域名弹 GCM 凭据对话框 / 挂死**:全局空 `helper` 清空机器 helper 链,未配置域名 `exit 128` 快速失败,不再弹窗。
|
|
29
|
+
- **含空格的 node/helper 路径调用失败**(`sh: C:/Program: No such file or directory`):`gitconfigEscape` / `shellQuote` 正确转义引号与反斜杠。
|
|
30
|
+
|
|
31
|
+
### 变更
|
|
32
|
+
|
|
33
|
+
- 新增 `createCredentialService`,把凭据入口收敛为单一服务(`store` / `authConfigArgs` / `refreshShellEnv` / `dispose`);routes、`git_network`、密钥工具统一经它取 store 与注入通道(决策 0034)。
|
|
34
|
+
- 桥 helper 环境变量改 `DGB_*` 命名(`DGB_BRIDGE_HELPER` 等),避开 DSH 环境清洗。
|
|
35
|
+
- 新增真实 `git` 端到端回归测试(`shell-bridge-e2e`):验证已配置域名命中桥令牌、空格路径正确调用、未配置域名快速失败。
|
|
36
|
+
|
|
37
|
+
## [1.0.10] - 2026-10-30
|
|
38
|
+
|
|
39
|
+
- shell 里的 `git push` 也走插件密钥(环境变量桥,决策 0032)。注:该注入通道在 DeepSeek Harness 宿主中失效,已由 1.1.0 修正(决策 0034)。
|
|
40
|
+
|
|
41
|
+
更早版本见 [GitHub Releases](https://github.com/GaoHu1997/dsh-git-branch/releases)。
|
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: [
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
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": "
|
|
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": "
|
|
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": "保存中…",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one entry point every credential channel goes through (decision 0037).
|
|
3
|
+
*
|
|
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
|
+
*
|
|
11
|
+
* `createCredentialService` owns the store instance and the include setup, so
|
|
12
|
+
* callers share one contract:
|
|
13
|
+
*
|
|
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.
|
|
20
|
+
*
|
|
21
|
+
* A store that cannot be read degrades to "no credentials" on every channel
|
|
22
|
+
* (an unauthenticated command runs as it always did; the credentials route is
|
|
23
|
+
* where the file problem surfaces) — a broken file must never break git.
|
|
24
|
+
*/
|
|
25
|
+
import { type CredentialStore } from './credentials.js';
|
|
26
|
+
import { type LocalImportReport } from './local-import.js';
|
|
27
|
+
/** Host-side seams the service reads through (tests substitute all of them). */
|
|
28
|
+
export interface CredentialServiceDeps {
|
|
29
|
+
/** User home directory seam. */
|
|
30
|
+
readonly home: () => string;
|
|
31
|
+
/** `$DSH_HOME` seam (blank/undefined = unset). */
|
|
32
|
+
readonly envHome: () => string | undefined;
|
|
33
|
+
/** Store seam; the fs-backed local store when absent. */
|
|
34
|
+
readonly store?: CredentialStore;
|
|
35
|
+
/** The environment object to mutate; `process.env` when absent. */
|
|
36
|
+
readonly env?: Record<string, string | undefined>;
|
|
37
|
+
/** User global config the generated include is installed into;
|
|
38
|
+
* `<home>/.gitconfig` when absent. */
|
|
39
|
+
readonly userGitconfig?: string;
|
|
40
|
+
}
|
|
41
|
+
/** The single credential entry point. */
|
|
42
|
+
export interface CredentialService {
|
|
43
|
+
/** The shared global store (one row per remote host). */
|
|
44
|
+
readonly store: CredentialStore;
|
|
45
|
+
/**
|
|
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.
|
|
50
|
+
*/
|
|
51
|
+
readonly refreshShellEnv: () => Promise<void>;
|
|
52
|
+
/**
|
|
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.
|
|
58
|
+
*/
|
|
59
|
+
readonly autoImport: () => Promise<LocalImportReport>;
|
|
60
|
+
/** Remove the fail-fast switches, the include line and the generated file. */
|
|
61
|
+
readonly dispose: () => Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build the credential service for one host process: one store instance, one
|
|
65
|
+
* gitconfig include, shared by the routes and every tool.
|
|
66
|
+
* @param deps - host seams (see {@link CredentialServiceDeps}).
|
|
67
|
+
*/
|
|
68
|
+
export declare function createCredentialService(deps: CredentialServiceDeps): CredentialService;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one entry point every credential channel goes through (decision 0037).
|
|
3
|
+
*
|
|
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
|
+
*
|
|
11
|
+
* `createCredentialService` owns the store instance and the include setup, so
|
|
12
|
+
* callers share one contract:
|
|
13
|
+
*
|
|
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.
|
|
20
|
+
*
|
|
21
|
+
* A store that cannot be read degrades to "no credentials" on every channel
|
|
22
|
+
* (an unauthenticated command runs as it always did; the credentials route is
|
|
23
|
+
* where the file problem surfaces) — a broken file must never break git.
|
|
24
|
+
*/
|
|
25
|
+
import { createLocalCredentialStore, migrateLegacyCredentialFile, } from './credentials.js';
|
|
26
|
+
import { createShellBridge } from './shell-bridge.js';
|
|
27
|
+
import { importLocalCredentials } from './local-import.js';
|
|
28
|
+
/**
|
|
29
|
+
* Build the credential service for one host process: one store instance, one
|
|
30
|
+
* gitconfig include, shared by the routes and every tool.
|
|
31
|
+
* @param deps - host seams (see {@link CredentialServiceDeps}).
|
|
32
|
+
*/
|
|
33
|
+
export function createCredentialService(deps) {
|
|
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
|
+
};
|
|
42
|
+
const shellBridge = createShellBridge(bridgeDeps);
|
|
43
|
+
let autoImportTask;
|
|
44
|
+
return {
|
|
45
|
+
store,
|
|
46
|
+
async refreshShellEnv() {
|
|
47
|
+
try {
|
|
48
|
+
await migrateLegacyCredentialFile(deps.home(), deps.envHome(), store);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A damaged legacy file must not block the include refresh; the
|
|
52
|
+
// credentials route reports it when the user opens the panel.
|
|
53
|
+
}
|
|
54
|
+
await shellBridge.apply();
|
|
55
|
+
},
|
|
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;
|
|
80
|
+
},
|
|
81
|
+
dispose() {
|
|
82
|
+
return shellBridge.dispose();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/lib/credentials.d.ts
CHANGED
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Global git credential store (host half)
|
|
3
|
-
*
|
|
4
|
-
* the panel touches, so the record lives in one file under `$DSH_HOME`
|
|
5
|
-
* (`~/.dsh`) instead of the per-repository settings document. The secret
|
|
6
|
-
* never travels to the browser — the list route answers masked tails only —
|
|
7
|
-
* and the store is read/written by the host alone.
|
|
2
|
+
* Global git credential store (host half), backed by the machine's own
|
|
3
|
+
* credential file (decision 0037).
|
|
8
4
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
5
|
+
* A credential belongs to a remote HOST, not to a project: the same
|
|
6
|
+
* `github.com` token serves every repository. Up to 1.1.x the plugin kept its
|
|
7
|
+
* own `~/.dsh/git-credentials.json`, which meant the panel's token was
|
|
8
|
+
* invisible to a plain `git` run in a shell and the machine's helpers
|
|
9
|
+
* answered instead (often with a different account). 0037 makes the panel
|
|
10
|
+
* manage the SAME file git's built-in `credential-store` helper uses —
|
|
11
|
+
* `~/.git-credentials` — so one save is visible to every git process:
|
|
12
|
+
*
|
|
13
|
+
* - the secret lines are the store format (`https://user:pass@host`),
|
|
14
|
+
* percent-encoded exactly as `git credential approve` writes them;
|
|
15
|
+
* - method/updatedAt cannot live in that format, so a sibling
|
|
16
|
+
* `<dsh-home>/git-credentials.meta.json` keeps the UI-only metadata
|
|
17
|
+
* (no secret ever goes in there);
|
|
18
|
+
* - a generated `<dsh-home>/git-credentials.include.gitconfig` scopes
|
|
19
|
+
* `helper = store` to each managed host, and `~/.gitconfig` gains one
|
|
20
|
+
* `[include] path = <that file>` line (idempotent, decision 0037
|
|
21
|
+
* overturns 0021's "never write the user's git config" for exactly this
|
|
22
|
+
* one scoped include).
|
|
23
|
+
*
|
|
24
|
+
* The secret never travels to the browser — the list route answers masked
|
|
25
|
+
* tails only — and only `https://` remotes are ever covered.
|
|
17
26
|
*/
|
|
18
27
|
import type { CredentialMethod } from './wire.js';
|
|
19
28
|
/**
|
|
20
29
|
* The Basic-auth name git sends when a token was stored without a username.
|
|
21
30
|
* GitHub, GitLab and Gitee all accept any non-empty name for a personal access
|
|
22
|
-
* token, and `oauth2` is the conventional placeholder.
|
|
31
|
+
* token, and `oauth2` is the conventional placeholder. The store format has no
|
|
32
|
+
* "blank username", so this value is what a blank one becomes on disk and is
|
|
33
|
+
* read back as blank again.
|
|
23
34
|
*/
|
|
24
35
|
export declare const TOKEN_USERNAME = "oauth2";
|
|
25
36
|
/** One stored credential: exactly one per remote host. */
|
|
@@ -27,19 +38,19 @@ export interface GitCredential {
|
|
|
27
38
|
/** Normalized remote host (`github.com`, `git.example.com:8443`). */
|
|
28
39
|
host: string;
|
|
29
40
|
/** Basic-auth username (a real account name, or a vendor-form placeholder
|
|
30
|
-
* such as `x-access-token` / `oauth2`). */
|
|
41
|
+
* such as `x-access-token` / `oauth2`). Blank for a bare token. */
|
|
31
42
|
username: string;
|
|
32
43
|
/** Token or password, verbatim (never returned to the client). */
|
|
33
44
|
secret: string;
|
|
34
45
|
/** How the secret is meant to be used — informational for the UI; git
|
|
35
46
|
* itself only sees the Basic header. */
|
|
36
47
|
method: CredentialMethod;
|
|
37
|
-
/** Epoch millis of the last save. */
|
|
48
|
+
/** Epoch millis of the last save (0 when unknown). */
|
|
38
49
|
updatedAt: number;
|
|
39
50
|
}
|
|
40
51
|
/** Load/save seam for the credential file (tests substitute an in-memory one). */
|
|
41
52
|
export interface CredentialStore {
|
|
42
|
-
/** Absolute file the store persists to (for diagnostics/UI hints). */
|
|
53
|
+
/** Absolute file the store persists secrets to (for diagnostics/UI hints). */
|
|
43
54
|
readonly path: string;
|
|
44
55
|
/** Every stored credential, one per host, sorted by host. A missing file
|
|
45
56
|
* is the empty list; an unreadable/malformed one throws
|
|
@@ -54,11 +65,32 @@ export declare class CredentialStoreError extends Error {
|
|
|
54
65
|
constructor(path: string, message: string);
|
|
55
66
|
}
|
|
56
67
|
/**
|
|
57
|
-
*
|
|
68
|
+
* The retired 0021/0029 plugin-private file `<dsh-home>/git-credentials.json`.
|
|
69
|
+
* Read once by {@link migrateLegacyCredentialFile} and deleted afterwards.
|
|
58
70
|
* @param home - user home directory seam.
|
|
59
71
|
* @param envHome - `$DSH_HOME` environment value seam (blank = unset).
|
|
60
72
|
*/
|
|
61
73
|
export declare function credentialFilePath(home: string, envHome: string | undefined): string;
|
|
74
|
+
/**
|
|
75
|
+
* `~/.git-credentials` — the file git's `credential-store` helper reads and
|
|
76
|
+
* writes (`$HOME/.git-credentials` on every platform).
|
|
77
|
+
* @param home - user home directory seam.
|
|
78
|
+
*/
|
|
79
|
+
export declare function gitCredentialsFile(home: string): string;
|
|
80
|
+
/**
|
|
81
|
+
* The UI-only metadata sidecar: method and `updatedAt` per host. Never holds a
|
|
82
|
+
* secret; a missing or damaged file only costs the "how/when" columns.
|
|
83
|
+
* @param home - user home directory seam.
|
|
84
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
85
|
+
*/
|
|
86
|
+
export declare function credentialMetaFile(home: string, envHome: string | undefined): string;
|
|
87
|
+
/**
|
|
88
|
+
* The generated gitconfig included from `~/.gitconfig`: one URL-scoped
|
|
89
|
+
* `helper = store` section per managed host.
|
|
90
|
+
* @param home - user home directory seam.
|
|
91
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
92
|
+
*/
|
|
93
|
+
export declare function credentialIncludeFile(home: string, envHome: string | undefined): string;
|
|
62
94
|
/** Why a user-typed host is not usable yet (undefined = fine). */
|
|
63
95
|
export type CredentialHostIssue = 'empty' | 'invalid';
|
|
64
96
|
/**
|
|
@@ -99,16 +131,6 @@ export declare function credentialSecretIssue(value: string): 'empty' | 'illegal
|
|
|
99
131
|
* @param secret - the stored secret.
|
|
100
132
|
*/
|
|
101
133
|
export declare function maskSecret(secret: string): string;
|
|
102
|
-
/**
|
|
103
|
-
* The git config argv that authenticates network commands against the stored
|
|
104
|
-
* hosts: one `http.<https-url>.extraHeader` per host. Git applies a
|
|
105
|
-
* `http.<url>.*` section only to a matching remote URL, so injecting every
|
|
106
|
-
* stored host at once cannot leak one host's header to another; only
|
|
107
|
-
* `https://` URLs match (ssh/http never receive it).
|
|
108
|
-
* @param entries - stored credentials (duplicates collapsed by host).
|
|
109
|
-
* @returns the `-c <key>=<value>` pairs, ready to prepend to a subcommand.
|
|
110
|
-
*/
|
|
111
|
-
export declare function authConfigArgs(entries: readonly GitCredential[]): readonly string[];
|
|
112
134
|
/**
|
|
113
135
|
* Insert or update one credential; the pair `(host, updatedAt)` changes,
|
|
114
136
|
* everything else is replaced. An existing host keeps its position.
|
|
@@ -127,22 +149,105 @@ export declare function removeCredential(entries: readonly GitCredential[], host
|
|
|
127
149
|
/** Stable host order for the file and the UI. */
|
|
128
150
|
export declare function sortCredentials(entries: readonly GitCredential[]): GitCredential[];
|
|
129
151
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
152
|
+
* Percent-encode one userinfo component the way git's store helper does:
|
|
153
|
+
* everything outside the RFC 3986 unreserved set becomes `%xx` (lowercase
|
|
154
|
+
* hex, matching `git credential approve`). `encodeURIComponent` would leave
|
|
155
|
+
* `!'()*` alone and git would then mis-split the line at a literal `@`.
|
|
156
|
+
* @param value - raw username or secret.
|
|
157
|
+
*/
|
|
158
|
+
export declare function encodeCredentialPart(value: string): string;
|
|
159
|
+
/**
|
|
160
|
+
* One `<scheme>://user:pass@host` line, ready for `~/.git-credentials`.
|
|
161
|
+
* @param entry - the credential to write.
|
|
162
|
+
*/
|
|
163
|
+
export declare function formatCredentialLine(entry: GitCredential): string;
|
|
164
|
+
/** A `~/.git-credentials` file split into credentials and untouched lines. */
|
|
165
|
+
export interface ParsedCredentials {
|
|
166
|
+
/** One credential per https host; a duplicated host keeps the LAST line,
|
|
167
|
+
* which is the line git's store helper would resolve to after a rewrite. */
|
|
168
|
+
readonly entries: GitCredential[];
|
|
169
|
+
/** Lines this store does not own (comments, ssh/http rows, malformed
|
|
170
|
+
* text). Written back verbatim so a hand-edited file survives a save. */
|
|
171
|
+
readonly others: string[];
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Parse a `~/.git-credentials` document.
|
|
175
|
+
* @param text - raw file contents (BOM tolerated).
|
|
176
|
+
*/
|
|
177
|
+
export declare function parseGitCredentials(text: string): ParsedCredentials;
|
|
178
|
+
/**
|
|
179
|
+
* Parse the retired `<dsh-home>/git-credentials.json`. Unknown extra fields
|
|
180
|
+
* are tolerated (a hand-edited file), but a malformed entry is an error rather
|
|
181
|
+
* than a silent drop — losing a credential during the one-time migration is
|
|
182
|
+
* worse than an explicit message.
|
|
134
183
|
* @param text - raw file contents.
|
|
135
184
|
* @param path - file path (for the error message).
|
|
136
185
|
*/
|
|
137
186
|
export declare function parseCredentialFile(text: string, path?: string): GitCredential[];
|
|
187
|
+
/** Write-then-rename so a crashed save never truncates the real file. */
|
|
188
|
+
export declare function writeFileAtomic(path: string, text: string, mode?: number): Promise<void>;
|
|
189
|
+
/**
|
|
190
|
+
* The real store over `~/.git-credentials`. The secret lines are the git
|
|
191
|
+
* store format; the sibling metadata file carries method/updatedAt only.
|
|
192
|
+
* A missing file reads as "no credentials yet".
|
|
193
|
+
* @param home - user home directory seam.
|
|
194
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
195
|
+
*/
|
|
196
|
+
export declare function createLocalCredentialStore(home: string, envHome: string | undefined): CredentialStore;
|
|
197
|
+
/**
|
|
198
|
+
* One-time 0037 migration: import the retired plugin-private JSON into the
|
|
199
|
+
* local store (plugin values win for a host they share), then delete the old
|
|
200
|
+
* file so there is exactly one place a secret lives.
|
|
201
|
+
*
|
|
202
|
+
* Idempotent: a missing legacy file is a no-op, and the file is removed only
|
|
203
|
+
* after the store write succeeded — a failed migration keeps the old file.
|
|
204
|
+
* @param home - user home directory seam.
|
|
205
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
206
|
+
* @param store - destination store.
|
|
207
|
+
* @returns true when a legacy file was imported and removed.
|
|
208
|
+
*/
|
|
209
|
+
export declare function migrateLegacyCredentialFile(home: string, envHome: string | undefined, store: CredentialStore): Promise<boolean>;
|
|
210
|
+
/**
|
|
211
|
+
* The generated include file's contents: one URL-scoped section per host that
|
|
212
|
+
* resets any inherited helper for that exact URL and then installs the
|
|
213
|
+
* built-in `store` helper. The empty `helper =` line is what beats a
|
|
214
|
+
* user-level helper (`gh`, GCM) configured for the same host.
|
|
215
|
+
* @param hosts - managed remote hosts (already normalized).
|
|
216
|
+
*/
|
|
217
|
+
export declare function credentialIncludeBody(hosts: readonly string[]): string;
|
|
218
|
+
/**
|
|
219
|
+
* Whether `~/.gitconfig` already includes the generated file. Matched loosely
|
|
220
|
+
* (any `path = <X>` line pointing at it) so a hand-reformatted include — or
|
|
221
|
+
* one with forward slashes on Windows — is not added a second time.
|
|
222
|
+
* @param text - current `~/.gitconfig` contents.
|
|
223
|
+
* @param includePath - the generated file.
|
|
224
|
+
*/
|
|
225
|
+
export declare function gitconfigHasInclude(text: string, includePath: string): boolean;
|
|
226
|
+
/**
|
|
227
|
+
* Append the `[include] path = …` section, leaving every existing byte alone.
|
|
228
|
+
* @param text - current `~/.gitconfig` contents.
|
|
229
|
+
* @param includePath - the generated file.
|
|
230
|
+
*/
|
|
231
|
+
export declare function appendGitconfigInclude(text: string, includePath: string): string;
|
|
232
|
+
/**
|
|
233
|
+
* Idempotently ensure `~/.gitconfig` includes the generated file. Rewrites
|
|
234
|
+
* atomically, preserving the file's existing mode; a missing file is created.
|
|
235
|
+
* @param gitconfigPath - user global config (`<home>/.gitconfig`).
|
|
236
|
+
* @param includePath - the generated file.
|
|
237
|
+
* @returns true when the file had to be changed.
|
|
238
|
+
*/
|
|
239
|
+
export declare function ensureGitconfigInclude(gitconfigPath: string, includePath: string): Promise<boolean>;
|
|
138
240
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
241
|
+
* Remove the include section pointing at the generated file (plugin teardown).
|
|
242
|
+
* Every other line is preserved verbatim.
|
|
243
|
+
* @param text - current `~/.gitconfig` contents.
|
|
244
|
+
* @param includePath - the generated file.
|
|
141
245
|
*/
|
|
142
|
-
export declare function
|
|
246
|
+
export declare function stripGitconfigInclude(text: string, includePath: string): string;
|
|
143
247
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* @param
|
|
248
|
+
* Remove the generated include from `~/.gitconfig`. Best effort: a teardown
|
|
249
|
+
* must not throw, and an already-clean file is left untouched.
|
|
250
|
+
* @param gitconfigPath - user global config (`<home>/.gitconfig`).
|
|
251
|
+
* @param includePath - the generated file.
|
|
147
252
|
*/
|
|
148
|
-
export declare function
|
|
253
|
+
export declare function removeGitconfigInclude(gitconfigPath: string, includePath: string): Promise<void>;
|