@openparachute/vault 0.5.0-rc.2 → 0.5.0-rc.3
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/src/mirror-routes.ts
CHANGED
|
@@ -26,16 +26,20 @@
|
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
28
|
defaultMirrorConfig,
|
|
29
|
+
readMirrorConfigForVault,
|
|
29
30
|
validateExternalPath,
|
|
30
31
|
validateMirrorConfigShape,
|
|
31
32
|
type MirrorConfig,
|
|
32
33
|
} from "./mirror-config.ts";
|
|
33
34
|
import type { MirrorManager } from "./mirror-manager.ts";
|
|
35
|
+
import { getMirrorManager } from "./mirror-registry.ts";
|
|
34
36
|
import {
|
|
35
37
|
applyToGitRemote,
|
|
36
38
|
deleteCredentials,
|
|
37
39
|
emptyCredentials,
|
|
40
|
+
githubAuthedRemoteUrl,
|
|
38
41
|
readCredentials,
|
|
42
|
+
redactRemoteUrl,
|
|
39
43
|
sanitizeCredentials,
|
|
40
44
|
unsetGitRemote,
|
|
41
45
|
writeCredentials,
|
|
@@ -1106,15 +1110,32 @@ export async function applyCredentialsToMirror(
|
|
|
1106
1110
|
// "mode": "merge" | "replace",
|
|
1107
1111
|
// "credentials": null
|
|
1108
1112
|
// | { "kind": "pat", "token": "ghp_..." }
|
|
1109
|
-
// | { "kind": "none" }
|
|
1113
|
+
// | { "kind": "none" },
|
|
1114
|
+
// "enable_sync": true // optional, DEFAULT TRUE
|
|
1110
1115
|
// }
|
|
1111
1116
|
//
|
|
1112
1117
|
// `credentials: null` means "use the stored mirror credentials." Passing
|
|
1113
|
-
// `{kind: "pat", token}` is the one-shot path — token doesn't get persisted
|
|
1118
|
+
// `{kind: "pat", token}` is the one-shot path — token doesn't get persisted
|
|
1119
|
+
// for the CLONE, but IS persisted when sync is enabled (it's the push
|
|
1120
|
+
// credential for the now-configured mirror).
|
|
1121
|
+
//
|
|
1122
|
+
// `enable_sync` (vault#416) — DEFAULT TRUE when omitted. After a successful
|
|
1123
|
+
// import, auto-enable mirror push-back to the SAME repo, reusing the import's
|
|
1124
|
+
// credentials. Makes "import a repo" and "back up to that repo going forward"
|
|
1125
|
+
// one fluid flow. The UI ships a checked-by-default checkbox the operator can
|
|
1126
|
+
// uncheck. Edge cases (handled in `enableSyncToImportedRepo`, never fail the
|
|
1127
|
+
// whole import):
|
|
1128
|
+
// - `auth: none` (public repo, no push creds) → skip + warn (can't push
|
|
1129
|
+
// without a credential).
|
|
1130
|
+
// - A mirror already points at a DIFFERENT remote → skip + warn (don't
|
|
1131
|
+
// clobber the operator's existing backup target).
|
|
1132
|
+
// - A mirror already points at the SAME remote → no-op success.
|
|
1133
|
+
// - Sync setup throws → import result still returned (success);
|
|
1134
|
+
// `sync_enabled: false` + a warning. Import success is never lost.
|
|
1114
1135
|
//
|
|
1115
1136
|
// Response:
|
|
1116
1137
|
// 200 { notes_imported, tags_imported, attachments_imported,
|
|
1117
|
-
// notes_deleted?, warnings }
|
|
1138
|
+
// notes_deleted?, warnings, sync_enabled, sync_warning? }
|
|
1118
1139
|
// 400 { error, error_type, message } — validation / not-a-vault-export
|
|
1119
1140
|
// 409 { error, error_type, message } — concurrent import for this vault
|
|
1120
1141
|
// 502 { error, message } — clone failed (auth, network, …)
|
|
@@ -1136,17 +1157,25 @@ export async function applyCredentialsToMirror(
|
|
|
1136
1157
|
* `whichOverride` is a test seam for the git-presence preflight (default
|
|
1137
1158
|
* `Bun.which` inside `cloneAndImport`). Inject a fn returning `null` to
|
|
1138
1159
|
* exercise the git_not_installed 503 path without uninstalling git.
|
|
1160
|
+
*
|
|
1161
|
+
* `managerOverride` is a test seam for the post-import sync-enable step
|
|
1162
|
+
* (vault#416). Production callers omit it; the route resolves the per-vault
|
|
1163
|
+
* manager via `getMirrorManager(vaultName)` (same registry the auth /
|
|
1164
|
+
* run-now / push-now routes use). Tests inject a manager so they can assert
|
|
1165
|
+
* on the config it ends up with without standing up the full registry.
|
|
1139
1166
|
*/
|
|
1140
1167
|
export async function handleMirrorImport(
|
|
1141
1168
|
req: Request,
|
|
1142
1169
|
vaultName: string,
|
|
1143
1170
|
spawnOverride?: GitSpawn,
|
|
1144
1171
|
whichOverride?: (cmd: string) => string | null,
|
|
1172
|
+
managerOverride?: MirrorManager,
|
|
1145
1173
|
): Promise<Response> {
|
|
1146
1174
|
let body: {
|
|
1147
1175
|
remote_url?: unknown;
|
|
1148
1176
|
mode?: unknown;
|
|
1149
1177
|
credentials?: unknown;
|
|
1178
|
+
enable_sync?: unknown;
|
|
1150
1179
|
};
|
|
1151
1180
|
try {
|
|
1152
1181
|
body = (await req.json()) as Record<string, unknown>;
|
|
@@ -1239,6 +1268,25 @@ export async function handleMirrorImport(
|
|
|
1239
1268
|
);
|
|
1240
1269
|
}
|
|
1241
1270
|
|
|
1271
|
+
// vault#416: `enable_sync` defaults TRUE when omitted — the default-on UX.
|
|
1272
|
+
// Only a literal `false` opts out; any other type is a validation error so
|
|
1273
|
+
// a malformed body never silently flips the default.
|
|
1274
|
+
let enableSync = true;
|
|
1275
|
+
if ("enable_sync" in body && body.enable_sync !== undefined) {
|
|
1276
|
+
if (typeof body.enable_sync !== "boolean") {
|
|
1277
|
+
return Response.json(
|
|
1278
|
+
{
|
|
1279
|
+
error: "enable_sync invalid",
|
|
1280
|
+
error_type: "validation",
|
|
1281
|
+
field: "enable_sync",
|
|
1282
|
+
message: "enable_sync must be a boolean (defaults to true when omitted).",
|
|
1283
|
+
},
|
|
1284
|
+
{ status: 400 },
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
enableSync = body.enable_sync;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1242
1290
|
// Resolve the target vault's store + assets dir. The route is gated on
|
|
1243
1291
|
// `vault:<name>:admin`, so we trust vaultName is real by the time we
|
|
1244
1292
|
// reach this code path; defensively re-resolve in case the vault was
|
|
@@ -1311,7 +1359,368 @@ export async function handleMirrorImport(
|
|
|
1311
1359
|
);
|
|
1312
1360
|
}
|
|
1313
1361
|
|
|
1362
|
+
// ---- vault#416: auto-enable sync to the imported repo (default-on) -------
|
|
1363
|
+
//
|
|
1364
|
+
// The import SUCCEEDED above. From here on, every failure is non-fatal —
|
|
1365
|
+
// we never lose a successful import to a sync-setup error. `result` already
|
|
1366
|
+
// carries `sync_enabled: false` (set by importResultFromStats); we flip it
|
|
1367
|
+
// true only when sync is actually wired (or already wired to this remote).
|
|
1368
|
+
if (enableSync) {
|
|
1369
|
+
try {
|
|
1370
|
+
const manager =
|
|
1371
|
+
managerOverride ?? getMirrorManager(vaultName) ?? undefined;
|
|
1372
|
+
const outcome = await enableSyncToImportedRepo({
|
|
1373
|
+
vaultName,
|
|
1374
|
+
remoteUrl: remote_url,
|
|
1375
|
+
auth,
|
|
1376
|
+
manager,
|
|
1377
|
+
});
|
|
1378
|
+
result.sync_enabled = outcome.sync_enabled;
|
|
1379
|
+
if (outcome.warning) result.sync_warning = outcome.warning;
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
// Defense-in-depth: enableSyncToImportedRepo is written to not throw
|
|
1382
|
+
// (it catches its own write/credential/reload errors), but a future
|
|
1383
|
+
// edit or an unexpected throw must NOT take down a successful import.
|
|
1384
|
+
const msg = redactToken((err as Error).message ?? String(err));
|
|
1385
|
+
console.warn(
|
|
1386
|
+
`[mirror-import] sync-enable threw after a successful import (non-fatal): ${msg}`,
|
|
1387
|
+
);
|
|
1388
|
+
result.sync_enabled = false;
|
|
1389
|
+
result.sync_warning =
|
|
1390
|
+
"Import succeeded, but enabling Sync failed. Set up Sync separately from the Git remote section.";
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1314
1394
|
return Response.json(result, {
|
|
1315
1395
|
headers: { "Access-Control-Allow-Origin": "*" },
|
|
1316
1396
|
});
|
|
1317
1397
|
}
|
|
1398
|
+
|
|
1399
|
+
// ---------------------------------------------------------------------------
|
|
1400
|
+
// vault#416 — sync-enable after a successful import.
|
|
1401
|
+
// ---------------------------------------------------------------------------
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* After a successful import, turn the imported-from repo into a configured,
|
|
1405
|
+
* credential-backed, auto-pushing mirror — reusing the import's credentials.
|
|
1406
|
+
* This is the back half of the default-on "import → also sync back" flow.
|
|
1407
|
+
*
|
|
1408
|
+
* Reuses the SAME machinery the manual mirror-setup flow uses:
|
|
1409
|
+
* - `writeCredentials` to persist the push credential (per-vault, vault#399).
|
|
1410
|
+
* - `MirrorConfig` + `manager.reload()` to enable `auto_push` and restart
|
|
1411
|
+
* the lifecycle — which calls `applyCredentialsToRemote` to set `origin`
|
|
1412
|
+
* from the stored credentials (exactly like handleAuthPat /
|
|
1413
|
+
* handleAuthGithubSelectRepo do).
|
|
1414
|
+
*
|
|
1415
|
+
* Mirror location is left `internal` (vault-managed dir under the vault's
|
|
1416
|
+
* data dir) — the import didn't ask the operator to pick an external path,
|
|
1417
|
+
* and `auto_push + internal + credentials` is the supported "push to a
|
|
1418
|
+
* GitHub/GitLab remote" shape (see mirror-config.ts validation note). The
|
|
1419
|
+
* remote lives in credentials, not the config; that's why we persist
|
|
1420
|
+
* credentials AND flip auto_push.
|
|
1421
|
+
*
|
|
1422
|
+
* Never throws — every failure path returns `{ sync_enabled: false, warning }`.
|
|
1423
|
+
* The import already succeeded by the time this runs; a sync-setup error must
|
|
1424
|
+
* not be surfaced as an import failure.
|
|
1425
|
+
*
|
|
1426
|
+
* Edge cases (returns `sync_enabled: false` + a warning, no broken mirror
|
|
1427
|
+
* left behind):
|
|
1428
|
+
* - **No push-capable credential** — `auth: none`, OR `credentialsFile`
|
|
1429
|
+
* with no stored credential that covers this remote's host. Can't push
|
|
1430
|
+
* without a credential; skip rather than configure a mirror that would
|
|
1431
|
+
* just fail every push.
|
|
1432
|
+
* - **A mirror already targets a DIFFERENT remote** — don't clobber the
|
|
1433
|
+
* operator's existing backup target. Detected by comparing the existing
|
|
1434
|
+
* stored credential's remote host/path against the import remote.
|
|
1435
|
+
* - **A mirror already targets the SAME remote** — no-op success.
|
|
1436
|
+
*/
|
|
1437
|
+
export async function enableSyncToImportedRepo(opts: {
|
|
1438
|
+
vaultName: string;
|
|
1439
|
+
remoteUrl: string;
|
|
1440
|
+
auth: ImportAuth;
|
|
1441
|
+
/**
|
|
1442
|
+
* The per-vault mirror manager. Undefined only in the boot-race window
|
|
1443
|
+
* where the registry factory hasn't been installed; we then skip (warn)
|
|
1444
|
+
* rather than persisting a half-configured mirror with no live manager.
|
|
1445
|
+
*/
|
|
1446
|
+
manager: MirrorManager | undefined;
|
|
1447
|
+
}): Promise<{ sync_enabled: boolean; warning?: string }> {
|
|
1448
|
+
const { vaultName, remoteUrl, auth, manager } = opts;
|
|
1449
|
+
|
|
1450
|
+
if (!manager) {
|
|
1451
|
+
return {
|
|
1452
|
+
sync_enabled: false,
|
|
1453
|
+
warning:
|
|
1454
|
+
"Sync not enabled — the vault's mirror manager wasn't ready. Set up Sync from the Git remote section.",
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// --- Resolve the push credential we'll persist for this remote. ----------
|
|
1459
|
+
// We need a credential that can PUSH to `remoteUrl`. The clone-time auth
|
|
1460
|
+
// shapes map onto push credentials as follows:
|
|
1461
|
+
// - pat → persist the supplied PAT against this remote.
|
|
1462
|
+
// - none → no push credential; cannot enable a working sync.
|
|
1463
|
+
// - credentialsFile → reuse the already-stored credential, but only if it
|
|
1464
|
+
// actually covers this remote's host (a stored PAT for
|
|
1465
|
+
// a different host can't push here).
|
|
1466
|
+
let credentialToWrite: MirrorCredentials | null = null;
|
|
1467
|
+
|
|
1468
|
+
if (auth.kind === "none") {
|
|
1469
|
+
return {
|
|
1470
|
+
sync_enabled: false,
|
|
1471
|
+
warning:
|
|
1472
|
+
"Sync not enabled — pushing changes back needs write credentials (a PAT or GitHub sign-in). Set up Sync separately to enable it.",
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if (auth.kind === "pat") {
|
|
1477
|
+
// Persist the supplied PAT against this remote — the same x-access-token
|
|
1478
|
+
// embedding the manual PAT route stores, so bare `git push` works.
|
|
1479
|
+
const embedded = embedTokenInRemoteUrl(remoteUrl, auth.token);
|
|
1480
|
+
if (!embedded) {
|
|
1481
|
+
return {
|
|
1482
|
+
sync_enabled: false,
|
|
1483
|
+
warning:
|
|
1484
|
+
"Sync not enabled — the remote URL couldn't be parsed to embed the access token. Set up Sync separately from the Git remote section.",
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
credentialToWrite = {
|
|
1488
|
+
...(readCredentials(vaultName) ?? emptyCredentials()),
|
|
1489
|
+
active_method: "pat",
|
|
1490
|
+
pat: {
|
|
1491
|
+
token: auth.token,
|
|
1492
|
+
remote_url: embedded,
|
|
1493
|
+
label: "Imported-repo sync",
|
|
1494
|
+
},
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// For credentialsFile we DON'T overwrite — we reuse what's already stored,
|
|
1499
|
+
// but must verify it covers this remote (host match). Resolve the existing
|
|
1500
|
+
// credential's effective remote so the conflict check below has something
|
|
1501
|
+
// to compare against.
|
|
1502
|
+
const existing = readCredentials(vaultName);
|
|
1503
|
+
|
|
1504
|
+
// --- Conflict / idempotency check against any already-configured mirror. --
|
|
1505
|
+
// The mirror's remote lives in credentials, not in MirrorConfig. Compare
|
|
1506
|
+
// the existing stored credential's remote against the import remote.
|
|
1507
|
+
const persistedConfig = readMirrorConfigForVault(vaultName);
|
|
1508
|
+
const mirrorAlreadyConfigured = !!persistedConfig && persistedConfig.enabled;
|
|
1509
|
+
const existingRemote = existing ? effectiveRemoteOf(existing) : null;
|
|
1510
|
+
|
|
1511
|
+
if (mirrorAlreadyConfigured && existingRemote) {
|
|
1512
|
+
if (sameRemote(existingRemote, remoteUrl)) {
|
|
1513
|
+
// Same remote — make sure auto_push is on, then no-op success. We don't
|
|
1514
|
+
// need to rewrite credentials (credentialsFile) or can refresh the PAT
|
|
1515
|
+
// (pat path) — either way the mirror already points here.
|
|
1516
|
+
if (auth.kind === "pat" && credentialToWrite) {
|
|
1517
|
+
try {
|
|
1518
|
+
writeCredentials(vaultName, credentialToWrite);
|
|
1519
|
+
} catch (err) {
|
|
1520
|
+
return {
|
|
1521
|
+
sync_enabled: false,
|
|
1522
|
+
warning: `Import succeeded; mirror already targets this repo but the credential refresh failed: ${redactToken((err as Error).message ?? String(err))}`,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
return await applyEnabledAutoPush(manager);
|
|
1527
|
+
}
|
|
1528
|
+
// Different remote — don't clobber the operator's existing backup target.
|
|
1529
|
+
return {
|
|
1530
|
+
sync_enabled: false,
|
|
1531
|
+
warning: `Sync not enabled — this vault already syncs to a different repo (${redactRemoteUrl(existingRemote)}). Leaving your existing backup target untouched. Change it from the Git remote section if you want to switch.`,
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// A mirror is already configured + enabled with an active credential, but we
|
|
1536
|
+
// couldn't read its remote to compare (github_oauth stores no owner/repo at
|
|
1537
|
+
// the credential level — its remote lives on the mirror dir's `origin`). To
|
|
1538
|
+
// avoid clobbering an existing GitHub-connected backup target by switching
|
|
1539
|
+
// `active_method` to a PAT, refuse unless the existing credential is OAuth
|
|
1540
|
+
// and this is the same GitHub host (then the manual select-repo flow already
|
|
1541
|
+
// points origin where it should; we treat that as "already set up — leave
|
|
1542
|
+
// it"). Conservative: don't auto-switch an existing connected mirror.
|
|
1543
|
+
if (
|
|
1544
|
+
mirrorAlreadyConfigured &&
|
|
1545
|
+
existing?.active_method === "github_oauth" &&
|
|
1546
|
+
existing.github_oauth
|
|
1547
|
+
) {
|
|
1548
|
+
return {
|
|
1549
|
+
sync_enabled: false,
|
|
1550
|
+
warning:
|
|
1551
|
+
"Sync not enabled — this vault already syncs via a connected GitHub account. Leaving your existing backup target untouched. Switch it from the Git remote section if you want to point sync at this repo instead.",
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// --- No conflicting mirror. Wire it up. ----------------------------------
|
|
1556
|
+
if (auth.kind === "credentialsFile") {
|
|
1557
|
+
// Reuse the stored credential — but only if it can push to this remote.
|
|
1558
|
+
const covers = existing && existingRemote && sameRemote(existingRemote, remoteUrl);
|
|
1559
|
+
const hasOauthForGithub =
|
|
1560
|
+
existing?.active_method === "github_oauth" &&
|
|
1561
|
+
!!existing.github_oauth &&
|
|
1562
|
+
isGithubRemote(remoteUrl);
|
|
1563
|
+
if (!covers && !hasOauthForGithub) {
|
|
1564
|
+
return {
|
|
1565
|
+
sync_enabled: false,
|
|
1566
|
+
warning:
|
|
1567
|
+
"Sync not enabled — no saved credential can push to this repo. Connect GitHub or paste a Personal Access Token in the Git remote section to enable Sync.",
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
if (existing?.active_method === "github_oauth" && hasOauthForGithub) {
|
|
1571
|
+
// OAuth path: persist origin via the github authed URL, same as the
|
|
1572
|
+
// select-repo flow. Parse owner/repo from the import remote.
|
|
1573
|
+
const ownerRepo = parseGithubOwnerRepo(remoteUrl);
|
|
1574
|
+
if (!ownerRepo) {
|
|
1575
|
+
return {
|
|
1576
|
+
sync_enabled: false,
|
|
1577
|
+
warning:
|
|
1578
|
+
"Sync not enabled — couldn't parse owner/repo from the GitHub URL. Pick the repo from the Git remote section to enable Sync.",
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
const status = manager.getStatus();
|
|
1582
|
+
const authedUrl = githubAuthedRemoteUrl(
|
|
1583
|
+
existing.github_oauth!.access_token,
|
|
1584
|
+
ownerRepo.owner,
|
|
1585
|
+
ownerRepo.repo,
|
|
1586
|
+
);
|
|
1587
|
+
if (status.mirror_path) {
|
|
1588
|
+
await applyToGitRemote(status.mirror_path, authedUrl);
|
|
1589
|
+
}
|
|
1590
|
+
// Stash a PAT-shaped credential so a restart re-applies the right
|
|
1591
|
+
// origin without needing the operator to re-pick the repo. (The OAuth
|
|
1592
|
+
// token works through the same x-access-token shape.) This mirrors how
|
|
1593
|
+
// applyCredentialsToRemote refreshes github origins on restart.
|
|
1594
|
+
credentialToWrite = {
|
|
1595
|
+
...existing,
|
|
1596
|
+
active_method: "github_oauth",
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
// covers === true → existing PAT already targets this remote; nothing to
|
|
1600
|
+
// re-persist. Fall through to enabling auto_push.
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
if (credentialToWrite) {
|
|
1604
|
+
try {
|
|
1605
|
+
writeCredentials(vaultName, credentialToWrite);
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
return {
|
|
1608
|
+
sync_enabled: false,
|
|
1609
|
+
warning: `Import succeeded, but saving the sync credential failed: ${redactToken((err as Error).message ?? String(err))}. Set up Sync separately from the Git remote section.`,
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
return await applyEnabledAutoPush(manager);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Flip the mirror config to `enabled: true, auto_push: true` (internal
|
|
1619
|
+
* location) and reload the manager — which applies the just-persisted
|
|
1620
|
+
* credentials to `origin` and starts the event-driven export loop. Returns
|
|
1621
|
+
* the sync outcome; reload failures are non-fatal (import already succeeded).
|
|
1622
|
+
*/
|
|
1623
|
+
async function applyEnabledAutoPush(
|
|
1624
|
+
manager: MirrorManager,
|
|
1625
|
+
): Promise<{ sync_enabled: boolean; warning?: string }> {
|
|
1626
|
+
const current = manager.getEffectiveConfig();
|
|
1627
|
+
const next: MirrorConfig = {
|
|
1628
|
+
...current,
|
|
1629
|
+
enabled: true,
|
|
1630
|
+
// Keep the operator's existing location if they'd set external; default
|
|
1631
|
+
// internal for the fresh case. Internal + credentials is the supported
|
|
1632
|
+
// "push to a hosted remote" shape.
|
|
1633
|
+
location: current.location,
|
|
1634
|
+
auto_push: true,
|
|
1635
|
+
};
|
|
1636
|
+
try {
|
|
1637
|
+
await manager.reload(next);
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
return {
|
|
1640
|
+
sync_enabled: false,
|
|
1641
|
+
warning: `Import succeeded, but enabling Sync failed: ${redactToken((err as Error).message ?? String(err))}. Set up Sync separately from the Git remote section.`,
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
// reload → start sets status.enabled iff bootstrap succeeded. If bootstrap
|
|
1645
|
+
// failed (e.g. git-less host, though import would've 503'd earlier), surface
|
|
1646
|
+
// that rather than claiming sync is on.
|
|
1647
|
+
const status = manager.getStatus();
|
|
1648
|
+
if (!status.enabled) {
|
|
1649
|
+
return {
|
|
1650
|
+
sync_enabled: false,
|
|
1651
|
+
warning: status.last_error
|
|
1652
|
+
? `Import succeeded, but the mirror couldn't start: ${redactToken(status.last_error)}`
|
|
1653
|
+
: "Import succeeded, but the mirror couldn't start. Check the Git remote section.",
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
return { sync_enabled: true };
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* Embed an x-access-token credential into an HTTPS remote URL, the same shape
|
|
1661
|
+
* the manual PAT route persists. Returns null when the URL can't be parsed or
|
|
1662
|
+
* isn't http(s) (SSH / local-path remotes can't carry a token in userinfo —
|
|
1663
|
+
* those rely on the operator's ssh-agent, so there's no push credential for
|
|
1664
|
+
* us to embed). When the URL already carries userinfo, trust it verbatim.
|
|
1665
|
+
*/
|
|
1666
|
+
function embedTokenInRemoteUrl(remoteUrl: string, token: string): string | null {
|
|
1667
|
+
let parsed: URL;
|
|
1668
|
+
try {
|
|
1669
|
+
parsed = new URL(remoteUrl);
|
|
1670
|
+
} catch {
|
|
1671
|
+
return null;
|
|
1672
|
+
}
|
|
1673
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
1674
|
+
if (parsed.username || parsed.password) return remoteUrl;
|
|
1675
|
+
parsed.username = "x-access-token";
|
|
1676
|
+
parsed.password = token;
|
|
1677
|
+
return parsed.toString();
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* The remote a stored credential effectively pushes to:
|
|
1682
|
+
* - pat → its `remote_url` (userinfo-embedded; comparison strips userinfo).
|
|
1683
|
+
* - github_oauth → null here (no owner/repo stored at the credential level;
|
|
1684
|
+
* the origin is set when a repo is picked). Callers treat null as "no
|
|
1685
|
+
* known remote to conflict with."
|
|
1686
|
+
*/
|
|
1687
|
+
function effectiveRemoteOf(creds: MirrorCredentials): string | null {
|
|
1688
|
+
if (creds.active_method === "pat" && creds.pat) return creds.pat.remote_url;
|
|
1689
|
+
return null;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Compare two remote URLs ignoring userinfo (embedded tokens) + a trailing
|
|
1694
|
+
* `.git` + a trailing slash, case-insensitively on host. "Same repo" for the
|
|
1695
|
+
* clobber check. Falls back to a trimmed string compare for non-URL remotes
|
|
1696
|
+
* (SSH shorthand, local paths).
|
|
1697
|
+
*/
|
|
1698
|
+
function sameRemote(a: string, b: string): boolean {
|
|
1699
|
+
const norm = (u: string): string => {
|
|
1700
|
+
try {
|
|
1701
|
+
const url = new URL(u);
|
|
1702
|
+
const host = url.host.toLowerCase();
|
|
1703
|
+
const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
1704
|
+
return `${url.protocol}//${host}${path}`;
|
|
1705
|
+
} catch {
|
|
1706
|
+
return u.trim().replace(/\.git$/, "").replace(/\/+$/, "");
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
return norm(a) === norm(b);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/** True when a remote URL is on github.com (host-exact, case-insensitive). */
|
|
1713
|
+
function isGithubRemote(remoteUrl: string): boolean {
|
|
1714
|
+
try {
|
|
1715
|
+
return new URL(remoteUrl).host.toLowerCase() === "github.com";
|
|
1716
|
+
} catch {
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** Parse `owner` + `repo` out of a github.com HTTPS URL. Null when it doesn't match. */
|
|
1722
|
+
function parseGithubOwnerRepo(remoteUrl: string): { owner: string; repo: string } | null {
|
|
1723
|
+
const match = remoteUrl.match(/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?(?:\/)?$/i);
|
|
1724
|
+
if (!match) return null;
|
|
1725
|
+
return { owner: match[1]!, repo: match[2]! };
|
|
1726
|
+
}
|