From 4f7146992fe312d0650959780b04cd995360a153 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 15 May 2026 22:57:37 +0200 Subject: [PATCH 01/11] fix: Windows terminal rendering, telemetry notice, and plugin install issues - Fix telemetry notice showing on every invocation by preserving the `noticeShown` flag when loading config from disk - Fix garbled console output on Windows PowerShell after inquirer prompts. Inquirer permanently enables VTP mode which sets DISABLE_NEWLINE_AUTO_RETURN, causing bare \n to not return cursor to column 0. The fix patches both stream writes (process.stdout/stderr.write) and console methods (console.log/error/warn) since Bun's native console bypasses the JS stream API. All inquirer prompts are now wrapped with withPromptFix(). - Fix Copilot CLI "marketplace already registered" error by detecting and skipping the duplicate gracefully instead of failing - Add pre-flight VSIX compatibility check for Cursor: reads Cursor's vscodeVersion from product.json and the VSIX engines.vscode requirement, then skips install with a clear message when incompatible - Remove noisy "Marketplace URL added to VS Code user settings" output - Capture stderr in plugin install run() helper for better error messages - Fix editor-detect.ts to use isCursorCliAvailable() for consistency Signed-off-by: Rhuan Barreto --- .../agent-memory/archgate-developer/MEMORY.md | 2 +- src/commands/adr/create.ts | 47 +++-- src/commands/adr/import.ts | 19 +- src/commands/adr/sync.ts | 27 +-- src/commands/init.ts | 80 ++++---- src/commands/plugin/install.ts | 4 - src/helpers/editor-detect.ts | 69 ++++--- src/helpers/login-flow.ts | 90 +++++---- src/helpers/plugin-install.ts | 142 ++++++++++++-- src/helpers/prompt.ts | 175 ++++++++++++++++++ src/helpers/telemetry-config.ts | 1 + tests/helpers/prompt.test.ts | 149 +++++++++++++++ tests/helpers/telemetry-config.test.ts | 21 +++ 13 files changed, 642 insertions(+), 184 deletions(-) create mode 100644 src/helpers/prompt.ts create mode 100644 tests/helpers/prompt.test.ts diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index aebc2a47..d4b2c1df 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -49,7 +49,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - **SLSA reusable workflow MUST be tag-pinned, not SHA-pinned** — `slsa-framework/slsa-github-generator/.github/workflows/*` looks like it should follow CI-001 (SHA pin), but the SLSA generator's `generate-builder.sh` reads the workflow ref to download the prebuilt builder from a GitHub release and rejects non-tag refs (`Invalid ref: ... Expected ref of the form refs/tags/vX.Y.Z`, exit 2). Pinning by SHA broke the v0.31.0 release ([run 25107195589](https://github.com/archgate/cli/actions/runs/25107195589)). The CI-001 rule allowlists this path so it does NOT block a SHA repin — meaning a future agent could "fix" the tag pin and the rule would be silent until the next release fails. ALWAYS keep `@v2.x.y` for `release-binaries.yml:165` and read the inline comment + CI-001 "Carved-out exceptions" before changing. Upstream issue: [slsa-framework/slsa-github-generator#150](https://github.com/slsa-framework/slsa-github-generator/issues/150). - **Windows binary upgrade: never use detached child processes for `.old` cleanup** — On Windows, `replaceBinary()` renames the running exe to `.old` because the OS file-locks it. Cleaning up the `.old` via a detached `cmd /c ping -n 2 ... & del` process is unreliable (process may not spawn, `del` may fail silently, timing races). Instead, `cleanupStaleBinary()` runs at the next CLI startup as a fire-and-forget `unlink()` — the file is guaranteed unlocked by then. The cleanup is platform-agnostic (uses `getArtifactInfo()` to resolve the binary name), so it works on any supported platform even though only Windows currently creates `.old` files. The sync `unlinkSync` in `replaceBinary()` is kept as defense-in-depth for leftover `.old` files from previous upgrades. Do NOT reintroduce detached cleanup processes. - **`bun:sqlite` file handles persist after `db.close()` on Windows — wrap test cleanup in try/catch** — Tests that create temp SQLite databases via `new Database(path)` will fail with `EBUSY: resource busy or locked` when `rmSync` tries to remove the temp directory in `afterEach`, even after calling `db.close()`. Windows holds the file handle briefly. Fix: (1) set `PRAGMA journal_mode = DELETE` in test DBs to avoid creating WAL/SHM files, and (2) wrap `rmSync` in `afterEach` with `try { rmSync(...) } catch { /* SQLite handles may persist */ }`. Each test must use a unique temp dir name so leftover files don't collide. -- **Inquirer prompts leave cursor at wrong column on Windows — always reset with `cursorTo`** — After an `inquirer.prompt()` call finishes (especially checkbox with long wrapped answer lines), the cursor is left at the end of the rendered answer text. On Windows terminals, `\n` moves down but does NOT reset to column 0, so all subsequent output starts at the wrong horizontal offset. Fix: call `cursorTo(process.stdout, 0)` from `node:readline` after each prompt returns, guarded by `if (process.stdout.isTTY)`. Applied in `src/helpers/editor-detect.ts` for `promptEditorSelection()` and `promptSingleEditorSelection()`. The `upgrade.ts` command already uses the same `clearLine`/`cursorTo` pattern for download progress cleanup. +- **Inquirer permanently breaks Windows console newline handling — use `withPromptFix()`** — When inquirer creates a readline interface on Windows, it enables VTP (Virtual Terminal Processing) which sets `DISABLE_NEWLINE_AUTO_RETURN`. This flag is NEVER restored after the prompt closes, so bare `\n` stops returning to column 0 for ALL subsequent output — not just during the prompt. The per-prompt `cursorTo(process.stdout, 0)` fix only addressed the cursor position after the answer line; it did NOT fix the console mode change. Root-cause fix: `src/helpers/prompt.ts` exports `withPromptFix()` which applies a permanent `process.stdout.write` patch (idempotent, applied once) that translates bare `\n` → `\r\n` via `(? inquirer.prompt([...]))`. The `upgrade.ts` command uses a separate `clearLine`/`cursorTo` pattern for download progress cleanup (not inquirer). - **`inquirer` must be lazy-loaded via dynamic `import()` — never statically imported at module level** — `inquirer` costs ~200ms to parse. Static `import inquirer from "inquirer"` at module level forces every CLI invocation to pay this cost — even `--help`, `--version`, and non-interactive commands that never prompt. Always use `const { default: inquirer } = await import("inquirer")` at the call site, inside the action handler or function that actually needs it. Applied in `src/commands/adr/create.ts`, `src/commands/init.ts`, `src/helpers/editor-detect.ts`, `src/helpers/login-flow.ts`. Same principle applies to any heavy dependency used only in specific code paths (e.g., Sentry and PostHog SDKs are already lazy-loaded in `sentry.ts` and `telemetry.ts`). - **Telemetry/Sentry init should be started eagerly but awaited lazily** — In `src/cli.ts`, `initSentry()` + `initTelemetry()` are started before command registration (so they run concurrently with setup) but only awaited in the `preAction` hook (right before the first telemetry event). This defers ~150ms of SDK parsing + git subprocess cost for `--help`/`--version` which never trigger `preAction`. The `preAction` hook is `async` to support this `await`. - **Smoke test install-script steps must find a release with uploaded assets, not just the latest tag** — On release-commit pushes to main, the Validate and Release workflows trigger concurrently. The Release workflow creates the tag/release before `release-binaries.yml` uploads platform binaries. Smoke tests that naively use `gh release view --json tagName` get the just-created release and 404 on the binary download. Fix: iterate `gh release list --limit 5` and check each release's assets for the expected artifact (`archgate-win32-x64.zip` / `archgate-linux-x64.tar.gz`) before selecting the version. Applied in `smoke-test-windows.yml` and `smoke-test-linux.yml`. diff --git a/src/commands/adr/create.ts b/src/commands/adr/create.ts index a60755d6..b551310b 100644 --- a/src/commands/adr/create.ts +++ b/src/commands/adr/create.ts @@ -1,7 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { cursorTo } from "node:readline"; - import type { Command } from "@commander-js/extra-typings"; import type { AdrDomain } from "../../formats/adr"; @@ -15,6 +13,7 @@ import { resolveDomainPrefix, resolvedProjectPaths, } from "../../helpers/project-config"; +import { withPromptFix } from "../../helpers/prompt"; export function registerAdrCreateCommand(adr: Command) { adr @@ -63,28 +62,28 @@ export function registerAdrCreateCommand(adr: Command) { // invocations or --help/--version. const { default: inquirer } = await import("inquirer"); // Interactive mode - const answers = await inquirer.prompt([ - { - type: "list", - name: "domain", - message: "Domain:", - choices: choices.map((d) => ({ name: d, value: d })), - }, - { - type: "input", - name: "title", - message: "Title:", - validate: (input: string) => - input.trim() !== "" || "Title is required", - }, - { - type: "input", - name: "files", - message: "File patterns (comma-separated, optional):", - }, - ]); - // Windows cursor-reset — see editor-detect.ts for explanation. - if (process.stdout.isTTY) cursorTo(process.stdout, 0); + const answers = await withPromptFix(() => + inquirer.prompt([ + { + type: "list", + name: "domain", + message: "Domain:", + choices: choices.map((d) => ({ name: d, value: d })), + }, + { + type: "input", + name: "title", + message: "Title:", + validate: (input: string) => + input.trim() !== "" || "Title is required", + }, + { + type: "input", + name: "files", + message: "File patterns (comma-separated, optional):", + }, + ]) + ); domain = answers.domain as AdrDomain; title = answers.title; diff --git a/src/commands/adr/import.ts b/src/commands/adr/import.ts index 2a840a3b..56e0aa36 100644 --- a/src/commands/adr/import.ts +++ b/src/commands/adr/import.ts @@ -26,6 +26,7 @@ import { getMergedDomainPrefixes, resolvedProjectPaths, } from "../../helpers/project-config"; +import { withPromptFix } from "../../helpers/prompt"; import { resolveSource, shallowClone, @@ -372,14 +373,16 @@ export function registerAdrImportCommand(adr: Command) { if (!opts.yes) { const { default: inquirer } = await import("inquirer"); - const { confirm } = await inquirer.prompt([ - { - type: "confirm", - name: "confirm", - message: `Import ${adrsToImport.length} ADR(s)?`, - default: true, - }, - ]); + const { confirm } = await withPromptFix(() => + inquirer.prompt([ + { + type: "confirm", + name: "confirm", + message: `Import ${adrsToImport.length} ADR(s)?`, + default: true, + }, + ]) + ); if (!confirm) { console.log("Import cancelled."); cleanup(tempDirs); diff --git a/src/commands/adr/sync.ts b/src/commands/adr/sync.ts index 409ef41a..bf9351da 100644 --- a/src/commands/adr/sync.ts +++ b/src/commands/adr/sync.ts @@ -15,6 +15,7 @@ import { logDebug, logError, logWarn } from "../../helpers/log"; import { formatJSON, isAgentContext } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; import { resolvedProjectPaths } from "../../helpers/project-config"; +import { withPromptFix } from "../../helpers/prompt"; import { resolveSource, shallowClone } from "../../helpers/registry"; // ---------- Types ---------- @@ -398,18 +399,20 @@ export function registerAdrSyncCommand(adr: Command) { // oxlint-disable-next-line no-await-in-loop -- sequential interactive prompts const { default: inquirer } = await import("inquirer"); // oxlint-disable-next-line no-await-in-loop -- sequential interactive prompts - const { choice } = await inquirer.prompt([ - { - type: "list", - name: "choice", - message: `${diff.adrId}: What would you like to do?`, - choices: [ - { name: "Keep local", value: "keep" }, - { name: "Take upstream", value: "take" }, - { name: "Skip", value: "skip" }, - ], - }, - ]); + const { choice } = await withPromptFix(() => + inquirer.prompt([ + { + type: "list", + name: "choice", + message: `${diff.adrId}: What would you like to do?`, + choices: [ + { name: "Keep local", value: "keep" }, + { name: "Take upstream", value: "take" }, + { name: "Skip", value: "skip" }, + ], + }, + ]) + ); action = choice; } diff --git a/src/commands/init.ts b/src/commands/init.ts index ea374848..47090df7 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -2,7 +2,6 @@ // Copyright 2026 Archgate import { existsSync } from "node:fs"; import { join } from "node:path"; -import { cursorTo } from "node:readline"; import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; @@ -15,6 +14,7 @@ import { EDITOR_LABELS, initProject } from "../helpers/init-project"; import type { EditorTarget } from "../helpers/init-project"; import { logError, logInfo, logWarn } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; +import { withPromptFix } from "../helpers/prompt"; import { getRepoContext, isPublicRepo, @@ -93,17 +93,17 @@ export function registerInitCommand(program: Command) { // Lazy-load inquirer — it costs ~200ms to parse and is only needed // for interactive prompts, not for scripted or --help invocations. const { default: inquirer } = await import("inquirer"); - const { wantPlugin } = await inquirer.prompt([ - { - type: "confirm", - name: "wantPlugin", - message: - "Would you like to install the Archgate editor plugin? (requires GitHub login)", - default: true, - }, - ]); - // Windows cursor-reset — see editor-detect.ts for explanation. - if (process.stdout.isTTY) cursorTo(process.stdout, 0); + const { wantPlugin } = await withPromptFix(() => + inquirer.prompt([ + { + type: "confirm", + name: "wantPlugin", + message: + "Would you like to install the Archgate editor plugin? (requires GitHub login)", + default: true, + }, + ]) + ); if (wantPlugin) { const result = await runLoginFlow({ @@ -216,20 +216,20 @@ async function runGreenfieldWizard(projectRoot: string): Promise { trackGreenfieldWizardShown(); console.log(""); - const { wantPacks } = await inquirer.prompt([ - { - type: "list", - name: "wantPacks", - message: - "No existing ADRs detected. Would you like to import starter packs?", - choices: [ - { name: "Yes, pick packs now (recommended)", value: true }, - { name: "No, start empty", value: false }, - ], - }, - ]); - // Windows cursor-reset — see editor-detect.ts for explanation. - if (process.stdout.isTTY) cursorTo(process.stdout, 0); + const { wantPacks } = await withPromptFix(() => + inquirer.prompt([ + { + type: "list", + name: "wantPacks", + message: + "No existing ADRs detected. Would you like to import starter packs?", + choices: [ + { name: "Yes, pick packs now (recommended)", value: true }, + { name: "No, start empty", value: false }, + ], + }, + ]) + ); if (!wantPacks) { trackWizardSkipped(); @@ -263,20 +263,20 @@ async function runGreenfieldWizard(projectRoot: string): Promise { return; } - const { selectedPacks } = await inquirer.prompt([ - { - type: "checkbox", - name: "selectedPacks", - message: "Select packs to import:", - choices: recommendations.map((rec) => ({ - name: `${rec.packPath.padEnd(30)} ${String(rec.adrCount).padStart(2)} ADRs (${rec.matchedTags.join(", ")})`, - value: rec.packPath, - checked: rec.relevance === "high", - })), - }, - ]); - // Windows cursor-reset - if (process.stdout.isTTY) cursorTo(process.stdout, 0); + const { selectedPacks } = await withPromptFix(() => + inquirer.prompt([ + { + type: "checkbox", + name: "selectedPacks", + message: "Select packs to import:", + choices: recommendations.map((rec) => ({ + name: `${rec.packPath.padEnd(30)} ${String(rec.adrCount).padStart(2)} ADRs (${rec.matchedTags.join(", ")})`, + value: rec.packPath, + checked: rec.relevance === "high", + })), + }, + ]) + ); if (selectedPacks.length === 0) { console.log("No packs selected."); diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index a8e456e7..5584cee3 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -110,10 +110,6 @@ async function installForEditor( case "vscode": { const url = buildVscodeMarketplaceUrl(); await configureVscodeSettings(findProjectRoot() ?? process.cwd(), url); - logInfo( - `Archgate plugin configured for ${label}.`, - "Marketplace URL added to VS Code user settings." - ); if (await isVscodeCliAvailable()) { await installVscodeExtension(token); logInfo(`Archgate extension installed for ${label}.`); diff --git a/src/helpers/editor-detect.ts b/src/helpers/editor-detect.ts index 52e2d367..303ed737 100644 --- a/src/helpers/editor-detect.ts +++ b/src/helpers/editor-detect.ts @@ -7,18 +7,17 @@ * In non-TTY (agent) contexts, defaults to "claude" for backward compatibility. */ -import { cursorTo } from "node:readline"; - import { EDITOR_LABELS } from "./init-project"; import type { EditorTarget } from "./init-project"; import { logDebug } from "./log"; -import { resolveCommand } from "./platform"; import { isClaudeCliAvailable, isCopilotCliAvailable, + isCursorCliAvailable, isOpencodeCliAvailable, isVscodeCliAvailable, } from "./plugin-install"; +import { withPromptFix } from "./prompt"; /** Result of editor availability detection. */ export interface DetectedEditor { @@ -35,7 +34,7 @@ export async function detectEditors(): Promise { logDebug("Detecting available editor CLIs"); const [claude, cursor, vscode, copilot, opencode] = await Promise.all([ isClaudeCliAvailable(), - resolveCommand("cursor").then((r) => r !== null), + isCursorCliAvailable(), isVscodeCliAvailable(), isCopilotCliAvailable(), isOpencodeCliAvailable(), @@ -70,24 +69,22 @@ export async function promptEditorSelection( // Lazy-load inquirer — it costs ~200ms to parse and is only needed when // the user is interactively prompted, not on every CLI startup. const { default: inquirer } = await import("inquirer"); - const { selected } = await inquirer.prompt([ - { - type: "checkbox", - name: "selected", - message: "Select editors to configure:", - choices: detected.map((e) => ({ - name: e.available ? `${e.label} (detected)` : `${e.label}`, - value: e.id, - checked: e.available, - })), - validate: (input: EditorTarget[]) => - input.length > 0 || "Select at least one editor.", - }, - ]); - // On Windows, inquirer leaves the cursor at the end of the wrapped answer - // line. Subsequent output calls inherit that column offset instead of - // starting at column 0. Explicitly reset the cursor to prevent garbled output. - if (process.stdout.isTTY) cursorTo(process.stdout, 0); + const { selected } = await withPromptFix(() => + inquirer.prompt([ + { + type: "checkbox", + name: "selected", + message: "Select editors to configure:", + choices: detected.map((e) => ({ + name: e.available ? `${e.label} (detected)` : `${e.label}`, + value: e.id, + checked: e.available, + })), + validate: (input: EditorTarget[]) => + input.length > 0 || "Select at least one editor.", + }, + ]) + ); return selected; } @@ -102,19 +99,19 @@ export async function promptSingleEditorSelection( const available = detected.filter((e) => e.available); const defaultEditor = available.length > 0 ? available[0].id : "claude"; - const { selected } = await inquirer.prompt([ - { - type: "list", - name: "selected", - message: "Select editor:", - choices: detected.map((e) => ({ - name: e.available ? `${e.label} (detected)` : e.label, - value: e.id, - })), - default: defaultEditor, - }, - ]); - // Same Windows cursor-reset fix as promptEditorSelection above. - if (process.stdout.isTTY) cursorTo(process.stdout, 0); + const { selected } = await withPromptFix(() => + inquirer.prompt([ + { + type: "list", + name: "selected", + message: "Select editor:", + choices: detected.map((e) => ({ + name: e.available ? `${e.label} (detected)` : e.label, + value: e.id, + })), + default: defaultEditor, + }, + ]) + ); return selected; } diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 743f5772..244c9b6a 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -5,17 +5,8 @@ * used by both `login` and `init` commands. */ -import { cursorTo } from "node:readline"; import { styleText } from "node:util"; -/** - * Reset cursor to column 0 after an inquirer prompt on Windows. - * See editor-detect.ts for the full explanation of the bug. - */ -function resetCursor(): void { - if (process.stdout.isTTY) cursorTo(process.stdout, 0); -} - import { requestDeviceCode, pollForAccessToken, @@ -24,6 +15,7 @@ import { } from "./auth"; import { saveCredentials } from "./credential-store"; import { logDebug, logError, logInfo } from "./log"; +import { withPromptFix } from "./prompt"; import { SignupRequiredError, requestSignup } from "./signup"; export interface LoginFlowOptions { @@ -125,50 +117,54 @@ async function runSignupPrompt( // Lazy-load inquirer — it costs ~200ms to parse and is only needed for // interactive signup prompts, not on every CLI startup. const { default: inquirer } = await import("inquirer"); - const { email } = await inquirer.prompt({ - type: "input", - name: "email", - message: "Email address:", - default: githubEmail ?? undefined, - validate: (v: string) => - /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(v) || "Enter a valid email address", - }); - resetCursor(); + const { email } = await withPromptFix(() => + inquirer.prompt({ + type: "input", + name: "email", + message: "Email address:", + default: githubEmail ?? undefined, + validate: (v: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(v) || "Enter a valid email address", + }) + ); let editor = preselectedEditor; if (!editor) { - const ans = await inquirer.prompt({ - type: "list", - name: "editor", - message: "Which editor will you use with archgate?", - choices: [ - { name: "Claude Code", value: "claude-code" }, - { name: "VS Code", value: "vscode" }, - { name: "Copilot CLI", value: "copilot-cli" }, - { name: "Cursor", value: "cursor" }, - ], - }); - resetCursor(); + const ans = await withPromptFix(() => + inquirer.prompt({ + type: "list", + name: "editor", + message: "Which editor will you use with archgate?", + choices: [ + { name: "Claude Code", value: "claude-code" }, + { name: "VS Code", value: "vscode" }, + { name: "Copilot CLI", value: "copilot-cli" }, + { name: "Cursor", value: "cursor" }, + ], + }) + ); editor = ans.editor; } - const { useCase } = await inquirer.prompt({ - type: "input", - name: "useCase", - message: "How do you plan to use archgate?", - validate: (v: string) => - v.trim().length > 0 || "Please describe your use case", - }); - resetCursor(); - - const { confirmed } = await inquirer.prompt({ - type: "confirm", - name: "confirmed", - message: - "I agree to be contacted by the Archgate team to provide feedback during the beta period.", - default: true, - }); - resetCursor(); + const { useCase } = await withPromptFix(() => + inquirer.prompt({ + type: "input", + name: "useCase", + message: "How do you plan to use archgate?", + validate: (v: string) => + v.trim().length > 0 || "Please describe your use case", + }) + ); + + const { confirmed } = await withPromptFix(() => + inquirer.prompt({ + type: "confirm", + name: "confirmed", + message: + "I agree to be contacted by the Archgate team to provide feedback during the beta period.", + default: true, + }) + ); if (!confirmed) { logInfo("Signup cancelled."); diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index b6afb944..7a2ae74e 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -2,11 +2,12 @@ // Copyright 2026 Archgate /** Download and install the archgate plugin for supported editors. */ -import { mkdirSync, unlinkSync } from "node:fs"; +import { existsSync, mkdirSync, unlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; import { logDebug } from "./log"; import { internalPath, opencodeAgentsDir } from "./paths"; -import { resolveCommand } from "./platform"; +import { isWindows, resolveCommand } from "./platform"; const PLUGINS_API = "https://plugins.archgate.dev"; @@ -21,20 +22,99 @@ const CURSOR_MARKETPLACE_URL = /** * Run a command using Bun.spawn (cross-platform, no shell). - * Returns { exitCode, stdout }. + * Returns { exitCode, stdout, stderr }. */ async function run( cmd: string[], opts?: { cwd?: string } -): Promise<{ exitCode: number; stdout: string }> { +): Promise<{ exitCode: number; stdout: string; stderr: string }> { const proc = Bun.spawn(cmd, { cwd: opts?.cwd, stdout: "pipe", stderr: "pipe", }); - const stdout = await new Response(proc.stdout).text(); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); const exitCode = await proc.exited; - return { exitCode, stdout }; + return { exitCode, stdout, stderr }; +} + +// --------------------------------------------------------------------------- +// VSIX compatibility helpers +// --------------------------------------------------------------------------- + +/** + * Read Cursor's underlying VS Code engine version from its `product.json`. + * + * The path is derived from the resolved `cursor` binary: + * .../cursor/resources/app/bin/cursor[.cmd] + * → .../cursor/resources/app/product.json + * + * Returns null when the version cannot be determined (missing file, no + * cursor on PATH, unexpected directory layout). + */ +export async function getCursorVscodeVersion(): Promise { + try { + const cursorBin = await resolveCommand("cursor"); + if (!cursorBin) return null; + + // On Windows Bun.which returns the native path (e.g. + // C:\Users\...\cursor\resources\app\bin\cursor.cmd). + // On Unix it may return a symlink target or similar. + // Navigate from bin/ up to the app directory. + const binDir = dirname(cursorBin); // .../resources/app/bin + const appDir = dirname(binDir); // .../resources/app + const productPath = join(appDir, "product.json"); + + if (!existsSync(productPath)) return null; + + const product = await Bun.file(productPath).json(); + if (typeof product.vscodeVersion === "string") { + return product.vscodeVersion; + } + return null; + } catch { + return null; + } +} + +/** + * Read the `engines.vscode` field from a downloaded VSIX file. + * + * The VSIX is a ZIP containing `extension/package.json`. We extract just + * that entry using platform tools (PowerShell on Windows, unzip on Unix). + * + * Returns the raw `engines.vscode` string (e.g. "^1.96.0") or null if + * the field cannot be read. + */ +async function readVsixEngineVersion(vsixPath: string): Promise { + try { + const cmd = isWindows() + ? buildPowershellZipReadCmd(vsixPath) + : ["unzip", "-p", vsixPath, "extension/package.json"]; + + const { stdout } = await run(cmd); + if (!stdout.trim()) return null; + const pkg = JSON.parse(stdout) as { engines?: { vscode?: string } }; + return pkg.engines?.vscode ?? null; + } catch { + return null; + } +} + +/** Build a PowerShell command to read a single entry from a ZIP file. */ +function buildPowershellZipReadCmd(vsixPath: string): string[] { + const script = [ + "Add-Type -AssemblyName System.IO.Compression.FileSystem;", + `$z=[System.IO.Compression.ZipFile]::OpenRead('${vsixPath}');`, + "try{", + "$e=$z.Entries|Where-Object{$_.FullName -eq 'extension/package.json'};", + "if($e){$r=New-Object System.IO.StreamReader($e.Open());$r.ReadToEnd();$r.Close()}", + "}finally{$z.Dispose()}", + ].join(""); + return ["powershell", "-NoProfile", "-Command", script]; } // --------------------------------------------------------------------------- @@ -180,14 +260,44 @@ export async function installCursorPlugin(token: string): Promise { ); await Bun.write(vsixPath, buffer); + // --- Pre-flight compatibility check --- + // Cursor is a VS Code fork pinned to a specific engine version. + // If the VSIX targets a newer engine, `cursor --install-extension` will + // reject it. Detect this upfront so we skip the noisy failed attempt. + const cursorVscode = await getCursorVscodeVersion(); + const requiredVscode = await readVsixEngineVersion(vsixPath); + logDebug("Cursor VS Code version:", cursorVscode ?? "unknown"); + logDebug("VSIX engines.vscode:", requiredVscode ?? "unknown"); + + if ( + cursorVscode && + requiredVscode && + !Bun.semver.satisfies(cursorVscode, requiredVscode) + ) { + // Clean up the VSIX — it can't be installed in this Cursor version + try { + unlinkSync(vsixPath); + } catch { + // Ignore cleanup errors + } + throw new Error( + `The archgate extension requires VS Code ${requiredVscode} but ` + + `Cursor is based on VS Code ${cursorVscode}. ` + + `Update Cursor or wait for a compatible extension release.` + ); + } + const cursorCmd = (await resolveCommand("cursor")) ?? "cursor"; logDebug("Installing VS Code extension in Cursor via cursor CLI"); const result = await run([cursorCmd, "--install-extension", vsixPath]); if (result.exitCode !== 0) { - // Keep the VSIX on disk so the user can install it manually + // Keep the VSIX on disk so the user can install it manually. + // Include stderr detail so the user knows why the install failed. + const detail = (result.stderr || result.stdout).trim(); throw new Error( - `cursor --install-extension failed (exit ${result.exitCode}). ` + - `The VSIX was saved to ${vsixPath} — install it manually in Cursor: ` + + `cursor --install-extension failed (exit ${result.exitCode}).` + + (detail ? ` ${detail}.` : "") + + ` The VSIX was saved to ${vsixPath} — install it manually in Cursor: ` + `Ctrl+Shift+P → "Extensions: Install from VSIX..."` ); } @@ -287,9 +397,17 @@ export async function installCopilotPlugin(): Promise { logDebug("Adding archgate marketplace to copilot CLI"); const addResult = await run([cmd, "plugin", "marketplace", "add", url]); if (addResult.exitCode !== 0) { - throw new Error( - `copilot plugin marketplace add failed (exit ${addResult.exitCode})` - ); + // "already registered" is not an error — the marketplace was added in a + // previous run. Skip and proceed to install. + const combined = addResult.stdout + addResult.stderr; + if (!combined.includes("already registered")) { + const detail = combined.trim(); + throw new Error( + `copilot plugin marketplace add failed (exit ${addResult.exitCode})` + + (detail ? `\n${detail}` : "") + ); + } + logDebug("Marketplace already registered, skipping add"); } logDebug("Installing archgate plugin via copilot CLI"); diff --git a/src/helpers/prompt.ts b/src/helpers/prompt.ts new file mode 100644 index 00000000..8cca6c88 --- /dev/null +++ b/src/helpers/prompt.ts @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * prompt.ts — Windows terminal fix for inquirer prompts. + * + * When inquirer creates a readline interface on Windows, it enables Virtual + * Terminal Processing (VTP) on the console output handle. VTP mode sets the + * `DISABLE_NEWLINE_AUTO_RETURN` flag, which causes bare `\n` (LF) to move the + * cursor down WITHOUT returning to column 0. Crucially, inquirer never restores + * the original console mode after the prompt closes — so ALL subsequent output + * is affected, not just the prompt itself. + * + * The fix has two parts: + * + * 1. **Stream-level:** Patch `process.stdout.write` and `process.stderr.write` + * to translate bare `\n` → `\r\n`. This fixes inquirer's own rendering + * (which writes through the JS stream API). + * + * 2. **Console-level:** Redirect `console.log`, `console.error`, `console.warn`, + * `console.info`, and `console.debug` through the patched stream writes. + * Bun's native console methods bypass `process.stdout.write` entirely + * (writing directly to the file descriptor for performance), so the + * stream-level patch alone cannot fix them. + * + * Both patches are applied **once** (idempotent) and persist for the lifetime + * of the process. + * + * `withPromptFix()` ensures the patches are active before running a prompt and + * resets the cursor to column 0 afterward (another quirk where the cursor is + * left at a non-zero column after a prompt answer is rendered). + */ + +import { cursorTo } from "node:readline"; +import { format } from "node:util"; + +import { isWindows } from "./platform"; + +// --------------------------------------------------------------------------- +// LF → CRLF translation +// --------------------------------------------------------------------------- + +/** Regex that matches bare LF (not preceded by CR). */ +const BARE_LF = /(? void)?, + ]); + } as typeof stream.write; +} + +// --------------------------------------------------------------------------- +// Console-level patch +// --------------------------------------------------------------------------- + +/** + * Redirect `console.log`, `.info`, `.error`, `.warn`, and `.debug` through + * `process.stdout.write` / `process.stderr.write` so the stream-level + * `\n` → `\r\n` translation applies to them as well. + * + * Bun's native console methods write directly to the file descriptor for + * performance, bypassing the JavaScript stream API entirely. Without this + * redirect, patching `process.stdout.write` has no effect on `console.log`. + */ +function patchConsoleMethods(): void { + // stdout-bound methods + const stdoutMethods = ["log", "info"] as const; + for (const method of stdoutMethods) { + console[method] = ((...args: unknown[]) => { + process.stdout.write(format(...args) + "\n"); + }) as typeof console.log; + } + + // stderr-bound methods + const stderrMethods = ["error", "warn", "debug"] as const; + for (const method of stderrMethods) { + console[method] = ((...args: unknown[]) => { + process.stderr.write(format(...args) + "\n"); + }) as typeof console.error; + } +} + +// --------------------------------------------------------------------------- +// High-level wrapper +// --------------------------------------------------------------------------- + +/** + * Execute an async function (typically an `inquirer.prompt()` call) with the + * Windows newline fix active. The patches are applied once and persist — they + * are NOT removed after the prompt because inquirer permanently changes the + * console mode. After the function resolves, the cursor is reset to column 0 + * (another Windows quirk where the cursor is left at a non-zero column after + * a prompt answer is rendered). + * + * On non-Windows platforms only the cursor reset is applied. + */ +export async function withPromptFix(fn: () => Promise): Promise { + ensureStdoutNewlinePatch(); + const result = await fn(); + resetCursor(); + return result; +} + +// --------------------------------------------------------------------------- +// Cursor reset +// --------------------------------------------------------------------------- + +/** + * Reset the cursor to column 0 if stdout is a TTY. + * Useful after inquirer prompts that leave the cursor at a non-zero column. + */ +function resetCursor(): void { + if (process.stdout.isTTY) cursorTo(process.stdout, 0); +} + +// --------------------------------------------------------------------------- +// Testing helpers +// --------------------------------------------------------------------------- + +/** Reset the patch state. For testing only. */ +export function _resetPatchState(): void { + patched = false; +} diff --git a/src/helpers/telemetry-config.ts b/src/helpers/telemetry-config.ts index 7d233402..9dc8c36a 100644 --- a/src/helpers/telemetry-config.ts +++ b/src/helpers/telemetry-config.ts @@ -94,6 +94,7 @@ export function loadTelemetryConfig(): TelemetryConfig { telemetry: parsed.telemetry, installId: parsed.installId, createdAt: parsed.createdAt ?? new Date().toISOString(), + noticeShown: parsed.noticeShown, }; logDebug("Telemetry config loaded:", cachedConfig.installId); return cachedConfig; diff --git a/tests/helpers/prompt.test.ts b/tests/helpers/prompt.test.ts new file mode 100644 index 00000000..b7289c2c --- /dev/null +++ b/tests/helpers/prompt.test.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test, afterEach, mock } from "bun:test"; + +import { withPromptFix, _resetPatchState } from "../../src/helpers/prompt"; + +// Save the original write so we can verify restoration. +const originalWrite = process.stdout.write; + +afterEach(() => { + // Safety: ensure process.stdout.write is always restored even if a test fails. + process.stdout.write = originalWrite; + _resetPatchState(); + mock.restore(); +}); + +describe("ensureStdoutNewlinePatch", () => { + test("replaces bare LF with CRLF on Windows", () => { + // Force isWindows() to return true by mocking the platform module. + mock.module("../../src/helpers/platform", () => ({ + isWindows: () => true, + isMacOS: () => false, + isLinux: () => false, + isWSL: () => false, + getPlatformInfo: () => ({ + runtime: "win32" as const, + isWSL: false, + wslDistro: null, + }), + })); + + // Re-import to pick up mock + const { + ensureStdoutNewlinePatch: ensurePatch, + _resetPatchState: resetPatch, + } = require("../../src/helpers/prompt"); + resetPatch(); + + const written: string[] = []; + process.stdout.write = ((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + ensurePatch(); + + // Call the patched write — bare \n should become \r\n + process.stdout.write("hello\nworld\n"); + + expect(written).toEqual(["hello\r\nworld\r\n"]); + }); + + test("does not double-replace existing CRLF", () => { + mock.module("../../src/helpers/platform", () => ({ + isWindows: () => true, + isMacOS: () => false, + isLinux: () => false, + isWSL: () => false, + getPlatformInfo: () => ({ + runtime: "win32" as const, + isWSL: false, + wslDistro: null, + }), + })); + + const { + ensureStdoutNewlinePatch: ensurePatch, + _resetPatchState: resetPatch, + } = require("../../src/helpers/prompt"); + resetPatch(); + + const written: string[] = []; + process.stdout.write = ((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + ensurePatch(); + process.stdout.write("line1\r\nline2\n"); + + // \r\n stays as \r\n, bare \n becomes \r\n + expect(written).toEqual(["line1\r\nline2\r\n"]); + }); + + test("is a no-op on non-Windows platforms", () => { + mock.module("../../src/helpers/platform", () => ({ + isWindows: () => false, + isMacOS: () => true, + isLinux: () => false, + isWSL: () => false, + getPlatformInfo: () => ({ + runtime: "darwin" as const, + isWSL: false, + wslDistro: null, + }), + })); + + const { + ensureStdoutNewlinePatch: ensurePatch, + _resetPatchState: resetPatch, + } = require("../../src/helpers/prompt"); + resetPatch(); + + ensurePatch(); + + // Write should not have been replaced + expect(process.stdout.write).toBe(originalWrite); + }); + + test("is idempotent — second call does not re-patch", () => { + mock.module("../../src/helpers/platform", () => ({ + isWindows: () => true, + isMacOS: () => false, + isLinux: () => false, + isWSL: () => false, + getPlatformInfo: () => ({ + runtime: "win32" as const, + isWSL: false, + wslDistro: null, + }), + })); + + const { + ensureStdoutNewlinePatch: ensurePatch, + _resetPatchState: resetPatch, + } = require("../../src/helpers/prompt"); + resetPatch(); + + ensurePatch(); + const afterFirstPatch = process.stdout.write; + + ensurePatch(); + // Second call should be a no-op — same patched function + expect(process.stdout.write).toBe(afterFirstPatch); + }); +}); + +describe("withPromptFix", () => { + test("returns the value from the wrapped function", async () => { + const result = await withPromptFix(() => Promise.resolve(42)); + expect(result).toBe(42); + }); + + test("propagates errors from the wrapped function", async () => { + await expect( + withPromptFix(() => Promise.reject(new Error("boom"))) + ).rejects.toThrow("boom"); + }); +}); diff --git a/tests/helpers/telemetry-config.test.ts b/tests/helpers/telemetry-config.test.ts index f3f1fc80..414f253f 100644 --- a/tests/helpers/telemetry-config.test.ts +++ b/tests/helpers/telemetry-config.test.ts @@ -76,6 +76,27 @@ describe("telemetry-config", () => { expect(config.installId).toBe("test-uuid-1234"); }); + test("preserves noticeShown flag from disk", async () => { + const { mkdirSync } = await import("node:fs"); + const configDir = join(tempDir, ".archgate"); + mkdirSync(configDir, { recursive: true }); + await Bun.write( + join(configDir, "config.json"), + JSON.stringify({ + telemetry: true, + installId: "test-uuid-5678", + createdAt: "2026-01-01T00:00:00.000Z", + noticeShown: true, + }) + ); + + const { loadTelemetryConfig } = + await import("../../src/helpers/telemetry-config"); + + const config = loadTelemetryConfig(); + expect(config.noticeShown).toBe(true); + }); + test("creates new config when file is malformed", async () => { const { mkdirSync } = await import("node:fs"); const configDir = join(tempDir, ".archgate"); From 3659a80b3f5d89d56d3fe2a56490f696c0f4a58a Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 15 May 2026 23:07:53 +0200 Subject: [PATCH 02/11] fix: drop Cursor VSIX install (incompatible engine version) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor is pinned to VS Code 1.105.1 while the archgate VSIX targets a newer engine. Rather than attempting the install and failing with a stack trace, skip Cursor entirely and show a clear message. The VSIX compatibility machinery (getCursorVscodeVersion, readVsixEngineVersion, PowerShell ZIP extraction) is removed — it was over-engineered for what is simply "not supported yet." Signed-off-by: Rhuan Barreto --- src/commands/plugin/install.ts | 30 ++---- src/helpers/init-project.ts | 29 +----- src/helpers/plugin-install.ts | 165 +-------------------------------- 3 files changed, 15 insertions(+), 209 deletions(-) diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 5584cee3..19d8ce4f 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -18,15 +18,12 @@ import { findProjectRoot } from "../../helpers/paths"; import { buildMarketplaceUrl, buildVscodeMarketplaceUrl, - downloadVsix, installClaudePlugin, installCopilotPlugin, - installCursorPlugin, installOpencodePlugin, installVscodeExtension, isClaudeCliAvailable, isCopilotCliAvailable, - isCursorCliAvailable, isOpencodeCliAvailable, isVscodeCliAvailable, } from "../../helpers/plugin-install"; @@ -76,17 +73,14 @@ async function installForEditor( break; } case "cursor": { - if (await isCursorCliAvailable()) { - await installCursorPlugin(token); - logInfo(`Archgate extension installed for ${label}.`); - } else { - const vsixPath = await downloadVsix(token); - logWarn("Cursor CLI not found. The VSIX has been downloaded:"); - console.log(` ${styleText("bold", vsixPath)}`); - console.log( - ` Open Cursor → Ctrl+Shift+P → ${styleText("bold", "Extensions: Install from VSIX...")} → select the file above` - ); - } + // Cursor is a VS Code fork pinned to an older engine version. + // The archgate VSIX currently targets a newer VS Code engine than + // Cursor supports, so installation would fail. Skip until Cursor + // catches up or a Cursor-specific extension is published. + logWarn( + `Cursor plugin install is not yet supported.`, + "The archgate VS Code extension requires a newer engine than Cursor currently provides." + ); break; } case "opencode": { @@ -155,13 +149,7 @@ function printManualInstructions(editor: EditorTarget): void { break; } case "cursor": { - logInfo("To install the plugin manually, run:"); - console.log( - ` ${styleText("bold", "curl")} -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix` - ); - console.log( - ` Then in Cursor: Ctrl+Shift+P → ${styleText("bold", "Extensions: Install from VSIX...")} → select archgate.vsix` - ); + // Cursor plugin install not yet supported — see installForEditor break; } case "vscode": { diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 62285f47..9aa46883 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -257,31 +257,10 @@ async function tryInstallPlugin(editor: EditorTarget): Promise { } if (editor === "cursor") { - const { isCursorCliAvailable, installCursorPlugin, downloadVsix } = - await import("./plugin-install"); - - if (await isCursorCliAvailable()) { - try { - await installCursorPlugin(credentials.token); - return { installed: true, autoInstalled: true }; - } catch (error) { - // CLI install failed — VSIX is preserved on disk, error message has the path - logDebug("Failed to auto-install Cursor plugin:", error); - return { - installed: true, - detail: error instanceof Error ? error.message : String(error), - }; - } - } - - // No cursor CLI — download VSIX for manual install - try { - const vsixPath = await downloadVsix(credentials.token); - return { installed: true, detail: vsixPath }; - } catch (error) { - logDebug("Failed to download Cursor VSIX:", error); - return { installed: false, detail: "download-failed" }; - } + // Cursor is a VS Code fork pinned to an older engine version. + // The archgate VSIX currently targets a newer VS Code engine than + // Cursor supports, so installation is not yet available. + return { installed: false }; } if (editor === "vscode") { diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 7a2ae74e..0fd3b51b 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -2,12 +2,11 @@ // Copyright 2026 Archgate /** Download and install the archgate plugin for supported editors. */ -import { existsSync, mkdirSync, unlinkSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { mkdirSync, unlinkSync } from "node:fs"; import { logDebug } from "./log"; import { internalPath, opencodeAgentsDir } from "./paths"; -import { isWindows, resolveCommand } from "./platform"; +import { resolveCommand } from "./platform"; const PLUGINS_API = "https://plugins.archgate.dev"; @@ -41,82 +40,6 @@ async function run( return { exitCode, stdout, stderr }; } -// --------------------------------------------------------------------------- -// VSIX compatibility helpers -// --------------------------------------------------------------------------- - -/** - * Read Cursor's underlying VS Code engine version from its `product.json`. - * - * The path is derived from the resolved `cursor` binary: - * .../cursor/resources/app/bin/cursor[.cmd] - * → .../cursor/resources/app/product.json - * - * Returns null when the version cannot be determined (missing file, no - * cursor on PATH, unexpected directory layout). - */ -export async function getCursorVscodeVersion(): Promise { - try { - const cursorBin = await resolveCommand("cursor"); - if (!cursorBin) return null; - - // On Windows Bun.which returns the native path (e.g. - // C:\Users\...\cursor\resources\app\bin\cursor.cmd). - // On Unix it may return a symlink target or similar. - // Navigate from bin/ up to the app directory. - const binDir = dirname(cursorBin); // .../resources/app/bin - const appDir = dirname(binDir); // .../resources/app - const productPath = join(appDir, "product.json"); - - if (!existsSync(productPath)) return null; - - const product = await Bun.file(productPath).json(); - if (typeof product.vscodeVersion === "string") { - return product.vscodeVersion; - } - return null; - } catch { - return null; - } -} - -/** - * Read the `engines.vscode` field from a downloaded VSIX file. - * - * The VSIX is a ZIP containing `extension/package.json`. We extract just - * that entry using platform tools (PowerShell on Windows, unzip on Unix). - * - * Returns the raw `engines.vscode` string (e.g. "^1.96.0") or null if - * the field cannot be read. - */ -async function readVsixEngineVersion(vsixPath: string): Promise { - try { - const cmd = isWindows() - ? buildPowershellZipReadCmd(vsixPath) - : ["unzip", "-p", vsixPath, "extension/package.json"]; - - const { stdout } = await run(cmd); - if (!stdout.trim()) return null; - const pkg = JSON.parse(stdout) as { engines?: { vscode?: string } }; - return pkg.engines?.vscode ?? null; - } catch { - return null; - } -} - -/** Build a PowerShell command to read a single entry from a ZIP file. */ -function buildPowershellZipReadCmd(vsixPath: string): string[] { - const script = [ - "Add-Type -AssemblyName System.IO.Compression.FileSystem;", - `$z=[System.IO.Compression.ZipFile]::OpenRead('${vsixPath}');`, - "try{", - "$e=$z.Entries|Where-Object{$_.FullName -eq 'extension/package.json'};", - "if($e){$r=New-Object System.IO.StreamReader($e.Open());$r.ReadToEnd();$r.Close()}", - "}finally{$z.Dispose()}", - ].join(""); - return ["powershell", "-NoProfile", "-Command", script]; -} - // --------------------------------------------------------------------------- // Claude Code — CLI auto-install + manual fallback // --------------------------------------------------------------------------- @@ -226,90 +149,6 @@ async function downloadPluginAsset( return response.arrayBuffer(); } -// --------------------------------------------------------------------------- -// Cursor — download .vsix and install via `cursor` CLI -// --------------------------------------------------------------------------- - -/** - * Download the archgate VSIX to `~/.archgate/archgate.vsix` without - * installing it. Returns the absolute path to the saved file. Used when - * the `cursor` CLI is not available so the user can install manually. - */ -export async function downloadVsix(token: string): Promise { - const vsixPath = internalPath("archgate.vsix"); - const buffer = await downloadPluginAsset("/api/vscode", token); - logDebug( - `Downloaded VS Code extension (${Math.round(buffer.byteLength / 1024)} KB)` - ); - await Bun.write(vsixPath, buffer); - return vsixPath; -} - -/** - * Install the archgate VS Code extension in Cursor via `cursor --install-extension`. - * - * On success the downloaded VSIX is cleaned up. On failure the VSIX is - * kept at `~/.archgate/archgate.vsix` so the user can install it manually - * via Cursor's "Extensions: Install from VSIX..." command. - */ -export async function installCursorPlugin(token: string): Promise { - const vsixPath = internalPath("archgate.vsix"); - const buffer = await downloadPluginAsset("/api/vscode", token); - logDebug( - `Downloaded VS Code extension (${Math.round(buffer.byteLength / 1024)} KB)` - ); - await Bun.write(vsixPath, buffer); - - // --- Pre-flight compatibility check --- - // Cursor is a VS Code fork pinned to a specific engine version. - // If the VSIX targets a newer engine, `cursor --install-extension` will - // reject it. Detect this upfront so we skip the noisy failed attempt. - const cursorVscode = await getCursorVscodeVersion(); - const requiredVscode = await readVsixEngineVersion(vsixPath); - logDebug("Cursor VS Code version:", cursorVscode ?? "unknown"); - logDebug("VSIX engines.vscode:", requiredVscode ?? "unknown"); - - if ( - cursorVscode && - requiredVscode && - !Bun.semver.satisfies(cursorVscode, requiredVscode) - ) { - // Clean up the VSIX — it can't be installed in this Cursor version - try { - unlinkSync(vsixPath); - } catch { - // Ignore cleanup errors - } - throw new Error( - `The archgate extension requires VS Code ${requiredVscode} but ` + - `Cursor is based on VS Code ${cursorVscode}. ` + - `Update Cursor or wait for a compatible extension release.` - ); - } - - const cursorCmd = (await resolveCommand("cursor")) ?? "cursor"; - logDebug("Installing VS Code extension in Cursor via cursor CLI"); - const result = await run([cursorCmd, "--install-extension", vsixPath]); - if (result.exitCode !== 0) { - // Keep the VSIX on disk so the user can install it manually. - // Include stderr detail so the user knows why the install failed. - const detail = (result.stderr || result.stdout).trim(); - throw new Error( - `cursor --install-extension failed (exit ${result.exitCode}).` + - (detail ? ` ${detail}.` : "") + - ` The VSIX was saved to ${vsixPath} — install it manually in Cursor: ` + - `Ctrl+Shift+P → "Extensions: Install from VSIX..."` - ); - } - - // Clean up only on success - try { - unlinkSync(vsixPath); - } catch { - // Ignore cleanup errors - } -} - // --------------------------------------------------------------------------- // opencode — download agent bundle into user-scope agents dir // --------------------------------------------------------------------------- From f52e01a039e542490dd8695e91d27ae419c0754c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 15 May 2026 23:10:32 +0200 Subject: [PATCH 03/11] fix: exclude Cursor from editor detection Cursor's VS Code engine is too old for the archgate VSIX, so don't detect or pre-check it in the editor selection prompt. Users won't see "Cursor (detected)" followed by a failed install. Signed-off-by: Rhuan Barreto --- src/helpers/editor-detect.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/helpers/editor-detect.ts b/src/helpers/editor-detect.ts index 303ed737..25c1a848 100644 --- a/src/helpers/editor-detect.ts +++ b/src/helpers/editor-detect.ts @@ -13,7 +13,6 @@ import { logDebug } from "./log"; import { isClaudeCliAvailable, isCopilotCliAvailable, - isCursorCliAvailable, isOpencodeCliAvailable, isVscodeCliAvailable, } from "./plugin-install"; @@ -32,14 +31,17 @@ export interface DetectedEditor { */ export async function detectEditors(): Promise { logDebug("Detecting available editor CLIs"); - const [claude, cursor, vscode, copilot, opencode] = await Promise.all([ + const [claude, vscode, copilot, opencode] = await Promise.all([ isClaudeCliAvailable(), - isCursorCliAvailable(), isVscodeCliAvailable(), isCopilotCliAvailable(), isOpencodeCliAvailable(), ]); + // Cursor is excluded from detection — the archgate VSIX targets a newer + // VS Code engine than Cursor currently provides (see plugin-install.ts). + const cursor = false; + logDebug("Editor detection:", { claude, cursor, vscode, copilot, opencode }); return [ { id: "claude" as const, label: EDITOR_LABELS.claude, available: claude }, From d55cb18b67e81acd7762a9c05d96d0fabb9b7257 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 15 May 2026 23:14:25 +0200 Subject: [PATCH 04/11] fix: use Cursor team marketplace instead of VSIX install Cursor supports plugins via Team Private Marketplaces, not VSIX installation (the VSIX targets a newer VS Code engine than Cursor provides). Show the marketplace URL and instructions to add it in Cursor Settings instead of attempting a VSIX install that would fail. Ref: https://cursor.com/docs/plugins#team-marketplaces Signed-off-by: Rhuan Barreto --- src/commands/plugin/install.ts | 24 ++++++++++++++++-------- src/helpers/editor-detect.ts | 8 +++----- src/helpers/init-project.ts | 8 ++++---- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 19d8ce4f..6f370ba4 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -16,6 +16,7 @@ import type { EditorTarget } from "../../helpers/init-project"; import { logError, logInfo, logWarn } from "../../helpers/log"; import { findProjectRoot } from "../../helpers/paths"; import { + buildCursorMarketplaceUrl, buildMarketplaceUrl, buildVscodeMarketplaceUrl, installClaudePlugin, @@ -73,13 +74,15 @@ async function installForEditor( break; } case "cursor": { - // Cursor is a VS Code fork pinned to an older engine version. - // The archgate VSIX currently targets a newer VS Code engine than - // Cursor supports, so installation would fail. Skip until Cursor - // catches up or a Cursor-specific extension is published. - logWarn( - `Cursor plugin install is not yet supported.`, - "The archgate VS Code extension requires a newer engine than Cursor currently provides." + // Cursor supports plugins via Team Private Marketplaces — not VSIX. + // See https://cursor.com/docs/plugins#team-marketplaces + const url = buildCursorMarketplaceUrl(); + logInfo( + `To install the Archgate plugin for ${label}, add the team marketplace URL in Cursor Settings:` + ); + console.log(` ${styleText("bold", url)}`); + console.log( + ` Cursor Settings → Extensions → Team Private Plugin Marketplaces → Add URL` ); break; } @@ -149,7 +152,12 @@ function printManualInstructions(editor: EditorTarget): void { break; } case "cursor": { - // Cursor plugin install not yet supported — see installForEditor + const url = buildCursorMarketplaceUrl(); + logInfo("Add the team marketplace URL in Cursor Settings:"); + console.log(` ${styleText("bold", url)}`); + console.log( + ` Cursor Settings → Extensions → Team Private Plugin Marketplaces → Add URL` + ); break; } case "vscode": { diff --git a/src/helpers/editor-detect.ts b/src/helpers/editor-detect.ts index 25c1a848..303ed737 100644 --- a/src/helpers/editor-detect.ts +++ b/src/helpers/editor-detect.ts @@ -13,6 +13,7 @@ import { logDebug } from "./log"; import { isClaudeCliAvailable, isCopilotCliAvailable, + isCursorCliAvailable, isOpencodeCliAvailable, isVscodeCliAvailable, } from "./plugin-install"; @@ -31,17 +32,14 @@ export interface DetectedEditor { */ export async function detectEditors(): Promise { logDebug("Detecting available editor CLIs"); - const [claude, vscode, copilot, opencode] = await Promise.all([ + const [claude, cursor, vscode, copilot, opencode] = await Promise.all([ isClaudeCliAvailable(), + isCursorCliAvailable(), isVscodeCliAvailable(), isCopilotCliAvailable(), isOpencodeCliAvailable(), ]); - // Cursor is excluded from detection — the archgate VSIX targets a newer - // VS Code engine than Cursor currently provides (see plugin-install.ts). - const cursor = false; - logDebug("Editor detection:", { claude, cursor, vscode, copilot, opencode }); return [ { id: "claude" as const, label: EDITOR_LABELS.claude, available: claude }, diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 9aa46883..e293fa84 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -257,10 +257,10 @@ async function tryInstallPlugin(editor: EditorTarget): Promise { } if (editor === "cursor") { - // Cursor is a VS Code fork pinned to an older engine version. - // The archgate VSIX currently targets a newer VS Code engine than - // Cursor supports, so installation is not yet available. - return { installed: false }; + // Cursor uses Team Private Plugin Marketplaces — not VSIX or CLI install. + // The user must add the marketplace URL manually in Cursor Settings. + const { buildCursorMarketplaceUrl } = await import("./plugin-install"); + return { installed: true, detail: buildCursorMarketplaceUrl() }; } if (editor === "vscode") { From 103744b752e667aa889c431b262261400d9aa9e3 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 15 May 2026 23:26:18 +0200 Subject: [PATCH 05/11] docs: update plugin install reference for Cursor marketplace approach Update EN and pt-br docs to reflect that Cursor uses Team Private Plugin Marketplaces instead of VSIX installation, and that VS Code no longer shows the "marketplace URL added" message. Signed-off-by: Rhuan Barreto --- docs/src/content/docs/pt-br/reference/cli/plugin.mdx | 4 ++-- docs/src/content/docs/reference/cli/plugin.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/pt-br/reference/cli/plugin.mdx b/docs/src/content/docs/pt-br/reference/cli/plugin.mdx index 773d054c..bd959eef 100644 --- a/docs/src/content/docs/pt-br/reference/cli/plugin.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/plugin.mdx @@ -54,8 +54,8 @@ O comportamento de instalação varia por editor: - **Claude Code:** Instala automaticamente via CLI `claude` se disponível; exibe comandos manuais caso contrário. - **Copilot CLI:** Instala automaticamente via CLI `copilot` se disponível; exibe comandos manuais caso contrário. -- **Cursor:** Instala a extensão VS Code via CLI `cursor` se disponível e exibe a URL do team marketplace; exibe instruções manuais caso contrário. -- **VS Code:** Adiciona a URL do marketplace nas configurações de usuário do VS Code e instala a extensão VS Code (`.vsix`) via CLI `code` se disponível; exibe instruções manuais caso contrário. +- **Cursor:** Exibe a URL do [Team Private Plugin Marketplace](https://cursor.com/docs/plugins#team-marketplaces) com instruções para adicioná-la nas Configurações do Cursor. O Cursor não suporta instalação de VSIX via CLI. +- **VS Code:** Instala a extensão VS Code (`.vsix`) via CLI `code` se disponível; exibe instruções manuais caso contrário. - **opencode:** Requer que a CLI `opencode` esteja no PATH -- pula a instalação com uma mensagem clara caso contrário. Quando presente, baixa um tarball autenticado de arquivos de agente e o extrai no diretório de agentes opencode do escopo do usuário. `archgate plugin url --editor opencode` exibe "N/A" -- opencode não tem URL de marketplace. Veja o [guia de integração com opencode](/guides/opencode-integration/) para detalhes. ## Exemplos diff --git a/docs/src/content/docs/reference/cli/plugin.mdx b/docs/src/content/docs/reference/cli/plugin.mdx index 916548e8..571a91a0 100644 --- a/docs/src/content/docs/reference/cli/plugin.mdx +++ b/docs/src/content/docs/reference/cli/plugin.mdx @@ -54,8 +54,8 @@ Installation behavior varies by editor: - **Claude Code:** Auto-installs via `claude` CLI if available; prints manual commands otherwise. - **Copilot CLI:** Auto-installs via `copilot` CLI if available; prints manual commands otherwise. -- **Cursor:** Installs the VS Code extension via `cursor` CLI if available and prints the team marketplace URL; prints manual instructions otherwise. -- **VS Code:** Adds the marketplace URL to VS Code user settings and installs the VS Code extension (`.vsix`) via `code` CLI if available; prints manual instructions otherwise. +- **Cursor:** Prints the [Team Private Plugin Marketplace](https://cursor.com/docs/plugins#team-marketplaces) URL with instructions to add it in Cursor Settings. Cursor does not support VSIX installation from the CLI. +- **VS Code:** Installs the VS Code extension (`.vsix`) via `code` CLI if available; prints manual instructions otherwise. - **opencode:** Requires the `opencode` CLI to be on PATH — skips the install with a clear message otherwise. When present, downloads an authenticated tarball of agent files and extracts it into the user-scope opencode agents directory. `archgate plugin url --editor opencode` prints "N/A" — opencode has no marketplace URL. See the [opencode integration guide](/guides/opencode-integration/) for details. ## Examples From 679ddd03206de88fa69ac78dc1b8fbb986677d89 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Fri, 15 May 2026 21:26:43 +0000 Subject: [PATCH 06/11] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 3427a673..13106fa7 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4020,8 +4020,8 @@ Installation behavior varies by editor: - **Claude Code:** Auto-installs via `claude` CLI if available; prints manual commands otherwise. - **Copilot CLI:** Auto-installs via `copilot` CLI if available; prints manual commands otherwise. -- **Cursor:** Installs the VS Code extension via `cursor` CLI if available and prints the team marketplace URL; prints manual instructions otherwise. -- **VS Code:** Adds the marketplace URL to VS Code user settings and installs the VS Code extension (`.vsix`) via `code` CLI if available; prints manual instructions otherwise. +- **Cursor:** Prints the [Team Private Plugin Marketplace](https://cursor.com/docs/plugins#team-marketplaces) URL with instructions to add it in Cursor Settings. Cursor does not support VSIX installation from the CLI. +- **VS Code:** Installs the VS Code extension (`.vsix`) via `code` CLI if available; prints manual instructions otherwise. - **opencode:** Requires the `opencode` CLI to be on PATH — skips the install with a clear message otherwise. When present, downloads an authenticated tarball of agent files and extracts it into the user-scope opencode agents directory. `archgate plugin url --editor opencode` prints "N/A" — opencode has no marketplace URL. See the [opencode integration guide](/guides/opencode-integration/) for details. ## Examples From 7a40df56863f2812bac6f1dc23758c030cdcebfa Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 15 May 2026 23:36:08 +0200 Subject: [PATCH 07/11] refactor: use Bun FFI to reset Windows console mode after prompts Instead of permanently monkey-patching console.log/error/warn (which diverges from Bun's native formatting), use SetConsoleMode via Bun FFI to clear the DISABLE_NEWLINE_AUTO_RETURN flag that inquirer sets. This fixes the root cause at the OS level. The stream-level patch (process.stdout/stderr.write) is still needed during prompt rendering since inquirer writes through the JS stream API while the flag is active. The console-method redirect is now only a fallback for environments where FFI is unavailable (e.g., mintty pipes). Signed-off-by: Rhuan Barreto --- src/helpers/prompt.ts | 187 +++++++++++++++++++++-------------- tests/helpers/prompt.test.ts | 135 +------------------------ 2 files changed, 114 insertions(+), 208 deletions(-) diff --git a/src/helpers/prompt.ts b/src/helpers/prompt.ts index 8cca6c88..11607597 100644 --- a/src/helpers/prompt.ts +++ b/src/helpers/prompt.ts @@ -10,24 +10,23 @@ * the original console mode after the prompt closes — so ALL subsequent output * is affected, not just the prompt itself. * - * The fix has two parts: + * The fix uses two complementary strategies: * - * 1. **Stream-level:** Patch `process.stdout.write` and `process.stderr.write` - * to translate bare `\n` → `\r\n`. This fixes inquirer's own rendering - * (which writes through the JS stream API). + * 1. **Stream-level patch** (applied once, persists): patches + * `process.stdout.write` / `process.stderr.write` to translate bare + * `\n` → `\r\n`. This covers inquirer's own rendering, which writes + * through the JS stream API while `DISABLE_NEWLINE_AUTO_RETURN` is set. * - * 2. **Console-level:** Redirect `console.log`, `console.error`, `console.warn`, - * `console.info`, and `console.debug` through the patched stream writes. - * Bun's native console methods bypass `process.stdout.write` entirely - * (writing directly to the file descriptor for performance), so the - * stream-level patch alone cannot fix them. + * 2. **Console mode reset** (after each prompt): uses Bun FFI to call the + * Windows `SetConsoleMode` API and clear the `DISABLE_NEWLINE_AUTO_RETURN` + * flag. This restores correct newline behavior for ALL output — including + * Bun's native `console.log` which bypasses the JS stream API entirely. + * If FFI is unavailable (e.g., running under mintty where stdout is a + * pipe, not a console handle), falls back to redirecting `console.*` + * methods through the patched stream writes. * - * Both patches are applied **once** (idempotent) and persist for the lifetime - * of the process. - * - * `withPromptFix()` ensures the patches are active before running a prompt and - * resets the cursor to column 0 afterward (another quirk where the cursor is - * left at a non-zero column after a prompt answer is rendered). + * `withPromptFix()` ensures the stream patches are active before running a + * prompt, resets the console mode afterward, and moves the cursor to column 0. */ import { cursorTo } from "node:readline"; @@ -48,43 +47,26 @@ function toCrlf(text: string): string { } // --------------------------------------------------------------------------- -// One-time patches (idempotent) +// Stream-level patch (applied once, persists) // --------------------------------------------------------------------------- -/** Whether the patches have already been applied. */ -let patched = false; +/** Whether the stream-level patches have been applied. */ +let streamPatched = false; /** - * On Windows, apply a permanent, idempotent patch so that ALL console output - * uses `\r\n` instead of bare `\n`. Covers: - * - * - `process.stdout.write` / `process.stderr.write` (stream-level — used by - * inquirer's readline pipeline) - * - `console.log` / `.info` / `.error` / `.warn` / `.debug` (console-level — - * Bun writes these directly to the fd, bypassing the JS stream API) - * - * On non-Windows platforms this is a no-op. Calling it multiple times is safe. + * Patch `process.stdout.write` and `process.stderr.write` to translate bare + * `\n` → `\r\n`. Applied once and persists — needed because inquirer writes + * through the JS stream API while `DISABLE_NEWLINE_AUTO_RETURN` is active. */ -export function ensureStdoutNewlinePatch(): void { - if (!isWindows() || patched) return; - patched = true; - +function ensureStreamPatches(): void { + if (streamPatched) return; + streamPatched = true; patchStreamWrite(process.stdout); patchStreamWrite(process.stderr); - patchConsoleMethods(); } -// --------------------------------------------------------------------------- -// Stream-level patch -// --------------------------------------------------------------------------- - -/** - * Patch the `write` method on a writable stream to translate bare `\n` → `\r\n`. - */ function patchStreamWrite(stream: NodeJS.WriteStream): void { const original = stream.write; - - // Regular function — not arrow — so `this` is forwarded correctly. stream.write = function patchedWrite( this: NodeJS.WriteStream, chunk: unknown, @@ -102,30 +84,92 @@ function patchStreamWrite(stream: NodeJS.WriteStream): void { } // --------------------------------------------------------------------------- -// Console-level patch +// Console mode reset via Windows API (after each prompt) +// --------------------------------------------------------------------------- + +const STD_OUTPUT_HANDLE = -11; +const STD_ERROR_HANDLE = -12; +/** @see https://learn.microsoft.com/en-us/windows/console/setconsolemode */ +const DISABLE_NEWLINE_AUTO_RETURN = 0x0008; + +/** + * Clear the `DISABLE_NEWLINE_AUTO_RETURN` flag on the stdout and stderr + * console handles via the Windows `SetConsoleMode` API (Bun FFI). + * + * This restores the default behavior where `\n` returns the cursor to + * column 0, fixing all native output (including Bun's `console.log` which + * bypasses the JS stream API). + * + * Returns `true` if the reset succeeded, `false` if FFI was unavailable + * (e.g., stdout is a pipe under mintty, not a real console handle). + */ +function resetConsoleNewlineMode(): boolean { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { dlopen, FFIType, ptr } = + require("bun:ffi") as typeof import("bun:ffi"); + const kernel32 = dlopen("kernel32.dll", { + GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr }, + GetConsoleMode: { + args: [FFIType.ptr, FFIType.ptr], + returns: FFIType.i32, + }, + SetConsoleMode: { + args: [FFIType.ptr, FFIType.u32], + returns: FFIType.i32, + }, + }); + + const modeBuffer = new Uint32Array(1); + let reset = false; + + for (const handleId of [STD_OUTPUT_HANDLE, STD_ERROR_HANDLE]) { + const handle = kernel32.symbols.GetStdHandle(handleId); + const ok = kernel32.symbols.GetConsoleMode(handle, ptr(modeBuffer)); + if (!ok) continue; // not a console handle (e.g., pipe under mintty) + const mode = modeBuffer[0]; + if (mode & DISABLE_NEWLINE_AUTO_RETURN) { + kernel32.symbols.SetConsoleMode( + handle, + mode & ~DISABLE_NEWLINE_AUTO_RETURN + ); + reset = true; + } + } + + kernel32.close(); + return reset || true; // true = FFI worked (even if flag wasn't set) + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Console-method fallback (only when FFI is unavailable) // --------------------------------------------------------------------------- +/** Whether the console-method fallback has been applied. */ +let consoleFallbackApplied = false; + /** - * Redirect `console.log`, `.info`, `.error`, `.warn`, and `.debug` through - * `process.stdout.write` / `process.stderr.write` so the stream-level - * `\n` → `\r\n` translation applies to them as well. + * Redirect `console.log`, `.info`, `.error`, `.warn`, `.debug` through the + * patched `process.stdout.write` / `process.stderr.write`. Only used as a + * fallback when `resetConsoleNewlineMode()` fails (FFI unavailable). * - * Bun's native console methods write directly to the file descriptor for - * performance, bypassing the JavaScript stream API entirely. Without this - * redirect, patching `process.stdout.write` has no effect on `console.log`. + * Bun's native console methods write directly to the file descriptor, + * bypassing the JS stream API. Without this fallback, the stream-level + * patch has no effect on `console.log` output. */ -function patchConsoleMethods(): void { - // stdout-bound methods - const stdoutMethods = ["log", "info"] as const; - for (const method of stdoutMethods) { +function ensureConsoleFallback(): void { + if (consoleFallbackApplied) return; + consoleFallbackApplied = true; + + for (const method of ["log", "info"] as const) { console[method] = ((...args: unknown[]) => { process.stdout.write(format(...args) + "\n"); }) as typeof console.log; } - - // stderr-bound methods - const stderrMethods = ["error", "warn", "debug"] as const; - for (const method of stderrMethods) { + for (const method of ["error", "warn", "debug"] as const) { console[method] = ((...args: unknown[]) => { process.stderr.write(format(...args) + "\n"); }) as typeof console.error; @@ -138,17 +182,23 @@ function patchConsoleMethods(): void { /** * Execute an async function (typically an `inquirer.prompt()` call) with the - * Windows newline fix active. The patches are applied once and persist — they - * are NOT removed after the prompt because inquirer permanently changes the - * console mode. After the function resolves, the cursor is reset to column 0 - * (another Windows quirk where the cursor is left at a non-zero column after - * a prompt answer is rendered). + * Windows newline fix active. + * + * - **Before:** ensures the stream-level `\n` → `\r\n` patches are active. + * - **After:** resets the console mode via FFI (clearing the flag inquirer + * set), then resets the cursor to column 0. * * On non-Windows platforms only the cursor reset is applied. */ export async function withPromptFix(fn: () => Promise): Promise { - ensureStdoutNewlinePatch(); + if (isWindows()) { + ensureStreamPatches(); + } const result = await fn(); + if (isWindows()) { + const ffiWorked = resetConsoleNewlineMode(); + if (!ffiWorked) ensureConsoleFallback(); + } resetCursor(); return result; } @@ -157,19 +207,6 @@ export async function withPromptFix(fn: () => Promise): Promise { // Cursor reset // --------------------------------------------------------------------------- -/** - * Reset the cursor to column 0 if stdout is a TTY. - * Useful after inquirer prompts that leave the cursor at a non-zero column. - */ function resetCursor(): void { if (process.stdout.isTTY) cursorTo(process.stdout, 0); } - -// --------------------------------------------------------------------------- -// Testing helpers -// --------------------------------------------------------------------------- - -/** Reset the patch state. For testing only. */ -export function _resetPatchState(): void { - patched = false; -} diff --git a/tests/helpers/prompt.test.ts b/tests/helpers/prompt.test.ts index b7289c2c..b45a5da9 100644 --- a/tests/helpers/prompt.test.ts +++ b/tests/helpers/prompt.test.ts @@ -1,139 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test, afterEach, mock } from "bun:test"; +import { describe, expect, test } from "bun:test"; -import { withPromptFix, _resetPatchState } from "../../src/helpers/prompt"; - -// Save the original write so we can verify restoration. -const originalWrite = process.stdout.write; - -afterEach(() => { - // Safety: ensure process.stdout.write is always restored even if a test fails. - process.stdout.write = originalWrite; - _resetPatchState(); - mock.restore(); -}); - -describe("ensureStdoutNewlinePatch", () => { - test("replaces bare LF with CRLF on Windows", () => { - // Force isWindows() to return true by mocking the platform module. - mock.module("../../src/helpers/platform", () => ({ - isWindows: () => true, - isMacOS: () => false, - isLinux: () => false, - isWSL: () => false, - getPlatformInfo: () => ({ - runtime: "win32" as const, - isWSL: false, - wslDistro: null, - }), - })); - - // Re-import to pick up mock - const { - ensureStdoutNewlinePatch: ensurePatch, - _resetPatchState: resetPatch, - } = require("../../src/helpers/prompt"); - resetPatch(); - - const written: string[] = []; - process.stdout.write = ((chunk: unknown) => { - written.push(String(chunk)); - return true; - }) as typeof process.stdout.write; - - ensurePatch(); - - // Call the patched write — bare \n should become \r\n - process.stdout.write("hello\nworld\n"); - - expect(written).toEqual(["hello\r\nworld\r\n"]); - }); - - test("does not double-replace existing CRLF", () => { - mock.module("../../src/helpers/platform", () => ({ - isWindows: () => true, - isMacOS: () => false, - isLinux: () => false, - isWSL: () => false, - getPlatformInfo: () => ({ - runtime: "win32" as const, - isWSL: false, - wslDistro: null, - }), - })); - - const { - ensureStdoutNewlinePatch: ensurePatch, - _resetPatchState: resetPatch, - } = require("../../src/helpers/prompt"); - resetPatch(); - - const written: string[] = []; - process.stdout.write = ((chunk: unknown) => { - written.push(String(chunk)); - return true; - }) as typeof process.stdout.write; - - ensurePatch(); - process.stdout.write("line1\r\nline2\n"); - - // \r\n stays as \r\n, bare \n becomes \r\n - expect(written).toEqual(["line1\r\nline2\r\n"]); - }); - - test("is a no-op on non-Windows platforms", () => { - mock.module("../../src/helpers/platform", () => ({ - isWindows: () => false, - isMacOS: () => true, - isLinux: () => false, - isWSL: () => false, - getPlatformInfo: () => ({ - runtime: "darwin" as const, - isWSL: false, - wslDistro: null, - }), - })); - - const { - ensureStdoutNewlinePatch: ensurePatch, - _resetPatchState: resetPatch, - } = require("../../src/helpers/prompt"); - resetPatch(); - - ensurePatch(); - - // Write should not have been replaced - expect(process.stdout.write).toBe(originalWrite); - }); - - test("is idempotent — second call does not re-patch", () => { - mock.module("../../src/helpers/platform", () => ({ - isWindows: () => true, - isMacOS: () => false, - isLinux: () => false, - isWSL: () => false, - getPlatformInfo: () => ({ - runtime: "win32" as const, - isWSL: false, - wslDistro: null, - }), - })); - - const { - ensureStdoutNewlinePatch: ensurePatch, - _resetPatchState: resetPatch, - } = require("../../src/helpers/prompt"); - resetPatch(); - - ensurePatch(); - const afterFirstPatch = process.stdout.write; - - ensurePatch(); - // Second call should be a no-op — same patched function - expect(process.stdout.write).toBe(afterFirstPatch); - }); -}); +import { withPromptFix } from "../../src/helpers/prompt"; describe("withPromptFix", () => { test("returns the value from the wrapped function", async () => { From 21431e9b6831b78956a9719a83238d6dda32f1cd Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 15 May 2026 23:48:52 +0200 Subject: [PATCH 08/11] revert: remove FFI kernel32 calls, keep pure JS workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FFI calls to kernel32.dll SetConsoleMode would be flagged by antimalware / EDR software — a scripting runtime dynamically loading system DLLs looks indistinguishable from malicious activity. The root cause is a runtime bug (Node.js/Bun should restore the console mode in rl.close()), tracked at: https://github.com/SBoudrias/Inquirer.js/issues/2123 Keep the pure JS workaround: stream-level \n → \r\n patches + console method redirects through the patched streams. Signed-off-by: Rhuan Barreto --- src/helpers/prompt.ts | 174 ++++++++++++++---------------------------- 1 file changed, 58 insertions(+), 116 deletions(-) diff --git a/src/helpers/prompt.ts b/src/helpers/prompt.ts index 11607597..96d5d377 100644 --- a/src/helpers/prompt.ts +++ b/src/helpers/prompt.ts @@ -3,30 +3,33 @@ /** * prompt.ts — Windows terminal fix for inquirer prompts. * - * When inquirer creates a readline interface on Windows, it enables Virtual - * Terminal Processing (VTP) on the console output handle. VTP mode sets the - * `DISABLE_NEWLINE_AUTO_RETURN` flag, which causes bare `\n` (LF) to move the - * cursor down WITHOUT returning to column 0. Crucially, inquirer never restores - * the original console mode after the prompt closes — so ALL subsequent output - * is affected, not just the prompt itself. + * When inquirer creates a readline interface on Windows, the runtime enables + * Virtual Terminal Processing (VTP) on the console output handle. VTP mode + * sets the `DISABLE_NEWLINE_AUTO_RETURN` flag, which causes bare `\n` (LF) + * to move the cursor down WITHOUT returning to column 0. The runtime never + * restores the original console mode when the readline closes — so ALL + * subsequent output is affected, not just the prompt itself. * - * The fix uses two complementary strategies: + * This is a runtime bug (Node.js/Bun should restore the console mode in + * rl.close()), tracked upstream at: + * https://github.com/SBoudrias/Inquirer.js/issues/2123 * - * 1. **Stream-level patch** (applied once, persists): patches - * `process.stdout.write` / `process.stderr.write` to translate bare - * `\n` → `\r\n`. This covers inquirer's own rendering, which writes - * through the JS stream API while `DISABLE_NEWLINE_AUTO_RETURN` is set. + * Until the runtime fixes this, we work around it by: * - * 2. **Console mode reset** (after each prompt): uses Bun FFI to call the - * Windows `SetConsoleMode` API and clear the `DISABLE_NEWLINE_AUTO_RETURN` - * flag. This restores correct newline behavior for ALL output — including - * Bun's native `console.log` which bypasses the JS stream API entirely. - * If FFI is unavailable (e.g., running under mintty where stdout is a - * pipe, not a console handle), falls back to redirecting `console.*` - * methods through the patched stream writes. + * 1. Patching `process.stdout.write` / `process.stderr.write` to translate + * bare `\n` → `\r\n`. This fixes inquirer's own rendering (which writes + * through the JS stream API) and any code that uses the stream API. * - * `withPromptFix()` ensures the stream patches are active before running a - * prompt, resets the console mode afterward, and moves the cursor to column 0. + * 2. Redirecting `console.log`, `.error`, `.warn`, `.info`, `.debug` through + * the patched stream writes. Bun's native console methods write directly + * to the file descriptor for performance, bypassing the JS stream API — + * so the stream-level patch alone cannot fix them. + * + * Both patches are applied once (idempotent) and persist for the lifetime + * of the process. + * + * `withPromptFix()` ensures the patches are active before running a prompt + * and resets the cursor to column 0 afterward. */ import { cursorTo } from "node:readline"; @@ -47,24 +50,36 @@ function toCrlf(text: string): string { } // --------------------------------------------------------------------------- -// Stream-level patch (applied once, persists) +// One-time patches (idempotent) // --------------------------------------------------------------------------- -/** Whether the stream-level patches have been applied. */ -let streamPatched = false; +/** Whether the patches have already been applied. */ +let patched = false; /** - * Patch `process.stdout.write` and `process.stderr.write` to translate bare - * `\n` → `\r\n`. Applied once and persists — needed because inquirer writes - * through the JS stream API while `DISABLE_NEWLINE_AUTO_RETURN` is active. + * On Windows, apply a permanent, idempotent patch so that ALL console output + * uses `\r\n` instead of bare `\n`. Covers: + * + * - `process.stdout.write` / `process.stderr.write` (stream-level — used by + * inquirer's readline pipeline) + * - `console.log` / `.info` / `.error` / `.warn` / `.debug` (console-level — + * Bun writes these directly to the fd, bypassing the JS stream API) + * + * On non-Windows platforms this is a no-op. Calling it multiple times is safe. */ -function ensureStreamPatches(): void { - if (streamPatched) return; - streamPatched = true; +function ensureNewlinePatches(): void { + if (patched) return; + patched = true; + patchStreamWrite(process.stdout); patchStreamWrite(process.stderr); + patchConsoleMethods(); } +// --------------------------------------------------------------------------- +// Stream-level patch +// --------------------------------------------------------------------------- + function patchStreamWrite(stream: NodeJS.WriteStream): void { const original = stream.write; stream.write = function patchedWrite( @@ -84,86 +99,19 @@ function patchStreamWrite(stream: NodeJS.WriteStream): void { } // --------------------------------------------------------------------------- -// Console mode reset via Windows API (after each prompt) -// --------------------------------------------------------------------------- - -const STD_OUTPUT_HANDLE = -11; -const STD_ERROR_HANDLE = -12; -/** @see https://learn.microsoft.com/en-us/windows/console/setconsolemode */ -const DISABLE_NEWLINE_AUTO_RETURN = 0x0008; - -/** - * Clear the `DISABLE_NEWLINE_AUTO_RETURN` flag on the stdout and stderr - * console handles via the Windows `SetConsoleMode` API (Bun FFI). - * - * This restores the default behavior where `\n` returns the cursor to - * column 0, fixing all native output (including Bun's `console.log` which - * bypasses the JS stream API). - * - * Returns `true` if the reset succeeded, `false` if FFI was unavailable - * (e.g., stdout is a pipe under mintty, not a real console handle). - */ -function resetConsoleNewlineMode(): boolean { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { dlopen, FFIType, ptr } = - require("bun:ffi") as typeof import("bun:ffi"); - const kernel32 = dlopen("kernel32.dll", { - GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr }, - GetConsoleMode: { - args: [FFIType.ptr, FFIType.ptr], - returns: FFIType.i32, - }, - SetConsoleMode: { - args: [FFIType.ptr, FFIType.u32], - returns: FFIType.i32, - }, - }); - - const modeBuffer = new Uint32Array(1); - let reset = false; - - for (const handleId of [STD_OUTPUT_HANDLE, STD_ERROR_HANDLE]) { - const handle = kernel32.symbols.GetStdHandle(handleId); - const ok = kernel32.symbols.GetConsoleMode(handle, ptr(modeBuffer)); - if (!ok) continue; // not a console handle (e.g., pipe under mintty) - const mode = modeBuffer[0]; - if (mode & DISABLE_NEWLINE_AUTO_RETURN) { - kernel32.symbols.SetConsoleMode( - handle, - mode & ~DISABLE_NEWLINE_AUTO_RETURN - ); - reset = true; - } - } - - kernel32.close(); - return reset || true; // true = FFI worked (even if flag wasn't set) - } catch { - return false; - } -} - -// --------------------------------------------------------------------------- -// Console-method fallback (only when FFI is unavailable) +// Console-level patch // --------------------------------------------------------------------------- -/** Whether the console-method fallback has been applied. */ -let consoleFallbackApplied = false; - /** - * Redirect `console.log`, `.info`, `.error`, `.warn`, `.debug` through the - * patched `process.stdout.write` / `process.stderr.write`. Only used as a - * fallback when `resetConsoleNewlineMode()` fails (FFI unavailable). + * Redirect `console.log`, `.info`, `.error`, `.warn`, and `.debug` through + * `process.stdout.write` / `process.stderr.write` so the stream-level + * `\n` → `\r\n` translation applies to them as well. * - * Bun's native console methods write directly to the file descriptor, - * bypassing the JS stream API. Without this fallback, the stream-level - * patch has no effect on `console.log` output. + * Bun's native console methods write directly to the file descriptor for + * performance, bypassing the JavaScript stream API entirely. Without this + * redirect, patching `process.stdout.write` has no effect on `console.log`. */ -function ensureConsoleFallback(): void { - if (consoleFallbackApplied) return; - consoleFallbackApplied = true; - +function patchConsoleMethods(): void { for (const method of ["log", "info"] as const) { console[method] = ((...args: unknown[]) => { process.stdout.write(format(...args) + "\n"); @@ -182,23 +130,17 @@ function ensureConsoleFallback(): void { /** * Execute an async function (typically an `inquirer.prompt()` call) with the - * Windows newline fix active. - * - * - **Before:** ensures the stream-level `\n` → `\r\n` patches are active. - * - **After:** resets the console mode via FFI (clearing the flag inquirer - * set), then resets the cursor to column 0. + * Windows newline fix active. The patches are applied once and persist — they + * are NOT removed after the prompt because the runtime permanently changes + * the console mode. After the function resolves, the cursor is reset to + * column 0 (another quirk where the cursor is left at a non-zero column + * after a prompt answer is rendered). * * On non-Windows platforms only the cursor reset is applied. */ export async function withPromptFix(fn: () => Promise): Promise { - if (isWindows()) { - ensureStreamPatches(); - } + if (isWindows()) ensureNewlinePatches(); const result = await fn(); - if (isWindows()) { - const ffiWorked = resetConsoleNewlineMode(); - if (!ffiWorked) ensureConsoleFallback(); - } resetCursor(); return result; } From 733c6759880799e884f1a1cb27fb841b69ceaa84 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 15 May 2026 23:55:03 +0200 Subject: [PATCH 09/11] fix: skip all Windows workarounds on non-Windows, improve tests withPromptFix is now a pure passthrough on non-Windows (early return, no cursor reset, no patches). Tests use test.skipIf for platform- conditional assertions. Signed-off-by: Rhuan Barreto --- src/helpers/prompt.ts | 4 +++- tests/helpers/prompt.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/helpers/prompt.ts b/src/helpers/prompt.ts index 96d5d377..51220b38 100644 --- a/src/helpers/prompt.ts +++ b/src/helpers/prompt.ts @@ -139,7 +139,9 @@ function patchConsoleMethods(): void { * On non-Windows platforms only the cursor reset is applied. */ export async function withPromptFix(fn: () => Promise): Promise { - if (isWindows()) ensureNewlinePatches(); + if (!isWindows()) return fn(); + + ensureNewlinePatches(); const result = await fn(); resetCursor(); return result; diff --git a/tests/helpers/prompt.test.ts b/tests/helpers/prompt.test.ts index b45a5da9..db005cb5 100644 --- a/tests/helpers/prompt.test.ts +++ b/tests/helpers/prompt.test.ts @@ -15,4 +15,28 @@ describe("withPromptFix", () => { withPromptFix(() => Promise.reject(new Error("boom"))) ).rejects.toThrow("boom"); }); + + // The Windows-specific patching (stream writes + console redirects) is a + // permanent, idempotent side effect that cannot be meaningfully asserted in + // unit tests — earlier tests in the full suite may have already triggered it. + // The behavior is verified manually on Windows PowerShell. + test.skipIf(process.platform !== "win32")( + "applies newline patches on Windows (manual verification)", + async () => { + await withPromptFix(() => Promise.resolve()); + // On Windows, process.stdout.write should be the patched version. + // We check the function name rather than reference equality because + // earlier tests in the suite may have already applied the patch. + expect(process.stdout.write.name).toBe("patchedWrite"); + } + ); + + test.skipIf(process.platform === "win32")( + "is a pure passthrough on non-Windows", + async () => { + const before = process.stdout.write; + await withPromptFix(() => Promise.resolve()); + expect(process.stdout.write).toBe(before); + } + ); }); From f396d4f05ee411c3dd8eaedf9fae7d2c2170dd58 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 16 May 2026 00:50:49 +0200 Subject: [PATCH 10/11] fix: keep cursor reset on all platforms, guard only newline patches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cursor reset (cursorTo column 0) after prompts is needed on all platforms — existing editor-detect tests depend on it. Only the stream/console newline patches are Windows-specific. Signed-off-by: Rhuan Barreto --- src/helpers/prompt.ts | 4 +--- tests/helpers/prompt.test.ts | 11 ++--------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/helpers/prompt.ts b/src/helpers/prompt.ts index 51220b38..96d5d377 100644 --- a/src/helpers/prompt.ts +++ b/src/helpers/prompt.ts @@ -139,9 +139,7 @@ function patchConsoleMethods(): void { * On non-Windows platforms only the cursor reset is applied. */ export async function withPromptFix(fn: () => Promise): Promise { - if (!isWindows()) return fn(); - - ensureNewlinePatches(); + if (isWindows()) ensureNewlinePatches(); const result = await fn(); resetCursor(); return result; diff --git a/tests/helpers/prompt.test.ts b/tests/helpers/prompt.test.ts index db005cb5..45056871 100644 --- a/tests/helpers/prompt.test.ts +++ b/tests/helpers/prompt.test.ts @@ -16,23 +16,16 @@ describe("withPromptFix", () => { ).rejects.toThrow("boom"); }); - // The Windows-specific patching (stream writes + console redirects) is a - // permanent, idempotent side effect that cannot be meaningfully asserted in - // unit tests — earlier tests in the full suite may have already triggered it. - // The behavior is verified manually on Windows PowerShell. test.skipIf(process.platform !== "win32")( - "applies newline patches on Windows (manual verification)", + "applies newline patches on Windows", async () => { await withPromptFix(() => Promise.resolve()); - // On Windows, process.stdout.write should be the patched version. - // We check the function name rather than reference equality because - // earlier tests in the suite may have already applied the patch. expect(process.stdout.write.name).toBe("patchedWrite"); } ); test.skipIf(process.platform === "win32")( - "is a pure passthrough on non-Windows", + "does not apply newline patches on non-Windows", async () => { const before = process.stdout.write; await withPromptFix(() => Promise.resolve()); From 1e8f897cf8238de4a42f95c1ad0af4f222310495 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 16 May 2026 00:53:29 +0200 Subject: [PATCH 11/11] fix: make withPromptFix a pure passthrough on non-Windows The cursor reset and all patches are Windows-only workarounds. On non-Windows, withPromptFix just calls fn() directly. Updated editor-detect cursor reset tests to skip on non-Windows with describe.skipIf since the behavior only applies on Windows. Signed-off-by: Rhuan Barreto --- src/helpers/prompt.ts | 4 +- tests/helpers/editor-detect.test.ts | 129 +++++++++++++++------------- 2 files changed, 71 insertions(+), 62 deletions(-) diff --git a/src/helpers/prompt.ts b/src/helpers/prompt.ts index 96d5d377..51220b38 100644 --- a/src/helpers/prompt.ts +++ b/src/helpers/prompt.ts @@ -139,7 +139,9 @@ function patchConsoleMethods(): void { * On non-Windows platforms only the cursor reset is applied. */ export async function withPromptFix(fn: () => Promise): Promise { - if (isWindows()) ensureNewlinePatches(); + if (!isWindows()) return fn(); + + ensureNewlinePatches(); const result = await fn(); resetCursor(); return result; diff --git a/tests/helpers/editor-detect.test.ts b/tests/helpers/editor-detect.test.ts index 8ba818f3..19037fc7 100644 --- a/tests/helpers/editor-detect.test.ts +++ b/tests/helpers/editor-detect.test.ts @@ -72,86 +72,93 @@ describe("editor-detect", () => { // subsequent output lines start at the wrong horizontal offset. // ------------------------------------------------------------------------- - describe("promptEditorSelection — cursor reset", () => { - const originalIsTTY = process.stdout.isTTY; - - beforeEach(() => { - mockCursorTo.mockClear(); - }); - - afterEach(() => { - // Restore isTTY to whatever the test runner had - Object.defineProperty(process.stdout, "isTTY", { - value: originalIsTTY, - writable: true, - configurable: true, + // Cursor reset is part of the Windows-only withPromptFix() workaround. + // These tests only run on Windows where the fix is active. + describe.skipIf(process.platform !== "win32")( + "promptEditorSelection — cursor reset (Windows)", + () => { + const originalIsTTY = process.stdout.isTTY; + + beforeEach(() => { + mockCursorTo.mockClear(); }); - }); - test("resets cursor to column 0 after prompt when stdout is TTY", async () => { - Object.defineProperty(process.stdout, "isTTY", { - value: true, - writable: true, - configurable: true, + afterEach(() => { + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + writable: true, + configurable: true, + }); }); - await promptEditorSelection(MOCK_DETECTED); + test("resets cursor to column 0 after prompt when stdout is TTY", async () => { + Object.defineProperty(process.stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); - expect(mockCursorTo).toHaveBeenCalledTimes(1); - expect(mockCursorTo).toHaveBeenCalledWith(process.stdout, 0); - }); + await promptEditorSelection(MOCK_DETECTED); - test("does not call cursorTo when stdout is not TTY", async () => { - Object.defineProperty(process.stdout, "isTTY", { - value: undefined, - writable: true, - configurable: true, + expect(mockCursorTo).toHaveBeenCalledTimes(1); + expect(mockCursorTo).toHaveBeenCalledWith(process.stdout, 0); }); - await promptEditorSelection(MOCK_DETECTED); + test("does not call cursorTo when stdout is not TTY", async () => { + Object.defineProperty(process.stdout, "isTTY", { + value: undefined, + writable: true, + configurable: true, + }); - expect(mockCursorTo).not.toHaveBeenCalled(); - }); - }); + await promptEditorSelection(MOCK_DETECTED); - describe("promptSingleEditorSelection — cursor reset", () => { - const originalIsTTY = process.stdout.isTTY; + expect(mockCursorTo).not.toHaveBeenCalled(); + }); + } + ); - beforeEach(() => { - mockCursorTo.mockClear(); - }); + describe.skipIf(process.platform !== "win32")( + "promptSingleEditorSelection — cursor reset (Windows)", + () => { + const originalIsTTY = process.stdout.isTTY; - afterEach(() => { - Object.defineProperty(process.stdout, "isTTY", { - value: originalIsTTY, - writable: true, - configurable: true, + beforeEach(() => { + mockCursorTo.mockClear(); }); - }); - test("resets cursor to column 0 after prompt when stdout is TTY", async () => { - Object.defineProperty(process.stdout, "isTTY", { - value: true, - writable: true, - configurable: true, + afterEach(() => { + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + writable: true, + configurable: true, + }); }); - await promptSingleEditorSelection(MOCK_DETECTED); + test("resets cursor to column 0 after prompt when stdout is TTY", async () => { + Object.defineProperty(process.stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); - expect(mockCursorTo).toHaveBeenCalledTimes(1); - expect(mockCursorTo).toHaveBeenCalledWith(process.stdout, 0); - }); + await promptSingleEditorSelection(MOCK_DETECTED); - test("does not call cursorTo when stdout is not TTY", async () => { - Object.defineProperty(process.stdout, "isTTY", { - value: undefined, - writable: true, - configurable: true, + expect(mockCursorTo).toHaveBeenCalledTimes(1); + expect(mockCursorTo).toHaveBeenCalledWith(process.stdout, 0); }); - await promptSingleEditorSelection(MOCK_DETECTED); + test("does not call cursorTo when stdout is not TTY", async () => { + Object.defineProperty(process.stdout, "isTTY", { + value: undefined, + writable: true, + configurable: true, + }); - expect(mockCursorTo).not.toHaveBeenCalled(); - }); - }); + await promptSingleEditorSelection(MOCK_DETECTED); + + expect(mockCursorTo).not.toHaveBeenCalled(); + }); + } + ); });