hostwares-cli 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "fs";
3
+ import { fileURLToPath } from "url";
4
+ import { dirname, join } from "path";
5
+ import { setVersion } from "./agent/run.js";
6
+ import { reportError } from "./errors.js";
7
+ import { login } from "./auth/device.js";
8
+ import { startChat } from "./commands/chat.js";
9
+ import { getConfig, saveConfig, clearConfig, isAuthenticated } from "./config.js";
10
+ import { listSessions } from "./session/store.js";
11
+ import { c, glyph, BANNER_LINES } from "./ui/theme.js";
12
+ import * as ui from "./ui/render.js";
13
+ import { isAborted } from "@hostwares/agent-client";
14
+ /**
15
+ * Entry point.
16
+ *
17
+ * Argument parsing is hand-rolled rather than pulled from commander. The old
18
+ * CLI depended on commander purely to register a dozen subcommands that each
19
+ * did `chat("Deploy a site named ...")` - they were natural-language prompts
20
+ * wearing a subcommand costume, and the dependency bought nothing. What is left
21
+ * here is either a real API call or a thin wrapper that says what it forwards.
22
+ */
23
+ const VERSION = readVersion();
24
+ setVersion(VERSION);
25
+ async function main(argv) {
26
+ const [cmd, ...rest] = argv;
27
+ switch (cmd) {
28
+ case undefined:
29
+ return interactive();
30
+ case "chat":
31
+ requireAuth();
32
+ return startChat({ resume: rest.includes("--resume") || rest.includes("-r") });
33
+ case "ask": {
34
+ requireAuth();
35
+ const message = rest.filter(a => !a.startsWith("-")).join(" ");
36
+ if (!message)
37
+ return startChat({});
38
+ return startChat({ oneShot: message });
39
+ }
40
+ case "login":
41
+ // --token skips the browser flow for CI and scripted installs.
42
+ if (rest[0] === "--token" && rest[1]) {
43
+ saveConfig({ apiKey: rest[1] });
44
+ ui.success("Signed in with the supplied token.");
45
+ return 0;
46
+ }
47
+ return (await login({ noBrowser: rest.includes("--no-browser") })) ? 0 : 1;
48
+ case "logout":
49
+ clearConfig();
50
+ ui.success("Signed out on this machine.");
51
+ return 0;
52
+ case "sessions":
53
+ return showSessions();
54
+ case "list":
55
+ case "ls":
56
+ requireAuth();
57
+ return listSites();
58
+ case "version":
59
+ case "--version":
60
+ case "-v":
61
+ console.log(VERSION);
62
+ return 0;
63
+ case "help":
64
+ case "--help":
65
+ case "-h":
66
+ showHelp();
67
+ return 0;
68
+ default:
69
+ // An unrecognised first word is far more likely to be a question than a
70
+ // typo'd command, so it is forwarded rather than rejected. `hw why is my
71
+ // site down` should work.
72
+ requireAuth();
73
+ return startChat({ oneShot: argv.join(" ") });
74
+ }
75
+ }
76
+ function interactive() {
77
+ showBanner();
78
+ if (!isAuthenticated()) {
79
+ ui.line(` Run ${c.bold("hw login")} to get started.`);
80
+ ui.line();
81
+ return Promise.resolve(0);
82
+ }
83
+ return startChat({});
84
+ }
85
+ function requireAuth() {
86
+ if (isAuthenticated())
87
+ return;
88
+ ui.error("Not signed in.");
89
+ ui.note("Run `hw login` first.");
90
+ process.exit(1);
91
+ }
92
+ function showBanner() {
93
+ const tag = [
94
+ ` ${c.bold("Hostwares")} ${c.dim(`v${VERSION}`)}`,
95
+ ` ${c.dim("AI DevOps in your terminal")}`,
96
+ "",
97
+ isAuthenticated()
98
+ ? ` ${c.green(glyph.tick)} ${c.dim("Signed in")}`
99
+ : ` ${c.dim(`${glyph.arrow} Not signed in`)}`,
100
+ ];
101
+ ui.line();
102
+ BANNER_LINES.forEach((art, i) => ui.line(` ${c.green(art)}${tag[i] ?? ""}`));
103
+ ui.line();
104
+ ui.line(c.dim(` Ask anything. ${c.bold("/help")} for commands, ${c.bold("!cmd")} for a shell command.`));
105
+ ui.line();
106
+ }
107
+ function showHelp() {
108
+ ui.line();
109
+ ui.line(` ${c.bold("hw")} ${c.dim("— AI DevOps in your terminal")}`);
110
+ ui.line();
111
+ const rows = [
112
+ ["hw", "Start an interactive session"],
113
+ ["hw ask \"...\"", "Ask one question and exit"],
114
+ ["hw chat --resume", "Continue this folder's last conversation"],
115
+ ["hw list", "List your deployments"],
116
+ ["hw sessions", "Sessions saved on this machine"],
117
+ ["hw login", "Sign in (--token <key> for CI)"],
118
+ ["hw logout", "Sign out on this machine"],
119
+ ["hw version", "Print the version"],
120
+ ];
121
+ for (const [name, help] of rows)
122
+ ui.line(` ${c.green(name.padEnd(20))} ${c.dim(help)}`);
123
+ ui.line();
124
+ ui.line(c.dim(" Anything else is treated as a question: hw why is my deploy failing"));
125
+ ui.line();
126
+ }
127
+ function showSessions() {
128
+ const sessions = listSessions();
129
+ if (!sessions.length) {
130
+ ui.line(c.dim(" No saved sessions yet."));
131
+ return 0;
132
+ }
133
+ ui.line();
134
+ for (const s of sessions.slice(0, 25)) {
135
+ const here = s.cwd === process.cwd() ? c.green(` ${glyph.tick} here`) : "";
136
+ ui.line(` ${s.cwd}${here}`);
137
+ ui.line(c.dim(` ${s.turnCount} turns · ${s.creditsSpent.toFixed(2)} credits`));
138
+ }
139
+ ui.line();
140
+ return 0;
141
+ }
142
+ /**
143
+ * A real API call, not a prompt in disguise.
144
+ *
145
+ * The sidebar calls these "Deployments" while the model and the route are still
146
+ * "sites"; the user-facing word follows the product.
147
+ */
148
+ async function listSites() {
149
+ try {
150
+ const cfg = getConfig();
151
+ const r = await fetch(`${cfg.baseUrl}/api/sites`, { headers: { Authorization: `Bearer ${cfg.apiKey}` } });
152
+ if (!r.ok) {
153
+ ui.error(r.status === 401 ? "Not signed in. Run `hw login`." : `Could not list deployments (HTTP ${r.status}).`);
154
+ return 1;
155
+ }
156
+ const res = await r.json();
157
+ const sites = Array.isArray(res) ? res : (res?.sites ?? []);
158
+ if (!sites.length) {
159
+ ui.line(c.dim(" No deployments yet. Ask me to deploy something to get started."));
160
+ return 0;
161
+ }
162
+ ui.line();
163
+ for (const s of sites) {
164
+ const dot = s.status === "RUNNING" ? c.green("●")
165
+ : s.status === "FAILED" ? c.red("●")
166
+ : c.yellow("●");
167
+ ui.line(` ${dot} ${c.bold((s.name ?? "unnamed").padEnd(24))} ${c.dim(s.domain ?? s.status ?? "")}`);
168
+ }
169
+ ui.line();
170
+ return 0;
171
+ }
172
+ catch (e) {
173
+ reportError(e);
174
+ return 1;
175
+ }
176
+ }
177
+ function readVersion() {
178
+ // Read from package.json rather than a literal. The banner used to hardcode
179
+ // "v1.1.0" beside a --version that read the manifest, so the two drifted the
180
+ // moment either changed - a bug that had already been fixed once for
181
+ // --version alone.
182
+ try {
183
+ const here = dirname(fileURLToPath(import.meta.url));
184
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
185
+ return pkg.version ?? "0.0.0";
186
+ }
187
+ catch {
188
+ return "0.0.0";
189
+ }
190
+ }
191
+ main(process.argv.slice(2))
192
+ .then(code => process.exit(code))
193
+ .catch(e => {
194
+ // Ctrl-C during startup is a normal exit, not a crash to report.
195
+ if (isAborted(e))
196
+ process.exit(130);
197
+ reportError(e);
198
+ process.exit(1);
199
+ });
@@ -0,0 +1,144 @@
1
+ import { createHash } from "crypto";
2
+ import { join } from "path";
3
+ import { existsSync, readFileSync, readdirSync, unlinkSync, mkdirSync } from "fs";
4
+ import { HW_DIR, writeJsonSecure } from "../config.js";
5
+ /**
6
+ * Per-project session state: which server conversation this directory is
7
+ * talking to, and a local record of the exchange.
8
+ *
9
+ * The bug this replaces was severe and silent. The old filename was:
10
+ *
11
+ * Buffer.from(cwd).toString("base64url").slice(0, 20)
12
+ *
13
+ * described in a comment as "hash the cwd". It is not a hash - it is base64 of
14
+ * the path truncated to 20 characters, which is the first FIFTEEN BYTES of the
15
+ * absolute path. Every directory under `/Users/<name>/` encodes to the same
16
+ * filename. Verified on the reporting machine: one session file existed for
17
+ * every project, its stored `cwd` was `/Users/<name>`, and each project
18
+ * silently inherited and then overwrote the previous one's conversation.
19
+ * `loadSession` never compared the stored cwd to the actual one, so nothing
20
+ * ever surfaced the mismatch.
21
+ *
22
+ * Now: sha256 of the path, and the stored cwd is verified on load.
23
+ */
24
+ const SESSION_DIR = join(HW_DIR, "sessions");
25
+ /** Bumped when the on-disk shape changes; older files are ignored, not crashed on. */
26
+ const SCHEMA_VERSION = 2;
27
+ function sessionPath(cwd) {
28
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 24);
29
+ return join(SESSION_DIR, `${hash}.json`);
30
+ }
31
+ export function newSession(cwd = process.cwd()) {
32
+ const now = new Date().toISOString();
33
+ return {
34
+ version: SCHEMA_VERSION,
35
+ cwd,
36
+ conversationId: null,
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ turns: [],
40
+ creditsSpent: 0,
41
+ };
42
+ }
43
+ /**
44
+ * Load this directory's session, or null.
45
+ *
46
+ * Returns null rather than throwing for every failure mode - a corrupt or
47
+ * foreign session file must degrade to "start fresh", never to a crash on
48
+ * startup.
49
+ *
50
+ * Note what is NOT here: the old loader scanned past assistant messages for the
51
+ * substrings "can't access your local", "outside my scope" and "out of scope",
52
+ * and silently DELETED the entire session if it found any, printing
53
+ * "↻ Fresh session (cleared stale context)". It was a workaround for a model
54
+ * that used to refuse local access; the model no longer does. It also fired on
55
+ * any legitimate discussion of those phrases, and it destroyed history with no
56
+ * backup - which is a large part of why the CLI appeared to remember nothing.
57
+ */
58
+ export function loadSession(cwd = process.cwd()) {
59
+ const file = sessionPath(cwd);
60
+ if (!existsSync(file))
61
+ return null;
62
+ try {
63
+ const data = JSON.parse(readFileSync(file, "utf8"));
64
+ if (data.version !== SCHEMA_VERSION)
65
+ return null;
66
+ // The guard the old code lacked. With a correct hash this should never
67
+ // fire, but a stale file from a renamed directory would otherwise resume
68
+ // someone else's conversation into this project.
69
+ if (data.cwd !== cwd)
70
+ return null;
71
+ if (!Array.isArray(data.turns))
72
+ return null;
73
+ return {
74
+ version: SCHEMA_VERSION,
75
+ cwd,
76
+ conversationId: typeof data.conversationId === "string" ? data.conversationId : null,
77
+ createdAt: data.createdAt ?? new Date().toISOString(),
78
+ updatedAt: data.updatedAt ?? new Date().toISOString(),
79
+ turns: data.turns.filter(isTurn),
80
+ creditsSpent: typeof data.creditsSpent === "number" ? data.creditsSpent : 0,
81
+ };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ function isTurn(t) {
88
+ return Boolean(t) && typeof t === "object"
89
+ && typeof t.content === "string"
90
+ && (t.role === "user" || t.role === "assistant");
91
+ }
92
+ /** How many turns to keep locally. The server holds the authoritative transcript. */
93
+ const MAX_LOCAL_TURNS = 200;
94
+ export function saveSession(session) {
95
+ mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
96
+ session.updatedAt = new Date().toISOString();
97
+ if (session.turns.length > MAX_LOCAL_TURNS) {
98
+ session.turns = session.turns.slice(-MAX_LOCAL_TURNS);
99
+ }
100
+ // Atomic + 0600: session files carry conversation text, and a half-written
101
+ // one loses the session.
102
+ writeJsonSecure(sessionPath(session.cwd), session);
103
+ }
104
+ /**
105
+ * Append a turn.
106
+ *
107
+ * The timestamp is set HERE, once, when the turn is recorded. The old
108
+ * autoSave() rewrote every entry with `new Date().toISOString()` on every save,
109
+ * so the whole history collapsed to the time of the last write and the record
110
+ * of when anything happened was destroyed.
111
+ */
112
+ export function appendTurn(session, role, content) {
113
+ session.turns.push({ role, content, at: new Date().toISOString() });
114
+ }
115
+ export function deleteSession(cwd = process.cwd()) {
116
+ try {
117
+ unlinkSync(sessionPath(cwd));
118
+ }
119
+ catch { /* already gone */ }
120
+ }
121
+ /** Every session on this machine, newest first. Powers `hw sessions`. */
122
+ export function listSessions() {
123
+ if (!existsSync(SESSION_DIR))
124
+ return [];
125
+ const out = [];
126
+ for (const name of readdirSync(SESSION_DIR)) {
127
+ if (!name.endsWith(".json"))
128
+ continue;
129
+ try {
130
+ const data = JSON.parse(readFileSync(join(SESSION_DIR, name), "utf8"));
131
+ if (data.version !== SCHEMA_VERSION || !data.cwd)
132
+ continue;
133
+ out.push({
134
+ cwd: data.cwd,
135
+ conversationId: data.conversationId ?? null,
136
+ updatedAt: data.updatedAt,
137
+ turnCount: Array.isArray(data.turns) ? data.turns.length : 0,
138
+ creditsSpent: data.creditsSpent ?? 0,
139
+ });
140
+ }
141
+ catch { /* skip unreadable */ }
142
+ }
143
+ return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
144
+ }
@@ -0,0 +1,242 @@
1
+ import { createInterface } from "readline";
2
+ import { c } from "./theme.js";
3
+ /**
4
+ * Single owner of stdin.
5
+ *
6
+ * This module exists because of one bug and a family of bugs behind it. The
7
+ * permission prompt used to read a keypress like this:
8
+ *
9
+ * stdin.setRawMode(true);
10
+ * stdin.once("data", d => { ... process.stdout.write(ch + "\n"); });
11
+ *
12
+ * while the REPL's readline interface was STILL attached to stdin and still in
13
+ * terminal mode. Both received the keypress, so both echoed it: pressing `y`
14
+ * once printed `yy`. A commit went in claiming to fix that; it stopped
15
+ * multi-character answers echoing but never detached readline, so the bug
16
+ * survived in the shipped build. Any leftover keystroke also stayed in
17
+ * readline's line buffer and was submitted as the user's NEXT message.
18
+ *
19
+ * The fix is not more careful escape handling - it is having exactly one reader
20
+ * at a time. `question()` and `readKey()` both go through `withExclusiveStdin`,
21
+ * which pauses the REPL interface, does its work, and resumes it. Nothing else
22
+ * in the codebase is allowed to touch `process.stdin`.
23
+ */
24
+ let repl = null;
25
+ /** Serialises overlapping reads so two prompts can never race for stdin. */
26
+ let queue = Promise.resolve();
27
+ export function createReplInterface(prompt) {
28
+ repl = createInterface({
29
+ input: process.stdin,
30
+ output: process.stdout,
31
+ prompt,
32
+ // Ctrl-C is handled explicitly via the SIGINT event so an in-flight
33
+ // request can be aborted without killing the process.
34
+ terminal: process.stdout.isTTY === true,
35
+ historySize: 500,
36
+ });
37
+ return repl;
38
+ }
39
+ export function closeRepl() {
40
+ repl?.close();
41
+ repl = null;
42
+ }
43
+ /**
44
+ * Run `fn` as the only consumer of stdin.
45
+ *
46
+ * Pausing the readline Interface is not enough on its own: in terminal mode,
47
+ * with the REPL mid `for await (... of rl)`, readline keeps its own `data`
48
+ * listener on stdin and echoes keys. Pressing `y` at a permission prompt then
49
+ * registered TWICE — once echoed by readline, once by the raw reader below —
50
+ * producing "yy" and, worse, feeding a stray "y" into the next prompt.
51
+ *
52
+ * The fix is to remove stdin's listeners entirely for the duration of `fn` and
53
+ * restore them afterwards, so the raw reader is genuinely the only consumer.
54
+ */
55
+ async function withExclusiveStdin(fn) {
56
+ const run = queue.then(async () => {
57
+ const wasRepl = repl;
58
+ const stdin = process.stdin;
59
+ // Snapshot and detach every data/keypress listener currently on stdin
60
+ // (these belong to readline). We reattach them in the finally block.
61
+ const dataListeners = stdin.listeners("data");
62
+ const keypressListeners = stdin.listeners("keypress");
63
+ wasRepl?.pause();
64
+ for (const l of dataListeners)
65
+ stdin.removeListener("data", l);
66
+ for (const l of keypressListeners)
67
+ stdin.removeListener("keypress", l);
68
+ try {
69
+ return await fn();
70
+ }
71
+ finally {
72
+ // Reattach readline's listeners, then repaint its prompt — resume() alone
73
+ // leaves the line unpainted so the terminal looks frozen until a keypress.
74
+ for (const l of dataListeners)
75
+ stdin.on("data", l);
76
+ for (const l of keypressListeners)
77
+ stdin.on("keypress", l);
78
+ if (wasRepl) {
79
+ wasRepl.resume();
80
+ }
81
+ }
82
+ });
83
+ // Keep the chain alive even if this call rejects, or one failed prompt would
84
+ // deadlock every later one.
85
+ queue = run.catch(() => undefined);
86
+ return run;
87
+ }
88
+ /**
89
+ * Read a single keypress without waiting for Enter.
90
+ *
91
+ * Returns the lowercased character, or a named key for the control sequences
92
+ * that must not be swallowed. Ctrl-C in raw mode arrives as the byte 0x03 and
93
+ * does NOT raise SIGINT - the old code treated it as "any other key", i.e. a
94
+ * silent "no", so Ctrl-C at a permission prompt neither cancelled nor exited.
95
+ */
96
+ export async function readKey(allowed) {
97
+ if (!process.stdin.isTTY) {
98
+ // Piped or CI: there is no interactive user to answer, and blocking forever
99
+ // is the worst outcome. Callers treat "" as "no answer given".
100
+ return "";
101
+ }
102
+ return withExclusiveStdin(() => new Promise(resolve => {
103
+ const stdin = process.stdin;
104
+ const wasRaw = stdin.isRaw === true;
105
+ const cleanup = () => {
106
+ stdin.removeListener("data", onData);
107
+ // Restore precisely what we found. `wasRaw || false` (the old code) is
108
+ // not the same thing: it restores raw mode whenever readline had it on,
109
+ // which is a different state from "what it was before I touched it".
110
+ if (stdin.isTTY)
111
+ stdin.setRawMode(wasRaw);
112
+ if (!wasRaw)
113
+ stdin.pause();
114
+ };
115
+ const onData = (buf) => {
116
+ const seq = buf.toString("utf8");
117
+ let key;
118
+ if (seq === "")
119
+ key = "ctrl-c";
120
+ else if (seq === "\u001b[A")
121
+ key = "up";
122
+ else if (seq === "\u001b[B")
123
+ key = "down";
124
+ else if (seq === "\u001b[C")
125
+ key = "right";
126
+ else if (seq === "\u001b[D")
127
+ key = "left";
128
+ else if (seq === "")
129
+ key = "escape";
130
+ else if (seq === "\r" || seq === "\n")
131
+ key = "enter";
132
+ else
133
+ key = seq[0]?.toLowerCase() ?? "";
134
+ // Ignore keys the caller does not accept instead of treating them as a
135
+ // decision. Pressing an arrow key should not silently mean "no".
136
+ if (allowed && !allowed.includes(key) && key !== "ctrl-c" && key !== "escape")
137
+ return;
138
+ cleanup();
139
+ // Echo exactly once, and only a real character.
140
+ if (key.length === 1)
141
+ process.stdout.write(key);
142
+ process.stdout.write("\n");
143
+ resolve(key);
144
+ };
145
+ stdin.setRawMode(true);
146
+ stdin.resume();
147
+ stdin.on("data", onData);
148
+ }));
149
+ }
150
+ /** Read a full line, with Enter to submit. Used for free-text answers. */
151
+ export async function question(prompt) {
152
+ if (!process.stdin.isTTY)
153
+ return "";
154
+ return withExclusiveStdin(() => new Promise(resolve => {
155
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
156
+ rl.question(prompt, answer => { rl.close(); resolve(answer.trim()); });
157
+ }));
158
+ }
159
+ /**
160
+ * A yes/no confirmation with a safe default.
161
+ *
162
+ * `defaultAnswer` is what Enter means. It is false everywhere a destructive
163
+ * action is involved: a user holding Enter to get through prompts must not be
164
+ * able to approve a deletion by momentum.
165
+ */
166
+ export async function confirm(prompt, defaultAnswer = false) {
167
+ const hint = defaultAnswer ? "[Y/n]" : "[y/N]";
168
+ process.stdout.write(`${prompt} ${c.dim(hint)} `);
169
+ const key = await readKey();
170
+ if (key === "ctrl-c" || key === "escape")
171
+ return false;
172
+ if (key === "enter" || key === "")
173
+ return defaultAnswer;
174
+ return key === "y";
175
+ }
176
+ /**
177
+ * Scope picker after `t` — Kiro-style: ↑↓ to navigate, ↵ to select
178
+ * Returns 0: Specific paths, 1: Complete directory, 2: Entire Tool, -1: cancelled
179
+ */
180
+ export async function pickScope(options) {
181
+ if (!process.stdin.isTTY)
182
+ return 0;
183
+ return withExclusiveStdin(() => new Promise(resolve => {
184
+ let idx = 0;
185
+ const stdin = process.stdin;
186
+ const wasRaw = stdin.isRaw === true;
187
+ const render = () => {
188
+ // Move cursor up to redraw options in place
189
+ // First render: just print, subsequent: overwrite
190
+ process.stdout.write(`\n ${c.dim("Press")} ${c.bold("(↑↓)")} ${c.dim("to navigate ·")} ${c.bold("(↵)")} ${c.dim("to select scope")}\n`);
191
+ for (let i = 0; i < options.length; i++) {
192
+ const sel = i === idx;
193
+ const arrow = sel ? c.cyan(">") : " ";
194
+ const txt = sel ? c.bold(c.cyan(options[i])) : c.dim(options[i]);
195
+ process.stdout.write(`${arrow} ${txt}\n`);
196
+ }
197
+ };
198
+ const cleanup = () => {
199
+ stdin.removeListener("data", onData);
200
+ if (stdin.isTTY)
201
+ stdin.setRawMode(wasRaw);
202
+ if (!wasRaw)
203
+ stdin.pause();
204
+ };
205
+ const onData = (buf) => {
206
+ const seq = buf.toString("utf8");
207
+ if (seq === "") {
208
+ cleanup();
209
+ resolve(-1);
210
+ return;
211
+ }
212
+ if (seq === "") {
213
+ cleanup();
214
+ resolve(-1);
215
+ return;
216
+ }
217
+ if (seq === "\u001b[A") {
218
+ idx = (idx - 1 + options.length) % options.length; // up
219
+ // Clear previous render (options.length+1 lines)
220
+ process.stdout.write(`\x1b[${options.length + 1}A\x1b[J`);
221
+ render();
222
+ return;
223
+ }
224
+ if (seq === "\u001b[B") {
225
+ idx = (idx + 1) % options.length; // down
226
+ process.stdout.write(`\x1b[${options.length + 1}A\x1b[J`);
227
+ render();
228
+ return;
229
+ }
230
+ if (seq === "\r" || seq === "\n") {
231
+ cleanup();
232
+ process.stdout.write("\n");
233
+ resolve(idx);
234
+ return;
235
+ }
236
+ };
237
+ stdin.setRawMode(true);
238
+ stdin.resume();
239
+ stdin.on("data", onData);
240
+ render();
241
+ }));
242
+ }