@openparachute/vault 0.4.8 → 0.4.9-rc.10
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/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/mcp.ts +35 -0
- package/core/src/portable-md.test.ts +252 -1
- package/core/src/portable-md.ts +370 -2
- package/core/src/schema.ts +51 -2
- package/core/src/store.ts +68 -2
- package/package.json +1 -1
- package/src/auth.ts +29 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- 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/mcp-http.ts +24 -36
- package/src/mcp-tools.ts +286 -2
- package/src/mirror-config.test.ts +184 -14
- package/src/mirror-config.ts +220 -24
- package/src/mirror-credentials.test.ts +450 -0
- package/src/mirror-credentials.ts +577 -0
- package/src/mirror-deps.ts +42 -1
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +484 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +579 -62
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1096 -5
- package/src/module-config.ts +11 -5
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +165 -1
- package/src/server.ts +21 -8
- package/src/token-store.ts +158 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +380 -1
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clone + import worker — symmetric counterpart to the export path that
|
|
3
|
+
* vault#382 + vault#384 shipped.
|
|
4
|
+
*
|
|
5
|
+
* The "import from a git repo" feature lets an operator on machine B pull a
|
|
6
|
+
* vault state that machine A has been pushing to a git remote. The flow:
|
|
7
|
+
*
|
|
8
|
+
* 1. Operator opens the admin SPA's "Import from git" section.
|
|
9
|
+
* 2. Picks a remote URL (paste, OAuth repo-picker, or a one-time PAT).
|
|
10
|
+
* 3. Picks a mode — `merge` (upsert-by-id, preserves notes that aren't
|
|
11
|
+
* in the remote) or `replace` (wipe-then-import, the remote becomes
|
|
12
|
+
* the new source of truth).
|
|
13
|
+
* 4. POSTs to `/.parachute/mirror/import`. The handler delegates to
|
|
14
|
+
* `cloneAndImport()` below.
|
|
15
|
+
*
|
|
16
|
+
* `cloneAndImport()`:
|
|
17
|
+
*
|
|
18
|
+
* - Creates a temp dir (`os.tmpdir() + /parachute-import-<rand>`).
|
|
19
|
+
* - Resolves the authed clone URL (stored credentials, supplied per-call
|
|
20
|
+
* PAT, or none).
|
|
21
|
+
* - Shells `git clone --depth 1 <authedUrl> <tempDir>` with a 60s
|
|
22
|
+
* timeout and `GIT_TERMINAL_PROMPT=0` so bad credentials fail fast
|
|
23
|
+
* rather than blocking on a stdin prompt.
|
|
24
|
+
* - Validates the clone looks like a vault export — `.parachute/vault.yaml`
|
|
25
|
+
* must be present. Refuses with a clear error otherwise.
|
|
26
|
+
* - On `mode: "replace"`: wipes notes + tags via `store.deleteNote()` /
|
|
27
|
+
* `store.deleteTag()` (same shape `importPortableVault` does
|
|
28
|
+
* internally when `blowAway: true` — we call the importer with
|
|
29
|
+
* `blowAway: true` to inherit that semantics rather than rolling our
|
|
30
|
+
* own delete loop).
|
|
31
|
+
* - Calls `importPortableVault(store, { inDir, blowAway, assetsDir })`.
|
|
32
|
+
* - Returns aggregated stats (notes_imported, tags_imported,
|
|
33
|
+
* attachments_imported, notes_deleted, warnings).
|
|
34
|
+
* - Always cleans up the temp dir on every code path (try/finally).
|
|
35
|
+
*
|
|
36
|
+
* **Threat model — credentials in argv (reviewer-flagged vault#384):**
|
|
37
|
+
*
|
|
38
|
+
* `git clone <https://x-access-token:TOKEN@host/...>` puts the token in
|
|
39
|
+
* `/proc/<pid>/cmdline` for the clone window (~few seconds). For vault's
|
|
40
|
+
* owner-operated, single-user self-host posture this is acceptable —
|
|
41
|
+
* anyone with shell on the box already has read access to
|
|
42
|
+
* `~/.parachute/vault/.mirror-credentials.yaml` (0600) and would have
|
|
43
|
+
* `~/.git-credentials` for that matter. Same posture as `git ls-remote`
|
|
44
|
+
* in mirror-credentials.ts. Multi-tenant cloud (cloud Tier 2) would
|
|
45
|
+
* switch this to a credential-helper script so the token never enters
|
|
46
|
+
* argv. Today's pattern: documented + scoped to the threat model.
|
|
47
|
+
*
|
|
48
|
+
* **Concurrency:** the in-flight set below blocks two concurrent imports
|
|
49
|
+
* against the same vault name. The first lays a marker; the second
|
|
50
|
+
* receives the conflict signal so the HTTP handler can return 409.
|
|
51
|
+
*
|
|
52
|
+
* **SIGTERM tempdir leak (known, acceptable):** if vault gets killed
|
|
53
|
+
* mid-clone (SIGINT/SIGTERM during the `Bun.spawn` window), the
|
|
54
|
+
* `parachute-import-<rand>` tempdir is abandoned on disk. Same posture
|
|
55
|
+
* as `probeGitLsRemote` in mirror-routes.ts — owner can sweep
|
|
56
|
+
* `/tmp/parachute-import-*` manually if they care. Adding a signal
|
|
57
|
+
* handler that cleans up only the in-flight import dir(s) is feasible
|
|
58
|
+
* but a) requires registering on every server boot, b) interacts
|
|
59
|
+
* weirdly with the existing graceful-shutdown drain path, c) doesn't
|
|
60
|
+
* close the OOM-killer / power-loss case anyway. Acceptable as-is for
|
|
61
|
+
* the self-host threat model.
|
|
62
|
+
*
|
|
63
|
+
* See vault#391 (the import sibling of #384's export work).
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
67
|
+
import { join } from "node:path";
|
|
68
|
+
import { tmpdir } from "node:os";
|
|
69
|
+
|
|
70
|
+
import {
|
|
71
|
+
importPortableVault,
|
|
72
|
+
type ImportStats,
|
|
73
|
+
} from "../core/src/portable-md.ts";
|
|
74
|
+
import type { SqliteStore } from "../core/src/store.ts";
|
|
75
|
+
import { redactRemoteUrl, readCredentials } from "./mirror-credentials.ts";
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Types
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Auth shape for a clone:
|
|
83
|
+
*
|
|
84
|
+
* - `{ kind: "credentialsFile" }` — use the stored mirror credentials
|
|
85
|
+
* (`~/.parachute/vault/.mirror-credentials.yaml`). If none exist the
|
|
86
|
+
* clone proceeds unauthed — fine for public repos, will fail for
|
|
87
|
+
* private ones with a clear `git`-surfaced error.
|
|
88
|
+
* - `{ kind: "pat", token, remoteUrl }` — one-shot. The supplied PAT
|
|
89
|
+
* gets embedded in the remote URL via the GitHub `x-access-token`
|
|
90
|
+
* convention. Does NOT touch the stored credentials.
|
|
91
|
+
* - `{ kind: "none" }` — explicit no-auth (for genuinely public repos).
|
|
92
|
+
*/
|
|
93
|
+
export type ImportAuth =
|
|
94
|
+
| { kind: "credentialsFile" }
|
|
95
|
+
| { kind: "pat"; token: string }
|
|
96
|
+
| { kind: "none" };
|
|
97
|
+
|
|
98
|
+
/** What the caller passes to `cloneAndImport`. */
|
|
99
|
+
export interface ImportOpts {
|
|
100
|
+
/** Target vault name (the one currently being imported INTO). */
|
|
101
|
+
vaultName: string;
|
|
102
|
+
/** HTTPS or SSH clone URL the operator pasted / picked. */
|
|
103
|
+
remoteUrl: string;
|
|
104
|
+
/** How to authenticate the clone. See `ImportAuth`. */
|
|
105
|
+
auth: ImportAuth;
|
|
106
|
+
/**
|
|
107
|
+
* `merge` — upsert-by-id semantics. Existing notes updated, new ones
|
|
108
|
+
* created, notes that aren't in the remote SURVIVE.
|
|
109
|
+
* `replace` — wipe-first. The remote becomes the new source of truth;
|
|
110
|
+
* any local-only notes are deleted.
|
|
111
|
+
*/
|
|
112
|
+
mode: "merge" | "replace";
|
|
113
|
+
/** Vault store to write into. */
|
|
114
|
+
store: SqliteStore;
|
|
115
|
+
/**
|
|
116
|
+
* Assets dir (where attachment binaries live for this vault). Wire
|
|
117
|
+
* via `assetsDir(vaultName)` from `routes.ts`.
|
|
118
|
+
*/
|
|
119
|
+
assetsDir: string;
|
|
120
|
+
/** Override the temp dir (test seam). Defaults to `os.tmpdir()`. */
|
|
121
|
+
workDirRoot?: string;
|
|
122
|
+
/** Override the spawner (test seam — fake `git`). */
|
|
123
|
+
spawn?: GitSpawn;
|
|
124
|
+
/** Override the post-clone import path (test seam — assume cloned dir is a vault export). */
|
|
125
|
+
importer?: typeof importPortableVault;
|
|
126
|
+
/** Override the clone timeout (default 60s; test seam to shorten). */
|
|
127
|
+
cloneTimeoutMs?: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Counts + warnings returned to the HTTP caller. `notes_imported` totals
|
|
132
|
+
* created+updated so the operator sees a single "imported N notes" number
|
|
133
|
+
* regardless of mode. `notes_deleted` is set only when `mode === "replace"`.
|
|
134
|
+
*/
|
|
135
|
+
export interface ImportResult {
|
|
136
|
+
notes_imported: number;
|
|
137
|
+
tags_imported: number;
|
|
138
|
+
attachments_imported: number;
|
|
139
|
+
/** Only set when `mode === "replace"`. */
|
|
140
|
+
notes_deleted?: number;
|
|
141
|
+
/**
|
|
142
|
+
* Per-skipped-thing detail (links pointing to notes outside the import
|
|
143
|
+
* set, attachments missing binaries, sidecars without content files,
|
|
144
|
+
* etc.). The HTTP handler returns these so the operator can audit.
|
|
145
|
+
*/
|
|
146
|
+
warnings: string[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Shape of the spawner we accept. Real impl is `Bun.spawn`. Tests inject
|
|
151
|
+
* a fake to skip the real git binary.
|
|
152
|
+
*/
|
|
153
|
+
export type GitSpawn = (
|
|
154
|
+
argv: string[],
|
|
155
|
+
options: { cwd?: string; timeoutMs: number },
|
|
156
|
+
) => Promise<GitSpawnResult>;
|
|
157
|
+
|
|
158
|
+
export interface GitSpawnResult {
|
|
159
|
+
exitCode: number;
|
|
160
|
+
stderr: string;
|
|
161
|
+
timedOut: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Concurrency guard — refuse 409 if the same vault is already importing.
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
const inFlight = new Set<string>();
|
|
169
|
+
|
|
170
|
+
/** Throw the conflict signal — handler catches and turns into a 409. */
|
|
171
|
+
export class ImportConflictError extends Error {
|
|
172
|
+
constructor(vaultName: string) {
|
|
173
|
+
super(
|
|
174
|
+
`Import already running for vault "${vaultName}". Wait for it to finish before starting another.`,
|
|
175
|
+
);
|
|
176
|
+
this.name = "ImportConflictError";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Throw when the clone target is not a portable-md vault export. */
|
|
181
|
+
export class NotAVaultExportError extends Error {
|
|
182
|
+
constructor() {
|
|
183
|
+
super(
|
|
184
|
+
'This does not look like a Parachute vault export — no `.parachute/vault.yaml` found at the repo root. ' +
|
|
185
|
+
"Make sure the remote was created by `parachute-vault export` or the mirror's export flow.",
|
|
186
|
+
);
|
|
187
|
+
this.name = "NotAVaultExportError";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Throw when the clone itself fails (network, auth, missing repo, etc.). */
|
|
192
|
+
export class CloneFailedError extends Error {
|
|
193
|
+
constructor(message: string) {
|
|
194
|
+
super(message);
|
|
195
|
+
this.name = "CloneFailedError";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Auth URL helpers
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build the URL we hand to `git clone`. Behavior depends on `auth`:
|
|
205
|
+
*
|
|
206
|
+
* - `pat` → embed `x-access-token:<TOKEN>` userinfo if the URL doesn't
|
|
207
|
+
* already carry creds. Sibling of `mirror-credentials.ts`'s
|
|
208
|
+
* `applyToGitRemote` approach.
|
|
209
|
+
* - `credentialsFile` → if stored creds are GitHub OAuth, embed the
|
|
210
|
+
* token. If stored creds are a PAT with a saved URL whose host
|
|
211
|
+
* matches, reuse the stored URL. Otherwise pass the URL verbatim.
|
|
212
|
+
* - `none` → return the URL verbatim.
|
|
213
|
+
*
|
|
214
|
+
* Returns `null` when the URL is unparseable (caller surfaces a 400).
|
|
215
|
+
*/
|
|
216
|
+
export function authedCloneUrl(
|
|
217
|
+
remoteUrl: string,
|
|
218
|
+
auth: ImportAuth,
|
|
219
|
+
): { authedUrl: string; appliedAuth: "stored_oauth" | "stored_pat" | "per_call_pat" | "none" } | null {
|
|
220
|
+
// Local-path / SSH-shorthand pass-through. Git accepts three URL shapes:
|
|
221
|
+
// - HTTPS/HTTP — what we embed auth into below.
|
|
222
|
+
// - SSH (`git@host:owner/repo`) — relies on ssh-agent, no userinfo
|
|
223
|
+
// slot. We pass verbatim.
|
|
224
|
+
// - Local path (`/abs/path` or `./rel/path`) or `file://` — used by
|
|
225
|
+
// local-disk mirrors + E2E tests. Pass verbatim; no auth to embed.
|
|
226
|
+
// The naive `new URL(...)` call below would reject all three of these
|
|
227
|
+
// pre-validation (local paths don't have a scheme), so detect them
|
|
228
|
+
// first.
|
|
229
|
+
if (remoteUrl.startsWith("/") || remoteUrl.startsWith("./") || remoteUrl.startsWith("../")) {
|
|
230
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
231
|
+
}
|
|
232
|
+
if (remoteUrl.startsWith("file://")) {
|
|
233
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
234
|
+
}
|
|
235
|
+
// SSH shorthand: `git@host:owner/repo.git`. Pattern: `<user>@<host>:<path>`.
|
|
236
|
+
// Doesn't parse as a URL — detect by the colon-after-host shape.
|
|
237
|
+
if (/^[A-Za-z0-9_-]+@[A-Za-z0-9.-]+:[^/]/.test(remoteUrl)) {
|
|
238
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let parsed: URL;
|
|
242
|
+
try {
|
|
243
|
+
parsed = new URL(remoteUrl);
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
// Only http/https get userinfo treatment. SSH URLs (`ssh://git@host/...`),
|
|
248
|
+
// git://, etc., parse as URLs but we don't try to embed creds in them.
|
|
249
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
250
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// If the URL already carries userinfo (operator pasted a URL with the
|
|
254
|
+
// token baked in), trust it and don't override.
|
|
255
|
+
if (parsed.username || parsed.password) {
|
|
256
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (auth.kind === "none") {
|
|
260
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (auth.kind === "pat") {
|
|
264
|
+
const u = new URL(remoteUrl);
|
|
265
|
+
u.username = "x-access-token";
|
|
266
|
+
u.password = auth.token;
|
|
267
|
+
return { authedUrl: u.toString(), appliedAuth: "per_call_pat" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// credentialsFile path — read from disk.
|
|
271
|
+
const creds = readCredentials();
|
|
272
|
+
if (!creds || !creds.active_method) {
|
|
273
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
274
|
+
}
|
|
275
|
+
if (creds.active_method === "github_oauth" && creds.github_oauth) {
|
|
276
|
+
// Embed the OAuth token if the URL is on github.com.
|
|
277
|
+
if (parsed.host.toLowerCase() === "github.com") {
|
|
278
|
+
const u = new URL(remoteUrl);
|
|
279
|
+
u.username = "x-access-token";
|
|
280
|
+
u.password = creds.github_oauth.access_token;
|
|
281
|
+
return { authedUrl: u.toString(), appliedAuth: "stored_oauth" };
|
|
282
|
+
}
|
|
283
|
+
// GitHub OAuth token against a non-github host: useless. Fall through.
|
|
284
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
285
|
+
}
|
|
286
|
+
if (creds.active_method === "pat" && creds.pat) {
|
|
287
|
+
// If the stored PAT URL host matches the request URL host, the stored
|
|
288
|
+
// token is the right one. Embed it.
|
|
289
|
+
let storedHost = "";
|
|
290
|
+
try {
|
|
291
|
+
storedHost = new URL(creds.pat.remote_url).host.toLowerCase();
|
|
292
|
+
} catch {
|
|
293
|
+
// ignore — fall through to no-auth
|
|
294
|
+
}
|
|
295
|
+
if (storedHost && storedHost === parsed.host.toLowerCase()) {
|
|
296
|
+
const u = new URL(remoteUrl);
|
|
297
|
+
u.username = "x-access-token";
|
|
298
|
+
u.password = creds.pat.token;
|
|
299
|
+
return { authedUrl: u.toString(), appliedAuth: "stored_pat" };
|
|
300
|
+
}
|
|
301
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
302
|
+
}
|
|
303
|
+
return { authedUrl: remoteUrl, appliedAuth: "none" };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Default git spawner — used in production. Tests inject a fake.
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Run a git command with a hard timeout + non-interactive env. Returns
|
|
312
|
+
* exit code + stderr text + timeout flag.
|
|
313
|
+
*/
|
|
314
|
+
export const defaultGitSpawn: GitSpawn = async (argv, options) => {
|
|
315
|
+
const proc = Bun.spawn(argv, {
|
|
316
|
+
cwd: options.cwd,
|
|
317
|
+
stdout: "pipe",
|
|
318
|
+
stderr: "pipe",
|
|
319
|
+
env: {
|
|
320
|
+
...process.env,
|
|
321
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
322
|
+
// Kill any system credential helper from intercepting — we want
|
|
323
|
+
// the clone to use ONLY the URL-embedded credential, not whatever's
|
|
324
|
+
// in keychain. Same shape as the ls-remote probe.
|
|
325
|
+
GIT_ASKPASS: "/bin/echo",
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
let timedOut = false;
|
|
329
|
+
const timer = setTimeout(() => {
|
|
330
|
+
timedOut = true;
|
|
331
|
+
try {
|
|
332
|
+
proc.kill();
|
|
333
|
+
} catch {
|
|
334
|
+
// already exited
|
|
335
|
+
}
|
|
336
|
+
}, options.timeoutMs);
|
|
337
|
+
const exitCode = await proc.exited;
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
const stderr = new TextDecoder()
|
|
340
|
+
.decode(await new Response(proc.stderr).arrayBuffer())
|
|
341
|
+
.trim();
|
|
342
|
+
return { exitCode, stderr, timedOut };
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Main entry point
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Clone a vault export from a git remote and import it into the target
|
|
351
|
+
* vault. See module-level docstring for the full shape.
|
|
352
|
+
*
|
|
353
|
+
* Throws:
|
|
354
|
+
* - `ImportConflictError` — another import is already running for this
|
|
355
|
+
* vault. Handler returns 409.
|
|
356
|
+
* - `CloneFailedError` — git clone exited non-zero (auth wall, network,
|
|
357
|
+
* missing repo). Message has the token redacted. Handler returns 502.
|
|
358
|
+
* - `NotAVaultExportError` — the clone target lacks `.parachute/vault.yaml`.
|
|
359
|
+
* Handler returns 400.
|
|
360
|
+
* - Other errors (filesystem, store) propagate; handler returns 500.
|
|
361
|
+
*
|
|
362
|
+
* Always cleans up the temp dir.
|
|
363
|
+
*/
|
|
364
|
+
export async function cloneAndImport(opts: ImportOpts): Promise<ImportResult> {
|
|
365
|
+
if (inFlight.has(opts.vaultName)) {
|
|
366
|
+
throw new ImportConflictError(opts.vaultName);
|
|
367
|
+
}
|
|
368
|
+
inFlight.add(opts.vaultName);
|
|
369
|
+
|
|
370
|
+
const spawn = opts.spawn ?? defaultGitSpawn;
|
|
371
|
+
const importer = opts.importer ?? importPortableVault;
|
|
372
|
+
const workDirRoot = opts.workDirRoot ?? tmpdir();
|
|
373
|
+
const cloneTimeoutMs = opts.cloneTimeoutMs ?? 60_000;
|
|
374
|
+
|
|
375
|
+
const authResult = authedCloneUrl(opts.remoteUrl, opts.auth);
|
|
376
|
+
if (!authResult) {
|
|
377
|
+
inFlight.delete(opts.vaultName);
|
|
378
|
+
throw new CloneFailedError(
|
|
379
|
+
`remote_url is not a valid URL: "${redactRemoteUrl(opts.remoteUrl)}"`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const { authedUrl } = authResult;
|
|
383
|
+
|
|
384
|
+
const tempDir = mkdtempSync(join(workDirRoot, "parachute-import-"));
|
|
385
|
+
try {
|
|
386
|
+
const cloneResult = await spawn(
|
|
387
|
+
["git", "clone", "--depth", "1", authedUrl, tempDir],
|
|
388
|
+
{ timeoutMs: cloneTimeoutMs },
|
|
389
|
+
);
|
|
390
|
+
if (cloneResult.timedOut) {
|
|
391
|
+
throw new CloneFailedError(
|
|
392
|
+
`git clone timed out after ${Math.floor(cloneTimeoutMs / 1000)}s. ` +
|
|
393
|
+
`Check the network connection or try a shallower remote. URL: ${redactRemoteUrl(opts.remoteUrl)}`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (cloneResult.exitCode !== 0) {
|
|
397
|
+
// Redact any leaked URLs in stderr — git error messages echo them.
|
|
398
|
+
const redactedStderr = cloneResult.stderr.replace(
|
|
399
|
+
/https?:\/\/[^@\s]+@/g,
|
|
400
|
+
"https://***@",
|
|
401
|
+
);
|
|
402
|
+
throw new CloneFailedError(
|
|
403
|
+
`git clone failed for ${redactRemoteUrl(opts.remoteUrl)}: ${redactedStderr}`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Validate the clone looks like a vault export.
|
|
408
|
+
if (!existsSync(join(tempDir, ".parachute", "vault.yaml"))) {
|
|
409
|
+
throw new NotAVaultExportError();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Delegate to the importer. `blowAway: true` for replace mode triggers
|
|
413
|
+
// the wipe-then-import path (deletes notes via the public store API
|
|
414
|
+
// so hooks fire); `false` for merge does upsert-by-id.
|
|
415
|
+
const stats: ImportStats = await importer(opts.store, {
|
|
416
|
+
inDir: tempDir,
|
|
417
|
+
blowAway: opts.mode === "replace",
|
|
418
|
+
assetsDir: opts.assetsDir,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
return importResultFromStats(stats, opts.mode);
|
|
422
|
+
} finally {
|
|
423
|
+
// Always nuke the temp dir, even on error. The clone might have
|
|
424
|
+
// partially populated it; force:true skips ENOENT.
|
|
425
|
+
try {
|
|
426
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
427
|
+
} catch (err) {
|
|
428
|
+
// Don't mask the original error with a cleanup error — just log.
|
|
429
|
+
console.warn(
|
|
430
|
+
`[mirror-import] temp dir cleanup failed (non-fatal): ${(err as Error).message ?? err}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
inFlight.delete(opts.vaultName);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Map the `importPortableVault` stats shape onto the import-API result
|
|
439
|
+
* shape. Combines created+updated into a single "imported" count (the
|
|
440
|
+
* operator's mental model is "I imported N notes" not "I created X and
|
|
441
|
+
* updated Y"); surfaces every skipped link/attachment/sidecar as a
|
|
442
|
+
* warning string.
|
|
443
|
+
*/
|
|
444
|
+
function importResultFromStats(
|
|
445
|
+
stats: ImportStats,
|
|
446
|
+
mode: "merge" | "replace",
|
|
447
|
+
): ImportResult {
|
|
448
|
+
const warnings: string[] = [];
|
|
449
|
+
for (const sl of stats.skipped_links) {
|
|
450
|
+
warnings.push(
|
|
451
|
+
`Skipped link ${sl.source_id} → ${sl.target_id} (${sl.relationship}): ${sl.reason}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
for (const sa of stats.skipped_attachments) {
|
|
455
|
+
warnings.push(
|
|
456
|
+
`Skipped attachment ${sa.attachment_id} on note ${sa.note_id}: ${sa.reason}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
for (const ss of stats.skipped_sidecars) {
|
|
460
|
+
warnings.push(
|
|
461
|
+
`Orphaned sidecar ${ss.sidecar_id} (path=${ss.expected_path ?? "—"}): ${ss.reason}`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
const result: ImportResult = {
|
|
465
|
+
notes_imported: stats.notes_created + stats.notes_updated,
|
|
466
|
+
tags_imported: stats.schemas_restored,
|
|
467
|
+
attachments_imported: stats.attachments_restored,
|
|
468
|
+
warnings,
|
|
469
|
+
};
|
|
470
|
+
if (mode === "replace") {
|
|
471
|
+
result.notes_deleted = stats.notes_wiped;
|
|
472
|
+
}
|
|
473
|
+
return result;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Test seam — surface in-flight state for assertions. */
|
|
477
|
+
export function _isImportInFlight(vaultName: string): boolean {
|
|
478
|
+
return inFlight.has(vaultName);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Test seam — clear the in-flight set. */
|
|
482
|
+
export function _resetImportInFlightForTest(): void {
|
|
483
|
+
inFlight.clear();
|
|
484
|
+
}
|