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.

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 handling is generally not working right.
  • Loading branch information
AtkinsSJ committed Mar 28, 2024
1 parent 86b02b9 commit c2ba7e3
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 3 deletions.
12 changes: 10 additions & 2 deletions src/ansi-shell/pipeline/Pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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++ ) {
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
139 changes: 139 additions & 0 deletions src/puter-shell/providers/PathCommandProvider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* 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';

function makeCommand(id, executablePath) {
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 ]);
}
};
}

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;
}

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 c2ba7e3

Please sign in to comment.