run402 4.59.0 → 4.61.0
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/git-remote-run402.mjs +159 -834
- package/gitvault-surface.json +1 -1
- package/lib/daemon-path.mjs +99 -0
- package/lib/doctor.mjs +45 -0
- package/lib/gitvault-daemon-run.mjs +22 -0
- package/lib/gitvault-daemon.mjs +263 -0
- package/lib/remote-helper-session.mjs +867 -0
- package/lib/repos.mjs +76 -0
- package/package.json +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +49 -0
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +105 -17
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.d.ts +8 -0
- package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.js +20 -0
- package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
- package/sdk/dist/node/http-dispatcher.d.ts.map +1 -1
- package/sdk/dist/node/http-dispatcher.js +5 -1
- package/sdk/dist/node/http-dispatcher.js.map +1 -1
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HOST-AGNOSTIC remote-helper session (gitvault-persistent-helper D1).
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from `cli/git-remote-run402.mjs` so ONE implementation
|
|
5
|
+
* serves two hosts: the in-process fallback (the thin bin imports this and
|
|
6
|
+
* runs it with the real process's stdio) and the resident daemon
|
|
7
|
+
* (`cli/lib/gitvault-daemon.mjs`, which forwards a client's session into it).
|
|
8
|
+
* Per-invocation state — wallet resolution, repository resolution, env-
|
|
9
|
+
* derived config — is resolved fresh per `runHelperSession` call; the only
|
|
10
|
+
* thing that persists across sessions in a daemon is warm code and warm
|
|
11
|
+
* connections. stdout/stderr go through `process.stdout`/`process.stderr`
|
|
12
|
+
* writes, which the daemon host redirects for the session's duration; stdin
|
|
13
|
+
* is injected (`sessionStdin`) because a daemon session's input arrives over
|
|
14
|
+
* the socket.
|
|
15
|
+
*
|
|
16
|
+
* The original file-level doctrine follows, unchanged:
|
|
17
|
+
*
|
|
18
|
+
/**
|
|
19
|
+
* `git-remote-run402` — the git remote helper for gitvault, so plain
|
|
20
|
+
* `git clone|fetch|push run402::<org_id>/<project_id>` speaks to a host-blind
|
|
21
|
+
* encrypted vault with no run402-specific git ceremony.
|
|
22
|
+
*
|
|
23
|
+
* ARCHITECTURAL LAW (gitvault-client-surface, "All protocol logic lives in the
|
|
24
|
+
* SDK"): this file is a THIN ADAPTER over `r.gitvault`. It translates git's
|
|
25
|
+
* remote-helper wire protocol into SDK calls and back, and does nothing else.
|
|
26
|
+
* No crypto, no HTTP, no ref-policy decisions, no pack building — those live
|
|
27
|
+
* once in `@run402/sdk`. Even git itself is invoked only through the SDK's own
|
|
28
|
+
* `hardenedGit` (hooks, fsmonitor, replace-refs and filter autodetection are
|
|
29
|
+
* neutralized there), so this helper hand-rolls no git behaviour either.
|
|
30
|
+
*
|
|
31
|
+
* PROTOCOL SURFACE (gitremote-helpers(1)). Implemented:
|
|
32
|
+
* capabilities → advertises fetch, push, option
|
|
33
|
+
* list [for-push] → the vault's canonical ref map + the HEAD target
|
|
34
|
+
* fetch <sha1> <name> → restore the vault's object database into this repo
|
|
35
|
+
* push [+]<src>:<dst> → publish one atomic ref transaction
|
|
36
|
+
* option <name> <value> → ok / unsupported, never a silent lie
|
|
37
|
+
*
|
|
38
|
+
* LAZY ALLOCATION ON FIRST PUSH (design D2). `list` never creates anything —
|
|
39
|
+
* an unallocated vault reports as an empty ref set, exactly what a fresh
|
|
40
|
+
* repository looks like — but `push` does: `git push origin main` against a
|
|
41
|
+
* project whose vault does not exist yet runs the six-stage creation journal
|
|
42
|
+
* inline (`r.gitvault.openOrCreate`, the SDK-owned primitive), prints the
|
|
43
|
+
* one-shot recovery receipt and the keystore path to stderr, and then
|
|
44
|
+
* completes the push. One command, no prior `gitvault init`. `git ls-remote`
|
|
45
|
+
* / `fetch` stay pure reads and allocate nothing.
|
|
46
|
+
*
|
|
47
|
+
* NAMED ADDRESSING + PUSH-TO-CREATE (repo-first-onramp task 4, design D6).
|
|
48
|
+
* `run402::<org>/<name>` admits TWO forms in the same slot — id-form
|
|
49
|
+
* (`org_id`/`prj_...`, unchanged: resolved via `r.gitvault.openOrCreate`
|
|
50
|
+
* above) and slug-form (`run402::<org-slug>/<name>`, e.g.
|
|
51
|
+
* `run402::acme/my-notes`) — discriminated by
|
|
52
|
+
* `gitvaultRemoteAddressForm`. A slug-form remote resolves through
|
|
53
|
+
* `r.gitvault.resolveOrCreateAddress`, which ALSO drives push-to-create on a
|
|
54
|
+
* miss (`push` only; `list`/`fetch` pass `allow_create: false`, same "reads
|
|
55
|
+
* never allocate" discipline as the id-form path) and PINS the resolved
|
|
56
|
+
* `repo_id` in this checkout's local git config the first time it resolves
|
|
57
|
+
* (task 4.5) — every later invocation on THIS checkout goes straight to the
|
|
58
|
+
* pinned id, skipping the address resolution round-trip entirely and
|
|
59
|
+
* surviving a later rename of either half. `SLUG_RELEASED` is never
|
|
60
|
+
* auto-followed: it refuses, naming the successor slug.
|
|
61
|
+
*
|
|
62
|
+
* WHICH REPOSITORY (the fail-closed rule). `process.cwd()` is NOT the
|
|
63
|
+
* repository. git identifies the repository with `GIT_DIR`, and during
|
|
64
|
+
* `git clone` cwd is the directory clone was RUN FROM — routinely some other,
|
|
65
|
+
* unrelated repository. Discovering the repository from cwd therefore wrote a
|
|
66
|
+
* vault's DECRYPTED objects into a repository the user never named, silently,
|
|
67
|
+
* on every clone (dogfood #1). Every repository-touching command now resolves
|
|
68
|
+
* through the SDK's `resolveGitInvocationRepo`, which proves `GIT_DIR` names a
|
|
69
|
+
* real repository and refuses otherwise; a refusal writes nothing at all.
|
|
70
|
+
* `capabilities`, `option` and `list` need no repository and are unaffected,
|
|
71
|
+
* so a repository-free `git ls-remote run402::<org>/<project>` still works.
|
|
72
|
+
*
|
|
73
|
+
* NOT advertised, deliberately: `list` is a COMMAND in this protocol, not a
|
|
74
|
+
* capability keyword — git's capability vocabulary is fetch/push/import/export/
|
|
75
|
+
* connect/stateless-connect/option/refspec/check-connectivity/object-format/
|
|
76
|
+
* signed-tags/bidi-import/get. Advertising `list` would be a line git silently
|
|
77
|
+
* discards; the command itself is implemented above. `connect`,
|
|
78
|
+
* `stateless-connect`, `import`, and `export` are NOT implemented: the vault is
|
|
79
|
+
* not a git-protocol endpoint you can tunnel to, and pretending otherwise would
|
|
80
|
+
* hand git a transport that cannot answer.
|
|
81
|
+
*
|
|
82
|
+
* KNOWN LIMITS, stated rather than papered over:
|
|
83
|
+
* - `fetch` restores the vault's object database WHOLESALE. The SDK exposes
|
|
84
|
+
* no per-ref object selection, so one batch = one full restore. That is a
|
|
85
|
+
* superset of what git asked for (git writes the refs itself from `list`),
|
|
86
|
+
* never a subset — but it is not incremental.
|
|
87
|
+
* - `push` REPAIRS a DANGLING vault HEAD and otherwise never moves it
|
|
88
|
+
* (kychee-com/run402#568). A fresh vault defaults its HEAD symref to
|
|
89
|
+
* `refs/heads/main`; before this fix, a first push of any OTHER branch
|
|
90
|
+
* left that symref naming a ref that would never exist, and the first
|
|
91
|
+
* `git clone` warned "remote HEAD refers to nonexistent ref" and checked
|
|
92
|
+
* out an EMPTY tree — publishing landed, but nothing was reachable from
|
|
93
|
+
* it. Now: when the vault's current HEAD target is unset, or is a
|
|
94
|
+
* symref naming a ref this push's own batch does not leave present, the
|
|
95
|
+
* helper points it at one of the branches THIS push is publishing — the
|
|
96
|
+
* local repository's own HEAD branch when it is among them, else the
|
|
97
|
+
* first branch in the batch — and prints a one-line stderr note saying
|
|
98
|
+
* which and why (see `chooseGitvaultHeadTargetForPush`). A HEALTHY HEAD
|
|
99
|
+
* (one that already names a ref this push leaves present) is NEVER
|
|
100
|
+
* touched — push moving history never means push moving HEAD.
|
|
101
|
+
* - `option dry-run true` (kychee-com/run402#565) runs the REAL local
|
|
102
|
+
* pipeline — pack building, encryption sizing, via `vault.planPush` — and
|
|
103
|
+
* reports the per-ref `ok` lines a real push would, plus a stderr summary
|
|
104
|
+
* (objects, encrypted bytes, refs, the generation it would admit as,
|
|
105
|
+
* whether allocation would be needed). It never uploads or admits, and a
|
|
106
|
+
* push-to-create dry run never allocates. Still honestly refuses
|
|
107
|
+
* anything git's own dry-run negotiation would also refuse (e.g. a
|
|
108
|
+
* non-fast-forward update) — reporting a fake `ok` would be worse than
|
|
109
|
+
* refusing, which is why this was `unsupported` until it could be real.
|
|
110
|
+
* - `fetch` and `push` REQUIRE the `GIT_DIR` git sets when it drives a
|
|
111
|
+
* helper against a repository, so running this binary by hand from a shell
|
|
112
|
+
* is refused rather than silently pointed at the current directory. Only
|
|
113
|
+
* `capabilities`, `option` and `list` work without one, which is exactly
|
|
114
|
+
* the set `git ls-remote <url>` outside a checkout needs.
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
import { createInterface } from "node:readline";
|
|
118
|
+
|
|
119
|
+
// The heavy graphs load as ONE top-level await batch — in the in-process
|
|
120
|
+
// host this races the thin bin's already-fired prewarm; in the daemon host
|
|
121
|
+
// it happens once at daemon boot (gitvault-startup-amortization D1).
|
|
122
|
+
const sdkModP = import("./sdk.mjs");
|
|
123
|
+
const walletModP = import("./wallet-context.mjs");
|
|
124
|
+
const isoModP = import("#sdk");
|
|
125
|
+
const nodeModP = import("#sdk/node");
|
|
126
|
+
const { getSdk } = await sdkModP;
|
|
127
|
+
const { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } = await walletModP;
|
|
128
|
+
const { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } = await isoModP;
|
|
129
|
+
const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo } = await nodeModP;
|
|
130
|
+
|
|
131
|
+
/** The session's input stream — injected per session (daemon: the socket's forwarded stdin). */
|
|
132
|
+
let sessionStdin = process.stdin;
|
|
133
|
+
|
|
134
|
+
const out = (line) => process.stdout.write(`${line}\n`);
|
|
135
|
+
/** Every helper response block is terminated by a blank line. */
|
|
136
|
+
const endBlock = () => process.stdout.write("\n");
|
|
137
|
+
const note = (line) => process.stderr.write(`git-remote-run402: ${line}\n`);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Wallet selection (kychee-com/run402#558). Before this, this file called
|
|
141
|
+
* `getSdk()` directly and ran NO wallet selection at all — a `.run402.json`
|
|
142
|
+
* binding, and even the global `wallets use` default, silently never
|
|
143
|
+
* reached it; only the `RUN402_WALLET` env layer worked, so a bound
|
|
144
|
+
* checkout's very next `git push run402 main` after a correctly-bound
|
|
145
|
+
* `run402 repos create` used the WRONG wallet's (usually empty) allowance.
|
|
146
|
+
*
|
|
147
|
+
* Shares `resolveWalletCore`/`enforceWalletExistsCore` with the CLI
|
|
148
|
+
* (`cli/lib/wallet-context.mjs`) — ONE implementation, minus the CLI's
|
|
149
|
+
* `--wallet` flag layer (this binary parses no argv flags at all). Resolved
|
|
150
|
+
* and applied (`process.env.RUN402_WALLET`) once per invocation, right
|
|
151
|
+
* before the first credential-touching call — never for `capabilities` /
|
|
152
|
+
* `option`, which touch neither credentials nor the network.
|
|
153
|
+
*
|
|
154
|
+
* WHICH DIRECTORY the binding walk starts from is NOT uniform, for the same
|
|
155
|
+
* fail-closed reason this file's own header explains for repository
|
|
156
|
+
* resolution: `list` needs no repository (a repository-free `git ls-remote`
|
|
157
|
+
* outside any checkout must keep working), so it walks from `process.cwd()`.
|
|
158
|
+
* `fetch`/`push` DO have a resolved repository by the time wallet selection
|
|
159
|
+
* runs (`requireRepo()` already succeeded) — walking from ITS directory
|
|
160
|
+
* rather than cwd is what makes `git clone` (cwd = wherever clone was RUN
|
|
161
|
+
* FROM, not the target repo) pick up a binding committed in the target
|
|
162
|
+
* repository, not whatever checkout happened to be current.
|
|
163
|
+
*/
|
|
164
|
+
let resolvedWallet = null;
|
|
165
|
+
|
|
166
|
+
function applyWalletForDir(dir) {
|
|
167
|
+
const resolved = resolveWalletCore({ env: process.env, cwd: dir });
|
|
168
|
+
enforceWalletExistsCore(resolved);
|
|
169
|
+
process.env.RUN402_WALLET = resolved.name;
|
|
170
|
+
resolvedWallet = resolved;
|
|
171
|
+
return resolved;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The resolved wallet's selection source, in the same words the CLI's own `--wallet` provenance line uses. `null` for the bare, unselected default. */
|
|
175
|
+
function walletSourceLabel(resolved) {
|
|
176
|
+
if (!resolved) return null;
|
|
177
|
+
if (resolved.source === "env") return "RUN402_WALLET";
|
|
178
|
+
if (resolved.source === "binding") return resolved.sourceDetail; // the .run402.json path
|
|
179
|
+
if (resolved.source === "config") return "wallets use";
|
|
180
|
+
return null; // "default" — nothing selected anything
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The allowance-missing/malformed family (`core/src/allowance.ts`'s own
|
|
185
|
+
* throws) all end with "Back up the file and run 'run402 init' to recreate
|
|
186
|
+
* it." — a remedy that assumes the resolved wallet is the one you meant.
|
|
187
|
+
* That is only true when NOTHING selected a wallet (the bare default); when
|
|
188
|
+
* an env var or a binding DID name one, the remedy is actively harmful —
|
|
189
|
+
* `run402 init` recreates the DEFAULT wallet's allowance, a DIFFERENT
|
|
190
|
+
* wallet than the one that was actually resolved and whose allowance is
|
|
191
|
+
* actually missing/broken (kychee-com/run402#558's second defect). Replace
|
|
192
|
+
* it with the resolved wallet's name and how selection works, so the fix is
|
|
193
|
+
* "correct the selection" rather than "recreate the wrong wallet".
|
|
194
|
+
*/
|
|
195
|
+
function enrichAllowanceError(message) {
|
|
196
|
+
if (!resolvedWallet || !/allowance\.json/.test(message)) return message;
|
|
197
|
+
const source = walletSourceLabel(resolvedWallet);
|
|
198
|
+
const stripped = message.replace(/\s*Back up the file and run 'run402 init' to recreate it\.?/, "").trim();
|
|
199
|
+
if (!source) {
|
|
200
|
+
// Genuinely the bare default wallet with no override anywhere — the
|
|
201
|
+
// original remedy already names the right target.
|
|
202
|
+
return `${stripped} Back up the file and run 'run402 init' to recreate it.`;
|
|
203
|
+
}
|
|
204
|
+
return (
|
|
205
|
+
`${stripped} Resolved wallet '${resolvedWallet.name}' via ${source} ` +
|
|
206
|
+
"(order: RUN402_WALLET env > .run402.json binding > 'wallets use' default > default). " +
|
|
207
|
+
`Wrong wallet? Fix selection instead. Right wallet, just no allowance yet? 'run402 wallets new ${resolvedWallet.name}'.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function describeError(err) {
|
|
212
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
213
|
+
const message = enrichAllowanceError(err?.message ?? err?.body?.message ?? String(err));
|
|
214
|
+
// SLUG_RELEASED is never auto-followed — but the successor slug (design D6)
|
|
215
|
+
// is exactly the fact a human/agent reading stderr needs to act on it.
|
|
216
|
+
const released = gitvaultSlugReleasedInfo(err);
|
|
217
|
+
const suffix = released?.successor_slug ? ` (renamed to "${released.successor_slug}" — update the remote and re-run)` : "";
|
|
218
|
+
return oneLine(code ? `${code}: ${message}${suffix}` : message);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Protocol lines are single-line: collapse anything that could break framing. */
|
|
222
|
+
function oneLine(value) {
|
|
223
|
+
return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the vault address from git's argv.
|
|
228
|
+
*
|
|
229
|
+
* Git invokes `git-remote-<transport> <remote> <url>`, and for a
|
|
230
|
+
* `<transport>::<address>` URL it passes the BARE `<address>` as the second
|
|
231
|
+
* argument — the `run402::` prefix is already stripped. The SDK's parser is the
|
|
232
|
+
* only thing that understands the address grammar, so both spellings are handed
|
|
233
|
+
* to it rather than re-implemented here.
|
|
234
|
+
*/
|
|
235
|
+
function resolveRemoteAddress(argv) {
|
|
236
|
+
for (const raw of [argv[1], argv[0]]) {
|
|
237
|
+
if (typeof raw !== "string" || raw.length === 0) continue;
|
|
238
|
+
const direct = parseGitvaultRemoteUrl(raw);
|
|
239
|
+
if (direct) return direct;
|
|
240
|
+
// Re-add the prefix ONLY for a bare, colon-free address. Without this
|
|
241
|
+
// guard `https://example.com/x` parses as org `https:` / project
|
|
242
|
+
// `/example.com/x`, and `git@github.com:x/y.git` as org `git@github.com:x`
|
|
243
|
+
// — a confidently wrong answer is worse than no answer here. The colon is
|
|
244
|
+
// git's own URL punctuation, never part of a bare `<org_id>/<project_id>`.
|
|
245
|
+
if (raw.includes(":")) continue;
|
|
246
|
+
const prefixed = parseGitvaultRemoteUrl(`run402::${raw}`);
|
|
247
|
+
if (prefixed) return prefixed;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `[+]<src>:<dst>` — an empty `<src>` is a deletion. */
|
|
253
|
+
function parsePushSpec(spec) {
|
|
254
|
+
const forced = spec.startsWith("+");
|
|
255
|
+
const body = forced ? spec.slice(1) : spec;
|
|
256
|
+
const colon = body.indexOf(":");
|
|
257
|
+
if (colon === -1) return { src: body, dst: body, force: forced };
|
|
258
|
+
return { src: body.slice(0, colon), dst: body.slice(colon + 1), force: forced };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** `R402_PROTECTED_REF_NAMESPACE`'s one-line reason, shared by the protocol `error` line and the stderr note. */
|
|
262
|
+
export const R402_PROTECTED_REF_NAMESPACE_REASON = `R402_PROTECTED_REF_NAMESPACE: ${GITVAULT_R402_REF_NAMESPACE}* is client-local bookkeeping maintained by fetch, not push — push branches instead.`;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* D4: `refs/r402/*` is client-local (design D1/D2's local ref bookkeeping) —
|
|
266
|
+
* never advertised for push, never part of a `GitvaultRefTransaction`. Split
|
|
267
|
+
* one push batch into the specs this helper will actually publish and the
|
|
268
|
+
* ones it refuses, PURE and pre-repository (a refname prefix check needs no
|
|
269
|
+
* repo, no network, no wallet), so a batch made ENTIRELY of protected refs
|
|
270
|
+
* costs nothing beyond parsing, and a MIXED batch still lets its unrelated
|
|
271
|
+
* branch updates proceed (client-surface spec's own scenario) — this is why
|
|
272
|
+
* the split happens here, before `vault.push`'s one all-or-nothing
|
|
273
|
+
* transaction is ever built, rather than inside the SDK's existing (whole-
|
|
274
|
+
* transaction-refusing) `refs/run402/*` guard.
|
|
275
|
+
*/
|
|
276
|
+
export function partitionProtectedRefPushes(specs) {
|
|
277
|
+
const refused = [];
|
|
278
|
+
const allowed = [];
|
|
279
|
+
for (const spec of specs) {
|
|
280
|
+
if (spec.dst.startsWith(GITVAULT_R402_REF_NAMESPACE)) refused.push(spec);
|
|
281
|
+
else allowed.push(spec);
|
|
282
|
+
}
|
|
283
|
+
return { refused, allowed };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Decide whether THIS push must repair a DANGLING vault HEAD, and to what
|
|
288
|
+
* (kychee-com/run402#568 — the first-clone empty-tree hazard). The rule:
|
|
289
|
+
*
|
|
290
|
+
* WHEN the vault's current materialized HEAD target is absent, OR is a
|
|
291
|
+
* symref naming a ref this push's own batch does not leave present, set
|
|
292
|
+
* `head_target` to one of the branches THIS push is publishing — this
|
|
293
|
+
* repository's own HEAD branch when it is among them, else the first
|
|
294
|
+
* branch in the batch (git's own order) — and say which, and why, in a
|
|
295
|
+
* one-line note. No silent magic.
|
|
296
|
+
*
|
|
297
|
+
* WHEN HEAD is already set and healthy (a symref naming a ref this push
|
|
298
|
+
* leaves present, or a detached target), it is NEVER touched — push
|
|
299
|
+
* moving history never means push moving HEAD. That stays the documented
|
|
300
|
+
* rule (`vault.push`'s own `head_target ?? base.head_target` carry-forward
|
|
301
|
+
* already guarantees this at the SDK layer; this function just decides
|
|
302
|
+
* WHEN to override that default).
|
|
303
|
+
*
|
|
304
|
+
* Pure — no I/O, no git, no network — so it is unit-testable directly.
|
|
305
|
+
* `updates` is this push's own ref-transaction updates (`{ ref, new_oid }`,
|
|
306
|
+
* `new_oid: null` for a deletion); `baseRefs`/`baseHeadTarget` are what the
|
|
307
|
+
* vault materialized BEFORE this push; `localHeadRef` is this repository's
|
|
308
|
+
* own HEAD branch (`refs/heads/<name>`), or `null` when detached/unknown.
|
|
309
|
+
*
|
|
310
|
+
* Returns `{ head_target: undefined }` (never publish an override — the SDK
|
|
311
|
+
* carries the base forward) when HEAD needs no repair, or when this batch
|
|
312
|
+
* has no branch update to repair it WITH (a tags-only or deletion-only
|
|
313
|
+
* batch cannot fix a dangling HEAD by itself).
|
|
314
|
+
*/
|
|
315
|
+
export function chooseGitvaultHeadTargetForPush({ baseHeadTarget, baseRefs, updates, localHeadRef }) {
|
|
316
|
+
const postPushRefs = { ...(baseRefs ?? {}) };
|
|
317
|
+
for (const u of updates) {
|
|
318
|
+
if (u.new_oid === null) delete postPushRefs[u.ref];
|
|
319
|
+
else postPushRefs[u.ref] = u.new_oid;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const dangling =
|
|
323
|
+
!baseHeadTarget ||
|
|
324
|
+
(baseHeadTarget.kind === "symref" && !Object.prototype.hasOwnProperty.call(postPushRefs, baseHeadTarget.ref));
|
|
325
|
+
if (!dangling) return { head_target: undefined, note: null };
|
|
326
|
+
|
|
327
|
+
const pushedBranches = updates.filter((u) => u.new_oid !== null && u.ref.startsWith("refs/heads/")).map((u) => u.ref);
|
|
328
|
+
if (pushedBranches.length === 0) return { head_target: undefined, note: null };
|
|
329
|
+
|
|
330
|
+
const localIsPushed = Boolean(localHeadRef) && pushedBranches.includes(localHeadRef);
|
|
331
|
+
const chosen = localIsPushed ? localHeadRef : pushedBranches[0];
|
|
332
|
+
const why = localIsPushed
|
|
333
|
+
? "this repository's own HEAD branch"
|
|
334
|
+
: pushedBranches.length > 1
|
|
335
|
+
? `the first of ${pushedBranches.length} branches pushed in this batch`
|
|
336
|
+
: "the branch this push publishes";
|
|
337
|
+
const priorState = baseHeadTarget ? `dangling (named '${baseHeadTarget.ref}', which this push does not publish)` : "unset";
|
|
338
|
+
const note = `vault HEAD was ${priorState} — setting it to '${chosen}' (${why}). A healthy HEAD is never moved by push.`;
|
|
339
|
+
return { head_target: { kind: "symref", ref: chosen }, note };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function main(argv) {
|
|
343
|
+
// gitvault-connection-amortization (bench P5) note: the prewarm now fires
|
|
344
|
+
// at the module TOP, before the SDK graph loads (gitvault-startup-
|
|
345
|
+
// amortization D1) — connection dial and signer warmup both race module
|
|
346
|
+
// evaluation instead of starting here.
|
|
347
|
+
const address = resolveRemoteAddress(argv);
|
|
348
|
+
if (!address) {
|
|
349
|
+
note(`could not read a run402 remote address from ${JSON.stringify(argv.join(" "))} — expected run402::<org_id>/<project_id>`);
|
|
350
|
+
return 1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// `org_id` rides in the parsed address (`run402::<org_id>/<project_id>`),
|
|
354
|
+
// so it costs nothing extra to carry — it is exactly what D2's lazy
|
|
355
|
+
// creation needs to allocate an unresolved vault from `runPush` below, with
|
|
356
|
+
// no separate lookup. Only meaningful for an ID-FORM address; a slug-form
|
|
357
|
+
// one resolves through `resolveOrCreateAddress` instead (below), which
|
|
358
|
+
// needs no separate org_id at all — the gateway resolves the slug itself.
|
|
359
|
+
const addressForm = gitvaultRemoteAddressForm(address);
|
|
360
|
+
const target = { project_id: address.project_id, org_id: address.org_id };
|
|
361
|
+
let verbosity = 1;
|
|
362
|
+
// kychee-com/run402#565: `option dry-run true` used to be honestly
|
|
363
|
+
// `unsupported` (this helper could not rehearse a publication, and
|
|
364
|
+
// reporting a fake success would be worse than refusing). It now IS
|
|
365
|
+
// real — see `handleOption`'s `dry-run` case and `runPush` below.
|
|
366
|
+
let dryRun = false;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The repository git invoked us for, resolved once and PROVEN.
|
|
370
|
+
*
|
|
371
|
+
* Deliberately lazy: `list` needs no repository, so `git ls-remote` outside
|
|
372
|
+
* any checkout keeps working. Deliberately not cached across a failure
|
|
373
|
+
* either — a refusal is terminal for the command that asked, and there is
|
|
374
|
+
* nothing to retry.
|
|
375
|
+
*/
|
|
376
|
+
let resolvedRepo = null;
|
|
377
|
+
async function requireRepo() {
|
|
378
|
+
if (!resolvedRepo) resolvedRepo = await resolveGitInvocationRepo(process.env, process.cwd());
|
|
379
|
+
return resolvedRepo.repo_dir;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* One materialize per push session (gitvault-client-round-trips design
|
|
384
|
+
* D1). Git guarantees `list` precedes `push` in the same helper process,
|
|
385
|
+
* so a `list` that resolves an EXISTING vault against a real repository
|
|
386
|
+
* stashes its vault instance + materialized base here; `runPush` reuses
|
|
387
|
+
* BOTH — skipping its own `openOrCreateVault` + `materialize()` entirely
|
|
388
|
+
* — instead of materializing the same state a second and third time.
|
|
389
|
+
* `null` whenever there is nothing safe to share: `list` never ran, ran
|
|
390
|
+
* repo-free (a bare `git ls-remote`), or found an UNALLOCATED vault
|
|
391
|
+
* (first-push-allocates keeps its own unchanged flow — design D7's own
|
|
392
|
+
* "base-sharing subtlety" risk note). Reuse also requires the SAME
|
|
393
|
+
* resolved repository AND the same resolved wallet as `push` is about to
|
|
394
|
+
* use — in ordinary usage (`push` run from inside the repo it targets)
|
|
395
|
+
* these always match `list`'s own resolution; the check just makes a
|
|
396
|
+
* mismatch (an unusual `git -C otherdir push`) fail safe into `push`'s
|
|
397
|
+
* original, unshared flow rather than reuse a snapshot read under a
|
|
398
|
+
* different identity.
|
|
399
|
+
*/
|
|
400
|
+
let sharedListSession = null;
|
|
401
|
+
|
|
402
|
+
/** This repository's own HEAD branch (`refs/heads/<name>`), or `null` when detached, unborn, or unreadable — never a failure by itself. */
|
|
403
|
+
async function localHeadBranchRef(repoDir) {
|
|
404
|
+
try {
|
|
405
|
+
const out = (await hardenedGit(repoDir, ["symbolic-ref", "--quiet", "HEAD"])).text().trim();
|
|
406
|
+
return out.length > 0 ? out : null;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* What to tell a human when we refuse. `git clone` is the case that used to
|
|
414
|
+
* fail; naming the working alternative beats a bare error.
|
|
415
|
+
*/
|
|
416
|
+
function repoRefusalNote(err) {
|
|
417
|
+
note(describeError(err));
|
|
418
|
+
note("refusing to touch a repository git did not name — nothing was read or written.");
|
|
419
|
+
note(`if you meant to restore this vault: git init --bare <dir> && git -C <dir> remote add run402 run402::${address.org_id}/${address.project_id} && git -C <dir> fetch run402 '+refs/heads/*:refs/heads/*'`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** A 404/absent-vault refusal — the "nothing here yet" shape, never a genuine failure to mask. */
|
|
423
|
+
function isVaultNotFound(err) {
|
|
424
|
+
return err?.status === 404 || err?.code === "RESOURCE_NOT_FOUND" || err?.code === "ROUTE_NOT_FOUND";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Open the vault lazily — `capabilities` and `option` must never touch the
|
|
429
|
+
* network. Both address forms resolve (and, on the first successful
|
|
430
|
+
* resolution, PIN `repo_id` in local git state — task 4.5 for slug-form,
|
|
431
|
+
* gitvault-client-round-trips design D4 widening the same mechanism to
|
|
432
|
+
* id-form) through `gitvault.resolveOrCreateAddress` with
|
|
433
|
+
* `allow_create: false` — a read never allocates, and `allow_create` is
|
|
434
|
+
* meaningless for id-form's own dispatch anyway (it never creates,
|
|
435
|
+
* pinned or not). A repo-free call (`repoDir` undefined — `list` outside
|
|
436
|
+
* any checkout) resolves exactly as it always has, just with nothing to
|
|
437
|
+
* pin.
|
|
438
|
+
*/
|
|
439
|
+
const openVault = async (repoDir) => {
|
|
440
|
+
const result = await getSdk().gitvault.resolveOrCreateAddress({ address, allow_create: false, ...(repoDir ? { repo_dir: repoDir } : {}) });
|
|
441
|
+
return { vault: result.handle.vault, resolution: result.resolution };
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Open the vault, allocating it first when it does not exist yet (D2), and
|
|
446
|
+
* — for a SLUG-form address whose name does not resolve yet —
|
|
447
|
+
* PUSH-TO-CREATE it (design D6, task 4.4/4.5). Used ONLY by `runPush` —
|
|
448
|
+
* `list`/`fetch` stay pure reads and never create anything (see
|
|
449
|
+
* `runList`'s own not-found handling below).
|
|
450
|
+
*
|
|
451
|
+
* Id-form (gitvault-client-round-trips design D4): a PINNED repo_id means
|
|
452
|
+
* this checkout has already resolved (or pushed to) this vault before, so
|
|
453
|
+
* there is nothing left to allocate — the read-only, pin-aware
|
|
454
|
+
* `resolveOrCreateAddress` path (same one `openVault` uses) is enough,
|
|
455
|
+
* and cheaper than re-running the allocation-capable flow on every push.
|
|
456
|
+
* With NO pin yet, this is unchanged: `gitvault.openOrCreate` runs its
|
|
457
|
+
* six-stage creation journal when the vault does not exist, exactly as
|
|
458
|
+
* before — and, on success, PINS the resolved id for every later push on
|
|
459
|
+
* this checkout (this is the "first successful resolution" the pin exists
|
|
460
|
+
* for; `resolveOrCreateAddress`'s own pin-on-resolve only covers
|
|
461
|
+
* slug-form, since id-form's OWN allocation path — this one — never
|
|
462
|
+
* routes through it).
|
|
463
|
+
*
|
|
464
|
+
* Prints the one-shot recovery receipt and the keystore path to stderr the
|
|
465
|
+
* moment allocation happens, per the client-surface spec: an agent reads
|
|
466
|
+
* stderr, and the receipt is worth exactly as many copies as get kept.
|
|
467
|
+
*/
|
|
468
|
+
async function openOrCreateVault(repoDir) {
|
|
469
|
+
if (addressForm === "id" && repoDir) {
|
|
470
|
+
const pinned = await readPinnedGitvaultRepo(repoDir);
|
|
471
|
+
if (pinned) {
|
|
472
|
+
const result = await getSdk().gitvault.resolveOrCreateAddress({ address, repo_dir: repoDir, allow_create: false });
|
|
473
|
+
return result.handle.vault;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const result =
|
|
477
|
+
addressForm === "id"
|
|
478
|
+
? await getSdk().gitvault.openOrCreate({ ...target, repo_dir: repoDir })
|
|
479
|
+
: await getSdk().gitvault.resolveOrCreateAddress({ address, repo_dir: repoDir, allow_create: true });
|
|
480
|
+
if (addressForm === "id" && repoDir) await pinGitvaultRepo(repoDir, result.handle.repo_id, undefined, { project_id: target.project_id, org_id: target.org_id });
|
|
481
|
+
if (!result.found && result.created) {
|
|
482
|
+
note("");
|
|
483
|
+
note(`vault ${result.handle.repo_id} allocated (genesis ${result.created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
|
|
484
|
+
note(JSON.stringify(result.created.recovery_receipt));
|
|
485
|
+
try {
|
|
486
|
+
const { getGitvaultKeystoreRoot } = await import("#sdk/node");
|
|
487
|
+
note(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship`);
|
|
488
|
+
} catch {
|
|
489
|
+
// Never let a diagnostic line fail a push that already allocated successfully.
|
|
490
|
+
}
|
|
491
|
+
note("");
|
|
492
|
+
}
|
|
493
|
+
return result.handle.vault;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function runList() {
|
|
497
|
+
// `list` needs no repository (a repository-free `git ls-remote` outside
|
|
498
|
+
// any checkout must keep working) — the binding walk falls back to cwd,
|
|
499
|
+
// same as `capabilities`/`option`'s repository-free tier. Wallet
|
|
500
|
+
// resolution is UNCHANGED (still cwd-based, never repoDir-based) — only
|
|
501
|
+
// the vault-open call below additionally threads a repository when one
|
|
502
|
+
// resolves, purely so a later `push` in this same session can reuse the
|
|
503
|
+
// resulting vault instance (design D1); the repo-free ls-remote case is
|
|
504
|
+
// unaffected (`repoDir` just stays `null`).
|
|
505
|
+
applyWalletForDir(process.cwd());
|
|
506
|
+
let repoDir = null;
|
|
507
|
+
try {
|
|
508
|
+
repoDir = await requireRepo();
|
|
509
|
+
} catch {
|
|
510
|
+
// Not resolvable as a repository (e.g. `git ls-remote` outside any
|
|
511
|
+
// checkout) — `list` still works, and there is nothing for `push` to
|
|
512
|
+
// share later in that case.
|
|
513
|
+
}
|
|
514
|
+
let vault;
|
|
515
|
+
let state;
|
|
516
|
+
try {
|
|
517
|
+
const opened = await openVault(repoDir ?? undefined);
|
|
518
|
+
vault = opened.vault;
|
|
519
|
+
try {
|
|
520
|
+
state = await vault.materialize();
|
|
521
|
+
} catch (err) {
|
|
522
|
+
// An OFFLINE (id-carrying pin) resolution discovers a stale pin on
|
|
523
|
+
// its FIRST repo-scoped read (client-surface spec, id-pinning
|
|
524
|
+
// requirement): recover once — clear the pin, re-resolve — and retry
|
|
525
|
+
// only when re-resolution lands on a DIFFERENT vault; a same-id
|
|
526
|
+
// answer means the pin was fine and the refusal below is real. git
|
|
527
|
+
// always runs `list` first in a helper session, so this one site
|
|
528
|
+
// heals the pin for the `fetch`/`push` that follows it.
|
|
529
|
+
const recovered = repoDir && opened.resolution?.offline
|
|
530
|
+
? await getSdk().gitvault.recoverStalePin({ address, repo_dir: repoDir, resolution: opened.resolution, error: err })
|
|
531
|
+
: null;
|
|
532
|
+
if (!recovered) throw err;
|
|
533
|
+
note(`pinned vault ${opened.resolution.repo_id} no longer resolves — re-resolved to ${recovered.resolution.repo_id}, retrying`);
|
|
534
|
+
vault = recovered.handle.vault;
|
|
535
|
+
state = await vault.materialize();
|
|
536
|
+
}
|
|
537
|
+
} catch (err) {
|
|
538
|
+
// An unallocated vault is not an error here: `list` is the read half of
|
|
539
|
+
// the protocol dance and must never create anything on its own (D2
|
|
540
|
+
// scopes lazy creation to `push`). Reporting it as an EMPTY ref set is
|
|
541
|
+
// exactly what a fresh repository looks like to git, and `push` still
|
|
542
|
+
// runs `list` first either way — this is what lets a first push land in
|
|
543
|
+
// one command instead of `list` failing the whole exchange before
|
|
544
|
+
// `push` ever gets a turn. Nothing to share with `push` either
|
|
545
|
+
// (design D7): an unallocated vault has no base to reuse.
|
|
546
|
+
if (isVaultNotFound(err)) {
|
|
547
|
+
endBlock();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
throw err;
|
|
551
|
+
}
|
|
552
|
+
if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, base: state };
|
|
553
|
+
const refs = state.refs ?? {};
|
|
554
|
+
for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
|
|
555
|
+
// A snapshot-only vault holds protocol refs but no branch heads, so a
|
|
556
|
+
// plain `git clone` prints "cloned an empty repository" with no hint the
|
|
557
|
+
// history exists (blind-acceptance finding, 2026-08-28). Say where it is.
|
|
558
|
+
const refNames = Object.keys(refs);
|
|
559
|
+
if (refNames.length > 0 && !refNames.some((r) => r.startsWith("refs/heads/"))) {
|
|
560
|
+
note(`this vault has no branch heads yet — its history lives on ${refNames.sort()[0]}`);
|
|
561
|
+
note(`fetch it with: git fetch <remote> '+${refNames.sort()[0]}:${refNames.sort()[0]}' && git checkout -b restored ${refNames.sort()[0]}`);
|
|
562
|
+
}
|
|
563
|
+
const head = state.head_target;
|
|
564
|
+
// A symref is only advertised when its target is actually present:
|
|
565
|
+
// pointing HEAD at a ref that does not exist is what an empty repository
|
|
566
|
+
// looks like, and git reads the empty list correctly on its own.
|
|
567
|
+
if (head?.kind === "symref" && Object.prototype.hasOwnProperty.call(refs, head.ref)) out(`@${head.ref} HEAD`);
|
|
568
|
+
else if (head?.kind === "detached") out(`${head.oid} HEAD`);
|
|
569
|
+
endBlock();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function runFetch(batch) {
|
|
573
|
+
// Resolve the target repository BEFORE a single byte is decrypted: a
|
|
574
|
+
// refusal here must leave no objects anywhere. This is what makes `clone`
|
|
575
|
+
// work (git names the fresh repo in `GIT_DIR`) and what stops a clone run
|
|
576
|
+
// from inside an unrelated checkout from writing into that checkout.
|
|
577
|
+
let repoDir;
|
|
578
|
+
try {
|
|
579
|
+
repoDir = await requireRepo();
|
|
580
|
+
} catch (err) {
|
|
581
|
+
repoRefusalNote(err);
|
|
582
|
+
return 1;
|
|
583
|
+
}
|
|
584
|
+
// The repository is resolved — walk the binding from ITS directory, not
|
|
585
|
+
// cwd (the "WHICH REPOSITORY" note above: during `git clone` cwd is
|
|
586
|
+
// wherever clone was run FROM, unrelated to the target repository).
|
|
587
|
+
applyWalletForDir(repoDir);
|
|
588
|
+
if (verbosity >= 1) note(`restoring the vault object database for ${batch.length} ref(s) into ${repoDir}`);
|
|
589
|
+
const restored = await getSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
|
|
590
|
+
if (verbosity >= 1) note(`restored generation ${restored.generation}`);
|
|
591
|
+
// clone-installs-retained-refs D3: a bookkeeping failure here degrades to
|
|
592
|
+
// exactly today's (pre-change) behavior — one stderr note, fetch still
|
|
593
|
+
// completes. `restored.retained_refs` is never absent (the SDK always
|
|
594
|
+
// returns a result, never throws for this step).
|
|
595
|
+
if (restored.retained_refs?.warning) note(restored.retained_refs.warning);
|
|
596
|
+
else if (verbosity >= 1 && (restored.retained_refs?.written.length > 0 || restored.retained_refs?.deleted.length > 0)) {
|
|
597
|
+
note(`refs/r402/retain: +${restored.retained_refs.written.length} -${restored.retained_refs.deleted.length} (${restored.retained_refs.retained_count} retained tip(s) total)`);
|
|
598
|
+
}
|
|
599
|
+
endBlock();
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function runPush(batch) {
|
|
604
|
+
const specs = batch.map(parsePushSpec);
|
|
605
|
+
// D4: `refs/r402/*` is client-local — refuse it per-ref, BEFORE any
|
|
606
|
+
// repository/wallet/network work, while unrelated branch updates in the
|
|
607
|
+
// SAME push proceed normally (client-surface spec's own scenario).
|
|
608
|
+
const { refused, allowed } = partitionProtectedRefPushes(specs);
|
|
609
|
+
for (const spec of refused) {
|
|
610
|
+
note(`refusing ${spec.dst}: ${R402_PROTECTED_REF_NAMESPACE_REASON}`);
|
|
611
|
+
out(`error ${spec.dst} ${R402_PROTECTED_REF_NAMESPACE_REASON}`);
|
|
612
|
+
}
|
|
613
|
+
if (allowed.length === 0) {
|
|
614
|
+
endBlock();
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
// Repository first, then every source revision, and only then the
|
|
619
|
+
// network: a push that names a ref this repository does not have must
|
|
620
|
+
// fail locally rather than after opening the vault.
|
|
621
|
+
const repoDir = await requireRepo();
|
|
622
|
+
// Same "walk from the resolved repository, not cwd" rule as `fetch`.
|
|
623
|
+
applyWalletForDir(repoDir);
|
|
624
|
+
const newOids = new Map();
|
|
625
|
+
for (const spec of allowed) {
|
|
626
|
+
// A deletion carries an empty <src>. Everything else is resolved by
|
|
627
|
+
// git itself; `--end-of-options` keeps a hostile refname from being
|
|
628
|
+
// read as a flag.
|
|
629
|
+
newOids.set(spec, spec.src === ""
|
|
630
|
+
? null
|
|
631
|
+
: (await hardenedGit(repoDir, ["rev-parse", "--verify", "--end-of-options", spec.src])).text().trim());
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (dryRun) {
|
|
635
|
+
// kychee-com/run402#565: READ-ONLY resolution — `openVault`, never
|
|
636
|
+
// `openOrCreateVault` — so a push-to-create dry run allocates
|
|
637
|
+
// NOTHING. An unresolved vault means there is nothing to preview a
|
|
638
|
+
// push against yet (no repo_id ⇒ no encryption key ⇒ sizing is
|
|
639
|
+
// genuinely unknowable, not merely unreported); still report success
|
|
640
|
+
// per-ref, since a real push here WOULD succeed (it would allocate
|
|
641
|
+
// first) — only the sizing is unavailable.
|
|
642
|
+
let vault;
|
|
643
|
+
try {
|
|
644
|
+
vault = (await openVault(repoDir)).vault;
|
|
645
|
+
} catch (err) {
|
|
646
|
+
if (!isVaultNotFound(err)) throw err;
|
|
647
|
+
note("dry-run: no vault allocated for this project yet — a real push would allocate one (push-to-create) before publishing; object/byte sizing is not knowable until then");
|
|
648
|
+
for (const spec of allowed) out(`ok ${spec.dst}`);
|
|
649
|
+
endBlock();
|
|
650
|
+
return 0;
|
|
651
|
+
}
|
|
652
|
+
const base = await vault.materialize();
|
|
653
|
+
const updates = [];
|
|
654
|
+
for (const spec of allowed) {
|
|
655
|
+
const expectedOld = base.refs?.[spec.dst] ?? null;
|
|
656
|
+
updates.push({
|
|
657
|
+
ref: spec.dst,
|
|
658
|
+
expected_old_oid: expectedOld,
|
|
659
|
+
new_oid: newOids.get(spec),
|
|
660
|
+
force: spec.force && expectedOld !== null,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
// Same evaluation, pack building, and sealing/encryption a real push
|
|
664
|
+
// runs — stops before the two network mutations (upload, admit). A
|
|
665
|
+
// refusal here (non-fast-forward, tag immutability, ...) throws the
|
|
666
|
+
// SAME way a real push's would, caught below and reported as
|
|
667
|
+
// `error`, never a fake `ok`.
|
|
668
|
+
const plan = await vault.planPush({ transaction: { updates } });
|
|
669
|
+
note(
|
|
670
|
+
`dry-run: would publish generation ${plan.would_admit_generation} (${plan.would_admit_generation_decimal}, ${plan.form}) — ` +
|
|
671
|
+
`${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw), ` +
|
|
672
|
+
`${Object.keys(plan.refs).length} ref(s); no allocation needed`,
|
|
673
|
+
);
|
|
674
|
+
for (const spec of allowed) out(`ok ${spec.dst}`);
|
|
675
|
+
endBlock();
|
|
676
|
+
return 0;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Design D1: reuse `list`'s vault + materialized base for this push's
|
|
680
|
+
// FIRST admission attempt when it resolved the SAME repository under
|
|
681
|
+
// the SAME wallet — skips `openOrCreateVault` and `materialize()`
|
|
682
|
+
// entirely instead of resolving/materializing the vault a second and
|
|
683
|
+
// third time in one `list → push` exchange. A conflict retry inside
|
|
684
|
+
// `vault.push` re-materializes from storage exactly as it always has;
|
|
685
|
+
// only the FIRST attempt's base changes here. Any mismatch (no prior
|
|
686
|
+
// `list`, an unallocated vault `list` had nothing to share for, or a
|
|
687
|
+
// different repository/wallet) falls back to the original flow.
|
|
688
|
+
const shared = sharedListSession && sharedListSession.repoDir === repoDir && sharedListSession.walletName === (resolvedWallet?.name ?? null) ? sharedListSession : null;
|
|
689
|
+
const vault = shared ? shared.vault : await openOrCreateVault(repoDir);
|
|
690
|
+
const base = shared ? shared.base : await vault.materialize();
|
|
691
|
+
const updates = [];
|
|
692
|
+
for (const spec of allowed) {
|
|
693
|
+
const expectedOld = base.refs?.[spec.dst] ?? null;
|
|
694
|
+
updates.push({
|
|
695
|
+
ref: spec.dst,
|
|
696
|
+
expected_old_oid: expectedOld,
|
|
697
|
+
new_oid: newOids.get(spec),
|
|
698
|
+
// Force-with-lease still requires a lease, so a CREATE is never
|
|
699
|
+
// forced. The SDK owns what force actually permits.
|
|
700
|
+
force: spec.force && expectedOld !== null,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
// Repair a DANGLING HEAD from this batch's own branches (#568) — see
|
|
704
|
+
// `chooseGitvaultHeadTargetForPush`'s own doc comment for the exact
|
|
705
|
+
// rule. `localHeadRef` is read from THIS repository (never cwd, same
|
|
706
|
+
// fail-closed resolution as everything else in this function).
|
|
707
|
+
const headFix = chooseGitvaultHeadTargetForPush({
|
|
708
|
+
baseHeadTarget: base.head_target,
|
|
709
|
+
baseRefs: base.refs,
|
|
710
|
+
updates,
|
|
711
|
+
localHeadRef: await localHeadBranchRef(repoDir),
|
|
712
|
+
});
|
|
713
|
+
if (headFix.note) note(headFix.note);
|
|
714
|
+
// ONE transaction for the whole batch: the SDK evaluates fast-forward,
|
|
715
|
+
// tag immutability, protocol-ref refusal and retention roots, builds the
|
|
716
|
+
// packs, and publishes — all or nothing. `head_target` is included ONLY
|
|
717
|
+
// when a repair is called for; omitted, `vault.push` carries the base
|
|
718
|
+
// forward unchanged — a healthy HEAD is never moved.
|
|
719
|
+
const published = await vault.push({
|
|
720
|
+
transaction: { updates },
|
|
721
|
+
base,
|
|
722
|
+
...(headFix.head_target ? { head_target: headFix.head_target } : {}),
|
|
723
|
+
});
|
|
724
|
+
if (verbosity >= 1) note(`published generation ${published.generation} (${published.form})`);
|
|
725
|
+
// gitvault-clone-scaling (P3): advisory only — never blocks, never
|
|
726
|
+
// auto-runs compaction, and never fires on a failed push (this line is
|
|
727
|
+
// unreachable from the catch). Unknown coverage reads as not-advised.
|
|
728
|
+
if (published.checkpoint_staleness?.advised) {
|
|
729
|
+
note(`${published.checkpoint_staleness.generations_since_checkpoint} generations since the last checkpoint — cold clones re-verify each one; run402 repos gc compacts them`);
|
|
730
|
+
}
|
|
731
|
+
for (const spec of allowed) out(`ok ${spec.dst}`);
|
|
732
|
+
} catch (err) {
|
|
733
|
+
// The transaction is atomic, so a failure failed every ref in it. Report
|
|
734
|
+
// it against each one rather than letting some look like they landed.
|
|
735
|
+
if (err?.code === "GIT_INVOCATION_REPO_UNRESOLVED") repoRefusalNote(err);
|
|
736
|
+
// Force-spelling truth (gitvault-force-spelling-and-pin-fold): render
|
|
737
|
+
// the SDK's own `git push --force` next_action beside git's per-ref
|
|
738
|
+
// rejection — humans read stderr, agents read the structured error.
|
|
739
|
+
const forceHint = Array.isArray(err?.body?.next_actions) ? err.body.next_actions.find((a) => a?.action === "git push --force") : null;
|
|
740
|
+
if (forceHint?.why) note(`hint: ${forceHint.action} — ${forceHint.why}`);
|
|
741
|
+
const reason = describeError(err);
|
|
742
|
+
for (const spec of allowed) out(`error ${spec.dst} ${reason}`);
|
|
743
|
+
}
|
|
744
|
+
endBlock();
|
|
745
|
+
return 0;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function handleOption(name, value) {
|
|
749
|
+
switch (name) {
|
|
750
|
+
case "verbosity": {
|
|
751
|
+
const parsed = Number.parseInt(value, 10);
|
|
752
|
+
if (!Number.isFinite(parsed)) { out("error expected an integer verbosity"); return; }
|
|
753
|
+
verbosity = parsed;
|
|
754
|
+
out("ok");
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
case "progress":
|
|
758
|
+
// Progress is stderr chatter, which `verbosity` already governs.
|
|
759
|
+
out("ok");
|
|
760
|
+
return;
|
|
761
|
+
case "atomic":
|
|
762
|
+
// Every push here is a single ref transaction, so the guarantee holds
|
|
763
|
+
// whichever way git asked for it.
|
|
764
|
+
out("ok");
|
|
765
|
+
return;
|
|
766
|
+
case "dry-run":
|
|
767
|
+
// kychee-com/run402#565: a REAL dry run — `runPush` runs the actual
|
|
768
|
+
// local pipeline (pack building, encryption sizing) and stops before
|
|
769
|
+
// the two network mutations. `value` is git's own boolean spelling
|
|
770
|
+
// ("true"/"false"); anything else is refused rather than guessed.
|
|
771
|
+
if (value === "true") { dryRun = true; out("ok"); return; }
|
|
772
|
+
if (value === "false") { dryRun = false; out("ok"); return; }
|
|
773
|
+
out("unsupported");
|
|
774
|
+
return;
|
|
775
|
+
default:
|
|
776
|
+
// Includes object-format, depth, cloning, check-connectivity,
|
|
777
|
+
// followtags, pushcert: honestly unsupported rather than acknowledged.
|
|
778
|
+
out("unsupported");
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
let fetchBatch = [];
|
|
783
|
+
let pushBatch = [];
|
|
784
|
+
|
|
785
|
+
/** Returns the process exit code the flushed batch demands (0 = keep going). */
|
|
786
|
+
async function flushBatches() {
|
|
787
|
+
if (fetchBatch.length > 0) {
|
|
788
|
+
const batch = fetchBatch;
|
|
789
|
+
fetchBatch = [];
|
|
790
|
+
return await runFetch(batch);
|
|
791
|
+
}
|
|
792
|
+
if (pushBatch.length > 0) {
|
|
793
|
+
const batch = pushBatch;
|
|
794
|
+
pushBatch = [];
|
|
795
|
+
return await runPush(batch);
|
|
796
|
+
}
|
|
797
|
+
return 0;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const rl = createInterface({ input: sessionStdin, crlfDelay: Infinity });
|
|
801
|
+
try {
|
|
802
|
+
for await (const raw of rl) {
|
|
803
|
+
const line = raw.replace(/\r$/, "");
|
|
804
|
+
if (line === "") {
|
|
805
|
+
const code = await flushBatches();
|
|
806
|
+
if (code !== 0) return code;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
const space = line.indexOf(" ");
|
|
810
|
+
const command = space === -1 ? line : line.slice(0, space);
|
|
811
|
+
const rest = space === -1 ? "" : line.slice(space + 1);
|
|
812
|
+
switch (command) {
|
|
813
|
+
case "capabilities":
|
|
814
|
+
out("fetch");
|
|
815
|
+
out("push");
|
|
816
|
+
out("option");
|
|
817
|
+
endBlock();
|
|
818
|
+
break;
|
|
819
|
+
case "list":
|
|
820
|
+
await runList();
|
|
821
|
+
break;
|
|
822
|
+
case "option": {
|
|
823
|
+
const optSpace = rest.indexOf(" ");
|
|
824
|
+
handleOption(optSpace === -1 ? rest : rest.slice(0, optSpace), optSpace === -1 ? "" : rest.slice(optSpace + 1));
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
case "fetch":
|
|
828
|
+
fetchBatch.push(rest);
|
|
829
|
+
break;
|
|
830
|
+
case "push":
|
|
831
|
+
pushBatch.push(rest);
|
|
832
|
+
break;
|
|
833
|
+
default:
|
|
834
|
+
note(`unknown command: ${oneLine(line)}`);
|
|
835
|
+
return 1;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// EOF. Git always terminates a batch with a blank line, but flushing here
|
|
839
|
+
// means a truncated stream still does the work it already asked for
|
|
840
|
+
// instead of silently dropping it.
|
|
841
|
+
return await flushBatches();
|
|
842
|
+
} finally {
|
|
843
|
+
rl.close();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Run one remote-helper session to completion (gitvault-persistent-helper
|
|
849
|
+
* D1). `argv` is git's argv slice (`[remote, address]`); `stdin` is the
|
|
850
|
+
* session's input stream (defaults to the real process stdin for the
|
|
851
|
+
* in-process host). Per-session module state is reset here so a daemon
|
|
852
|
+
* serving sequential sessions never leaks one invocation's resolution into
|
|
853
|
+
* the next (D2). Never throws — the error path is the same
|
|
854
|
+
* note-and-exit-1 the standalone binary always had.
|
|
855
|
+
*/
|
|
856
|
+
export async function runHelperSession(argv, { stdin } = {}) {
|
|
857
|
+
sessionStdin = stdin ?? process.stdin;
|
|
858
|
+
resolvedWallet = null;
|
|
859
|
+
try {
|
|
860
|
+
return await main(argv);
|
|
861
|
+
} catch (err) {
|
|
862
|
+
note(describeError(err));
|
|
863
|
+
return 1;
|
|
864
|
+
} finally {
|
|
865
|
+
sessionStdin = process.stdin;
|
|
866
|
+
}
|
|
867
|
+
}
|