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

Commit

Permalink
Merge pull request #26 from AtkinsSJ/path-command-provider
Browse files Browse the repository at this point in the history
WIP: Add a basic PathCommandProvider
  • Loading branch information
KernelDeimos committed Apr 10, 2024
2 parents 5538e23 + 95b23b6 commit 84c2450
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 @@ -25,6 +25,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 84c2450

Please sign in to comment.