@openparachute/vault 0.5.0-rc.1 → 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/package.json +1 -1
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +463 -1
- package/src/mirror-routes.ts +474 -4
- package/web/ui/dist/assets/{index-DDRo6F4u.js → index-BKYNb2II.js} +10 -10
- package/web/ui/dist/index.html +1 -1
package/src/mirror-manager.ts
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
applyToGitRemote,
|
|
76
76
|
readCredentials,
|
|
77
77
|
} from "./mirror-credentials.ts";
|
|
78
|
+
import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
|
|
78
79
|
import type { HookRegistry } from "../core/src/hooks.ts";
|
|
79
80
|
|
|
80
81
|
/**
|
|
@@ -230,7 +231,20 @@ export type BootstrapResult = BootstrapResultOk | BootstrapResultError;
|
|
|
230
231
|
*/
|
|
231
232
|
export async function bootstrapInternalMirror(
|
|
232
233
|
path: string,
|
|
234
|
+
// Test seam for the git-presence preflight (default `Bun.which`). Inject a
|
|
235
|
+
// fn returning `null` to exercise the git-not-installed bootstrap path.
|
|
236
|
+
which?: (cmd: string) => string | null,
|
|
233
237
|
): Promise<BootstrapResult> {
|
|
238
|
+
// Preflight: a git-less server can't bootstrap a mirror. Surface the
|
|
239
|
+
// friendly, actionable message into the bootstrap-error channel so the
|
|
240
|
+
// caller threads it into mirror status (`last_error`) rather than letting
|
|
241
|
+
// a raw `Executable not found in $PATH: "git"` crash out of the spawn.
|
|
242
|
+
try {
|
|
243
|
+
ensureGitAvailable(which);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
return { ok: false, error: (err as Error).message };
|
|
246
|
+
}
|
|
247
|
+
|
|
234
248
|
if (existsSync(path)) {
|
|
235
249
|
let stat;
|
|
236
250
|
try {
|
|
@@ -466,7 +480,11 @@ export class MirrorManager {
|
|
|
466
480
|
* Returns the final status snapshot — useful for tests + the PUT
|
|
467
481
|
* endpoint response.
|
|
468
482
|
*/
|
|
469
|
-
async start(
|
|
483
|
+
async start(
|
|
484
|
+
// Test seam for the git-presence preflight (default `Bun.which`). Inject
|
|
485
|
+
// a fn returning `null` to exercise the git-not-installed start path.
|
|
486
|
+
which?: (cmd: string) => string | null,
|
|
487
|
+
): Promise<MirrorStatus> {
|
|
470
488
|
this.startCount++;
|
|
471
489
|
await this.stop({ preserveStatus: true });
|
|
472
490
|
|
|
@@ -501,6 +519,24 @@ export class MirrorManager {
|
|
|
501
519
|
}
|
|
502
520
|
this.status.mirror_path = path;
|
|
503
521
|
|
|
522
|
+
// Preflight git BEFORE branching on location. Both branches shell `git`
|
|
523
|
+
// (internal → bootstrapInternalMirror; external → isGitRepo). On a
|
|
524
|
+
// git-less server the external branch's `isGitRepo` would otherwise throw
|
|
525
|
+
// a raw "Executable not found in $PATH: \"git\"" and crash start();
|
|
526
|
+
// catching it here lands the friendly, actionable message in
|
|
527
|
+
// status.last_error (disabled) for either location, uniformly.
|
|
528
|
+
try {
|
|
529
|
+
ensureGitAvailable(which);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
if (err instanceof GitNotInstalledError) {
|
|
532
|
+
this.status.enabled = false;
|
|
533
|
+
this.status.last_error = err.message;
|
|
534
|
+
console.warn(`[mirror] ${err.message}`);
|
|
535
|
+
return this.getStatus();
|
|
536
|
+
}
|
|
537
|
+
throw err;
|
|
538
|
+
}
|
|
539
|
+
|
|
504
540
|
// Internal bootstrap. External path is the operator's responsibility —
|
|
505
541
|
// they should have validated via the PUT endpoint before we hit boot.
|
|
506
542
|
// We re-check `isGitRepo` defensively here either way; a missing/non-
|
|
@@ -641,9 +677,13 @@ export class MirrorManager {
|
|
|
641
677
|
* the operator-intended config on disk; on the next vault boot it
|
|
642
678
|
* applies cleanly.
|
|
643
679
|
*/
|
|
644
|
-
async reload(
|
|
680
|
+
async reload(
|
|
681
|
+
newConfig: MirrorConfig,
|
|
682
|
+
// Test seam forwarded to `start()` — see `start(which)`.
|
|
683
|
+
which?: (cmd: string) => string | null,
|
|
684
|
+
): Promise<MirrorStatus> {
|
|
645
685
|
this.deps.writeMirrorConfig(newConfig);
|
|
646
|
-
return this.start();
|
|
686
|
+
return this.start(which);
|
|
647
687
|
}
|
|
648
688
|
|
|
649
689
|
/**
|
|
@@ -887,14 +927,25 @@ export class MirrorManager {
|
|
|
887
927
|
}
|
|
888
928
|
|
|
889
929
|
const firstNoteTitle = await this.deps.firstChangedNoteTitle(sinceCursor);
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
930
|
+
let commitResult: Awaited<ReturnType<typeof runGitCommitCycle>>;
|
|
931
|
+
try {
|
|
932
|
+
commitResult = await runGitCommitCycle({
|
|
933
|
+
repoDir: path,
|
|
934
|
+
template: this.currentConfig.commit_template,
|
|
935
|
+
notesChanged: totalChanged,
|
|
936
|
+
vaultName: this.deps.vaultName,
|
|
937
|
+
firstNoteTitle,
|
|
938
|
+
push: this.currentConfig.auto_push,
|
|
939
|
+
});
|
|
940
|
+
} catch (err) {
|
|
941
|
+
// git-not-installed (or any commit-cycle throw) lands in status as a
|
|
942
|
+
// friendly last_error rather than crashing the cycle. Matches the
|
|
943
|
+
// "errors reflected in last_error, never rethrown" contract above.
|
|
944
|
+
const msg = (err as Error).message ?? String(err);
|
|
945
|
+
this.status.last_error = `commit cycle failed: ${msg}`;
|
|
946
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
898
949
|
|
|
899
950
|
if (commitResult.committed) {
|
|
900
951
|
// Resolve the new HEAD sha so the status displays the commit that
|
|
@@ -950,6 +1001,17 @@ export class MirrorManager {
|
|
|
950
1001
|
if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
|
|
951
1002
|
if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
|
|
952
1003
|
const path = this.status.mirror_path;
|
|
1004
|
+
// Preflight: git-less server can't push. Surface the friendly message
|
|
1005
|
+
// into last_push_error (the SPA renders it) rather than throwing a raw
|
|
1006
|
+
// "Executable not found" out of the gitPush spawn.
|
|
1007
|
+
try {
|
|
1008
|
+
ensureGitAvailable();
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
const msg = (err as Error).message ?? String(err);
|
|
1011
|
+
this.status.last_push_error = msg;
|
|
1012
|
+
console.warn(`[mirror] push-now failed: ${msg}`);
|
|
1013
|
+
return { fired: true, pushed: false, error: msg };
|
|
1014
|
+
}
|
|
953
1015
|
const pushResult = await gitPush(path);
|
|
954
1016
|
const now = new Date().toISOString();
|
|
955
1017
|
// Refresh commits_unpushed either way — a no-op push still reflects
|
|
@@ -11,7 +11,12 @@ import fs from "node:fs";
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
defaultMirrorConfig,
|
|
16
|
+
readMirrorConfigForVault,
|
|
17
|
+
writeMirrorConfigForVault,
|
|
18
|
+
type MirrorConfig,
|
|
19
|
+
} from "./mirror-config.ts";
|
|
15
20
|
import {
|
|
16
21
|
MirrorManager,
|
|
17
22
|
type MirrorDeps,
|
|
@@ -245,6 +250,37 @@ describe("handleMirrorPut", () => {
|
|
|
245
250
|
}
|
|
246
251
|
});
|
|
247
252
|
|
|
253
|
+
test("external + git not installed → 503 git_not_installed + actionable message", async () => {
|
|
254
|
+
// vault#415 nit — handleMirrorPut validates the external path via
|
|
255
|
+
// validateExternalPath, which shells `git`. On a git-less server it must
|
|
256
|
+
// return the friendly 503 (consistent with the import route), not let a
|
|
257
|
+
// raw "Executable not found" crash out. Force the preflight via the
|
|
258
|
+
// whichOverride seam against a REAL git repo so the only failure is the
|
|
259
|
+
// preflight.
|
|
260
|
+
home = tmp("mirror-put-nogit-installed-");
|
|
261
|
+
const { manager } = makeManager(home);
|
|
262
|
+
const external = tmp("mirror-put-nogit-target-");
|
|
263
|
+
initRepo(external);
|
|
264
|
+
try {
|
|
265
|
+
const req = new Request("http://x/admin/mirror", {
|
|
266
|
+
method: "PUT",
|
|
267
|
+
body: JSON.stringify({
|
|
268
|
+
enabled: true,
|
|
269
|
+
location: "external",
|
|
270
|
+
external_path: external,
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
const res = await handleMirrorPut(req, manager, () => null);
|
|
274
|
+
expect(res.status).toBe(503);
|
|
275
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
276
|
+
expect(body.error_type).toBe("git_not_installed");
|
|
277
|
+
expect(body.message).toContain("git is required");
|
|
278
|
+
expect(body.message).toContain("dnf install git");
|
|
279
|
+
} finally {
|
|
280
|
+
fs.rmSync(external, { recursive: true, force: true });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
248
284
|
test("accepts a valid external config, persists, restarts watch", async () => {
|
|
249
285
|
home = tmp("mirror-put-happy-");
|
|
250
286
|
const external = tmp("mirror-put-ext-");
|
|
@@ -1081,6 +1117,28 @@ const spawnCloneFail: GitSpawn = async () => ({
|
|
|
1081
1117
|
timedOut: false,
|
|
1082
1118
|
});
|
|
1083
1119
|
|
|
1120
|
+
/**
|
|
1121
|
+
* vault#416 — a MirrorManager wired to the REAL per-vault config file (so
|
|
1122
|
+
* `handleMirrorImport`'s `readMirrorConfigForVault` agrees with what
|
|
1123
|
+
* `manager.reload()` wrote) + a no-op export. Passed to `handleMirrorImport`
|
|
1124
|
+
* as the `managerOverride` so the sync-enable step has a live manager without
|
|
1125
|
+
* standing up the registry factory. Bootstrap (git init of the internal
|
|
1126
|
+
* mirror) runs for real; push to a fake remote fails non-fatally (we assert
|
|
1127
|
+
* on persisted config + credentials, not on a landed push).
|
|
1128
|
+
*/
|
|
1129
|
+
function makeSyncManager(home: string): MirrorManager {
|
|
1130
|
+
process.env.PARACHUTE_HOME = home;
|
|
1131
|
+
process.env.HOME = home;
|
|
1132
|
+
const deps: MirrorDeps = {
|
|
1133
|
+
vaultName: "default",
|
|
1134
|
+
runExport: async () => ({ notes: 0 }),
|
|
1135
|
+
firstChangedNoteTitle: async () => "",
|
|
1136
|
+
readMirrorConfig: () => readMirrorConfigForVault("default"),
|
|
1137
|
+
writeMirrorConfig: (c) => writeMirrorConfigForVault("default", c),
|
|
1138
|
+
};
|
|
1139
|
+
return new MirrorManager(deps);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1084
1142
|
describe("handleMirrorImport", () => {
|
|
1085
1143
|
let home: string;
|
|
1086
1144
|
let fixture: string;
|
|
@@ -1258,6 +1316,35 @@ describe("handleMirrorImport", () => {
|
|
|
1258
1316
|
expect(body.message).toContain("vault.yaml");
|
|
1259
1317
|
});
|
|
1260
1318
|
|
|
1319
|
+
test("git not installed returns 503 + git_not_installed + actionable message", async () => {
|
|
1320
|
+
// vault#415 — live bug on a git-less Amazon Linux EC2 box. Force the
|
|
1321
|
+
// preflight (via the whichOverride seam) to see no git; the spawn seam
|
|
1322
|
+
// should never be reached.
|
|
1323
|
+
home = tmp("import-route-nogit-");
|
|
1324
|
+
await bootstrapVault(home);
|
|
1325
|
+
let spawnCalled = false;
|
|
1326
|
+
const spyingSpawn: GitSpawn = async () => {
|
|
1327
|
+
spawnCalled = true;
|
|
1328
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
1329
|
+
};
|
|
1330
|
+
const req = new Request("http://x/import", {
|
|
1331
|
+
method: "POST",
|
|
1332
|
+
body: JSON.stringify({
|
|
1333
|
+
remote_url: "https://github.com/a/b.git",
|
|
1334
|
+
mode: "merge",
|
|
1335
|
+
credentials: { kind: "none" },
|
|
1336
|
+
}),
|
|
1337
|
+
});
|
|
1338
|
+
const res = await handleMirrorImport(req, "default", spyingSpawn, () => null);
|
|
1339
|
+
expect(res.status).toBe(503);
|
|
1340
|
+
const body = (await res.json()) as { error_type: string; message: string };
|
|
1341
|
+
expect(body.error_type).toBe("git_not_installed");
|
|
1342
|
+
expect(body.message).toContain("git is required");
|
|
1343
|
+
expect(body.message).toContain("dnf install git");
|
|
1344
|
+
// Failed fast: the git spawn was never reached.
|
|
1345
|
+
expect(spawnCalled).toBe(false);
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1261
1348
|
test("uses stored credentials when credentials: null (credentialsFile path)", async () => {
|
|
1262
1349
|
home = tmp("import-route-stored-creds-");
|
|
1263
1350
|
await bootstrapVault(home);
|
|
@@ -1334,3 +1421,378 @@ describe("handleMirrorImport", () => {
|
|
|
1334
1421
|
});
|
|
1335
1422
|
});
|
|
1336
1423
|
|
|
1424
|
+
// ---------------------------------------------------------------------------
|
|
1425
|
+
// vault#416 — auto-enable sync to the imported repo (default-on, opt-out).
|
|
1426
|
+
// ---------------------------------------------------------------------------
|
|
1427
|
+
|
|
1428
|
+
describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
1429
|
+
let home: string;
|
|
1430
|
+
let fixture: string;
|
|
1431
|
+
let manager: MirrorManager;
|
|
1432
|
+
|
|
1433
|
+
afterEach(async () => {
|
|
1434
|
+
if (manager) await manager.stop();
|
|
1435
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
1436
|
+
if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
|
|
1437
|
+
_resetImportInFlightForTest();
|
|
1438
|
+
clearVaultStoreCache();
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
test("enable_sync true + PAT auth → mirror configured, creds persisted, auto_push on, sync_enabled true", async () => {
|
|
1442
|
+
home = tmp("import-sync-pat-");
|
|
1443
|
+
await bootstrapVault(home);
|
|
1444
|
+
fixture = await buildExportFixture();
|
|
1445
|
+
manager = makeSyncManager(home);
|
|
1446
|
+
|
|
1447
|
+
const req = new Request("http://x/import", {
|
|
1448
|
+
method: "POST",
|
|
1449
|
+
body: JSON.stringify({
|
|
1450
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
1451
|
+
mode: "merge",
|
|
1452
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
1453
|
+
enable_sync: true,
|
|
1454
|
+
}),
|
|
1455
|
+
});
|
|
1456
|
+
const res = await handleMirrorImport(
|
|
1457
|
+
req,
|
|
1458
|
+
"default",
|
|
1459
|
+
spawnCloneSuccess(fixture),
|
|
1460
|
+
undefined,
|
|
1461
|
+
manager,
|
|
1462
|
+
);
|
|
1463
|
+
expect(res.status).toBe(200);
|
|
1464
|
+
const body = (await res.json()) as {
|
|
1465
|
+
notes_imported: number;
|
|
1466
|
+
sync_enabled: boolean;
|
|
1467
|
+
sync_warning?: string;
|
|
1468
|
+
};
|
|
1469
|
+
expect(body.notes_imported).toBe(2);
|
|
1470
|
+
expect(body.sync_enabled).toBe(true);
|
|
1471
|
+
expect(body.sync_warning).toBeUndefined();
|
|
1472
|
+
|
|
1473
|
+
// Mirror config persisted with auto_push + enabled.
|
|
1474
|
+
const cfg = readMirrorConfigForVault("default");
|
|
1475
|
+
expect(cfg?.enabled).toBe(true);
|
|
1476
|
+
expect(cfg?.auto_push).toBe(true);
|
|
1477
|
+
|
|
1478
|
+
// Credentials persisted, pointing at the imported remote.
|
|
1479
|
+
const creds = readCredentials("default");
|
|
1480
|
+
expect(creds?.active_method).toBe("pat");
|
|
1481
|
+
expect(creds?.pat?.token).toBe("ghp_import_token_abc");
|
|
1482
|
+
expect(creds?.pat?.remote_url).toContain("github.com/aaron/my-vault.git");
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
test("enable_sync false → no mirror configured, sync_enabled false, no warning", async () => {
|
|
1486
|
+
home = tmp("import-sync-optout-");
|
|
1487
|
+
await bootstrapVault(home);
|
|
1488
|
+
fixture = await buildExportFixture();
|
|
1489
|
+
manager = makeSyncManager(home);
|
|
1490
|
+
|
|
1491
|
+
const req = new Request("http://x/import", {
|
|
1492
|
+
method: "POST",
|
|
1493
|
+
body: JSON.stringify({
|
|
1494
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
1495
|
+
mode: "merge",
|
|
1496
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
1497
|
+
enable_sync: false,
|
|
1498
|
+
}),
|
|
1499
|
+
});
|
|
1500
|
+
const res = await handleMirrorImport(
|
|
1501
|
+
req,
|
|
1502
|
+
"default",
|
|
1503
|
+
spawnCloneSuccess(fixture),
|
|
1504
|
+
undefined,
|
|
1505
|
+
manager,
|
|
1506
|
+
);
|
|
1507
|
+
expect(res.status).toBe(200);
|
|
1508
|
+
const body = (await res.json()) as {
|
|
1509
|
+
sync_enabled: boolean;
|
|
1510
|
+
sync_warning?: string;
|
|
1511
|
+
};
|
|
1512
|
+
expect(body.sync_enabled).toBe(false);
|
|
1513
|
+
expect(body.sync_warning).toBeUndefined();
|
|
1514
|
+
|
|
1515
|
+
// Nothing configured.
|
|
1516
|
+
expect(readMirrorConfigForVault("default")).toBeUndefined();
|
|
1517
|
+
expect(readCredentials("default")).toBeNull();
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
test("enable_sync true + auth none → sync_enabled false + needs-write-creds warning; no broken mirror", async () => {
|
|
1521
|
+
home = tmp("import-sync-nocreds-");
|
|
1522
|
+
await bootstrapVault(home);
|
|
1523
|
+
fixture = await buildExportFixture();
|
|
1524
|
+
manager = makeSyncManager(home);
|
|
1525
|
+
|
|
1526
|
+
const req = new Request("http://x/import", {
|
|
1527
|
+
method: "POST",
|
|
1528
|
+
body: JSON.stringify({
|
|
1529
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
1530
|
+
mode: "merge",
|
|
1531
|
+
credentials: { kind: "none" },
|
|
1532
|
+
enable_sync: true,
|
|
1533
|
+
}),
|
|
1534
|
+
});
|
|
1535
|
+
const res = await handleMirrorImport(
|
|
1536
|
+
req,
|
|
1537
|
+
"default",
|
|
1538
|
+
spawnCloneSuccess(fixture),
|
|
1539
|
+
undefined,
|
|
1540
|
+
manager,
|
|
1541
|
+
);
|
|
1542
|
+
expect(res.status).toBe(200);
|
|
1543
|
+
const body = (await res.json()) as {
|
|
1544
|
+
notes_imported: number;
|
|
1545
|
+
sync_enabled: boolean;
|
|
1546
|
+
sync_warning?: string;
|
|
1547
|
+
};
|
|
1548
|
+
// Import still succeeded.
|
|
1549
|
+
expect(body.notes_imported).toBe(2);
|
|
1550
|
+
expect(body.sync_enabled).toBe(false);
|
|
1551
|
+
expect(body.sync_warning).toContain("write credentials");
|
|
1552
|
+
|
|
1553
|
+
// No mirror left configured, no credentials written.
|
|
1554
|
+
expect(readMirrorConfigForVault("default")).toBeUndefined();
|
|
1555
|
+
expect(readCredentials("default")).toBeNull();
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
test("enable_sync defaults to true when omitted", async () => {
|
|
1559
|
+
home = tmp("import-sync-default-");
|
|
1560
|
+
await bootstrapVault(home);
|
|
1561
|
+
fixture = await buildExportFixture();
|
|
1562
|
+
manager = makeSyncManager(home);
|
|
1563
|
+
|
|
1564
|
+
const req = new Request("http://x/import", {
|
|
1565
|
+
method: "POST",
|
|
1566
|
+
body: JSON.stringify({
|
|
1567
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
1568
|
+
mode: "merge",
|
|
1569
|
+
credentials: { kind: "pat", token: "ghp_default_on_token" },
|
|
1570
|
+
// enable_sync omitted — should default ON.
|
|
1571
|
+
}),
|
|
1572
|
+
});
|
|
1573
|
+
const res = await handleMirrorImport(
|
|
1574
|
+
req,
|
|
1575
|
+
"default",
|
|
1576
|
+
spawnCloneSuccess(fixture),
|
|
1577
|
+
undefined,
|
|
1578
|
+
manager,
|
|
1579
|
+
);
|
|
1580
|
+
expect(res.status).toBe(200);
|
|
1581
|
+
const body = (await res.json()) as { sync_enabled: boolean };
|
|
1582
|
+
expect(body.sync_enabled).toBe(true);
|
|
1583
|
+
expect(readMirrorConfigForVault("default")?.auto_push).toBe(true);
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
test("existing mirror to a DIFFERENT remote → not clobbered, sync_enabled false + conflict warning", async () => {
|
|
1587
|
+
home = tmp("import-sync-conflict-");
|
|
1588
|
+
await bootstrapVault(home);
|
|
1589
|
+
fixture = await buildExportFixture();
|
|
1590
|
+
manager = makeSyncManager(home);
|
|
1591
|
+
|
|
1592
|
+
// Pre-existing mirror config (enabled) + credential pointing elsewhere.
|
|
1593
|
+
writeMirrorConfigForVault("default", {
|
|
1594
|
+
...defaultMirrorConfig(),
|
|
1595
|
+
enabled: true,
|
|
1596
|
+
auto_push: true,
|
|
1597
|
+
});
|
|
1598
|
+
writeCredentials("default", {
|
|
1599
|
+
active_method: "pat",
|
|
1600
|
+
github_oauth: null,
|
|
1601
|
+
pat: {
|
|
1602
|
+
token: "ghp_existing_other",
|
|
1603
|
+
remote_url:
|
|
1604
|
+
"https://x-access-token:ghp_existing_other@github.com/aaron/OTHER-repo.git",
|
|
1605
|
+
label: "existing backup",
|
|
1606
|
+
},
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
const req = new Request("http://x/import", {
|
|
1610
|
+
method: "POST",
|
|
1611
|
+
body: JSON.stringify({
|
|
1612
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
1613
|
+
mode: "merge",
|
|
1614
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
1615
|
+
enable_sync: true,
|
|
1616
|
+
}),
|
|
1617
|
+
});
|
|
1618
|
+
const res = await handleMirrorImport(
|
|
1619
|
+
req,
|
|
1620
|
+
"default",
|
|
1621
|
+
spawnCloneSuccess(fixture),
|
|
1622
|
+
undefined,
|
|
1623
|
+
manager,
|
|
1624
|
+
);
|
|
1625
|
+
expect(res.status).toBe(200);
|
|
1626
|
+
const body = (await res.json()) as {
|
|
1627
|
+
sync_enabled: boolean;
|
|
1628
|
+
sync_warning?: string;
|
|
1629
|
+
};
|
|
1630
|
+
expect(body.sync_enabled).toBe(false);
|
|
1631
|
+
expect(body.sync_warning).toContain("already syncs to a different repo");
|
|
1632
|
+
|
|
1633
|
+
// The existing credential was NOT clobbered.
|
|
1634
|
+
const creds = readCredentials("default");
|
|
1635
|
+
expect(creds?.pat?.token).toBe("ghp_existing_other");
|
|
1636
|
+
expect(creds?.pat?.remote_url).toContain("OTHER-repo.git");
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
test("existing mirror to the SAME remote → no-op success (sync_enabled true)", async () => {
|
|
1640
|
+
home = tmp("import-sync-same-");
|
|
1641
|
+
await bootstrapVault(home);
|
|
1642
|
+
fixture = await buildExportFixture();
|
|
1643
|
+
manager = makeSyncManager(home);
|
|
1644
|
+
|
|
1645
|
+
writeMirrorConfigForVault("default", {
|
|
1646
|
+
...defaultMirrorConfig(),
|
|
1647
|
+
enabled: true,
|
|
1648
|
+
auto_push: true,
|
|
1649
|
+
});
|
|
1650
|
+
writeCredentials("default", {
|
|
1651
|
+
active_method: "pat",
|
|
1652
|
+
github_oauth: null,
|
|
1653
|
+
pat: {
|
|
1654
|
+
token: "ghp_same_token",
|
|
1655
|
+
remote_url:
|
|
1656
|
+
"https://x-access-token:ghp_same_token@github.com/aaron/my-vault.git",
|
|
1657
|
+
label: "existing same",
|
|
1658
|
+
},
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
const req = new Request("http://x/import", {
|
|
1662
|
+
method: "POST",
|
|
1663
|
+
body: JSON.stringify({
|
|
1664
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
1665
|
+
mode: "merge",
|
|
1666
|
+
credentials: { kind: "pat", token: "ghp_same_token" },
|
|
1667
|
+
enable_sync: true,
|
|
1668
|
+
}),
|
|
1669
|
+
});
|
|
1670
|
+
const res = await handleMirrorImport(
|
|
1671
|
+
req,
|
|
1672
|
+
"default",
|
|
1673
|
+
spawnCloneSuccess(fixture),
|
|
1674
|
+
undefined,
|
|
1675
|
+
manager,
|
|
1676
|
+
);
|
|
1677
|
+
expect(res.status).toBe(200);
|
|
1678
|
+
const body = (await res.json()) as {
|
|
1679
|
+
sync_enabled: boolean;
|
|
1680
|
+
sync_warning?: string;
|
|
1681
|
+
};
|
|
1682
|
+
expect(body.sync_enabled).toBe(true);
|
|
1683
|
+
expect(body.sync_warning).toBeUndefined();
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
test("existing GitHub-connected mirror → PAT import doesn't clobber it (sync_enabled false + warning)", async () => {
|
|
1687
|
+
home = tmp("import-sync-oauth-conflict-");
|
|
1688
|
+
await bootstrapVault(home);
|
|
1689
|
+
fixture = await buildExportFixture();
|
|
1690
|
+
manager = makeSyncManager(home);
|
|
1691
|
+
|
|
1692
|
+
writeMirrorConfigForVault("default", {
|
|
1693
|
+
...defaultMirrorConfig(),
|
|
1694
|
+
enabled: true,
|
|
1695
|
+
auto_push: true,
|
|
1696
|
+
});
|
|
1697
|
+
writeCredentials("default", {
|
|
1698
|
+
active_method: "github_oauth",
|
|
1699
|
+
github_oauth: {
|
|
1700
|
+
access_token: "gho_existing_oauth",
|
|
1701
|
+
scope: "repo",
|
|
1702
|
+
authorized_at: "2026-05-28T00:00:00.000Z",
|
|
1703
|
+
user_login: "aaron",
|
|
1704
|
+
user_id: 1,
|
|
1705
|
+
},
|
|
1706
|
+
pat: null,
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
const req = new Request("http://x/import", {
|
|
1710
|
+
method: "POST",
|
|
1711
|
+
body: JSON.stringify({
|
|
1712
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
1713
|
+
mode: "merge",
|
|
1714
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
1715
|
+
enable_sync: true,
|
|
1716
|
+
}),
|
|
1717
|
+
});
|
|
1718
|
+
const res = await handleMirrorImport(
|
|
1719
|
+
req,
|
|
1720
|
+
"default",
|
|
1721
|
+
spawnCloneSuccess(fixture),
|
|
1722
|
+
undefined,
|
|
1723
|
+
manager,
|
|
1724
|
+
);
|
|
1725
|
+
expect(res.status).toBe(200);
|
|
1726
|
+
const body = (await res.json()) as {
|
|
1727
|
+
sync_enabled: boolean;
|
|
1728
|
+
sync_warning?: string;
|
|
1729
|
+
};
|
|
1730
|
+
expect(body.sync_enabled).toBe(false);
|
|
1731
|
+
expect(body.sync_warning).toContain("connected GitHub account");
|
|
1732
|
+
|
|
1733
|
+
// The existing OAuth credential is untouched (not switched to a PAT).
|
|
1734
|
+
const creds = readCredentials("default");
|
|
1735
|
+
expect(creds?.active_method).toBe("github_oauth");
|
|
1736
|
+
expect(creds?.pat).toBeNull();
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
test("sync-setup failure after a successful import → import result still returned, sync_enabled false + warning", async () => {
|
|
1740
|
+
home = tmp("import-sync-setupfail-");
|
|
1741
|
+
await bootstrapVault(home);
|
|
1742
|
+
fixture = await buildExportFixture();
|
|
1743
|
+
manager = makeSyncManager(home);
|
|
1744
|
+
|
|
1745
|
+
// Force the sync-enable step to throw by stubbing the manager's reload.
|
|
1746
|
+
// The import itself has already succeeded by the time reload runs, so the
|
|
1747
|
+
// request must still return a 200 with the import counts intact.
|
|
1748
|
+
manager.reload = async () => {
|
|
1749
|
+
throw new Error("boom: simulated reload failure");
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
const req = new Request("http://x/import", {
|
|
1753
|
+
method: "POST",
|
|
1754
|
+
body: JSON.stringify({
|
|
1755
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
1756
|
+
mode: "merge",
|
|
1757
|
+
credentials: { kind: "pat", token: "ghp_import_token_abc" },
|
|
1758
|
+
enable_sync: true,
|
|
1759
|
+
}),
|
|
1760
|
+
});
|
|
1761
|
+
const res = await handleMirrorImport(
|
|
1762
|
+
req,
|
|
1763
|
+
"default",
|
|
1764
|
+
spawnCloneSuccess(fixture),
|
|
1765
|
+
undefined,
|
|
1766
|
+
manager,
|
|
1767
|
+
);
|
|
1768
|
+
expect(res.status).toBe(200);
|
|
1769
|
+
const body = (await res.json()) as {
|
|
1770
|
+
notes_imported: number;
|
|
1771
|
+
sync_enabled: boolean;
|
|
1772
|
+
sync_warning?: string;
|
|
1773
|
+
};
|
|
1774
|
+
// Import NOT lost.
|
|
1775
|
+
expect(body.notes_imported).toBe(2);
|
|
1776
|
+
expect(body.sync_enabled).toBe(false);
|
|
1777
|
+
expect(body.sync_warning).toContain("enabling Sync failed");
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
test("invalid enable_sync type → 400 validation error", async () => {
|
|
1781
|
+
home = tmp("import-sync-badtype-");
|
|
1782
|
+
await bootstrapVault(home);
|
|
1783
|
+
const req = new Request("http://x/import", {
|
|
1784
|
+
method: "POST",
|
|
1785
|
+
body: JSON.stringify({
|
|
1786
|
+
remote_url: "https://github.com/aaron/my-vault.git",
|
|
1787
|
+
mode: "merge",
|
|
1788
|
+
credentials: { kind: "none" },
|
|
1789
|
+
enable_sync: "yes",
|
|
1790
|
+
}),
|
|
1791
|
+
});
|
|
1792
|
+
const res = await handleMirrorImport(req, "default");
|
|
1793
|
+
expect(res.status).toBe(400);
|
|
1794
|
+
const body = (await res.json()) as { field: string };
|
|
1795
|
+
expect(body.field).toBe("enable_sync");
|
|
1796
|
+
});
|
|
1797
|
+
});
|
|
1798
|
+
|