diff --git a/src/ansi-shell/pipeline/Pipeline.js b/src/ansi-shell/pipeline/Pipeline.js index 076ed2d..3fdc64a 100644 --- a/src/ansi-shell/pipeline/Pipeline.js +++ b/src/ansi-shell/pipeline/Pipeline.js @@ -281,7 +281,15 @@ export class PreparedCommand { }); } } - + + // FIXME: This is really sketchy... + // `await execute(ctx);` should automatically throw any promise rejections, + // but for some reason Node crashes first, unless we set this handler, + // EVEN IF IT DOES NOTHING. I also can't find a place to safely remove it, + // so apologies if it makes debugging promises harder. + const rejectionCatcher = (reason, promise) => {}; + process.on('unhandledRejection', rejectionCatcher); + let exit_code = 0; try { await execute(ctx); @@ -306,7 +314,7 @@ export class PreparedCommand { // ctx.externs.in?.close?.(); // ctx.externs.out?.close?.(); - ctx.externs.out.close(); + await ctx.externs.out.close(); // TODO: need write command from puter-shell before this can be done for ( let i=0 ; i < this.outputRedirects.length ; i++ ) { diff --git a/src/puter-shell/main.js b/src/puter-shell/main.js index 8ccbf54..c640194 100644 --- a/src/puter-shell/main.js +++ b/src/puter-shell/main.js @@ -28,6 +28,7 @@ import { Context } from "contextlink"; import { SHELL_VERSIONS } from "../meta/versions.js"; import { PuterShellParser } from "../ansi-shell/parsing/PuterShellParser.js"; import { BuiltinCommandProvider } from "./providers/BuiltinCommandProvider.js"; +import { PathCommandProvider } from "./providers/PathCommandProvider.js"; import { CreateChatHistoryPlugin } from './plugins/ChatHistoryPlugin.js'; import { Pipe } from '../ansi-shell/pipeline/Pipe.js'; import { Coupler } from '../ansi-shell/pipeline/Coupler.js'; @@ -82,9 +83,10 @@ export const launchPuterShell = async (ctx) => { await sdkv2.setAPIOrigin(source_without_trailing_slash); } - // const commandProvider = new BuiltinCommandProvider(); const commandProvider = new CompositeCommandProvider([ new BuiltinCommandProvider(), + // PathCommandProvider is only compatible with node.js for now + ...(ctx.platform.name === 'node' ? [new PathCommandProvider()] : []), new ScriptCommandProvider(), ]); diff --git a/src/puter-shell/providers/PathCommandProvider.js b/src/puter-shell/providers/PathCommandProvider.js new file mode 100644 index 0000000..3c55563 --- /dev/null +++ b/src/puter-shell/providers/PathCommandProvider.js @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 Puter Technologies Inc. + * + * This file is part of Phoenix Shell. + * + * Phoenix Shell is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import path_ from "path-browserify"; +import child_process from "node:child_process"; +import stream from "node:stream"; +import { signals } from '../../ansi-shell/signals.js'; +import { Exit } from '../coreutils/coreutil_lib/exit.js'; + +export class PathCommandProvider { + async lookup (id, { ctx }) { + const PATH = ctx.env['PATH']; + if (!PATH) + return; + const pathDirectories = PATH.split(':'); + + for (const dir of pathDirectories) { + const executablePath = path_.resolve(dir, id); + let stat; + try { + stat = await ctx.platform.filesystem.stat(executablePath); + } catch (e) { + // Stat failed -> file does not exist + continue; + } + // TODO: Detect if the file is executable, and ignore it if not. + return { + name: id, + path: executablePath, + async execute(ctx) { + const child = child_process.spawn(executablePath, ctx.locals.args, { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: ctx.vars.pwd, + }); + + const in_ = new stream.PassThrough(); + const out = new stream.PassThrough(); + const err = new stream.PassThrough(); + + in_.on('data', (chunk) => { + child.stdin.write(chunk); + }); + out.on('data', (chunk) => { + ctx.externs.out.write(chunk); + }); + err.on('data', (chunk) => { + ctx.externs.err.write(chunk); + }); + + const fn_err = label => err => { + console.log(`ERR(${label})`, err); + }; + in_.on('error', fn_err('in_')); + out.on('error', fn_err('out')); + err.on('error', fn_err('err')); + child.stdin.on('error', fn_err('stdin')); + child.stdout.on('error', fn_err('stdout')); + child.stderr.on('error', fn_err('stderr')); + + child.stdout.pipe(out); + child.stderr.pipe(err); + + child.on('error', (err) => { + console.error(`Error running path executable '${executablePath}':`, err); + }); + + const sigint_promise = new Promise((resolve, reject) => { + ctx.externs.sig.on((signal) => { + if ( signal === signals.SIGINT ) { + reject(new Exit(130)); + } + }); + }); + + const exit_promise = new Promise((resolve, reject) => { + child.on('exit', (code) => { + ctx.externs.out.write(`Exited with code ${code}\n`); + if (code === 0) { + resolve({ done: true }); + } else { + reject(new Exit(code)); + } + }); + }); + + // Repeatedly copy data from stdin to the child, while it's running. + let data, done; + const next_data = async () => { + // FIXME: This waits for one more read() after we finish. + ({ value: data, done } = await Promise.race([ + exit_promise, sigint_promise, ctx.externs.in_.read(), + ])); + if ( data ) { + in_.write(data); + if ( ! done ) setTimeout(next_data, 0); + } + } + setTimeout(next_data, 0); + + return Promise.race([ exit_promise, sigint_promise ]); + } + }; + } + } +}