Skip to content

Commit 77755dc

Browse files
authored
fix: read the password without echoing it, and name both agents at the end (#7)
1 parent 05e2cb6 commit 77755dc

4 files changed

Lines changed: 157 additions & 20 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ KEENETIC_TEST_HOST=… KEENETIC_TEST_PASSWORD=… npm run smoke
201201
Fixtures are anonymized deterministically and a test scans the whole repository
202202
for anything that looks like a real MAC address, private IP or key.
203203

204+
The setup wizard reads a password from the terminal, which no unit test can
205+
reach: piped input takes a different code path entirely. That part is checked
206+
with a script that drives a real pty, so it needs a terminal and cannot run in
207+
CI:
208+
209+
```
210+
KEENETIC_TEST_PASSWORD=… ./scripts/verify-wizard.exp
211+
```
212+
204213
## License
205214

206215
MIT

scripts/verify-wizard.exp

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env expect
2+
#
3+
# Checks the two things about `keenetic-mcp init` that no unit test can reach:
4+
# that the password prompt is actually visible, and that what you type is not.
5+
#
6+
# Both need a real terminal. Piped input will not do: under a pty the end of a
7+
# pipe arrives as Ctrl-D and aborts the first question, and without a pty the
8+
# wizard takes its buffered path and never touches the terminal code at all.
9+
# That is why this is expect and not vitest, and why CI cannot run it.
10+
#
11+
# npm run build
12+
# KEENETIC_TEST_PASSWORD=... ./scripts/verify-wizard.exp
13+
#
14+
# Prints PASS or FAIL for each check and exits non-zero on any failure.
15+
16+
set timeout 30
17+
log_user 0
18+
19+
if {![info exists env(KEENETIC_TEST_PASSWORD)]} {
20+
puts "KEENETIC_TEST_PASSWORD must be set to a password the router accepts."
21+
exit 2
22+
}
23+
set password $env(KEENETIC_TEST_PASSWORD)
24+
25+
# A throwaway config directory, so a real installation is never touched.
26+
set config_dir "/tmp/keenetic-wizard-check-[pid]"
27+
file mkdir $config_dir
28+
29+
spawn env KEENETIC_CONFIG_DIR=$config_dir node dist/index.js init
30+
31+
set failures 0
32+
33+
expect {
34+
"Router address" {}
35+
timeout { puts "FAIL the wizard never asked for an address"; exit 1 }
36+
}
37+
send "\r"
38+
39+
expect {
40+
"Login" {}
41+
timeout { puts "FAIL the wizard never asked for a login"; exit 1 }
42+
}
43+
send "\r"
44+
45+
expect {
46+
"Password: " { puts "PASS the password prompt is visible" }
47+
timeout { puts "FAIL the password prompt never appeared"; incr failures }
48+
}
49+
50+
# Type it, then look at the screen before pressing enter.
51+
send $password
52+
sleep 1
53+
expect {
54+
-re $password { puts "FAIL the password was echoed to the terminal"; incr failures }
55+
timeout { puts "PASS the password was not echoed" }
56+
}
57+
send "\r"
58+
59+
expect {
60+
"Password stored in" { puts "PASS the wizard completed" }
61+
timeout { puts "FAIL the wizard did not finish"; incr failures }
62+
}
63+
64+
expect eof
65+
file delete -force $config_dir
66+
exit [expr {$failures > 0}]

src/cli/init.ts

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,15 @@ export async function runInit(deps: InitDeps): Promise<number> {
6969
deps.out(`Password stored in ${where}`);
7070
deps.out(`Settings written to ${path}`);
7171
deps.out('');
72-
deps.out('Add the server to your agent:');
73-
deps.out(' Codex: codex mcp add keenetic -- npx -y keenetic-mcp');
74-
deps.out(' Others: {"command": "npx", "args": ["-y", "keenetic-mcp"]}');
72+
deps.out('Add it to your agent. The plugin brings the skills along with the server:');
73+
deps.out('');
74+
deps.out(' Claude Code /plugin marketplace add salatmaster/keenetic-mcp');
75+
deps.out(' /plugin install keenetic@keenetic');
76+
deps.out('');
77+
deps.out(' Codex codex plugin marketplace add salatmaster/keenetic-mcp');
78+
deps.out(' codex plugin add keenetic@keenetic');
79+
deps.out('');
80+
deps.out(' Anything else {"command": "npx", "args": ["-y", "keenetic-mcp"]}');
7581
return 0;
7682
}
7783

@@ -93,34 +99,85 @@ async function readGateway(): Promise<string | null> {
9399
}
94100
}
95101

96-
// Required-but-nullable rather than optional: exactOptionalPropertyTypes
97-
// forbids assigning undefined back to an optional property.
98-
type MutableReadline = { _writeToOutput: ((text: string) => void) | undefined };
99-
100102
interface LineSource {
101103
ask(question: string, echo: boolean): Promise<string>;
102104
close(): void;
103105
}
104106

105-
/** Reads whole lines from a terminal, hiding the echo when asked to. */
107+
/**
108+
* Reads a secret from the terminal without echoing it.
109+
*
110+
* Raw mode rather than readline: in terminal mode readline redraws the current
111+
* line, which erases a prompt written straight to stdout, and muting its
112+
* private `_writeToOutput` does not suppress the echo on current Node. Raw mode
113+
* turns the echo off at the terminal, so nothing can leak it.
114+
*/
115+
function readSecret(question: string): Promise<string> {
116+
return new Promise((resolve, reject) => {
117+
const { stdin, stdout } = process;
118+
stdout.write(question);
119+
120+
const wasRaw = stdin.isRaw === true;
121+
if (stdin.isTTY) stdin.setRawMode(true);
122+
stdin.resume();
123+
124+
let secret = '';
125+
126+
const stop = (): void => {
127+
stdin.off('data', onData);
128+
if (stdin.isTTY) stdin.setRawMode(wasRaw);
129+
stdin.pause();
130+
stdout.write('\n');
131+
};
132+
133+
const onData = (chunk: Buffer): void => {
134+
for (const char of chunk.toString('utf8')) {
135+
switch (char) {
136+
case '\r':
137+
case '\n':
138+
case '\u0004': // Ctrl-D, an empty password
139+
stop();
140+
resolve(secret);
141+
return;
142+
case '\u0003': // Ctrl-C
143+
stop();
144+
reject(new Error('cancelled'));
145+
return;
146+
case '\u007f': // Delete
147+
case '\b':
148+
secret = secret.slice(0, -1);
149+
break;
150+
default:
151+
// Skip the rest of the control range, including escape sequences
152+
// from arrow keys, which would otherwise land in the password.
153+
if (char >= ' ' && char !== '\u007f') secret += char;
154+
}
155+
}
156+
};
157+
158+
stdin.on('data', onData);
159+
});
160+
}
161+
162+
/**
163+
* Reads from a terminal.
164+
*
165+
* A readline interface is opened per visible question and closed again, so it
166+
* is never competing with the raw-mode reader for stdin.
167+
*/
106168
function terminalSource(): LineSource {
107-
const rl = createInterface({ input: process.stdin, output: process.stdout });
108169
return {
109170
async ask(question, echo) {
110-
if (echo) return (await rl.question(question)).trim();
171+
if (!echo) return readSecret(question);
111172

112-
const mutable = rl as unknown as MutableReadline;
113-
const original = mutable._writeToOutput;
114-
process.stdout.write(question);
115-
mutable._writeToOutput = () => {};
173+
const rl = createInterface({ input: process.stdin, output: process.stdout });
116174
try {
117-
return await rl.question('');
175+
return (await rl.question(question)).trim();
118176
} finally {
119-
mutable._writeToOutput = original;
120-
process.stdout.write('\n');
177+
rl.close();
121178
}
122179
},
123-
close: () => rl.close()
180+
close: () => {}
124181
};
125182
}
126183

tests/cli/init.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,15 @@ describe('runInit', () => {
5050
expect(out.join('\n')).not.toContain('hunter2');
5151
});
5252

53-
it('prints the config block for the user to paste', async () => {
53+
it('names both agents, not just one, plus the generic config', async () => {
5454
const { deps, out } = await makeDeps();
5555
await runInit(deps);
56-
expect(out.join('\n')).toMatch(/npx -y keenetic-mcp/);
56+
const text = out.join('\n');
57+
expect(text).toMatch(/Claude Code/);
58+
expect(text).toMatch(/Codex/);
59+
expect(text).toMatch(/plugin marketplace add salatmaster\/keenetic-mcp/);
60+
expect(text).toMatch(/"command": "npx"/);
61+
expect(text).toMatch(/"keenetic-mcp"/);
5762
});
5863

5964
it('fails with a usable message when nothing answers at the gateway', async () => {

0 commit comments

Comments
 (0)