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/credentials.js
CHANGED
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Global git credential store (host half)
|
|
3
|
-
*
|
|
4
|
-
* the panel touches, so the record lives in one file under `$DSH_HOME`
|
|
5
|
-
* (`~/.dsh`) instead of the per-repository settings document. The secret
|
|
6
|
-
* never travels to the browser — the list route answers masked tails only —
|
|
7
|
-
* and the store is read/written by the host alone.
|
|
2
|
+
* Global git credential store (host half), backed by the machine's own
|
|
3
|
+
* credential file (decision 0037).
|
|
8
4
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
5
|
+
* A credential belongs to a remote HOST, not to a project: the same
|
|
6
|
+
* `github.com` token serves every repository. Up to 1.1.x the plugin kept its
|
|
7
|
+
* own `~/.dsh/git-credentials.json`, which meant the panel's token was
|
|
8
|
+
* invisible to a plain `git` run in a shell and the machine's helpers
|
|
9
|
+
* answered instead (often with a different account). 0037 makes the panel
|
|
10
|
+
* manage the SAME file git's built-in `credential-store` helper uses —
|
|
11
|
+
* `~/.git-credentials` — so one save is visible to every git process:
|
|
12
|
+
*
|
|
13
|
+
* - the secret lines are the store format (`https://user:pass@host`),
|
|
14
|
+
* percent-encoded exactly as `git credential approve` writes them;
|
|
15
|
+
* - method/updatedAt cannot live in that format, so a sibling
|
|
16
|
+
* `<dsh-home>/git-credentials.meta.json` keeps the UI-only metadata
|
|
17
|
+
* (no secret ever goes in there);
|
|
18
|
+
* - a generated `<dsh-home>/git-credentials.include.gitconfig` scopes
|
|
19
|
+
* `helper = store` to each managed host, and `~/.gitconfig` gains one
|
|
20
|
+
* `[include] path = <that file>` line (idempotent, decision 0037
|
|
21
|
+
* overturns 0021's "never write the user's git config" for exactly this
|
|
22
|
+
* one scoped include).
|
|
23
|
+
*
|
|
24
|
+
* The secret never travels to the browser — the list route answers masked
|
|
25
|
+
* tails only — and only `https://` remotes are ever covered.
|
|
17
26
|
*/
|
|
18
|
-
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
27
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
19
28
|
import { dirname, join } from 'node:path';
|
|
20
29
|
import { resolveDshHome } from './settings.js';
|
|
21
30
|
/**
|
|
22
31
|
* The Basic-auth name git sends when a token was stored without a username.
|
|
23
32
|
* GitHub, GitLab and Gitee all accept any non-empty name for a personal access
|
|
24
|
-
* token, and `oauth2` is the conventional placeholder.
|
|
33
|
+
* token, and `oauth2` is the conventional placeholder. The store format has no
|
|
34
|
+
* "blank username", so this value is what a blank one becomes on disk and is
|
|
35
|
+
* read back as blank again.
|
|
25
36
|
*/
|
|
26
37
|
export const TOKEN_USERNAME = 'oauth2';
|
|
27
38
|
/** A credential file the store could not read or understand. */
|
|
@@ -34,13 +45,40 @@ export class CredentialStoreError extends Error {
|
|
|
34
45
|
}
|
|
35
46
|
}
|
|
36
47
|
/**
|
|
37
|
-
*
|
|
48
|
+
* The retired 0021/0029 plugin-private file `<dsh-home>/git-credentials.json`.
|
|
49
|
+
* Read once by {@link migrateLegacyCredentialFile} and deleted afterwards.
|
|
38
50
|
* @param home - user home directory seam.
|
|
39
51
|
* @param envHome - `$DSH_HOME` environment value seam (blank = unset).
|
|
40
52
|
*/
|
|
41
53
|
export function credentialFilePath(home, envHome) {
|
|
42
54
|
return join(resolveDshHome(home, envHome), 'git-credentials.json');
|
|
43
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* `~/.git-credentials` — the file git's `credential-store` helper reads and
|
|
58
|
+
* writes (`$HOME/.git-credentials` on every platform).
|
|
59
|
+
* @param home - user home directory seam.
|
|
60
|
+
*/
|
|
61
|
+
export function gitCredentialsFile(home) {
|
|
62
|
+
return join(home, '.git-credentials');
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The UI-only metadata sidecar: method and `updatedAt` per host. Never holds a
|
|
66
|
+
* secret; a missing or damaged file only costs the "how/when" columns.
|
|
67
|
+
* @param home - user home directory seam.
|
|
68
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
69
|
+
*/
|
|
70
|
+
export function credentialMetaFile(home, envHome) {
|
|
71
|
+
return join(resolveDshHome(home, envHome), 'git-credentials.meta.json');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The generated gitconfig included from `~/.gitconfig`: one URL-scoped
|
|
75
|
+
* `helper = store` section per managed host.
|
|
76
|
+
* @param home - user home directory seam.
|
|
77
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
78
|
+
*/
|
|
79
|
+
export function credentialIncludeFile(home, envHome) {
|
|
80
|
+
return join(resolveDshHome(home, envHome), 'git-credentials.include.gitconfig');
|
|
81
|
+
}
|
|
44
82
|
/**
|
|
45
83
|
* Normalize a remote host typed or pasted by the user. Accepts a bare host
|
|
46
84
|
* (`github.com`), a host with a port (`git.example.com:8443`), a full remote
|
|
@@ -126,28 +164,6 @@ export function maskSecret(secret) {
|
|
|
126
164
|
const tail = secret.slice(-4);
|
|
127
165
|
return secret.length <= 4 ? '****' : `****${tail}`;
|
|
128
166
|
}
|
|
129
|
-
/**
|
|
130
|
-
* The git config argv that authenticates network commands against the stored
|
|
131
|
-
* hosts: one `http.<https-url>.extraHeader` per host. Git applies a
|
|
132
|
-
* `http.<url>.*` section only to a matching remote URL, so injecting every
|
|
133
|
-
* stored host at once cannot leak one host's header to another; only
|
|
134
|
-
* `https://` URLs match (ssh/http never receive it).
|
|
135
|
-
* @param entries - stored credentials (duplicates collapsed by host).
|
|
136
|
-
* @returns the `-c <key>=<value>` pairs, ready to prepend to a subcommand.
|
|
137
|
-
*/
|
|
138
|
-
export function authConfigArgs(entries) {
|
|
139
|
-
// Collapse duplicates by host, LAST entry winning: a hand-edited file with
|
|
140
|
-
// two rows for one host must hand git the row the list route shows last.
|
|
141
|
-
const byHost = new Map();
|
|
142
|
-
for (const entry of entries)
|
|
143
|
-
byHost.set(entry.host, entry);
|
|
144
|
-
const args = [];
|
|
145
|
-
for (const entry of byHost.values()) {
|
|
146
|
-
const basic = Buffer.from(`${basicUsername(entry)}:${entry.secret}`, 'utf8').toString('base64');
|
|
147
|
-
args.push('-c', `http.https://${entry.host}/.extraHeader=AUTHORIZATION: basic ${basic}`);
|
|
148
|
-
}
|
|
149
|
-
return args;
|
|
150
|
-
}
|
|
151
167
|
/**
|
|
152
168
|
* Insert or update one credential; the pair `(host, updatedAt)` changes,
|
|
153
169
|
* everything else is replaced. An existing host keeps its position.
|
|
@@ -173,10 +189,96 @@ export function sortCredentials(entries) {
|
|
|
173
189
|
return [...entries].sort((left, right) => left.host.localeCompare(right.host));
|
|
174
190
|
}
|
|
175
191
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
192
|
+
* Percent-encode one userinfo component the way git's store helper does:
|
|
193
|
+
* everything outside the RFC 3986 unreserved set becomes `%xx` (lowercase
|
|
194
|
+
* hex, matching `git credential approve`). `encodeURIComponent` would leave
|
|
195
|
+
* `!'()*` alone and git would then mis-split the line at a literal `@`.
|
|
196
|
+
* @param value - raw username or secret.
|
|
197
|
+
*/
|
|
198
|
+
export function encodeCredentialPart(value) {
|
|
199
|
+
let encoded = '';
|
|
200
|
+
for (const byte of Buffer.from(value, 'utf8')) {
|
|
201
|
+
const char = String.fromCharCode(byte);
|
|
202
|
+
encoded += /[A-Za-z0-9\-._~]/.test(char) ? char : `%${byte.toString(16).padStart(2, '0')}`;
|
|
203
|
+
}
|
|
204
|
+
return encoded;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* One `<scheme>://user:pass@host` line, ready for `~/.git-credentials`.
|
|
208
|
+
* @param entry - the credential to write.
|
|
209
|
+
*/
|
|
210
|
+
export function formatCredentialLine(entry) {
|
|
211
|
+
return `https://${encodeCredentialPart(basicUsername(entry))}:${encodeCredentialPart(entry.secret)}@${entry.host}`;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Parse one `~/.git-credentials` line. Anything that is not an `https://`
|
|
215
|
+
* URL with a password is not ours (the store helper would not authenticate
|
|
216
|
+
* with it either).
|
|
217
|
+
* @param line - one non-empty line, already trimmed.
|
|
218
|
+
*/
|
|
219
|
+
function parseCredentialLine(line) {
|
|
220
|
+
let url;
|
|
221
|
+
try {
|
|
222
|
+
url = new URL(line);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
if (url.protocol !== 'https:')
|
|
228
|
+
return undefined;
|
|
229
|
+
if (url.password === '')
|
|
230
|
+
return undefined;
|
|
231
|
+
const host = normalizeCredentialHost(url.host);
|
|
232
|
+
if (host === undefined)
|
|
233
|
+
return undefined;
|
|
234
|
+
let username = url.username;
|
|
235
|
+
let secret = url.password;
|
|
236
|
+
try {
|
|
237
|
+
username = decodeURIComponent(username);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Keep the raw text; a literal `%` is legal in a username.
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
secret = decodeURIComponent(secret);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Same for the secret.
|
|
247
|
+
}
|
|
248
|
+
if (username === TOKEN_USERNAME)
|
|
249
|
+
username = '';
|
|
250
|
+
return {
|
|
251
|
+
host,
|
|
252
|
+
username,
|
|
253
|
+
secret,
|
|
254
|
+
method: username === '' ? 'token' : 'password',
|
|
255
|
+
updatedAt: 0,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Parse a `~/.git-credentials` document.
|
|
260
|
+
* @param text - raw file contents (BOM tolerated).
|
|
261
|
+
*/
|
|
262
|
+
export function parseGitCredentials(text) {
|
|
263
|
+
const byHost = new Map();
|
|
264
|
+
const others = [];
|
|
265
|
+
for (const raw of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
|
266
|
+
if (raw.trim() === '')
|
|
267
|
+
continue;
|
|
268
|
+
const entry = parseCredentialLine(raw.trim());
|
|
269
|
+
if (entry === undefined) {
|
|
270
|
+
others.push(raw);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
byHost.set(entry.host, entry);
|
|
274
|
+
}
|
|
275
|
+
return { entries: sortCredentials([...byHost.values()]), others };
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Parse the retired `<dsh-home>/git-credentials.json`. Unknown extra fields
|
|
279
|
+
* are tolerated (a hand-edited file), but a malformed entry is an error rather
|
|
280
|
+
* than a silent drop — losing a credential during the one-time migration is
|
|
281
|
+
* worse than an explicit message.
|
|
180
282
|
* @param text - raw file contents.
|
|
181
283
|
* @param path - file path (for the error message).
|
|
182
284
|
*/
|
|
@@ -213,48 +315,274 @@ export function parseCredentialFile(text, path = 'git-credentials.json') {
|
|
|
213
315
|
return sortCredentials([...byHost.values()]);
|
|
214
316
|
}
|
|
215
317
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
318
|
+
* Read the metadata sidecar. A missing, empty or damaged file reads as "no
|
|
319
|
+
* metadata": the credentials themselves still load, only the method/age
|
|
320
|
+
* columns fall back to their inferred values.
|
|
321
|
+
* @param path - absolute meta file path.
|
|
218
322
|
*/
|
|
219
|
-
|
|
220
|
-
|
|
323
|
+
async function readCredentialMeta(path) {
|
|
324
|
+
let text;
|
|
325
|
+
try {
|
|
326
|
+
text = await readFile(path, 'utf8');
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return new Map();
|
|
330
|
+
}
|
|
331
|
+
if (text.trim() === '')
|
|
332
|
+
return new Map();
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(text);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return new Map();
|
|
339
|
+
}
|
|
340
|
+
const raw = typeof parsed === 'object' && parsed !== null
|
|
341
|
+
? parsed.credentials
|
|
342
|
+
: undefined;
|
|
343
|
+
if (!Array.isArray(raw))
|
|
344
|
+
return new Map();
|
|
345
|
+
const byHost = new Map();
|
|
346
|
+
for (const item of raw) {
|
|
347
|
+
if (typeof item !== 'object' || item === null)
|
|
348
|
+
continue;
|
|
349
|
+
const record = item;
|
|
350
|
+
const host = typeof record.host === 'string' ? normalizeCredentialHost(record.host) : undefined;
|
|
351
|
+
const method = record.method;
|
|
352
|
+
if (host === undefined || (method !== 'token' && method !== 'password'))
|
|
353
|
+
continue;
|
|
354
|
+
const updatedAt = typeof record.updatedAt === 'number' && Number.isFinite(record.updatedAt) ? record.updatedAt : 0;
|
|
355
|
+
byHost.set(host, { method, updatedAt });
|
|
356
|
+
}
|
|
357
|
+
return byHost;
|
|
358
|
+
}
|
|
359
|
+
/** Serialize the metadata sidecar. */
|
|
360
|
+
function serializeCredentialMeta(entries) {
|
|
361
|
+
const document = {
|
|
362
|
+
version: 1,
|
|
363
|
+
credentials: sortCredentials(entries).map(entry => ({
|
|
364
|
+
host: entry.host,
|
|
365
|
+
method: entry.method,
|
|
366
|
+
updatedAt: entry.updatedAt,
|
|
367
|
+
})),
|
|
368
|
+
};
|
|
221
369
|
return `${JSON.stringify(document, null, 2)}\n`;
|
|
222
370
|
}
|
|
371
|
+
/** Write-then-rename so a crashed save never truncates the real file. */
|
|
372
|
+
export async function writeFileAtomic(path, text, mode) {
|
|
373
|
+
await mkdir(dirname(path), { recursive: true });
|
|
374
|
+
const temp = `${path}.${String(process.pid)}.${String(Date.now())}.tmp`;
|
|
375
|
+
try {
|
|
376
|
+
await writeFile(temp, text, { encoding: 'utf8', ...(mode === undefined ? {} : { mode }) });
|
|
377
|
+
await rename(temp, path);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Read a text file, treating "missing" as empty. */
|
|
385
|
+
async function readTextOrEmpty(path) {
|
|
386
|
+
try {
|
|
387
|
+
return await readFile(path, 'utf8');
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
if (error.code === 'ENOENT')
|
|
391
|
+
return '';
|
|
392
|
+
throw new CredentialStoreError(path, `could not be read (${error instanceof Error ? error.message : String(error)})`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
223
395
|
/**
|
|
224
|
-
* The real
|
|
225
|
-
*
|
|
226
|
-
*
|
|
396
|
+
* The real store over `~/.git-credentials`. The secret lines are the git
|
|
397
|
+
* store format; the sibling metadata file carries method/updatedAt only.
|
|
398
|
+
* A missing file reads as "no credentials yet".
|
|
399
|
+
* @param home - user home directory seam.
|
|
400
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
227
401
|
*/
|
|
228
|
-
export function
|
|
402
|
+
export function createLocalCredentialStore(home, envHome) {
|
|
403
|
+
const path = gitCredentialsFile(home);
|
|
404
|
+
const metaPath = credentialMetaFile(home, envHome);
|
|
229
405
|
return {
|
|
230
406
|
path,
|
|
231
407
|
load: async () => {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return [];
|
|
239
|
-
throw new CredentialStoreError(path, `could not be read (${error instanceof Error ? error.message : String(error)})`);
|
|
240
|
-
}
|
|
241
|
-
if (text.trim() === '')
|
|
242
|
-
return [];
|
|
243
|
-
return parseCredentialFile(text, path);
|
|
408
|
+
const { entries } = parseGitCredentials(await readTextOrEmpty(path));
|
|
409
|
+
const meta = await readCredentialMeta(metaPath);
|
|
410
|
+
return entries.map(entry => {
|
|
411
|
+
const extra = meta.get(entry.host);
|
|
412
|
+
return extra === undefined ? entry : { ...entry, method: extra.method, updatedAt: extra.updatedAt };
|
|
413
|
+
});
|
|
244
414
|
},
|
|
245
415
|
save: async (entries) => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
416
|
+
// Keep the lines we do not own (ssh/http rows, comments) so a save
|
|
417
|
+
// never rewrites somebody else's credential file.
|
|
418
|
+
const { others } = parseGitCredentials(await readTextOrEmpty(path));
|
|
419
|
+
const lines = [...others, ...sortCredentials(entries).map(formatCredentialLine)];
|
|
250
420
|
try {
|
|
251
|
-
await
|
|
252
|
-
await
|
|
421
|
+
await writeFileAtomic(path, lines.length === 0 ? '' : `${lines.join('\n')}\n`, 0o600);
|
|
422
|
+
await writeFileAtomic(metaPath, serializeCredentialMeta(entries), 0o600);
|
|
253
423
|
}
|
|
254
424
|
catch (error) {
|
|
255
|
-
await rm(temp, { force: true }).catch(() => undefined);
|
|
256
425
|
throw new CredentialStoreError(path, `could not be written (${error instanceof Error ? error.message : String(error)})`);
|
|
257
426
|
}
|
|
258
427
|
},
|
|
259
428
|
};
|
|
260
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* One-time 0037 migration: import the retired plugin-private JSON into the
|
|
432
|
+
* local store (plugin values win for a host they share), then delete the old
|
|
433
|
+
* file so there is exactly one place a secret lives.
|
|
434
|
+
*
|
|
435
|
+
* Idempotent: a missing legacy file is a no-op, and the file is removed only
|
|
436
|
+
* after the store write succeeded — a failed migration keeps the old file.
|
|
437
|
+
* @param home - user home directory seam.
|
|
438
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
439
|
+
* @param store - destination store.
|
|
440
|
+
* @returns true when a legacy file was imported and removed.
|
|
441
|
+
*/
|
|
442
|
+
export async function migrateLegacyCredentialFile(home, envHome, store) {
|
|
443
|
+
const legacy = credentialFilePath(home, envHome);
|
|
444
|
+
let text;
|
|
445
|
+
try {
|
|
446
|
+
text = await readFile(legacy, 'utf8');
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
if (error.code === 'ENOENT')
|
|
450
|
+
return false;
|
|
451
|
+
throw new CredentialStoreError(legacy, `could not be read (${error instanceof Error ? error.message : String(error)})`);
|
|
452
|
+
}
|
|
453
|
+
const imported = text.trim() === '' ? [] : parseCredentialFile(text, legacy);
|
|
454
|
+
if (imported.length > 0) {
|
|
455
|
+
const current = await store.load();
|
|
456
|
+
const merged = imported.reduce((entries, entry) => upsertCredential(entries, entry), current);
|
|
457
|
+
await store.save(merged);
|
|
458
|
+
}
|
|
459
|
+
await rm(legacy, { force: true });
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The generated include file's contents: one URL-scoped section per host that
|
|
464
|
+
* resets any inherited helper for that exact URL and then installs the
|
|
465
|
+
* built-in `store` helper. The empty `helper =` line is what beats a
|
|
466
|
+
* user-level helper (`gh`, GCM) configured for the same host.
|
|
467
|
+
* @param hosts - managed remote hosts (already normalized).
|
|
468
|
+
*/
|
|
469
|
+
export function credentialIncludeBody(hosts) {
|
|
470
|
+
const lines = [
|
|
471
|
+
'# Generated by dsh-git-branch (decision 0037). Do not edit:',
|
|
472
|
+
'# every credential save rewrites this file.',
|
|
473
|
+
];
|
|
474
|
+
for (const host of [...new Set(hosts)].sort()) {
|
|
475
|
+
lines.push('', `[credential "https://${host}/"]`, '\thelper =', '\thelper = store');
|
|
476
|
+
}
|
|
477
|
+
return `${lines.join('\n')}\n`;
|
|
478
|
+
}
|
|
479
|
+
/** Compare two config paths the way git resolves them (case-insensitive on
|
|
480
|
+
* Windows in practice; slashes and surrounding quotes normalized here). */
|
|
481
|
+
function normalizeConfigPath(value) {
|
|
482
|
+
return value
|
|
483
|
+
.trim()
|
|
484
|
+
.replace(/^"(.*)"$/, '$1')
|
|
485
|
+
.replace(/\\/g, '/')
|
|
486
|
+
.replace(/\/+$/, '')
|
|
487
|
+
.toLowerCase();
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Whether `~/.gitconfig` already includes the generated file. Matched loosely
|
|
491
|
+
* (any `path = <X>` line pointing at it) so a hand-reformatted include — or
|
|
492
|
+
* one with forward slashes on Windows — is not added a second time.
|
|
493
|
+
* @param text - current `~/.gitconfig` contents.
|
|
494
|
+
* @param includePath - the generated file.
|
|
495
|
+
*/
|
|
496
|
+
export function gitconfigHasInclude(text, includePath) {
|
|
497
|
+
const wanted = normalizeConfigPath(includePath);
|
|
498
|
+
for (const line of text.split(/\r?\n/)) {
|
|
499
|
+
const match = /^\s*path\s*=\s*(.*)$/i.exec(line);
|
|
500
|
+
if (match !== null && normalizeConfigPath(match[1] ?? '') === wanted)
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Append the `[include] path = …` section, leaving every existing byte alone.
|
|
507
|
+
* @param text - current `~/.gitconfig` contents.
|
|
508
|
+
* @param includePath - the generated file.
|
|
509
|
+
*/
|
|
510
|
+
export function appendGitconfigInclude(text, includePath) {
|
|
511
|
+
const body = `[include]\n\tpath = ${includePath.replace(/\\/g, '/')}\n`;
|
|
512
|
+
if (text.trim() === '')
|
|
513
|
+
return body;
|
|
514
|
+
return text.endsWith('\n') ? `${text}${body}` : `${text}\n${body}`;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Idempotently ensure `~/.gitconfig` includes the generated file. Rewrites
|
|
518
|
+
* atomically, preserving the file's existing mode; a missing file is created.
|
|
519
|
+
* @param gitconfigPath - user global config (`<home>/.gitconfig`).
|
|
520
|
+
* @param includePath - the generated file.
|
|
521
|
+
* @returns true when the file had to be changed.
|
|
522
|
+
*/
|
|
523
|
+
export async function ensureGitconfigInclude(gitconfigPath, includePath) {
|
|
524
|
+
let text = '';
|
|
525
|
+
let mode;
|
|
526
|
+
try {
|
|
527
|
+
text = await readFile(gitconfigPath, 'utf8');
|
|
528
|
+
mode = (await stat(gitconfigPath)).mode & 0o777;
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
if (error.code !== 'ENOENT')
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
if (gitconfigHasInclude(text, includePath))
|
|
535
|
+
return false;
|
|
536
|
+
await writeFileAtomic(gitconfigPath, appendGitconfigInclude(text, includePath), mode);
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Remove the include section pointing at the generated file (plugin teardown).
|
|
541
|
+
* Every other line is preserved verbatim.
|
|
542
|
+
* @param text - current `~/.gitconfig` contents.
|
|
543
|
+
* @param includePath - the generated file.
|
|
544
|
+
*/
|
|
545
|
+
export function stripGitconfigInclude(text, includePath) {
|
|
546
|
+
const wanted = normalizeConfigPath(includePath);
|
|
547
|
+
const lines = text.split(/\r?\n/);
|
|
548
|
+
const kept = [];
|
|
549
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
550
|
+
const line = lines[index] ?? '';
|
|
551
|
+
if (/^\s*\[include\]\s*$/i.test(line)) {
|
|
552
|
+
let end = index + 1;
|
|
553
|
+
let matched = false;
|
|
554
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end] ?? '')) {
|
|
555
|
+
const match = /^\s*path\s*=\s*(.*)$/i.exec(lines[end] ?? '');
|
|
556
|
+
if (match !== null && normalizeConfigPath(match[1] ?? '') === wanted)
|
|
557
|
+
matched = true;
|
|
558
|
+
end += 1;
|
|
559
|
+
}
|
|
560
|
+
if (matched) {
|
|
561
|
+
index = end - 1;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
kept.push(line);
|
|
566
|
+
}
|
|
567
|
+
return kept.join('\n');
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Remove the generated include from `~/.gitconfig`. Best effort: a teardown
|
|
571
|
+
* must not throw, and an already-clean file is left untouched.
|
|
572
|
+
* @param gitconfigPath - user global config (`<home>/.gitconfig`).
|
|
573
|
+
* @param includePath - the generated file.
|
|
574
|
+
*/
|
|
575
|
+
export async function removeGitconfigInclude(gitconfigPath, includePath) {
|
|
576
|
+
let text;
|
|
577
|
+
let mode;
|
|
578
|
+
try {
|
|
579
|
+
text = await readFile(gitconfigPath, 'utf8');
|
|
580
|
+
mode = (await stat(gitconfigPath)).mode & 0o777;
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (!gitconfigHasInclude(text, includePath))
|
|
586
|
+
return;
|
|
587
|
+
await writeFileAtomic(gitconfigPath, stripGitconfigInclude(text, includePath), mode);
|
|
588
|
+
}
|