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