@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.
- package/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- 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("
|
|
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(
|
|
81
|
+
})).toBe(true);
|
|
77
82
|
});
|
|
78
83
|
|
|
79
84
|
test("skips when scribe URL is undefined (no services.json entry, no env)", () => {
|
package/src/auto-transcribe.ts
CHANGED
|
@@ -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
|
|
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
|
-
??
|
|
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
|
|
937
|
-
* per-vault admin
|
|
938
|
-
*
|
|
939
|
-
*
|
|
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#
|
|
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
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
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;
|
|
3412
|
-
without-hub
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
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:");
|
package/src/export-watch.test.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
DEFAULT_COMMIT_TEMPLATE,
|
|
28
28
|
gitAddAll,
|
|
29
29
|
gitCommit,
|
|
30
|
+
gitPush,
|
|
30
31
|
gitUnstageAll,
|
|
31
32
|
isGitRepo,
|
|
32
33
|
listStagedFiles,
|
|
@@ -315,6 +316,79 @@ describe("git-shell helpers", () => {
|
|
|
315
316
|
});
|
|
316
317
|
});
|
|
317
318
|
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// 2b. gitPush — first-push upstream tracking (Cut 4 of vault#392)
|
|
321
|
+
//
|
|
322
|
+
// A freshly-bootstrapped mirror has commits but no upstream branch.
|
|
323
|
+
// Bare `git push` fails with "fatal: The current branch X has no upstream
|
|
324
|
+
// branch." gitPush now detects the missing-upstream case and falls back
|
|
325
|
+
// to `git push -u origin <branch>`. Subsequent pushes go bare.
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
describe("gitPush — upstream tracking", () => {
|
|
329
|
+
let workdir: string;
|
|
330
|
+
let remote: string;
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
workdir = makeTmp("vault-push-work-");
|
|
333
|
+
remote = makeTmp("vault-push-remote-");
|
|
334
|
+
// Bare repo as the "remote" — `git push` to it lands like any real remote.
|
|
335
|
+
Bun.spawnSync(["git", "init", "--bare", "-q", "-b", "main"], { cwd: remote });
|
|
336
|
+
initGitRepo(workdir);
|
|
337
|
+
Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: workdir });
|
|
338
|
+
fs.writeFileSync(path.join(workdir, "seed.md"), "# seed\n");
|
|
339
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: workdir });
|
|
340
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: workdir });
|
|
341
|
+
});
|
|
342
|
+
afterEach(() => {
|
|
343
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
344
|
+
fs.rmSync(remote, { recursive: true, force: true });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("first push to a fresh remote establishes upstream tracking", async () => {
|
|
348
|
+
// Pre-Cut-4 this failed with "no upstream branch."
|
|
349
|
+
const result = await gitPush(workdir);
|
|
350
|
+
expect(result.ok).toBe(true);
|
|
351
|
+
// Verify upstream is now set so subsequent pushes can be bare.
|
|
352
|
+
const upstream = Bun.spawnSync(
|
|
353
|
+
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
354
|
+
{ cwd: workdir, stdout: "pipe" },
|
|
355
|
+
);
|
|
356
|
+
expect(upstream.exitCode).toBe(0);
|
|
357
|
+
expect(
|
|
358
|
+
new TextDecoder().decode(upstream.stdout).trim(),
|
|
359
|
+
).toBe("origin/main");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("subsequent push (upstream already set) succeeds bare", async () => {
|
|
363
|
+
// First push wires the upstream.
|
|
364
|
+
await gitPush(workdir);
|
|
365
|
+
// Make another commit, push again — should succeed.
|
|
366
|
+
fs.writeFileSync(path.join(workdir, "n.md"), "# n\n");
|
|
367
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: workdir });
|
|
368
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "second"], { cwd: workdir });
|
|
369
|
+
const result = await gitPush(workdir);
|
|
370
|
+
expect(result.ok).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("push with no remote configured surfaces the error (back-compat)", async () => {
|
|
374
|
+
// Same shape as the existing "no remote" test in runGitCommitCycle —
|
|
375
|
+
// the branch probe returns a branch but no upstream, gitPush falls
|
|
376
|
+
// back to `git push -u origin main`, which fails because there's no
|
|
377
|
+
// `origin` remote configured.
|
|
378
|
+
const localOnly = makeTmp("vault-push-noremote-");
|
|
379
|
+
initGitRepo(localOnly);
|
|
380
|
+
fs.writeFileSync(path.join(localOnly, "x.md"), "# x\n");
|
|
381
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: localOnly });
|
|
382
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "x"], { cwd: localOnly });
|
|
383
|
+
const result = await gitPush(localOnly);
|
|
384
|
+
expect(result.ok).toBe(false);
|
|
385
|
+
// Doesn't matter what the exact error is — just that it's non-fatal
|
|
386
|
+
// (gitPush returns rather than throws).
|
|
387
|
+
expect(typeof result.stderr).toBe("string");
|
|
388
|
+
fs.rmSync(localOnly, { recursive: true, force: true });
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
318
392
|
// ---------------------------------------------------------------------------
|
|
319
393
|
// 3. runGitCommitCycle — stage → decide → commit → push
|
|
320
394
|
// ---------------------------------------------------------------------------
|
package/src/export-watch.ts
CHANGED
|
@@ -121,11 +121,72 @@ export async function gitCommit(
|
|
|
121
121
|
return { ok: exitCode === 0, stderr: stderr.trim() };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Run `git push` in `repoDir`. Returns true on success.
|
|
126
|
+
*
|
|
127
|
+
* Handles the first-push case: a freshly-bootstrapped mirror has
|
|
128
|
+
* commits but no upstream tracking, and a bare `git push` fails with
|
|
129
|
+
* "fatal: The current branch X has no upstream branch." That's
|
|
130
|
+
* unrecoverable from the operator's POV — they'd have to drop to a
|
|
131
|
+
* shell and run `git push -u origin main`. The wiring here detects the
|
|
132
|
+
* missing-upstream case ahead of time and falls back to `git push -u
|
|
133
|
+
* origin <branch>` so the first push works and every subsequent push
|
|
134
|
+
* picks up the now-configured tracking.
|
|
135
|
+
*
|
|
136
|
+
* Vault#382 carried bare `git push`; this is the Cut 4 fix in the
|
|
137
|
+
* credentials-save round-trip work.
|
|
138
|
+
*/
|
|
125
139
|
export async function gitPush(
|
|
126
140
|
repoDir: string,
|
|
127
141
|
): Promise<{ ok: boolean; stderr: string }> {
|
|
128
|
-
|
|
142
|
+
// Resolve the current branch name. `git rev-parse --abbrev-ref HEAD`
|
|
143
|
+
// returns the branch on a checked-out branch (e.g. "main"); on a
|
|
144
|
+
// detached HEAD it returns "HEAD" — in that case we bail to bare push
|
|
145
|
+
// since `-u origin HEAD` doesn't mean anything useful.
|
|
146
|
+
let branch: string | null = null;
|
|
147
|
+
try {
|
|
148
|
+
const branchProc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
149
|
+
cwd: repoDir,
|
|
150
|
+
stdout: "pipe",
|
|
151
|
+
stderr: "pipe",
|
|
152
|
+
});
|
|
153
|
+
const branchCode = await branchProc.exited;
|
|
154
|
+
if (branchCode === 0) {
|
|
155
|
+
const out = new TextDecoder()
|
|
156
|
+
.decode(await new Response(branchProc.stdout).arrayBuffer())
|
|
157
|
+
.trim();
|
|
158
|
+
if (out.length > 0 && out !== "HEAD") branch = out;
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Fall through to bare push.
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Probe for an existing upstream. `git rev-parse --abbrev-ref
|
|
165
|
+
// --symbolic-full-name @{u}` exits non-zero when no upstream is
|
|
166
|
+
// configured for the current branch. Exit 0 + a value → upstream
|
|
167
|
+
// exists; bare `git push` is fine.
|
|
168
|
+
let hasUpstream = false;
|
|
169
|
+
if (branch !== null) {
|
|
170
|
+
const upstreamProc = Bun.spawn(
|
|
171
|
+
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
172
|
+
{
|
|
173
|
+
cwd: repoDir,
|
|
174
|
+
stdout: "pipe",
|
|
175
|
+
stderr: "pipe",
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
const upstreamCode = await upstreamProc.exited;
|
|
179
|
+
hasUpstream = upstreamCode === 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// First-push path: `git push -u origin <branch>` to establish
|
|
183
|
+
// tracking. Subsequent calls take the bare path because hasUpstream
|
|
184
|
+
// will be true after this lands.
|
|
185
|
+
const cmd =
|
|
186
|
+
branch !== null && !hasUpstream
|
|
187
|
+
? ["git", "push", "-u", "origin", branch]
|
|
188
|
+
: ["git", "push"];
|
|
189
|
+
const proc = Bun.spawn(cmd, {
|
|
129
190
|
cwd: repoDir,
|
|
130
191
|
stdout: "pipe",
|
|
131
192
|
stderr: "pipe",
|
|
@@ -190,7 +251,14 @@ export function shouldCommit(stagedFiles: string[], notesChanged: number): {
|
|
|
190
251
|
|
|
191
252
|
/**
|
|
192
253
|
* Stage → decide → commit → optionally push. Logs status to stdout/stderr.
|
|
193
|
-
* Returns whether a commit landed
|
|
254
|
+
* Returns whether a commit landed and (when push was attempted) the
|
|
255
|
+
* push outcome — so callers can surface push status in their UIs
|
|
256
|
+
* without having to grep logs.
|
|
257
|
+
*
|
|
258
|
+
* Cut 5: the return shape now includes a `push` field with the outcome
|
|
259
|
+
* when push was attempted. Tokens are redacted from `push.error` via
|
|
260
|
+
* the userinfo + `gho_/ghp_/glpat-` regex so log + status surfaces are
|
|
261
|
+
* safe to display.
|
|
194
262
|
*/
|
|
195
263
|
export async function runGitCommitCycle(opts: {
|
|
196
264
|
repoDir: string;
|
|
@@ -201,7 +269,11 @@ export async function runGitCommitCycle(opts: {
|
|
|
201
269
|
push: boolean;
|
|
202
270
|
/** Override for tests — defaults to `new Date().toISOString()`. */
|
|
203
271
|
now?: () => string;
|
|
204
|
-
}): Promise<{
|
|
272
|
+
}): Promise<{
|
|
273
|
+
committed: boolean;
|
|
274
|
+
message?: string;
|
|
275
|
+
push?: { attempted: true; ok: boolean; error?: string };
|
|
276
|
+
}> {
|
|
205
277
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
206
278
|
|
|
207
279
|
const add = await gitAddAll(opts.repoDir);
|
|
@@ -245,11 +317,40 @@ export async function runGitCommitCycle(opts: {
|
|
|
245
317
|
if (!pushResult.ok) {
|
|
246
318
|
// Non-fatal — a network blip shouldn't kill a watch loop. Warn and
|
|
247
319
|
// move on; the next successful commit's push will catch up history.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
320
|
+
const redacted = redactToken(pushResult.stderr);
|
|
321
|
+
console.warn(`[git-commit] git push failed (non-fatal): ${redacted}`);
|
|
322
|
+
return {
|
|
323
|
+
committed: true,
|
|
324
|
+
message,
|
|
325
|
+
push: { attempted: true, ok: false, error: redacted },
|
|
326
|
+
};
|
|
251
327
|
}
|
|
328
|
+
console.log(`[git-commit] pushed`);
|
|
329
|
+
return { committed: true, message, push: { attempted: true, ok: true } };
|
|
252
330
|
}
|
|
253
331
|
|
|
254
332
|
return { committed: true, message };
|
|
255
333
|
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Redact tokens from a string that came back from `git push` /
|
|
337
|
+
* `git ls-remote` stderr. Same pattern as `redactRemoteUrl` for full
|
|
338
|
+
* URLs; also handles bare `gho_*`/`ghp_*`/`glpat-*` tokens that
|
|
339
|
+
* sometimes show up in git error messages.
|
|
340
|
+
*
|
|
341
|
+
* Best-effort — git's error format isn't a stable contract, so we
|
|
342
|
+
* scrub the obvious patterns and accept that a really unusual
|
|
343
|
+
* formatting could slip through. The credentials file is the
|
|
344
|
+
* authoritative store; logs are diagnostic.
|
|
345
|
+
*/
|
|
346
|
+
export function redactToken(text: string): string {
|
|
347
|
+
return text
|
|
348
|
+
// userinfo (https://user:token@host/…)
|
|
349
|
+
.replace(/https?:\/\/[^@\s]+@/g, "https://***@")
|
|
350
|
+
// bare GitHub OAuth tokens
|
|
351
|
+
.replace(/gho_[A-Za-z0-9_]{8,}/g, "gho_***")
|
|
352
|
+
// bare GitHub PATs
|
|
353
|
+
.replace(/ghp_[A-Za-z0-9_]{8,}/g, "ghp_***")
|
|
354
|
+
// bare GitLab PATs
|
|
355
|
+
.replace(/glpat-[A-Za-z0-9_-]{8,}/g, "glpat-***");
|
|
356
|
+
}
|