dsh-git-branch-manage 1.0.7 → 1.0.9
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/lib/client.js +1134 -51
- package/lib/credentials.d.ts +144 -0
- package/lib/credentials.js +256 -0
- package/lib/git.d.ts +43 -5
- package/lib/git.js +83 -9
- package/lib/index.js +30 -2
- package/lib/network-tool.d.ts +111 -0
- package/lib/network-tool.js +246 -0
- package/lib/normalize.d.ts +24 -0
- package/lib/normalize.js +47 -0
- package/lib/oauth.d.ts +109 -0
- package/lib/oauth.js +182 -0
- package/lib/routes.d.ts +67 -2
- package/lib/routes.js +359 -7
- package/lib/settings.d.ts +9 -0
- package/lib/settings.js +12 -2
- package/lib/tool.d.ts +82 -0
- package/lib/tool.js +133 -0
- package/lib/wire.d.ts +132 -0
- package/lib/wire.js +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global git credential store (host half). Credentials belong to a remote
|
|
3
|
+
* HOST, not to a project: the same `github.com` token serves every repository
|
|
4
|
+
* the panel touches, so the record lives in one file under `$DSH_HOME`
|
|
5
|
+
* (`~/.dsh`) instead of the per-repository settings document. The secret
|
|
6
|
+
* never travels to the browser — the list route answers masked tails only —
|
|
7
|
+
* and the store is read/written by the host alone.
|
|
8
|
+
*
|
|
9
|
+
* Authentication rides `git -c http.https://<host>/.extraHeader=…` on the
|
|
10
|
+
* panel's own network commands: the user's global git config and credential
|
|
11
|
+
* helpers are left untouched. Only `https://` remotes receive the header;
|
|
12
|
+
* ssh and plain-http remotes are never sent a secret.
|
|
13
|
+
*/
|
|
14
|
+
import type { CredentialMethod } from './wire.js';
|
|
15
|
+
/**
|
|
16
|
+
* The Basic-auth name git sends when a token was stored without a username.
|
|
17
|
+
* GitHub, GitLab and Gitee all accept any non-empty name for a personal access
|
|
18
|
+
* token, and `oauth2` is the conventional placeholder.
|
|
19
|
+
*/
|
|
20
|
+
export declare const TOKEN_USERNAME = "oauth2";
|
|
21
|
+
/** One stored credential: exactly one per remote host. */
|
|
22
|
+
export interface GitCredential {
|
|
23
|
+
/** Normalized remote host (`github.com`, `git.example.com:8443`). */
|
|
24
|
+
host: string;
|
|
25
|
+
/** Basic-auth username (a real account name, or a vendor-form placeholder
|
|
26
|
+
* such as `x-access-token` / `oauth2`). */
|
|
27
|
+
username: string;
|
|
28
|
+
/** Token or password, verbatim (never returned to the client). */
|
|
29
|
+
secret: string;
|
|
30
|
+
/** How the secret is meant to be used — informational for the UI; git
|
|
31
|
+
* itself only sees the Basic header. */
|
|
32
|
+
method: CredentialMethod;
|
|
33
|
+
/** Epoch millis of the last save. */
|
|
34
|
+
updatedAt: number;
|
|
35
|
+
}
|
|
36
|
+
/** Load/save seam for the credential file (tests substitute an in-memory one). */
|
|
37
|
+
export interface CredentialStore {
|
|
38
|
+
/** Absolute file the store persists to (for diagnostics/UI hints). */
|
|
39
|
+
readonly path: string;
|
|
40
|
+
/** Every stored credential, one per host, sorted by host. A missing file
|
|
41
|
+
* is the empty list; an unreadable/malformed one throws
|
|
42
|
+
* {@link CredentialStoreError}. */
|
|
43
|
+
load: () => Promise<GitCredential[]>;
|
|
44
|
+
/** Replace the whole set atomically (temp file + rename). */
|
|
45
|
+
save: (entries: readonly GitCredential[]) => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
/** A credential file the store could not read or understand. */
|
|
48
|
+
export declare class CredentialStoreError extends Error {
|
|
49
|
+
readonly path: string;
|
|
50
|
+
constructor(path: string, message: string);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the credential file location: `<dsh-home>/git-credentials.json`.
|
|
54
|
+
* @param home - user home directory seam.
|
|
55
|
+
* @param envHome - `$DSH_HOME` environment value seam (blank = unset).
|
|
56
|
+
*/
|
|
57
|
+
export declare function credentialFilePath(home: string, envHome: string | undefined): string;
|
|
58
|
+
/** Why a user-typed host is not usable yet (undefined = fine). */
|
|
59
|
+
export type CredentialHostIssue = 'empty' | 'invalid';
|
|
60
|
+
/**
|
|
61
|
+
* Normalize a remote host typed or pasted by the user. Accepts a bare host
|
|
62
|
+
* (`github.com`), a host with a port (`git.example.com:8443`), a full remote
|
|
63
|
+
* URL (`https://github.com/org/repo.git`), an scp-like remote
|
|
64
|
+
* (`git@github.com:org/repo.git`) and a URL with userinfo — the host is the
|
|
65
|
+
* only part the credential needs. Lowercased, no scheme, path, userinfo or
|
|
66
|
+
* trailing slash.
|
|
67
|
+
* @param value - raw draft text.
|
|
68
|
+
* @returns the normalized host, or undefined when nothing usable is left.
|
|
69
|
+
*/
|
|
70
|
+
export declare function normalizeCredentialHost(value: string): string | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Pre-flight check of a user-typed credential host.
|
|
73
|
+
* @param value - raw draft text.
|
|
74
|
+
*/
|
|
75
|
+
export declare function credentialHostIssue(value: string): CredentialHostIssue | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Why a username is not usable yet (undefined = fine). A token credential may
|
|
78
|
+
* leave the username empty: vendors accept any non-empty Basic-auth name
|
|
79
|
+
* alongside a personal access token, so only password mode requires one.
|
|
80
|
+
* @param value - raw draft text.
|
|
81
|
+
* @param method - the method the draft targets (defaults to password mode).
|
|
82
|
+
*/
|
|
83
|
+
export declare function credentialUsernameIssue(value: string, method?: CredentialMethod): 'empty' | 'illegal' | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* The Basic-auth username git actually sends for one entry.
|
|
86
|
+
* @param entry - the stored credential.
|
|
87
|
+
* @returns the stored username, or {@link TOKEN_USERNAME} for a token saved
|
|
88
|
+
* without one.
|
|
89
|
+
*/
|
|
90
|
+
export declare function basicUsername(entry: GitCredential): string;
|
|
91
|
+
/** Why a secret is not usable yet (undefined = fine). */
|
|
92
|
+
export declare function credentialSecretIssue(value: string): 'empty' | 'illegal' | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Mask a stored secret for the list route: the tail only, never the value.
|
|
95
|
+
* @param secret - the stored secret.
|
|
96
|
+
*/
|
|
97
|
+
export declare function maskSecret(secret: string): string;
|
|
98
|
+
/**
|
|
99
|
+
* The git config argv that authenticates network commands against the stored
|
|
100
|
+
* hosts: one `http.<https-url>.extraHeader` per host. Git applies a
|
|
101
|
+
* `http.<url>.*` section only to a matching remote URL, so injecting every
|
|
102
|
+
* stored host at once cannot leak one host's header to another; only
|
|
103
|
+
* `https://` URLs match (ssh/http never receive it).
|
|
104
|
+
* @param entries - stored credentials (duplicates collapsed by host).
|
|
105
|
+
* @returns the `-c <key>=<value>` pairs, ready to prepend to a subcommand.
|
|
106
|
+
*/
|
|
107
|
+
export declare function authConfigArgs(entries: readonly GitCredential[]): readonly string[];
|
|
108
|
+
/**
|
|
109
|
+
* Insert or update one credential; the pair `(host, updatedAt)` changes,
|
|
110
|
+
* everything else is replaced. An existing host keeps its position.
|
|
111
|
+
* @param entries - current credentials.
|
|
112
|
+
* @param next - the entry to store (host already normalized).
|
|
113
|
+
* @returns a NEW sorted array.
|
|
114
|
+
*/
|
|
115
|
+
export declare function upsertCredential(entries: readonly GitCredential[], next: GitCredential): GitCredential[];
|
|
116
|
+
/**
|
|
117
|
+
* Drop one host's credential.
|
|
118
|
+
* @param entries - current credentials.
|
|
119
|
+
* @param host - normalized host to forget.
|
|
120
|
+
* @returns a NEW sorted array.
|
|
121
|
+
*/
|
|
122
|
+
export declare function removeCredential(entries: readonly GitCredential[], host: string): GitCredential[];
|
|
123
|
+
/** Stable host order for the file and the UI. */
|
|
124
|
+
export declare function sortCredentials(entries: readonly GitCredential[]): GitCredential[];
|
|
125
|
+
/**
|
|
126
|
+
* Parse the credential file. Unknown extra fields are tolerated (a
|
|
127
|
+
* hand-edited file), but a malformed entry is an error rather than a silent
|
|
128
|
+
* drop — losing a credential behind the user's back is worse than an
|
|
129
|
+
* explicit message. Hosts are re-normalized and de-duplicated.
|
|
130
|
+
* @param text - raw file contents.
|
|
131
|
+
* @param path - file path (for the error message).
|
|
132
|
+
*/
|
|
133
|
+
export declare function parseCredentialFile(text: string, path?: string): GitCredential[];
|
|
134
|
+
/**
|
|
135
|
+
* Serialize the credential set for the file.
|
|
136
|
+
* @param entries - stored credentials.
|
|
137
|
+
*/
|
|
138
|
+
export declare function serializeCredentialFile(entries: readonly GitCredential[]): string;
|
|
139
|
+
/**
|
|
140
|
+
* The real file-backed store: atomic replace, `0600` where the platform
|
|
141
|
+
* honours it. A missing file reads as "no credentials yet".
|
|
142
|
+
* @param path - absolute credential file path.
|
|
143
|
+
*/
|
|
144
|
+
export declare function createCredentialStore(path: string): CredentialStore;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global git credential store (host half). Credentials belong to a remote
|
|
3
|
+
* HOST, not to a project: the same `github.com` token serves every repository
|
|
4
|
+
* the panel touches, so the record lives in one file under `$DSH_HOME`
|
|
5
|
+
* (`~/.dsh`) instead of the per-repository settings document. The secret
|
|
6
|
+
* never travels to the browser — the list route answers masked tails only —
|
|
7
|
+
* and the store is read/written by the host alone.
|
|
8
|
+
*
|
|
9
|
+
* Authentication rides `git -c http.https://<host>/.extraHeader=…` on the
|
|
10
|
+
* panel's own network commands: the user's global git config and credential
|
|
11
|
+
* helpers are left untouched. Only `https://` remotes receive the header;
|
|
12
|
+
* ssh and plain-http remotes are never sent a secret.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { resolveDshHome } from './settings.js';
|
|
17
|
+
/**
|
|
18
|
+
* The Basic-auth name git sends when a token was stored without a username.
|
|
19
|
+
* GitHub, GitLab and Gitee all accept any non-empty name for a personal access
|
|
20
|
+
* token, and `oauth2` is the conventional placeholder.
|
|
21
|
+
*/
|
|
22
|
+
export const TOKEN_USERNAME = 'oauth2';
|
|
23
|
+
/** A credential file the store could not read or understand. */
|
|
24
|
+
export class CredentialStoreError extends Error {
|
|
25
|
+
path;
|
|
26
|
+
constructor(path, message) {
|
|
27
|
+
super(`credential file "${path}" ${message}`);
|
|
28
|
+
this.path = path;
|
|
29
|
+
this.name = 'CredentialStoreError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the credential file location: `<dsh-home>/git-credentials.json`.
|
|
34
|
+
* @param home - user home directory seam.
|
|
35
|
+
* @param envHome - `$DSH_HOME` environment value seam (blank = unset).
|
|
36
|
+
*/
|
|
37
|
+
export function credentialFilePath(home, envHome) {
|
|
38
|
+
return join(resolveDshHome(home, envHome), 'git-credentials.json');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a remote host typed or pasted by the user. Accepts a bare host
|
|
42
|
+
* (`github.com`), a host with a port (`git.example.com:8443`), a full remote
|
|
43
|
+
* URL (`https://github.com/org/repo.git`), an scp-like remote
|
|
44
|
+
* (`git@github.com:org/repo.git`) and a URL with userinfo — the host is the
|
|
45
|
+
* only part the credential needs. Lowercased, no scheme, path, userinfo or
|
|
46
|
+
* trailing slash.
|
|
47
|
+
* @param value - raw draft text.
|
|
48
|
+
* @returns the normalized host, or undefined when nothing usable is left.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeCredentialHost(value) {
|
|
51
|
+
let host = value.trim().toLowerCase();
|
|
52
|
+
// Scheme (`https://`) and everything after the authority (`/org/repo`).
|
|
53
|
+
host = host.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
|
|
54
|
+
const slash = host.search(/[/?#]/);
|
|
55
|
+
if (slash >= 0)
|
|
56
|
+
host = host.slice(0, slash);
|
|
57
|
+
// Userinfo (`user:pass@host`); scp-like `git@host:path` keeps the host part.
|
|
58
|
+
const at = host.lastIndexOf('@');
|
|
59
|
+
if (at >= 0)
|
|
60
|
+
host = host.slice(at + 1);
|
|
61
|
+
// scp-like `host:path` — keep the host before the colon, unless the colon
|
|
62
|
+
// introduces a numeric port.
|
|
63
|
+
const colon = host.indexOf(':');
|
|
64
|
+
if (colon >= 0 && !/^\d{1,5}$/.test(host.slice(colon + 1)))
|
|
65
|
+
host = host.slice(0, colon);
|
|
66
|
+
if (host.endsWith('.'))
|
|
67
|
+
host = host.slice(0, -1);
|
|
68
|
+
if (host === '')
|
|
69
|
+
return undefined;
|
|
70
|
+
// Hostname (single-label intranet hosts allowed) with an optional port.
|
|
71
|
+
if (!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?::\d{1,5})?$/.test(host))
|
|
72
|
+
return undefined;
|
|
73
|
+
return host;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pre-flight check of a user-typed credential host.
|
|
77
|
+
* @param value - raw draft text.
|
|
78
|
+
*/
|
|
79
|
+
export function credentialHostIssue(value) {
|
|
80
|
+
if (value.trim() === '')
|
|
81
|
+
return 'empty';
|
|
82
|
+
return normalizeCredentialHost(value) === undefined ? 'invalid' : undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Why a username is not usable yet (undefined = fine). A token credential may
|
|
86
|
+
* leave the username empty: vendors accept any non-empty Basic-auth name
|
|
87
|
+
* alongside a personal access token, so only password mode requires one.
|
|
88
|
+
* @param value - raw draft text.
|
|
89
|
+
* @param method - the method the draft targets (defaults to password mode).
|
|
90
|
+
*/
|
|
91
|
+
export function credentialUsernameIssue(value, method = 'password') {
|
|
92
|
+
if (value.trim() === '')
|
|
93
|
+
return method === 'token' ? undefined : 'empty';
|
|
94
|
+
if (/[\u0000-\u001f\u007f]/.test(value))
|
|
95
|
+
return 'illegal';
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The Basic-auth username git actually sends for one entry.
|
|
100
|
+
* @param entry - the stored credential.
|
|
101
|
+
* @returns the stored username, or {@link TOKEN_USERNAME} for a token saved
|
|
102
|
+
* without one.
|
|
103
|
+
*/
|
|
104
|
+
export function basicUsername(entry) {
|
|
105
|
+
return entry.username.trim() === '' ? TOKEN_USERNAME : entry.username;
|
|
106
|
+
}
|
|
107
|
+
/** Why a secret is not usable yet (undefined = fine). */
|
|
108
|
+
export function credentialSecretIssue(value) {
|
|
109
|
+
if (value.trim() === '')
|
|
110
|
+
return 'empty';
|
|
111
|
+
// A pasted multi-line blob (or a stray control character) would corrupt
|
|
112
|
+
// the Basic header; interior spaces stay legal — passwords may carry them.
|
|
113
|
+
if (/[\u0000-\u001f\u007f]/.test(value))
|
|
114
|
+
return 'illegal';
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Mask a stored secret for the list route: the tail only, never the value.
|
|
119
|
+
* @param secret - the stored secret.
|
|
120
|
+
*/
|
|
121
|
+
export function maskSecret(secret) {
|
|
122
|
+
const tail = secret.slice(-4);
|
|
123
|
+
return secret.length <= 4 ? '****' : `****${tail}`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The git config argv that authenticates network commands against the stored
|
|
127
|
+
* hosts: one `http.<https-url>.extraHeader` per host. Git applies a
|
|
128
|
+
* `http.<url>.*` section only to a matching remote URL, so injecting every
|
|
129
|
+
* stored host at once cannot leak one host's header to another; only
|
|
130
|
+
* `https://` URLs match (ssh/http never receive it).
|
|
131
|
+
* @param entries - stored credentials (duplicates collapsed by host).
|
|
132
|
+
* @returns the `-c <key>=<value>` pairs, ready to prepend to a subcommand.
|
|
133
|
+
*/
|
|
134
|
+
export function authConfigArgs(entries) {
|
|
135
|
+
// Collapse duplicates by host, LAST entry winning: a hand-edited file with
|
|
136
|
+
// two rows for one host must hand git the row the list route shows last.
|
|
137
|
+
const byHost = new Map();
|
|
138
|
+
for (const entry of entries)
|
|
139
|
+
byHost.set(entry.host, entry);
|
|
140
|
+
const args = [];
|
|
141
|
+
for (const entry of byHost.values()) {
|
|
142
|
+
const basic = Buffer.from(`${basicUsername(entry)}:${entry.secret}`, 'utf8').toString('base64');
|
|
143
|
+
args.push('-c', `http.https://${entry.host}/.extraHeader=AUTHORIZATION: basic ${basic}`);
|
|
144
|
+
}
|
|
145
|
+
return args;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Insert or update one credential; the pair `(host, updatedAt)` changes,
|
|
149
|
+
* everything else is replaced. An existing host keeps its position.
|
|
150
|
+
* @param entries - current credentials.
|
|
151
|
+
* @param next - the entry to store (host already normalized).
|
|
152
|
+
* @returns a NEW sorted array.
|
|
153
|
+
*/
|
|
154
|
+
export function upsertCredential(entries, next) {
|
|
155
|
+
const rest = entries.filter(entry => entry.host !== next.host);
|
|
156
|
+
return sortCredentials([...rest, next]);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Drop one host's credential.
|
|
160
|
+
* @param entries - current credentials.
|
|
161
|
+
* @param host - normalized host to forget.
|
|
162
|
+
* @returns a NEW sorted array.
|
|
163
|
+
*/
|
|
164
|
+
export function removeCredential(entries, host) {
|
|
165
|
+
return entries.filter(entry => entry.host !== host);
|
|
166
|
+
}
|
|
167
|
+
/** Stable host order for the file and the UI. */
|
|
168
|
+
export function sortCredentials(entries) {
|
|
169
|
+
return [...entries].sort((left, right) => left.host.localeCompare(right.host));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Parse the credential file. Unknown extra fields are tolerated (a
|
|
173
|
+
* hand-edited file), but a malformed entry is an error rather than a silent
|
|
174
|
+
* drop — losing a credential behind the user's back is worse than an
|
|
175
|
+
* explicit message. Hosts are re-normalized and de-duplicated.
|
|
176
|
+
* @param text - raw file contents.
|
|
177
|
+
* @param path - file path (for the error message).
|
|
178
|
+
*/
|
|
179
|
+
export function parseCredentialFile(text, path = 'git-credentials.json') {
|
|
180
|
+
let parsed;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(text);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw new CredentialStoreError(path, `is not valid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
186
|
+
}
|
|
187
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
188
|
+
throw new CredentialStoreError(path, 'must contain a JSON object');
|
|
189
|
+
}
|
|
190
|
+
const raw = parsed.credentials;
|
|
191
|
+
if (!Array.isArray(raw))
|
|
192
|
+
throw new CredentialStoreError(path, 'has no "credentials" array');
|
|
193
|
+
const byHost = new Map();
|
|
194
|
+
for (const [index, item] of raw.entries()) {
|
|
195
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
196
|
+
throw new CredentialStoreError(path, `entry #${String(index + 1)} is not an object`);
|
|
197
|
+
}
|
|
198
|
+
const record = item;
|
|
199
|
+
const host = typeof record.host === 'string' ? normalizeCredentialHost(record.host) : undefined;
|
|
200
|
+
const username = typeof record.username === 'string' ? record.username : undefined;
|
|
201
|
+
const secret = typeof record.secret === 'string' ? record.secret : undefined;
|
|
202
|
+
const method = record.method;
|
|
203
|
+
if (host === undefined || username === undefined || secret === undefined || (method !== 'token' && method !== 'password')) {
|
|
204
|
+
throw new CredentialStoreError(path, `entry #${String(index + 1)} is missing a valid host/username/secret/method`);
|
|
205
|
+
}
|
|
206
|
+
const updatedAt = typeof record.updatedAt === 'number' && Number.isFinite(record.updatedAt) ? record.updatedAt : 0;
|
|
207
|
+
byHost.set(host, { host, username, secret, method, updatedAt });
|
|
208
|
+
}
|
|
209
|
+
return sortCredentials([...byHost.values()]);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Serialize the credential set for the file.
|
|
213
|
+
* @param entries - stored credentials.
|
|
214
|
+
*/
|
|
215
|
+
export function serializeCredentialFile(entries) {
|
|
216
|
+
const document = { version: 1, credentials: sortCredentials(entries) };
|
|
217
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The real file-backed store: atomic replace, `0600` where the platform
|
|
221
|
+
* honours it. A missing file reads as "no credentials yet".
|
|
222
|
+
* @param path - absolute credential file path.
|
|
223
|
+
*/
|
|
224
|
+
export function createCredentialStore(path) {
|
|
225
|
+
return {
|
|
226
|
+
path,
|
|
227
|
+
load: async () => {
|
|
228
|
+
let text;
|
|
229
|
+
try {
|
|
230
|
+
text = await readFile(path, 'utf8');
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error.code === 'ENOENT')
|
|
234
|
+
return [];
|
|
235
|
+
throw new CredentialStoreError(path, `could not be read (${error instanceof Error ? error.message : String(error)})`);
|
|
236
|
+
}
|
|
237
|
+
if (text.trim() === '')
|
|
238
|
+
return [];
|
|
239
|
+
return parseCredentialFile(text, path);
|
|
240
|
+
},
|
|
241
|
+
save: async (entries) => {
|
|
242
|
+
const directory = dirname(path);
|
|
243
|
+
await mkdir(directory, { recursive: true });
|
|
244
|
+
// Write-then-rename keeps a crashed save from truncating the real file.
|
|
245
|
+
const temp = `${path}.${String(process.pid)}.${String(Date.now())}.tmp`;
|
|
246
|
+
try {
|
|
247
|
+
await writeFile(temp, serializeCredentialFile(entries), { encoding: 'utf8', mode: 0o600 });
|
|
248
|
+
await rename(temp, path);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
252
|
+
throw new CredentialStoreError(path, `could not be written (${error instanceof Error ? error.message : String(error)})`);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
package/lib/git.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* injectable `exec` seam (tests substitute it); every method is plain
|
|
4
4
|
* argument assembly plus porcelain parsing, no ambient state.
|
|
5
5
|
*/
|
|
6
|
-
import type { BranchEntry, WorktreeEntry, WorkspaceGitFacts } from './wire.js';
|
|
6
|
+
import type { BranchEntry, RemoteEntry, WorktreeEntry, WorkspaceGitFacts } from './wire.js';
|
|
7
7
|
/** One process result as the seam reports it. */
|
|
8
8
|
export interface ExecResult {
|
|
9
9
|
code: number;
|
|
@@ -253,8 +253,10 @@ export declare function accelArgs(prefix: string | undefined): readonly string[]
|
|
|
253
253
|
* @param repoRoot - main worktree directory (fetch is repository-wide).
|
|
254
254
|
* @param accelPrefix - optional cn-accelerate mirror prefix (see
|
|
255
255
|
* {@link accelArgs}); only the READ half of the network commands gets it.
|
|
256
|
+
* @param authArgs - optional per-host credential `-c` pairs (see
|
|
257
|
+
* `credentials.ts`); prepended before the subcommand like the accel rewrite.
|
|
256
258
|
*/
|
|
257
|
-
export declare function fetchAll(exec: Exec, repoRoot: string, accelPrefix?: string): Promise<void>;
|
|
259
|
+
export declare function fetchAll(exec: Exec, repoRoot: string, accelPrefix?: string, authArgs?: readonly string[]): Promise<void>;
|
|
258
260
|
/** Outcome of {@link updateBranch}: the branch touched and whether it moved. */
|
|
259
261
|
export interface UpdateOutcome {
|
|
260
262
|
branch: string;
|
|
@@ -276,7 +278,7 @@ export interface UpdateOutcome {
|
|
|
276
278
|
* @returns the branch name and whether HEAD actually moved (false = the
|
|
277
279
|
* upstream already contained it).
|
|
278
280
|
*/
|
|
279
|
-
export declare function updateBranch(exec: Exec, cwd: string, accelPrefix?: string): Promise<UpdateOutcome>;
|
|
281
|
+
export declare function updateBranch(exec: Exec, cwd: string, accelPrefix?: string, authArgs?: readonly string[]): Promise<UpdateOutcome>;
|
|
280
282
|
/** Outcome of {@link pushBranch}: the branch touched and whether it moved. */
|
|
281
283
|
export interface PushOutcome {
|
|
282
284
|
branch: string;
|
|
@@ -300,7 +302,7 @@ export interface PushOutcome {
|
|
|
300
302
|
* without an upstream can still be handed to `git push`, so Git's own
|
|
301
303
|
* push-specific diagnostic is preserved; in that case `pushed` is false.
|
|
302
304
|
*/
|
|
303
|
-
export declare function pushBranch(exec: Exec, cwd: string): Promise<PushOutcome>;
|
|
305
|
+
export declare function pushBranch(exec: Exec, cwd: string, authArgs?: readonly string[]): Promise<PushOutcome>;
|
|
304
306
|
/** Every LOCAL tag name of the repository, in git's own sort order. */
|
|
305
307
|
export declare function listTagNames(exec: Exec, cwd: string): Promise<string[]>;
|
|
306
308
|
/** Where a one-off push should go, or why no remote could be picked. */
|
|
@@ -351,9 +353,45 @@ export interface TagPushOutcome {
|
|
|
351
353
|
* @param remote - the remote to publish to (see {@link resolvePushRemote}).
|
|
352
354
|
* @returns the tag, the remote, and whether the push had to run.
|
|
353
355
|
*/
|
|
354
|
-
export declare function pushTag(exec: Exec, cwd: string, name: string, remote: string): Promise<TagPushOutcome>;
|
|
356
|
+
export declare function pushTag(exec: Exec, cwd: string, name: string, remote: string, authArgs?: readonly string[]): Promise<TagPushOutcome>;
|
|
355
357
|
/** Guard for route inputs: a non-empty absolute directory path. */
|
|
356
358
|
export declare function isAbsoluteDir(value: string): boolean;
|
|
359
|
+
/**
|
|
360
|
+
* Every configured remote of the repository (`git remote -v`), with the fetch
|
|
361
|
+
* and push URLs git would actually use. A remote with only one side set
|
|
362
|
+
* mirrors that URL into both fields — git falls back to it the same way.
|
|
363
|
+
* @param exec - executor seam.
|
|
364
|
+
* @param cwd - any directory inside the repository.
|
|
365
|
+
*/
|
|
366
|
+
export declare function listRemotes(exec: Exec, cwd: string): Promise<RemoteEntry[]>;
|
|
367
|
+
/**
|
|
368
|
+
* Register a new remote (`git remote add`). The name/URL were validated by
|
|
369
|
+
* the route; git still refuses a duplicate name — its stderr rides the
|
|
370
|
+
* GitError, mapped to 400 for the caller.
|
|
371
|
+
* @param exec - executor seam.
|
|
372
|
+
* @param cwd - any directory inside the repository.
|
|
373
|
+
* @param name - the new remote name.
|
|
374
|
+
* @param url - its URL (fetch and push; a push URL is not set apart).
|
|
375
|
+
*/
|
|
376
|
+
export declare function addRemote(exec: Exec, cwd: string, name: string, url: string): Promise<void>;
|
|
377
|
+
/**
|
|
378
|
+
* Re-point an existing remote's fetch AND push URL (`git remote set-url`).
|
|
379
|
+
* Deliberately both: a separate push URL would survive as a stale target the
|
|
380
|
+
* UI cannot show, so the panel keeps one address per remote.
|
|
381
|
+
* @param exec - executor seam.
|
|
382
|
+
* @param cwd - any directory inside the repository.
|
|
383
|
+
* @param name - an existing remote name.
|
|
384
|
+
* @param url - the new URL.
|
|
385
|
+
*/
|
|
386
|
+
export declare function setRemoteUrl(exec: Exec, cwd: string, name: string, url: string): Promise<void>;
|
|
387
|
+
/**
|
|
388
|
+
* Forget one remote (`git remote remove`): deletes its remote-tracking refs
|
|
389
|
+
* and its config section, never the objects the local branches share.
|
|
390
|
+
* @param exec - executor seam.
|
|
391
|
+
* @param cwd - any directory inside the repository.
|
|
392
|
+
* @param name - an existing remote name.
|
|
393
|
+
*/
|
|
394
|
+
export declare function removeRemote(exec: Exec, cwd: string, name: string): Promise<void>;
|
|
357
395
|
/** Pre-delete facts of one worktree directory, for the remove-confirm dialog. */
|
|
358
396
|
export interface WorktreeInspect {
|
|
359
397
|
/** Uncommitted-change file rows (`git status --porcelain` line count). */
|
package/lib/git.js
CHANGED
|
@@ -521,9 +521,11 @@ export function accelArgs(prefix) {
|
|
|
521
521
|
* @param repoRoot - main worktree directory (fetch is repository-wide).
|
|
522
522
|
* @param accelPrefix - optional cn-accelerate mirror prefix (see
|
|
523
523
|
* {@link accelArgs}); only the READ half of the network commands gets it.
|
|
524
|
+
* @param authArgs - optional per-host credential `-c` pairs (see
|
|
525
|
+
* `credentials.ts`); prepended before the subcommand like the accel rewrite.
|
|
524
526
|
*/
|
|
525
|
-
export async function fetchAll(exec, repoRoot, accelPrefix) {
|
|
526
|
-
await git(exec, repoRoot, [...accelArgs(accelPrefix), 'fetch', '--all', '--prune'], NETWORK_TIMEOUT_MS);
|
|
527
|
+
export async function fetchAll(exec, repoRoot, accelPrefix, authArgs = []) {
|
|
528
|
+
await git(exec, repoRoot, [...accelArgs(accelPrefix), ...authArgs, 'fetch', '--all', '--prune'], NETWORK_TIMEOUT_MS);
|
|
527
529
|
}
|
|
528
530
|
/**
|
|
529
531
|
* Update the CURRENT checkout: fetch every remote, then fast-forward the
|
|
@@ -541,8 +543,8 @@ export async function fetchAll(exec, repoRoot, accelPrefix) {
|
|
|
541
543
|
* @returns the branch name and whether HEAD actually moved (false = the
|
|
542
544
|
* upstream already contained it).
|
|
543
545
|
*/
|
|
544
|
-
export async function updateBranch(exec, cwd, accelPrefix) {
|
|
545
|
-
await fetchAll(exec, cwd, accelPrefix);
|
|
546
|
+
export async function updateBranch(exec, cwd, accelPrefix, authArgs = []) {
|
|
547
|
+
await fetchAll(exec, cwd, accelPrefix, authArgs);
|
|
546
548
|
const before = (await git(exec, cwd, ['rev-parse', 'HEAD'])).trim();
|
|
547
549
|
await git(exec, cwd, ['merge', '--ff-only', '@{u}']);
|
|
548
550
|
const after = (await git(exec, cwd, ['rev-parse', 'HEAD'])).trim();
|
|
@@ -567,9 +569,9 @@ export async function updateBranch(exec, cwd, accelPrefix) {
|
|
|
567
569
|
* without an upstream can still be handed to `git push`, so Git's own
|
|
568
570
|
* push-specific diagnostic is preserved; in that case `pushed` is false.
|
|
569
571
|
*/
|
|
570
|
-
export async function pushBranch(exec, cwd) {
|
|
572
|
+
export async function pushBranch(exec, cwd, authArgs = []) {
|
|
571
573
|
const before = await gitMaybe(exec, cwd, ['rev-parse', '@{u}']);
|
|
572
|
-
await git(exec, cwd, ['push'], NETWORK_TIMEOUT_MS);
|
|
574
|
+
await git(exec, cwd, [...authArgs, 'push'], NETWORK_TIMEOUT_MS);
|
|
573
575
|
const after = await gitMaybe(exec, cwd, ['rev-parse', '@{u}']);
|
|
574
576
|
const branch = (await gitMaybe(exec, cwd, ['branch', '--show-current']))?.trim() ?? '';
|
|
575
577
|
return { branch, pushed: before !== undefined && after !== undefined && before.trim() !== after.trim() };
|
|
@@ -629,22 +631,94 @@ export async function resolvePushRemote(exec, cwd) {
|
|
|
629
631
|
* @param remote - the remote to publish to (see {@link resolvePushRemote}).
|
|
630
632
|
* @returns the tag, the remote, and whether the push had to run.
|
|
631
633
|
*/
|
|
632
|
-
export async function pushTag(exec, cwd, name, remote) {
|
|
634
|
+
export async function pushTag(exec, cwd, name, remote, authArgs = []) {
|
|
633
635
|
const ref = `refs/tags/${name}`;
|
|
634
636
|
const local = (await git(exec, cwd, ['rev-parse', '--verify', ref])).trim();
|
|
635
|
-
const listed = await gitMaybe(exec, cwd, ['ls-remote', '--refs', remote, ref], NETWORK_TIMEOUT_MS);
|
|
637
|
+
const listed = await gitMaybe(exec, cwd, [...authArgs, 'ls-remote', '--refs', remote, ref], NETWORK_TIMEOUT_MS);
|
|
636
638
|
const present = (listed ?? '').split('\n')
|
|
637
639
|
.map(line => line.trim().split(/\s+/)[0] ?? '')
|
|
638
640
|
.includes(local);
|
|
639
641
|
if (present)
|
|
640
642
|
return { tag: name, remote, pushed: false };
|
|
641
|
-
await git(exec, cwd, ['push', remote, `${ref}:${ref}`], NETWORK_TIMEOUT_MS);
|
|
643
|
+
await git(exec, cwd, [...authArgs, 'push', remote, `${ref}:${ref}`], NETWORK_TIMEOUT_MS);
|
|
642
644
|
return { tag: name, remote, pushed: true };
|
|
643
645
|
}
|
|
644
646
|
/** Guard for route inputs: a non-empty absolute directory path. */
|
|
645
647
|
export function isAbsoluteDir(value) {
|
|
646
648
|
return value.trim() !== '' && isAbsolute(value);
|
|
647
649
|
}
|
|
650
|
+
/**
|
|
651
|
+
* Every configured remote of the repository (`git remote -v`), with the fetch
|
|
652
|
+
* and push URLs git would actually use. A remote with only one side set
|
|
653
|
+
* mirrors that URL into both fields — git falls back to it the same way.
|
|
654
|
+
* @param exec - executor seam.
|
|
655
|
+
* @param cwd - any directory inside the repository.
|
|
656
|
+
*/
|
|
657
|
+
export async function listRemotes(exec, cwd) {
|
|
658
|
+
const text = await git(exec, cwd, ['remote', '-v']);
|
|
659
|
+
const byName = new Map();
|
|
660
|
+
for (const line of text.split('\n')) {
|
|
661
|
+
// `git remote -v` separates the name from `<url> (fetch|push)` with a tab;
|
|
662
|
+
// splitting there keeps a local-path URL with spaces intact.
|
|
663
|
+
const tab = line.indexOf('\t');
|
|
664
|
+
if (tab <= 0)
|
|
665
|
+
continue;
|
|
666
|
+
const name = line.slice(0, tab).trim();
|
|
667
|
+
const rest = line.slice(tab + 1).trim();
|
|
668
|
+
const match = /^(.*)\s+\((fetch|push)\)$/.exec(rest);
|
|
669
|
+
if (name === '' || match === null)
|
|
670
|
+
continue;
|
|
671
|
+
const url = (match[1] ?? '').trim();
|
|
672
|
+
const entry = byName.get(name) ?? {};
|
|
673
|
+
if (match[2] === 'fetch')
|
|
674
|
+
entry.fetchUrl = url;
|
|
675
|
+
else
|
|
676
|
+
entry.pushUrl = url;
|
|
677
|
+
byName.set(name, entry);
|
|
678
|
+
}
|
|
679
|
+
return [...byName.entries()]
|
|
680
|
+
.map(([name, urls]) => ({
|
|
681
|
+
name,
|
|
682
|
+
fetchUrl: urls.fetchUrl ?? urls.pushUrl ?? '',
|
|
683
|
+
pushUrl: urls.pushUrl ?? urls.fetchUrl ?? '',
|
|
684
|
+
}))
|
|
685
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Register a new remote (`git remote add`). The name/URL were validated by
|
|
689
|
+
* the route; git still refuses a duplicate name — its stderr rides the
|
|
690
|
+
* GitError, mapped to 400 for the caller.
|
|
691
|
+
* @param exec - executor seam.
|
|
692
|
+
* @param cwd - any directory inside the repository.
|
|
693
|
+
* @param name - the new remote name.
|
|
694
|
+
* @param url - its URL (fetch and push; a push URL is not set apart).
|
|
695
|
+
*/
|
|
696
|
+
export async function addRemote(exec, cwd, name, url) {
|
|
697
|
+
await git(exec, cwd, ['remote', 'add', name, url]);
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Re-point an existing remote's fetch AND push URL (`git remote set-url`).
|
|
701
|
+
* Deliberately both: a separate push URL would survive as a stale target the
|
|
702
|
+
* UI cannot show, so the panel keeps one address per remote.
|
|
703
|
+
* @param exec - executor seam.
|
|
704
|
+
* @param cwd - any directory inside the repository.
|
|
705
|
+
* @param name - an existing remote name.
|
|
706
|
+
* @param url - the new URL.
|
|
707
|
+
*/
|
|
708
|
+
export async function setRemoteUrl(exec, cwd, name, url) {
|
|
709
|
+
await git(exec, cwd, ['remote', 'set-url', name, url]);
|
|
710
|
+
await git(exec, cwd, ['remote', 'set-url', '--push', name, url]);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Forget one remote (`git remote remove`): deletes its remote-tracking refs
|
|
714
|
+
* and its config section, never the objects the local branches share.
|
|
715
|
+
* @param exec - executor seam.
|
|
716
|
+
* @param cwd - any directory inside the repository.
|
|
717
|
+
* @param name - an existing remote name.
|
|
718
|
+
*/
|
|
719
|
+
export async function removeRemote(exec, cwd, name) {
|
|
720
|
+
await git(exec, cwd, ['remote', 'remove', name]);
|
|
721
|
+
}
|
|
648
722
|
/**
|
|
649
723
|
* Inspect one worktree directory for removal: how many files carry
|
|
650
724
|
* uncommitted changes (those ARE lost with the folder), and how many commits
|