diff --git a/package-lock.json b/package-lock.json index 0842543..e2dbd55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "columnify": "^1.6.0", "fs-mode-to-string": "^0.0.2", "json-query": "^2.2.2", + "node-pty": "^1.0.0", "path-browserify": "^1.0.1", "sinon": "^17.0.1", "xterm": "^5.1.0", @@ -1286,6 +1287,11 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/nan": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz", + "integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==" + }, "node_modules/nanoid": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", @@ -1310,6 +1316,15 @@ "path-to-regexp": "^6.2.1" } }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index d421e0b..36e8054 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "columnify": "^1.6.0", "fs-mode-to-string": "^0.0.2", "json-query": "^2.2.2", + "node-pty": "^1.0.0", "path-browserify": "^1.0.1", "sinon": "^17.0.1", "xterm": "^5.1.0", diff --git a/src/ansi-shell/pipeline/Pipeline.js b/src/ansi-shell/pipeline/Pipeline.js index 076ed2d..178a589 100644 --- a/src/ansi-shell/pipeline/Pipeline.js +++ b/src/ansi-shell/pipeline/Pipeline.js @@ -246,7 +246,8 @@ export class PreparedCommand { }, locals: { command, - args + args, + outputIsRedirected: this.outputRedirects.length > 0, } }); @@ -281,7 +282,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 +315,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/HostedCommandProvider.js b/src/puter-shell/providers/HostedCommandProvider.js deleted file mode 100644 index 8564637..0000000 --- a/src/puter-shell/providers/HostedCommandProvider.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 . - */ diff --git a/src/puter-shell/providers/PathCommandProvider.js b/src/puter-shell/providers/PathCommandProvider.js new file mode 100644 index 0000000..bf84a05 --- /dev/null +++ b/src/puter-shell/providers/PathCommandProvider.js @@ -0,0 +1,203 @@ +/* + * 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'; +import pty from 'node-pty'; + +function spawn_process(ctx, executablePath) { + console.log(`Spawning ${executablePath} as a child process`); + 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 ]); +} + +function spawn_pty(ctx, executablePath) { + console.log(`Spawning ${executablePath} as a pty`); + const child = pty.spawn(executablePath, ctx.locals.args, { + name: 'xterm-color', + rows: ctx.env.ROWS, + cols: ctx.env.COLS, + cwd: ctx.vars.pwd, + env: ctx.env + }); + child.onData(chunk => { + ctx.externs.out.write(chunk); + }); + + const sigint_promise = new Promise((resolve, reject) => { + ctx.externs.sig.on((signal) => { + if ( signal === signals.SIGINT ) { + child.kill('SIGINT'); // FIXME: Docs say this will throw when used on Windows + reject(new Exit(130)); + } + }); + }); + + const exit_promise = new Promise((resolve, reject) => { + child.onExit(({code, signal}) => { + ctx.externs.out.write(`Exited with code ${code || 0} and signal ${signal || 0}\n`); + if ( signal ) { + reject(new Exit(1)); + } else if ( code ) { + reject(new Exit(code)); + } else { + resolve({ done: true }); + } + }); + }); + + // 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 ) { + child.write(data); + if ( ! done ) setTimeout(next_data, 0); + } + } + setTimeout(next_data, 0); + + return Promise.race([ exit_promise, sigint_promise ]); +} + +function makeCommand(id, executablePath) { + return { + name: id, + path: executablePath, + async execute(ctx) { + // TODO: spawn_pty() does a lot of things better than spawn_process(), but can't handle output redirection. + // At some point, we'll need to implement more ioctls within spawn_process() and then remove spawn_pty(), + // but for now, the best experience is to use spawn_pty() unless we need the redirection. + if (ctx.locals.outputIsRedirected) { + return spawn_process(ctx, executablePath); + } + return spawn_pty(ctx, executablePath); + } + }; +} + +async function findCommandsInPath(id, ctx, firstOnly) { + const PATH = ctx.env['PATH']; + if (!PATH) + return; + const pathDirectories = PATH.split(':'); + + const results = []; + + 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. + const command = makeCommand(id, executablePath); + + if ( firstOnly ) return command; + results.push(command); + } + + return results.length > 0 ? results : undefined; +} + +export class PathCommandProvider { + async lookup (id, { ctx }) { + return findCommandsInPath(id, ctx, true); + } + + async lookupAll(id, { ctx }) { + return findCommandsInPath(id, ctx, false); + } +}