dsh-git-branch-manage 1.0.7 → 1.0.8
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 +22 -2
- 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
package/lib/routes.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
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 { authConfigArgs, credentialFilePath, createCredentialStore, credentialHostIssue, credentialSecretIssue, credentialUsernameIssue, maskSecret, normalizeCredentialHost, removeCredential, sortCredentials, upsertCredential } from './credentials.js';
|
|
9
|
+
import { appendWorktreeExclude, fsExcludeSeams, GitError, addRemote, addWorktree, addWorktreeCutout, collectAiDiff, commitFiles, commitIndex, createBranch, createTag, cutoutBranchName, deleteBranch, fetchAll, fsDirExists, inspectWorktree, isAbsoluteDir, isStageablePath, listRemotes, listTagNames, logHistory, probeRepo, probeWorkspaceGit, pushTag, removeRemote, removeWorktree, renameBranch, resolveBranch, resolvePushRemote, pushBranch, setRemoteUrl, stagePaths, switchBranch, updateBranch, workingChanges } from './git.js';
|
|
10
|
+
import { ACCEL_DEFAULT_URL, accelerateUrlError, isAbsoluteConfigPath, normalizeAccelerateUrl, remoteNameIssue, remoteUrlIssue, sanitizeBranchDir } from './normalize.js';
|
|
11
|
+
import { deviceFlowClientId, isDeviceFlowHost, pollDeviceFlow, startDeviceFlow } from './oauth.js';
|
|
10
12
|
import { resolveRootDir } from './settings.js';
|
|
11
13
|
import { COMMIT_MESSAGE_LIMIT } from './wire.js';
|
|
12
14
|
/** Real fs-backed one-level directory listing (names only). Plain FILES are
|
|
@@ -51,6 +53,26 @@ function accelPrefix(deps) {
|
|
|
51
53
|
const url = deps.sectionCnAccelerateUrl?.() ?? ACCEL_DEFAULT_URL;
|
|
52
54
|
return accelerateUrlError(url) === undefined ? normalizeAccelerateUrl(url) : undefined;
|
|
53
55
|
}
|
|
56
|
+
/** The global credential store: the wired one, else the fs-backed file at the
|
|
57
|
+
* resolved dsh home (`<dshHome>/git-credentials.json`). */
|
|
58
|
+
function credentialStore(deps) {
|
|
59
|
+
return deps.credentialsStore ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `-c http.<url>.extraHeader` pairs for every stored credential, ready to
|
|
63
|
+
* prepend to a git network command. A broken credential FILE must never break
|
|
64
|
+
* git itself: an unreadable store degrades to "no credentials" (the network
|
|
65
|
+
* command then runs unauthenticated, as it always did) — the credentials
|
|
66
|
+
* route is where the file problem surfaces.
|
|
67
|
+
*/
|
|
68
|
+
async function authArgsFor(deps) {
|
|
69
|
+
try {
|
|
70
|
+
return authConfigArgs(await credentialStore(deps).load());
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
54
76
|
/** Upper bound on distinct paths one /group request may probe. */
|
|
55
77
|
const GROUP_PATHS_LIMIT = 256;
|
|
56
78
|
/** Probes per in-flight batch — polite on Windows, where each git call is a
|
|
@@ -426,7 +448,7 @@ export async function handleCreateWorktree(deps, body) {
|
|
|
426
448
|
const resolved = await resolveBranch(deps.exec, facts.repoRoot, branch);
|
|
427
449
|
if (resolved?.remote !== undefined) {
|
|
428
450
|
try {
|
|
429
|
-
await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps));
|
|
451
|
+
await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps), await authArgsFor(deps));
|
|
430
452
|
}
|
|
431
453
|
catch (error) {
|
|
432
454
|
fetchWarning = error instanceof GitError ? error.stderr.trim() : error instanceof Error ? error.message : String(error);
|
|
@@ -661,7 +683,7 @@ export async function handleFetch(deps, body) {
|
|
|
661
683
|
const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
|
|
662
684
|
if (facts === undefined)
|
|
663
685
|
return fail(400, `"${repoPath}" is not inside a git repository`);
|
|
664
|
-
await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps));
|
|
686
|
+
await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps), await authArgsFor(deps));
|
|
665
687
|
const result = { remote: 'all' };
|
|
666
688
|
return { status: 200, body: result };
|
|
667
689
|
}
|
|
@@ -694,7 +716,7 @@ export async function handleUpdate(deps, body) {
|
|
|
694
716
|
// session "update current branch" means the branch that worktree holds.
|
|
695
717
|
// `repoRoot` would fast-forward the main checkout behind the user's back
|
|
696
718
|
// (both are the same directory when the session IS the main checkout).
|
|
697
|
-
const outcome = await updateBranch(deps.exec, facts.toplevel, accelPrefix(deps));
|
|
719
|
+
const outcome = await updateBranch(deps.exec, facts.toplevel, accelPrefix(deps), await authArgsFor(deps));
|
|
698
720
|
const result = { branch: outcome.branch, updated: outcome.updated };
|
|
699
721
|
return { status: 200, body: result };
|
|
700
722
|
}
|
|
@@ -724,7 +746,7 @@ export async function handlePush(deps, body) {
|
|
|
724
746
|
return fail(400, `"${repoPath}" is not inside a git repository`);
|
|
725
747
|
// The session's OWN worktree, not the main checkout: in a linked-worktree
|
|
726
748
|
// session "push current branch" means the branch that worktree holds.
|
|
727
|
-
const outcome = await pushBranch(deps.exec, facts.toplevel);
|
|
749
|
+
const outcome = await pushBranch(deps.exec, facts.toplevel, await authArgsFor(deps));
|
|
728
750
|
const result = { branch: outcome.branch, pushed: outcome.pushed };
|
|
729
751
|
return { status: 200, body: result };
|
|
730
752
|
}
|
|
@@ -1156,7 +1178,7 @@ export async function handlePushTag(deps, body) {
|
|
|
1156
1178
|
const choice = await resolvePushRemote(deps.exec, facts.toplevel);
|
|
1157
1179
|
if (!choice.ok)
|
|
1158
1180
|
return fail(400, choice.message);
|
|
1159
|
-
const result = await pushTag(deps.exec, facts.toplevel, name, choice.remote);
|
|
1181
|
+
const result = await pushTag(deps.exec, facts.toplevel, name, choice.remote, await authArgsFor(deps));
|
|
1160
1182
|
const body = result;
|
|
1161
1183
|
return { status: 200, body };
|
|
1162
1184
|
}
|
|
@@ -1252,3 +1274,333 @@ export async function handleAiCommitMessage(deps, llm, body) {
|
|
|
1252
1274
|
return checkoutFailure(error);
|
|
1253
1275
|
}
|
|
1254
1276
|
}
|
|
1277
|
+
/** Masked browser view: the stored secret never crosses the wire again. */
|
|
1278
|
+
function credentialView(entry) {
|
|
1279
|
+
return {
|
|
1280
|
+
host: entry.host,
|
|
1281
|
+
username: entry.username,
|
|
1282
|
+
method: entry.method,
|
|
1283
|
+
updatedAt: entry.updatedAt,
|
|
1284
|
+
secretMask: maskSecret(entry.secret),
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
/** Credential-store failures are host-side state (an unreadable/corrupt file,
|
|
1288
|
+
* a failed write), so they answer 500 with the file's own explanation. */
|
|
1289
|
+
function storeFailure(error) {
|
|
1290
|
+
return fail(500, error instanceof Error ? error.message : String(error));
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* POST /credentials — every stored credential as a MASKED row. One row per
|
|
1294
|
+
* host: the store is global (not per repository), so the panel edits one
|
|
1295
|
+
* address for github.com, gitlab.com, a self-hosted gitea alike.
|
|
1296
|
+
* @param deps - host dependencies.
|
|
1297
|
+
* @param body - parsed request body: absent or `{}`.
|
|
1298
|
+
*/
|
|
1299
|
+
export async function handleCredentials(deps, body) {
|
|
1300
|
+
const parsed = readBody(body ?? {}, []);
|
|
1301
|
+
if (isOutcome(parsed))
|
|
1302
|
+
return parsed;
|
|
1303
|
+
try {
|
|
1304
|
+
const entries = await credentialStore(deps).load();
|
|
1305
|
+
const result = { credentials: sortCredentials(entries).map(credentialView) };
|
|
1306
|
+
return { status: 200, body: result };
|
|
1307
|
+
}
|
|
1308
|
+
catch (error) {
|
|
1309
|
+
return storeFailure(error);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* POST /credentials-save — create or update the credential of ONE host. A
|
|
1314
|
+
* blank `secret` on an existing host keeps the stored secret (the browser
|
|
1315
|
+
* only ever had the mask), while a new host requires one.
|
|
1316
|
+
* @param deps - host dependencies.
|
|
1317
|
+
* @param body - parsed request body: `{ host, username, method, secret? }`.
|
|
1318
|
+
*/
|
|
1319
|
+
export async function handleCredentialsSave(deps, body) {
|
|
1320
|
+
const parsed = readBody(body, ['host', 'username', 'method'], [], ['secret']);
|
|
1321
|
+
if (isOutcome(parsed))
|
|
1322
|
+
return parsed;
|
|
1323
|
+
const host = normalizeCredentialHost(parsed.host);
|
|
1324
|
+
if (host === undefined) {
|
|
1325
|
+
return fail(400, credentialHostIssue(parsed.host) === 'empty'
|
|
1326
|
+
? '"host" must be non-empty'
|
|
1327
|
+
: '"host" must be a remote host such as github.com');
|
|
1328
|
+
}
|
|
1329
|
+
if (parsed.method !== 'token' && parsed.method !== 'password') {
|
|
1330
|
+
return fail(400, '"method" must be "token" or "password"');
|
|
1331
|
+
}
|
|
1332
|
+
const username = parsed.username.trim();
|
|
1333
|
+
const usernameIssue = credentialUsernameIssue(username, parsed.method);
|
|
1334
|
+
if (usernameIssue !== undefined) {
|
|
1335
|
+
return fail(400, usernameIssue === 'empty'
|
|
1336
|
+
? '"username" is required for "password" credentials'
|
|
1337
|
+
: '"username" must not contain control characters');
|
|
1338
|
+
}
|
|
1339
|
+
try {
|
|
1340
|
+
const store = credentialStore(deps);
|
|
1341
|
+
const entries = await store.load();
|
|
1342
|
+
const existing = entries.find(entry => entry.host === host);
|
|
1343
|
+
const typed = parsed.secret === undefined ? '' : parsed.secret.trim();
|
|
1344
|
+
let secret;
|
|
1345
|
+
if (typed !== '')
|
|
1346
|
+
secret = typed;
|
|
1347
|
+
else if (existing !== undefined)
|
|
1348
|
+
secret = existing.secret;
|
|
1349
|
+
else
|
|
1350
|
+
return fail(400, '"secret" is required for a new host');
|
|
1351
|
+
const secretIssue = credentialSecretIssue(secret);
|
|
1352
|
+
if (secretIssue !== undefined) {
|
|
1353
|
+
return fail(400, secretIssue === 'empty'
|
|
1354
|
+
? '"secret" must be non-empty'
|
|
1355
|
+
: '"secret" must not contain control characters');
|
|
1356
|
+
}
|
|
1357
|
+
const next = upsertCredential(entries, {
|
|
1358
|
+
host,
|
|
1359
|
+
username,
|
|
1360
|
+
secret,
|
|
1361
|
+
method: parsed.method,
|
|
1362
|
+
updatedAt: Date.now(),
|
|
1363
|
+
});
|
|
1364
|
+
await store.save(next);
|
|
1365
|
+
const result = { credentials: sortCredentials(next).map(credentialView) };
|
|
1366
|
+
return { status: 200, body: result };
|
|
1367
|
+
}
|
|
1368
|
+
catch (error) {
|
|
1369
|
+
return storeFailure(error);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* POST /credentials-delete — forget ONE host's credential. The other hosts
|
|
1374
|
+
* keep theirs: deletion is per host, never a store reset.
|
|
1375
|
+
* @param deps - host dependencies.
|
|
1376
|
+
* @param body - parsed request body: `{ host }`.
|
|
1377
|
+
*/
|
|
1378
|
+
export async function handleCredentialsDelete(deps, body) {
|
|
1379
|
+
const parsed = readBody(body, ['host']);
|
|
1380
|
+
if (isOutcome(parsed))
|
|
1381
|
+
return parsed;
|
|
1382
|
+
const host = normalizeCredentialHost(parsed.host);
|
|
1383
|
+
if (host === undefined)
|
|
1384
|
+
return fail(400, '"host" must be a remote host such as github.com');
|
|
1385
|
+
try {
|
|
1386
|
+
const store = credentialStore(deps);
|
|
1387
|
+
const entries = await store.load();
|
|
1388
|
+
const next = removeCredential(entries, host);
|
|
1389
|
+
if (next.length === entries.length)
|
|
1390
|
+
return fail(400, `no credential is stored for "${host}"`);
|
|
1391
|
+
await store.save(next);
|
|
1392
|
+
const result = { credentials: sortCredentials(next).map(credentialView) };
|
|
1393
|
+
return { status: 200, body: result };
|
|
1394
|
+
}
|
|
1395
|
+
catch (error) {
|
|
1396
|
+
return storeFailure(error);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
/** The HTTP seam: the wired fake in tests, global fetch in production. */
|
|
1400
|
+
function fetchFor(deps) {
|
|
1401
|
+
return deps.fetchImpl ?? globalThis.fetch;
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* POST /credentials-oauth-start — start a browser sign-in for ONE host
|
|
1405
|
+
* (GitHub device flow). The answer carries a one-time user code and the
|
|
1406
|
+
* polling credential; the dialog opens the verification page and polls
|
|
1407
|
+
* /credentials-oauth-poll until the user approves.
|
|
1408
|
+
* @param deps - host dependencies.
|
|
1409
|
+
* @param body - parsed request body: `{ host }`.
|
|
1410
|
+
*/
|
|
1411
|
+
export async function handleCredentialsOAuthStart(deps, body) {
|
|
1412
|
+
const parsed = readBody(body, ['host']);
|
|
1413
|
+
if (isOutcome(parsed))
|
|
1414
|
+
return parsed;
|
|
1415
|
+
const host = normalizeCredentialHost(parsed.host);
|
|
1416
|
+
if (host === undefined) {
|
|
1417
|
+
return fail(400, credentialHostIssue(parsed.host) === 'empty'
|
|
1418
|
+
? '"host" must be non-empty'
|
|
1419
|
+
: '"host" must be a remote host such as github.com');
|
|
1420
|
+
}
|
|
1421
|
+
if (!isDeviceFlowHost(host))
|
|
1422
|
+
return fail(400, `browser sign-in is not available for "${host}"`);
|
|
1423
|
+
try {
|
|
1424
|
+
const grant = await startDeviceFlow(fetchFor(deps), host, deviceFlowClientId());
|
|
1425
|
+
const result = {
|
|
1426
|
+
userCode: grant.userCode,
|
|
1427
|
+
verificationUri: grant.verificationUri,
|
|
1428
|
+
...(grant.verificationUriComplete === undefined ? {} : { verificationUriComplete: grant.verificationUriComplete }),
|
|
1429
|
+
deviceCode: grant.deviceCode,
|
|
1430
|
+
intervalSeconds: grant.intervalSeconds,
|
|
1431
|
+
expiresInSeconds: grant.expiresInSeconds,
|
|
1432
|
+
};
|
|
1433
|
+
return { status: 200, body: result };
|
|
1434
|
+
}
|
|
1435
|
+
catch (error) {
|
|
1436
|
+
return fail(502, `could not start the browser sign-in: ${error instanceof Error ? error.message : String(error)}`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* POST /credentials-oauth-poll — one poll of a started browser sign-in.
|
|
1441
|
+
* A pending grant answers `{ status: 'pending' }` so the dialog keeps
|
|
1442
|
+
* waiting; an approved grant is stored as a `token` credential for the host
|
|
1443
|
+
* (the token itself never crosses the wire again) and the fresh masked list
|
|
1444
|
+
* comes back.
|
|
1445
|
+
* @param deps - host dependencies.
|
|
1446
|
+
* @param body - parsed request body: `{ host, deviceCode }`.
|
|
1447
|
+
*/
|
|
1448
|
+
export async function handleCredentialsOAuthPoll(deps, body) {
|
|
1449
|
+
const parsed = readBody(body, ['host', 'deviceCode']);
|
|
1450
|
+
if (isOutcome(parsed))
|
|
1451
|
+
return parsed;
|
|
1452
|
+
const host = normalizeCredentialHost(parsed.host);
|
|
1453
|
+
if (host === undefined)
|
|
1454
|
+
return fail(400, '"host" must be a remote host such as github.com');
|
|
1455
|
+
if (!isDeviceFlowHost(host))
|
|
1456
|
+
return fail(400, `browser sign-in is not available for "${host}"`);
|
|
1457
|
+
const deviceCode = parsed.deviceCode.trim();
|
|
1458
|
+
if (deviceCode === '')
|
|
1459
|
+
return fail(400, '"deviceCode" must be non-empty');
|
|
1460
|
+
let poll;
|
|
1461
|
+
try {
|
|
1462
|
+
poll = await pollDeviceFlow(fetchFor(deps), host, deviceFlowClientId(), deviceCode);
|
|
1463
|
+
}
|
|
1464
|
+
catch (error) {
|
|
1465
|
+
return fail(502, `could not poll the browser sign-in: ${error instanceof Error ? error.message : String(error)}`);
|
|
1466
|
+
}
|
|
1467
|
+
if (poll.status === 'pending') {
|
|
1468
|
+
const waiting = { status: 'pending' };
|
|
1469
|
+
return { status: 200, body: waiting };
|
|
1470
|
+
}
|
|
1471
|
+
if (poll.status === 'slow_down') {
|
|
1472
|
+
const backoff = { status: 'slow_down' };
|
|
1473
|
+
return { status: 200, body: backoff };
|
|
1474
|
+
}
|
|
1475
|
+
if (poll.status === 'network') {
|
|
1476
|
+
// Not a failure: the grant is untouched, so the dialog retries instead of
|
|
1477
|
+
// throwing away a sign-in the user may already have approved. The client
|
|
1478
|
+
// decides when repeated failures become an error.
|
|
1479
|
+
const retry = { status: 'network', message: poll.message };
|
|
1480
|
+
return { status: 200, body: retry };
|
|
1481
|
+
}
|
|
1482
|
+
if (poll.status !== 'token')
|
|
1483
|
+
return fail(400, poll.message);
|
|
1484
|
+
const secretIssue = credentialSecretIssue(poll.token);
|
|
1485
|
+
if (secretIssue !== undefined)
|
|
1486
|
+
return fail(502, 'the sign-in returned an unusable token');
|
|
1487
|
+
try {
|
|
1488
|
+
const store = credentialStore(deps);
|
|
1489
|
+
const entries = await store.load();
|
|
1490
|
+
const next = upsertCredential(entries, {
|
|
1491
|
+
host,
|
|
1492
|
+
username: '',
|
|
1493
|
+
secret: poll.token,
|
|
1494
|
+
method: 'token',
|
|
1495
|
+
updatedAt: Date.now(),
|
|
1496
|
+
});
|
|
1497
|
+
await store.save(next);
|
|
1498
|
+
const done = { status: 'done', credentials: sortCredentials(next).map(credentialView) };
|
|
1499
|
+
return { status: 200, body: done };
|
|
1500
|
+
}
|
|
1501
|
+
catch (error) {
|
|
1502
|
+
return storeFailure(error);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
/** Human message for a rejected remote NAME. */
|
|
1506
|
+
function remoteNameMessage(issue) {
|
|
1507
|
+
if (issue === 'empty')
|
|
1508
|
+
return '"name" must be non-empty';
|
|
1509
|
+
if (issue === 'leadingDash')
|
|
1510
|
+
return '"name" must not start with "-"';
|
|
1511
|
+
return '"name" may only contain letters, digits, ".", "_" and "-"';
|
|
1512
|
+
}
|
|
1513
|
+
/** Human message for a rejected remote URL. */
|
|
1514
|
+
function remoteUrlMessage(issue) {
|
|
1515
|
+
if (issue === 'empty')
|
|
1516
|
+
return '"url" must be non-empty';
|
|
1517
|
+
if (issue === 'whitespace')
|
|
1518
|
+
return '"url" must not contain whitespace';
|
|
1519
|
+
return '"url" must be an address git accepts (https://..., git@host:path or an absolute path)';
|
|
1520
|
+
}
|
|
1521
|
+
/** Actions one /remote call may take. */
|
|
1522
|
+
const REMOTE_ACTIONS = ['add', 'set-url', 'remove'];
|
|
1523
|
+
/**
|
|
1524
|
+
* POST /remotes — the repository's configured remotes with their fetch and
|
|
1525
|
+
* push URLs. Remotes belong to the repository, not to a worktree, so any
|
|
1526
|
+
* directory inside it answers the full list.
|
|
1527
|
+
* @param deps - host dependencies.
|
|
1528
|
+
* @param body - parsed request body: `{ repoPath }`.
|
|
1529
|
+
*/
|
|
1530
|
+
export async function handleRemotes(deps, body) {
|
|
1531
|
+
const parsed = readBody(body, ['repoPath']);
|
|
1532
|
+
if (isOutcome(parsed))
|
|
1533
|
+
return parsed;
|
|
1534
|
+
const { repoPath } = parsed;
|
|
1535
|
+
if (!isAbsoluteDir(repoPath))
|
|
1536
|
+
return fail(400, '"repoPath" must be an absolute directory');
|
|
1537
|
+
try {
|
|
1538
|
+
const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
|
|
1539
|
+
if (facts === undefined)
|
|
1540
|
+
return fail(400, `"${repoPath}" is not inside a git repository`);
|
|
1541
|
+
const remotes = await listRemotes(deps.exec, facts.repoRoot);
|
|
1542
|
+
const result = { remotes };
|
|
1543
|
+
return { status: 200, body: result };
|
|
1544
|
+
}
|
|
1545
|
+
catch (error) {
|
|
1546
|
+
if (error instanceof GitError)
|
|
1547
|
+
return gitFailure(error);
|
|
1548
|
+
return fail(500, error instanceof Error ? error.message : String(error));
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* POST /remote — add, re-point or remove ONE remote, then answer the fresh
|
|
1553
|
+
* list. Existence is checked against `git remote -v` first, so a duplicate
|
|
1554
|
+
* `add` and a missing `set-url`/`remove` answer a clean 400 instead of git's
|
|
1555
|
+
* stderr; the URL is pre-flighted by {@link remoteUrlIssue}.
|
|
1556
|
+
* @param deps - host dependencies.
|
|
1557
|
+
* @param body - parsed request body: `{ repoPath, action, name, url? }`.
|
|
1558
|
+
*/
|
|
1559
|
+
export async function handleRemote(deps, body) {
|
|
1560
|
+
const parsed = readBody(body, ['repoPath', 'action', 'name'], [], ['url']);
|
|
1561
|
+
if (isOutcome(parsed))
|
|
1562
|
+
return parsed;
|
|
1563
|
+
const { repoPath, action, name } = parsed;
|
|
1564
|
+
if (!isAbsoluteDir(repoPath))
|
|
1565
|
+
return fail(400, '"repoPath" must be an absolute directory');
|
|
1566
|
+
if (!REMOTE_ACTIONS.includes(action))
|
|
1567
|
+
return fail(400, '"action" must be "add", "set-url" or "remove"');
|
|
1568
|
+
const nameIssue = remoteNameIssue(name);
|
|
1569
|
+
if (nameIssue !== null)
|
|
1570
|
+
return fail(400, remoteNameMessage(nameIssue));
|
|
1571
|
+
const url = parsed.url === undefined ? '' : parsed.url.trim();
|
|
1572
|
+
if (action !== 'remove') {
|
|
1573
|
+
const urlIssue = remoteUrlIssue(url);
|
|
1574
|
+
if (urlIssue !== undefined)
|
|
1575
|
+
return fail(400, remoteUrlMessage(urlIssue));
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
|
|
1579
|
+
if (facts === undefined)
|
|
1580
|
+
return fail(400, `"${repoPath}" is not inside a git repository`);
|
|
1581
|
+
const before = await listRemotes(deps.exec, facts.repoRoot);
|
|
1582
|
+
const exists = before.some(remote => remote.name === name);
|
|
1583
|
+
if (action === 'add') {
|
|
1584
|
+
if (exists)
|
|
1585
|
+
return fail(400, `remote "${name}" already exists`);
|
|
1586
|
+
await addRemote(deps.exec, facts.repoRoot, name, url);
|
|
1587
|
+
}
|
|
1588
|
+
else if (!exists) {
|
|
1589
|
+
return fail(400, `remote "${name}" does not exist`);
|
|
1590
|
+
}
|
|
1591
|
+
else if (action === 'set-url') {
|
|
1592
|
+
await setRemoteUrl(deps.exec, facts.repoRoot, name, url);
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
await removeRemote(deps.exec, facts.repoRoot, name);
|
|
1596
|
+
}
|
|
1597
|
+
const remotes = await listRemotes(deps.exec, facts.repoRoot);
|
|
1598
|
+
const result = { remotes };
|
|
1599
|
+
return { status: 200, body: result };
|
|
1600
|
+
}
|
|
1601
|
+
catch (error) {
|
|
1602
|
+
if (error instanceof GitError)
|
|
1603
|
+
return gitFailure(error);
|
|
1604
|
+
return fail(500, error instanceof Error ? error.message : String(error));
|
|
1605
|
+
}
|
|
1606
|
+
}
|
package/lib/settings.d.ts
CHANGED
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
* @returns an absolute directory path.
|
|
17
17
|
*/
|
|
18
18
|
export declare function resolveRootDir(rootDir: string | undefined, home: string, envHome: string | undefined): string;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the dsh configuration home: `$DSH_HOME` when set (a blank value
|
|
21
|
+
* counts as unset), else `~/.dsh`. Host-owned files that are NOT part of the
|
|
22
|
+
* settings document (the global git credential store) live directly here.
|
|
23
|
+
* @param home - user home directory (`os.homedir()` seam).
|
|
24
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
25
|
+
* @returns an absolute directory path.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveDshHome(home: string, envHome: string | undefined): string;
|
|
19
28
|
/**
|
|
20
29
|
* Reject a stored rootDir the plugin could not act on: a non-blank value that
|
|
21
30
|
* is not an absolute path. Blank/absent means the default location and passes.
|
package/lib/settings.js
CHANGED
|
@@ -21,8 +21,18 @@ export function resolveRootDir(rootDir, home, envHome) {
|
|
|
21
21
|
const configured = rootDir?.trim();
|
|
22
22
|
if (configured !== undefined && configured !== '')
|
|
23
23
|
return configured;
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
return join(resolveDshHome(home, envHome), 'gitworktree');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the dsh configuration home: `$DSH_HOME` when set (a blank value
|
|
28
|
+
* counts as unset), else `~/.dsh`. Host-owned files that are NOT part of the
|
|
29
|
+
* settings document (the global git credential store) live directly here.
|
|
30
|
+
* @param home - user home directory (`os.homedir()` seam).
|
|
31
|
+
* @param envHome - `$DSH_HOME` environment value seam.
|
|
32
|
+
* @returns an absolute directory path.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveDshHome(home, envHome) {
|
|
35
|
+
return typeof envHome === 'string' && envHome.trim() !== '' ? envHome : join(home, '.dsh');
|
|
26
36
|
}
|
|
27
37
|
/**
|
|
28
38
|
* Reject a stored rootDir the plugin could not act on: a non-blank value that
|
package/lib/tool.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing `git_credentials` tool (host half).
|
|
3
|
+
*
|
|
4
|
+
* The credential file is global, so a session that pushes to a new host often
|
|
5
|
+
* needs to know which hosts are already configured before asking the user to
|
|
6
|
+
* paste a token again. Hence one read-only tool over the same store the routes
|
|
7
|
+
* use: it lists hosts, methods and masked secrets, and never writes.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here imports `@deepseek-ai/dsh-tools`: a plain definition object
|
|
10
|
+
* registered through `ctx.tools.register` is the documented path for a plugin
|
|
11
|
+
* that must not take a value dependency on the host runtime. The registry
|
|
12
|
+
* validates `output.schema` and passes model arguments through verbatim, so
|
|
13
|
+
* this module validates its own arguments.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-git-branch/tool
|
|
16
|
+
*/
|
|
17
|
+
import { type CredentialStore } from './credentials.js';
|
|
18
|
+
/** Model-facing tool name. */
|
|
19
|
+
export declare const GIT_CREDENTIALS_TOOL_NAME = "git_credentials";
|
|
20
|
+
/** One text block in a tool result. */
|
|
21
|
+
export interface ToolTextBlock {
|
|
22
|
+
readonly type: 'text';
|
|
23
|
+
readonly text: string;
|
|
24
|
+
}
|
|
25
|
+
/** The arguments this tool accepts. */
|
|
26
|
+
export interface GitCredentialsArgs {
|
|
27
|
+
/** Restrict the listing to one host (`github.com`); omitted lists all. */
|
|
28
|
+
readonly host?: string;
|
|
29
|
+
/** Return stored secrets verbatim instead of masking them. */
|
|
30
|
+
readonly includeSecret?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** One credential row as the model sees it. */
|
|
33
|
+
export interface GitCredentialsRow {
|
|
34
|
+
readonly host: string;
|
|
35
|
+
readonly username: string;
|
|
36
|
+
readonly method: string;
|
|
37
|
+
/** Masked tail, or the secret verbatim when `includeSecret` was set. */
|
|
38
|
+
readonly secret: string;
|
|
39
|
+
/** ISO-8601 timestamp of the last save (empty when unknown). */
|
|
40
|
+
readonly updatedAt: string;
|
|
41
|
+
}
|
|
42
|
+
/** The canonical value `execute` returns. */
|
|
43
|
+
export interface GitCredentialsValue {
|
|
44
|
+
/** Absolute credential file the rows come from. */
|
|
45
|
+
readonly path: string;
|
|
46
|
+
/** Total number of configured hosts, before `host` filtering. */
|
|
47
|
+
readonly total: number;
|
|
48
|
+
/** The rows, sorted by host. */
|
|
49
|
+
readonly credentials: readonly GitCredentialsRow[];
|
|
50
|
+
/** Why the store could not be read; rows are then empty. */
|
|
51
|
+
readonly error?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Host-side seams the tool reads through. */
|
|
54
|
+
export interface GitCredentialsToolDeps {
|
|
55
|
+
/** User home directory seam. */
|
|
56
|
+
readonly home: () => string;
|
|
57
|
+
/** `$DSH_HOME` seam (blank/undefined = unset). */
|
|
58
|
+
readonly envHome: () => string | undefined;
|
|
59
|
+
/** Store seam; the fs-backed store at the resolved path when absent. */
|
|
60
|
+
readonly store?: CredentialStore;
|
|
61
|
+
}
|
|
62
|
+
/** A tool definition as `ctx.tools.register` accepts it. */
|
|
63
|
+
export interface GitCredentialsTool {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly description: string;
|
|
66
|
+
readonly parameters: Record<string, unknown>;
|
|
67
|
+
readonly output: {
|
|
68
|
+
readonly schema: Record<string, unknown>;
|
|
69
|
+
readonly render: (args: GitCredentialsArgs, value: GitCredentialsValue) => readonly ToolTextBlock[];
|
|
70
|
+
};
|
|
71
|
+
readonly execute: (args: GitCredentialsArgs, exec?: {
|
|
72
|
+
readonly signal?: AbortSignal;
|
|
73
|
+
}) => Promise<GitCredentialsValue>;
|
|
74
|
+
}
|
|
75
|
+
/** Compose the model-facing text of one result. */
|
|
76
|
+
export declare function renderGitCredentials(value: GitCredentialsValue): string;
|
|
77
|
+
/**
|
|
78
|
+
* Build the `git_credentials` tool definition.
|
|
79
|
+
* @param deps - host seams (home/env and an optional store override).
|
|
80
|
+
* @returns a definition ready for `ctx.tools.register`.
|
|
81
|
+
*/
|
|
82
|
+
export declare function gitCredentialsTool(deps: GitCredentialsToolDeps): GitCredentialsTool;
|
package/lib/tool.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing `git_credentials` tool (host half).
|
|
3
|
+
*
|
|
4
|
+
* The credential file is global, so a session that pushes to a new host often
|
|
5
|
+
* needs to know which hosts are already configured before asking the user to
|
|
6
|
+
* paste a token again. Hence one read-only tool over the same store the routes
|
|
7
|
+
* use: it lists hosts, methods and masked secrets, and never writes.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here imports `@deepseek-ai/dsh-tools`: a plain definition object
|
|
10
|
+
* registered through `ctx.tools.register` is the documented path for a plugin
|
|
11
|
+
* that must not take a value dependency on the host runtime. The registry
|
|
12
|
+
* validates `output.schema` and passes model arguments through verbatim, so
|
|
13
|
+
* this module validates its own arguments.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-git-branch/tool
|
|
16
|
+
*/
|
|
17
|
+
import { basicUsername, createCredentialStore, credentialFilePath, maskSecret, } from './credentials.js';
|
|
18
|
+
/** Model-facing tool name. */
|
|
19
|
+
export const GIT_CREDENTIALS_TOOL_NAME = 'git_credentials';
|
|
20
|
+
/** Project one stored entry onto its model-facing row. */
|
|
21
|
+
function credentialRow(entry, includeSecret) {
|
|
22
|
+
return {
|
|
23
|
+
host: entry.host,
|
|
24
|
+
username: entry.username === '' ? basicUsername(entry) : entry.username,
|
|
25
|
+
method: entry.method,
|
|
26
|
+
secret: includeSecret ? entry.secret : maskSecret(entry.secret),
|
|
27
|
+
updatedAt: entry.updatedAt > 0 ? new Date(entry.updatedAt).toISOString() : '',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Compose the model-facing text of one result. */
|
|
31
|
+
export function renderGitCredentials(value) {
|
|
32
|
+
if (value.error !== undefined)
|
|
33
|
+
return `git credentials are unavailable: ${value.error}`;
|
|
34
|
+
const head = `${String(value.total)} configured host${value.total === 1 ? '' : 's'} (${value.path})`;
|
|
35
|
+
if (value.credentials.length === 0)
|
|
36
|
+
return `${head}\n(no matching credential)`;
|
|
37
|
+
const rows = value.credentials.map((row) => {
|
|
38
|
+
const parts = [row.host, row.method, `user ${row.username}`, row.secret];
|
|
39
|
+
if (row.updatedAt !== '')
|
|
40
|
+
parts.push(`saved ${row.updatedAt}`);
|
|
41
|
+
return `- ${parts.join(' · ')}`;
|
|
42
|
+
});
|
|
43
|
+
return [head, ...rows].join('\n');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Build the `git_credentials` tool definition.
|
|
47
|
+
* @param deps - host seams (home/env and an optional store override).
|
|
48
|
+
* @returns a definition ready for `ctx.tools.register`.
|
|
49
|
+
*/
|
|
50
|
+
export function gitCredentialsTool(deps) {
|
|
51
|
+
const storeOf = () => deps.store ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
|
|
52
|
+
/** Read the store, folding a read failure into the result value. */
|
|
53
|
+
const read = async (args) => {
|
|
54
|
+
const store = storeOf();
|
|
55
|
+
if (args.host !== undefined && typeof args.host !== 'string') {
|
|
56
|
+
throw new Error('invalid host: expected a string');
|
|
57
|
+
}
|
|
58
|
+
if (args.includeSecret !== undefined && typeof args.includeSecret !== 'boolean') {
|
|
59
|
+
throw new Error('invalid includeSecret: expected a boolean');
|
|
60
|
+
}
|
|
61
|
+
const wanted = args.host?.trim().toLowerCase();
|
|
62
|
+
const includeSecret = args.includeSecret === true;
|
|
63
|
+
try {
|
|
64
|
+
const entries = await store.load();
|
|
65
|
+
const rows = entries
|
|
66
|
+
.filter(entry => wanted === undefined || wanted === '' || entry.host === wanted)
|
|
67
|
+
.map(entry => credentialRow(entry, includeSecret));
|
|
68
|
+
return { path: store.path, total: entries.length, credentials: rows };
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return {
|
|
72
|
+
path: store.path,
|
|
73
|
+
total: 0,
|
|
74
|
+
credentials: [],
|
|
75
|
+
error: error instanceof Error ? error.message : String(error),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
name: GIT_CREDENTIALS_TOOL_NAME,
|
|
81
|
+
description: 'List the Git credentials this machine stores for remote hosts (GitHub, GitLab, Gitee, ...). '
|
|
82
|
+
+ 'The store is global per host and the git-worktree plugin injects it automatically into its fetch, '
|
|
83
|
+
+ 'update, push and tag-push commands, so a configured host needs no further setup. Secrets are masked; '
|
|
84
|
+
+ 'pass includeSecret only when the user explicitly asked to see the stored token, because the value then '
|
|
85
|
+
+ 'appears in the conversation transcript.',
|
|
86
|
+
parameters: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
host: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
description: 'Only list this host, for example "github.com". Omit to list every configured host.',
|
|
92
|
+
},
|
|
93
|
+
includeSecret: {
|
|
94
|
+
type: 'boolean',
|
|
95
|
+
description: 'Return the stored secret verbatim instead of a masked tail. Only set this when the user '
|
|
96
|
+
+ 'explicitly asked to see the secret.',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
},
|
|
101
|
+
output: {
|
|
102
|
+
schema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
path: { type: 'string' },
|
|
106
|
+
total: { type: 'integer' },
|
|
107
|
+
credentials: {
|
|
108
|
+
type: 'array',
|
|
109
|
+
items: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
host: { type: 'string' },
|
|
113
|
+
username: { type: 'string' },
|
|
114
|
+
method: { type: 'string' },
|
|
115
|
+
secret: { type: 'string' },
|
|
116
|
+
updatedAt: { type: 'string' },
|
|
117
|
+
},
|
|
118
|
+
required: ['host', 'username', 'method', 'secret', 'updatedAt'],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
error: { type: 'string' },
|
|
122
|
+
},
|
|
123
|
+
required: ['path', 'total', 'credentials'],
|
|
124
|
+
},
|
|
125
|
+
render: (_args, value) => [
|
|
126
|
+
{ type: 'text', text: renderGitCredentials(value) },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
async execute(args) {
|
|
130
|
+
return read(args ?? {});
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|