dsh-git-branch-manage 1.1.0 → 1.3.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 +34 -0
- package/lib/client.js +271 -29
- package/lib/credential-service.d.ts +32 -38
- package/lib/credential-service.js +56 -27
- package/lib/credentials.d.ts +145 -40
- package/lib/credentials.js +398 -70
- package/lib/git.d.ts +82 -9
- package/lib/git.js +260 -24
- package/lib/index.d.ts +11 -0
- package/lib/index.js +56 -19
- package/lib/local-import.d.ts +68 -0
- package/lib/local-import.js +151 -0
- package/lib/network-ip.d.ts +99 -0
- package/lib/network-ip.js +206 -0
- package/lib/network-probe.d.ts +28 -0
- package/lib/network-probe.js +47 -0
- package/lib/network-proxy.d.ts +35 -0
- package/lib/network-proxy.js +55 -0
- package/lib/network-tool.d.ts +5 -8
- package/lib/network-tool.js +17 -20
- package/lib/network.d.ts +58 -0
- package/lib/network.js +128 -0
- package/lib/normalize.d.ts +14 -2
- package/lib/normalize.js +38 -2
- package/lib/routes.d.ts +41 -3
- package/lib/routes.js +159 -27
- package/lib/shell-bridge.d.ts +57 -115
- package/lib/shell-bridge.js +96 -170
- package/lib/tool.js +2 -2
- package/lib/wire.d.ts +64 -0
- package/lib/wire.js +5 -0
- package/package.json +1 -1
- package/lib/helpers/git-credential-bridge.d.ts +0 -37
- package/lib/helpers/git-credential-bridge.js +0 -119
package/lib/normalize.d.ts
CHANGED
|
@@ -75,8 +75,8 @@ export type AccelerateUrlIssue = 'empty' | 'invalid';
|
|
|
75
75
|
export declare function accelerateUrlError(value: string): AccelerateUrlIssue | undefined;
|
|
76
76
|
/**
|
|
77
77
|
* Normalize an accepted accelerate URL: trim, and append the trailing `/`
|
|
78
|
-
* the prefix concatenation requires (`https://gh-proxy.com/` +
|
|
79
|
-
*
|
|
78
|
+
* the prefix concatenation requires (`https://gh-proxy.com/` + the complete
|
|
79
|
+
* `https://github.com/o/r.git` URL). accelArgs builds the Git rewrite base.
|
|
80
80
|
* @param value - an accepted (non-empty, http(s), whitespace-free) URL.
|
|
81
81
|
* @returns the prefix ending in `/`.
|
|
82
82
|
*/
|
|
@@ -105,3 +105,15 @@ export type RemoteUrlIssue = 'empty' | 'whitespace' | 'invalid';
|
|
|
105
105
|
* @returns the issue kind, or undefined when acceptable.
|
|
106
106
|
*/
|
|
107
107
|
export declare function remoteUrlIssue(url: string): RemoteUrlIssue | undefined;
|
|
108
|
+
/** Schemes git's libcurl accepts for an HTTP(S) remote. */
|
|
109
|
+
export declare const PROXY_SCHEMES: readonly string[];
|
|
110
|
+
/**
|
|
111
|
+
* Why this proxy value cannot be used, or undefined when it can.
|
|
112
|
+
*
|
|
113
|
+
* Credentials are rejected on purpose: the value is stored in the plugin
|
|
114
|
+
* config and passed as one `-c` argument, so `user:pass@` would be readable
|
|
115
|
+
* in the settings UI and in any diagnostics (0043).
|
|
116
|
+
* @param value - candidate proxy URL.
|
|
117
|
+
* @returns the user-facing reason, or undefined.
|
|
118
|
+
*/
|
|
119
|
+
export declare function proxyUrlError(value: string): string | undefined;
|
package/lib/normalize.js
CHANGED
|
@@ -117,8 +117,8 @@ export function accelerateUrlError(value) {
|
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
119
119
|
* Normalize an accepted accelerate URL: trim, and append the trailing `/`
|
|
120
|
-
* the prefix concatenation requires (`https://gh-proxy.com/` +
|
|
121
|
-
*
|
|
120
|
+
* the prefix concatenation requires (`https://gh-proxy.com/` + the complete
|
|
121
|
+
* `https://github.com/o/r.git` URL). accelArgs builds the Git rewrite base.
|
|
122
122
|
* @param value - an accepted (non-empty, http(s), whitespace-free) URL.
|
|
123
123
|
* @returns the prefix ending in `/`.
|
|
124
124
|
*/
|
|
@@ -173,3 +173,39 @@ export function remoteUrlIssue(url) {
|
|
|
173
173
|
return undefined;
|
|
174
174
|
return 'invalid';
|
|
175
175
|
}
|
|
176
|
+
/** Schemes git's libcurl accepts for an HTTP(S) remote. */
|
|
177
|
+
export const PROXY_SCHEMES = ['http', 'https', 'socks5', 'socks5h'];
|
|
178
|
+
/**
|
|
179
|
+
* Why this proxy value cannot be used, or undefined when it can.
|
|
180
|
+
*
|
|
181
|
+
* Credentials are rejected on purpose: the value is stored in the plugin
|
|
182
|
+
* config and passed as one `-c` argument, so `user:pass@` would be readable
|
|
183
|
+
* in the settings UI and in any diagnostics (0043).
|
|
184
|
+
* @param value - candidate proxy URL.
|
|
185
|
+
* @returns the user-facing reason, or undefined.
|
|
186
|
+
*/
|
|
187
|
+
export function proxyUrlError(value) {
|
|
188
|
+
let url;
|
|
189
|
+
try {
|
|
190
|
+
url = new URL(value);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return 'must be an absolute proxy URL such as http://127.0.0.1:7890';
|
|
194
|
+
}
|
|
195
|
+
if (!PROXY_SCHEMES.includes(url.protocol.replace(':', ''))) {
|
|
196
|
+
return `scheme must be one of ${PROXY_SCHEMES.join(', ')}`;
|
|
197
|
+
}
|
|
198
|
+
if (url.hostname === '')
|
|
199
|
+
return 'must name a proxy host';
|
|
200
|
+
if (url.port === '')
|
|
201
|
+
return 'must name a proxy port';
|
|
202
|
+
const port = Number(url.port);
|
|
203
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
204
|
+
return 'proxy port must be 1-65535';
|
|
205
|
+
if (url.username !== '' || url.password !== '')
|
|
206
|
+
return 'credentials in the proxy URL are not supported';
|
|
207
|
+
if ((url.pathname !== '' && url.pathname !== '/') || url.search !== '' || url.hash !== '') {
|
|
208
|
+
return 'must not carry a path, query, or fragment';
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
package/lib/routes.d.ts
CHANGED
|
@@ -5,13 +5,22 @@
|
|
|
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
|
|
9
|
-
import
|
|
8
|
+
import { type LocalImportReport } from './local-import.js';
|
|
9
|
+
import { type ChannelProvider, type DirExists, type ExcludeSeams, type Exec } from './git.js';
|
|
10
|
+
import type { NetworkProbeResult, 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;
|
|
14
|
+
/** Fallback channels for a network command, in priority order: the
|
|
15
|
+
* explicit proxy first, then the IP-optimized channel (absent = direct only; tests leave
|
|
16
|
+
* it out so no real lookup, race, or proxy argv ever runs). */
|
|
17
|
+
networkChannels?: (exec: Exec, cwd: string, authArgs: readonly string[]) => readonly ChannelProvider[];
|
|
13
18
|
/** The settings-resolved rootDir (absent/blank = the default location). */
|
|
14
19
|
sectionRootDir: () => string | undefined;
|
|
20
|
+
/** Explicit proxy URL for the proxy channel; absent/blank = none. */
|
|
21
|
+
sectionNetworkProxy?: () => string | undefined;
|
|
22
|
+
/** The proxy Windows itself has enabled (read-only WinINET probe); absent = not looked up. */
|
|
23
|
+
sectionSystemProxy?: () => Promise<string | undefined>;
|
|
15
24
|
/** Whether a worktree creation syncs the remotes first (absent = off). */
|
|
16
25
|
sectionFetchBeforeCreate: () => boolean | undefined;
|
|
17
26
|
/** Post-create configuration files to copy when .worktreeinclude is absent. */
|
|
@@ -51,6 +60,10 @@ export interface RouteDeps {
|
|
|
51
60
|
/** Re-derive the shell credential bridge after the store changed (absent =
|
|
52
61
|
* a host that does not provide the bridge; tests omit it). */
|
|
53
62
|
refreshShellBridge?: () => Promise<void>;
|
|
63
|
+
/** Machine-credential import seam (decision 0038): probes the helpers the
|
|
64
|
+
* user's own gitconfig points git at, for the hosts it names. Tests
|
|
65
|
+
* substitute; absent = the real `git credential fill` probe. */
|
|
66
|
+
importLocalCredentials?: (store: CredentialStore, hosts?: readonly string[]) => Promise<LocalImportReport>;
|
|
54
67
|
}
|
|
55
68
|
/** Real fs-backed one-level directory listing (names only). Plain FILES are
|
|
56
69
|
* filtered out — the storage root can carry strays (a legacy settings.json
|
|
@@ -63,7 +76,7 @@ export declare function fsMkdirRecursive(path: string): Promise<void>;
|
|
|
63
76
|
/** One route outcome: HTTP status plus the JSON body. */
|
|
64
77
|
export interface RouteOutcome {
|
|
65
78
|
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;
|
|
79
|
+
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 | NetworkProbeResult | RouteError;
|
|
67
80
|
}
|
|
68
81
|
/**
|
|
69
82
|
* POST /group — git belonging facts for a batch of workspace directories.
|
|
@@ -387,6 +400,18 @@ export declare function handleCredentialsSave(deps: RouteDeps, body: unknown): P
|
|
|
387
400
|
* @param body - parsed request body: `{ host }`.
|
|
388
401
|
*/
|
|
389
402
|
export declare function handleCredentialsDelete(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
|
|
403
|
+
/**
|
|
404
|
+
* POST /credentials-import — copy what the machine's own credential helpers
|
|
405
|
+
* already answer for into the managed store, so a machine signed in through
|
|
406
|
+
* gh/GCM/Keychain stops needing a second sign-in here (decision 0038).
|
|
407
|
+
*
|
|
408
|
+
* The candidates are the URL-scoped hosts of the user's gitconfig; a host the
|
|
409
|
+
* panel already manages is skipped (the panel wins), and a host whose helper
|
|
410
|
+
* answers nothing is reported per host instead of failing the whole request.
|
|
411
|
+
* @param deps - host dependencies.
|
|
412
|
+
* @param body - parsed request body: absent, `{}` or `{ hosts?: string[] }`.
|
|
413
|
+
*/
|
|
414
|
+
export declare function handleCredentialsImport(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
|
|
390
415
|
/**
|
|
391
416
|
* POST /credentials-oauth-start — start a browser sign-in for ONE host
|
|
392
417
|
* (GitHub device flow). The answer carries a one-time user code and the
|
|
@@ -423,3 +448,16 @@ export declare function handleRemotes(deps: RouteDeps, body: unknown): Promise<R
|
|
|
423
448
|
* @param body - parsed request body: `{ repoPath, action, name, url? }`.
|
|
424
449
|
*/
|
|
425
450
|
export declare function handleRemote(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
|
|
451
|
+
/**
|
|
452
|
+
* Report which network channel can read the repository's endpoint right now.
|
|
453
|
+
*
|
|
454
|
+
* Diagnostics only: nothing here writes to git or to the config. Each
|
|
455
|
+
* channel gets its own budget (PROBE_TIMEOUT_MS), so a dead proxy costs one
|
|
456
|
+
* timeout and the answer still names the channels that work. The system
|
|
457
|
+
* proxy is reported when the plugin has none of its own — git ignores
|
|
458
|
+
* WinINET, so a value here is a suggestion the user must opt into (0043).
|
|
459
|
+
* @param deps - route dependencies (exec, channel providers, config readers).
|
|
460
|
+
* @param body - `{ repoPath }`.
|
|
461
|
+
* @returns one report per channel, in the order the write path would try them.
|
|
462
|
+
*/
|
|
463
|
+
export declare function handleNetworkProbe(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
|
package/lib/routes.js
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
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 {
|
|
9
|
-
import {
|
|
8
|
+
import { credentialHostIssue, credentialSecretIssue, credentialUsernameIssue, createLocalCredentialStore, maskSecret, normalizeCredentialHost, removeCredential, sortCredentials, upsertCredential } from './credentials.js';
|
|
9
|
+
import { importLocalCredentials as runLocalImport } from './local-import.js';
|
|
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, resolvePushTarget, pushBranch, setRemoteUrl, stagePaths, switchBranch, updateBranch, workingChanges } from './git.js';
|
|
11
|
+
import { classifyGitFailure, withHint } from './network.js';
|
|
12
|
+
import { channelReport } from './network-probe.js';
|
|
10
13
|
import { ACCEL_DEFAULT_URL, accelerateUrlError, isAbsoluteConfigPath, normalizeAccelerateUrl, remoteNameIssue, remoteUrlIssue, sanitizeBranchDir } from './normalize.js';
|
|
11
14
|
import { deviceFlowClientId, isDeviceFlowHost, pollDeviceFlow, startDeviceFlow } from './oauth.js';
|
|
12
15
|
import { resolveRootDir } from './settings.js';
|
|
@@ -53,28 +56,25 @@ function accelPrefix(deps) {
|
|
|
53
56
|
const url = deps.sectionCnAccelerateUrl?.() ?? ACCEL_DEFAULT_URL;
|
|
54
57
|
return accelerateUrlError(url) === undefined ? normalizeAccelerateUrl(url) : undefined;
|
|
55
58
|
}
|
|
56
|
-
/** The
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
/** The fallback chain for one command: the host's providers, or none at all. */
|
|
60
|
+
function networkOptions(deps, cwd, authArgs) {
|
|
61
|
+
const channels = deps.networkChannels?.(deps.exec, cwd, authArgs) ?? [];
|
|
62
|
+
return channels.length === 0 ? {} : { fallbackChannels: channels };
|
|
63
|
+
}
|
|
64
|
+
/** The global credential store: the service's shared instance (decision 0037),
|
|
65
|
+
* else the wired store, else the local `~/.git-credentials`-backed store. */
|
|
59
66
|
function credentialStore(deps) {
|
|
60
|
-
return deps.credentialsService?.store ?? deps.credentialsStore ??
|
|
67
|
+
return deps.credentialsService?.store ?? deps.credentialsStore ?? createLocalCredentialStore(deps.home(), deps.envHome());
|
|
61
68
|
}
|
|
62
69
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
70
|
+
* Credential argv for one network command. Empty by design since 0037: the
|
|
71
|
+
* token reaches git through the URL-scoped `helper = store` include that
|
|
72
|
+
* `~/.gitconfig` carries, so there is nothing to inject. The seam stays so
|
|
73
|
+
* every call site keeps one place to change if a command ever needs an
|
|
74
|
+
* explicit override again.
|
|
68
75
|
*/
|
|
69
|
-
async function authArgsFor(
|
|
70
|
-
|
|
71
|
-
return deps.credentialsService.authConfigArgs();
|
|
72
|
-
try {
|
|
73
|
-
return authConfigArgs(await credentialStore(deps).load());
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
return [];
|
|
77
|
-
}
|
|
76
|
+
async function authArgsFor(_deps) {
|
|
77
|
+
return [];
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
80
|
* Re-derive the shell credential bridge after a credential changed, so a
|
|
@@ -320,6 +320,10 @@ export async function handlePurgeDirectory(deps, body) {
|
|
|
320
320
|
}
|
|
321
321
|
/** GitError to outcome: usage-shaped failures are 400, the rest 500. */
|
|
322
322
|
function gitFailure(error) {
|
|
323
|
+
// Every git failure the routes surface passes through one classifier so
|
|
324
|
+
// the toast explains WHAT broke (host, credentials, divergence) instead of
|
|
325
|
+
// repeating git's raw text alone.
|
|
326
|
+
const failure = classifyGitFailure(error.stderr, { timedOut: error.timedOut });
|
|
323
327
|
const usage = error.stderr.includes('usage:')
|
|
324
328
|
|| error.stderr.includes('fatal: invalid')
|
|
325
329
|
// A user-typed branch name git refuses (`'bad..name' is not a valid
|
|
@@ -354,7 +358,7 @@ function gitFailure(error) {
|
|
|
354
358
|
// (a stale sidebar row racing a terminal-side removal).
|
|
355
359
|
|| error.stderr.includes('cannot remove the main worktree')
|
|
356
360
|
|| error.stderr.includes('is not a registered worktree');
|
|
357
|
-
return fail(usage ? 400 : 500, error.message);
|
|
361
|
+
return fail(usage ? 400 : 500, withHint(error.message, failure));
|
|
358
362
|
}
|
|
359
363
|
/**
|
|
360
364
|
* GET /status 鈥?repository facts for one directory.
|
|
@@ -469,7 +473,10 @@ export async function handleCreateWorktree(deps, body) {
|
|
|
469
473
|
const resolved = await resolveBranch(deps.exec, facts.repoRoot, branch);
|
|
470
474
|
if (resolved?.remote !== undefined) {
|
|
471
475
|
try {
|
|
472
|
-
|
|
476
|
+
{
|
|
477
|
+
const authArgs = await authArgsFor(deps);
|
|
478
|
+
await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps), authArgs, networkOptions(deps, facts.repoRoot, authArgs));
|
|
479
|
+
}
|
|
473
480
|
}
|
|
474
481
|
catch (error) {
|
|
475
482
|
fetchWarning = error instanceof GitError ? error.stderr.trim() : error instanceof Error ? error.message : String(error);
|
|
@@ -704,7 +711,10 @@ export async function handleFetch(deps, body) {
|
|
|
704
711
|
const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
|
|
705
712
|
if (facts === undefined)
|
|
706
713
|
return fail(400, `"${repoPath}" is not inside a git repository`);
|
|
707
|
-
|
|
714
|
+
{
|
|
715
|
+
const authArgs = await authArgsFor(deps);
|
|
716
|
+
await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps), authArgs, networkOptions(deps, facts.repoRoot, authArgs));
|
|
717
|
+
}
|
|
708
718
|
const result = { remote: 'all' };
|
|
709
719
|
return { status: 200, body: result };
|
|
710
720
|
}
|
|
@@ -767,8 +777,9 @@ export async function handlePush(deps, body) {
|
|
|
767
777
|
return fail(400, `"${repoPath}" is not inside a git repository`);
|
|
768
778
|
// The session's OWN worktree, not the main checkout: in a linked-worktree
|
|
769
779
|
// session "push current branch" means the branch that worktree holds.
|
|
770
|
-
const
|
|
771
|
-
const
|
|
780
|
+
const authArgs = await authArgsFor(deps);
|
|
781
|
+
const outcome = await pushBranch(deps.exec, facts.toplevel, authArgs, networkOptions(deps, facts.toplevel, authArgs));
|
|
782
|
+
const result = { branch: outcome.branch, pushed: outcome.pushed, ...(outcome.note === undefined ? {} : { note: outcome.note }) };
|
|
772
783
|
return { status: 200, body: result };
|
|
773
784
|
}
|
|
774
785
|
catch (error) {
|
|
@@ -1199,8 +1210,9 @@ export async function handlePushTag(deps, body) {
|
|
|
1199
1210
|
const choice = await resolvePushRemote(deps.exec, facts.toplevel);
|
|
1200
1211
|
if (!choice.ok)
|
|
1201
1212
|
return fail(400, choice.message);
|
|
1202
|
-
const
|
|
1203
|
-
const
|
|
1213
|
+
const authArgs = await authArgsFor(deps);
|
|
1214
|
+
const result = await pushTag(deps.exec, facts.toplevel, name, choice.remote, authArgs, networkOptions(deps, facts.toplevel, authArgs));
|
|
1215
|
+
const body = { ...result, ...(result.note === undefined ? {} : { note: result.note }) };
|
|
1204
1216
|
return { status: 200, body };
|
|
1205
1217
|
}
|
|
1206
1218
|
catch (error) {
|
|
@@ -1419,6 +1431,56 @@ export async function handleCredentialsDelete(deps, body) {
|
|
|
1419
1431
|
return storeFailure(error);
|
|
1420
1432
|
}
|
|
1421
1433
|
}
|
|
1434
|
+
/**
|
|
1435
|
+
* POST /credentials-import — copy what the machine's own credential helpers
|
|
1436
|
+
* already answer for into the managed store, so a machine signed in through
|
|
1437
|
+
* gh/GCM/Keychain stops needing a second sign-in here (decision 0038).
|
|
1438
|
+
*
|
|
1439
|
+
* The candidates are the URL-scoped hosts of the user's gitconfig; a host the
|
|
1440
|
+
* panel already manages is skipped (the panel wins), and a host whose helper
|
|
1441
|
+
* answers nothing is reported per host instead of failing the whole request.
|
|
1442
|
+
* @param deps - host dependencies.
|
|
1443
|
+
* @param body - parsed request body: absent, `{}` or `{ hosts?: string[] }`.
|
|
1444
|
+
*/
|
|
1445
|
+
export async function handleCredentialsImport(deps, body) {
|
|
1446
|
+
const record = body ?? {};
|
|
1447
|
+
if (typeof record !== 'object' || record === null || Array.isArray(record)) {
|
|
1448
|
+
return fail(400, 'request body must be a JSON object');
|
|
1449
|
+
}
|
|
1450
|
+
const fields = record;
|
|
1451
|
+
for (const key of Object.keys(fields)) {
|
|
1452
|
+
if (key !== 'hosts')
|
|
1453
|
+
return fail(400, `unknown body key "${key}"`);
|
|
1454
|
+
}
|
|
1455
|
+
const rawHosts = fields.hosts;
|
|
1456
|
+
let hosts;
|
|
1457
|
+
if (rawHosts !== undefined) {
|
|
1458
|
+
if (!Array.isArray(rawHosts) || rawHosts.some(value => typeof value !== 'string')) {
|
|
1459
|
+
return fail(400, '"hosts" must be an array of remote hosts');
|
|
1460
|
+
}
|
|
1461
|
+
hosts = rawHosts.map(value => value.trim()).filter(value => value !== '');
|
|
1462
|
+
if (hosts.length === 0)
|
|
1463
|
+
return fail(400, '"hosts" must not be empty');
|
|
1464
|
+
}
|
|
1465
|
+
try {
|
|
1466
|
+
const store = credentialStore(deps);
|
|
1467
|
+
const importNow = deps.importLocalCredentials ?? ((target, wanted) => runLocalImport(target, {}, wanted));
|
|
1468
|
+
const report = await importNow(store, hosts);
|
|
1469
|
+
if (report.imported.length > 0)
|
|
1470
|
+
await refreshBridge(deps);
|
|
1471
|
+
const entries = await store.load();
|
|
1472
|
+
const result = {
|
|
1473
|
+
credentials: sortCredentials(entries).map(credentialView),
|
|
1474
|
+
imported: report.imported,
|
|
1475
|
+
skipped: report.skipped,
|
|
1476
|
+
failed: report.failed,
|
|
1477
|
+
};
|
|
1478
|
+
return { status: 200, body: result };
|
|
1479
|
+
}
|
|
1480
|
+
catch (error) {
|
|
1481
|
+
return storeFailure(error);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1422
1484
|
/** The HTTP seam: the wired fake in tests, global fetch in production. */
|
|
1423
1485
|
function fetchFor(deps) {
|
|
1424
1486
|
return deps.fetchImpl ?? globalThis.fetch;
|
|
@@ -1628,3 +1690,73 @@ export async function handleRemote(deps, body) {
|
|
|
1628
1690
|
return fail(500, error instanceof Error ? error.message : String(error));
|
|
1629
1691
|
}
|
|
1630
1692
|
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Report which network channel can read the repository's endpoint right now.
|
|
1695
|
+
*
|
|
1696
|
+
* Diagnostics only: nothing here writes to git or to the config. Each
|
|
1697
|
+
* channel gets its own budget (PROBE_TIMEOUT_MS), so a dead proxy costs one
|
|
1698
|
+
* timeout and the answer still names the channels that work. The system
|
|
1699
|
+
* proxy is reported when the plugin has none of its own — git ignores
|
|
1700
|
+
* WinINET, so a value here is a suggestion the user must opt into (0043).
|
|
1701
|
+
* @param deps - route dependencies (exec, channel providers, config readers).
|
|
1702
|
+
* @param body - `{ repoPath }`.
|
|
1703
|
+
* @returns one report per channel, in the order the write path would try them.
|
|
1704
|
+
*/
|
|
1705
|
+
export async function handleNetworkProbe(deps, body) {
|
|
1706
|
+
const parsed = readBody(body, ['repoPath']);
|
|
1707
|
+
if (isOutcome(parsed))
|
|
1708
|
+
return parsed;
|
|
1709
|
+
const { repoPath } = parsed;
|
|
1710
|
+
if (!isAbsoluteDir(repoPath))
|
|
1711
|
+
return fail(400, '"repoPath" must be an absolute directory');
|
|
1712
|
+
try {
|
|
1713
|
+
const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
|
|
1714
|
+
if (facts === undefined)
|
|
1715
|
+
return fail(400, JSON.stringify(repoPath) + " is not inside a git repository");
|
|
1716
|
+
const authArgs = await authArgsFor(deps);
|
|
1717
|
+
const url = await probeEndpoint(deps.exec, facts.repoRoot, authArgs);
|
|
1718
|
+
if (url === undefined)
|
|
1719
|
+
return fail(400, '"repoPath" has no remote to probe');
|
|
1720
|
+
const channels = [await channelReport(deps.exec, facts.repoRoot, 'direct', url, [], authArgs)];
|
|
1721
|
+
for (const provider of deps.networkChannels?.(deps.exec, facts.repoRoot, authArgs) ?? []) {
|
|
1722
|
+
const argv = await provider(url);
|
|
1723
|
+
if (argv === undefined || argv.length === 0)
|
|
1724
|
+
continue;
|
|
1725
|
+
const channel = argv.some(item => item.startsWith('http.proxy=')) ? 'proxy' : 'ip';
|
|
1726
|
+
if (channels.some(report => report.channel === channel))
|
|
1727
|
+
continue;
|
|
1728
|
+
channels.push(await channelReport(deps.exec, facts.repoRoot, channel, url, argv, authArgs));
|
|
1729
|
+
}
|
|
1730
|
+
const systemProxy = await deps.sectionSystemProxy?.();
|
|
1731
|
+
const result = {
|
|
1732
|
+
url,
|
|
1733
|
+
channels,
|
|
1734
|
+
...(systemProxy === undefined || systemProxy === '' ? {} : { systemProxy }),
|
|
1735
|
+
};
|
|
1736
|
+
return { status: 200, body: result };
|
|
1737
|
+
}
|
|
1738
|
+
catch (error) {
|
|
1739
|
+
if (error instanceof GitError)
|
|
1740
|
+
return gitFailure(error);
|
|
1741
|
+
throw error;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* The endpoint a probe should read: the upstream's push URL when the branch
|
|
1746
|
+
* tracks one, otherwise the push URL of `origin` (or the first remote).
|
|
1747
|
+
* @param exec - executor seam.
|
|
1748
|
+
* @param cwd - any directory inside the repository.
|
|
1749
|
+
* @param authArgs - credential argv for the host.
|
|
1750
|
+
* @returns the URL, or undefined when the repository has no remote at all.
|
|
1751
|
+
*/
|
|
1752
|
+
async function probeEndpoint(exec, cwd, authArgs) {
|
|
1753
|
+
const target = await resolvePushTarget(exec, cwd, authArgs);
|
|
1754
|
+
if (target?.url !== undefined && target.url !== '')
|
|
1755
|
+
return target.url;
|
|
1756
|
+
const remotes = await listRemotes(exec, cwd);
|
|
1757
|
+
const chosen = remotes.find(remote => remote.name === 'origin') ?? remotes[0];
|
|
1758
|
+
if (chosen === undefined)
|
|
1759
|
+
return undefined;
|
|
1760
|
+
const url = chosen.pushUrl !== '' ? chosen.pushUrl : chosen.fetchUrl;
|
|
1761
|
+
return url === '' ? undefined : url;
|
|
1762
|
+
}
|
package/lib/shell-bridge.d.ts
CHANGED
|
@@ -1,143 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shell credential
|
|
3
|
-
* credential panel stores one token per remote host, but 0029 could only
|
|
4
|
-
* inject it into the panel's own routes and the `git_network` tool; a plain
|
|
5
|
-
* `git push` run in a shell authenticated as whatever the machine's
|
|
6
|
-
* credential helper held — often a different account — or hung on an
|
|
7
|
-
* interactive prompt nobody could answer.
|
|
2
|
+
* Shell credential setup (host half) — decision 0037.
|
|
8
3
|
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* in the shell, not only the networked ones (0034).
|
|
4
|
+
* Before 0037 the panel's token only reached commands the plugin itself ran;
|
|
5
|
+
* a plain `git push` in a shell authenticated as whatever the machine's
|
|
6
|
+
* helper held (often a different account) or hung on a prompt nobody could
|
|
7
|
+
* answer. 0032/0034 tried to bridge that through the process environment
|
|
8
|
+
* (`GIT_CONFIG_COUNT`, then a generated `GIT_CONFIG_GLOBAL` session file) —
|
|
9
|
+
* both channels existed only in the host process's own environment, so a
|
|
10
|
+
* shell that received a different environment snapshot saw nothing.
|
|
17
11
|
*
|
|
18
|
-
*
|
|
19
|
-
* git at it with `GIT_CONFIG_GLOBAL` — a name the scrubber keeps. The file:
|
|
12
|
+
* 0037 stops fighting the environment and uses git's own mechanisms:
|
|
20
13
|
*
|
|
21
|
-
* 1.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* helper (`helpers/git-credential-bridge.js`) as a `!`-shell command.
|
|
14
|
+
* 1. `<dsh-home>/git-credentials.include.gitconfig` is generated with one
|
|
15
|
+
* URL-scoped section per managed host;
|
|
16
|
+
* 2. `~/.gitconfig` gains exactly one `[include] path = <that file>` line
|
|
17
|
+
* (idempotent; see `ensureGitconfigInclude` in `credentials.ts`);
|
|
18
|
+
* 3. every git process — the plugin's own routes, `git_network`, and a
|
|
19
|
+
* plain shell — reads that include and therefore resolves the host
|
|
20
|
+
* through `credential-store`, i.e. `~/.git-credentials`.
|
|
29
21
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
22
|
+
* Nothing rides the process environment any more, which is why the module is
|
|
23
|
+
* now only a gitconfig-install + fail-fast switch manager. `GIT_TERMINAL_PROMPT=0`
|
|
24
|
+
* and `GCM_INTERACTIVE=never` stay: a host without a stored credential should
|
|
25
|
+
* fail at once instead of parking an agent shell on a GUI dialog.
|
|
34
26
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* The file is re-derived whenever the credential store changes
|
|
42
|
-
* (`src/routes.ts` calls the bridge after every save / delete / sign-in).
|
|
27
|
+
* `apply`/`dispose` still delete the retired 0032 channel's leftovers
|
|
28
|
+
* (`GIT_CONFIG_COUNT` with a broken `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n`
|
|
29
|
+
* set). The DSH subprocess scrubber strips the KEY names while the count and
|
|
30
|
+
* values survive, so a half-injected channel — left behind by an upgrade from
|
|
31
|
+
* 1.0.10 — makes git abort every command; see `legacyConfigNames` (0036).
|
|
43
32
|
*/
|
|
44
33
|
import { type CredentialStore } from './credentials.js';
|
|
45
|
-
/**
|
|
46
|
-
export declare const BRIDGE_HELPER_ENV = "DGB_BRIDGE_HELPER";
|
|
47
|
-
/** Env var carrying a debug log path to the helper script (unset by default). */
|
|
48
|
-
export declare const BRIDGE_DEBUG_ENV = "DGB_BRIDGE_DEBUG";
|
|
49
|
-
/** Env var pointing git at the generated session configuration file. */
|
|
50
|
-
export declare const SESSION_GITCONFIG_ENV = "GIT_CONFIG_GLOBAL";
|
|
51
|
-
/** Host-side seams the bridge reads through (tests substitute all of them). */
|
|
34
|
+
/** Host-side seams the setup reads through (tests substitute all of them). */
|
|
52
35
|
export interface ShellBridgeDeps {
|
|
53
36
|
/** User home directory seam. */
|
|
54
37
|
readonly home: () => string;
|
|
55
38
|
/** `$DSH_HOME` seam (blank/undefined = unset). */
|
|
56
39
|
readonly envHome: () => string | undefined;
|
|
57
|
-
/** Store seam; the fs-backed store
|
|
40
|
+
/** Store seam; the fs-backed local store when absent. */
|
|
58
41
|
readonly store?: CredentialStore;
|
|
59
42
|
/** The environment object to mutate; `process.env` when absent. */
|
|
60
43
|
readonly env?: Record<string, string | undefined>;
|
|
61
|
-
/**
|
|
62
|
-
* absent. */
|
|
63
|
-
readonly helperPath?: string;
|
|
64
|
-
/** Node executable that runs the helper; `process.execPath` when absent. */
|
|
65
|
-
readonly nodePath?: string;
|
|
66
|
-
/** Directory for the session config; the OS temp dir when absent. */
|
|
67
|
-
readonly configDir?: () => string;
|
|
68
|
-
/** Exact session config path (tests); `<configDir>/dsh-git-branch-<pid>.gitconfig` when absent. */
|
|
69
|
-
readonly configPath?: string;
|
|
70
|
-
/** User global config the session file includes; `<home>/.gitconfig` when absent. */
|
|
44
|
+
/** User global config the generated file is included from; `<home>/.gitconfig` when absent. */
|
|
71
45
|
readonly userGitconfig?: string;
|
|
72
46
|
}
|
|
73
|
-
/** The
|
|
47
|
+
/** The setup's operations: re-derive the include, and undo it. */
|
|
74
48
|
export interface ShellBridge {
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
*
|
|
49
|
+
/** The generated include file (diagnostics/tests). */
|
|
50
|
+
readonly configPath: () => string;
|
|
51
|
+
/** Read the credential store and rewrite the include + `~/.gitconfig`.
|
|
52
|
+
* A store that cannot be read writes an empty include (no host is
|
|
53
|
+
* scoped), while the fail-fast switches still go out. */
|
|
79
54
|
readonly apply: () => Promise<void>;
|
|
80
|
-
/** Remove the
|
|
55
|
+
/** Remove the fail-fast switches, the include line and the generated file. */
|
|
81
56
|
readonly dispose: () => Promise<void>;
|
|
82
|
-
/** The session config path this bridge writes. */
|
|
83
|
-
readonly configPath: () => string;
|
|
84
57
|
}
|
|
85
|
-
/**
|
|
86
|
-
export
|
|
87
|
-
/**
|
|
88
|
-
* Quote one executable or path for git's `!` shell command. Backslashes are
|
|
89
|
-
* normalized to forward slashes first: git runs `!` helpers through `sh`,
|
|
90
|
-
* where `C:\\…` would be read as escape sequences.
|
|
91
|
-
* @param value - an absolute path.
|
|
92
|
-
*/
|
|
93
|
-
export declare function shellQuote(value: string): string;
|
|
94
|
-
/**
|
|
95
|
-
* The helper command git runs for one configured host: a `!`-prefixed shell
|
|
96
|
-
* command, because git does not word-split a helper value by itself (a bare
|
|
97
|
-
* `node "C:/…/bridge.js"` is looked up as the program `git-credential-node`).
|
|
98
|
-
* @param nodePath - the node executable.
|
|
99
|
-
* @param helperPath - the compiled helper script.
|
|
100
|
-
*/
|
|
101
|
-
export declare function bridgeCommand(nodePath: string, helperPath: string): string;
|
|
58
|
+
/** The retired 0032 channel's count variable. */
|
|
59
|
+
export declare const LEGACY_COUNT_ENV = "GIT_CONFIG_COUNT";
|
|
102
60
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* @param
|
|
117
|
-
* @param options - the node/helper pair and the user config to include.
|
|
118
|
-
*/
|
|
119
|
-
export declare function sessionGitconfigBody(hosts: readonly string[], options: {
|
|
120
|
-
readonly nodePath: string;
|
|
121
|
-
readonly helperPath: string;
|
|
122
|
-
readonly userGitconfig?: string;
|
|
123
|
-
}): string;
|
|
124
|
-
/**
|
|
125
|
-
* The environment assignments pointing git at the session config: the config
|
|
126
|
-
* path itself, the credential file the helper reads, and the two fail-fast
|
|
127
|
-
* switches. `hosts` live in the file now, not in the environment.
|
|
128
|
-
* @param options - the session config path and the credential file.
|
|
61
|
+
* Environment names left over from the retired 0032 channel that would make
|
|
62
|
+
* git abort with `fatal: unable to parse command-line config`.
|
|
63
|
+
*
|
|
64
|
+
* The DSH subprocess scrubber deletes every `GIT_CONFIG_KEY_<n>` (the name
|
|
65
|
+
* contains `KEY`) while `GIT_CONFIG_COUNT` / `GIT_CONFIG_VALUE_<n>` survive, so
|
|
66
|
+
* the channel is injected half-way and git can no longer parse it. Those
|
|
67
|
+
* leftovers outlive the code that wrote them: upgrading the plugin from 1.0.10
|
|
68
|
+
* leaves them in the host process environment, where they break every `git`
|
|
69
|
+
* command in every derived shell until someone deletes them by hand.
|
|
70
|
+
*
|
|
71
|
+
* A count whose pairs are all present is valid git input (a caller may set the
|
|
72
|
+
* channel deliberately), so it is left untouched; only a broken channel — or a
|
|
73
|
+
* non-numeric count — is reported for removal.
|
|
74
|
+
* @param env - the environment to inspect.
|
|
129
75
|
*/
|
|
130
|
-
export declare function
|
|
131
|
-
readonly configPath: string;
|
|
132
|
-
readonly credentialFile: string;
|
|
133
|
-
}): readonly BridgeAssignment[];
|
|
76
|
+
export declare function legacyConfigNames(env: Record<string, string | undefined>): string[];
|
|
134
77
|
/**
|
|
135
|
-
* Build the
|
|
136
|
-
* rewrites the
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* unauthenticated at worst) while the fail-fast switches still go out.
|
|
78
|
+
* Build the setup over one host process. Every {@link ShellBridge.apply}
|
|
79
|
+
* rewrites the include for the current store, so a deleted host loses its
|
|
80
|
+
* helper scope just like a freshly added one gains it. A failing write never
|
|
81
|
+
* throws: the worst case is a stale include until the next credential save,
|
|
82
|
+
* and git keeps working with the machine's own configuration.
|
|
141
83
|
* @param deps - host seams.
|
|
142
84
|
*/
|
|
143
85
|
export declare function createShellBridge(deps: ShellBridgeDeps): ShellBridge;
|