feat: askpass bridge from git prompts to a callback
Co-Authored-By: Claude
This commit is contained in:
25
src/askpass.test.ts
Normal file
25
src/askpass.test.ts
Normal 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
60
src/askpass.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user