@openparachute/vault 0.4.8 → 0.4.9-rc.10
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/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/mcp.ts +35 -0
- package/core/src/portable-md.test.ts +252 -1
- package/core/src/portable-md.ts +370 -2
- package/core/src/schema.ts +51 -2
- package/core/src/store.ts +68 -2
- package/package.json +1 -1
- package/src/auth.ts +29 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- 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/mcp-http.ts +24 -36
- package/src/mcp-tools.ts +286 -2
- package/src/mirror-config.test.ts +184 -14
- package/src/mirror-config.ts +220 -24
- package/src/mirror-credentials.test.ts +450 -0
- package/src/mirror-credentials.ts +577 -0
- package/src/mirror-deps.ts +42 -1
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +484 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +579 -62
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1096 -5
- package/src/module-config.ts +11 -5
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +165 -1
- package/src/server.ts +21 -8
- package/src/token-store.ts +158 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +380 -1
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/core/src/schema.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 19;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
8
|
-- Notes: the universal record.
|
|
@@ -118,6 +118,20 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
|
118
118
|
--
|
|
119
119
|
-- scope_tag / scope_path_prefix are deprecated Phase-0 columns — never
|
|
120
120
|
-- enforced at runtime, kept only for schema stability.
|
|
121
|
+
-- created_via (v19) records the provenance of a token. NULL means the
|
|
122
|
+
-- legacy/unspecified path (CLI, REST mint, YAML import); 'mcp_mint' means
|
|
123
|
+
-- the token was minted by the manage-token MCP tool, which lets the
|
|
124
|
+
-- list/revoke surface of that tool restrict itself to its own session's
|
|
125
|
+
-- mints (no cross-session token management from inside MCP).
|
|
126
|
+
--
|
|
127
|
+
-- parent_jti (v19) is the display id (t_hashprefix) of the token that
|
|
128
|
+
-- minted this one, or the hub-JWT jti claim when minted from a hub
|
|
129
|
+
-- session. Session-pinned list+revoke in manage-token filters on this.
|
|
130
|
+
--
|
|
131
|
+
-- revoked_at (v19) marks soft-revocation. Revoke from manage-token sets
|
|
132
|
+
-- this rather than deleting the row, so the audit trail stays intact and
|
|
133
|
+
-- the second revoke of the same jti is idempotent (returns ok=true).
|
|
134
|
+
-- resolveToken treats a revoked_at-set row as not-found.
|
|
121
135
|
CREATE TABLE IF NOT EXISTS tokens (
|
|
122
136
|
token_hash TEXT PRIMARY KEY,
|
|
123
137
|
label TEXT NOT NULL,
|
|
@@ -129,7 +143,10 @@ CREATE TABLE IF NOT EXISTS tokens (
|
|
|
129
143
|
expires_at TEXT,
|
|
130
144
|
created_at TEXT NOT NULL,
|
|
131
145
|
last_used_at TEXT,
|
|
132
|
-
vault_name TEXT
|
|
146
|
+
vault_name TEXT,
|
|
147
|
+
created_via TEXT,
|
|
148
|
+
parent_jti TEXT,
|
|
149
|
+
revoked_at TEXT
|
|
133
150
|
);
|
|
134
151
|
|
|
135
152
|
-- OAuth: registered clients (Dynamic Client Registration)
|
|
@@ -380,6 +397,12 @@ export function initSchema(db: Database): void {
|
|
|
380
397
|
// (markdown), unchanged in meaning. See vault#328.
|
|
381
398
|
migrateToV18(db);
|
|
382
399
|
|
|
400
|
+
// Migrate v18 → v19: add MCP-mint provenance columns to `tokens`
|
|
401
|
+
// (created_via, parent_jti, revoked_at) for vault#376's manage-token tool.
|
|
402
|
+
// All three are nullable; existing rows backfill to NULL which means
|
|
403
|
+
// "non-MCP-minted, not revoked" — identical pre-v19 semantics.
|
|
404
|
+
migrateToV19(db);
|
|
405
|
+
|
|
383
406
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
384
407
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
385
408
|
rebuildIndexes(db);
|
|
@@ -952,6 +975,32 @@ function migrateToV18(db: Database): void {
|
|
|
952
975
|
}
|
|
953
976
|
}
|
|
954
977
|
|
|
978
|
+
/**
|
|
979
|
+
* Migrate v18 → v19: add `created_via`, `parent_jti`, `revoked_at` columns
|
|
980
|
+
* to `tokens` so manage-token can attribute mints to the MCP session that
|
|
981
|
+
* minted them, scope its list+revoke to those tokens, and soft-revoke
|
|
982
|
+
* (preserving the audit trail).
|
|
983
|
+
*
|
|
984
|
+
* All three columns are nullable; existing rows backfill to NULL with
|
|
985
|
+
* back-compat semantics — `created_via IS NULL` matches the CLI/REST-mint
|
|
986
|
+
* provenance, `revoked_at IS NULL` matches "still active". Idempotent —
|
|
987
|
+
* the column-existence guard means the migration is safe to re-run on a
|
|
988
|
+
* post-v19 vault. See vault#376.
|
|
989
|
+
*/
|
|
990
|
+
function migrateToV19(db: Database): void {
|
|
991
|
+
if (!hasTable(db, "tokens")) return;
|
|
992
|
+
const cols: [string, string][] = [
|
|
993
|
+
["created_via", "TEXT"],
|
|
994
|
+
["parent_jti", "TEXT"],
|
|
995
|
+
["revoked_at", "TEXT"],
|
|
996
|
+
];
|
|
997
|
+
for (const [col, type] of cols) {
|
|
998
|
+
if (!hasColumn(db, "tokens", col)) {
|
|
999
|
+
db.exec(`ALTER TABLE tokens ADD COLUMN ${col} ${type}`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
955
1004
|
function hasTable(db: Database, name: string): boolean {
|
|
956
1005
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
957
1006
|
return !!row;
|
package/core/src/store.ts
CHANGED
|
@@ -217,10 +217,23 @@ export class BunSqliteStore implements Store {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
async deleteNote(id: string): Promise<void> {
|
|
220
|
-
// Read before delete so we can invalidate config caches on the way out
|
|
220
|
+
// Read before delete so we can invalidate config caches on the way out
|
|
221
|
+
// AND so the post-delete hook dispatch carries the minimum payload
|
|
222
|
+
// ({ id, path }). The full note can't be reconstructed post-delete —
|
|
223
|
+
// by design, hooks subscribing to "deleted" receive a DeletedNoteRef,
|
|
224
|
+
// not a Note.
|
|
221
225
|
const existing = noteOps.getNote(this.db, id);
|
|
222
226
|
noteOps.deleteNote(this.db, id);
|
|
223
227
|
if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
|
|
228
|
+
// Dispatch even when `existing` was null — the caller asked for a
|
|
229
|
+
// deletion, and downstream consumers (e.g. the mirror) reconcile via
|
|
230
|
+
// id. Path is undefined in that case; the mirror sweep will catch
|
|
231
|
+
// any orphans missed by the targeted-removal fast path.
|
|
232
|
+
this.hooks.dispatch(
|
|
233
|
+
"deleted",
|
|
234
|
+
{ id, ...(existing?.path ? { path: existing.path } : {}) },
|
|
235
|
+
this,
|
|
236
|
+
);
|
|
224
237
|
}
|
|
225
238
|
|
|
226
239
|
async queryNotes(opts: QueryOpts): Promise<Note[]> {
|
|
@@ -340,6 +353,10 @@ export class BunSqliteStore implements Store {
|
|
|
340
353
|
// and may have declared `fields` powering schema validation.
|
|
341
354
|
this._tagHierarchy = null;
|
|
342
355
|
this._schemaConfig = null;
|
|
356
|
+
// Fire "deleted" only when SOMETHING happened (the underlying
|
|
357
|
+
// deleteTag returns `deleted: false` when the tag didn't exist).
|
|
358
|
+
// The git-mirror reacts to this by sweeping the schema sidecar.
|
|
359
|
+
if (result.deleted) this.hooks.dispatchTag("deleted", name, this);
|
|
343
360
|
return result;
|
|
344
361
|
}
|
|
345
362
|
|
|
@@ -352,6 +369,16 @@ export class BunSqliteStore implements Store {
|
|
|
352
369
|
// the schema-config by parent_names + fields content.
|
|
353
370
|
this._tagHierarchy = null;
|
|
354
371
|
this._schemaConfig = null;
|
|
372
|
+
// Rename = delete-then-upsert from the perspective of any consumer
|
|
373
|
+
// that keys schema artifacts on the tag name (e.g. the git-mirror's
|
|
374
|
+
// `.parachute/schemas/<tag>.yaml` sidecar file). Fire both events
|
|
375
|
+
// so the consumer drops the old artifact and writes the new one.
|
|
376
|
+
// Only dispatch when the rename actually happened — error returns
|
|
377
|
+
// ({ error: ... }) shouldn't notify subscribers about phantom moves.
|
|
378
|
+
if ("renamed" in result) {
|
|
379
|
+
this.hooks.dispatchTag("deleted", oldName, this);
|
|
380
|
+
this.hooks.dispatchTag("upserted", newName, this);
|
|
381
|
+
}
|
|
355
382
|
return result;
|
|
356
383
|
}
|
|
357
384
|
|
|
@@ -365,6 +392,15 @@ export class BunSqliteStore implements Store {
|
|
|
365
392
|
// bust the schema cache — `fields` declarations follow tag identity.
|
|
366
393
|
this._tagHierarchy = null;
|
|
367
394
|
this._schemaConfig = null;
|
|
395
|
+
// Each merged source vanishes from the tag set; the target's
|
|
396
|
+
// schema may have absorbed fields/relationships from the sources.
|
|
397
|
+
// Fire "deleted" for each source and "upserted" for the target so
|
|
398
|
+
// the mirror sweeps the source sidecars and rewrites the target.
|
|
399
|
+
for (const source of sources) {
|
|
400
|
+
if (source === target) continue;
|
|
401
|
+
this.hooks.dispatchTag("deleted", source, this);
|
|
402
|
+
}
|
|
403
|
+
this.hooks.dispatchTag("upserted", target, this);
|
|
368
404
|
return result;
|
|
369
405
|
}
|
|
370
406
|
|
|
@@ -440,12 +476,26 @@ export class BunSqliteStore implements Store {
|
|
|
440
476
|
// `fields` drives validation — bust the schema cache so the next
|
|
441
477
|
// create/update sees the new declarations.
|
|
442
478
|
this._schemaConfig = null;
|
|
479
|
+
// The tag schema sidecar in the mirror needs to track this. Fire
|
|
480
|
+
// "upserted" regardless of whether the row was created or modified
|
|
481
|
+
// — the mirror writes the sidecar fresh either way.
|
|
482
|
+
this.hooks.dispatchTag("upserted", tag, this);
|
|
443
483
|
return result;
|
|
444
484
|
}
|
|
445
485
|
|
|
446
486
|
async deleteTagSchema(tag: string) {
|
|
447
487
|
const result = tagSchemaOps.deleteTagSchema(this.db, tag);
|
|
448
|
-
if (result)
|
|
488
|
+
if (result) {
|
|
489
|
+
this._schemaConfig = null;
|
|
490
|
+
// Schema-only delete: the tag may still exist as a name in the
|
|
491
|
+
// hierarchy, but the sidecar lost its content. Mirror reacts by
|
|
492
|
+
// sweeping the sidecar file. (If the underlying row was reduced
|
|
493
|
+
// to a bare name with no schema content, hasSchemaContent() in
|
|
494
|
+
// exportVaultToDir already wouldn't have written it on the next
|
|
495
|
+
// export pass — the targeted delete is the fast path; the sweep
|
|
496
|
+
// is the safety net.)
|
|
497
|
+
this.hooks.dispatchTag("deleted", tag, this);
|
|
498
|
+
}
|
|
449
499
|
return result;
|
|
450
500
|
}
|
|
451
501
|
|
|
@@ -494,6 +544,11 @@ export class BunSqliteStore implements Store {
|
|
|
494
544
|
this._tagHierarchy = null;
|
|
495
545
|
this._schemaConfig = null;
|
|
496
546
|
}
|
|
547
|
+
// Tag-mutation event for the git-mirror and any other downstream
|
|
548
|
+
// consumer. Fire "upserted" on every successful tag-record write —
|
|
549
|
+
// schema/relationship/parent-name mutations all alter the sidecar
|
|
550
|
+
// contents the mirror persists.
|
|
551
|
+
this.hooks.dispatchTag("upserted", tag, this);
|
|
497
552
|
return result;
|
|
498
553
|
}
|
|
499
554
|
|
|
@@ -599,6 +654,17 @@ export class BunSqliteStore implements Store {
|
|
|
599
654
|
const other = this.db.prepare(
|
|
600
655
|
"SELECT 1 FROM attachments WHERE path = ? LIMIT 1",
|
|
601
656
|
).get(row.path);
|
|
657
|
+
|
|
658
|
+
// Post-delete event for downstream consumers (e.g. the git-mirror's
|
|
659
|
+
// sweep of `.parachute/attachments/<id>/...`). Payload is the
|
|
660
|
+
// DeletedAttachmentRef — the row is gone, so we pass only id /
|
|
661
|
+
// note_id / path.
|
|
662
|
+
this.hooks.dispatchAttachment(
|
|
663
|
+
"deleted",
|
|
664
|
+
{ id: attachmentId, noteId, path: row.path },
|
|
665
|
+
this,
|
|
666
|
+
);
|
|
667
|
+
|
|
602
668
|
return { deleted: true, path: row.path, orphaned: !other };
|
|
603
669
|
}
|
|
604
670
|
|
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -91,6 +91,12 @@ function tryServerWideAuth(
|
|
|
91
91
|
legacyDerived: false,
|
|
92
92
|
scoped_tags: null,
|
|
93
93
|
vault_name: null,
|
|
94
|
+
// No stable session id for the env-var operator token — every request
|
|
95
|
+
// is the operator-bearer, not a minted session. manage-token's session
|
|
96
|
+
// pin is a no-op for this caller (it'd still mint, but list/revoke
|
|
97
|
+
// would see no other operator mints; that's fine — env-var-bearer is
|
|
98
|
+
// explicitly the operator-channel, not a user surface).
|
|
99
|
+
caller_jti: null,
|
|
94
100
|
};
|
|
95
101
|
}
|
|
96
102
|
|
|
@@ -120,6 +126,15 @@ export interface AuthResult {
|
|
|
120
126
|
* legacy / server-wide / hub JWT — no per-vault binding. See vault#257.
|
|
121
127
|
*/
|
|
122
128
|
vault_name: string | null;
|
|
129
|
+
/**
|
|
130
|
+
* Session identifier (v19). For `pvt_*` tokens this is the display id
|
|
131
|
+
* (`t_<hashprefix>`) of the presented token. For hub JWTs it's the
|
|
132
|
+
* `jti` claim, when present. NULL for legacy YAML keys / server-wide
|
|
133
|
+
* env-var tokens / hub JWTs without a `jti`. Used by the manage-token
|
|
134
|
+
* MCP tool to stamp child tokens with `parent_jti` so list/revoke can
|
|
135
|
+
* scope to this session's mints. See vault#376.
|
|
136
|
+
*/
|
|
137
|
+
caller_jti: string | null;
|
|
123
138
|
}
|
|
124
139
|
|
|
125
140
|
/**
|
|
@@ -134,6 +149,7 @@ function legacyAuthResult(permission: TokenPermission): AuthResult {
|
|
|
134
149
|
legacyDerived: true,
|
|
135
150
|
scoped_tags: null,
|
|
136
151
|
vault_name: null,
|
|
152
|
+
caller_jti: null,
|
|
137
153
|
};
|
|
138
154
|
}
|
|
139
155
|
|
|
@@ -285,6 +301,7 @@ export async function authenticateVaultRequest(
|
|
|
285
301
|
legacyDerived: resolved.legacyDerived,
|
|
286
302
|
scoped_tags: resolved.scoped_tags,
|
|
287
303
|
vault_name: resolved.vault_name,
|
|
304
|
+
caller_jti: resolved.jti,
|
|
288
305
|
};
|
|
289
306
|
}
|
|
290
307
|
} catch {
|
|
@@ -396,7 +413,17 @@ async function authenticateHubJwt(
|
|
|
396
413
|
hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
|
|
397
414
|
? "full"
|
|
398
415
|
: "read";
|
|
399
|
-
return {
|
|
416
|
+
return {
|
|
417
|
+
permission,
|
|
418
|
+
scopes: claims.scopes,
|
|
419
|
+
legacyDerived: false,
|
|
420
|
+
scoped_tags: null,
|
|
421
|
+
vault_name: null,
|
|
422
|
+
// claims.jti is `undefined` when the issuer didn't stamp one. Pass it
|
|
423
|
+
// through verbatim — manage-token's session-pin will be null in that
|
|
424
|
+
// case, and list/revoke from that session sees no mints.
|
|
425
|
+
caller_jti: claims.jti ?? null,
|
|
426
|
+
};
|
|
400
427
|
} catch (err) {
|
|
401
428
|
if (err instanceof HubJwtError) {
|
|
402
429
|
// Revocation-related codes get sanitized client messages: server-side
|
|
@@ -511,6 +538,7 @@ export async function authenticateGlobalRequest(
|
|
|
511
538
|
legacyDerived: resolved.legacyDerived,
|
|
512
539
|
scoped_tags: resolved.scoped_tags,
|
|
513
540
|
vault_name: resolved.vault_name,
|
|
541
|
+
caller_jti: resolved.jti,
|
|
514
542
|
};
|
|
515
543
|
}
|
|
516
544
|
} catch {
|
|
@@ -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/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
|
+
}
|