@openparachute/vault 0.6.0 → 0.6.2-rc.1
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/README.md +31 -6
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/links.ts +76 -21
- package/core/src/mcp.ts +53 -1
- package/core/src/notes.ts +128 -40
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +30 -1
- package/package.json +1 -1
- package/src/cli.ts +90 -25
- package/src/content-range-routes.test.ts +178 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/init-summary.test.ts +125 -125
- package/src/init-summary.ts +89 -54
- package/src/init.test.ts +128 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-remote-guard.test.ts +269 -0
- package/src/mirror-remote-guard.ts +273 -0
- package/src/mirror-routes.test.ts +1118 -46
- package/src/mirror-routes.ts +405 -32
- package/src/routes.ts +69 -3
- package/src/routing.ts +8 -0
- package/src/vault.test.ts +56 -0
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CGL256oe.js +0 -60
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-vault remote-clobber guard (vault#482).
|
|
3
|
+
*
|
|
4
|
+
* Per-vault mirror managers (vault#400) made every vault on a server
|
|
5
|
+
* independently linkable to a GitHub (or any git) remote. Nothing stopped
|
|
6
|
+
* TWO vaults on the same server from pointing their mirror at the SAME repo —
|
|
7
|
+
* and two mirrors pushing the same branch of the same repo fight each other:
|
|
8
|
+
* each `git push` (often `--force` for a mirror) overwrites the other vault's
|
|
9
|
+
* snapshot. The loser's backup is silently clobbered. Realistic on a
|
|
10
|
+
* family/shared box where two users share one GitHub account and both pick the
|
|
11
|
+
* obvious repo name, and more likely once the multi-user invite flow makes
|
|
12
|
+
* per-user vaults common.
|
|
13
|
+
*
|
|
14
|
+
* This module is the cheap same-server guard the issue calls for: when a vault
|
|
15
|
+
* is about to bind a remote (PAT save, OAuth repo pick, or import-then-sync),
|
|
16
|
+
* scan the OTHER vaults on this server for one that already claims the same
|
|
17
|
+
* normalized remote and refuse — naming the conflicting vault + the repo —
|
|
18
|
+
* unless the caller passes an explicit override.
|
|
19
|
+
*
|
|
20
|
+
* Cross-SERVER collisions can't be detected locally; the docs cover that half
|
|
21
|
+
* ("one repo per vault").
|
|
22
|
+
*
|
|
23
|
+
* ## What counts as a vault's "claimed remote"
|
|
24
|
+
*
|
|
25
|
+
* A vault's mirror remote lives in two places depending on the credential
|
|
26
|
+
* method:
|
|
27
|
+
* - **PAT** — the full authed `remote_url` in the per-vault credentials file.
|
|
28
|
+
* - **GitHub OAuth** — NOT in credentials (the credential carries no
|
|
29
|
+
* owner/repo). The actual remote is written onto the mirror dir's git
|
|
30
|
+
* `origin` by the select-repo flow.
|
|
31
|
+
* So we look at BOTH: the stored PAT credential, and the mirror dir's `origin`
|
|
32
|
+
* read straight from `.git/config`. Reading `.git/config` is a plain file read
|
|
33
|
+
* (no network, no git spawn) so the scan stays fast + hermetic.
|
|
34
|
+
*
|
|
35
|
+
* ## Normalization
|
|
36
|
+
*
|
|
37
|
+
* `https://github.com/x/y`, `https://github.com/x/y.git`,
|
|
38
|
+
* `git@github.com:x/y.git`, and `ssh://git@github.com/x/y` all name the same
|
|
39
|
+
* repo. We normalize to `<host-lowercased>/<owner>/<repo>` (userinfo + auth
|
|
40
|
+
* token stripped, `.git` + trailing slash removed) so equivalent URLs compare
|
|
41
|
+
* equal regardless of protocol/case/suffix.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
45
|
+
import { join } from "node:path";
|
|
46
|
+
import { homedir } from "node:os";
|
|
47
|
+
|
|
48
|
+
import { listVaults } from "./config.ts";
|
|
49
|
+
import { readCredentials } from "./mirror-credentials.ts";
|
|
50
|
+
import { readMirrorConfigForVault, resolveMirrorPath } from "./mirror-config.ts";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Canonical identity for a git remote, used for "same repo?" comparison.
|
|
54
|
+
* Strips the auth token / userinfo, lower-cases the host, drops a trailing
|
|
55
|
+
* `.git` and trailing slashes. Returns `<host>/<path>` (e.g.
|
|
56
|
+
* `github.com/aaron/my-vault`).
|
|
57
|
+
*
|
|
58
|
+
* Recognizes the three remote shapes git accepts:
|
|
59
|
+
* - HTTPS/HTTP/ssh:// URLs (parse via `URL`).
|
|
60
|
+
* - SCP-style SSH shorthand `git@host:owner/repo(.git)`.
|
|
61
|
+
* - Anything else (local path, oddball) → a trimmed, suffix-stripped string
|
|
62
|
+
* compare, so two byte-identical local paths still match.
|
|
63
|
+
*
|
|
64
|
+
* Returns `null` for an empty/whitespace string (nothing to compare).
|
|
65
|
+
*/
|
|
66
|
+
export function normalizeRemoteIdentity(remote: string): string | null {
|
|
67
|
+
const trimmed = remote.trim();
|
|
68
|
+
if (trimmed.length === 0) return null;
|
|
69
|
+
|
|
70
|
+
// The whole identity is LOWER-CASED before returning. The host is
|
|
71
|
+
// case-insensitive by DNS, and GitHub (the primary mirror target) treats
|
|
72
|
+
// owner/repo case-insensitively too — `github.com/Aaron/Vault` and
|
|
73
|
+
// `github.com/aaron/vault` are the SAME repo, so a data-loss guard must treat
|
|
74
|
+
// them as equal (else two vaults with different-case configs still clobber).
|
|
75
|
+
// The theoretical false-positive on a path-case-SENSITIVE Git host is
|
|
76
|
+
// acceptable for a clobber guard — the operator can override.
|
|
77
|
+
|
|
78
|
+
// SCP-style SSH shorthand: `user@host:owner/repo.git`. Doesn't parse as a
|
|
79
|
+
// URL (no scheme), so detect + rewrite to the canonical `host/path` shape
|
|
80
|
+
// before the URL path. Pattern: `<user>@<host>:<path>` where the char after
|
|
81
|
+
// the colon isn't a slash (a `//` after colon would be a real URL).
|
|
82
|
+
const scp = trimmed.match(/^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(.+)$/);
|
|
83
|
+
if (scp && !scp[2]!.startsWith("/")) {
|
|
84
|
+
const host = scp[1]!;
|
|
85
|
+
const path = stripRepoSuffix(scp[2]!);
|
|
86
|
+
return `${host}/${path}`.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const url = new URL(trimmed);
|
|
91
|
+
// Host already excludes userinfo. `URL` keeps a leading slash on pathname —
|
|
92
|
+
// drop it so the join below doesn't double up.
|
|
93
|
+
const host = url.host;
|
|
94
|
+
const path = stripRepoSuffix(url.pathname.replace(/^\/+/, ""));
|
|
95
|
+
const identity = path.length === 0 ? host : `${host}/${path}`;
|
|
96
|
+
return identity.toLowerCase();
|
|
97
|
+
} catch {
|
|
98
|
+
// Non-URL, non-SCP (local path, etc.) — fall back to a trimmed,
|
|
99
|
+
// suffix-stripped string compare so identical local paths match.
|
|
100
|
+
return stripRepoSuffix(trimmed).toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Strip a trailing `.git` and any trailing slashes from a remote path. */
|
|
105
|
+
function stripRepoSuffix(path: string): string {
|
|
106
|
+
return path.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Two remotes name the same repo iff their normalized identities match. */
|
|
110
|
+
export function sameRemoteIdentity(a: string, b: string): boolean {
|
|
111
|
+
const na = normalizeRemoteIdentity(a);
|
|
112
|
+
const nb = normalizeRemoteIdentity(b);
|
|
113
|
+
if (na === null || nb === null) return false;
|
|
114
|
+
return na === nb;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Per-vault claimed-remote resolution
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** The vault home root — `<configDir>/vault`. Re-reads PARACHUTE_HOME per call. */
|
|
122
|
+
function vaultHomeRoot(): string {
|
|
123
|
+
const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
|
|
124
|
+
return join(root, "vault");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The mirror dir for a vault, derived from its persisted mirror config the
|
|
129
|
+
* same way `MirrorManager.start()` does (`resolveMirrorPath(vaultDataDir,
|
|
130
|
+
* config)`). Returns null when the vault has no enabled mirror config or the
|
|
131
|
+
* path can't be resolved (e.g. external location without external_path).
|
|
132
|
+
*
|
|
133
|
+
* Re-derives the vault data dir from PARACHUTE_HOME rather than importing
|
|
134
|
+
* `config.vaultDir` — keeping the dependency surface narrow + matching the
|
|
135
|
+
* path resolution `mirror-credentials.ts` uses.
|
|
136
|
+
*/
|
|
137
|
+
function vaultMirrorDir(vaultName: string): string | null {
|
|
138
|
+
const config = readMirrorConfigForVault(vaultName);
|
|
139
|
+
if (!config) return null;
|
|
140
|
+
const vaultDataDir = join(vaultHomeRoot(), "data", vaultName);
|
|
141
|
+
return resolveMirrorPath(vaultDataDir, config);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read a vault's mirror dir `origin` URL straight from `.git/config` — no git
|
|
146
|
+
* spawn, no network. Returns null when there's no mirror dir, no `.git/config`,
|
|
147
|
+
* or no `[remote "origin"]` url line.
|
|
148
|
+
*
|
|
149
|
+
* `.git/config` is INI-ish; the origin url lives under a `[remote "origin"]`
|
|
150
|
+
* section header as `url = <value>`. We do a minimal section-aware scan rather
|
|
151
|
+
* than pull in a git-config parser — the file shape is stable + simple.
|
|
152
|
+
*/
|
|
153
|
+
function readOriginFromGitConfig(mirrorDir: string): string | null {
|
|
154
|
+
const gitConfigPath = join(mirrorDir, ".git", "config");
|
|
155
|
+
if (!existsSync(gitConfigPath)) return null;
|
|
156
|
+
let raw: string;
|
|
157
|
+
try {
|
|
158
|
+
raw = readFileSync(gitConfigPath, "utf8");
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
let inOrigin = false;
|
|
163
|
+
for (const line of raw.split("\n")) {
|
|
164
|
+
const section = line.match(/^\s*\[(.+?)\]\s*$/);
|
|
165
|
+
if (section) {
|
|
166
|
+
// Section headers: `[remote "origin"]` or `[core]` etc. Normalize the
|
|
167
|
+
// inner text + match the remote-origin shape (quoting can vary).
|
|
168
|
+
const header = section[1]!.trim().toLowerCase().replace(/\s+/g, " ");
|
|
169
|
+
inOrigin = header === 'remote "origin"';
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!inOrigin) continue;
|
|
173
|
+
const url = line.match(/^\s*url\s*=\s*(.+?)\s*$/);
|
|
174
|
+
if (url) return url[1]!;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The remote a vault currently claims, normalized, or null when it claims
|
|
181
|
+
* none. Looks at both credential-backed (PAT) and mirror-dir (`origin`)
|
|
182
|
+
* sources, so it covers OAuth-selected repos (which live only on `origin`)
|
|
183
|
+
* as well as PAT remotes.
|
|
184
|
+
*
|
|
185
|
+
* Returns the FIRST resolvable normalized remote it finds. `origin` is
|
|
186
|
+
* authoritative when set (it's what actually gets pushed); the stored PAT
|
|
187
|
+
* `remote_url` is the fallback for a vault whose mirror dir hasn't been
|
|
188
|
+
* bootstrapped yet.
|
|
189
|
+
*/
|
|
190
|
+
export function claimedRemoteOf(vaultName: string): string | null {
|
|
191
|
+
// 1. The live `origin` on the mirror dir — authoritative, covers OAuth.
|
|
192
|
+
const mirrorDir = vaultMirrorDir(vaultName);
|
|
193
|
+
if (mirrorDir) {
|
|
194
|
+
const origin = readOriginFromGitConfig(mirrorDir);
|
|
195
|
+
if (origin) {
|
|
196
|
+
const norm = normalizeRemoteIdentity(origin);
|
|
197
|
+
if (norm) return norm;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// 2. The stored PAT remote_url — fallback for a not-yet-bootstrapped mirror.
|
|
201
|
+
const creds = readCredentials(vaultName);
|
|
202
|
+
if (creds?.active_method === "pat" && creds.pat?.remote_url) {
|
|
203
|
+
const norm = normalizeRemoteIdentity(creds.pat.remote_url);
|
|
204
|
+
if (norm) return norm;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A detected cross-vault remote collision. */
|
|
210
|
+
export interface RemoteConflict {
|
|
211
|
+
/** The other vault on this server that already targets the same repo. */
|
|
212
|
+
conflictingVault: string;
|
|
213
|
+
/** The normalized repo identity both vaults point at (`host/owner/repo`). */
|
|
214
|
+
remoteIdentity: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Scan every OTHER vault on this server for one that already claims the same
|
|
219
|
+
* remote as `candidateRemote`. Returns the first conflict found, or null when
|
|
220
|
+
* the repo is unclaimed.
|
|
221
|
+
*
|
|
222
|
+
* The current vault is excluded — re-pointing a vault at its OWN existing
|
|
223
|
+
* remote (token rotation, re-running select-repo with the same repo, re-import)
|
|
224
|
+
* is legitimate + idempotent and must never trip the guard.
|
|
225
|
+
*
|
|
226
|
+
* Fail-open by design: any per-vault read error is swallowed (skip that vault)
|
|
227
|
+
* rather than blocking a legitimate bind on an unrelated vault's broken state.
|
|
228
|
+
* The guard's job is to catch the obvious double-target, not to be a hard
|
|
229
|
+
* gate that a corrupt sibling vault can wedge.
|
|
230
|
+
*/
|
|
231
|
+
export function findConflictingVault(
|
|
232
|
+
currentVaultName: string,
|
|
233
|
+
candidateRemote: string,
|
|
234
|
+
): RemoteConflict | null {
|
|
235
|
+
const target = normalizeRemoteIdentity(candidateRemote);
|
|
236
|
+
if (target === null) return null;
|
|
237
|
+
|
|
238
|
+
let vaults: string[];
|
|
239
|
+
try {
|
|
240
|
+
vaults = listVaults();
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const other of vaults) {
|
|
246
|
+
if (other === currentVaultName) continue;
|
|
247
|
+
let claimed: string | null;
|
|
248
|
+
try {
|
|
249
|
+
claimed = claimedRemoteOf(other);
|
|
250
|
+
} catch {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (claimed !== null && claimed === target) {
|
|
254
|
+
return { conflictingVault: other, remoteIdentity: target };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Build the operator-facing error message for a refused bind. Names the
|
|
262
|
+
* conflicting vault + the repo, and tells the operator how to proceed.
|
|
263
|
+
* Centralized so the three call sites (PAT, select-repo, import-sync) surface
|
|
264
|
+
* identical wording.
|
|
265
|
+
*/
|
|
266
|
+
export function remoteConflictMessage(conflict: RemoteConflict): string {
|
|
267
|
+
return (
|
|
268
|
+
`Vault "${conflict.conflictingVault}" on this server already backs up to ${conflict.remoteIdentity}. ` +
|
|
269
|
+
`Two vaults pushing to the same repo overwrite each other's backups (silent data loss). ` +
|
|
270
|
+
`Pick a different repo for this vault, or disconnect the other vault's backup first. ` +
|
|
271
|
+
`If you're sure (e.g. you just moved the repo between vaults), pass override=true to proceed anyway.`
|
|
272
|
+
);
|
|
273
|
+
}
|