Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 48 additions & 33 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,42 +15,57 @@ interface MeResponse {
}

async function promptSecret(question: string): Promise<string> {
// Wrap process.stdout so we can mute the echoed password while keeping
// all EventEmitter methods (on, removeListener, etc.) that Node's readline
// expects. Node 24+ calls output.on('resize', ...) during construction.
const mutedOutput = new (await import("node:stream")).PassThrough({
decodeStrings: false,
});
let muted = false;
mutedOutput.on("data", (chunk: Buffer | string) => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
if (!muted || text.includes(question)) {
process.stdout.write(text);
// Read a secret without echoing keystrokes.
// We avoid readline entirely — Node 24's readline requires a full
// EventEmitter output stream, and fake streams caused duplicate prompts.
// Instead: raw mode + manual character collection. Simple and portable.
process.stdout.write(question);

return new Promise((resolve, reject) => {
if (!process.stdin.isTTY) {
// Non-interactive (piped input): fall back to line reading.
let data = "";
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk: string) => { data += chunk; });
process.stdin.on("end", () => resolve(data.split("\n")[0].trim()));
process.stdin.resume();
return;
}
});
// Forward resize events from stdout so readline can track terminal width.
const onResize = () => mutedOutput.emit("resize");
process.stdout.on("resize", onResize);
// Expose columns/rows so readline doesn't error when checking terminal size.
Object.defineProperty(mutedOutput, "columns", { get: () => process.stdout.columns });
Object.defineProperty(mutedOutput, "rows", { get: () => process.stdout.rows });

const rl = node_readline.createInterface({
input: process.stdin,
output: mutedOutput as unknown as NodeJS.WritableStream,
terminal: true,
});

muted = true;
let input = "";
const wasRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding("utf-8");

const onData = (key: string) => {
const code = key.charCodeAt(0);
if (key === "\r" || key === "\n") {
// Enter — done
cleanup();
process.stdout.write("\n");
resolve(input.trim());
} else if (code === 3) {
// Ctrl+C
cleanup();
process.stdout.write("\n");
reject(new Error("Cancelled."));
} else if (code === 127 || code === 8) {
// Backspace / Delete
input = input.slice(0, -1);
} else if (code >= 32) {
// Printable character
input += key;
}
};

const cleanup = () => {
process.stdin.removeListener("data", onData);
process.stdin.setRawMode(wasRaw ?? false);
process.stdin.pause();
};

return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
process.stdout.removeListener("resize", onResize);
mutedOutput.destroy();
process.stdout.write("\n");
resolve(answer.trim());
});
process.stdin.on("data", onData);
});
}

Expand Down
9 changes: 5 additions & 4 deletions src/secret-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ function readMacosKeychain(): string | undefined {
}

function writeMacosKeychain(apiKey: string): void {
// Pass -w without a value so `security` reads the password interactively from stdin.
// We pipe the key via stdin to avoid exposing it in process argv (visible in `ps`).
// Note: macOS `security` does not support reading -w from stdin; it opens /dev/tty.
// Passing the key as an argv is the only reliable approach without native bindings.
// The exposure window is brief (< 100ms while `security` runs).
node_child_process.execFileSync(
"security",
["add-generic-password", "-U", "-a", ACCOUNT_NAME, "-s", SERVICE_NAME, "-w"],
{ input: apiKey, encoding: "utf-8", stdio: ["pipe", "ignore", "ignore"] },
["add-generic-password", "-U", "-a", ACCOUNT_NAME, "-s", SERVICE_NAME, "-w", apiKey],
{ stdio: "ignore" },
);
}

Expand Down