pi-opencode-cli-bridge 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/SECURITY.md +18 -0
- package/package.json +73 -4
- package/src/index.ts +1977 -0
- package/src/process.ts +112 -0
package/src/process.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
|
|
4
|
+
export type ProcessOptions = {
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
input?: string | (() => string | Promise<string>);
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
onStart?: () => void | Promise<void>;
|
|
10
|
+
onStdout?: (chunk: string) => void | Promise<void>;
|
|
11
|
+
onEnd?: () => void | Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Own the child, its pipes and cancellation, including async consumer failures. */
|
|
15
|
+
export async function runOpenCodeProcess(
|
|
16
|
+
executable: string,
|
|
17
|
+
args: string[],
|
|
18
|
+
options: ProcessOptions,
|
|
19
|
+
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
20
|
+
if (options.signal?.aborted) throw new Error("Request was aborted");
|
|
21
|
+
const child = spawn(executable, args, {
|
|
22
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
23
|
+
env: options.env,
|
|
24
|
+
// The npm launcher can spawn the native CLI; terminate that entire group.
|
|
25
|
+
detached: process.platform !== "win32",
|
|
26
|
+
});
|
|
27
|
+
let stdout = "";
|
|
28
|
+
let stderr = "";
|
|
29
|
+
let failure: Error | undefined;
|
|
30
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
31
|
+
let rejectStopped: (error: Error) => void;
|
|
32
|
+
const stopped = new Promise<never>((_, reject) => { rejectStopped = reject; });
|
|
33
|
+
const signalChild = (signal: NodeJS.Signals) => {
|
|
34
|
+
if (!child.pid) return;
|
|
35
|
+
try {
|
|
36
|
+
if (process.platform === "win32") {
|
|
37
|
+
// child.kill would stop only the launcher; /T also ends the native CLI it spawned.
|
|
38
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
|
|
39
|
+
.on("error", () => child.kill(signal));
|
|
40
|
+
} else process.kill(-child.pid, signal);
|
|
41
|
+
} catch { /* The child may already have exited. */ }
|
|
42
|
+
};
|
|
43
|
+
const stop = (error: Error) => {
|
|
44
|
+
if (failure) return;
|
|
45
|
+
failure = error;
|
|
46
|
+
signalChild("SIGTERM");
|
|
47
|
+
killTimer = setTimeout(() => {
|
|
48
|
+
signalChild("SIGKILL");
|
|
49
|
+
child.stdin?.destroy();
|
|
50
|
+
child.stdout.destroy();
|
|
51
|
+
child.stderr.destroy();
|
|
52
|
+
// Let close reap the killed child, but never wait on inherited pipes forever.
|
|
53
|
+
killTimer = setTimeout(() => rejectStopped(error), 250);
|
|
54
|
+
}, 250);
|
|
55
|
+
};
|
|
56
|
+
const closed = new Promise<number | null>((resolve, reject) => {
|
|
57
|
+
child.once("error", reject);
|
|
58
|
+
child.once("close", (code) => {
|
|
59
|
+
if (failure) rejectStopped(failure);
|
|
60
|
+
resolve(code);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
// Observe spawn failures immediately, even while a start callback is awaiting.
|
|
64
|
+
void closed.catch(() => undefined);
|
|
65
|
+
// A CLI that quits before reading its prompt explains why via stderr and exit code.
|
|
66
|
+
child.stdin?.on("error", (error: NodeJS.ErrnoException) => {
|
|
67
|
+
if (error.code !== "EPIPE") stop(error);
|
|
68
|
+
});
|
|
69
|
+
child.stdout.setEncoding("utf8");
|
|
70
|
+
child.stderr.setEncoding("utf8");
|
|
71
|
+
child.stderr.on("data", (chunk: string) => {
|
|
72
|
+
stderr = (stderr + chunk).slice(-20_000);
|
|
73
|
+
});
|
|
74
|
+
const abort = () => stop(new Error("Request was aborted"));
|
|
75
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
76
|
+
if (options.signal?.aborted) abort();
|
|
77
|
+
const timer = setTimeout(
|
|
78
|
+
() => stop(new Error(`opencode timed out after ${options.timeoutMs}ms`)),
|
|
79
|
+
options.timeoutMs,
|
|
80
|
+
);
|
|
81
|
+
const consume = async () => {
|
|
82
|
+
await once(child, "spawn");
|
|
83
|
+
const input = typeof options.input === "function" ? await options.input() : options.input;
|
|
84
|
+
if (failure) throw failure;
|
|
85
|
+
await options.onStart?.();
|
|
86
|
+
if (failure) throw failure;
|
|
87
|
+
child.stdin.end(input);
|
|
88
|
+
for await (const chunk of child.stdout) {
|
|
89
|
+
if (options.onStdout) await options.onStdout(String(chunk));
|
|
90
|
+
else stdout += chunk;
|
|
91
|
+
}
|
|
92
|
+
await options.onEnd?.();
|
|
93
|
+
const code = await closed;
|
|
94
|
+
if (failure) throw failure;
|
|
95
|
+
return { stdout, stderr, code };
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([consume(), stopped]);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
stop(error instanceof Error ? error : new Error(String(error)));
|
|
101
|
+
await Promise.race([closed.catch(() => undefined), stopped.catch(() => undefined)]);
|
|
102
|
+
throw failure;
|
|
103
|
+
} finally {
|
|
104
|
+
if (failure) signalChild("SIGKILL");
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
clearTimeout(killTimer);
|
|
107
|
+
options.signal?.removeEventListener("abort", abort);
|
|
108
|
+
child.stdin?.destroy();
|
|
109
|
+
child.stdout.destroy();
|
|
110
|
+
child.stderr.destroy();
|
|
111
|
+
}
|
|
112
|
+
}
|