Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

89 lines · 3.0 KBRaw History
1import type { Readable } from "node:stream";
2
3/** Everything a command touches in the outside world, so tests can run the CLI in-process. */
4export interface Io {
5 env: NodeJS.ProcessEnv;
6 cwd: string;
7 stdin: Readable & { isTTY?: boolean };
8 stdout: { write(s: string): unknown; isTTY?: boolean };
9 stderr: { write(s: string): unknown; isTTY?: boolean };
10 /** Overridable for tests (polling loops). */
11 sleep(ms: number, signal?: AbortSignal): Promise<void>;
12}
13
14export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
15 return new Promise((resolve) => {
16 if (signal?.aborted) return resolve();
17 const t = setTimeout(done, ms);
18 function done() {
19 clearTimeout(t);
20 signal?.removeEventListener("abort", done);
21 resolve();
22 }
23 signal?.addEventListener("abort", done, { once: true });
24 });
25}
26
27export function processIo(): Io {
28 return { env: process.env, cwd: process.cwd(), stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, sleep };
29}
30
31/** A user-facing failure: printed as `error: message`, exits with `code`. */
32export class CliError extends Error {
33 constructor(
34 message: string,
35 readonly code = 1,
36 ) {
37 super(message);
38 }
39}
40
41/** Exit silently with a code (the command already printed what it wanted). */
42export class ExitCode extends Error {
43 constructor(readonly code: number) {
44 super(`exit ${code}`);
45 }
46}
47
48export async function readAll(stream: Readable): Promise<string> {
49 const chunks: Buffer[] = [];
50 for await (const c of stream) chunks.push(typeof c === "string" ? Buffer.from(c) : (c as Buffer));
51 return Buffer.concat(chunks).toString("utf8");
52}
53
54/** Reads one line from a TTY without echoing it (for tokens and secret values). */
55export async function promptHidden(io: Io, question: string): Promise<string> {
56 const stdin = io.stdin as NodeJS.ReadStream;
57 io.stderr.write(question);
58 return new Promise((resolve, reject) => {
59 let value = "";
60 const raw = typeof stdin.setRawMode === "function";
61 if (raw) stdin.setRawMode(true);
62 stdin.resume();
63 stdin.setEncoding("utf8");
64 const finish = (err?: Error) => {
65 stdin.off("data", onData);
66 if (raw) stdin.setRawMode(false);
67 stdin.pause();
68 io.stderr.write("\n");
69 if (err) reject(err);
70 else resolve(value);
71 };
72 const onData = (chunk: string) => {
73 for (const ch of chunk) {
74 if (ch === "\r" || ch === "\n" || ch === "\u0004") return finish();
75 if (ch === "\u0003") return finish(new CliError("cancelled", 130));
76 if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1);
77 else value += ch;
78 }
79 };
80 stdin.on("data", onData);
81 });
82}
83
84/** Value from a flag, else stdin when piped, else a hidden prompt. */
85export async function valueFromFlagOrStdin(io: Io, flag: string | undefined, prompt: string): Promise<string> {
86 if (flag !== undefined) return flag;
87 if (!io.stdin.isTTY) return (await readAll(io.stdin)).replace(/\r?\n$/, "");
88 return promptHidden(io, prompt);
89}