@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/src/mirror-manager.ts
CHANGED
|
@@ -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(() => {
|
package/src/mirror-routes.ts
CHANGED
|
@@ -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
|
-
|
|
606
|
-
|
|
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
|