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/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 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 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..6f370ba4 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -16,17 +16,15 @@ import type { EditorTarget } from "../../helpers/init-project"; import { logError, logInfo, logWarn } from "../../helpers/log"; import { findProjectRoot } from "../../helpers/paths"; import { + buildCursorMarketplaceUrl, buildMarketplaceUrl, buildVscodeMarketplaceUrl, - downloadVsix, installClaudePlugin, installCopilotPlugin, - installCursorPlugin, installOpencodePlugin, installVscodeExtension, isClaudeCliAvailable, isCopilotCliAvailable, - isCursorCliAvailable, isOpencodeCliAvailable, isVscodeCliAvailable, } from "../../helpers/plugin-install"; @@ -76,17 +74,16 @@ 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 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; } case "opencode": { @@ -110,10 +107,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}.`); @@ -159,12 +152,11 @@ 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` - ); + const url = buildCursorMarketplaceUrl(); + logInfo("Add the team marketplace URL in Cursor Settings:"); + console.log(` ${styleText("bold", url)}`); console.log( - ` Then in Cursor: Ctrl+Shift+P → ${styleText("bold", "Extensions: Install from VSIX...")} → select archgate.vsix` + ` Cursor Settings → Extensions → Team Private Plugin Marketplaces → Add URL` ); break; } 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/init-project.ts b/src/helpers/init-project.ts index 62285f47..e293fa84 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 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") { 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..0fd3b51b 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -21,20 +21,23 @@ 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 }; } // --------------------------------------------------------------------------- @@ -146,60 +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); - - 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 - throw new Error( - `cursor --install-extension failed (exit ${result.exitCode}). ` + - `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 // --------------------------------------------------------------------------- @@ -287,9 +236,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..51220b38 --- /dev/null +++ b/src/helpers/prompt.ts @@ -0,0 +1,156 @@ +// 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, 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. + * + * 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 + * + * Until the runtime fixes this, we work around it by: + * + * 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. + * + * 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"; +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 { + for (const method of ["log", "info"] as const) { + console[method] = ((...args: unknown[]) => { + process.stdout.write(format(...args) + "\n"); + }) as typeof console.log; + } + for (const method of ["error", "warn", "debug"] as const) { + 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 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()) return fn(); + + ensureNewlinePatches(); + const result = await fn(); + resetCursor(); + return result; +} + +// --------------------------------------------------------------------------- +// Cursor reset +// --------------------------------------------------------------------------- + +function resetCursor(): void { + if (process.stdout.isTTY) cursorTo(process.stdout, 0); +} 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/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(); + }); + } + ); }); diff --git a/tests/helpers/prompt.test.ts b/tests/helpers/prompt.test.ts new file mode 100644 index 00000000..45056871 --- /dev/null +++ b/tests/helpers/prompt.test.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test } from "bun:test"; + +import { withPromptFix } from "../../src/helpers/prompt"; + +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"); + }); + + test.skipIf(process.platform !== "win32")( + "applies newline patches on Windows", + async () => { + await withPromptFix(() => Promise.resolve()); + expect(process.stdout.write.name).toBe("patchedWrite"); + } + ); + + test.skipIf(process.platform === "win32")( + "does not apply newline patches on non-Windows", + async () => { + const before = process.stdout.write; + await withPromptFix(() => Promise.resolve()); + expect(process.stdout.write).toBe(before); + } + ); +}); 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");