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.

104 lines · 3.7 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 /** Opens a URL in the browser (tests stand in for the browser); the default runs the system's opener. */
13 openUrl?(url: string): Promise<boolean>;
14 /** The file this CLI runs from, which `diffinery upgrade` replaces. */
15 selfPath?: string;
16}
17
18export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
19 return new Promise((resolve) => {
20 if (signal?.aborted) return resolve();
21 const t = setTimeout(done, ms);
22 function done() {
23 clearTimeout(t);
24 signal?.removeEventListener("abort", done);
25 resolve();
26 }
27 signal?.addEventListener("abort", done, { once: true });
28 });
29}
30
31export function processIo(): Io {
32 return { env: process.env, cwd: process.cwd(), stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, sleep, selfPath: process.argv[1] };
33}
34
35/** A user-facing failure: printed as `error: message`, exits with `code`. */
36export class CliError extends Error {
37 constructor(
38 message: string,
39 readonly code = 1,
40 ) {
41 super(message);
42 }
43}
44
45/** Exit silently with a code (the command already printed what it wanted). */
46export class ExitCode extends Error {
47 constructor(readonly code: number) {
48 super(`exit ${code}`);
49 }
50}
51
52export async function readAll(stream: Readable): Promise<string> {
53 const chunks: Buffer[] = [];
54 for await (const c of stream) chunks.push(typeof c === "string" ? Buffer.from(c) : (c as Buffer));
55 return Buffer.concat(chunks).toString("utf8");
56}
57
58/** Reads one line from a TTY without echoing it (for tokens and secret values). */
59export async function promptHidden(io: Io, question: string): Promise<string> {
60 const stdin = io.stdin as NodeJS.ReadStream;
61 io.stderr.write(question);
62 return new Promise((resolve, reject) => {
63 let value = "";
64 const raw = typeof stdin.setRawMode === "function";
65 if (raw) stdin.setRawMode(true);
66 stdin.resume();
67 stdin.setEncoding("utf8");
68 const finish = (err?: Error) => {
69 stdin.off("data", onData);
70 if (raw) stdin.setRawMode(false);
71 stdin.pause();
72 io.stderr.write("\n");
73 if (err) reject(err);
74 else resolve(value);
75 };
76 const onData = (chunk: string) => {
77 for (const ch of chunk) {
78 if (ch === "\r" || ch === "\n" || ch === "\u0004") return finish();
79 if (ch === "\u0003") return finish(new CliError("cancelled", 130));
80 if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1);
81 else value += ch;
82 }
83 };
84 stdin.on("data", onData);
85 });
86}
87
88/** Asks a yes-or-no question on a terminal; anything but y or yes is no. */
89export async function promptYesNo(io: Io, question: string): Promise<boolean> {
90 const { createInterface } = await import("node:readline/promises");
91 const rl = createInterface({ input: io.stdin, output: io.stderr as NodeJS.WritableStream, terminal: false });
92 try {
93 return /^\s*y(es)?\s*$/i.test(await rl.question(question));
94 } finally {
95 rl.close();
96 }
97}
98
99/** Value from a flag, else stdin when piped, else a hidden prompt. */
100export async function valueFromFlagOrStdin(io: Io, flag: string | undefined, prompt: string): Promise<string> {
101 if (flag !== undefined) return flag;
102 if (!io.stdin.isTTY) return (await readAll(io.stdin)).replace(/\r?\n$/, "");
103 return promptHidden(io, prompt);
104}