feat: askpass bridge from git prompts to a callback

Co-Authored-By: Claude
This commit is contained in:
2026-06-19 02:05:25 +00:00
parent 32263df062
commit 9987e21739
2 changed files with 85 additions and 0 deletions

25
src/askpass.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { afterEach, expect, test } from "vitest";
import { execFile } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { AskpassBridge } from "./askpass";
const run = promisify(execFile);
let bridge: AskpassBridge | undefined;
afterEach(() => bridge?.stop());
test("helper script round-trips a prompt to onPrompt and returns the answer", async () => {
const dir = mkdtempSync(join(tmpdir(), "jp-ask-"));
bridge = new AskpassBridge({
dir,
onPrompt: async (p) => (p.includes("Password") ? "s3cret" : "alice"),
});
const env = await bridge.start();
const { stdout } = await run(env.GIT_ASKPASS, ["Password for 'https://x':"], {
env: { ...process.env, JEKYLL_ASKPASS_SOCK: env.JEKYLL_ASKPASS_SOCK },
});
expect(stdout.trim()).toBe("s3cret");
rmSync(dir, { recursive: true, force: true });
});

60
src/askpass.ts Normal file
View File

@@ -0,0 +1,60 @@
import { createServer, Server } from "node:net";
import { chmodSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export class AskpassBridge {
private server?: Server;
private sock = "";
private script = "";
constructor(private opts: { dir: string; onPrompt: (prompt: string) => Promise<string> }) {}
async start(): Promise<{
GIT_ASKPASS: string;
SSH_ASKPASS: string;
SSH_ASKPASS_REQUIRE: string;
JEKYLL_ASKPASS_SOCK: string;
}> {
this.sock = join(this.opts.dir, "askpass.sock");
this.script = join(this.opts.dir, "askpass.sh");
this.server = createServer({ allowHalfOpen: true }, (conn) => {
let buf = "";
conn.on("data", (d) => (buf += d.toString()));
conn.on("end", async () => {
const answer = await this.opts.onPrompt(buf.replace(/\n$/, ""));
conn.end(answer.endsWith("\n") ? answer : answer + "\n");
});
});
await new Promise<void>((res) => this.server!.listen(this.sock, res));
// Helper: send argv[1] (the prompt) to the socket, print the reply.
// Uses a Node one-liner for reliable EOF signalling across platforms
// (nc -U without -N hangs on BSD-derived netcat; socat may also vary).
// Protocol: client writes prompt + "\n", server replies with answer + "\n" and closes.
// allowHalfOpen ensures the client can still receive after calling end().
const sh = [
"#!/bin/sh",
'node -e "' +
"const n=require('net'),s=process.env.JEKYLL_ASKPASS_SOCK,p=process.argv[1];" +
"const c=n.createConnection({path:s,allowHalfOpen:true},()=>{c.end(p+'\\n')});" +
"c.on('data',d=>process.stdout.write(d));" +
'" -- "$1"',
"",
].join("\n");
writeFileSync(this.script, sh);
chmodSync(this.script, 0o755);
return {
GIT_ASKPASS: this.script,
SSH_ASKPASS: this.script,
SSH_ASKPASS_REQUIRE: "force",
JEKYLL_ASKPASS_SOCK: this.sock,
};
}
stop(): void {
this.server?.close();
this.server = undefined;
}
}