Spaces:
Running
Running
File size: 4,174 Bytes
fb4d8fe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | import type { Component, SelectItem } from "@mariozechner/pi-tui";
import { spawn } from "node:child_process";
import { createSearchableSelectList } from "./components/selectors.js";
type LocalShellDeps = {
chatLog: {
addSystem: (line: string) => void;
};
tui: {
requestRender: () => void;
};
openOverlay: (component: Component) => void;
closeOverlay: () => void;
createSelector?: (
items: SelectItem[],
maxVisible: number,
) => Component & {
onSelect?: (item: SelectItem) => void;
onCancel?: () => void;
};
spawnCommand?: typeof spawn;
getCwd?: () => string;
env?: NodeJS.ProcessEnv;
maxOutputChars?: number;
};
export function createLocalShellRunner(deps: LocalShellDeps) {
let localExecAsked = false;
let localExecAllowed = false;
const createSelector = deps.createSelector ?? createSearchableSelectList;
const spawnCommand = deps.spawnCommand ?? spawn;
const getCwd = deps.getCwd ?? (() => process.cwd());
const env = deps.env ?? process.env;
const maxChars = deps.maxOutputChars ?? 40_000;
const ensureLocalExecAllowed = async (): Promise<boolean> => {
if (localExecAllowed) {
return true;
}
if (localExecAsked) {
return false;
}
localExecAsked = true;
return await new Promise<boolean>((resolve) => {
deps.chatLog.addSystem("Allow local shell commands for this session?");
deps.chatLog.addSystem(
"This runs commands on YOUR machine (not the gateway) and may delete files or reveal secrets.",
);
deps.chatLog.addSystem("Select Yes/No (arrows + Enter), Esc to cancel.");
const selector = createSelector(
[
{ value: "no", label: "No" },
{ value: "yes", label: "Yes" },
],
2,
);
selector.onSelect = (item) => {
deps.closeOverlay();
if (item.value === "yes") {
localExecAllowed = true;
deps.chatLog.addSystem("local shell: enabled for this session");
resolve(true);
} else {
deps.chatLog.addSystem("local shell: not enabled");
resolve(false);
}
deps.tui.requestRender();
};
selector.onCancel = () => {
deps.closeOverlay();
deps.chatLog.addSystem("local shell: cancelled");
deps.tui.requestRender();
resolve(false);
};
deps.openOverlay(selector);
deps.tui.requestRender();
});
};
const runLocalShellLine = async (line: string) => {
const cmd = line.slice(1);
// NOTE: A lone '!' is handled by the submit handler as a normal message.
// Keep this guard anyway in case this is called directly.
if (cmd === "") {
return;
}
if (localExecAsked && !localExecAllowed) {
deps.chatLog.addSystem("local shell: not enabled for this session");
deps.tui.requestRender();
return;
}
const allowed = await ensureLocalExecAllowed();
if (!allowed) {
return;
}
deps.chatLog.addSystem(`[local] $ ${cmd}`);
deps.tui.requestRender();
await new Promise<void>((resolve) => {
const child = spawnCommand(cmd, {
shell: true,
cwd: getCwd(),
env,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (buf) => {
stdout += buf.toString("utf8");
});
child.stderr.on("data", (buf) => {
stderr += buf.toString("utf8");
});
child.on("close", (code, signal) => {
const combined = (stdout + (stderr ? (stdout ? "\n" : "") + stderr : ""))
.slice(0, maxChars)
.trimEnd();
if (combined) {
for (const line of combined.split("\n")) {
deps.chatLog.addSystem(`[local] ${line}`);
}
}
deps.chatLog.addSystem(
`[local] exit ${code ?? "?"}${signal ? ` (signal ${String(signal)})` : ""}`,
);
deps.tui.requestRender();
resolve();
});
child.on("error", (err) => {
deps.chatLog.addSystem(`[local] error: ${String(err)}`);
deps.tui.requestRender();
resolve();
});
});
};
return { runLocalShellLine };
}
|