diff --git a/src/index.ts b/src/index.ts index 5632f09..7d2554c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { selfUpgrade } from "./upgrade"; import { detectOS } from "./utils"; import { color } from "./ui"; import { showCursor, clearScreen } from "./renderer"; +import { openTerminalInput } from "./terminal"; const VERSION = process.env.DOT_VERSION || "dev"; @@ -96,19 +97,28 @@ export async function main(): Promise { } const isTty = process.stdin.isTTY ?? false; - const options = { dryRun: args.dryRun, verbose: args.verbose, interactive: isTty && args.mode === "direct" }; if (args.mode === "interactive") { + const terminalInput = isTty ? null : openTerminalInput(); if (!isTty) { - process.stderr.write(`${color("[error]", "red")} Interactive mode requires a terminal. Use --install, --link, or --dry-run flags for non-interactive use.\n`); - process.exit(1); + if (!terminalInput) { + process.stderr.write(`${color("[error]", "red")} Interactive mode requires a terminal. Use --install, --link, or --dry-run flags for non-interactive use.\n`); + process.exit(1); + } + } + + let selected; + try { + selected = await runInteractive(resolved, terminalInput || process.stdin); + } finally { + terminalInput?.destroy(); } - const selected = await runInteractive(resolved); if (selected.length === 0) { process.exit(0); } const action = args.interactiveAction; + const options = { dryRun: args.dryRun, verbose: args.verbose, interactive: true }; for (const item of selected) { if (item.unavailable) continue; @@ -161,6 +171,7 @@ export async function main(): Promise { } if (args.mode === "direct") { + const options = { dryRun: args.dryRun, verbose: args.verbose, interactive: isTty }; const names = resolved.map((c: { name: string }) => c.name); if (args.list) { diff --git a/src/installer.ts b/src/installer.ts index 81ebc8a..9dd87cd 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -14,6 +14,23 @@ export interface RunResult { manager?: string; } +async function runNonInteractive(command: string): Promise<{ exitCode: number; stderr: Buffer }> { + const shellCommand = process.platform === "win32" + ? [process.env.ComSpec || "cmd.exe", "/d", "/s", "/c", command] + : [Bun.which("bash") || "/bin/sh", "-c", command]; + const child = Bun.spawn(shellCommand, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).arrayBuffer(), + new Response(child.stdout).arrayBuffer(), + ]); + return { exitCode, stderr: Buffer.from(stderr) }; +} + export async function installComponent( name: string, command: string | null, @@ -42,7 +59,7 @@ export async function installComponent( if (options.interactive) { result = await Bun.$`${{ raw: command }}`.nothrow().quiet(); } else { - result = await Bun.$`${{ raw: command }} < /dev/null`.nothrow().quiet(); + result = await runNonInteractive(command); } if (result.exitCode !== 0) { if (options.verbose) { @@ -92,7 +109,7 @@ export async function uninstallComponent( if (options.interactive) { result = await Bun.$`${{ raw: command }}`.nothrow().quiet(); } else { - result = await Bun.$`${{ raw: command }} < /dev/null`.nothrow().quiet(); + result = await runNonInteractive(command); } if (result.exitCode !== 0) { return { ...base, failed: true }; diff --git a/src/interactive.ts b/src/interactive.ts index 6bee65a..02adc74 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -39,7 +39,10 @@ export function buildChecklist(components: ResolvedComponent[]): CheckboxItem[] return items; } -export async function runInteractive(components: ResolvedComponent[]): Promise { +export async function runInteractive( + components: ResolvedComponent[], + stdin: NodeJS.ReadStream = process.stdin +): Promise { const items = buildChecklist(components); const response = await prompts({ @@ -66,6 +69,7 @@ export async function runInteractive(components: ResolvedComponent[]): Promise Promise.resolve( diff --git a/src/terminal.ts b/src/terminal.ts new file mode 100644 index 0000000..0ab2aa7 --- /dev/null +++ b/src/terminal.ts @@ -0,0 +1,17 @@ +import { closeSync, openSync } from "node:fs"; +import { ReadStream } from "node:tty"; + +export function openTerminalInput(terminalPath = "/dev/tty"): ReadStream | null { + if (process.platform === "win32") { + return null; + } + + let terminalFd: number | null = null; + try { + terminalFd = openSync(terminalPath, "r"); + return new ReadStream(terminalFd); + } catch { + if (terminalFd !== null) closeSync(terminalFd); + return null; + } +} diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 8acbc26..a11bb20 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -1,5 +1,18 @@ -import { describe, test, expect } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { installComponent, uninstallComponent } from "../src/installer"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "dot-installer-")); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); describe("installComponent", () => { test("returns success for echo command", async () => { @@ -34,6 +47,18 @@ describe("installComponent", () => { const result = await installComponent("custom", null as any, { dryRun: false, verbose: false, interactive: false }); expect(result.failed).toBe(true); }); + + test("non-interactive commands preserve pipeline input", async () => { + const marker = join(tmp, "mise-installed"); + const result = await installComponent( + "mise", + `printf 'touch ${marker}' | sh`, + { dryRun: false, verbose: false, interactive: false } + ); + + expect(result.success).toBe(true); + expect(existsSync(marker)).toBe(true); + }); }); describe("uninstallComponent", () => { diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 43ca825..ca69c4e 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -2,9 +2,11 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { tmpdir } from "node:os"; import { mkdtempSync, writeFileSync, rmSync, existsSync, readlinkSync, mkdirSync } from "node:fs"; import { join } from "node:path"; +import prompts from "prompts"; import { parseConfig, resolveComponents } from "../src/config"; import { resolveComponentNames } from "../src/fuzzy"; import { createLinks } from "../src/linker"; +import { main } from "../src/index"; function makeTempDir(): string { return mkdtempSync(join(tmpdir(), "dot-integration-")); @@ -61,6 +63,37 @@ postinstall = "echo 'git configured'" expect(existsSync(join(homeDir, ".config", "git", "config"))).toBe(true); }); + test("interactive installs preserve pipeline input", async () => { + const marker = join(repoDir, "mise-installed"); + writeFileSync(join(repoDir, "dot.toml"), ` +[mise] +install.any = "printf 'touch ${marker}' | sh" +`); + + const originalArgv = process.argv; + const originalCwd = process.cwd(); + const originalIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + + try { + process.argv = ["dot"]; + process.chdir(repoDir); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + prompts.inject([["mise"]]); + + await main(); + + expect(existsSync(marker)).toBe(true); + } finally { + process.argv = originalArgv; + process.chdir(originalCwd); + if (originalIsTty) { + Object.defineProperty(process.stdin, "isTTY", originalIsTty); + } else { + delete (process.stdin as any).isTTY; + } + } + }); + test("dry run does not create links", async () => { const configToml = ` [zsh] diff --git a/tests/terminal.test.ts b/tests/terminal.test.ts new file mode 100644 index 0000000..e92f456 --- /dev/null +++ b/tests/terminal.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { openTerminalInput } from "../src/terminal"; + +describe("openTerminalInput", () => { + test("returns null when no terminal can be opened", async () => { + const result = openTerminalInput("/definitely/missing/dot-terminal"); + + expect(result).toBeNull(); + }); +});