@openparachute/vault 0.4.9-rc.8 → 0.4.9-rc.9
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 +74 -0
- package/src/export-watch.ts +108 -7
- package/src/mirror-config.test.ts +69 -10
- package/src/mirror-config.ts +66 -17
- package/src/mirror-manager.test.ts +104 -0
- package/src/mirror-manager.ts +129 -0
- package/src/mirror-routes.test.ts +168 -0
- package/src/mirror-routes.ts +155 -3
- package/src/routing.ts +33 -0
- package/web/ui/dist/assets/{index-CudVv0Mv.js → index-Degr8snN.js} +13 -13
- package/web/ui/dist/index.html +1 -1
package/package.json
CHANGED
package/src/export-watch.test.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
DEFAULT_COMMIT_TEMPLATE,
|
|
28
28
|
gitAddAll,
|
|
29
29
|
gitCommit,
|
|
30
|
+
gitPush,
|
|
30
31
|
gitUnstageAll,
|
|
31
32
|
isGitRepo,
|
|
32
33
|
listStagedFiles,
|
|
@@ -315,6 +316,79 @@ describe("git-shell helpers", () => {
|
|
|
315
316
|
});
|
|
316
317
|
});
|
|
317
318
|
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// 2b. gitPush — first-push upstream tracking (Cut 4 of vault#392)
|
|
321
|
+
//
|
|
322
|
+
// A freshly-bootstrapped mirror has commits but no upstream branch.
|
|
323
|
+
// Bare `git push` fails with "fatal: The current branch X has no upstream
|
|
324
|
+
// branch." gitPush now detects the missing-upstream case and falls back
|
|
325
|
+
// to `git push -u origin <branch>`. Subsequent pushes go bare.
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
describe("gitPush — upstream tracking", () => {
|
|
329
|
+
let workdir: string;
|
|
330
|
+
let remote: string;
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
workdir = makeTmp("vault-push-work-");
|
|
333
|
+
remote = makeTmp("vault-push-remote-");
|
|
334
|
+
// Bare repo as the "remote" — `git push` to it lands like any real remote.
|
|
335
|
+
Bun.spawnSync(["git", "init", "--bare", "-q", "-b", "main"], { cwd: remote });
|
|
336
|
+
initGitRepo(workdir);
|
|
337
|
+
Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: workdir });
|
|
338
|
+
fs.writeFileSync(path.join(workdir, "seed.md"), "# seed\n");
|
|
339
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: workdir });
|
|
340
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: workdir });
|
|
341
|
+
});
|
|
342
|
+
afterEach(() => {
|
|
343
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
344
|
+
fs.rmSync(remote, { recursive: true, force: true });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("first push to a fresh remote establishes upstream tracking", async () => {
|
|
348
|
+
// Pre-Cut-4 this failed with "no upstream branch."
|
|
349
|
+
const result = await gitPush(workdir);
|
|
350
|
+
expect(result.ok).toBe(true);
|
|
351
|
+
// Verify upstream is now set so subsequent pushes can be bare.
|
|
352
|
+
const upstream = Bun.spawnSync(
|
|
353
|
+
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
354
|
+
{ cwd: workdir, stdout: "pipe" },
|
|
355
|
+
);
|
|
356
|
+
expect(upstream.exitCode).toBe(0);
|
|
357
|
+
expect(
|
|
358
|
+
new TextDecoder().decode(upstream.stdout).trim(),
|
|
359
|
+
).toBe("origin/main");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("subsequent push (upstream already set) succeeds bare", async () => {
|
|
363
|
+
// First push wires the upstream.
|
|
364
|
+
await gitPush(workdir);
|
|
365
|
+
// Make another commit, push again — should succeed.
|
|
366
|
+
fs.writeFileSync(path.join(workdir, "n.md"), "# n\n");
|
|
367
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: workdir });
|
|
368
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "second"], { cwd: workdir });
|
|
369
|
+
const result = await gitPush(workdir);
|
|
370
|
+
expect(result.ok).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("push with no remote configured surfaces the error (back-compat)", async () => {
|
|
374
|
+
// Same shape as the existing "no remote" test in runGitCommitCycle —
|
|
375
|
+
// the branch probe returns a branch but no upstream, gitPush falls
|
|
376
|
+
// back to `git push -u origin main`, which fails because there's no
|
|
377
|
+
// `origin` remote configured.
|
|
378
|
+
const localOnly = makeTmp("vault-push-noremote-");
|
|
379
|
+
initGitRepo(localOnly);
|
|
380
|
+
fs.writeFileSync(path.join(localOnly, "x.md"), "# x\n");
|
|
381
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: localOnly });
|
|
382
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "x"], { cwd: localOnly });
|
|
383
|
+
const result = await gitPush(localOnly);
|
|
384
|
+
expect(result.ok).toBe(false);
|
|
385
|
+
// Doesn't matter what the exact error is — just that it's non-fatal
|
|
386
|
+
// (gitPush returns rather than throws).
|
|
387
|
+
expect(typeof result.stderr).toBe("string");
|
|
388
|
+
fs.rmSync(localOnly, { recursive: true, force: true });
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
318
392
|
// ---------------------------------------------------------------------------
|
|
319
393
|
// 3. runGitCommitCycle — stage → decide → commit → push
|
|
320
394
|
// ---------------------------------------------------------------------------
|
package/src/export-watch.ts
CHANGED
|
@@ -121,11 +121,72 @@ export async function gitCommit(
|
|
|
121
121
|
return { ok: exitCode === 0, stderr: stderr.trim() };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Run `git push` in `repoDir`. Returns true on success.
|
|
126
|
+
*
|
|
127
|
+
* Handles the first-push case: a freshly-bootstrapped mirror has
|
|
128
|
+
* commits but no upstream tracking, and a bare `git push` fails with
|
|
129
|
+
* "fatal: The current branch X has no upstream branch." That's
|
|
130
|
+
* unrecoverable from the operator's POV — they'd have to drop to a
|
|
131
|
+
* shell and run `git push -u origin main`. The wiring here detects the
|
|
132
|
+
* missing-upstream case ahead of time and falls back to `git push -u
|
|
133
|
+
* origin <branch>` so the first push works and every subsequent push
|
|
134
|
+
* picks up the now-configured tracking.
|
|
135
|
+
*
|
|
136
|
+
* Vault#382 carried bare `git push`; this is the Cut 4 fix in the
|
|
137
|
+
* credentials-save round-trip work.
|
|
138
|
+
*/
|
|
125
139
|
export async function gitPush(
|
|
126
140
|
repoDir: string,
|
|
127
141
|
): Promise<{ ok: boolean; stderr: string }> {
|
|
128
|
-
|
|
142
|
+
// Resolve the current branch name. `git rev-parse --abbrev-ref HEAD`
|
|
143
|
+
// returns the branch on a checked-out branch (e.g. "main"); on a
|
|
144
|
+
// detached HEAD it returns "HEAD" — in that case we bail to bare push
|
|
145
|
+
// since `-u origin HEAD` doesn't mean anything useful.
|
|
146
|
+
let branch: string | null = null;
|
|
147
|
+
try {
|
|
148
|
+
const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
149
|
+
cwd: repoDir,
|
|
150
|
+
stdout: "pipe",
|
|
151
|
+
stderr: "pipe",
|
|
152
|
+
});
|
|
153
|
+
const branchCode = await branchProc.exited;
|
|
154
|
+
if (branchCode === 0) {
|
|
155
|
+
const out = new TextDecoder()
|
|
156
|
+
.decode(await new Response(branchProc.stdout).arrayBuffer())
|
|
157
|
+
.trim();
|
|
158
|
+
if (out.length > 0 && out !== "HEAD") branch = out;
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Fall through to bare push.
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Probe for an existing upstream. `git rev-parse --abbrev-ref
|
|
165
|
+
// --symbolic-full-name @{u}` exits non-zero when no upstream is
|
|
166
|
+
// configured for the current branch. Exit 0 + a value → upstream
|
|
167
|
+
// exists; bare `git push` is fine.
|
|
168
|
+
let hasUpstream = false;
|
|
169
|
+
if (branch !== null) {
|
|
170
|
+
const upstreamProc = Bun.spawn(
|
|
171
|
+
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
172
|
+
{
|
|
173
|
+
cwd: repoDir,
|
|
174
|
+
stdout: "pipe",
|
|
175
|
+
stderr: "pipe",
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
const upstreamCode = await upstreamProc.exited;
|
|
179
|
+
hasUpstream = upstreamCode === 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// First-push path: `git push -u origin <branch>` to establish
|
|
183
|
+
// tracking. Subsequent calls take the bare path because hasUpstream
|
|
184
|
+
// will be true after this lands.
|
|
185
|
+
const cmd =
|
|
186
|
+
branch !== null && !hasUpstream
|
|
187
|
+
? ["git", "push", "-u", "origin", branch]
|
|
188
|
+
: ["git", "push"];
|
|
189
|
+
const proc = Bun.spawn(cmd, {
|
|
129
190
|
cwd: repoDir,
|
|
130
191
|
stdout: "pipe",
|
|
131
192
|
stderr: "pipe",
|
|
@@ -190,7 +251,14 @@ export function shouldCommit(stagedFiles: string[], notesChanged: number): {
|
|
|
190
251
|
|
|
191
252
|
/**
|
|
192
253
|
* Stage → decide → commit → optionally push. Logs status to stdout/stderr.
|
|
193
|
-
* Returns whether a commit landed
|
|
254
|
+
* Returns whether a commit landed and (when push was attempted) the
|
|
255
|
+
* push outcome — so callers can surface push status in their UIs
|
|
256
|
+
* without having to grep logs.
|
|
257
|
+
*
|
|
258
|
+
* Cut 5: the return shape now includes a `push` field with the outcome
|
|
259
|
+
* when push was attempted. Tokens are redacted from `push.error` via
|
|
260
|
+
* the userinfo + `gho_/ghp_/glpat-` regex so log + status surfaces are
|
|
261
|
+
* safe to display.
|
|
194
262
|
*/
|
|
195
263
|
export async function runGitCommitCycle(opts: {
|
|
196
264
|
repoDir: string;
|
|
@@ -201,7 +269,11 @@ export async function runGitCommitCycle(opts: {
|
|
|
201
269
|
push: boolean;
|
|
202
270
|
/** Override for tests — defaults to `new Date().toISOString()`. */
|
|
203
271
|
now?: () => string;
|
|
204
|
-
}): Promise<{
|
|
272
|
+
}): Promise<{
|
|
273
|
+
committed: boolean;
|
|
274
|
+
message?: string;
|
|
275
|
+
push?: { attempted: true; ok: boolean; error?: string };
|
|
276
|
+
}> {
|
|
205
277
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
206
278
|
|
|
207
279
|
const add = await gitAddAll(opts.repoDir);
|
|
@@ -245,11 +317,40 @@ export async function runGitCommitCycle(opts: {
|
|
|
245
317
|
if (!pushResult.ok) {
|
|
246
318
|
// Non-fatal — a network blip shouldn't kill a watch loop. Warn and
|
|
247
319
|
// move on; the next successful commit's push will catch up history.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
320
|
+
const redacted = redactToken(pushResult.stderr);
|
|
321
|
+
console.warn(`[git-commit] git push failed (non-fatal): ${redacted}`);
|
|
322
|
+
return {
|
|
323
|
+
committed: true,
|
|
324
|
+
message,
|
|
325
|
+
push: { attempted: true, ok: false, error: redacted },
|
|
326
|
+
};
|
|
251
327
|
}
|
|
328
|
+
console.log(`[git-commit] pushed`);
|
|
329
|
+
return { committed: true, message, push: { attempted: true, ok: true } };
|
|
252
330
|
}
|
|
253
331
|
|
|
254
332
|
return { committed: true, message };
|
|
255
333
|
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Redact tokens from a string that came back from `git push` /
|
|
337
|
+
* `git ls-remote` stderr. Same pattern as `redactRemoteUrl` for full
|
|
338
|
+
* URLs; also handles bare `gho_*`/`ghp_*`/`glpat-*` tokens that
|
|
339
|
+
* sometimes show up in git error messages.
|
|
340
|
+
*
|
|
341
|
+
* Best-effort — git's error format isn't a stable contract, so we
|
|
342
|
+
* scrub the obvious patterns and accept that a really unusual
|
|
343
|
+
* formatting could slip through. The credentials file is the
|
|
344
|
+
* authoritative store; logs are diagnostic.
|
|
345
|
+
*/
|
|
346
|
+
export function redactToken(text: string): string {
|
|
347
|
+
return text
|
|
348
|
+
// userinfo (https://user:token@host/…)
|
|
349
|
+
.replace(/https?:\/\/[^@\s]+@/g, "https://***@")
|
|
350
|
+
// bare GitHub OAuth tokens
|
|
351
|
+
.replace(/gho_[A-Za-z0-9_]{8,}/g, "gho_***")
|
|
352
|
+
// bare GitHub PATs
|
|
353
|
+
.replace(/ghp_[A-Za-z0-9_]{8,}/g, "ghp_***")
|
|
354
|
+
// bare GitLab PATs
|
|
355
|
+
.replace(/glpat-[A-Za-z0-9_-]{8,}/g, "glpat-***");
|
|
356
|
+
}
|
|
@@ -333,17 +333,76 @@ describe("validateMirrorConfigShape", () => {
|
|
|
333
333
|
if (r.ok) expect(r.config.sync_mode).toBe("manual");
|
|
334
334
|
});
|
|
335
335
|
|
|
336
|
-
test("rejects auto_push + internal location
|
|
337
|
-
// Pre-
|
|
338
|
-
// internal
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
336
|
+
test("rejects auto_push + internal location WHEN no credentials are configured", () => {
|
|
337
|
+
// Pre-credentials shape: auto_push + internal was rejected outright
|
|
338
|
+
// (internal mirror = no remote = push would silently fail). Once
|
|
339
|
+
// credentials are wired (PAT or GitHub OAuth), the credential save
|
|
340
|
+
// path sets `origin` on the internal repo too — so push IS
|
|
341
|
+
// meaningful. We keep the rejection only on the no-credentials path,
|
|
342
|
+
// with a clear error pointing the operator at the credential flow.
|
|
343
|
+
const r = validateMirrorConfigShape(
|
|
344
|
+
{
|
|
345
|
+
enabled: true,
|
|
346
|
+
location: "internal",
|
|
347
|
+
auto_push: true,
|
|
348
|
+
},
|
|
349
|
+
{ readCredentials: () => null },
|
|
350
|
+
);
|
|
345
351
|
expect(r.ok).toBe(false);
|
|
346
|
-
if (!r.ok)
|
|
352
|
+
if (!r.ok) {
|
|
353
|
+
expect(r.field).toBe("auto_push");
|
|
354
|
+
expect(r.error).toContain("credentials");
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("auto_push + internal IS accepted when PAT credentials are configured", () => {
|
|
359
|
+
// The three-stacking-gaps bug Aaron hit: History preset (internal
|
|
360
|
+
// location) + PAT saved → expected pushes to fire. validation was
|
|
361
|
+
// the first blocker. Now the combination passes when credentials
|
|
362
|
+
// are present.
|
|
363
|
+
const r = validateMirrorConfigShape(
|
|
364
|
+
{
|
|
365
|
+
enabled: true,
|
|
366
|
+
location: "internal",
|
|
367
|
+
auto_push: true,
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
readCredentials: () => ({
|
|
371
|
+
active_method: "pat",
|
|
372
|
+
github_oauth: null,
|
|
373
|
+
pat: {
|
|
374
|
+
token: "ghp_xxxxxxxxxxxxxxxx",
|
|
375
|
+
remote_url: "https://x-access-token:ghp_xxxxxxxxxxxxxxxx@github.com/a/b.git",
|
|
376
|
+
label: "test",
|
|
377
|
+
},
|
|
378
|
+
}),
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
expect(r.ok).toBe(true);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("auto_push + internal IS accepted when github_oauth credentials are configured", () => {
|
|
385
|
+
const r = validateMirrorConfigShape(
|
|
386
|
+
{
|
|
387
|
+
enabled: true,
|
|
388
|
+
location: "internal",
|
|
389
|
+
auto_push: true,
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
readCredentials: () => ({
|
|
393
|
+
active_method: "github_oauth",
|
|
394
|
+
github_oauth: {
|
|
395
|
+
access_token: "gho_xxxxxxxxxxxx",
|
|
396
|
+
scope: "repo",
|
|
397
|
+
authorized_at: "2026-05-28T03:14:15.000Z",
|
|
398
|
+
user_login: "aaron",
|
|
399
|
+
user_id: 1,
|
|
400
|
+
},
|
|
401
|
+
pat: null,
|
|
402
|
+
}),
|
|
403
|
+
},
|
|
404
|
+
);
|
|
405
|
+
expect(r.ok).toBe(true);
|
|
347
406
|
});
|
|
348
407
|
|
|
349
408
|
test("auto_push + external location is fine", () => {
|
package/src/mirror-config.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { existsSync, statSync } from "fs";
|
|
|
23
23
|
import { join } from "path";
|
|
24
24
|
|
|
25
25
|
import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
|
|
26
|
+
import { readCredentials, type MirrorCredentials } from "./mirror-credentials.ts";
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// Types
|
|
@@ -372,12 +373,21 @@ export type ShapeValidation = ShapeValidationOk | ShapeValidationError;
|
|
|
372
373
|
* `defaultMirrorConfig()`; rejects values that don't conform to the
|
|
373
374
|
* declared types.
|
|
374
375
|
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
376
|
+
* Mostly pure: the one filesystem touch is reading
|
|
377
|
+
* `.mirror-credentials.yaml` to decide whether `auto_push: true +
|
|
378
|
+
* location: internal` is acceptable (credentials carry a remote URL, so
|
|
379
|
+
* "internal mirrors have no remote" no longer holds). The credentials
|
|
380
|
+
* read is injectable via `opts.readCredentials` so tests stay hermetic;
|
|
381
|
+
* production callers omit it and pick up the canonical `readCredentials`
|
|
382
|
+
* from `./mirror-credentials.ts`.
|
|
383
|
+
*
|
|
384
|
+
* Filesystem-level validation of `external_path` (exists, is a git repo)
|
|
385
|
+
* still lives in `validateExternalPath` — that's I/O and stays out of
|
|
386
|
+
* this function.
|
|
378
387
|
*/
|
|
379
388
|
export function validateMirrorConfigShape(
|
|
380
389
|
input: unknown,
|
|
390
|
+
opts: { readCredentials?: () => MirrorCredentials | null } = {},
|
|
381
391
|
): ShapeValidation {
|
|
382
392
|
if (input === null || typeof input !== "object") {
|
|
383
393
|
return {
|
|
@@ -544,21 +554,60 @@ export function validateMirrorConfigShape(
|
|
|
544
554
|
};
|
|
545
555
|
}
|
|
546
556
|
|
|
547
|
-
// Cross-field rule: auto_push
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
557
|
+
// Cross-field rule: auto_push + internal location was historically
|
|
558
|
+
// rejected outright — the assumption being "internal mirrors live under
|
|
559
|
+
// vault's data dir with no configured remote, so push would always
|
|
560
|
+
// fail." That assumption no longer holds once credentials are wired:
|
|
561
|
+
// the credential save path (handleAuthPat / handleAuthGithubSelectRepo)
|
|
562
|
+
// sets `origin` on the internal repo to the embedded-credential URL,
|
|
563
|
+
// so `git push` to GitHub/GitLab/etc. is meaningful even when the
|
|
564
|
+
// working tree lives under `~/.parachute/vault/data/<name>/mirror/`.
|
|
565
|
+
//
|
|
566
|
+
// New rule: auto_push + internal is rejected ONLY when no credentials
|
|
567
|
+
// are configured. If credentials ARE wired (PAT or GitHub OAuth),
|
|
568
|
+
// accept the combination — vault has a remote to push to. Operators
|
|
569
|
+
// hitting Aaron's three-stacking-gaps bug (History preset + PAT saved,
|
|
570
|
+
// pushes never fire) get unblocked.
|
|
571
|
+
//
|
|
572
|
+
// Asymmetry note (reviewer-flagged on vault#392): external + auto_push +
|
|
573
|
+
// no vault-stored credentials is INTENTIONALLY not rejected here.
|
|
574
|
+
// External mirrors are operator-managed paths — operators may have
|
|
575
|
+
// configured push credentials via system-level git config (SSH agent,
|
|
576
|
+
// ~/.git-credentials, GH_TOKEN env, `gh auth login`), none of which
|
|
577
|
+
// vault can detect by reading `.mirror-credentials.yaml`. Rejecting on
|
|
578
|
+
// "vault doesn't see credentials" would refuse legitimate
|
|
579
|
+
// operator-managed setups. Push failures on missing credentials surface
|
|
580
|
+
// via the non-fatal warning path in gitPush — operators see them in
|
|
581
|
+
// `last_push_error` rather than being blocked at save time. Internal
|
|
582
|
+
// location is different: internal mirrors live under vault's data
|
|
583
|
+
// dir, so vault IS the only thing that can wire a remote, which is
|
|
584
|
+
// why the gate below requires vault-stored credentials specifically.
|
|
555
585
|
if (out.enabled && out.auto_push && out.location === "internal") {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
586
|
+
const readCreds = opts.readCredentials ?? readCredentials;
|
|
587
|
+
let creds: MirrorCredentials | null = null;
|
|
588
|
+
try {
|
|
589
|
+
creds = readCreds();
|
|
590
|
+
} catch {
|
|
591
|
+
// If the credentials file is unreadable we conservatively fail
|
|
592
|
+
// closed — same outcome as "no credentials." Better to surface the
|
|
593
|
+
// actionable "configure credentials first" error than to accept a
|
|
594
|
+
// config that won't actually push.
|
|
595
|
+
creds = null;
|
|
596
|
+
}
|
|
597
|
+
const hasCredentials = !!(
|
|
598
|
+
creds &&
|
|
599
|
+
creds.active_method &&
|
|
600
|
+
((creds.active_method === "pat" && creds.pat) ||
|
|
601
|
+
(creds.active_method === "github_oauth" && creds.github_oauth))
|
|
602
|
+
);
|
|
603
|
+
if (!hasCredentials) {
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
field: "auto_push",
|
|
607
|
+
error:
|
|
608
|
+
"Configure git credentials before enabling auto-push on an internal mirror — vault has no remote to push to without them. Connect GitHub or paste a Personal Access Token in the Git remote section, or set auto_push to false.",
|
|
609
|
+
};
|
|
610
|
+
}
|
|
562
611
|
}
|
|
563
612
|
|
|
564
613
|
return { ok: true, config: out };
|
|
@@ -855,3 +855,107 @@ describe("MirrorManager — event-driven (sync_mode: events)", () => {
|
|
|
855
855
|
await mgr.stop();
|
|
856
856
|
});
|
|
857
857
|
});
|
|
858
|
+
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
// MirrorManager.pushNow — Cut 6 of vault#392 (credentials → push round-trip)
|
|
861
|
+
//
|
|
862
|
+
// Live `git push` against a local bare repo as the "remote." Tests the
|
|
863
|
+
// status mutations (last_push_at, last_push_sha, last_push_error,
|
|
864
|
+
// commits_unpushed) the SPA renders.
|
|
865
|
+
// ---------------------------------------------------------------------------
|
|
866
|
+
|
|
867
|
+
describe("MirrorManager.pushNow — push observability", () => {
|
|
868
|
+
let home: string;
|
|
869
|
+
let remote: string;
|
|
870
|
+
|
|
871
|
+
afterEach(async () => {
|
|
872
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
873
|
+
if (remote) fs.rmSync(remote, { recursive: true, force: true });
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("returns not_enabled when mirror is disabled", async () => {
|
|
877
|
+
home = tmp("mgr-pushnow-disabled-");
|
|
878
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
879
|
+
const deps = makeFakeDeps({
|
|
880
|
+
parachuteHome: home,
|
|
881
|
+
initialConfig: { ...defaultMirrorConfig(), enabled: false },
|
|
882
|
+
});
|
|
883
|
+
const mgr = new MirrorManager(deps);
|
|
884
|
+
await mgr.start();
|
|
885
|
+
const r = await mgr.pushNow();
|
|
886
|
+
expect(r.fired).toBe(false);
|
|
887
|
+
if (r.fired === false) expect(r.reason).toBe("not_enabled");
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("first push wires upstream and sets last_push_at + last_push_sha", async () => {
|
|
891
|
+
// Spin up an enabled internal mirror; wire a local bare repo as
|
|
892
|
+
// origin; verify pushNow lands the seed commit and updates status.
|
|
893
|
+
home = tmp("mgr-pushnow-first-");
|
|
894
|
+
remote = tmp("mgr-pushnow-remote-");
|
|
895
|
+
Bun.spawnSync(["git", "init", "--bare", "-q", "-b", "main"], { cwd: remote });
|
|
896
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
897
|
+
const deps = makeFakeDeps({
|
|
898
|
+
parachuteHome: home,
|
|
899
|
+
initialConfig: {
|
|
900
|
+
...defaultMirrorConfig(),
|
|
901
|
+
enabled: true,
|
|
902
|
+
location: "internal",
|
|
903
|
+
sync_mode: "manual",
|
|
904
|
+
auto_commit: false,
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
const mgr = new MirrorManager(deps);
|
|
908
|
+
const status = await mgr.start();
|
|
909
|
+
expect(status.enabled).toBe(true);
|
|
910
|
+
// Wire origin to the bare repo. The internal-mirror's first commit
|
|
911
|
+
// (`initial mirror bootstrap`) is sitting on HEAD waiting to push.
|
|
912
|
+
Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: status.mirror_path! });
|
|
913
|
+
|
|
914
|
+
const result = await mgr.pushNow();
|
|
915
|
+
expect(result.fired).toBe(true);
|
|
916
|
+
if (result.fired) {
|
|
917
|
+
expect(result.pushed).toBe(true);
|
|
918
|
+
expect(typeof result.sha).toBe("string");
|
|
919
|
+
}
|
|
920
|
+
const after = mgr.getStatus();
|
|
921
|
+
expect(after.last_push_at).not.toBeNull();
|
|
922
|
+
expect(after.last_push_sha).not.toBeNull();
|
|
923
|
+
expect(after.last_push_error).toBeNull();
|
|
924
|
+
expect(after.commits_unpushed).toBe(0);
|
|
925
|
+
await mgr.stop();
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("push failure surfaces redacted error in last_push_error", async () => {
|
|
929
|
+
// Wire origin to a non-existent host. Push fails; status reflects
|
|
930
|
+
// the failure without leaking any potentially-sensitive URL parts.
|
|
931
|
+
home = tmp("mgr-pushnow-fail-");
|
|
932
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
933
|
+
const deps = makeFakeDeps({
|
|
934
|
+
parachuteHome: home,
|
|
935
|
+
initialConfig: {
|
|
936
|
+
...defaultMirrorConfig(),
|
|
937
|
+
enabled: true,
|
|
938
|
+
location: "internal",
|
|
939
|
+
sync_mode: "manual",
|
|
940
|
+
auto_commit: false,
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
const mgr = new MirrorManager(deps);
|
|
944
|
+
const status = await mgr.start();
|
|
945
|
+
Bun.spawnSync(
|
|
946
|
+
["git", "remote", "add", "origin", "https://x-access-token:ghp_fake1234567890abcdef@nonexistent.parachute.test/a/b.git"],
|
|
947
|
+
{ cwd: status.mirror_path! },
|
|
948
|
+
);
|
|
949
|
+
const result = await mgr.pushNow();
|
|
950
|
+
expect(result.fired).toBe(true);
|
|
951
|
+
if (result.fired) expect(result.pushed).toBe(false);
|
|
952
|
+
const after = mgr.getStatus();
|
|
953
|
+
expect(after.last_push_error).not.toBeNull();
|
|
954
|
+
// Redacted — no token leak.
|
|
955
|
+
expect(after.last_push_error).not.toContain("ghp_fake1234567890");
|
|
956
|
+
// Last successful push timestamp is unchanged (still null on a
|
|
957
|
+
// never-succeeded mirror).
|
|
958
|
+
expect(after.last_push_at).toBeNull();
|
|
959
|
+
await mgr.stop();
|
|
960
|
+
}, 30_000);
|
|
961
|
+
});
|