From 9987e21739f33af674130dc5328af1738f7797b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 02:05:25 +0000 Subject: [PATCH] feat: askpass bridge from git prompts to a callback Co-Authored-By: Claude --- src/askpass.test.ts | 25 +++++++++++++++++++ src/askpass.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/askpass.test.ts create mode 100644 src/askpass.ts diff --git a/src/askpass.test.ts b/src/askpass.test.ts new file mode 100644 index 0000000..2f744bb --- /dev/null +++ b/src/askpass.test.ts @@ -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 }); +}); diff --git a/src/askpass.ts b/src/askpass.ts new file mode 100644 index 0000000..128d85f --- /dev/null +++ b/src/askpass.ts @@ -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 }) {} + + 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((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; + } +}