diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e623fd6..9f662c7 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -27,10 +27,12 @@ jobs: fail-fast: false matrix: settings: - - host: macos-latest + - host: macos-14-large target: x86_64-apple-darwin - build: | - bun run build + architecture: x64 + build: |- + set -e && + bun run build --target x86_64-apple-darwin && strip -x *.node - host: ubuntu-latest target: x86_64-unknown-linux-gnu @@ -46,6 +48,8 @@ jobs: - uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.0.26 - name: Setup node uses: actions/setup-node@v4 if: ${{ !matrix.settings.docker }} @@ -58,7 +62,7 @@ jobs: toolchain: stable targets: ${{ matrix.settings.target }} - name: Cache cargo - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.cargo/registry/index/ @@ -89,7 +93,7 @@ jobs: if: ${{ !matrix.settings.docker }} shell: bash - name: Upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: bindings-${{ matrix.settings.target }} path: ${{ env.APP_NAME }}.*.node @@ -102,21 +106,25 @@ jobs: strategy: matrix: settings: - - host: macos-latest + - host: macos-14-large target: x86_64-apple-darwin + architecture: x64 runs-on: ${{ matrix.settings.host }} steps: - uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.0.26 - name: Setup node uses: actions/setup-node@v4 with: node-version: 20 + architecture: ${{ matrix.settings.architecture }} - name: Install dependencies run: bun install - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: bindings-${{ matrix.settings.target }} path: . @@ -140,6 +148,8 @@ jobs: - uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.0.26 - name: Setup node uses: actions/setup-node@v4 with: @@ -147,7 +157,7 @@ jobs: - name: Install dependencies run: bun install - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: bindings-${{ matrix.settings.target }} path: . @@ -155,7 +165,7 @@ jobs: run: ls -R . shell: bash - name: Test bindings - run: docker run --rm -v $(pwd):/build -w /build oven/bun:1 bun test + run: docker run --rm -v $(pwd):/build -w /build oven/bun:1.0.26 bun test publish: name: Publish @@ -167,6 +177,8 @@ jobs: - uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.0.26 - name: Setup node uses: actions/setup-node@v4 with: @@ -174,7 +186,7 @@ jobs: - name: Install dependencies run: bun install - name: Download all artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: path: artifacts - name: Move artifacts diff --git a/bun.lockb b/bun.lockb index 50a1bf2..8cf7ea9 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/flake.nix b/flake.nix index b04fca6..84c707e 100644 --- a/flake.nix +++ b/flake.nix @@ -20,5 +20,6 @@ { devShells.aarch64-darwin.default = mkDevShell "aarch64-darwin"; devShells.x86_64-darwin.default = mkDevShell "x86_64-darwin"; + devShells.x86_64-linux.default = mkDevShell "x86_64-linux"; }; } diff --git a/index.d.ts b/index.d.ts index 12f8396..657f9a8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,13 +3,16 @@ /* auto-generated by NAPI-RS */ +export interface Pid { + pid: number +} export interface Size { cols: number rows: number } export class Pty { fd: number - pid: number - constructor(command: string, args: Array, envs: Record, dir: string, size: Size, onExit: (err: null | Error, exitCode: number) => void) + constructor(command: string, args: Array, envs: Record, dir: string, size: Size) + start(onExit: (err: null | Error, exitCode: number) => void): Pid resize(size: Size): void } diff --git a/index.js b/index.js index 1481fc1..1a2a909 100644 --- a/index.js +++ b/index.js @@ -224,17 +224,32 @@ switch (platform) { } break case 'arm': - localFileExisted = existsSync( - join(__dirname, 'ruspty.linux-arm-gnueabihf.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./ruspty.linux-arm-gnueabihf.node') - } else { - nativeBinding = require('@replit/ruspty-linux-arm-gnueabihf') + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-arm-musleabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-arm-musleabihf.node') + } else { + nativeBinding = require('@replit/ruspty-linux-arm-musleabihf') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'ruspty.linux-arm-gnueabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./ruspty.linux-arm-gnueabihf.node') + } else { + nativeBinding = require('@replit/ruspty-linux-arm-gnueabihf') + } + } catch (e) { + loadError = e } - } catch (e) { - loadError = e } break case 'riscv64': diff --git a/index.test.ts b/index.test.ts index 6ad32e0..2753dcc 100644 --- a/index.test.ts +++ b/index.test.ts @@ -3,56 +3,43 @@ import { Pty } from './index'; describe('PTY', () => { const CWD = process.cwd(); + const ENV = process.env as Record; test('spawns and exits', (done) => { const message = 'hello from a pty'; - const pty = new Pty( - '/bin/echo', - [message], - {}, - CWD, - { rows: 24, cols: 80 }, - (err, exitCode) => { - expect(err).toBeNull(); - expect(exitCode).toBe(0); - done(); - }, - ); + const pty = new Pty('echo', [message], ENV, CWD, { rows: 24, cols: 80 }); const readStream = fs.createReadStream('', { fd: pty.fd }); readStream.on('data', (chunk) => { expect(chunk.toString()).toBe(message + '\r\n'); }); + + pty.start((err, exitCode) => { + expect(err).toBeNull(); + expect(exitCode).toBe(0); + done(); + }); }); test('captures an exit code', (done) => { - new Pty( - '/bin/sh', - ['-c', 'exit 17'], - {}, - CWD, - { rows: 24, cols: 80 }, - (err, exitCode) => { - expect(err).toBeNull(); - expect(exitCode).toBe(17); - done(); - }, - ); + const pty = new Pty('sh', ['-c', 'exit 17'], ENV, CWD, { + rows: 24, + cols: 80, + }); + + pty.start((err, exitCode) => { + expect(err).toBeNull(); + expect(exitCode).toBe(17); + done(); + }); }); test('can be written to', (done) => { const message = 'hello cat'; - const pty = new Pty( - '/bin/cat', - [], - {}, - CWD, - { rows: 24, cols: 80 }, - () => {}, - ); + const pty = new Pty('cat', [], ENV, CWD, { rows: 24, cols: 80 }); const readStream = fs.createReadStream('', { fd: pty.fd }); const writeStream = fs.createWriteStream('', { fd: pty.fd }); @@ -62,18 +49,13 @@ describe('PTY', () => { done(); }); + pty.start(() => {}); + writeStream.write(message); }); test('can be resized', (done) => { - const pty = new Pty( - '/bin/sh', - [], - {}, - CWD, - { rows: 24, cols: 80 }, - () => {}, - ); + const pty = new Pty('sh', [], ENV, CWD, { rows: 24, cols: 80 }); const readStream = fs.createReadStream('', { fd: pty.fd }); const writeStream = fs.createWriteStream('', { fd: pty.fd }); @@ -96,28 +78,25 @@ describe('PTY', () => { } }); + pty.start(() => {}); + writeStream.write("stty size; echo 'done1'\n"); }); test('respects working directory', (done) => { - const pty = new Pty( - '/bin/pwd', - [], - {}, - CWD, - { rows: 24, cols: 80 }, - (err, exitCode) => { - expect(err).toBeNull(); - expect(exitCode).toBe(0); - done(); - }, - ); + const pty = new Pty('pwd', [], ENV, CWD, { rows: 24, cols: 80 }); const readStream = fs.createReadStream('', { fd: pty.fd }); readStream.on('data', (chunk) => { expect(chunk.toString()).toBe(`${CWD}\r\n`); }); + + pty.start((err, exitCode) => { + expect(err).toBeNull(); + expect(exitCode).toBe(0); + done(); + }); }); test.skip('respects env', (done) => { @@ -125,20 +104,14 @@ describe('PTY', () => { let buffer = ''; const pty = new Pty( - '/bin/sh', - ['-c', 'sleep 0.1s && echo $ENV_VARIABLE && exit'], + 'sh', + ['-c', 'echo $ENV_VARIABLE && exit'], { + ...ENV, ENV_VARIABLE: message, }, CWD, { rows: 24, cols: 80 }, - (err, exitCode) => { - expect(err).toBeNull(); - expect(exitCode).toBe(0); - expect(buffer).toBe(message + '\r\n'); - - done(); - }, ); const readStream = fs.createReadStream('', { fd: pty.fd }); @@ -146,19 +119,20 @@ describe('PTY', () => { readStream.on('data', (chunk) => { buffer += chunk.toString(); }); + + pty.start((err, exitCode) => { + expect(err).toBeNull(); + expect(exitCode).toBe(0); + expect(buffer).toBe(message + '\r\n'); + + done(); + }); }); test('works with Bun.read & Bun.write', (done) => { const message = 'hello bun'; - const pty = new Pty( - '/bin/cat', - [], - {}, - CWD, - { rows: 24, cols: 80 }, - () => {}, - ); + const pty = new Pty('cat', [], ENV, CWD, { rows: 24, cols: 80 }); const file = Bun.file(pty.fd); @@ -173,23 +147,63 @@ describe('PTY', () => { read(); + pty.start(() => {}); + Bun.write(pty.fd, message); }); test("doesn't break when executing non-existing binary", (done) => { + const pty = new Pty('/bin/this-does-not-exist', [], ENV, CWD, { + rows: 24, + cols: 80, + }); + try { - new Pty( - '/bin/this-does-not-exist', - [], - {}, - CWD, - { rows: 24, cols: 80 }, - () => {}, - ); + pty.start(() => {}); } catch (e) { expect(e.message).toContain('No such file or directory'); done(); } }); + + test('can start the process at an arbitrary later point in time', (done) => { + const allFinished = { + dataChunk: false, + startResult: false, + startCallback: false, + }; + + function checkDone() { + if (Object.values(allFinished).every((d) => d === true)) { + done(); + } + } + + const pty = new Pty('echo', ['hello world'], ENV, CWD, { + rows: 24, + cols: 80, + }); + + const readStream = fs.createReadStream('', { fd: pty.fd, start: 0 }); + + readStream.on('data', (chunk) => { + expect(chunk.toString()).toBe('hello world\r\n'); + allFinished.dataChunk = true; + checkDone(); + }); + + setTimeout(() => { + const startResult = pty.start((err, exitCode) => { + expect(err).toBeNull(); + expect(exitCode).toBe(0); + allFinished.startCallback = true; + checkDone(); + }); + + expect(startResult.pid).toBeGreaterThan(0); + allFinished.startResult = true; + checkDone(); + }, 100); + }); }); diff --git a/package.json b/package.json index 3fb0923..8bfb8cb 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "license": "MIT", "devDependencies": { - "@napi-rs/cli": "^2.17.0", + "@napi-rs/cli": "^2.18.2", "@types/node": "^20.4.1", "@types/jest": "^29.5.11", "prettier": "^3.2.4" diff --git a/src/lib.rs b/src/lib.rs index a7ff449..6623285 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,9 +22,16 @@ extern crate napi_derive; #[napi] #[allow(dead_code)] struct Pty { - file: File, + cmd: Command, + file_controller: File, + file_user: File, + #[napi(ts_type = "number")] pub fd: c_int, +} + +#[napi(object)] +struct Pid { pub pid: u32, } @@ -83,7 +90,6 @@ impl Pty { envs: HashMap, dir: String, size: Size, - #[napi(ts_arg_type = "(err: null | Error, exitCode: number) => void")] on_exit: JsFunction, ) -> Result { let window_size = Winsize { ws_col: size.cols, @@ -93,7 +99,10 @@ impl Pty { }; let mut cmd = Command::new(command); + cmd.args(args); + cmd.envs(envs); + cmd.current_dir(dir); let pty_pair = openpty(None, Some(&window_size)) .map_err(|err| NAPI_ERROR::new(napi::Status::GenericFailure, err))?; @@ -101,21 +110,43 @@ impl Pty { let fd_controller = pty_pair.controller.as_raw_fd(); let fd_user = pty_pair.user.as_raw_fd(); - if let Ok(mut termios) = termios::tcgetattr(&pty_pair.controller) { + cmd.stdin(unsafe { Stdio::from_raw_fd(fd_user) }); + cmd.stderr(unsafe { Stdio::from_raw_fd(fd_user) }); + cmd.stdout(unsafe { Stdio::from_raw_fd(fd_user) }); + + let file_controller = File::from(pty_pair.controller); + let file_user = File::from(pty_pair.user); + + Ok(Pty { + fd: fd_controller, + cmd, + file_controller, + file_user, + }) + } + + #[napi] + #[allow(dead_code)] + pub fn start( + &mut self, + #[napi(ts_arg_type = "(err: null | Error, exitCode: number) => void")] on_exit: JsFunction, + ) -> Result { + let ts_on_exit: ThreadsafeFunction = on_exit + .create_threadsafe_function(0, |ctx| ctx.env.create_int32(ctx.value).map(|v| vec![v]))?; + + if let Ok(mut termios) = termios::tcgetattr(&self.file_controller) { termios.input_modes.set(InputModes::IUTF8, true); - termios::tcsetattr(&pty_pair.controller, OptionalActions::Now, &termios) + termios::tcsetattr(&self.file_controller, OptionalActions::Now, &termios) .map_err(|err| NAPI_ERROR::new(napi::Status::GenericFailure, err))?; } - cmd.stdin(unsafe { Stdio::from_raw_fd(fd_user) }); - cmd.stderr(unsafe { Stdio::from_raw_fd(fd_user) }); - cmd.stdout(unsafe { Stdio::from_raw_fd(fd_user) }); + let fd_user = self.file_user.as_raw_fd(); + let fd_controller = self.file_controller.as_raw_fd(); - cmd.envs(envs); - cmd.current_dir(dir); + set_nonblocking(fd_controller)?; unsafe { - cmd.pre_exec(move || { + self.cmd.pre_exec(move || { let err = libc::setsid(); if err == -1 { return Err(Error::new(ErrorKind::Other, "Failed to set session id")); @@ -137,20 +168,13 @@ impl Pty { }); } - let ts_on_exit: ThreadsafeFunction = on_exit - .create_threadsafe_function(0, |ctx| ctx.env.create_int32(ctx.value).map(|v| vec![v]))?; - - let mut child = cmd + let mut child = self + .cmd .spawn() .map_err(|err| NAPI_ERROR::new(GenericFailure, err))?; let pid = child.id(); - set_nonblocking(fd_controller)?; - - let file = File::from(pty_pair.controller); - let fd = file.as_raw_fd(); - // We're creating a new thread for every child, this uses a bit more system resources compared // to alternatives (below), trading off simplicity of implementation. // @@ -192,11 +216,11 @@ impl Pty { // Close the fd once we return from `child.wait()`. unsafe { - rustix::io::close(fd); + rustix::io::close(fd_controller); } }); - Ok(Pty { file, fd, pid }) + Ok(Pid { pid }) } #[napi]