-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtiny-runloop-repl.ts
More file actions
110 lines (90 loc) · 3.01 KB
/
tiny-runloop-repl.ts
File metadata and controls
110 lines (90 loc) · 3.01 KB
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
import path from 'node:path';
import * as readline from 'node:readline';
import type { Devbox } from '@runloop/api-client';
/**
* Options parameter for the @see tinyRunloopRepl function.
*/
export interface IRunloopReplOptions {
/** The current working directory. */
cwd: string | undefined;
}
function expandTilde(filePath: string, homedir: string): string {
return filePath.startsWith('~') ? path.posix.join(homedir, filePath.slice(1)) : filePath;
}
class ShellState {
#homedir: string;
#cwd: string;
#devbox: Devbox;
private constructor(homedir: string, cwd: string, devbox: Devbox) {
this.#homedir = homedir;
this.#cwd = cwd;
this.#devbox = devbox;
}
static async create(devbox: Devbox, cwd: string): Promise<ShellState> {
const homedir = await ShellState.probeHome(devbox);
return new ShellState(homedir, expandTilde(cwd, homedir), devbox);
}
/** Execute a shell command. Handles `cd` gracefully enough. */
async run(cmd: string): Promise<[number | null, string | null, string | null] | null> {
const trimmed: string = cmd.trim();
if (!trimmed) {
return null;
}
const splitCommand = trimmed.split(/\s+/);
if (splitCommand[0] === 'cd') {
const target = splitCommand[1] ?? '~';
this.#cwd = path.posix.isAbsolute(target)
? splitCommand[1]
: path.posix.join(this.#cwd, this.expandTilde(target));
return null;
}
const result = await this.#devbox.cmd.exec(`cd ${this.#cwd} && ${trimmed}`);
return [result.exitCode, await result.stdout(), await result.stderr()];
}
private static async probeHome(devbox: Devbox): Promise<string> {
const result = await devbox.cmd.exec('echo $HOME');
const stdout = await result.stdout();
return stdout.trim();
}
private expandTilde(filePath: string): string {
return expandTilde(filePath, this.#homedir);
}
}
function question(rl: readline.Interface, prompt: string): Promise<string | null> {
return new Promise((resolve) => {
rl.once('close', () => resolve(null));
rl.question(prompt, (answer) => resolve(answer));
});
}
/**
* Spawns the REPL so that users can use it.
*
* This is a tiny REPL and is not a full shell, so it has an incredibly limited subset of regular shell commands.
*/
export default async function tinyRunloopRepl(devbox: Devbox, options: IRunloopReplOptions | undefined): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const shell = await ShellState.create(devbox, options?.cwd ?? '~');
while (true) {
const input = await question(rl, '$ ');
if (input === null) {
break;
}
const res = await shell.run(input);
if (res === null) {
continue;
}
const [exitCode, stdout, stderr] = res;
if (stdout) {
process.stdout.write(stdout);
}
if (stderr) {
process.stderr.write(stderr);
}
if (exitCode !== null && exitCode !== 0) {
console.error(`The process exited with a non-zero exit code: ${exitCode}`);
}
}
}