Passed the cmd.exe /s /c command line through Bun verbatim so configured editors and temporary paths retain their quotes. Added command-line regression coverage and documented the fix. Fixes #8544
99 lines
3.1 KiB
TypeScript
99 lines
3.1 KiB
TypeScript
/**
|
|
* Utilities for launching an external text editor ($VISUAL / $EDITOR).
|
|
*/
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { $env, $which, Snowflake } from "@oh-my-pi/pi-utils";
|
|
|
|
/**
|
|
* Returns the user's preferred editor command, or a platform default.
|
|
*
|
|
* Resolution order:
|
|
* 1. `$VISUAL`
|
|
* 2. `$EDITOR`
|
|
* 3. `notepad` on Windows (always present in `%SystemRoot%\System32`)
|
|
*
|
|
* POSIX returns `undefined` when neither variable is set so the caller can
|
|
* surface a warning that nudges the user to configure one.
|
|
*/
|
|
export function getEditorCommand(): string | undefined {
|
|
const configured = $env.VISUAL?.trim() || $env.EDITOR?.trim();
|
|
if (configured) return configured;
|
|
if (process.platform === "win32") return "notepad";
|
|
return undefined;
|
|
}
|
|
|
|
export interface OpenInEditorOptions {
|
|
/** File extension for the temp file (default: ".md"). */
|
|
extension?: string;
|
|
/** Custom stdio configuration (default: all "inherit"). */
|
|
stdio?: [number | "inherit", number | "inherit", number | "inherit"];
|
|
/** Keep the file's trailing newline instead of trimming it from the returned text. */
|
|
trimTrailingNewline?: boolean;
|
|
}
|
|
|
|
/** Subprocess argv and Windows quoting mode used to launch an external editor. */
|
|
export interface EditorSpawnCommand {
|
|
cmd: string[];
|
|
windowsVerbatimArguments: boolean;
|
|
}
|
|
|
|
/** Resolves shell argv without letting the host runtime re-quote the editor command. */
|
|
export function resolveEditorSpawnCommand(
|
|
editorCmd: string,
|
|
tmpFile: string,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): EditorSpawnCommand {
|
|
const windows = platform === "win32";
|
|
// cmd.exe strips the outer /s /c quote pair; Bun must pass the embedded
|
|
// editor/path quotes verbatim instead of applying argv escaping to them.
|
|
const cmd = windows
|
|
? ["cmd.exe", "/d", "/s", "/c", `"${editorCmd} "${tmpFile}""`]
|
|
: [$which("sh") ?? "sh", "-c", `${editorCmd} "$1"`, "sh", tmpFile];
|
|
return { cmd, windowsVerbatimArguments: windows };
|
|
}
|
|
|
|
/**
|
|
* Opens `content` in the user's external editor and returns the edited text.
|
|
* Returns `null` if the editor exits with a non-zero code.
|
|
*
|
|
* The caller is responsible for stopping/starting the TUI around this call.
|
|
*/
|
|
export async function openInEditor(
|
|
editorCmd: string,
|
|
content: string,
|
|
options?: OpenInEditorOptions,
|
|
): Promise<string | null> {
|
|
const ext = options?.extension ?? ".md";
|
|
const tmpFile = path.join(os.tmpdir(), `omp-editor-${Snowflake.next()}${ext}`);
|
|
|
|
try {
|
|
await Bun.write(tmpFile, content);
|
|
|
|
const spawnCommand = resolveEditorSpawnCommand(editorCmd, tmpFile);
|
|
const [stdin, stdout, stderr] = options?.stdio ?? ["inherit", "inherit", "inherit"];
|
|
const child = Bun.spawn(spawnCommand.cmd, {
|
|
stdin,
|
|
stdout,
|
|
stderr,
|
|
windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
|
|
});
|
|
const exitCode = await child.exited;
|
|
if (exitCode === 0) {
|
|
const text = await Bun.file(tmpFile).text();
|
|
if (options?.trimTrailingNewline === false) {
|
|
return text;
|
|
}
|
|
return text.replace(/\n$/, "");
|
|
}
|
|
return null;
|
|
} finally {
|
|
try {
|
|
await fs.rm(tmpFile, { force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
}
|