From 6b04ab8694f96c613dc2cb5effa691f80adcab8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:51:53 +0000 Subject: [PATCH 1/2] Initial plan From 24b83a35a2a5fa2c998e292a7e176424e6bef975 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:57:43 +0000 Subject: [PATCH 2/2] feat: add optional passphrase prompt for SSH key generation Co-authored-by: ChangeHow <23733347+ChangeHow@users.noreply.github.com> --- src/steps/ssh.js | 14 +++++++++++- src/utils/shell.js | 8 +++++-- tests/ssh.test.js | 55 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/steps/ssh.js b/src/steps/ssh.js index 7ac8b2c..ead1905 100644 --- a/src/steps/ssh.js +++ b/src/steps/ssh.js @@ -29,9 +29,21 @@ export async function setupSsh({ home } = {}) { if (p.isCancel(email)) return; + const passphrase = await p.password({ + message: "Enter a passphrase for the SSH key (leave blank for no passphrase):", + validate(value) { + if (value.length > 0 && value.length < 8) { + return "Passphrase must be at least 8 characters, or leave blank"; + } + }, + }); + + if (p.isCancel(passphrase)) return; + p.log.step("Generating SSH key..."); await runStream( - `ssh-keygen -t rsa -b 4096 -C "${email}" -f "${keyFile}" -N ""` + `ssh-keygen -t rsa -b 4096 -C "${email}" -f "${keyFile}" -N "$SSH_KEYGEN_PASSPHRASE"`, + { env: { ...process.env, SSH_KEYGEN_PASSPHRASE: passphrase } } ); // Copy public key to clipboard diff --git a/src/utils/shell.js b/src/utils/shell.js index 244e503..caac8f5 100644 --- a/src/utils/shell.js +++ b/src/utils/shell.js @@ -52,10 +52,14 @@ export function brewInstall(name, { cask = false } = {}) { /** * Run a shell command and stream output to stdout/stderr in real-time. * Returns a promise that resolves with the exit code. + * @param {string} cmd + * @param {{ env?: Record }} [opts] */ -export function runStream(cmd) { +export function runStream(cmd, opts = {}) { return new Promise((resolve, reject) => { - const child = spawn("bash", ["-c", cmd], { stdio: "inherit" }); + const spawnOpts = { stdio: "inherit" }; + if (opts.env) spawnOpts.env = opts.env; + const child = spawn("bash", ["-c", cmd], spawnOpts); child.on("close", (code) => resolve(code)); child.on("error", reject); }); diff --git a/tests/ssh.test.js b/tests/ssh.test.js index bf5c0cc..b7a54d8 100644 --- a/tests/ssh.test.js +++ b/tests/ssh.test.js @@ -3,8 +3,9 @@ import { mkdirSync, existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createSandbox } from "./helpers.js"; -const { mockText } = vi.hoisted(() => ({ +const { mockText, mockPassword } = vi.hoisted(() => ({ mockText: vi.fn(), + mockPassword: vi.fn(), })); vi.mock("@clack/prompts", () => ({ @@ -12,6 +13,7 @@ vi.mock("@clack/prompts", () => ({ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), confirm: vi.fn(), text: mockText, + password: mockPassword, isCancel: vi.fn(() => false), })); @@ -33,6 +35,7 @@ describe("ssh step", () => { vi.clearAllMocks(); sandbox = createSandbox(); mockText.mockResolvedValue("test@example.com"); + mockPassword.mockResolvedValue(""); }); afterEach(() => { @@ -54,12 +57,9 @@ describe("ssh step", () => { await setupSsh({ home: sandbox.path }); expect(mockText).toHaveBeenCalled(); - expect(runStream).toHaveBeenCalledWith( - expect.stringContaining("ssh-keygen") - ); - expect(runStream).toHaveBeenCalledWith( - expect.stringContaining("test@example.com") - ); + const sshKeygenCall = runStream.mock.calls.find((c) => c[0].includes("ssh-keygen")); + expect(sshKeygenCall).toBeDefined(); + expect(sshKeygenCall[0]).toContain("test@example.com"); }); test("uses sandbox path for key file location", async () => { @@ -69,4 +69,45 @@ describe("ssh step", () => { expect(sshKeygenCall).toBeDefined(); expect(sshKeygenCall[0]).toContain(sandbox.path); }); + + test("generates key without passphrase when left blank", async () => { + mockPassword.mockResolvedValue(""); + + await setupSsh({ home: sandbox.path }); + + const sshKeygenCall = runStream.mock.calls.find((c) => c[0].includes("ssh-keygen")); + expect(sshKeygenCall).toBeDefined(); + expect(sshKeygenCall[0]).toContain('-N "$SSH_KEYGEN_PASSPHRASE"'); + expect(sshKeygenCall[1]).toEqual({ env: expect.objectContaining({ SSH_KEYGEN_PASSPHRASE: "" }) }); + }); + + test("generates key with passphrase when provided", async () => { + mockPassword.mockResolvedValue("s3cr3tPass"); + + await setupSsh({ home: sandbox.path }); + + const sshKeygenCall = runStream.mock.calls.find((c) => c[0].includes("ssh-keygen")); + expect(sshKeygenCall).toBeDefined(); + expect(sshKeygenCall[0]).toContain('-N "$SSH_KEYGEN_PASSPHRASE"'); + expect(sshKeygenCall[1]).toEqual({ env: expect.objectContaining({ SSH_KEYGEN_PASSPHRASE: "s3cr3tPass" }) }); + }); + + test("aborts when passphrase prompt is cancelled", async () => { + const { isCancel } = await import("@clack/prompts"); + isCancel.mockImplementationOnce(() => false); // email not cancelled + isCancel.mockImplementationOnce(() => true); // passphrase is cancelled + mockPassword.mockResolvedValue("s3cr3tPass"); + + await setupSsh({ home: sandbox.path }); + + expect(runStream).not.toHaveBeenCalled(); + }); + + test("prompts for passphrase after email", async () => { + await setupSsh({ home: sandbox.path }); + + expect(mockPassword).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("passphrase") }) + ); + }); });