@openparachute/vault 0.4.8 → 0.4.9-rc.11

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.
Files changed (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -69,11 +69,16 @@ describe("shouldAutoTranscribe", () => {
69
69
  })).toBe(false);
70
70
  });
71
71
 
72
- test("skips when enabled is unset (no auto_transcribe block in config)", () => {
72
+ test("fires when enabled is unset unset config means ON", () => {
73
+ // Default behavior (no `auto_transcribe` block in config) is opt-out:
74
+ // once an operator has scribe reachable, audio attachments transcribe
75
+ // automatically. Operators wanting it OFF set
76
+ // `auto_transcribe.enabled: false` explicitly. Previously default-off;
77
+ // flipped to default-on so installing scribe is the only opt-in signal.
73
78
  expect(shouldAutoTranscribe("audio/wav", {
74
79
  readGlobalConfigImpl: readGlobalConfig(undefined),
75
80
  getCachedScribeUrlImpl: scribePresent,
76
- })).toBe(false);
81
+ })).toBe(true);
77
82
  });
78
83
 
79
84
  test("skips when scribe URL is undefined (no services.json entry, no env)", () => {
@@ -19,7 +19,11 @@ import { getCachedScribeUrl } from "./scribe-discovery.ts";
19
19
  *
20
20
  * Returns `true` only when ALL three conditions hold:
21
21
  * 1. mime-type starts with `audio/` (case-insensitive).
22
- * 2. `globalConfig.auto_transcribe?.enabled === true`.
22
+ * 2. `globalConfig.auto_transcribe?.enabled` is not explicitly false.
23
+ * Default behavior (when unset) is ON — once an operator has scribe
24
+ * reachable, audio attachments transcribe automatically without a
25
+ * separate config step. Operators who want it OFF set
26
+ * `auto_transcribe.enabled: false` explicitly.
23
27
  * 3. Scribe is discoverable (services.json entry OR SCRIBE_URL env).
24
28
  *
25
29
  * The three conditions are independent guards: a single `false` is sufficient
@@ -40,7 +44,7 @@ export function shouldAutoTranscribe(
40
44
  }
41
45
  const enabled = opts.enabledOverride
42
46
  ?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
43
- ?? false;
47
+ ?? true;
44
48
  if (!enabled) return false;
45
49
  const url = (opts.getCachedScribeUrlImpl ?? getCachedScribeUrl)();
46
50
  if (!url || !url.trim()) return false;
package/src/cli.ts CHANGED
@@ -222,6 +222,9 @@ switch (command) {
222
222
  case "export":
223
223
  await cmdExport(cmdArgs);
224
224
  break;
225
+ case "schema":
226
+ await cmdSchema(cmdArgs);
227
+ break;
225
228
  case "help":
226
229
  case "--help":
227
230
  case "-h":
@@ -933,11 +936,10 @@ function takeArgValue(args: string[], name: string): { value?: string; missingVa
933
936
  * Targeting:
934
937
  * --scope <verb> vault:read | vault:write | vault:admin (default: vault:read).
935
938
  * For --mint, expands to vault:<vault-name>:<verb>.
936
- * vault:admin requires --legacy-pat hub policy makes
937
- * per-vault admin non-requestable via mint-token (it's
938
- * operator-only, minted only through the session-
939
- * cookie-gated admin SPA endpoint), so --mint + admin
940
- * is rejected pre-flight.
939
+ * vault:admin is mintable via --mint as of hub PR-A
940
+ * (hub#449): hub mints per-vault admin when the operator
941
+ * bearer carries parachute:host:admin (the default
942
+ * operator.token does). Requires a hub running PR-A.
941
943
  * --install-scope <s> local (default) | user | project. local writes to
942
944
  * ~/.claude.json under projects[<cwd>].mcpServers
943
945
  * (private, this directory only — matches Claude
@@ -1307,7 +1309,7 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1307
1309
  console.error(
1308
1310
  "Note: --legacy-pat mints a vault-DB pvt_* token. The hub-issued JWT path (--mint, default) " +
1309
1311
  "is the canonical install going forward; pvt_* support is preserved for self-hosted-without-hub " +
1310
- "setups, tracked at vault#288, planned removal 0.6.0.",
1312
+ "setups, tracked at vault#282, planned removal 0.6.0.",
1311
1313
  );
1312
1314
  const store = getVaultStore(vaultName);
1313
1315
  const { fullToken } = generateToken();
@@ -1326,28 +1328,13 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1326
1328
  bearer = fullToken;
1327
1329
  } else {
1328
1330
  // mode === "mint"
1329
- // Pre-flight: hub policy rejects `vault:<name>:admin` via the public
1330
- // mint-token endpoint. Per-vault admin is operator-only, mintable
1331
- // only through the session-cookie-gated `/admin/vault-admin-token/:name`
1332
- // SPA path. Calling hub with admin would surface a 400:
1333
- // "Hub mint-token rejected (HTTP 400, invalid_scope):
1334
- // scope vault:default:admin is not requestable via mint-token;
1335
- // use OAuth flow or operator rotation"
1336
- // Fail early with the actionable remediation rather than letting
1337
- // the operator chase the hub's wire-level error. See
1338
- // `parachute-hub/src/scope-explanations.ts` (VAULT_ADMIN_RE) and
1339
- // `parachute-hub/src/api-mint-token.ts` (non-requestable guard).
1340
- if (verb === "admin") {
1341
- console.error(
1342
- "Hub policy: vault:<name>:admin is not requestable via mint-token " +
1343
- "(per-vault admin is operator-only, minted only by the session-cookie-gated " +
1344
- "admin SPA at <hub>/admin/vaults/" + vaultName + ").\n" +
1345
- " Fix: use `--legacy-pat --scope vault:admin` to mint a vault-DB pvt_* with admin scope " +
1346
- "(the right shape for an MCP entry needing schema management).\n" +
1347
- " Or: drop --scope to default to vault:read (least privilege), or use --scope vault:write.",
1348
- );
1349
- process.exit(1);
1350
- }
1331
+ // `vault:<name>:admin` is mintable via the hub mint-token endpoint as
1332
+ // of hub PR-A (hub#449): the hub mints per-vault admin when the calling
1333
+ // operator bearer carries `parachute:host:admin` (which the default
1334
+ // operator.token does). No admin pre-flight reject the narrowScope
1335
+ // below produces `vault:<name>:admin` and the hub honors it. This
1336
+ // requires a hub running PR-A; older hubs reject admin with HTTP 400
1337
+ // invalid_scope, surfaced via the api-error branch below.
1351
1338
  const operatorToken = readOperatorToken();
1352
1339
  if (!operatorToken) {
1353
1340
  console.error(
@@ -1391,6 +1378,17 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1391
1378
  console.error(
1392
1379
  `Hub mint-token rejected (HTTP ${result.status}, ${result.error}): ${result.description}`,
1393
1380
  );
1381
+ // Older hubs (pre-hub#449) reject vault:<name>:admin as a
1382
+ // non-requestable scope with HTTP 400 invalid_scope. Surface an
1383
+ // actionable hint so the operator reaches for the upgrade rather
1384
+ // than chasing the wire-level error.
1385
+ if (result.status === 400 && verb === "admin") {
1386
+ console.error(
1387
+ " Hint: minting vault:admin requires a hub with per-vault admin mint support " +
1388
+ "(hub#449). Your hub may predate it — upgrade the hub, or use --legacy-pat for " +
1389
+ "a vault-DB pvt_* admin token instead.",
1390
+ );
1391
+ }
1394
1392
  break;
1395
1393
  }
1396
1394
  process.exit(1);
@@ -3217,6 +3215,92 @@ async function cmdExport(args: string[]) {
3217
3215
  await new Promise(() => {});
3218
3216
  }
3219
3217
 
3218
+ // ---------------------------------------------------------------------------
3219
+ // Schema maintenance — `parachute-vault schema <subcommand>`
3220
+ // ---------------------------------------------------------------------------
3221
+
3222
+ /**
3223
+ * `parachute-vault schema prune` — drop orphaned indexed-field columns +
3224
+ * indexes whose declaring tags no longer exist (the gitcoin orphaned-fields
3225
+ * bug). Dry-run by default; `--apply` (alias `--yes`) executes. A field
3226
+ * co-declared by a still-live tag is never dropped — only the dead declarers
3227
+ * are trimmed. Dropping a generated column loses only the index; the source
3228
+ * values stay in notes.metadata, so the column rebuilds when the field is
3229
+ * declared again.
3230
+ */
3231
+ async function cmdSchema(args: string[] = []) {
3232
+ const sub = args[0];
3233
+ if (sub !== "prune") {
3234
+ console.error("Usage: parachute-vault schema prune [--vault <name>] [--dry-run|--apply|--yes]");
3235
+ console.error("\nSubcommands:");
3236
+ console.error(" prune Drop orphaned indexed-field columns whose declaring tags are gone.");
3237
+ console.error(" Dry-run by default (--dry-run is an explicit alias); pass --apply (or --yes) to execute.");
3238
+ process.exit(1);
3239
+ }
3240
+
3241
+ let vaultName = "default";
3242
+ let apply = false;
3243
+ for (let i = 1; i < args.length; i++) {
3244
+ const arg = args[i]!;
3245
+ if (arg === "--vault") {
3246
+ const v = args[++i];
3247
+ if (!v) {
3248
+ console.error("--vault requires a value.");
3249
+ process.exit(1);
3250
+ }
3251
+ vaultName = v;
3252
+ } else if (arg === "--apply" || arg === "--yes") {
3253
+ apply = true;
3254
+ } else if (arg === "--dry-run") {
3255
+ // Dry-run is the default; accept the flag as an explicit affirmative
3256
+ // for convention + scriptability. --apply wins if both are passed.
3257
+ } else {
3258
+ console.error(`Unknown flag for \`schema prune\`: ${arg}`);
3259
+ process.exit(1);
3260
+ }
3261
+ }
3262
+
3263
+ const config = readVaultConfig(vaultName);
3264
+ if (!config) {
3265
+ console.error(`Vault "${vaultName}" not found.`);
3266
+ process.exit(1);
3267
+ }
3268
+
3269
+ const { getVaultStore } = await import("./vault-store.ts");
3270
+ const store = getVaultStore(vaultName);
3271
+ const plan = await store.pruneIndexedFields({ dryRun: !apply });
3272
+
3273
+ const dropped = plan.filter((p) => p.dropped);
3274
+ const trimmed = plan.filter((p) => !p.dropped);
3275
+
3276
+ if (plan.length === 0) {
3277
+ console.log(`No orphaned indexed fields in vault "${vaultName}". Nothing to prune.`);
3278
+ return;
3279
+ }
3280
+
3281
+ console.log(
3282
+ apply
3283
+ ? `Pruned orphaned indexed fields in vault "${vaultName}":`
3284
+ : `Would prune orphaned indexed fields in vault "${vaultName}" (dry-run):`,
3285
+ );
3286
+ for (const p of dropped) {
3287
+ console.log(
3288
+ ` ${apply ? "DROPPED" : "drop "} ${p.field} (dead declarers: ${p.deadDeclarers.join(", ")})`,
3289
+ );
3290
+ }
3291
+ for (const p of trimmed) {
3292
+ console.log(
3293
+ ` ${apply ? "TRIMMED" : "trim "} ${p.field} (removed dead declarers: ${p.deadDeclarers.join(", ")}; column kept — still co-declared)`,
3294
+ );
3295
+ }
3296
+ console.log(
3297
+ `\n${apply ? "Dropped" : "Would drop"} ${dropped.length} orphaned field(s); ${apply ? "trimmed" : "would trim"} ${trimmed.length} co-declared field(s).`,
3298
+ );
3299
+ if (!apply) {
3300
+ console.log("Re-run with --apply (or --yes) to execute.");
3301
+ }
3302
+ }
3303
+
3220
3304
  // ---------------------------------------------------------------------------
3221
3305
  // Export-watch glue. The git-shell + commit-message logic lives in
3222
3306
  // `./export-watch.ts` for unit-testability; cli.ts just wires it in.
@@ -3408,14 +3492,15 @@ Vaults:
3408
3492
  --token <t>: paste an existing bearer
3409
3493
  (any shape) instead of minting.
3410
3494
  --legacy-pat: mint a vault-DB pvt_*
3411
- token (deprecated; for self-hosted-
3412
- without-hub setups). Also the only
3413
- path for --scope vault:admin — hub
3414
- policy reserves per-vault admin for
3415
- operator-only minting (the admin SPA
3416
- session-cookie path), so --mint
3417
- --scope vault:admin is rejected
3418
- pre-flight.
3495
+ token (deprecated, vault#282; removal
3496
+ 0.6.0; for self-hosted-without-hub
3497
+ setups).
3498
+ --scope vault:admin IS mintable via
3499
+ --mint (hub#449): hub mints
3500
+ vault:<name>:admin when the operator
3501
+ bearer carries parachute:host:admin
3502
+ (the default operator.token does).
3503
+ Requires a hub running hub#449.
3419
3504
  --install-scope local (default) writes
3420
3505
  ~/.claude.json under
3421
3506
  projects[<cwd>].mcpServers (this
@@ -3513,6 +3598,16 @@ Import/Export:
3513
3598
  template via --git-message-template;
3514
3599
  --git-push to push after commit)
3515
3600
 
3601
+ Schema maintenance:
3602
+ parachute-vault schema prune [--vault <name>] Drop orphaned indexed-field columns +
3603
+ indexes whose declaring tags no longer
3604
+ exist. Dry-run by default (--dry-run is an
3605
+ explicit alias) — prints the drop plan
3606
+ without changing anything.
3607
+ parachute-vault schema prune --apply Execute the prune (alias: --yes). Co-declared
3608
+ fields keep their column; a drop loses only
3609
+ the index (data lives in notes.metadata).
3610
+
3516
3611
  ── Advanced / standalone ──────────────────────────────────────────────
3517
3612
 
3518
3613
  Direct daemon controls. For normal use, prefer the Parachute Hub wrappers
package/src/config.ts CHANGED
@@ -34,8 +34,11 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSy
34
34
  import crypto from "node:crypto";
35
35
 
36
36
  import {
37
+ // vault#400: only the PARSE side is used here now — to detect a legacy
38
+ // server-wide `mirror:` block so the boot migration can relocate it to the
39
+ // owning vault's per-vault file. `writeGlobalConfig` no longer serializes a
40
+ // mirror block (per-vault writes live in `mirror-config.ts`).
37
41
  parseMirrorConfig as parseMirrorSectionFromYaml,
38
- serializeMirrorConfig as serializeMirrorSection,
39
42
  type MirrorConfig as MirrorConfigType,
40
43
  } from "./mirror-config.ts";
41
44
 
@@ -1330,9 +1333,14 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1330
1333
  lines.push(...serializeBackup(config.backup));
1331
1334
  }
1332
1335
 
1333
- if (config.mirror) {
1334
- lines.push(...serializeMirrorSection(config.mirror));
1335
- }
1336
+ // vault#400: mirror config is now PER-VAULT (`data/<vault>/mirror-config.yaml`),
1337
+ // not a server-wide block here. `writeGlobalConfig` deliberately does NOT
1338
+ // re-emit a `mirror:` block — doing so would resurrect the legacy
1339
+ // server-wide config the boot migration just commented out, re-introducing
1340
+ // the "same remote on every vault page" bug. `readGlobalConfig` still parses
1341
+ // a legacy block (below) so the one-time migration can detect + relocate it.
1342
+ // `serializeMirrorSection` remains used by the per-vault writer in
1343
+ // `mirror-config.ts`.
1336
1344
 
1337
1345
  if (config.auto_transcribe) {
1338
1346
  lines.push("auto_transcribe:");
@@ -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
+ }