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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Import the machine's OWN credentials into the managed store (decision 0038).
3
+ *
4
+ * The store itself only knows the lines of `~/.git-credentials`. Everything
5
+ * the user already signed in through — Windows Credential Manager, macOS
6
+ * keychain, `gh auth git-credential` — lives outside that file and cannot be
7
+ * enumerated. What CAN be enumerated is the set of hosts git is configured to
8
+ * talk to (`[credential "https://host"]` sections in the user's gitconfig), so
9
+ * the import probes each of those with `git credential fill` and copies the
10
+ * secret the helper is willing to hand over. A host that answers nothing is
11
+ * reported as failed and left alone: importing never clears, only adds.
12
+ */
13
+ import { type CredentialStore } from './credentials.js';
14
+ /** One host the import copied out of the machine's own helper. */
15
+ export interface ImportedCredential {
16
+ host: string;
17
+ username: string;
18
+ method: 'token' | 'password';
19
+ }
20
+ /** One host whose helper refused (or could not be asked) — the store is untouched. */
21
+ export interface LocalImportFailure {
22
+ host: string;
23
+ error: string;
24
+ }
25
+ /** Outcome of one import run. */
26
+ export interface LocalImportReport {
27
+ imported: ImportedCredential[];
28
+ skipped: string[];
29
+ failed: LocalImportFailure[];
30
+ }
31
+ /** One finished git invocation (local import never throws on a non-zero exit). */
32
+ export interface LocalGitResult {
33
+ code: number;
34
+ stdout: string;
35
+ stderr: string;
36
+ }
37
+ /** Runs one git command with `input` on stdin; tests substitute a fake. */
38
+ export type LocalGit = (args: readonly string[], input: string, env: NodeJS.ProcessEnv, timeoutMs: number) => Promise<LocalGitResult>;
39
+ /** Seams for the import: the environment, the git runner, the per-host timeout. */
40
+ export interface LocalImportDeps {
41
+ env?: NodeJS.ProcessEnv;
42
+ runGit?: LocalGit;
43
+ timeoutMs?: number;
44
+ }
45
+ /** The production git runner: spawn, feed stdin, collect both streams, kill on
46
+ * the deadline. `GIT_TERMINAL_PROMPT=0` / `GCM_INTERACTIVE=never` are applied
47
+ * by the caller, so a missing credential fails instead of opening a dialog. */
48
+ export declare const runGitLocal: LocalGit;
49
+ /**
50
+ * Hosts git is configured to ask for credentials: the URL-scoped
51
+ * `[credential "https://host"]` sections of the user's gitconfig, plus any
52
+ * that the global `credential.<url>.helper` keys spell out. Read-only.
53
+ * @param deps - environment and git seams.
54
+ */
55
+ export declare function localCredentialHosts(deps?: LocalImportDeps): Promise<string[]>;
56
+ /**
57
+ * Copy every host the machine already answers for into the managed store.
58
+ *
59
+ * Candidates come from the caller (`hosts`) or from the user's gitconfig. A
60
+ * host already managed is skipped (the panel is the source of truth for it),
61
+ * a host whose helper answers nothing is reported and left untouched, and the
62
+ * store is written once at the end — a partial import is still a valid import.
63
+ *
64
+ * @param store - the managed credential store.
65
+ * @param deps - environment and git seams.
66
+ * @param hosts - explicit candidates; omitted, the gitconfig's hosts are used.
67
+ */
68
+ export declare function importLocalCredentials(store: CredentialStore, deps?: LocalImportDeps, hosts?: readonly string[]): Promise<LocalImportReport>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Import the machine's OWN credentials into the managed store (decision 0038).
3
+ *
4
+ * The store itself only knows the lines of `~/.git-credentials`. Everything
5
+ * the user already signed in through — Windows Credential Manager, macOS
6
+ * keychain, `gh auth git-credential` — lives outside that file and cannot be
7
+ * enumerated. What CAN be enumerated is the set of hosts git is configured to
8
+ * talk to (`[credential "https://host"]` sections in the user's gitconfig), so
9
+ * the import probes each of those with `git credential fill` and copies the
10
+ * secret the helper is willing to hand over. A host that answers nothing is
11
+ * reported as failed and left alone: importing never clears, only adds.
12
+ */
13
+ import { spawn } from 'node:child_process';
14
+ import { TOKEN_USERNAME, normalizeCredentialHost, upsertCredential } from './credentials.js';
15
+ /** A helper may take a while (keychain prompts, network providers); 20s is
16
+ * long enough for a cold GCM start and short enough not to hang a modal. */
17
+ const DEFAULT_TIMEOUT_MS = 20_000;
18
+ /** The production git runner: spawn, feed stdin, collect both streams, kill on
19
+ * the deadline. `GIT_TERMINAL_PROMPT=0` / `GCM_INTERACTIVE=never` are applied
20
+ * by the caller, so a missing credential fails instead of opening a dialog. */
21
+ export const runGitLocal = (args, input, env, timeoutMs) => new Promise(resolve => {
22
+ const child = spawn('git', [...args], { env, windowsHide: true });
23
+ let stdout = '';
24
+ let stderr = '';
25
+ let settled = false;
26
+ const finish = (result) => {
27
+ if (settled)
28
+ return;
29
+ settled = true;
30
+ clearTimeout(timer);
31
+ resolve(result);
32
+ };
33
+ const timer = setTimeout(() => {
34
+ child.kill();
35
+ finish({ code: -1, stdout, stderr: stderr === '' ? 'git timed out' : stderr });
36
+ }, timeoutMs);
37
+ child.stdout.setEncoding('utf8');
38
+ child.stderr.setEncoding('utf8');
39
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
40
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
41
+ child.on('error', error => finish({ code: -1, stdout, stderr: error.message }));
42
+ child.on('close', code => finish({ code: code ?? -1, stdout, stderr }));
43
+ child.stdin.on('error', () => { });
44
+ child.stdin.end(input);
45
+ });
46
+ /** The local channel: fail fast instead of prompting, and drop any half
47
+ * injected `GIT_CONFIG_COUNT` channel (decision 0036) that would make every
48
+ * git command fail to parse. */
49
+ function localEnv(env) {
50
+ const next = { ...env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
51
+ for (const key of Object.keys(next)) {
52
+ if (key === 'GIT_CONFIG_COUNT' || key.startsWith('GIT_CONFIG_KEY_') || key.startsWith('GIT_CONFIG_VALUE_'))
53
+ delete next[key];
54
+ }
55
+ return next;
56
+ }
57
+ /** First non-empty line of a helper's complaint, for the panel. */
58
+ function firstLine(text) {
59
+ const line = text.split(/\r?\n/).map(part => part.trim()).find(part => part !== '');
60
+ return line ?? '';
61
+ }
62
+ /**
63
+ * Hosts git is configured to ask for credentials: the URL-scoped
64
+ * `[credential "https://host"]` sections of the user's gitconfig, plus any
65
+ * that the global `credential.<url>.helper` keys spell out. Read-only.
66
+ * @param deps - environment and git seams.
67
+ */
68
+ export async function localCredentialHosts(deps = {}) {
69
+ const run = deps.runGit ?? runGitLocal;
70
+ const result = await run(['config', '--get-regexp', '^credential\\.https?://'], '', localEnv(deps.env ?? process.env), deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
71
+ // `git config --get-regexp` exits 1 when nothing matches: that is "no
72
+ // candidates", never an error worth surfacing.
73
+ if (result.code !== 0)
74
+ return [];
75
+ const hosts = [];
76
+ for (const line of result.stdout.split(/\r?\n/)) {
77
+ const match = /^credential\.https?:\/\/(\S+?)\.[A-Za-z0-9_-]+(?:\s|$)/i.exec(line);
78
+ if (match === null)
79
+ continue;
80
+ const host = normalizeCredentialHost(match[1] ?? '');
81
+ if (host !== undefined && !hosts.includes(host))
82
+ hosts.push(host);
83
+ }
84
+ return hosts;
85
+ }
86
+ /** Ask the machine's own helper for one host's credential. */
87
+ async function probe(host, deps) {
88
+ const run = deps.runGit ?? runGitLocal;
89
+ const result = await run(['credential', 'fill'], `protocol=https\nhost=${host}\n\n`, localEnv(deps.env ?? process.env), deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
90
+ if (result.code !== 0) {
91
+ return { error: firstLine(result.stderr) === '' ? `git exited ${String(result.code)}` : firstLine(result.stderr) };
92
+ }
93
+ const field = (name) => result.stdout.split(/\r?\n/)
94
+ .find(line => line.startsWith(`${name}=`))
95
+ ?.slice(name.length + 1) ?? '';
96
+ const secret = field('password');
97
+ if (secret === '')
98
+ return { error: 'the credential helper returned no secret' };
99
+ return { username: field('username').trim(), secret };
100
+ }
101
+ /**
102
+ * Copy every host the machine already answers for into the managed store.
103
+ *
104
+ * Candidates come from the caller (`hosts`) or from the user's gitconfig. A
105
+ * host already managed is skipped (the panel is the source of truth for it),
106
+ * a host whose helper answers nothing is reported and left untouched, and the
107
+ * store is written once at the end — a partial import is still a valid import.
108
+ *
109
+ * @param store - the managed credential store.
110
+ * @param deps - environment and git seams.
111
+ * @param hosts - explicit candidates; omitted, the gitconfig's hosts are used.
112
+ */
113
+ export async function importLocalCredentials(store, deps = {}, hosts) {
114
+ const report = { imported: [], skipped: [], failed: [] };
115
+ const entries = await store.load();
116
+ const managed = new Set(entries.map(entry => entry.host));
117
+ const candidates = [];
118
+ const seen = new Set();
119
+ for (const value of hosts ?? await localCredentialHosts(deps)) {
120
+ const host = normalizeCredentialHost(value);
121
+ if (host === undefined || seen.has(host))
122
+ continue;
123
+ seen.add(host);
124
+ if (managed.has(host)) {
125
+ report.skipped.push(host);
126
+ continue;
127
+ }
128
+ candidates.push(host);
129
+ }
130
+ let next = entries;
131
+ for (const host of candidates) {
132
+ const probed = await probe(host, deps);
133
+ if ('error' in probed) {
134
+ report.failed.push({ host, error: probed.error });
135
+ continue;
136
+ }
137
+ const token = probed.username === '' || probed.username === TOKEN_USERNAME;
138
+ const entry = {
139
+ host,
140
+ username: token ? '' : probed.username,
141
+ secret: probed.secret,
142
+ method: token ? 'token' : 'password',
143
+ updatedAt: Date.now(),
144
+ };
145
+ next = upsertCredential(next, entry);
146
+ report.imported.push({ host, username: token ? TOKEN_USERNAME : probed.username, method: token ? 'token' : 'password' });
147
+ }
148
+ if (report.imported.length > 0)
149
+ await store.save(next);
150
+ return report;
151
+ }
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * The model-facing `git_network` tool (host half).
3
3
  *
4
- * The credentials panel stores one token per remote host and makes it work on
5
- * two channels: the panel's own fetch / pull / push routes (`-c
6
- * http.<url>.extraHeader=…`, see `authConfigArgs`) and the environment bridge
7
- * the host installs for the shells it spawns (`shell-bridge.ts`). This tool is
8
- * the third, explicit one: it runs ONE git network command with the same
9
- * `authConfigArgs` the routes use, so a session reliably pushes as the account
10
- * the user configured and the secret never enters the conversation — including
11
- * on a host that predates the bridge.
4
+ * The credentials panel manages the machine's own `~/.git-credentials`
5
+ * (decision 0037), scoped per host through a generated include that
6
+ * `~/.gitconfig` pulls in. Because that is git's standard `credential-store`
7
+ * channel, this tool needs no injection of its own: it runs ONE git network
8
+ * command and git resolves the host exactly as it would in a plain shell.
12
9
  *
13
10
  * It is deliberately narrower than a shell wrapper: a fixed subcommand
14
11
  * allow-list (fetch / pull / push / ls-remote), no force/delete refspecs, no
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * The model-facing `git_network` tool (host half).
3
3
  *
4
- * The credentials panel stores one token per remote host and makes it work on
5
- * two channels: the panel's own fetch / pull / push routes (`-c
6
- * http.<url>.extraHeader=…`, see `authConfigArgs`) and the environment bridge
7
- * the host installs for the shells it spawns (`shell-bridge.ts`). This tool is
8
- * the third, explicit one: it runs ONE git network command with the same
9
- * `authConfigArgs` the routes use, so a session reliably pushes as the account
10
- * the user configured and the secret never enters the conversation — including
11
- * on a host that predates the bridge.
4
+ * The credentials panel manages the machine's own `~/.git-credentials`
5
+ * (decision 0037), scoped per host through a generated include that
6
+ * `~/.gitconfig` pulls in. Because that is git's standard `credential-store`
7
+ * channel, this tool needs no injection of its own: it runs ONE git network
8
+ * command and git resolves the host exactly as it would in a plain shell.
12
9
  *
13
10
  * It is deliberately narrower than a shell wrapper: a fixed subcommand
14
11
  * allow-list (fetch / pull / push / ls-remote), no force/delete refspecs, no
@@ -20,7 +17,7 @@
20
17
  * @module dsh-git-branch/network-tool
21
18
  */
22
19
  import { isAbsolute } from 'node:path';
23
- import { authConfigArgs, basicUsername, createCredentialStore, credentialFilePath, } from './credentials.js';
20
+ import { basicUsername, createLocalCredentialStore, } from './credentials.js';
24
21
  import { childProcessExec, NETWORK_TIMEOUT_MS } from './git.js';
25
22
  /** Model-facing tool name. */
26
23
  export const GIT_NETWORK_TOOL_NAME = 'git_network';
@@ -162,19 +159,15 @@ export function renderGitNetwork(value) {
162
159
  * @returns a definition ready for `ctx.tools.register`.
163
160
  */
164
161
  export function gitNetworkTool(deps) {
165
- const storeOf = () => deps.credentialsService?.store ?? deps.store ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
162
+ const storeOf = () => deps.credentialsService?.store ?? deps.store ?? createLocalCredentialStore(deps.home(), deps.envHome());
166
163
  const exec = deps.exec ?? childProcessExec;
167
164
  return {
168
165
  name: GIT_NETWORK_TOOL_NAME,
169
166
  description: 'Run ONE git network command (fetch, pull, push or ls-remote) inside a repository with the '
170
- + 'plugin\'s stored Git credentials injected, so the command authenticates as the account configured in the '
167
+ + 'Git credentials managed for the remote host, so the command authenticates as the account configured in the '
171
168
  + 'Git credential panel instead of whatever the machine\'s git credential helper happens to hold. Prefer it for '
172
- + 'every push/fetch/pull: the credential is injected explicitly and redacted from the output, while a plain '
173
- + 'shell `git push` only reaches these credentials through the environment bridge the plugin host installs '
174
- + 'for the shells it spawns. That bridge is absent in an older host or in a terminal started outside it; there '
175
- + 'git clears the machine helper chain too, so an unconfigured host fails fast with a "terminal prompts '
176
- + 'disabled" error instead of opening a credential dialog nobody can answer. Pass the git '
177
- + 'arguments subcommand-first, for example ["push"], ["fetch", "--all", "--prune"] or '
169
+ + 'every push/fetch/pull: output is redacted from the tool result. Pass the git arguments subcommand-first, '
170
+ + 'for example ["push"], ["fetch", "--all", "--prune"] or '
178
171
  + '["push", "origin", "main"]. Force/delete refspecs, --force/--delete/--mirror/--prune and '
179
172
  + '--upload-pack/--receive-pack style options are rejected by design. The secret itself never appears in the '
180
173
  + 'result: only exit status, redacted stdout/stderr and the hosts that were authenticated.',
@@ -226,7 +219,10 @@ export function gitNetworkTool(deps) {
226
219
  credentialsError = error instanceof Error ? error.message : String(error);
227
220
  }
228
221
  const hosts = [...new Set(entries.map(entry => entry.host))].sort();
229
- const result = await exec('git', [...authConfigArgs(entries), ...request.args], {
222
+ // No credential argv: git authenticates through the URL-scoped
223
+ // `helper = store` include that 0037 installs into `~/.gitconfig`, so
224
+ // the same token serves this command and a plain shell `git push`.
225
+ const result = await exec('git', request.args, {
230
226
  cwd: request.repoPath,
231
227
  timeoutMs: NETWORK_TIMEOUT_MS,
232
228
  });
package/lib/routes.d.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  import { type CopyFilesSeams } from './copy-files.js';
6
6
  import { type CredentialStore } from './credentials.js';
7
7
  import type { CredentialService } from './credential-service.js';
8
+ import { type LocalImportReport } from './local-import.js';
8
9
  import { type DirExists, type ExcludeSeams, type Exec } from './git.js';
9
- import type { AiCommitMessageResult, ChangesResult, CommitFilesResult, CommitResult, CreateBranchResult, CreateTagResult, CreateWorktreeResult, CredentialOAuthPollResult, CredentialOAuthStartResult, CredentialsResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, LogResult, PathExistsResult, PurgeDirectoryResult, PushTagResult, RemoveWorktreeResult, RemotesResult, RepoStatus, RouteError, StageResult, SwitchResult, UpdateResult, WorktreesAllResult } from './wire.js';
10
+ import type { AiCommitMessageResult, ChangesResult, CommitFilesResult, CommitResult, CreateBranchResult, CreateTagResult, CreateWorktreeResult, CredentialOAuthPollResult, CredentialOAuthStartResult, CredentialImportResult, CredentialsResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, LogResult, PathExistsResult, PurgeDirectoryResult, PushTagResult, RemoveWorktreeResult, RemotesResult, RepoStatus, RouteError, StageResult, SwitchResult, UpdateResult, WorktreesAllResult } from './wire.js';
10
11
  /** Everything the handlers need from the host half. */
11
12
  export interface RouteDeps {
12
13
  exec: Exec;
@@ -51,6 +52,10 @@ export interface RouteDeps {
51
52
  /** Re-derive the shell credential bridge after the store changed (absent =
52
53
  * a host that does not provide the bridge; tests omit it). */
53
54
  refreshShellBridge?: () => Promise<void>;
55
+ /** Machine-credential import seam (decision 0038): probes the helpers the
56
+ * user's own gitconfig points git at, for the hosts it names. Tests
57
+ * substitute; absent = the real `git credential fill` probe. */
58
+ importLocalCredentials?: (store: CredentialStore, hosts?: readonly string[]) => Promise<LocalImportReport>;
54
59
  }
55
60
  /** Real fs-backed one-level directory listing (names only). Plain FILES are
56
61
  * filtered out — the storage root can carry strays (a legacy settings.json
@@ -63,7 +68,7 @@ export declare function fsMkdirRecursive(path: string): Promise<void>;
63
68
  /** One route outcome: HTTP status plus the JSON body. */
64
69
  export interface RouteOutcome {
65
70
  status: number;
66
- body: RepoStatus | CreateWorktreeResult | SwitchResult | CreateBranchResult | FetchResult | UpdateResult | GroupWorkspacesResult | InspectWorktreeResult | RemoveWorktreeResult | PathExistsResult | EnsureDirectoryResult | WorktreesAllResult | PurgeDirectoryResult | ChangesResult | StageResult | CommitResult | LogResult | CommitFilesResult | CreateTagResult | PushTagResult | AiCommitMessageResult | CredentialsResult | CredentialOAuthStartResult | CredentialOAuthPollResult | RemotesResult | RouteError;
71
+ body: RepoStatus | CreateWorktreeResult | SwitchResult | CreateBranchResult | FetchResult | UpdateResult | GroupWorkspacesResult | InspectWorktreeResult | RemoveWorktreeResult | PathExistsResult | EnsureDirectoryResult | WorktreesAllResult | PurgeDirectoryResult | ChangesResult | StageResult | CommitResult | LogResult | CommitFilesResult | CreateTagResult | PushTagResult | AiCommitMessageResult | CredentialsResult | CredentialOAuthStartResult | CredentialOAuthPollResult | CredentialImportResult | RemotesResult | RouteError;
67
72
  }
68
73
  /**
69
74
  * POST /group — git belonging facts for a batch of workspace directories.
@@ -387,6 +392,18 @@ export declare function handleCredentialsSave(deps: RouteDeps, body: unknown): P
387
392
  * @param body - parsed request body: `{ host }`.
388
393
  */
389
394
  export declare function handleCredentialsDelete(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
395
+ /**
396
+ * POST /credentials-import — copy what the machine's own credential helpers
397
+ * already answer for into the managed store, so a machine signed in through
398
+ * gh/GCM/Keychain stops needing a second sign-in here (decision 0038).
399
+ *
400
+ * The candidates are the URL-scoped hosts of the user's gitconfig; a host the
401
+ * panel already manages is skipped (the panel wins), and a host whose helper
402
+ * answers nothing is reported per host instead of failing the whole request.
403
+ * @param deps - host dependencies.
404
+ * @param body - parsed request body: absent, `{}` or `{ hosts?: string[] }`.
405
+ */
406
+ export declare function handleCredentialsImport(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
390
407
  /**
391
408
  * POST /credentials-oauth-start — start a browser sign-in for ONE host
392
409
  * (GitHub device flow). The answer carries a one-time user code and the
package/lib/routes.js CHANGED
@@ -5,7 +5,8 @@
5
5
  import { basename, dirname, join, resolve } from 'node:path';
6
6
  import { mkdir, readdir, rm, stat } from 'node:fs/promises';
7
7
  import { runPostCreateCopy } from './copy-files.js';
8
- import { authConfigArgs, credentialFilePath, createCredentialStore, credentialHostIssue, credentialSecretIssue, credentialUsernameIssue, maskSecret, normalizeCredentialHost, removeCredential, sortCredentials, upsertCredential } from './credentials.js';
8
+ import { credentialHostIssue, credentialSecretIssue, credentialUsernameIssue, createLocalCredentialStore, maskSecret, normalizeCredentialHost, removeCredential, sortCredentials, upsertCredential } from './credentials.js';
9
+ import { importLocalCredentials as runLocalImport } from './local-import.js';
9
10
  import { appendWorktreeExclude, fsExcludeSeams, GitError, addRemote, addWorktree, addWorktreeCutout, collectAiDiff, commitFiles, commitIndex, createBranch, createTag, cutoutBranchName, deleteBranch, fetchAll, fsDirExists, inspectWorktree, isAbsoluteDir, isStageablePath, listRemotes, listTagNames, logHistory, probeRepo, probeWorkspaceGit, pushTag, removeRemote, removeWorktree, renameBranch, resolveBranch, resolvePushRemote, pushBranch, setRemoteUrl, stagePaths, switchBranch, updateBranch, workingChanges } from './git.js';
10
11
  import { ACCEL_DEFAULT_URL, accelerateUrlError, isAbsoluteConfigPath, normalizeAccelerateUrl, remoteNameIssue, remoteUrlIssue, sanitizeBranchDir } from './normalize.js';
11
12
  import { deviceFlowClientId, isDeviceFlowHost, pollDeviceFlow, startDeviceFlow } from './oauth.js';
@@ -53,28 +54,20 @@ function accelPrefix(deps) {
53
54
  const url = deps.sectionCnAccelerateUrl?.() ?? ACCEL_DEFAULT_URL;
54
55
  return accelerateUrlError(url) === undefined ? normalizeAccelerateUrl(url) : undefined;
55
56
  }
56
- /** The global credential store: the service's shared instance (decision 0034),
57
- * else the wired store, else the fs-backed file at the resolved dsh home
58
- * (`<dshHome>/git-credentials.json`). */
57
+ /** The global credential store: the service's shared instance (decision 0037),
58
+ * else the wired store, else the local `~/.git-credentials`-backed store. */
59
59
  function credentialStore(deps) {
60
- return deps.credentialsService?.store ?? deps.credentialsStore ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
60
+ return deps.credentialsService?.store ?? deps.credentialsStore ?? createLocalCredentialStore(deps.home(), deps.envHome());
61
61
  }
62
62
  /**
63
- * `-c http.<url>.extraHeader` pairs for every stored credential, ready to
64
- * prepend to a git network command. A broken credential FILE must never break
65
- * git itself: an unreadable store degrades to "no credentials" (the network
66
- * command then runs unauthenticated, as it always did) — the credentials
67
- * route is where the file problem surfaces.
63
+ * Credential argv for one network command. Empty by design since 0037: the
64
+ * token reaches git through the URL-scoped `helper = store` include that
65
+ * `~/.gitconfig` carries, so there is nothing to inject. The seam stays so
66
+ * every call site keeps one place to change if a command ever needs an
67
+ * explicit override again.
68
68
  */
69
- async function authArgsFor(deps) {
70
- if (deps.credentialsService !== undefined)
71
- return deps.credentialsService.authConfigArgs();
72
- try {
73
- return authConfigArgs(await credentialStore(deps).load());
74
- }
75
- catch {
76
- return [];
77
- }
69
+ async function authArgsFor(_deps) {
70
+ return [];
78
71
  }
79
72
  /**
80
73
  * Re-derive the shell credential bridge after a credential changed, so a
@@ -1419,6 +1412,56 @@ export async function handleCredentialsDelete(deps, body) {
1419
1412
  return storeFailure(error);
1420
1413
  }
1421
1414
  }
1415
+ /**
1416
+ * POST /credentials-import — copy what the machine's own credential helpers
1417
+ * already answer for into the managed store, so a machine signed in through
1418
+ * gh/GCM/Keychain stops needing a second sign-in here (decision 0038).
1419
+ *
1420
+ * The candidates are the URL-scoped hosts of the user's gitconfig; a host the
1421
+ * panel already manages is skipped (the panel wins), and a host whose helper
1422
+ * answers nothing is reported per host instead of failing the whole request.
1423
+ * @param deps - host dependencies.
1424
+ * @param body - parsed request body: absent, `{}` or `{ hosts?: string[] }`.
1425
+ */
1426
+ export async function handleCredentialsImport(deps, body) {
1427
+ const record = body ?? {};
1428
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) {
1429
+ return fail(400, 'request body must be a JSON object');
1430
+ }
1431
+ const fields = record;
1432
+ for (const key of Object.keys(fields)) {
1433
+ if (key !== 'hosts')
1434
+ return fail(400, `unknown body key "${key}"`);
1435
+ }
1436
+ const rawHosts = fields.hosts;
1437
+ let hosts;
1438
+ if (rawHosts !== undefined) {
1439
+ if (!Array.isArray(rawHosts) || rawHosts.some(value => typeof value !== 'string')) {
1440
+ return fail(400, '"hosts" must be an array of remote hosts');
1441
+ }
1442
+ hosts = rawHosts.map(value => value.trim()).filter(value => value !== '');
1443
+ if (hosts.length === 0)
1444
+ return fail(400, '"hosts" must not be empty');
1445
+ }
1446
+ try {
1447
+ const store = credentialStore(deps);
1448
+ const importNow = deps.importLocalCredentials ?? ((target, wanted) => runLocalImport(target, {}, wanted));
1449
+ const report = await importNow(store, hosts);
1450
+ if (report.imported.length > 0)
1451
+ await refreshBridge(deps);
1452
+ const entries = await store.load();
1453
+ const result = {
1454
+ credentials: sortCredentials(entries).map(credentialView),
1455
+ imported: report.imported,
1456
+ skipped: report.skipped,
1457
+ failed: report.failed,
1458
+ };
1459
+ return { status: 200, body: result };
1460
+ }
1461
+ catch (error) {
1462
+ return storeFailure(error);
1463
+ }
1464
+ }
1422
1465
  /** The HTTP seam: the wired fake in tests, global fetch in production. */
1423
1466
  function fetchFor(deps) {
1424
1467
  return deps.fetchImpl ?? globalThis.fetch;