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