Skip to content
This repository has been archived by the owner on Apr 13, 2024. It is now read-only.

Commit

Permalink
Add a basic PathCommandProvider
Browse files Browse the repository at this point in the history
This checks for the given command name in each directory in $PATH, and
returns an object for executing the first match it finds.

In situations where we don't need to redirect output, we use node-pty to
spawn a new PTY for the command to run in. This works better than our
own ioctl implementation for now, but but can't be used always because
it doesn't allow distinguishing between stderr and stdout. So when we
do need to redirect those, we use Node's process spawning.

Current issues:

- The unhandledRejection callback is very dubious.
- We eat one chunk of stdin input after the executable stops, because we
  can't cancel `ctx.externs.in_.read()`. Possibly this should go via
  another stream that we can disconnect.
- Stdin is always echoed even when the command handles it for us. This
  means typing in the `python` repl makes each character appear
  doubled. It's not actually doubled though, it's just a visual error.
  • Loading branch information
AtkinsSJ committed Apr 2, 2024
1 parent 86b02b9 commit 95b23b6
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 4 deletions.
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions src/ansi-shell/pipeline/Pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,8 @@ export class PreparedCommand {
},
locals: {
command,
args
args,
outputIsRedirected: this.outputRedirects.length > 0,
}
});

Expand Down Expand Up @@ -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);
Expand All @@ -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++ ) {
Expand Down
4 changes: 3 additions & 1 deletion src/puter-shell/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
]);

Expand Down
203 changes: 203 additions & 0 deletions src/puter-shell/providers/PathCommandProvider.js
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
}
}

0 comments on commit 95b23b6

Please sign in to comment.