2024-11-08 16:17:31 +00:00
|
|
|
|
import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
|
2024-07-29 12:37:23 +00:00
|
|
|
|
import type { ITerminal } from '~/types/terminal';
|
|
|
|
|
import { withResolvers } from './promises';
|
2024-11-08 16:17:31 +00:00
|
|
|
|
import { atom } from 'nanostores';
|
2024-07-29 12:37:23 +00:00
|
|
|
|
|
|
|
|
|
export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
|
|
|
|
|
const args: string[] = [];
|
|
|
|
|
|
|
|
|
|
// we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal
|
|
|
|
|
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
|
|
|
|
|
terminal: {
|
|
|
|
|
cols: terminal.cols ?? 80,
|
|
|
|
|
rows: terminal.rows ?? 15,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const input = process.input.getWriter();
|
|
|
|
|
const output = process.output;
|
|
|
|
|
|
|
|
|
|
const jshReady = withResolvers<void>();
|
|
|
|
|
|
|
|
|
|
let isInteractive = false;
|
|
|
|
|
output.pipeTo(
|
|
|
|
|
new WritableStream({
|
|
|
|
|
write(data) {
|
|
|
|
|
if (!isInteractive) {
|
|
|
|
|
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
|
|
|
|
|
|
|
|
|
|
if (osc === 'interactive') {
|
|
|
|
|
// wait until we see the interactive OSC
|
|
|
|
|
isInteractive = true;
|
|
|
|
|
|
|
|
|
|
jshReady.resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
terminal.write(data);
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
terminal.onData((data) => {
|
2024-11-08 16:17:31 +00:00
|
|
|
|
// console.log('terminal onData', { data, isInteractive });
|
|
|
|
|
|
2024-07-29 12:37:23 +00:00
|
|
|
|
if (isInteractive) {
|
|
|
|
|
input.write(data);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await jshReady.promise;
|
|
|
|
|
|
|
|
|
|
return process;
|
|
|
|
|
}
|
2024-11-08 16:17:31 +00:00
|
|
|
|
|
2024-11-21 21:27:29 +00:00
|
|
|
|
export type ExecutionResult = { output: string; exitCode: number } | undefined;
|
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
export class BoltShell {
|
2024-11-21 21:05:35 +00:00
|
|
|
|
#initialized: (() => void) | undefined;
|
|
|
|
|
#readyPromise: Promise<void>;
|
|
|
|
|
#webcontainer: WebContainer | undefined;
|
|
|
|
|
#terminal: ITerminal | undefined;
|
|
|
|
|
#process: WebContainerProcess | undefined;
|
2024-12-17 15:49:43 +00:00
|
|
|
|
executionState = atom<
|
|
|
|
|
{ sessionId: string; active: boolean; executionPrms?: Promise<any>; abort?: () => void } | undefined
|
|
|
|
|
>();
|
2024-11-21 21:05:35 +00:00
|
|
|
|
#outputStream: ReadableStreamDefaultReader<string> | undefined;
|
|
|
|
|
#shellInputStream: WritableStreamDefaultWriter<string> | undefined;
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
constructor() {
|
|
|
|
|
this.#readyPromise = new Promise((resolve) => {
|
2024-11-21 21:05:35 +00:00
|
|
|
|
this.#initialized = resolve;
|
|
|
|
|
});
|
2024-11-08 16:17:31 +00:00
|
|
|
|
}
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
ready() {
|
|
|
|
|
return this.#readyPromise;
|
|
|
|
|
}
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
async init(webcontainer: WebContainer, terminal: ITerminal) {
|
2024-11-21 21:05:35 +00:00
|
|
|
|
this.#webcontainer = webcontainer;
|
|
|
|
|
this.#terminal = terminal;
|
|
|
|
|
|
|
|
|
|
const { process, output } = await this.newBoltShellProcess(webcontainer, terminal);
|
|
|
|
|
this.#process = process;
|
|
|
|
|
this.#outputStream = output.getReader();
|
|
|
|
|
await this.waitTillOscCode('interactive');
|
|
|
|
|
this.#initialized?.();
|
2024-11-08 16:17:31 +00:00
|
|
|
|
}
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
get terminal() {
|
2024-11-21 21:05:35 +00:00
|
|
|
|
return this.#terminal;
|
2024-11-08 16:17:31 +00:00
|
|
|
|
}
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
get process() {
|
2024-11-21 21:05:35 +00:00
|
|
|
|
return this.#process;
|
2024-11-08 16:17:31 +00:00
|
|
|
|
}
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
2024-12-17 15:49:43 +00:00
|
|
|
|
async executeCommand(sessionId: string, command: string, abort?: () => void): Promise<ExecutionResult> {
|
2024-11-08 16:17:31 +00:00
|
|
|
|
if (!this.process || !this.terminal) {
|
2024-11-21 21:27:29 +00:00
|
|
|
|
return undefined;
|
2024-11-08 16:17:31 +00:00
|
|
|
|
}
|
2024-11-09 07:29:42 +00:00
|
|
|
|
|
2024-11-21 21:05:35 +00:00
|
|
|
|
const state = this.executionState.get();
|
|
|
|
|
|
2024-12-17 15:49:43 +00:00
|
|
|
|
if (state?.active && state.abort) {
|
|
|
|
|
state.abort();
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-21 21:05:35 +00:00
|
|
|
|
/*
|
|
|
|
|
* interrupt the current execution
|
|
|
|
|
* this.#shellInputStream?.write('\x03');
|
|
|
|
|
*/
|
2024-11-09 07:29:42 +00:00
|
|
|
|
this.terminal.input('\x03');
|
2024-12-17 09:27:16 +00:00
|
|
|
|
await this.waitTillOscCode('prompt');
|
2024-11-21 21:05:35 +00:00
|
|
|
|
|
2024-11-09 07:29:42 +00:00
|
|
|
|
if (state && state.executionPrms) {
|
2024-11-21 21:05:35 +00:00
|
|
|
|
await state.executionPrms;
|
2024-11-08 16:17:31 +00:00
|
|
|
|
}
|
2024-11-21 21:05:35 +00:00
|
|
|
|
|
2024-11-09 07:29:42 +00:00
|
|
|
|
//start a new execution
|
2024-11-08 16:17:31 +00:00
|
|
|
|
this.terminal.input(command.trim() + '\n');
|
2024-11-09 07:29:42 +00:00
|
|
|
|
|
|
|
|
|
//wait for the execution to finish
|
2024-11-21 21:27:29 +00:00
|
|
|
|
const executionPromise = this.getCurrentExecutionResult();
|
2024-12-17 15:49:43 +00:00
|
|
|
|
this.executionState.set({ sessionId, active: true, executionPrms: executionPromise, abort });
|
2024-11-09 07:29:42 +00:00
|
|
|
|
|
2024-11-21 21:27:29 +00:00
|
|
|
|
const resp = await executionPromise;
|
2024-11-21 21:05:35 +00:00
|
|
|
|
this.executionState.set({ sessionId, active: false });
|
2024-11-08 16:17:31 +00:00
|
|
|
|
|
2024-12-17 15:49:43 +00:00
|
|
|
|
if (resp) {
|
|
|
|
|
try {
|
|
|
|
|
resp.output = cleanTerminalOutput(resp.output);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log('failed to format terminal output', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-21 21:05:35 +00:00
|
|
|
|
return resp;
|
2024-11-08 16:17:31 +00:00
|
|
|
|
}
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
|
|
|
|
|
const args: string[] = [];
|
|
|
|
|
|
|
|
|
|
// we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal
|
|
|
|
|
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
|
|
|
|
|
terminal: {
|
|
|
|
|
cols: terminal.cols ?? 80,
|
|
|
|
|
rows: terminal.rows ?? 15,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const input = process.input.getWriter();
|
2024-11-09 07:29:42 +00:00
|
|
|
|
this.#shellInputStream = input;
|
2024-11-21 21:05:35 +00:00
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
const [internalOutput, terminalOutput] = process.output.tee();
|
|
|
|
|
|
|
|
|
|
const jshReady = withResolvers<void>();
|
|
|
|
|
|
|
|
|
|
let isInteractive = false;
|
|
|
|
|
terminalOutput.pipeTo(
|
|
|
|
|
new WritableStream({
|
|
|
|
|
write(data) {
|
|
|
|
|
if (!isInteractive) {
|
|
|
|
|
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
|
|
|
|
|
|
|
|
|
|
if (osc === 'interactive') {
|
|
|
|
|
// wait until we see the interactive OSC
|
|
|
|
|
isInteractive = true;
|
|
|
|
|
|
|
|
|
|
jshReady.resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
terminal.write(data);
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
terminal.onData((data) => {
|
|
|
|
|
// console.log('terminal onData', { data, isInteractive });
|
|
|
|
|
|
|
|
|
|
if (isInteractive) {
|
|
|
|
|
input.write(data);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await jshReady.promise;
|
|
|
|
|
|
|
|
|
|
return { process, output: internalOutput };
|
|
|
|
|
}
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
|
|
|
|
async getCurrentExecutionResult(): Promise<ExecutionResult> {
|
2024-11-21 21:05:35 +00:00
|
|
|
|
const { output, exitCode } = await this.waitTillOscCode('exit');
|
2024-11-09 07:29:42 +00:00
|
|
|
|
return { output, exitCode };
|
|
|
|
|
}
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
2024-11-09 07:29:42 +00:00
|
|
|
|
async waitTillOscCode(waitCode: string) {
|
2024-11-08 16:17:31 +00:00
|
|
|
|
let fullOutput = '';
|
|
|
|
|
let exitCode: number = 0;
|
2024-11-21 21:05:35 +00:00
|
|
|
|
|
|
|
|
|
if (!this.#outputStream) {
|
|
|
|
|
return { output: fullOutput, exitCode };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tappedStream = this.#outputStream;
|
2024-11-08 16:17:31 +00:00
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
const { value, done } = await tappedStream.read();
|
2024-11-21 21:05:35 +00:00
|
|
|
|
|
|
|
|
|
if (done) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
const text = value || '';
|
|
|
|
|
fullOutput += text;
|
|
|
|
|
|
|
|
|
|
// Check if command completion signal with exit code
|
2024-11-21 21:27:29 +00:00
|
|
|
|
const [, osc, , , code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];
|
2024-11-21 21:05:35 +00:00
|
|
|
|
|
2024-11-09 07:29:42 +00:00
|
|
|
|
if (osc === 'exit') {
|
|
|
|
|
exitCode = parseInt(code, 10);
|
|
|
|
|
}
|
2024-11-21 21:05:35 +00:00
|
|
|
|
|
2024-11-09 07:29:42 +00:00
|
|
|
|
if (osc === waitCode) {
|
2024-11-08 16:17:31 +00:00
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-11-21 21:05:35 +00:00
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
return { output: fullOutput, exitCode };
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-11-21 21:27:29 +00:00
|
|
|
|
|
2024-12-17 15:49:43 +00:00
|
|
|
|
/**
|
|
|
|
|
* Cleans and formats terminal output while preserving structure and paths
|
|
|
|
|
*/
|
|
|
|
|
export function cleanTerminalOutput(input: string): string {
|
|
|
|
|
// Step 1: Remove ANSI escape sequences and control characters
|
|
|
|
|
const removeAnsi = input.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '');
|
|
|
|
|
|
|
|
|
|
// Step 2: Remove terminal control sequences
|
|
|
|
|
const removeControl = removeAnsi
|
|
|
|
|
.replace(/\[\?[0-9;]*[a-zA-Z]/g, '')
|
|
|
|
|
.replace(/\]654;[^\n]*/g, '')
|
|
|
|
|
.replace(/\[[0-9]+[GJ]/g, '');
|
|
|
|
|
|
|
|
|
|
// Step 3: Add newlines at key breakpoints while preserving paths
|
|
|
|
|
const formatOutput = removeControl
|
|
|
|
|
// Preserve prompt line
|
|
|
|
|
.replace(/^([~\/][^\n❯]+)❯/m, '$1\n❯')
|
|
|
|
|
// Add newline before command output indicators
|
|
|
|
|
.replace(/(?<!^|\n)>/g, '\n>')
|
|
|
|
|
// Add newline before error keywords without breaking paths
|
|
|
|
|
.replace(/(?<!^|\n|\w)(error|failed|warning|Error|Failed|Warning):/g, '\n$1:')
|
|
|
|
|
// Add newline before 'at' in stack traces without breaking paths
|
|
|
|
|
.replace(/(?<!^|\n|\/)(at\s+(?!async|sync))/g, '\nat ')
|
|
|
|
|
// Ensure 'at async' stays on same line
|
|
|
|
|
.replace(/\bat\s+async/g, 'at async');
|
|
|
|
|
|
|
|
|
|
// Step 4: Clean up whitespace while preserving intentional spacing
|
|
|
|
|
const cleanSpaces = formatOutput
|
|
|
|
|
.split('\n')
|
|
|
|
|
.map((line) => line.trim())
|
|
|
|
|
.filter((line) => line.length > 0) // Remove empty lines
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
|
|
|
|
// Step 5: Final cleanup
|
|
|
|
|
return cleanSpaces
|
|
|
|
|
.replace(/\n{3,}/g, '\n\n') // Replace multiple newlines with double newlines
|
|
|
|
|
.replace(/:\s+/g, ': ') // Normalize spacing after colons
|
|
|
|
|
.replace(/\s{2,}/g, ' ') // Remove multiple spaces
|
|
|
|
|
.trim();
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-08 16:17:31 +00:00
|
|
|
|
export function newBoltShellProcess() {
|
|
|
|
|
return new BoltShell();
|
|
|
|
|
}
|