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
|
@@ -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
|
+
}
|
package/lib/network-tool.d.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The model-facing `git_network` tool (host half).
|
|
3
3
|
*
|
|
4
|
-
* The credentials panel
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,6 +17,7 @@
|
|
|
20
17
|
* @module dsh-git-branch/network-tool
|
|
21
18
|
*/
|
|
22
19
|
import { type CredentialStore, type GitCredential } from './credentials.js';
|
|
20
|
+
import type { CredentialService } from './credential-service.js';
|
|
23
21
|
import { type Exec } from './git.js';
|
|
24
22
|
/** Model-facing tool name. */
|
|
25
23
|
export declare const GIT_NETWORK_TOOL_NAME = "git_network";
|
|
@@ -66,6 +64,9 @@ export interface GitNetworkToolDeps {
|
|
|
66
64
|
readonly envHome: () => string | undefined;
|
|
67
65
|
/** Store seam; the fs-backed store at the resolved path when absent. */
|
|
68
66
|
readonly store?: CredentialStore;
|
|
67
|
+
/** The single credential entry point (decision 0034); when wired its store
|
|
68
|
+
* is shared with the routes and the shell bridge. */
|
|
69
|
+
readonly credentialsService?: CredentialService;
|
|
69
70
|
/** Process seam; the real `git` over node:child_process when absent. */
|
|
70
71
|
readonly exec?: Exec;
|
|
71
72
|
}
|
package/lib/network-tool.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The model-facing `git_network` tool (host half).
|
|
3
3
|
*
|
|
4
|
-
* The credentials panel
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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.store ??
|
|
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
|
-
+ '
|
|
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:
|
|
173
|
-
+ '
|
|
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
|
-
|
|
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
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { type CopyFilesSeams } from './copy-files.js';
|
|
6
6
|
import { type CredentialStore } from './credentials.js';
|
|
7
|
+
import type { CredentialService } from './credential-service.js';
|
|
8
|
+
import { type LocalImportReport } from './local-import.js';
|
|
7
9
|
import { type DirExists, type ExcludeSeams, type Exec } from './git.js';
|
|
8
|
-
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';
|
|
9
11
|
/** Everything the handlers need from the host half. */
|
|
10
12
|
export interface RouteDeps {
|
|
11
13
|
exec: Exec;
|
|
@@ -44,9 +46,16 @@ export interface RouteDeps {
|
|
|
44
46
|
/** Global per-host credential store. Absent = the fs-backed store at the
|
|
45
47
|
* resolved dsh home; tests substitute an in-memory one. */
|
|
46
48
|
credentialsStore?: CredentialStore;
|
|
49
|
+
/** The single credential entry point (decision 0034): when wired, it owns
|
|
50
|
+
* the store and both injection channels, and the seams below are ignored. */
|
|
51
|
+
credentialsService?: CredentialService;
|
|
47
52
|
/** Re-derive the shell credential bridge after the store changed (absent =
|
|
48
53
|
* a host that does not provide the bridge; tests omit it). */
|
|
49
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>;
|
|
50
59
|
}
|
|
51
60
|
/** Real fs-backed one-level directory listing (names only). Plain FILES are
|
|
52
61
|
* filtered out — the storage root can carry strays (a legacy settings.json
|
|
@@ -59,7 +68,7 @@ export declare function fsMkdirRecursive(path: string): Promise<void>;
|
|
|
59
68
|
/** One route outcome: HTTP status plus the JSON body. */
|
|
60
69
|
export interface RouteOutcome {
|
|
61
70
|
status: number;
|
|
62
|
-
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;
|
|
63
72
|
}
|
|
64
73
|
/**
|
|
65
74
|
* POST /group — git belonging facts for a batch of workspace directories.
|
|
@@ -383,6 +392,18 @@ export declare function handleCredentialsSave(deps: RouteDeps, body: unknown): P
|
|
|
383
392
|
* @param body - parsed request body: `{ host }`.
|
|
384
393
|
*/
|
|
385
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>;
|
|
386
407
|
/**
|
|
387
408
|
* POST /credentials-oauth-start — start a browser sign-in for ONE host
|
|
388
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 {
|
|
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,25 +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
|
|
57
|
-
*
|
|
57
|
+
/** The global credential store: the service's shared instance (decision 0037),
|
|
58
|
+
* else the wired store, else the local `~/.git-credentials`-backed store. */
|
|
58
59
|
function credentialStore(deps) {
|
|
59
|
-
return deps.credentialsStore ??
|
|
60
|
+
return deps.credentialsService?.store ?? deps.credentialsStore ?? createLocalCredentialStore(deps.home(), deps.envHome());
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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.
|
|
67
68
|
*/
|
|
68
|
-
async function authArgsFor(
|
|
69
|
-
|
|
70
|
-
return authConfigArgs(await credentialStore(deps).load());
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
69
|
+
async function authArgsFor(_deps) {
|
|
70
|
+
return [];
|
|
75
71
|
}
|
|
76
72
|
/**
|
|
77
73
|
* Re-derive the shell credential bridge after a credential changed, so a
|
|
@@ -81,10 +77,11 @@ async function authArgsFor(deps) {
|
|
|
81
77
|
* save (an added host is not reachable, a deleted one may still be).
|
|
82
78
|
*/
|
|
83
79
|
async function refreshBridge(deps) {
|
|
84
|
-
|
|
80
|
+
const refresh = deps.credentialsService?.refreshShellEnv ?? deps.refreshShellBridge;
|
|
81
|
+
if (refresh === undefined)
|
|
85
82
|
return;
|
|
86
83
|
try {
|
|
87
|
-
await
|
|
84
|
+
await refresh();
|
|
88
85
|
}
|
|
89
86
|
catch {
|
|
90
87
|
// Deliberately swallowed; see the contract above.
|
|
@@ -1415,6 +1412,56 @@ export async function handleCredentialsDelete(deps, body) {
|
|
|
1415
1412
|
return storeFailure(error);
|
|
1416
1413
|
}
|
|
1417
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
|
+
}
|
|
1418
1465
|
/** The HTTP seam: the wired fake in tests, global fetch in production. */
|
|
1419
1466
|
function fetchFor(deps) {
|
|
1420
1467
|
return deps.fetchImpl ?? globalThis.fetch;
|