PHP port of charmbracelet/wish โ an SSH server middleware framework that lets you build TUIs anyone can ssh user@host to run.
composer require sugarcraft/candy-wishCandyWish leans on the host's OpenSSH daemon rather than implementing the SSH wire protocol from scratch. Each SSH connection forks a fresh PHP process under sshd (via ForceCommand). What that PHP process does internally depends on the active transport:
[client] โsshโโถ [sshd] โForceCommandโโโถ [php supervisor] โโโถ [middleware stack]
โ โ
โโpump bytesโโโ โโSpawn middleware
โ โ
โผ โผ
[candy-pty master โโโโโ slave / inner cmd]
(bash, vim, custom binary)
The supervisor allocates a candy-pty master/slave pair, spawns the user's cmd as a subprocess with full controlling-terminal semantics (Ctrl+C โ SIGINT, SIGWINCH-driven resize, job control), and pumps bytes between the supervisor's STDIN/STDOUT (= sshd's PTY slave) and the candy-pty master. The terminal middleware is Spawn, which produces the cmd from the Session.
[client] โsshโโถ [sshd] โForceCommandโโโถ [php supervisor] โโโถ [middleware stack] โโโถ [SugarCraft Program reading STDIN, writing STDOUT]
The pre-PTY-upgrade architecture: middleware run inline in the supervisor, and the terminal middleware (BubbleTea) mounts a SugarCraft Program directly on the supervisor's STDIN/STDOUT. Pin via Server::new()->withTransport(new HostSshdTransport()). Use this if your existing entry script reads STDIN/echoes STDOUT directly without a subprocess.
InProcessTransportwhen you want to spawn arbitrary shells (bash -i,zsh,fish), editors (vim,less), or compiled TUI binaries โ anything that needs a controlling terminal. Subprocess overhead per connection (~50-200ms PHP cold start), but full PTY semantics.HostSshdTransportwhen your TUI is a SugarCraftProgramand you want zero subprocess overhead, or when you have an inline-STDIN-reading middleware (banner-style). No subprocess, but no controlling-terminal isolation.
Add to /etc/ssh/sshd_config.d/wish.conf:
Match User wishuser
ForceCommand /usr/bin/php /opt/wish/server.php
AllowTcpForwarding no
PermitTTY yes
X11Forwarding no
Then systemctl reload sshd.
InProcessTransport (default) โ spawn an interactive shell:
<?php // /opt/wish/server.php
require '/opt/wish/vendor/autoload.php';
use SugarCraft\Wish\Server;
use SugarCraft\Wish\Middleware\Logger;
use SugarCraft\Wish\Middleware\Auth;
use SugarCraft\Wish\Middleware\RateLimit;
use SugarCraft\Wish\Middleware\Spawn;
use SugarCraft\Wish\Session;
Server::new()
->use(new Logger('/var/log/wish.jsonl'))
->use(new RateLimit('/var/lib/wish/buckets.json', burst: 5, ratePerSec: 0.5))
->use(new Auth(users: ['alice', 'bob']))
->use(new Spawn(fn (Session $s) => [
'cmd' => ['/bin/bash', '-l'],
'env' => [
'TERM' => $s->term, 'USER' => $s->user, 'HOME' => "/home/{$s->user}",
'PATH' => '/usr/local/bin:/usr/bin:/bin',
],
]))
->serve();HostSshdTransport (legacy) โ mount a SugarCraft Program inline:
<?php // /opt/wish/server.php
require '/opt/wish/vendor/autoload.php';
use SugarCraft\Wish\Server;
use SugarCraft\Wish\Middleware\Logger;
use SugarCraft\Wish\Middleware\Auth;
use SugarCraft\Wish\Middleware\RateLimit;
use SugarCraft\Wish\Middleware\BubbleTea;
use SugarCraft\Wish\Transport\HostSshdTransport;
Server::new()
->withTransport(new HostSshdTransport())
->use(new Logger('/var/log/wish.jsonl'))
->use(new RateLimit('/var/lib/wish/buckets.json', burst: 5, ratePerSec: 0.5))
->use(new Auth(users: ['alice', 'bob']))
->use(new BubbleTea(fn ($session) => new MyApp($session)))
->serve();ssh wishuser@your-host
| Middleware | Transport | Purpose |
|---|---|---|
Logger |
both | One-line JSON event at session start + end, with elapsed time and connection meta. |
Auth |
both | Username allowlist, public-key fingerprint allowlist (or both). |
RateLimit |
both | Per-IP token-bucket persisted to a JSON state file with flock(LOCK_EX). |
Spawn |
InProcess only | Terminal โ spawns a child cmd in a candy-pty controlled by the supervisor. |
BubbleTea |
HostSshd only | Terminal โ mounts a SugarCraft Program inline reading STDIN, writing STDOUT. |
You can write your own โ implement SugarCraft\Wish\Middleware:
final class HelloBanner implements Middleware
{
public function handle(Session $s, callable $next): void
{
echo "Welcome, {$s->user}!\n";
$next($s);
}
}Session::fromEnvironment() reads the standard sshd-supplied environment:
$s->user; // 'alice'
$s->clientHost; // '203.0.113.7'
$s->clientPort; // 54321
$s->term; // 'xterm-256color'
$s->cols; // 120
$s->rows; // 40
$s->tty; // '/dev/pts/3' (null when non-interactive)
$s->command; // SSH_ORIGINAL_COMMAND if set
$s->isInteractive();
$s->toLogContext();The PECL ssh2 extension is optional and used only if you want a middleware that opens outbound SSH connections from inside the session (e.g. SFTP file pickers, remote-control agents). Standard server-side use does not require it.
Phase 9+ โ first cut. Five middleware classes, 19 tests / 65 assertions, ready for v0 deployment.
See examples/hello-server.php for a runnable banner-only stack you can ForceCommand against.