@openparachute/vault 0.4.8 → 0.4.9-rc.11
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/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/src/mirror-deps.ts
CHANGED
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
* vault-store + portable-md.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { exportVaultToDir } from "../core/src/portable-md.ts";
|
|
10
|
+
import { exportVaultToDir, hasSchemaContent, pruneOrphans } from "../core/src/portable-md.ts";
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
13
|
+
import { readGlobalConfig, readVaultConfig } from "./config.ts";
|
|
14
|
+
import {
|
|
15
|
+
defaultMirrorConfig,
|
|
16
|
+
readMirrorConfigForVault,
|
|
17
|
+
writeMirrorConfigForVault,
|
|
18
|
+
type MirrorConfig,
|
|
19
|
+
} from "./mirror-config.ts";
|
|
14
20
|
import type { MirrorDeps } from "./mirror-manager.ts";
|
|
15
21
|
import { assetsDir } from "./routes.ts";
|
|
16
22
|
import { getVaultStore } from "./vault-store.ts";
|
|
@@ -23,9 +29,9 @@ import { getVaultStore } from "./vault-store.ts";
|
|
|
23
29
|
* CLI mode exactly.
|
|
24
30
|
* - `firstChangedNoteTitle` → DB query for the most recent note with
|
|
25
31
|
* `updated_at >= cursor`. Identical to the CLI helper.
|
|
26
|
-
* - `readMirrorConfig` / `writeMirrorConfig` →
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
32
|
+
* - `readMirrorConfig` / `writeMirrorConfig` → per-vault config file at
|
|
33
|
+
* `data/<vault>/mirror-config.yaml` (vault#400). Each vault carries its
|
|
34
|
+
* own mirror config, so configuring vault B never touches vault A's.
|
|
29
35
|
*/
|
|
30
36
|
export function buildMirrorDeps(vaultName: string): MirrorDeps {
|
|
31
37
|
return {
|
|
@@ -42,6 +48,41 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
|
|
|
42
48
|
});
|
|
43
49
|
return { notes: stats.notes };
|
|
44
50
|
},
|
|
51
|
+
runPrune: async ({ outDir }) => {
|
|
52
|
+
const store = getVaultStore(vaultName);
|
|
53
|
+
// Build the valid-id sets the prune sweep needs. Single-query
|
|
54
|
+
// walk per dimension; cheap on typical vaults.
|
|
55
|
+
const allNotes = await store.queryNotes({ limit: 1_000_000, sort: "asc" });
|
|
56
|
+
const validNoteIds = new Set(allNotes.map((n) => n.id));
|
|
57
|
+
// Tag names with schema content drive the schema sidecars. Filter
|
|
58
|
+
// through `hasSchemaContent` — a tag whose schema content was wiped
|
|
59
|
+
// via `deleteTagSchema` keeps its tags-table row (bare name), so a
|
|
60
|
+
// map-by-name set would leave the stale sidecar in the mirror
|
|
61
|
+
// indefinitely. Only schema-bearing tags belong in this set.
|
|
62
|
+
// Reviewer-flagged on vault#382 (Critical #1).
|
|
63
|
+
const tagRecords = await store.listTagRecords();
|
|
64
|
+
const validTagNames = new Set(
|
|
65
|
+
tagRecords.filter((t) => hasSchemaContent(t)).map((t) => t.tag),
|
|
66
|
+
);
|
|
67
|
+
// Attachment IDs across all notes (the prune sweep keys on id).
|
|
68
|
+
const validAttachmentIds = new Set<string>();
|
|
69
|
+
for (const note of allNotes) {
|
|
70
|
+
const atts = await store.getAttachments(note.id);
|
|
71
|
+
for (const a of atts) validAttachmentIds.add(a.id);
|
|
72
|
+
}
|
|
73
|
+
const stats = pruneOrphans({
|
|
74
|
+
outDir,
|
|
75
|
+
validNoteIds,
|
|
76
|
+
validTagNames,
|
|
77
|
+
validAttachmentIds,
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
notes_removed: stats.notes_removed,
|
|
81
|
+
sidecars_removed: stats.sidecars_removed,
|
|
82
|
+
schemas_removed: stats.schemas_removed,
|
|
83
|
+
attachment_dirs_removed: stats.attachment_dirs_removed,
|
|
84
|
+
};
|
|
85
|
+
},
|
|
45
86
|
firstChangedNoteTitle: async (cursor) => {
|
|
46
87
|
if (!cursor) return "";
|
|
47
88
|
try {
|
|
@@ -56,24 +97,33 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
|
|
|
56
97
|
return "";
|
|
57
98
|
}
|
|
58
99
|
},
|
|
59
|
-
|
|
100
|
+
// Per-vault (vault#400): read/write THIS vault's own config file, never
|
|
101
|
+
// a shared server-wide block. Configuring vault B's mirror leaves vault
|
|
102
|
+
// A's config untouched.
|
|
103
|
+
readMirrorConfig: () => readMirrorConfigForVault(vaultName),
|
|
60
104
|
writeMirrorConfig: (config: MirrorConfig) => {
|
|
61
|
-
|
|
62
|
-
global.mirror = config;
|
|
63
|
-
writeGlobalConfig(global);
|
|
105
|
+
writeMirrorConfigForVault(vaultName, config);
|
|
64
106
|
},
|
|
107
|
+
// Share the process-wide hook registry so mirror's subscriptions land
|
|
108
|
+
// on the same event bus that `BunSqliteStore` dispatches on. This is
|
|
109
|
+
// load-bearing for the event-driven path; without it, the manager
|
|
110
|
+
// falls back to safety-net polling only.
|
|
111
|
+
hooks: defaultHookRegistry,
|
|
65
112
|
};
|
|
66
113
|
}
|
|
67
114
|
|
|
68
115
|
/**
|
|
69
|
-
* Resolve the mirror's owning vault
|
|
70
|
-
*
|
|
71
|
-
* `default_vault` (the same vault the CLI + MCP wire up by default).
|
|
72
|
-
* If no default is set, fall back to the first listed vault.
|
|
116
|
+
* Resolve the mirror's "owning" vault — the one the LEGACY server-wide
|
|
117
|
+
* config + credentials are attributed to during migration.
|
|
73
118
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
119
|
+
* Post-vault#400 every vault has its own mirror config + manager (real
|
|
120
|
+
* multi-vault mirroring), so this is no longer "the one vault that can
|
|
121
|
+
* mirror." It survives as the migration-attribution target: the legacy
|
|
122
|
+
* server-wide `mirror:` block (vault#400) and the legacy server-wide
|
|
123
|
+
* credentials file (vault#399) belong to the vault the single old mirror
|
|
124
|
+
* was bound to — `default_vault`, or the first listed vault when no default
|
|
125
|
+
* is set. Localizing the binding here keeps the migration attribution in one
|
|
126
|
+
* place.
|
|
77
127
|
*/
|
|
78
128
|
export function resolveMirrorVaultName(
|
|
79
129
|
listVaults: () => string[],
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the clone-and-import worker (vault#391).
|
|
3
|
+
*
|
|
4
|
+
* Mocks the git binary via the `spawn` injection point so we don't
|
|
5
|
+
* actually clone anything in tests — instead we pre-populate the tempdir
|
|
6
|
+
* the fake "clone" claims to have written, or have the fake return a
|
|
7
|
+
* non-zero exit code to exercise the failure paths.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import {
|
|
13
|
+
cpSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
mkdtempSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
24
|
+
import { exportVaultToDir } from "../core/src/portable-md.ts";
|
|
25
|
+
import {
|
|
26
|
+
CloneFailedError,
|
|
27
|
+
ImportConflictError,
|
|
28
|
+
NotAVaultExportError,
|
|
29
|
+
_isImportInFlight,
|
|
30
|
+
_resetImportInFlightForTest,
|
|
31
|
+
authedCloneUrl,
|
|
32
|
+
cloneAndImport,
|
|
33
|
+
type GitSpawn,
|
|
34
|
+
} from "./mirror-import.ts";
|
|
35
|
+
import {
|
|
36
|
+
emptyCredentials,
|
|
37
|
+
mirrorCredentialsPath,
|
|
38
|
+
writeCredentials,
|
|
39
|
+
type MirrorCredentials,
|
|
40
|
+
} from "./mirror-credentials.ts";
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function tmp(prefix: string): string {
|
|
47
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a real portable-md export on disk + return its path. Used by tests
|
|
52
|
+
* that need a valid clone-target so the import succeeds.
|
|
53
|
+
*/
|
|
54
|
+
async function buildExportFixture(opts?: { extraNotes?: number }): Promise<string> {
|
|
55
|
+
const extraNotes = opts?.extraNotes ?? 0;
|
|
56
|
+
const store = new SqliteStore(new Database(":memory:"));
|
|
57
|
+
await store.createNote("alpha body", { id: "n-alpha", path: "alpha", tags: ["t1"] });
|
|
58
|
+
await store.createNote("beta body", { id: "n-beta", path: "beta" });
|
|
59
|
+
for (let i = 0; i < extraNotes; i++) {
|
|
60
|
+
await store.createNote(`extra ${i}`, { id: `n-extra-${i}`, path: `extra-${i}` });
|
|
61
|
+
}
|
|
62
|
+
const exportDir = tmp("import-fixture-export-");
|
|
63
|
+
await exportVaultToDir(store, {
|
|
64
|
+
outDir: exportDir,
|
|
65
|
+
vaultName: "source",
|
|
66
|
+
exportedAt: "2026-05-28T00:00:00.000Z",
|
|
67
|
+
});
|
|
68
|
+
return exportDir;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a fake spawn that copies a pre-baked fixture into the tempdir
|
|
73
|
+
* the importer expects. Mimics what `git clone <url> <tempDir>` would do
|
|
74
|
+
* on success: populate the tempDir with the fixture's contents.
|
|
75
|
+
*/
|
|
76
|
+
function spawnCloneSuccess(fixtureDir: string): GitSpawn {
|
|
77
|
+
return async (argv) => {
|
|
78
|
+
// argv = ["git", "clone", "--depth", "1", <url>, <destDir>]
|
|
79
|
+
expect(argv[0]).toBe("git");
|
|
80
|
+
expect(argv[1]).toBe("clone");
|
|
81
|
+
const destDir = argv[argv.length - 1]!;
|
|
82
|
+
// Copy fixture into the destination. The destination already exists
|
|
83
|
+
// (mkdtempSync created it), so use cpSync's `recursive` mode.
|
|
84
|
+
cpSync(fixtureDir, destDir, { recursive: true });
|
|
85
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const spawnCloneFailure = (stderr: string): GitSpawn =>
|
|
90
|
+
async () => ({ exitCode: 128, stderr, timedOut: false });
|
|
91
|
+
|
|
92
|
+
const spawnCloneTimeout: GitSpawn = async () => ({
|
|
93
|
+
exitCode: -1,
|
|
94
|
+
stderr: "",
|
|
95
|
+
timedOut: true,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const ORIG_HOME = process.env.HOME;
|
|
99
|
+
const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
_resetImportInFlightForTest();
|
|
103
|
+
if (ORIG_HOME === undefined) delete process.env.HOME;
|
|
104
|
+
else process.env.HOME = ORIG_HOME;
|
|
105
|
+
if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
|
|
106
|
+
else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// authedCloneUrl
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
describe("authedCloneUrl", () => {
|
|
114
|
+
test("returns null for unparseable URL", () => {
|
|
115
|
+
expect(authedCloneUrl("not-a-url", { kind: "none" }, "v")).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("passes git:// URLs verbatim (no userinfo to embed)", () => {
|
|
119
|
+
// `git://github.com/owner/repo.git` parses as a URL with protocol
|
|
120
|
+
// `git:`, not http/https — our helper returns it verbatim.
|
|
121
|
+
const r = authedCloneUrl("git://github.com/owner/repo.git", { kind: "pat", token: "ghp_x" }, "v");
|
|
122
|
+
expect(r).not.toBeNull();
|
|
123
|
+
expect(r!.authedUrl).toBe("git://github.com/owner/repo.git");
|
|
124
|
+
expect(r!.appliedAuth).toBe("none");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Reviewer-flagged on vault#390 — the original ssh-shorthand assertion
|
|
128
|
+
// accidentally tested `git://` instead of the `git@host:owner/repo`
|
|
129
|
+
// shape, leaving the ssh-shorthand regex branch uncovered. SSH
|
|
130
|
+
// shorthand doesn't parse as `new URL()` (no scheme), so authedCloneUrl
|
|
131
|
+
// falls through the regex matcher and returns the URL verbatim — there
|
|
132
|
+
// is no userinfo slot to embed a token into. This test pins that path.
|
|
133
|
+
test("passes ssh-shorthand URLs verbatim (no scheme, no userinfo slot)", () => {
|
|
134
|
+
const r = authedCloneUrl("git@github.com:owner/repo.git", {
|
|
135
|
+
kind: "pat",
|
|
136
|
+
token: "ghp_should_not_appear",
|
|
137
|
+
}, "v");
|
|
138
|
+
expect(r).not.toBeNull();
|
|
139
|
+
expect(r!.authedUrl).toBe("git@github.com:owner/repo.git");
|
|
140
|
+
expect(r!.authedUrl).not.toContain("ghp_should_not_appear");
|
|
141
|
+
expect(r!.appliedAuth).toBe("none");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("passes ssh:// URLs verbatim", () => {
|
|
145
|
+
const r = authedCloneUrl("ssh://git@github.com/owner/repo.git", {
|
|
146
|
+
kind: "pat",
|
|
147
|
+
token: "ghp_x",
|
|
148
|
+
}, "v");
|
|
149
|
+
expect(r).not.toBeNull();
|
|
150
|
+
expect(r!.authedUrl).toBe("ssh://git@github.com/owner/repo.git");
|
|
151
|
+
expect(r!.appliedAuth).toBe("none");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("does not override URL that already carries userinfo", () => {
|
|
155
|
+
const r = authedCloneUrl("https://user:pass@github.com/owner/repo.git", {
|
|
156
|
+
kind: "pat",
|
|
157
|
+
token: "ghp_x",
|
|
158
|
+
}, "v");
|
|
159
|
+
expect(r).not.toBeNull();
|
|
160
|
+
expect(r!.authedUrl).toContain("user:pass@");
|
|
161
|
+
expect(r!.appliedAuth).toBe("none");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("per-call PAT embeds x-access-token user", () => {
|
|
165
|
+
const r = authedCloneUrl("https://github.com/owner/repo.git", {
|
|
166
|
+
kind: "pat",
|
|
167
|
+
token: "ghp_abc123",
|
|
168
|
+
}, "v");
|
|
169
|
+
expect(r).not.toBeNull();
|
|
170
|
+
expect(r!.authedUrl).toContain("x-access-token:ghp_abc123@");
|
|
171
|
+
expect(r!.appliedAuth).toBe("per_call_pat");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("none auth returns verbatim URL", () => {
|
|
175
|
+
const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "none" }, "v");
|
|
176
|
+
expect(r).not.toBeNull();
|
|
177
|
+
expect(r!.authedUrl).toBe("https://github.com/owner/repo.git");
|
|
178
|
+
expect(r!.appliedAuth).toBe("none");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("credentialsFile path", () => {
|
|
182
|
+
let home: string;
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
home = tmp("import-creds-");
|
|
185
|
+
process.env.PARACHUTE_HOME = home;
|
|
186
|
+
process.env.HOME = home;
|
|
187
|
+
});
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
if (home) rmSync(home, { recursive: true, force: true });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("no credentials file → verbatim URL", () => {
|
|
193
|
+
const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" }, "default");
|
|
194
|
+
expect(r!.appliedAuth).toBe("none");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("stored github_oauth credentials → embed token on github.com", () => {
|
|
198
|
+
const creds: MirrorCredentials = {
|
|
199
|
+
...emptyCredentials(),
|
|
200
|
+
active_method: "github_oauth",
|
|
201
|
+
github_oauth: {
|
|
202
|
+
access_token: "gho_abc",
|
|
203
|
+
scope: "repo",
|
|
204
|
+
authorized_at: "2026-05-28T00:00:00.000Z",
|
|
205
|
+
user_login: "aaron",
|
|
206
|
+
user_id: 1,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
writeCredentials("default", creds);
|
|
210
|
+
const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" }, "default");
|
|
211
|
+
expect(r!.authedUrl).toContain("x-access-token:gho_abc@");
|
|
212
|
+
expect(r!.appliedAuth).toBe("stored_oauth");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("stored github_oauth + non-github host → verbatim (OAuth token is useless off-host)", () => {
|
|
216
|
+
const creds: MirrorCredentials = {
|
|
217
|
+
...emptyCredentials(),
|
|
218
|
+
active_method: "github_oauth",
|
|
219
|
+
github_oauth: {
|
|
220
|
+
access_token: "gho_abc",
|
|
221
|
+
scope: "repo",
|
|
222
|
+
authorized_at: "2026-05-28T00:00:00.000Z",
|
|
223
|
+
user_login: "aaron",
|
|
224
|
+
user_id: 1,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
writeCredentials("default", creds);
|
|
228
|
+
const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" }, "default");
|
|
229
|
+
expect(r!.appliedAuth).toBe("none");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("stored PAT + matching host → embed token", () => {
|
|
233
|
+
const creds: MirrorCredentials = {
|
|
234
|
+
...emptyCredentials(),
|
|
235
|
+
active_method: "pat",
|
|
236
|
+
pat: {
|
|
237
|
+
token: "glpat_xyz",
|
|
238
|
+
remote_url: "https://x-access-token:glpat_xyz@gitlab.com/owner/repo.git",
|
|
239
|
+
label: "GitLab PAT",
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
writeCredentials("default", creds);
|
|
243
|
+
const r = authedCloneUrl("https://gitlab.com/owner/repo.git", { kind: "credentialsFile" }, "default");
|
|
244
|
+
expect(r!.authedUrl).toContain("x-access-token:glpat_xyz@");
|
|
245
|
+
expect(r!.appliedAuth).toBe("stored_pat");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("stored PAT + non-matching host → verbatim", () => {
|
|
249
|
+
const creds: MirrorCredentials = {
|
|
250
|
+
...emptyCredentials(),
|
|
251
|
+
active_method: "pat",
|
|
252
|
+
pat: {
|
|
253
|
+
token: "glpat_xyz",
|
|
254
|
+
remote_url: "https://x-access-token:glpat_xyz@gitlab.com/owner/repo.git",
|
|
255
|
+
label: "GitLab PAT",
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
writeCredentials("default", creds);
|
|
259
|
+
const r = authedCloneUrl("https://github.com/owner/repo.git", { kind: "credentialsFile" }, "default");
|
|
260
|
+
expect(r!.appliedAuth).toBe("none");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// cloneAndImport — success path
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
describe("cloneAndImport — success", () => {
|
|
270
|
+
let fixtureDir: string;
|
|
271
|
+
let assetsDir: string;
|
|
272
|
+
let store: SqliteStore;
|
|
273
|
+
|
|
274
|
+
beforeEach(async () => {
|
|
275
|
+
fixtureDir = await buildExportFixture();
|
|
276
|
+
assetsDir = tmp("import-assets-");
|
|
277
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
afterEach(() => {
|
|
281
|
+
if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true });
|
|
282
|
+
if (assetsDir) rmSync(assetsDir, { recursive: true, force: true });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("merge mode — imports notes from a fixture export", async () => {
|
|
286
|
+
const result = await cloneAndImport({
|
|
287
|
+
vaultName: "default",
|
|
288
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
289
|
+
auth: { kind: "none" },
|
|
290
|
+
mode: "merge",
|
|
291
|
+
store,
|
|
292
|
+
assetsDir,
|
|
293
|
+
spawn: spawnCloneSuccess(fixtureDir),
|
|
294
|
+
});
|
|
295
|
+
expect(result.notes_imported).toBe(2); // alpha + beta
|
|
296
|
+
expect(result.notes_deleted).toBeUndefined();
|
|
297
|
+
expect(result.warnings).toEqual([]);
|
|
298
|
+
|
|
299
|
+
const restored = await store.getNote("n-alpha");
|
|
300
|
+
expect(restored).toBeTruthy();
|
|
301
|
+
expect(restored!.content.trimEnd()).toBe("alpha body");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("merge mode — preserves existing notes that aren't in the remote", async () => {
|
|
305
|
+
// Seed a local note that the remote doesn't carry.
|
|
306
|
+
await store.createNote("local-only", { id: "n-local", path: "local" });
|
|
307
|
+
const result = await cloneAndImport({
|
|
308
|
+
vaultName: "default",
|
|
309
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
310
|
+
auth: { kind: "none" },
|
|
311
|
+
mode: "merge",
|
|
312
|
+
store,
|
|
313
|
+
assetsDir,
|
|
314
|
+
spawn: spawnCloneSuccess(fixtureDir),
|
|
315
|
+
});
|
|
316
|
+
expect(result.notes_imported).toBe(2);
|
|
317
|
+
expect(result.notes_deleted).toBeUndefined();
|
|
318
|
+
// Local note survives.
|
|
319
|
+
const localStill = await store.getNote("n-local");
|
|
320
|
+
expect(localStill).toBeTruthy();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("replace mode — wipes existing notes, sets notes_deleted", async () => {
|
|
324
|
+
await store.createNote("local-only", { id: "n-local", path: "local" });
|
|
325
|
+
const result = await cloneAndImport({
|
|
326
|
+
vaultName: "default",
|
|
327
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
328
|
+
auth: { kind: "none" },
|
|
329
|
+
mode: "replace",
|
|
330
|
+
store,
|
|
331
|
+
assetsDir,
|
|
332
|
+
spawn: spawnCloneSuccess(fixtureDir),
|
|
333
|
+
});
|
|
334
|
+
expect(result.notes_imported).toBe(2);
|
|
335
|
+
expect(result.notes_deleted).toBe(1);
|
|
336
|
+
// Local note got wiped before the import replayed.
|
|
337
|
+
const localGone = await store.getNote("n-local");
|
|
338
|
+
expect(localGone).toBeNull();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("cleans up tempdir on success", async () => {
|
|
342
|
+
const workDirRoot = tmp("import-workroot-");
|
|
343
|
+
await cloneAndImport({
|
|
344
|
+
vaultName: "default",
|
|
345
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
346
|
+
auth: { kind: "none" },
|
|
347
|
+
mode: "merge",
|
|
348
|
+
store,
|
|
349
|
+
assetsDir,
|
|
350
|
+
spawn: spawnCloneSuccess(fixtureDir),
|
|
351
|
+
workDirRoot,
|
|
352
|
+
});
|
|
353
|
+
// workDirRoot itself still exists; the parachute-import-<rand> subdir
|
|
354
|
+
// inside it should be gone.
|
|
355
|
+
const { readdirSync } = await import("node:fs");
|
|
356
|
+
const entries = readdirSync(workDirRoot);
|
|
357
|
+
expect(entries.filter((e) => e.startsWith("parachute-import-"))).toEqual([]);
|
|
358
|
+
rmSync(workDirRoot, { recursive: true, force: true });
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// cloneAndImport — failure paths
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
describe("cloneAndImport — failures", () => {
|
|
367
|
+
let assetsDir: string;
|
|
368
|
+
let store: SqliteStore;
|
|
369
|
+
|
|
370
|
+
beforeEach(() => {
|
|
371
|
+
assetsDir = tmp("import-assets-fail-");
|
|
372
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
afterEach(() => {
|
|
376
|
+
if (assetsDir) rmSync(assetsDir, { recursive: true, force: true });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("invalid URL → CloneFailedError before any spawn", async () => {
|
|
380
|
+
let spawnCalled = false;
|
|
381
|
+
const fakeSpawn: GitSpawn = async () => {
|
|
382
|
+
spawnCalled = true;
|
|
383
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
384
|
+
};
|
|
385
|
+
await expect(
|
|
386
|
+
cloneAndImport({
|
|
387
|
+
vaultName: "default",
|
|
388
|
+
remoteUrl: "not-a-url",
|
|
389
|
+
auth: { kind: "none" },
|
|
390
|
+
mode: "merge",
|
|
391
|
+
store,
|
|
392
|
+
assetsDir,
|
|
393
|
+
spawn: fakeSpawn,
|
|
394
|
+
}),
|
|
395
|
+
).rejects.toThrow(CloneFailedError);
|
|
396
|
+
expect(spawnCalled).toBe(false);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("git clone non-zero exit → CloneFailedError with redacted stderr", async () => {
|
|
400
|
+
await expect(
|
|
401
|
+
cloneAndImport({
|
|
402
|
+
vaultName: "default",
|
|
403
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
404
|
+
auth: { kind: "pat", token: "ghp_secret" },
|
|
405
|
+
mode: "merge",
|
|
406
|
+
store,
|
|
407
|
+
assetsDir,
|
|
408
|
+
spawn: spawnCloneFailure(
|
|
409
|
+
"fatal: could not read Password for 'https://x-access-token:ghp_secret@github.com'",
|
|
410
|
+
),
|
|
411
|
+
}),
|
|
412
|
+
).rejects.toThrow(/git clone failed/);
|
|
413
|
+
|
|
414
|
+
// Re-run to capture the error message; ensure the token is redacted.
|
|
415
|
+
try {
|
|
416
|
+
await cloneAndImport({
|
|
417
|
+
vaultName: "default",
|
|
418
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
419
|
+
auth: { kind: "pat", token: "ghp_secret" },
|
|
420
|
+
mode: "merge",
|
|
421
|
+
store,
|
|
422
|
+
assetsDir,
|
|
423
|
+
spawn: spawnCloneFailure(
|
|
424
|
+
"fatal: could not read Password for 'https://x-access-token:ghp_secret@github.com'",
|
|
425
|
+
),
|
|
426
|
+
});
|
|
427
|
+
} catch (err) {
|
|
428
|
+
const message = (err as Error).message;
|
|
429
|
+
expect(message).not.toContain("ghp_secret");
|
|
430
|
+
expect(message).toContain("***@");
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("clone timeout → CloneFailedError mentioning timeout", async () => {
|
|
435
|
+
await expect(
|
|
436
|
+
cloneAndImport({
|
|
437
|
+
vaultName: "default",
|
|
438
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
439
|
+
auth: { kind: "none" },
|
|
440
|
+
mode: "merge",
|
|
441
|
+
store,
|
|
442
|
+
assetsDir,
|
|
443
|
+
spawn: spawnCloneTimeout,
|
|
444
|
+
cloneTimeoutMs: 100,
|
|
445
|
+
}),
|
|
446
|
+
).rejects.toThrow(/timed out/);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("clone target lacks .parachute/vault.yaml → NotAVaultExportError", async () => {
|
|
450
|
+
const notAnExport = tmp("import-not-export-");
|
|
451
|
+
writeFileSync(join(notAnExport, "README.md"), "hello");
|
|
452
|
+
const fakeSpawn: GitSpawn = async (argv) => {
|
|
453
|
+
const dest = argv[argv.length - 1]!;
|
|
454
|
+
cpSync(notAnExport, dest, { recursive: true });
|
|
455
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
456
|
+
};
|
|
457
|
+
await expect(
|
|
458
|
+
cloneAndImport({
|
|
459
|
+
vaultName: "default",
|
|
460
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
461
|
+
auth: { kind: "none" },
|
|
462
|
+
mode: "merge",
|
|
463
|
+
store,
|
|
464
|
+
assetsDir,
|
|
465
|
+
spawn: fakeSpawn,
|
|
466
|
+
}),
|
|
467
|
+
).rejects.toThrow(NotAVaultExportError);
|
|
468
|
+
rmSync(notAnExport, { recursive: true, force: true });
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("concurrent imports against the same vault → ImportConflictError", async () => {
|
|
472
|
+
// Build a real fixture so the long-running clone has something
|
|
473
|
+
// valid to find.
|
|
474
|
+
const fixture = await buildExportFixture();
|
|
475
|
+
|
|
476
|
+
// First import — slow spawn (resolves on the next tick), but starts
|
|
477
|
+
// immediately.
|
|
478
|
+
let firstSpawnGate: (v?: unknown) => void;
|
|
479
|
+
const firstSpawn: GitSpawn = async (argv) => {
|
|
480
|
+
await new Promise((res) => {
|
|
481
|
+
firstSpawnGate = res;
|
|
482
|
+
});
|
|
483
|
+
const dest = argv[argv.length - 1]!;
|
|
484
|
+
cpSync(fixture, dest, { recursive: true });
|
|
485
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const firstPromise = cloneAndImport({
|
|
489
|
+
vaultName: "default",
|
|
490
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
491
|
+
auth: { kind: "none" },
|
|
492
|
+
mode: "merge",
|
|
493
|
+
store,
|
|
494
|
+
assetsDir,
|
|
495
|
+
spawn: firstSpawn,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Wait a tick so the inFlight set has populated.
|
|
499
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
500
|
+
expect(_isImportInFlight("default")).toBe(true);
|
|
501
|
+
|
|
502
|
+
// Second import — should immediately reject.
|
|
503
|
+
await expect(
|
|
504
|
+
cloneAndImport({
|
|
505
|
+
vaultName: "default",
|
|
506
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
507
|
+
auth: { kind: "none" },
|
|
508
|
+
mode: "merge",
|
|
509
|
+
store,
|
|
510
|
+
assetsDir,
|
|
511
|
+
spawn: spawnCloneSuccess(fixture),
|
|
512
|
+
}),
|
|
513
|
+
).rejects.toThrow(ImportConflictError);
|
|
514
|
+
|
|
515
|
+
// Let the first import finish.
|
|
516
|
+
firstSpawnGate!();
|
|
517
|
+
await firstPromise;
|
|
518
|
+
expect(_isImportInFlight("default")).toBe(false);
|
|
519
|
+
|
|
520
|
+
rmSync(fixture, { recursive: true, force: true });
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("cleans up tempdir even when import throws", async () => {
|
|
524
|
+
const workDirRoot = tmp("import-workroot-fail-");
|
|
525
|
+
const notAnExport = tmp("import-not-export-fail-");
|
|
526
|
+
writeFileSync(join(notAnExport, "README.md"), "hello");
|
|
527
|
+
const fakeSpawn: GitSpawn = async (argv) => {
|
|
528
|
+
const dest = argv[argv.length - 1]!;
|
|
529
|
+
cpSync(notAnExport, dest, { recursive: true });
|
|
530
|
+
return { exitCode: 0, stderr: "", timedOut: false };
|
|
531
|
+
};
|
|
532
|
+
await expect(
|
|
533
|
+
cloneAndImport({
|
|
534
|
+
vaultName: "default",
|
|
535
|
+
remoteUrl: "https://github.com/owner/repo.git",
|
|
536
|
+
auth: { kind: "none" },
|
|
537
|
+
mode: "merge",
|
|
538
|
+
store,
|
|
539
|
+
assetsDir,
|
|
540
|
+
spawn: fakeSpawn,
|
|
541
|
+
workDirRoot,
|
|
542
|
+
}),
|
|
543
|
+
).rejects.toThrow();
|
|
544
|
+
const { readdirSync } = await import("node:fs");
|
|
545
|
+
const entries = readdirSync(workDirRoot);
|
|
546
|
+
expect(entries.filter((e) => e.startsWith("parachute-import-"))).toEqual([]);
|
|
547
|
+
rmSync(workDirRoot, { recursive: true, force: true });
|
|
548
|
+
rmSync(notAnExport, { recursive: true, force: true });
|
|
549
|
+
});
|
|
550
|
+
});
|