// Shell protocol — ported from hypershell (shell subset) to dual-runtime ESM.
// Top-level imports are all bare-safe (os/process via the #node: import map,
// compact-encoding + tt-native both load on bare), so this module loads on
// node AND bare. Local-terminal control (raw mode / window size) is only
// touched at client runtime and guarded, so importing is always safe.
import os from '#node:os'
import process from '#node:process'
import c from 'compact-encoding'
import PTY from 'tt-native'
import * as m from './messages.js'
const isWin = os.platform() === 'win32'
export const shellFile = isWin ? 'powershell.exe' : (process.env.SHELL || 'bash')
const EMPTY = Buffer.alloc(0)
// Server side: spawn a PTY and pipe it over a protomux channel. No local
// terminal needed here — fully clean on both node and bare.
export class ShellServer {
constructor ({ mux }) {
this.channel = mux.createChannel({
protocol: 'hypershell',
id: null,
handshake: m.handshakeSpawn,
onopen: this.onopen.bind(this),
onclose: this.onclose.bind(this),
messages: [
{ encoding: c.buffer, onmessage: this.onstdin.bind(this) },
{ encoding: c.buffer }, // stdout
{ encoding: c.buffer }, // stderr
{ encoding: c.uint }, // exit code
{ encoding: m.resize, onmessage: this.onresize.bind(this) }
]
})
this.pty = null
}
open () { this.channel.open({}) }
onopen (handshake) {
try {
this.pty = PTY.spawn(handshake.command || shellFile, handshake.args, {
cwd: os.homedir(),
env: process.env,
width: handshake.width,
height: handshake.height
})
} catch (error) {
this.channel.messages[3].send(1)
this.channel.messages[2].send(Buffer.from(error.toString() + '\n'))
this.channel.close()
return
}
this.pty.on('data', (data) => this.channel.messages[1].send(data))
this.pty.once('exit', (code) => this.channel.messages[3].send(code))
this.pty.once('close', () => this.channel.close())
}
onclose () {
if (this.pty) {
try { this.pty.kill('SIGKILL') } catch {} // ignore "Process has exited"
}
}
onstdin (data) {
if (data === null) this.pty.write(EMPTY)
else this.pty.write(data)
}
onresize (data) {
if (this.pty) this.pty.resize(data.width, data.height)
}
}
// Client side: pipe local stdio over the channel. Local-TTY control is guarded
// (works fully on node; degrades gracefully to cooked/fixed-size on bare until
// a bare-tty terminal layer is added — see TODO).
export class ShellClient {
constructor (rawArgs, { socket, mux, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr }) {
const spawn = ShellClient.parseVariadic(rawArgs)
this.command = spawn.shift() || ''
this.args = spawn
this.socket = socket
this.stdin = stdin
this.stdout = stdout
this.stderr = stderr
this.channel = mux.createChannel({
protocol: 'hypershell',
id: null,
handshake: m.handshakeSpawn,
onopen: this.onopen.bind(this),
onclose: this.onclose.bind(this),
messages: [
{ encoding: c.buffer }, // stdin
{ encoding: c.buffer, onmessage: this.onstdout.bind(this) },
{ encoding: c.buffer, onmessage: this.onstderr.bind(this) },
{ encoding: c.uint, onmessage: this.onexitcode.bind(this) },
{ encoding: m.resize }
]
})
}
size () {
return {
width: (this.stdout && this.stdout.columns) || 80,
height: (this.stdout && this.stdout.rows) || 24
}
}
open () {
const { width, height } = this.size()
this.channel.open({ command: this.command, args: this.args, width, height })
this.setup()
}
onopen () {}
onclose () { this.socket.end() }
setup () {
this.onstdin = this.onstdin.bind(this)
this.onresize = this.onresize.bind(this)
this.onsocketclose = this.onsocketclose.bind(this)
// TODO(bare): on bare use bare-tty for raw mode + resize; guarded so the
// client still works (cooked mode, fixed 80x24) where these are absent.
if (this.stdin && this.stdin.isTTY && this.stdin.setRawMode) this.stdin.setRawMode(true)
if (this.stdin && this.stdin.on) this.stdin.on('data', this.onstdin)
if (this.stdout && this.stdout.on) this.stdout.on('resize', this.onresize)
this.socket.on('close', this.onsocketclose)
if (this.stdin && this.stdin.resume) this.stdin.resume()
}
onstdin (data) { this.channel.messages[0].send(data) }
onstdout (data) { this.stdout.write(data) }
onstderr (data) { this.stderr.write(data) }
onexitcode (code) { process.exitCode = code }
onresize () {
const { width, height } = this.size()
this.channel.messages[4].send({ width, height })
}
onsocketclose () {
if (this.stdin && this.stdin.isTTY && this.stdin.setRawMode) this.stdin.setRawMode(false)
if (this.stdin && this.stdin.removeListener) this.stdin.removeListener('data', this.onstdin)
if (this.stdout && this.stdout.removeListener) this.stdout.removeListener('resize', this.onresize)
this.socket.removeListener('close', this.onsocketclose)
if (this.stdin && this.stdin.pause) this.stdin.pause()
}
static parseVariadic (rawArgs) {
const index = rawArgs.indexOf('--')
const variadic = index === -1 ? null : rawArgs.splice(index + 1)
return variadic || []
}
}