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