Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -96,19 +97,28 @@ export async function main(): Promise<void> {
}

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;
Expand Down Expand Up @@ -161,6 +171,7 @@ export async function main(): Promise<void> {
}

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) {
Expand Down
21 changes: 19 additions & 2 deletions src/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
Expand Down
6 changes: 5 additions & 1 deletion src/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export function buildChecklist(components: ResolvedComponent[]): CheckboxItem[]
return items;
}

export async function runInteractive(components: ResolvedComponent[]): Promise<CheckboxItem[]> {
export async function runInteractive(
components: ResolvedComponent[],
stdin: NodeJS.ReadStream = process.stdin
): Promise<CheckboxItem[]> {
const items = buildChecklist(components);

const response = await prompts({
Expand All @@ -66,6 +69,7 @@ export async function runInteractive(components: ResolvedComponent[]): Promise<C
}),
hint: "✓=done ⚠=no install method (type to filter, space to select, enter to confirm)",
instructions: false,
stdin,
optionsPerPage: process.stdout.rows ? process.stdout.rows - 10 : 30,
suggest: (input: string, choices: any[]) =>
Promise.resolve(
Expand Down
17 changes: 17 additions & 0 deletions src/terminal.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
27 changes: 26 additions & 1 deletion tests/installer.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
33 changes: 33 additions & 0 deletions tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 10 additions & 0 deletions tests/terminal.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading