archbyte 0.3.4 → 0.4.0

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.
@@ -1,4 +1,5 @@
1
1
  import chalk from "chalk";
2
+ import { fileURLToPath } from "url";
2
3
  import { resolveArchitecturePath, loadArchitectureFile } from "./shared.js";
3
4
  /**
4
5
  * Export architecture diagram to Mermaid, Markdown, or JSON format.
@@ -7,11 +8,24 @@ export async function handleExport(options) {
7
8
  const diagramPath = resolveArchitecturePath(options);
8
9
  const arch = loadArchitectureFile(diagramPath);
9
10
  const format = options.format || "mermaid";
10
- const SUPPORTED_FORMATS = ["mermaid", "markdown", "json", "plantuml", "dot"];
11
+ const SUPPORTED_FORMATS = ["mermaid", "markdown", "json", "plantuml", "dot", "html"];
11
12
  if (!SUPPORTED_FORMATS.includes(format)) {
12
- console.error(chalk.red(`Unknown format: "${format}". Supported: ${SUPPORTED_FORMATS.join(", ")}`));
13
+ const formatList = SUPPORTED_FORMATS.map(f => f === "html" ? `html ${chalk.yellow("[Pro]")}` : f).join(", ");
14
+ console.error(chalk.red(`Unknown format: "${format}". Supported: ${formatList}`));
13
15
  process.exit(1);
14
16
  }
17
+ // HTML export requires Pro tier (skip in dev/local builds)
18
+ if (format === "html" && !process.env.ARCHBYTE_DEV) {
19
+ const { loadCredentials } = await import("./auth.js");
20
+ const creds = loadCredentials();
21
+ if (!creds || creds.tier !== "premium") {
22
+ console.error();
23
+ console.error(chalk.red("HTML export requires a Pro subscription."));
24
+ console.error(chalk.gray("Upgrade at https://heartbyte.io/archbyte"));
25
+ console.error();
26
+ process.exit(1);
27
+ }
28
+ }
15
29
  let output;
16
30
  switch (format) {
17
31
  case "mermaid":
@@ -26,6 +40,9 @@ export async function handleExport(options) {
26
40
  case "dot":
27
41
  output = exportDot(arch);
28
42
  break;
43
+ case "html":
44
+ output = await exportHtml(arch);
45
+ break;
29
46
  default:
30
47
  output = exportMarkdown(arch);
31
48
  break;
@@ -306,6 +323,51 @@ function exportDot(arch) {
306
323
  lines.push("}");
307
324
  return lines.join("\n");
308
325
  }
326
+ /**
327
+ * Export architecture as a self-contained interactive HTML file.
328
+ * Reads the pre-built UI assets from ui/dist/ and inlines them
329
+ * with the architecture data injected as window.__ARCHBYTE_DATA__.
330
+ */
331
+ async function exportHtml(arch) {
332
+ const fs = await import("fs");
333
+ const pathMod = await import("path");
334
+ const __filename = fileURLToPath(import.meta.url);
335
+ const __dirname = pathMod.dirname(__filename);
336
+ const uiDist = pathMod.resolve(__dirname, "../../ui/dist");
337
+ if (!fs.existsSync(uiDist)) {
338
+ console.error(chalk.red("UI build not found at " + uiDist));
339
+ console.error(chalk.gray("Run: npm run build:ui"));
340
+ process.exit(1);
341
+ }
342
+ // Read CSS and JS assets
343
+ const assetsDir = pathMod.join(uiDist, "assets");
344
+ const assetFiles = fs.readdirSync(assetsDir);
345
+ let cssContent = "";
346
+ for (const f of assetFiles.filter((f) => f.endsWith(".css"))) {
347
+ cssContent += fs.readFileSync(pathMod.join(assetsDir, f), "utf-8") + "\n";
348
+ }
349
+ let jsContent = "";
350
+ for (const f of assetFiles.filter((f) => f.endsWith(".js"))) {
351
+ jsContent += fs.readFileSync(pathMod.join(assetsDir, f), "utf-8") + "\n";
352
+ }
353
+ const projectName = process.cwd().split("/").pop() || "Architecture";
354
+ // Build HTML from scratch (avoids fragile regex on Vite template)
355
+ return `<!DOCTYPE html>
356
+ <html lang="en" data-theme="dark">
357
+ <head>
358
+ <meta charset="UTF-8">
359
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
360
+ <title>${projectName} - ArchByte Architecture</title>
361
+ <style>${cssContent}</style>
362
+ </head>
363
+ <body>
364
+ <script>try{if(localStorage.getItem('archbyte-theme')==='light')document.documentElement.setAttribute('data-theme','light')}catch(e){}</script>
365
+ <div id="root"></div>
366
+ <script>window.__ARCHBYTE_DATA__ = ${JSON.stringify(arch)};</script>
367
+ <script type="module">${jsContent}</script>
368
+ </body>
369
+ </html>`;
370
+ }
309
371
  /**
310
372
  * Sanitize an ID for use in PlantUML diagrams.
311
373
  */
@@ -1,4 +1,4 @@
1
- type GatedAction = "scan" | "analyze" | "generate";
1
+ type GatedAction = "analyze" | "generate";
2
2
  /**
3
3
  * Pre-flight license check. Must be called before scan/analyze/generate.
4
4
  *
@@ -28,8 +28,9 @@ export async function requireLicense(action) {
28
28
  console.error(chalk.gray("Free tier includes unlimited scans. No credit card required."));
29
29
  process.exit(1);
30
30
  }
31
- // Token expired locally
32
- if (new Date(creds.expiresAt) < new Date()) {
31
+ // Token expired locally (treat invalid dates as expired — fail-closed)
32
+ const expiry = new Date(creds.expiresAt);
33
+ if (isNaN(expiry.getTime()) || expiry < new Date()) {
33
34
  console.error();
34
35
  console.error(chalk.red("Session expired."));
35
36
  console.error(chalk.gray("Run `archbyte login` to refresh your session."));
package/dist/cli/mcp.js CHANGED
@@ -1,16 +1,9 @@
1
- import { execSync, spawnSync } from "child_process";
1
+ import { spawnSync } from "child_process";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import chalk from "chalk";
5
- function isInPath(cmd) {
6
- try {
7
- execSync(`which ${cmd}`, { stdio: "ignore" });
8
- return true;
9
- }
10
- catch {
11
- return false;
12
- }
13
- }
5
+ import { isInPath } from "./utils.js";
6
+ import { CONFIG_DIR } from "./constants.js";
14
7
  export async function handleMcpInstall() {
15
8
  console.log();
16
9
  console.log(chalk.bold.cyan("ArchByte MCP Setup"));
@@ -30,7 +23,7 @@ export async function handleMcpInstall() {
30
23
  console.log();
31
24
  }
32
25
  // ─── Codex CLI ───
33
- const codexDir = path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".codex");
26
+ const codexDir = path.join(CONFIG_DIR, "../.codex");
34
27
  const codexConfig = path.join(codexDir, "config.toml");
35
28
  if (fs.existsSync(codexDir)) {
36
29
  console.log(chalk.white("Detected Codex CLI."));
@@ -46,7 +39,10 @@ export async function handleMcpInstall() {
46
39
  configured = true;
47
40
  }
48
41
  else {
49
- const block = `
42
+ // Ensure a trailing newline before appending the TOML block
43
+ // so headers don't merge with the last line of the existing file.
44
+ const needsNewline = existing.length > 0 && !existing.endsWith("\n");
45
+ const block = `${needsNewline ? "\n" : ""}
50
46
  [mcp_servers.archbyte]
51
47
  type = "stdio"
52
48
  command = "npx"
package/dist/cli/setup.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { fileURLToPath } from "url";
4
- import { execSync } from "child_process";
5
4
  import chalk from "chalk";
6
5
  import { resolveModel } from "../agents/runtime/types.js";
7
6
  import { createProvider } from "../agents/providers/router.js";
8
7
  import { select, spinner, confirm } from "./ui.js";
9
8
  import { CONFIG_DIR, CONFIG_PATH } from "./constants.js";
9
+ import { isInPath, maskKey, isTTY, isValidEmail } from "./utils.js";
10
10
  const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = path.dirname(__filename);
12
12
  const PROVIDERS = [
@@ -15,6 +15,12 @@ const PROVIDERS = [
15
15
  { name: "google", label: "Google", hint: "Gemini 2.5 Pro / Flash" },
16
16
  ];
17
17
  const PROVIDER_MODELS = {
18
+ "claude-sdk": [
19
+ { id: "", label: "Default (recommended)", hint: "Sonnet for all agents" },
20
+ { id: "opus", label: "Claude Opus 4.6", hint: "Most capable" },
21
+ { id: "sonnet", label: "Claude Sonnet 4.5", hint: "Fast, great quality" },
22
+ { id: "haiku", label: "Claude Haiku 4.5", hint: "Fastest, cheapest" },
23
+ ],
18
24
  anthropic: [
19
25
  { id: "", label: "Default (recommended)", hint: "Opus for all agents" },
20
26
  { id: "claude-opus-4-6", label: "Claude Opus 4.6", hint: "Most capable" },
@@ -49,13 +55,23 @@ function saveConfig(config) {
49
55
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
50
56
  }
51
57
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
58
+ // Restrict permissions — config contains API keys in profiles
59
+ try {
60
+ fs.chmodSync(CONFIG_PATH, 0o600);
61
+ }
62
+ catch {
63
+ // Windows doesn't support chmod
64
+ }
52
65
  }
53
- function maskKey(key) {
54
- if (key.length <= 8)
55
- return "****";
56
- return key.slice(0, 6) + "..." + key.slice(-4);
57
- }
66
+ /**
67
+ * Read a line from stdin with character masking (for API keys).
68
+ * Non-TTY fallback: reads from stdin as a line (for piped input / CI).
69
+ */
58
70
  function askHidden(prompt) {
71
+ if (!isTTY()) {
72
+ process.stdout.write(prompt);
73
+ return readLineFromStdin();
74
+ }
59
75
  return new Promise((resolve) => {
60
76
  process.stdout.write(prompt);
61
77
  const stdin = process.stdin;
@@ -93,7 +109,15 @@ function askHidden(prompt) {
93
109
  stdin.on("data", onData);
94
110
  });
95
111
  }
112
+ /**
113
+ * Read a line of visible text from stdin.
114
+ * Non-TTY fallback: reads from stdin as a line (for piped input / CI).
115
+ */
96
116
  function askText(prompt) {
117
+ if (!isTTY()) {
118
+ process.stdout.write(prompt);
119
+ return readLineFromStdin();
120
+ }
97
121
  return new Promise((resolve) => {
98
122
  process.stdout.write(prompt);
99
123
  const stdin = process.stdin;
@@ -131,6 +155,32 @@ function askText(prompt) {
131
155
  stdin.on("data", onData);
132
156
  });
133
157
  }
158
+ /**
159
+ * Non-TTY line reader. Reads a single line from stdin (for piped input).
160
+ */
161
+ function readLineFromStdin() {
162
+ return new Promise((resolve) => {
163
+ const stdin = process.stdin;
164
+ stdin.resume();
165
+ stdin.setEncoding("utf8");
166
+ let buf = "";
167
+ const onData = (data) => {
168
+ buf += data;
169
+ const nl = buf.indexOf("\n");
170
+ if (nl !== -1) {
171
+ stdin.removeListener("data", onData);
172
+ stdin.pause();
173
+ resolve(buf.slice(0, nl).trim());
174
+ }
175
+ };
176
+ const onEnd = () => {
177
+ stdin.removeListener("data", onData);
178
+ resolve(buf.trim());
179
+ };
180
+ stdin.on("data", onData);
181
+ stdin.once("end", onEnd);
182
+ });
183
+ }
134
184
  async function validateProviderSilent(providerName, apiKey, model) {
135
185
  try {
136
186
  const provider = createProvider({ provider: providerName, apiKey });
@@ -157,29 +207,14 @@ async function validateProviderSilent(providerName, apiKey, model) {
157
207
  function getProfiles(config) {
158
208
  return config.profiles ?? {};
159
209
  }
160
- function isInPath(cmd) {
161
- try {
162
- execSync(`which ${cmd}`, { stdio: "ignore" });
163
- return true;
164
- }
165
- catch {
166
- return false;
167
- }
168
- }
169
210
  export async function handleSetup() {
170
211
  console.log();
171
212
  console.log(chalk.bold.cyan("ArchByte Setup"));
172
213
  console.log(chalk.gray("Configure your model provider and API key.\n"));
173
214
  // Detect AI coding tools — suggest MCP instead of BYOK
174
215
  const hasClaude = isInPath("claude");
175
- const codexDir = path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".codex");
216
+ const codexDir = path.join(CONFIG_DIR, "../.codex");
176
217
  const hasCodex = fs.existsSync(codexDir);
177
- if (hasClaude || hasCodex) {
178
- const tools = [hasClaude && "Claude Code", hasCodex && "Codex CLI"].filter(Boolean).join(" and ");
179
- console.log(chalk.cyan(` Detected ${tools} on this machine.`));
180
- console.log(chalk.white(` After setup, run `) + chalk.bold.cyan(`archbyte mcp install`) + chalk.white(` to use ArchByte from your AI tool.`));
181
- console.log();
182
- }
183
218
  const config = loadConfig();
184
219
  const profiles = getProfiles(config);
185
220
  // Migrate legacy flat config → profiles
@@ -212,6 +247,79 @@ export async function handleSetup() {
212
247
  }
213
248
  console.log();
214
249
  }
250
+ // Detect AI coding tools and offer zero-config options
251
+ if (hasClaude || hasCodex) {
252
+ const tools = [hasClaude && "Claude Code", hasCodex && "Codex CLI"].filter(Boolean).join(" and ");
253
+ console.log(chalk.cyan(` Detected ${tools} on this machine.\n`));
254
+ // Build options based on what's detected
255
+ const toolOptions = [];
256
+ if (hasClaude) {
257
+ toolOptions.push({
258
+ label: `Claude Code (SDK) ${chalk.gray("zero config — uses your Claude Code subscription")}`,
259
+ value: "claude-sdk",
260
+ });
261
+ }
262
+ if (hasCodex) {
263
+ toolOptions.push({
264
+ label: `Codex CLI ${chalk.gray("zero config — uses your Codex subscription")}`,
265
+ value: "codex",
266
+ });
267
+ }
268
+ toolOptions.push({
269
+ label: `Bring your own API key ${chalk.gray("Anthropic, OpenAI, or Google")}`,
270
+ value: "byok",
271
+ });
272
+ const toolIdx = await select("How do you want to run ArchByte?", toolOptions.map((o) => o.label));
273
+ const choice = toolOptions[toolIdx].value;
274
+ if (choice === "claude-sdk") {
275
+ config.provider = "claude-sdk";
276
+ // Model selection
277
+ const models = PROVIDER_MODELS["claude-sdk"];
278
+ const modelIdx = await select("\n Choose a model:", models.map((m) => `${m.label} ${chalk.gray(m.hint)}`));
279
+ const chosenModel = models[modelIdx];
280
+ if (chosenModel.id) {
281
+ config.model = chosenModel.id;
282
+ console.log(chalk.green(` ✓ Model: ${chosenModel.label}`));
283
+ }
284
+ else {
285
+ delete config.model;
286
+ console.log(chalk.green(` ✓ Model: Sonnet (default)`));
287
+ }
288
+ config.profiles = profiles;
289
+ delete config.apiKey;
290
+ saveConfig(config);
291
+ const dim = chalk.gray;
292
+ const sep = dim(" ───");
293
+ console.log();
294
+ console.log(chalk.bold.green(" ✓ Setup complete — using Claude Code (SDK)"));
295
+ console.log();
296
+ console.log(sep);
297
+ console.log();
298
+ console.log(dim(" No API key needed. ArchByte uses your Claude Code subscription."));
299
+ console.log(dim(" All model calls go through Claude Code on this machine."));
300
+ console.log();
301
+ console.log(sep);
302
+ console.log();
303
+ console.log(" " + chalk.bold("Next steps"));
304
+ console.log();
305
+ console.log(" " + chalk.cyan("archbyte run") + " Analyze your codebase");
306
+ if (hasCodex) {
307
+ console.log(" " + chalk.cyan("archbyte mcp install") + " Use from Codex CLI");
308
+ }
309
+ console.log(" " + chalk.cyan("archbyte status") + " Check account and usage");
310
+ console.log(" " + chalk.cyan("archbyte --help") + " See all commands");
311
+ console.log();
312
+ return;
313
+ }
314
+ if (choice === "codex") {
315
+ // TODO: Add Codex SDK provider when available
316
+ console.log(chalk.yellow("\n Codex SDK provider coming soon. Setting up with API key for now."));
317
+ console.log(chalk.gray(" In the meantime, use archbyte mcp install to run ArchByte from Codex.\n"));
318
+ }
319
+ // User chose BYOK — continue to normal provider selection below
320
+ if (choice === "byok")
321
+ console.log();
322
+ }
215
323
  // Step 1: Choose provider
216
324
  const idx = await select("Choose your model provider:", PROVIDERS.map((p) => {
217
325
  const active = config.provider === p.name ? chalk.green(" (active)") : "";
@@ -258,7 +366,7 @@ export async function handleSetup() {
258
366
  if (!profiles[provider])
259
367
  profiles[provider] = { apiKey: "" };
260
368
  profiles[provider].apiKey = apiKey;
261
- // Step 2b: Email for this provider account (optional)
369
+ // Step 2b: Email for this provider account (optional, with validation)
262
370
  const existingEmail = profiles[provider].email;
263
371
  if (existingEmail) {
264
372
  console.log(chalk.gray(`\n Account email: ${existingEmail}`));
@@ -270,16 +378,26 @@ export async function handleSetup() {
270
378
  if (emailIdx === 1) {
271
379
  const newEmail = await askText(chalk.bold(" Email: "));
272
380
  if (newEmail) {
273
- profiles[provider].email = newEmail;
274
- console.log(chalk.green(` ✓ Email: ${newEmail}`));
381
+ if (!isValidEmail(newEmail)) {
382
+ console.log(chalk.yellow(` "${newEmail}" doesn't look like a valid email. Skipping.`));
383
+ }
384
+ else {
385
+ profiles[provider].email = newEmail;
386
+ console.log(chalk.green(` ✓ Email: ${newEmail}`));
387
+ }
275
388
  }
276
389
  }
277
390
  }
278
391
  else {
279
392
  const email = await askText(chalk.bold(` ${selected.label} account email ${chalk.gray("(optional, Enter to skip)")}: `));
280
393
  if (email) {
281
- profiles[provider].email = email;
282
- console.log(chalk.green(` ✓ Email: ${email}`));
394
+ if (!isValidEmail(email)) {
395
+ console.log(chalk.yellow(` "${email}" doesn't look like a valid email. Skipping.`));
396
+ }
397
+ else {
398
+ profiles[provider].email = email;
399
+ console.log(chalk.green(` ✓ Email: ${email}`));
400
+ }
283
401
  }
284
402
  }
285
403
  // Step 3: Model selection
@@ -323,11 +441,14 @@ export async function handleSetup() {
323
441
  if (result === false) {
324
442
  let retries = 0;
325
443
  while (result === false && retries < 2) {
326
- if (!await confirm(" Retry?"))
444
+ if (!await confirm(" Retry with a different key?"))
327
445
  break;
328
446
  retries++;
329
447
  const newKey = await askHidden(chalk.bold(" API key: "));
330
- if (newKey) {
448
+ if (!newKey) {
449
+ console.log(chalk.yellow(" No key entered. Retrying with existing key."));
450
+ }
451
+ else {
331
452
  profiles[provider].apiKey = newKey;
332
453
  console.log(chalk.green(` ✓ API key: ${maskKey(newKey)}`));
333
454
  }
@@ -433,10 +554,16 @@ export async function handleSetup() {
433
554
  catch { /* ignore */ }
434
555
  }
435
556
  const templatePath = path.resolve(__dirname, "../../templates/archbyte.yaml");
436
- let template = fs.readFileSync(templatePath, "utf-8");
437
- template = template.replace("name: my-project", `name: ${projectName}`);
438
- fs.writeFileSync(yamlPath, template, "utf-8");
439
- yamlCreated = true;
557
+ if (!fs.existsSync(templatePath)) {
558
+ console.log(chalk.yellow(" Could not find archbyte.yaml template. Skipping."));
559
+ console.log(chalk.gray(` Expected at: ${templatePath}`));
560
+ }
561
+ else {
562
+ let template = fs.readFileSync(templatePath, "utf-8");
563
+ template = template.replace("name: my-project", `name: ${projectName}`);
564
+ fs.writeFileSync(yamlPath, template, "utf-8");
565
+ yamlCreated = true;
566
+ }
440
567
  }
441
568
  // Generate README.md in .archbyte/
442
569
  writeArchbyteReadme(archbyteDir);
@@ -465,15 +592,19 @@ export async function handleSetup() {
465
592
  console.log();
466
593
  console.log(sep);
467
594
  console.log();
468
- console.log(" " + chalk.bold("Next"));
595
+ console.log(" " + chalk.bold("Next steps"));
596
+ console.log();
469
597
  console.log(" " + chalk.cyan("archbyte run") + " Analyze your codebase");
470
598
  if (hasClaude || hasCodex) {
471
599
  console.log(" " + chalk.cyan("archbyte mcp install") + " Use from your AI tool");
472
600
  }
601
+ console.log(" " + chalk.cyan("archbyte status") + " Check account and usage");
602
+ console.log(" " + chalk.cyan("archbyte --help") + " See all commands");
473
603
  if (result === false) {
474
- console.log(chalk.yellow(" Warning: credentials unverified. Saving anyway."));
475
604
  console.log();
605
+ console.log(chalk.yellow(" Warning: credentials unverified. Saving anyway."));
476
606
  }
607
+ console.log();
477
608
  }
478
609
  function writeArchbyteReadme(archbyteDir) {
479
610
  const readmePath = path.join(archbyteDir, "README.md");
package/dist/cli/ui.d.ts CHANGED
@@ -8,6 +8,8 @@ export declare function spinner(label: string): Spinner;
8
8
  /**
9
9
  * Arrow-key selection menu. Returns the selected index.
10
10
  * Non-TTY fallback: returns 0 (first option).
11
+ *
12
+ * Navigation: ↑/↓ arrows to move, Enter to confirm, Ctrl+C to exit.
11
13
  */
12
14
  export declare function select(prompt: string, options: string[]): Promise<number>;
13
15
  interface ProgressBar {
@@ -23,6 +25,18 @@ export declare function progressBar(totalSteps: number): ProgressBar;
23
25
  /**
24
26
  * Y/n confirmation prompt. Returns true for y/Enter, false for n.
25
27
  * Non-TTY fallback: returns true.
28
+ *
29
+ * Only responds to explicit y/n/Enter/Ctrl+C. Ignores escape sequences
30
+ * (arrow keys, etc.) to prevent accidental confirmation.
26
31
  */
27
32
  export declare function confirm(prompt: string): Promise<boolean>;
33
+ /**
34
+ * Text input prompt. Returns the entered string.
35
+ * Non-TTY fallback: returns empty string.
36
+ *
37
+ * @param mask - If true, replaces each character with * (for passwords).
38
+ */
39
+ export declare function textInput(prompt: string, opts?: {
40
+ mask?: boolean;
41
+ }): Promise<string>;
28
42
  export {};
package/dist/cli/ui.js CHANGED
@@ -1,5 +1,26 @@
1
1
  import chalk from "chalk";
2
2
  const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
+ // ─── Cursor Safety ───
4
+ // Ensure the terminal cursor is always restored, even on unhandled crashes.
5
+ let cursorHidden = false;
6
+ function hideCursor() {
7
+ if (!cursorHidden) {
8
+ process.stdout.write("\x1b[?25l");
9
+ cursorHidden = true;
10
+ }
11
+ }
12
+ function showCursor() {
13
+ if (cursorHidden) {
14
+ process.stdout.write("\x1b[?25h");
15
+ cursorHidden = false;
16
+ }
17
+ }
18
+ // Restore cursor on any exit path
19
+ for (const event of ["exit", "SIGINT", "SIGTERM", "uncaughtException", "unhandledRejection"]) {
20
+ process.on(event, () => {
21
+ showCursor();
22
+ });
23
+ }
3
24
  /**
4
25
  * Animated braille spinner. Falls back to static console.log when not a TTY.
5
26
  */
@@ -30,6 +51,8 @@ export function spinner(label) {
30
51
  /**
31
52
  * Arrow-key selection menu. Returns the selected index.
32
53
  * Non-TTY fallback: returns 0 (first option).
54
+ *
55
+ * Navigation: ↑/↓ arrows to move, Enter to confirm, Ctrl+C to exit.
33
56
  */
34
57
  export function select(prompt, options) {
35
58
  if (!process.stdout.isTTY || options.length === 0) {
@@ -45,10 +68,9 @@ export function select(prompt, options) {
45
68
  stdin.resume();
46
69
  stdin.setEncoding("utf8");
47
70
  let selected = 0;
48
- // Hide cursor
49
- process.stdout.write("\x1b[?25l");
71
+ hideCursor();
50
72
  function render() {
51
- // Move up to clear previous render (except first time)
73
+ // Move up to clear previous render
52
74
  const lines = options.length + 1; // prompt + options
53
75
  process.stdout.write(`\x1b[${lines}A`);
54
76
  process.stdout.write(`\x1b[K ${chalk.bold(prompt)}\n`);
@@ -74,13 +96,13 @@ export function select(prompt, options) {
74
96
  }
75
97
  }
76
98
  const onData = (data) => {
77
- if (data === "\x1b[A") {
78
- // Up arrow
99
+ if (data === "\x1b[A" || data === "k") {
100
+ // Up arrow or k (vim-style)
79
101
  selected = (selected - 1 + options.length) % options.length;
80
102
  render();
81
103
  }
82
- else if (data === "\x1b[B") {
83
- // Down arrow
104
+ else if (data === "\x1b[B" || data === "j") {
105
+ // Down arrow or j (vim-style)
84
106
  selected = (selected + 1) % options.length;
85
107
  render();
86
108
  }
@@ -89,9 +111,10 @@ export function select(prompt, options) {
89
111
  cleanup();
90
112
  resolve(selected);
91
113
  }
92
- else if (data === "\x03" || data === "q" || data === "Q") {
93
- // Ctrl+C or q to quit
114
+ else if (data === "\x03" || data === "q") {
115
+ // Ctrl+C or q clean exit
94
116
  cleanup();
117
+ process.stdout.write("\n");
95
118
  process.exit(0);
96
119
  }
97
120
  };
@@ -99,8 +122,7 @@ export function select(prompt, options) {
99
122
  stdin.removeListener("data", onData);
100
123
  stdin.setRawMode(wasRaw ?? false);
101
124
  stdin.pause();
102
- // Show cursor
103
- process.stdout.write("\x1b[?25h");
125
+ showCursor();
104
126
  }
105
127
  stdin.on("data", onData);
106
128
  });
@@ -152,6 +174,9 @@ export function progressBar(totalSteps) {
152
174
  /**
153
175
  * Y/n confirmation prompt. Returns true for y/Enter, false for n.
154
176
  * Non-TTY fallback: returns true.
177
+ *
178
+ * Only responds to explicit y/n/Enter/Ctrl+C. Ignores escape sequences
179
+ * (arrow keys, etc.) to prevent accidental confirmation.
155
180
  */
156
181
  export function confirm(prompt) {
157
182
  if (!process.stdout.isTTY) {
@@ -166,6 +191,9 @@ export function confirm(prompt) {
166
191
  stdin.resume();
167
192
  stdin.setEncoding("utf8");
168
193
  const onData = (data) => {
194
+ // Ignore escape sequences (arrow keys, function keys, etc.)
195
+ if (data.startsWith("\x1b"))
196
+ return;
169
197
  stdin.removeListener("data", onData);
170
198
  stdin.setRawMode(wasRaw ?? false);
171
199
  stdin.pause();
@@ -173,15 +201,71 @@ export function confirm(prompt) {
173
201
  process.stdout.write("n\n");
174
202
  resolve(false);
175
203
  }
176
- else if (data === "\x03" || data === "q" || data === "Q") {
204
+ else if (data === "\x03" || data === "q") {
205
+ // Ctrl+C or q — clean exit
177
206
  process.stdout.write("\n");
178
207
  process.exit(0);
179
208
  }
180
- else {
181
- // y, Y, Enter — all true
209
+ else if (data === "y" || data === "Y" || data === "\r" || data === "\n") {
182
210
  process.stdout.write("y\n");
183
211
  resolve(true);
184
212
  }
213
+ // Ignore any other single keypresses — wait for y/n/Enter
214
+ };
215
+ stdin.on("data", onData);
216
+ });
217
+ }
218
+ /**
219
+ * Text input prompt. Returns the entered string.
220
+ * Non-TTY fallback: returns empty string.
221
+ *
222
+ * @param mask - If true, replaces each character with * (for passwords).
223
+ */
224
+ export function textInput(prompt, opts) {
225
+ if (!process.stdout.isTTY) {
226
+ console.log(` ${prompt}: `);
227
+ return Promise.resolve("");
228
+ }
229
+ return new Promise((resolve) => {
230
+ process.stdout.write(` ${prompt}: `);
231
+ const stdin = process.stdin;
232
+ const wasRaw = stdin.isRaw;
233
+ stdin.setRawMode(true);
234
+ stdin.resume();
235
+ stdin.setEncoding("utf8");
236
+ let value = "";
237
+ const onData = (data) => {
238
+ if (data === "\r" || data === "\n") {
239
+ // Enter — submit
240
+ stdin.removeListener("data", onData);
241
+ stdin.setRawMode(wasRaw ?? false);
242
+ stdin.pause();
243
+ process.stdout.write("\n");
244
+ resolve(value);
245
+ }
246
+ else if (data === "\x03") {
247
+ // Ctrl+C
248
+ stdin.removeListener("data", onData);
249
+ stdin.setRawMode(wasRaw ?? false);
250
+ stdin.pause();
251
+ process.stdout.write("\n");
252
+ process.exit(0);
253
+ }
254
+ else if (data === "\x7f" || data === "\b") {
255
+ // Backspace
256
+ if (value.length > 0) {
257
+ value = value.slice(0, -1);
258
+ process.stdout.write("\b \b");
259
+ }
260
+ }
261
+ else if (data.startsWith("\x1b")) {
262
+ // Ignore escape sequences
263
+ }
264
+ else if (data >= " ") {
265
+ // Printable character
266
+ value += data;
267
+ process.stdout.write(opts?.mask ? "*" : data);
268
+ }
185
269
  };
186
270
  stdin.on("data", onData);
187
271
  });