run402 4.58.0 → 4.60.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 +2 -1
- package/sdk/dist/namespaces/pay.d.ts.map +1 -1
- package/sdk/dist/namespaces/pay.js +12 -1
- package/sdk/dist/namespaces/pay.js.map +1 -1
- package/sdk/dist/node/gitvault-prewarm.d.ts +1 -0
- package/sdk/dist/node/gitvault-prewarm.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-prewarm.js +30 -6
- package/sdk/dist/node/gitvault-prewarm.js.map +1 -1
- package/sdk/dist/node/http-dispatcher.d.ts +26 -0
- package/sdk/dist/node/http-dispatcher.d.ts.map +1 -0
- package/sdk/dist/node/http-dispatcher.js +209 -0
- package/sdk/dist/node/http-dispatcher.js.map +1 -0
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +5 -1
- package/sdk/dist/node/index.js.map +1 -1
- package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
- package/sdk/dist/node/paid-fetch.js +10 -8
- package/sdk/dist/node/paid-fetch.js.map +1 -1
package/git-remote-run402.mjs
CHANGED
|
@@ -1,874 +1,188 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* `git-remote-run402` — the
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
113
|
-
//
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
return null;
|
|
498
70
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
return result.handle.vault;
|
|
502
|
-
}
|
|
71
|
+
})();
|
|
72
|
+
if (!socketPath) return resolve(null);
|
|
503
73
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
529
|
-
} catch
|
|
530
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
653
|
-
} catch
|
|
654
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
160
|
+
/** Best-effort, detached, single-shot daemon spawn so the NEXT invocation is warm. */
|
|
161
|
+
function spawnDaemon() {
|
|
809
162
|
try {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
}
|