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

WIP: Add a basic PathCommandProvider #26

Merged
merged 2 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
18 changes: 0 additions & 18 deletions src/puter-shell/providers/HostedCommandProvider.js

This file was deleted.

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