fix: revert broken keychain stdin, fix login prompt duplication#15
fix: revert broken keychain stdin, fix login prompt duplication#15ryanmcmillan merged 2 commits intomainfrom
Conversation
macOS security command does NOT support reading -w from stdin — it opens /dev/tty and prompts interactively, which broke delega login completely. Revert to passing key via argv (brief exposure, only reliable method without native bindings). Also replace PassThrough stream approach for password masking with simpler terminal:false readline that works on Node 24 without echoing the prompt multiple times.
Greptile SummaryThis PR fixes two regressions introduced in v1.1.1 and v1.1.2: a broken macOS Keychain write (where Key changes:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant CLI as delega login
participant Prompt as promptSecret()
participant RL as readline terminal:false
participant Out as process.stdout
participant Store as key-store
participant Sec as macOS security(1)
User->>CLI: delega login
CLI->>Prompt: call with question string
Prompt->>Out: write prompt text
Prompt->>RL: createInterface
Note over RL: TTY stays in cooked mode<br/>keystrokes echoed by OS
User->>RL: types API key (visible on screen)
RL->>Prompt: line event fired
Prompt->>Out: write newline
Prompt-->>CLI: answer resolved
CLI->>CLI: validate format and call API
CLI->>Store: persistApiKey
Store->>Sec: execFileSync with key in argv
Note over Sec: brief argv exposure in ps
Sec-->>Store: done
Store-->>CLI: storage label
CLI->>Out: Logged in confirmation
Reviews (1): Last reviewed commit: "fix: revert keychain stdin approach, fix..." | Re-trigger Greptile |
src/commands/login.ts
Outdated
| // Disable raw echo at the TTY level so keystrokes aren't visible. | ||
| if (process.stdin.isTTY) { | ||
| process.stdin.setRawMode?.(false); | ||
| } |
There was a problem hiding this comment.
Echo is not suppressed — API key visible as typed
The comment says "Disable raw echo at the TTY level", but process.stdin.setRawMode?.(false) keeps the terminal in cooked mode (the default), where the kernel TTY line discipline echoes keystrokes automatically. Calling setRawMode(false) is a no-op when the terminal is already in cooked mode, so it does nothing to hide input.
With terminal: false, readline cedes all TTY control back to the OS. The result is that every character of the API key will be echoed to the screen as the user types, defeating the purpose of promptSecret.
To actually suppress echo you need either:
setRawMode(true)— enters raw mode so the OS doesn't echo, but then you must collect characters manually until\n.- Override
_writeToOutputon aterminal: trueinterface — a common Node.js idiom for password prompts:
const rl = node_readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
(rl as any)._writeToOutput = () => { /* suppress echo */ };
process.stdout.write(question);The current code regresses from the previous (broken) implementation's intent: users will see their key echoed in plain text.
src/commands/login.ts
Outdated
| rl.on("SIGINT", () => { | ||
| rl.close(); | ||
| process.stdout.write("\n"); | ||
| reject(new Error("Cancelled.")); | ||
| }); |
There was a problem hiding this comment.
SIGINT listener is dead code with terminal: false
Node's readline only intercepts Ctrl+C and emits the 'SIGINT' event when terminal: true. With terminal: false, readline does not install a SIGINT interceptor, so this handler will never fire. Ctrl+C will just invoke the default process SIGINT handler and terminate the process without printing the newline.
The handler is harmless to leave in, but if graceful cancellation is desired, a process-level listener would be needed instead:
process.once("SIGINT", () => {
rl.close();
process.stdout.write("\n");
reject(new Error("Cancelled."));
});
src/commands/login.ts
Outdated
| rl.on("close", () => { | ||
| resolve(""); | ||
| }); |
There was a problem hiding this comment.
close handler can race with line handler
When the line event fires, rl.close() is called synchronously, which immediately emits close. Both handlers call resolve(...), but since it's the same Promise, the second call (with "") is silently discarded — safe, but potentially confusing.
A more defensive pattern guards the resolve:
let settled = false;
rl.on("line", (answer) => {
if (settled) return;
settled = true;
rl.close();
process.stdout.write("\n");
resolve(answer.trim());
});
rl.on("close", () => {
if (!settled) resolve("");
});Greptile correctly flagged that setRawMode(false) is a no-op — cooked mode echoes by default, so the API key would be visible as typed. Replace readline entirely with raw mode character collection: - setRawMode(true) disables OS echo - Manual handling of Enter, Ctrl+C, Backspace, printable chars - Restore raw mode state on cleanup - Non-TTY fallback for piped input (CI/scripts) No Node version dependencies — raw mode is stable since Node 0.x.
|
/greptile review |
Problem
v1.1.1 changed macOS keychain writes to pipe via stdin. macOS
securitydoes NOT support this — it opens/dev/ttyand prompts interactively ('password data for new item'). This brokedelega logincompletely.v1.1.2 fixed the Node 24 crash but introduced a PassThrough stream that echoed the prompt multiple times.
Fix
Keychain: Revert to
execFileSyncwith-w apiKeyin argv. This is the only reliable approach without native Keychain bindings. The argv exposure window is <100ms whilesecurityruns. Documented the limitation in comments.Login prompt: Replace PassThrough stream with simple
terminal: falsereadline. Print prompt ourselves, read input without echo. Works on Node 24, no duplicate prompts.Testing
npm run buildpasses