@openparachute/vault 0.4.9-rc.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.9-rc.7",
3
+ "version": "0.4.9-rc.9",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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
  // ---------------------------------------------------------------------------
@@ -121,11 +121,72 @@ export async function gitCommit(
121
121
  return { ok: exitCode === 0, stderr: stderr.trim() };
122
122
  }
123
123
 
124
- /** Run `git push` in `repoDir`. Returns true on success. */
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
- const proc = Bun.spawn(["git", "push"], {
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<{ committed: boolean; message?: string }> {
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
- console.warn(`[git-commit] git push failed (non-fatal): ${pushResult.stderr}`);
249
- } else {
250
- console.log(`[git-commit] pushed`);
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 (validation rather than silent-fail)", () => {
337
- // Pre-event-driven shape silently let push fail at runtime for
338
- // internal mirrors. Now we reject the combination at config time so
339
- // the operator sees the issue immediately.
340
- const r = validateMirrorConfigShape({
341
- enabled: true,
342
- location: "internal",
343
- auto_push: true,
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) expect(r.field).toBe("auto_push");
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", () => {
@@ -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
- * Does NOT touch the filesystem operators get a fast 400 on shape
376
- * errors before vault attempts any filesystem work. Filesystem-level
377
- * validation (path exists, is a git repo) lives in `validateExternalPath`.
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 only makes sense for external mirrors —
548
- // internal mirrors live under vault's data dir with no configured
549
- // remote. Silent-fail on push was the pre-event-driven behavior; the
550
- // backend now rejects the combination so the operator sees the issue
551
- // at config time. The UI hides the auto_push checkbox when location
552
- // is internal (vault#380's reviewer-flagged nit), so this only fires
553
- // when an operator either edits config.yaml by hand or POSTs a bare
554
- // JSON body with mismatched fields.
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
- return {
557
- ok: false,
558
- field: "auto_push",
559
- error:
560
- "`auto_push: true` requires `location: external` (internal mirrors live under the vault data directory and have no configured remote). Switch the location, or set auto_push to false.",
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 };