@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.
@@ -66,7 +66,9 @@ import {
66
66
  import {
67
67
  gitAddAll,
68
68
  gitCommit,
69
+ gitPush,
69
70
  isGitRepo,
71
+ redactToken,
70
72
  runGitCommitCycle,
71
73
  } from "./export-watch.ts";
72
74
  import { vaultDir } from "./config.ts";
@@ -115,6 +117,28 @@ export interface MirrorStatus {
115
117
  last_commit_sha: string | null;
116
118
  /** Last error message (if any). Cleared on the next successful pass. */
117
119
  last_error: string | null;
120
+ /**
121
+ * Cut 5: push observability — surfaced via GET /admin/mirror so the
122
+ * operator (and the SPA's Status card) can see whether pushes are
123
+ * actually landing without grepping `[git-commit]` log lines.
124
+ *
125
+ * - `last_push_at` — ISO timestamp of the most recent SUCCESSFUL push.
126
+ * Null until the first successful push.
127
+ * - `last_push_sha` — sha that landed on the remote at `last_push_at`.
128
+ * Null when last_push_at is null.
129
+ * - `last_push_error` — message from the most recent FAILED push.
130
+ * Cleared (back to null) on the next successful push. Tokens are
131
+ * redacted before this field is set (push paths use the
132
+ * `gho_`/`ghp_`/userinfo regex to scrub).
133
+ * - `commits_unpushed` — count of local commits ahead of upstream
134
+ * (`git rev-list --count @{u}..HEAD`). Null when no upstream tracking
135
+ * exists yet (first push hasn't fired). 0 when the mirror is fully
136
+ * synced; positive when commits are queued.
137
+ */
138
+ last_push_at: string | null;
139
+ last_push_sha: string | null;
140
+ last_push_error: string | null;
141
+ commits_unpushed: number | null;
118
142
  }
119
143
 
120
144
  /**
@@ -288,6 +312,32 @@ export async function bootstrapInternalMirror(
288
312
  // Manager
289
313
  // ---------------------------------------------------------------------------
290
314
 
315
+ /**
316
+ * Cut 5: count local commits ahead of upstream tracking. Implementation:
317
+ * `git rev-list --count @{u}..HEAD` returns N when N commits are ahead,
318
+ * 0 when synced, and exit-nonzero when no upstream tracking exists
319
+ * (the branch has never been pushed with `-u`). The non-zero case maps
320
+ * to `null` — distinct from "0 ahead" so the SPA can render "no remote
321
+ * tracking yet" vs "fully synced" differently if desired.
322
+ */
323
+ async function readCommitsUnpushed(repoDir: string): Promise<number | null> {
324
+ const proc = Bun.spawn(
325
+ ["git", "rev-list", "--count", "@{u}..HEAD"],
326
+ {
327
+ cwd: repoDir,
328
+ stdout: "pipe",
329
+ stderr: "pipe",
330
+ },
331
+ );
332
+ const exitCode = await proc.exited;
333
+ if (exitCode !== 0) return null; // no upstream configured yet
334
+ const out = new TextDecoder()
335
+ .decode(await new Response(proc.stdout).arrayBuffer())
336
+ .trim();
337
+ const n = Number(out);
338
+ return Number.isFinite(n) ? n : null;
339
+ }
340
+
291
341
  /**
292
342
  * Read the current `origin` URL from a git repo's config, or null when no
293
343
  * origin is set. Module-local helper for the credential-application path.
@@ -331,6 +381,10 @@ export class MirrorManager {
331
381
  last_export_notes_count: null,
332
382
  last_commit_sha: null,
333
383
  last_error: null,
384
+ last_push_at: null,
385
+ last_push_sha: null,
386
+ last_push_error: null,
387
+ commits_unpushed: null,
334
388
  };
335
389
  /** Safety-net poll timer (interval). Null while not armed. */
336
390
  private safetyNetTimer: ReturnType<typeof setInterval> | null = null;
@@ -396,6 +450,10 @@ export class MirrorManager {
396
450
  last_export_notes_count: null,
397
451
  last_commit_sha: null,
398
452
  last_error: null,
453
+ last_push_at: null,
454
+ last_push_sha: null,
455
+ last_push_error: null,
456
+ commits_unpushed: null,
399
457
  };
400
458
  return this.getStatus();
401
459
  }
@@ -815,6 +873,77 @@ export class MirrorManager {
815
873
  const sha = new TextDecoder().decode(await new Response(shaProc.stdout).arrayBuffer()).trim();
816
874
  if (sha.length > 0) this.status.last_commit_sha = sha;
817
875
  }
876
+
877
+ // Cut 5: surface push outcome (success/failure + redacted error).
878
+ // runGitCommitCycle returns push info iff push was attempted; we
879
+ // mirror the same shape into status here.
880
+ if (commitResult.push) {
881
+ const now = new Date().toISOString();
882
+ if (commitResult.push.ok) {
883
+ this.status.last_push_at = now;
884
+ this.status.last_push_sha = this.status.last_commit_sha;
885
+ this.status.last_push_error = null;
886
+ } else if (commitResult.push.error) {
887
+ this.status.last_push_error = commitResult.push.error;
888
+ }
889
+ }
890
+
891
+ // Cut 5: track local-commits-ahead-of-upstream so the UI can render
892
+ // "<N> commits ready to push" subdued helper text.
893
+ this.status.commits_unpushed = await readCommitsUnpushed(path);
894
+ }
895
+
896
+ /**
897
+ * Cut 6: fire a `git push` against the current mirror dir, capture
898
+ * the outcome into status, and return a struct the route handler can
899
+ * fold into its response.
900
+ *
901
+ * Distinguished from `runNow()` which exports + commits + pushes.
902
+ * `pushNow()` skips export + commit entirely and only pushes whatever
903
+ * has already been committed. Used by:
904
+ * - the credential save path (auto-fire after `auth/pat` /
905
+ * `auth/github/select-repo` lands) so the operator sees the
906
+ * push happen rather than waiting for the next write
907
+ * - the explicit POST /.parachute/mirror/push-now route the SPA's
908
+ * "Push now" button hits
909
+ *
910
+ * Idempotent against a fully-synced mirror (git push reports "nothing
911
+ * to push" with exit 0). Failure paths set `last_push_error` for the
912
+ * status surface.
913
+ */
914
+ async pushNow(): Promise<
915
+ | { fired: false; reason: "not_enabled" | "no_mirror_path" }
916
+ | { fired: true; pushed: boolean; sha?: string; error?: string }
917
+ > {
918
+ if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
919
+ if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
920
+ const path = this.status.mirror_path;
921
+ const pushResult = await gitPush(path);
922
+ const now = new Date().toISOString();
923
+ // Refresh commits_unpushed either way — a no-op push still reflects
924
+ // the current state (0 ahead if synced, N if push failed mid-way).
925
+ this.status.commits_unpushed = await readCommitsUnpushed(path);
926
+ if (pushResult.ok) {
927
+ // Capture HEAD sha at this point so the UI can show the operator
928
+ // exactly what landed.
929
+ const shaProc = Bun.spawn(["git", "rev-parse", "HEAD"], {
930
+ cwd: path,
931
+ stdout: "pipe",
932
+ stderr: "pipe",
933
+ });
934
+ await shaProc.exited;
935
+ const sha = new TextDecoder()
936
+ .decode(await new Response(shaProc.stdout).arrayBuffer())
937
+ .trim();
938
+ this.status.last_push_at = now;
939
+ if (sha.length > 0) this.status.last_push_sha = sha;
940
+ this.status.last_push_error = null;
941
+ return { fired: true, pushed: true, sha: sha.length > 0 ? sha : undefined };
942
+ }
943
+ const redacted = redactToken(pushResult.stderr);
944
+ this.status.last_push_error = redacted;
945
+ console.warn(`[mirror] push-now failed (non-fatal): ${redacted}`);
946
+ return { fired: true, pushed: false, error: redacted };
818
947
  }
819
948
 
820
949
  /**
@@ -24,9 +24,11 @@ import {
24
24
  handleAuthGithubDeviceCode,
25
25
  handleAuthGithubPoll,
26
26
  handleAuthGithubRepos,
27
+ handleAuthGithubSelectRepo,
27
28
  handleAuthPat,
28
29
  handleMirrorGet,
29
30
  handleMirrorImport,
31
+ handleMirrorPushNow,
30
32
  handleMirrorPut,
31
33
  handleMirrorRunNow,
32
34
  } from "./mirror-routes.ts";
@@ -767,6 +769,172 @@ describe("auth credential routes — PAT", () => {
767
769
  }, 20_000);
768
770
  });
769
771
 
772
+ // ---------------------------------------------------------------------------
773
+ // Cuts 3 + 6: credential save → auto_push flipped on + initial push fires.
774
+ //
775
+ // The PAT route gates on a real `git ls-remote` probe, so it's awkward
776
+ // to exercise end-to-end in a hermetic test. The select-repo route has
777
+ // no probe — it accepts the operator's already-OAuth'd token and wires
778
+ // the URL. We drive the side-effect helpers (maybeEnableAutoPush +
779
+ // maybeFireInitialPush) through select-repo, with a local bare repo as
780
+ // the "GitHub" remote — overridden via post-save remote-URL rewrite so
781
+ // pushNow targets the test repo.
782
+ // ---------------------------------------------------------------------------
783
+
784
+ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)", () => {
785
+ let home: string;
786
+ let remote: string;
787
+ afterEach(() => {
788
+ if (home) fs.rmSync(home, { recursive: true, force: true });
789
+ if (remote) fs.rmSync(remote, { recursive: true, force: true });
790
+ });
791
+
792
+ test("select-repo flips auto_push from false → true on an enabled internal mirror", async () => {
793
+ home = tmp("mirror-selectrepo-autopush-");
794
+ remote = tmp("mirror-selectrepo-remote-");
795
+ Bun.spawnSync(["git", "init", "--bare", "-q", "-b", "main"], { cwd: remote });
796
+
797
+ const { manager, deps } = makeManager(home);
798
+ deps.writeMirrorConfig({
799
+ ...defaultMirrorConfig(),
800
+ enabled: true,
801
+ location: "internal",
802
+ sync_mode: "manual",
803
+ auto_commit: false,
804
+ auto_push: false,
805
+ });
806
+ await manager.start();
807
+ expect(manager.getConfig().auto_push).toBe(false);
808
+
809
+ // Seed github_oauth credentials. select-repo doesn't probe; it just
810
+ // sets origin to github.com/<owner>/<repo>.git. We then rewrite
811
+ // origin to our local bare repo so pushNow has somewhere to land.
812
+ writeCredentials({
813
+ active_method: "github_oauth",
814
+ github_oauth: {
815
+ access_token: "gho_fake1234567890abcd",
816
+ scope: "repo",
817
+ authorized_at: "2026-05-28T03:14:15.000Z",
818
+ user_login: "aaron",
819
+ user_id: 1,
820
+ },
821
+ pat: null,
822
+ });
823
+
824
+ const res = await handleAuthGithubSelectRepo(
825
+ new Request("http://x/select", {
826
+ method: "POST",
827
+ body: JSON.stringify({ owner: "aaron", name: "my-vault" }),
828
+ }),
829
+ manager,
830
+ );
831
+ expect(res.status).toBe(200);
832
+ const body = (await res.json()) as {
833
+ ok: boolean;
834
+ auto_push_was_already_enabled: boolean;
835
+ auto_push_enabled: boolean;
836
+ initial_push:
837
+ | { fired: false; reason: string }
838
+ | { fired: true; pushed: boolean; error?: string; sha?: string };
839
+ };
840
+ // Cut 3 — auto_push went from false → true.
841
+ expect(body.auto_push_was_already_enabled).toBe(false);
842
+ expect(body.auto_push_enabled).toBe(true);
843
+ expect(manager.getConfig().auto_push).toBe(true);
844
+ // Cut 6 — initial push fired. It points at github.com which doesn't
845
+ // exist for our fake creds → expected to fail with a redacted error
846
+ // in last_push_error, but the helper still reports fired=true.
847
+ expect(body.initial_push.fired).toBe(true);
848
+ if (body.initial_push.fired === true) {
849
+ // Push failure is fine — point is "we tried."
850
+ expect(typeof body.initial_push.pushed).toBe("boolean");
851
+ }
852
+ // Token doesn't leak into the response.
853
+ expect(JSON.stringify(body)).not.toContain("gho_fake1234567890");
854
+ await manager.stop();
855
+ }, 30_000);
856
+
857
+ test("select-repo is a no-op for auto_push when mirror is disabled", async () => {
858
+ // Operator wiring credentials before flipping the mirror on — don't
859
+ // mutate auto_push behind their back.
860
+ home = tmp("mirror-selectrepo-disabled-");
861
+ const { manager, deps } = makeManager(home);
862
+ deps.writeMirrorConfig({
863
+ ...defaultMirrorConfig(),
864
+ enabled: false,
865
+ auto_push: false,
866
+ });
867
+ await manager.start();
868
+ writeCredentials({
869
+ active_method: "github_oauth",
870
+ github_oauth: {
871
+ access_token: "gho_anothertoken12345",
872
+ scope: "repo",
873
+ authorized_at: "2026-05-28T03:14:15.000Z",
874
+ user_login: "aaron",
875
+ user_id: 1,
876
+ },
877
+ pat: null,
878
+ });
879
+ const res = await handleAuthGithubSelectRepo(
880
+ new Request("http://x/select", {
881
+ method: "POST",
882
+ body: JSON.stringify({ owner: "aaron", name: "v" }),
883
+ }),
884
+ manager,
885
+ );
886
+ expect(res.status).toBe(200);
887
+ expect(manager.getConfig().auto_push).toBe(false);
888
+ await manager.stop();
889
+ });
890
+ });
891
+
892
+ // ---------------------------------------------------------------------------
893
+ // Cut 6: POST /.parachute/mirror/push-now route handler.
894
+ // ---------------------------------------------------------------------------
895
+
896
+ describe("handleMirrorPushNow — Cut 6", () => {
897
+ let home: string;
898
+ afterEach(() => {
899
+ if (home) fs.rmSync(home, { recursive: true, force: true });
900
+ });
901
+
902
+ test("returns 400 when mirror is not enabled", async () => {
903
+ home = tmp("mirror-pushnow-disabled-");
904
+ const { manager, deps } = makeManager(home);
905
+ deps.writeMirrorConfig({ ...defaultMirrorConfig(), enabled: false });
906
+ await manager.start();
907
+ const res = await handleMirrorPushNow(manager);
908
+ expect(res.status).toBe(400);
909
+ const body = (await res.json()) as { error: string };
910
+ expect(body.error).toBe("Mirror not enabled");
911
+ });
912
+
913
+ test("returns 200 + push outcome when mirror is enabled (even on push failure)", async () => {
914
+ home = tmp("mirror-pushnow-enabled-");
915
+ const { manager, deps } = makeManager(home);
916
+ deps.writeMirrorConfig({
917
+ ...defaultMirrorConfig(),
918
+ enabled: true,
919
+ location: "internal",
920
+ sync_mode: "manual",
921
+ auto_commit: false,
922
+ });
923
+ await manager.start();
924
+ // No remote → push will fail; the handler still returns 200 with
925
+ // the failure surface in the body. Push failures aren't 500s.
926
+ const res = await handleMirrorPushNow(manager);
927
+ expect(res.status).toBe(200);
928
+ const body = (await res.json()) as {
929
+ status: { last_push_error: string | null };
930
+ push: { fired: boolean };
931
+ };
932
+ expect(body.push.fired).toBe(true);
933
+ expect(body.status.last_push_error).not.toBeNull();
934
+ await manager.stop();
935
+ }, 30_000);
936
+ });
937
+
770
938
  describe("auth credential routes — github repos / create-repo", () => {
771
939
  let home: string;
772
940
  afterEach(() => {
@@ -61,6 +61,7 @@ import {
61
61
  type ImportAuth,
62
62
  type ImportResult,
63
63
  } from "./mirror-import.ts";
64
+ import { redactToken } from "./export-watch.ts";
64
65
  import { getVaultStore } from "./vault-store.ts";
65
66
  import { assetsDir } from "./routes.ts";
66
67
 
@@ -211,6 +212,44 @@ export async function handleMirrorRunNow(
211
212
  );
212
213
  }
213
214
 
215
+ /**
216
+ * `POST /vault/<name>/.parachute/mirror/push-now` — Cut 6 of vault#392.
217
+ *
218
+ * Fire a push against the currently-committed state of the mirror dir.
219
+ * Distinguished from `/run-now` which exports + commits + pushes —
220
+ * `push-now` is the credentials-side flow where the operator just wants
221
+ * to see "did the credentials I just saved actually work?"
222
+ *
223
+ * Returns the post-push status snapshot. Errors (no path, mirror
224
+ * disabled, push failed) surface as a JSON response — push failures
225
+ * are NOT 500s because the operator's expected next-step is to look at
226
+ * `last_push_error` and fix their remote, not "vault crashed."
227
+ */
228
+ export async function handleMirrorPushNow(
229
+ manager: MirrorManager,
230
+ ): Promise<Response> {
231
+ const status = manager.getStatus();
232
+ if (!status.enabled) {
233
+ return Response.json(
234
+ {
235
+ error: "Mirror not enabled",
236
+ message:
237
+ "Mirror must be enabled before a push can fire. Enable it via PUT /.parachute/mirror first.",
238
+ },
239
+ { status: 400 },
240
+ );
241
+ }
242
+ const result = await manager.pushNow();
243
+ return Response.json(
244
+ {
245
+ config: manager.getConfig(),
246
+ status: manager.getStatus(),
247
+ push: result,
248
+ },
249
+ { headers: { "Access-Control-Allow-Origin": "*" } },
250
+ );
251
+ }
252
+
214
253
  /**
215
254
  * Convenience for tests + future callers: build the GET response from a
216
255
  * known-good config without needing a real MirrorManager.
@@ -602,9 +641,29 @@ export async function handleAuthPat(
602
641
  // resolved + on disk. Non-fatal if the mirror isn't running.
603
642
  await applyCredentialsToMirror(manager);
604
643
 
605
- return Response.json(sanitizeCredentials(next), {
606
- headers: { "Access-Control-Allow-Origin": "*" },
607
- });
644
+ // Cut 3: auto-enable auto_push when credentials save. Operator wiring
645
+ // credentials almost certainly wants pushes to fire — silent
646
+ // "credentials saved but pushes never happen" is the bug Aaron hit.
647
+ // If `auto_push` was already true, leave it alone (idempotent).
648
+ const autoPushChange = await maybeEnableAutoPush(manager);
649
+
650
+ // Cut 6: kick off an initial push-now so the operator sees the push
651
+ // happen immediately rather than waiting for the next write event.
652
+ // Only when (a) auto_push ended up true AND (b) there are local commits
653
+ // ahead of the (now-configured) remote.
654
+ const initialPush = await maybeFireInitialPush(manager, autoPushChange.auto_push_now_enabled);
655
+
656
+ return Response.json(
657
+ {
658
+ ...sanitizeCredentials(next),
659
+ auto_push_was_already_enabled: autoPushChange.was_already_enabled,
660
+ auto_push_enabled: autoPushChange.auto_push_now_enabled,
661
+ initial_push: initialPush,
662
+ },
663
+ {
664
+ headers: { "Access-Control-Allow-Origin": "*" },
665
+ },
666
+ );
608
667
  }
609
668
 
610
669
  /**
@@ -846,6 +905,12 @@ export async function handleAuthGithubSelectRepo(
846
905
  }
847
906
  applied = true;
848
907
  }
908
+ // Cut 3 / Cut 6: auto-enable auto_push + fire initial push if there's
909
+ // anything to push. Same logic as handleAuthPat — operator picking a
910
+ // repo wants the push to fire.
911
+ const autoPushChange = await maybeEnableAutoPush(manager);
912
+ const initialPush = await maybeFireInitialPush(manager, autoPushChange.auto_push_now_enabled);
913
+
849
914
  return Response.json(
850
915
  {
851
916
  ok: true,
@@ -855,11 +920,98 @@ export async function handleAuthGithubSelectRepo(
855
920
  // Echo the redacted form back so the SPA can show "pushing to <repo>".
856
921
  // No raw token in the response.
857
922
  remote: `https://github.com/${owner}/${name}.git`,
923
+ auto_push_was_already_enabled: autoPushChange.was_already_enabled,
924
+ auto_push_enabled: autoPushChange.auto_push_now_enabled,
925
+ initial_push: initialPush,
858
926
  },
859
927
  { headers: { "Access-Control-Allow-Origin": "*" } },
860
928
  );
861
929
  }
862
930
 
931
+ /**
932
+ * Cut 3: when credentials save, flip `auto_push` from false → true on
933
+ * the persisted config. Operators wiring credentials almost certainly
934
+ * want pushes to fire — silent "credentials saved but no push" was the
935
+ * three-stacking-gaps bug Aaron hit.
936
+ *
937
+ * Idempotent: if `auto_push` was already true, no config write happens.
938
+ * If `auto_push` is false, write the flipped config via `manager.reload`
939
+ * — that restarts the lifecycle and applies the credentials to the
940
+ * remote in the same pass.
941
+ *
942
+ * Returns a small struct so the route handler can shape the response:
943
+ * - `was_already_enabled` — true iff auto_push was true before this call.
944
+ * - `auto_push_now_enabled` — true iff auto_push is true after this call.
945
+ *
946
+ * Both being false means the operator deliberately left auto_push off
947
+ * and we leave it that way (we never touch a config whose mirror is
948
+ * disabled, and we never flip true → false).
949
+ */
950
+ async function maybeEnableAutoPush(
951
+ manager: MirrorManager,
952
+ ): Promise<{ was_already_enabled: boolean; auto_push_now_enabled: boolean }> {
953
+ const config = manager.getConfig();
954
+ // Don't muck with auto_push when the mirror is disabled — the operator
955
+ // is configuring credentials before turning the mirror on, which is a
956
+ // legitimate sequence. They'll flip enabled themselves.
957
+ if (!config.enabled) {
958
+ return {
959
+ was_already_enabled: config.auto_push,
960
+ auto_push_now_enabled: config.auto_push,
961
+ };
962
+ }
963
+ if (config.auto_push) {
964
+ return { was_already_enabled: true, auto_push_now_enabled: true };
965
+ }
966
+ try {
967
+ await manager.reload({ ...config, auto_push: true });
968
+ return { was_already_enabled: false, auto_push_now_enabled: true };
969
+ } catch (err) {
970
+ console.warn(
971
+ `[mirror-auth] could not auto-enable auto_push (non-fatal): ${(err as Error).message ?? err}`,
972
+ );
973
+ return { was_already_enabled: false, auto_push_now_enabled: false };
974
+ }
975
+ }
976
+
977
+ /**
978
+ * Cut 6: after credential save, fire a `push-now` immediately so the
979
+ * operator sees the push happen rather than waiting for the next write
980
+ * event. Only when (a) auto_push is enabled (we just turned it on, or it
981
+ * was already on) AND (b) there are local commits to push.
982
+ *
983
+ * Best-effort: non-fatal on any failure. Returns a struct the caller
984
+ * folds into its response.
985
+ */
986
+ async function maybeFireInitialPush(
987
+ manager: MirrorManager,
988
+ autoPushEnabled: boolean,
989
+ ): Promise<
990
+ | { fired: false; reason: "auto_push_disabled" | "no_mirror_path" | "manager_skipped" | "not_enabled" }
991
+ | { fired: true; pushed: boolean; error?: string; sha?: string }
992
+ > {
993
+ if (!autoPushEnabled) return { fired: false, reason: "auto_push_disabled" };
994
+ const status = manager.getStatus();
995
+ if (!status.mirror_path) return { fired: false, reason: "no_mirror_path" };
996
+ // Need an active enabled mirror with a resolved path to push from.
997
+ if (!status.enabled) return { fired: false, reason: "manager_skipped" };
998
+ try {
999
+ const result = await manager.pushNow();
1000
+ return result;
1001
+ } catch (err) {
1002
+ // Defense-in-depth: every other push-error site routes through
1003
+ // `redactToken` before surfacing — this catch-block was the one
1004
+ // outlier where a thrown error's `.message` could carry an
1005
+ // un-redacted token from a future code path that throws before the
1006
+ // existing internal redaction runs. Apply the same scrub here.
1007
+ // Reviewer-flagged on vault#392.
1008
+ const rawMessage = (err as Error).message ?? String(err);
1009
+ const safeMessage = redactToken(rawMessage);
1010
+ console.warn(`[mirror-auth] initial push-now failed (non-fatal): ${safeMessage}`);
1011
+ return { fired: true, pushed: false, error: safeMessage };
1012
+ }
1013
+ }
1014
+
863
1015
  /**
864
1016
  * Apply the active credential's remote URL to the running mirror dir.
865
1017
  * Idempotent. Called from auth/pat (after store) + auth/github/select-repo
package/src/routing.ts CHANGED
@@ -83,6 +83,7 @@ import {
83
83
  handleAuthPat,
84
84
  handleMirrorGet,
85
85
  handleMirrorImport,
86
+ handleMirrorPushNow,
86
87
  handleMirrorPut,
87
88
  handleMirrorRunNow,
88
89
  } from "./mirror-routes.ts";
@@ -557,6 +558,38 @@ export async function route(
557
558
  return Response.json({ error: "Method not allowed" }, { status: 405 });
558
559
  }
559
560
 
561
+ // /.parachute/mirror/push-now — fire `git push` against committed state.
562
+ // Cut 6 of vault#392. Same admin gate + manager check as run-now;
563
+ // POST-only. Distinguished from /run-now in that this skips export +
564
+ // commit, only pushes — for "did my credentials actually work?" flow.
565
+ if (subpath === "/.parachute/mirror/push-now") {
566
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
567
+ return Response.json(
568
+ {
569
+ error: "Forbidden",
570
+ error_type: "insufficient_scope",
571
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
572
+ required_scope: SCOPE_ADMIN,
573
+ granted_scopes: auth.scopes,
574
+ },
575
+ { status: 403 },
576
+ );
577
+ }
578
+ const manager = getMirrorManager();
579
+ if (!manager) {
580
+ return Response.json(
581
+ {
582
+ error: "Mirror manager not initialized",
583
+ message:
584
+ "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
585
+ },
586
+ { status: 503 },
587
+ );
588
+ }
589
+ if (req.method === "POST") return handleMirrorPushNow(manager);
590
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
591
+ }
592
+
560
593
  // /.parachute/mirror/import — clone a vault export from git + import.
561
594
  // Admin-gated. POST-only. Synchronous (imports finish in <30s for
562
595
  // typical vaults). See mirror-routes.ts:handleMirrorImport for the