jubianchi / devnull-stream
A devnull:// PHP stream wrapper for https://dev-null.sh: read, commit, reply, star and give pastéis with plain file functions.
Requires
- php: ^8.5
Requires (Dev)
- laravel/pint: ^1.32
- pestphp/pest: ^5.3
- phpstan/phpstan: ^2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A devnull:// PHP stream wrapper for dev-null.sh: read, commit, reply, star, give pastéis and
delete with PHP's plain file functions.
echo file_get_contents('devnull://@dseguy'); // their commits, `git log` style file_put_contents('devnull://@me', 'Hello from PHP!'); // a commit, signed with your SSH key touch('devnull://@dseguy/commits/<id>/star'); // a star
It has no dependency beyond PHP itself: requests go through PHP's native https:// stream and signatures through
ssh-keygen.
Requirements
- PHP 8.5 or later, with
allow_url_fopenenabled ssh-keygenfrom OpenSSH 8.2 or later, to write (reading needs nothing more)
Installation
composer require jubianchi/devnull-stream
The devnull:// wrapper is registered as soon as Composer's autoloader is loaded. Without Composer's autoloader, call
Jubianchi\DevNull\StreamWrapper::register() yourself; registering twice is harmless.
Usage
Paths
| Path | Read | Write | touch() |
unlink() |
|---|---|---|---|---|
devnull://@user |
their commits | commits as you | ||
devnull://@user/replies |
their replies | |||
devnull://@user/commits/<id> |
one commit | replies to it | deletes it | |
devnull://@user/commits/<id>/star |
stars it | unstars it | ||
devnull://@user/commits/<id>/pasteis |
gives it pastéis |
devnull://@user and devnull://@user/replies are directories of commit ids; commits are files.
Read
echo file_get_contents('devnull://@dseguy'); // their commits echo file_get_contents('devnull://@dseguy/replies'); // their replies echo file_get_contents('devnull://@dseguy/commits/<id>'); // one commit print_r(scandir('devnull://@dseguy')); // their commit ids var_dump(file_exists('devnull://@dseguy/commits/<id>')); echo date('c', filemtime('devnull://@dseguy/commits/<id>'));
Commits read like git log:
commit 01a16807-1328-7241-b932-f19dd0286c13
Author: Seguy Damien <@dseguy>
Date: 2026-10-23T09:10:56Z
Stars: 3 Replies: 1
After witnessing the frenzy around #PHP deploying its most recent fix…
Lists are paginated by the API: the next page is only fetched when the reader gets to it, so
fgets(fopen('devnull://@dseguy', 'r')) costs a single request.
Commit and reply
Writes are signed with an SSH key of your GitHub account, as curl -fsSL dev-null.sh/commit | bash does: no
sign-up and no token.
use Jubianchi\DevNull\StreamWrapper; file_put_contents('devnull://@me', 'Hello from PHP!'); // commit file_put_contents('devnull://@dseguy/commits/<id>', "Let's do it!"); // reply echo StreamWrapper::$lastResponse; // link to what was created
The message is sent once, when the stream closes, so several fwrite() calls make a single commit:
$commit = fopen('devnull://@me', 'w'); fwrite($commit, "Shipped!\n\n"); fwrite($commit, 'devnull:// is out.'); fclose($commit);
StreamWrapper::$lastResponse is read-only outside the wrapper and holds '' when the last write failed.
Star, give pastéis and delete
These go through the signed API the mobile app uses, with a key approved as a device of your account (see Authorize a key).
touch('devnull://@dseguy/commits/<id>/star'); // star unlink('devnull://@dseguy/commits/<id>/star'); // unstar touch('devnull://@dseguy/commits/<id>/pasteis'); // give a pastel de nata unlink('devnull://@me/commits/<id>'); // delete one of your commits
Errors
The wrapper never throws: like any stream, it raises a warning saying what went wrong and the file function returns
false.
Warning: devnull: POST /api/v1/commits/<id>/star failed (HTTP 401): This key isn't approved for the /dev/null app. Sign in again in the app.
Warning: Write to devnull://@you to commit, or to devnull://@user/commits/<id> to reply, not to devnull://@me/replies.
Settings
| Environment variable | Context option | Default |
|---|---|---|
DEVNULL_URL |
url |
https://dev-null.sh; only https:// or local http:// URLs |
DEVNULL_KEY |
key |
see Which key signs |
Context options win over the environment:
$context = stream_context_create(['devnull' => ['key' => getenv('HOME').'/.ssh/id_devnull']]); file_put_contents('devnull://@me', 'Signed with another key', 0, $context);
Authorize a key
Stars, pastéis and deletion need a key approved as a device, once:
vendor/bin/devnull authorize "My laptop"
It prints the account the key was approved for. The key is DEVNULL_KEY, or your SSH agent's first key.
With a code typed on the web
This is the intended flow, the device authorization grant for terminals. dev-null.sh doesn't offer it yet: it is proposed in dev-null-sh/dev-null.sh#585.
Open and approve:
https://dev-null.sh/activate?user_code=WDJB-MJHT
or open https://dev-null.sh/activate and type the code WDJB-MJHT
Waiting for the approval…
Approved as a device of @you.
- The command asks for a code with a request signed by the key to approve, and prints it with where to type it.
- Open the link, sign in, check the code and approve.
- Meanwhile the command asks every few seconds whether you approved, slowing down when the server says so. It stops if you deny the device or once the code expires, after 15 minutes.
With a link copied from the browser
When the server has no device authorization grant (its start endpoint answers 404, as dev-null.sh does today), the command falls back on the mobile app's sign-in:
- The command prints a
https://dev-null.sh/mobile/authorize?…link: open it, sign in and approve. - The page then sends your browser to a
devnull://authorize?code=…link, which it cannot open. Copy that link from the address bar, or from theLocationheader of the approval'sPOSTin the developer tools' Network tab, and paste it in the terminal.
The command redeems the code with a PKCE verifier only it knows, so the link is useless to anyone else.
How signing works
Private keys are never read: ssh-keygen -Y sign signs, from your key file or through your SSH agent, and the wrapper
only reads public keys.
| What | Endpoint | Namespace | Signed message |
|---|---|---|---|
| Commit, reply | POST /api/cli/commits |
devnull-commit |
<timestamp>\n<nonce>\n<reply to or empty>\n<body> |
| Star, pastéis, delete | the /api/v1 signed API |
devnull-api |
<timestamp>\n<nonce>\n<METHOD>\n<path and query>\n<JSON body> |
The nonce is 32 random hexadecimal characters. The signature goes in the X-SSH-Signature header, as the bare base64
of the armored signature; the signed API also gets X-Devnull-Timestamp and X-Devnull-Nonce.
Which key signs
DEVNULL_KEY(or thekeycontext option) when set: a private key file, or a public key held by your SSH agent.- Otherwise, to commit: the first Ed25519 or RSA key of your SSH agent or of
~/.ssh/*.pubthat dev-null.sh finds on the GitHub account of one of its users (POST /api/cli/key-lookups, with public keys only). - Otherwise, for the signed API: the first of those keys, since devices know nothing of GitHub.
Design
The stream wrapper is a thin adapter over a client, whose transport, signer and key discovery are ports, so that everything is tested without network. Device approval is a port too, with one strategy per flow, and its polling sleeps through a clock port, so that tests don't wait.
flowchart LR
functions["file_get_contents(), fopen(), scandir(),<br>stat(), touch(), unlink()"] --> wrapper[StreamWrapper]
cli[bin/devnull] --> app[Console\Application]
wrapper --> path[Path]
wrapper --> formatter[CommitFormatter]
wrapper --> client[Client]
app --> flow{{Authorization\AuthorizationFlow}}
flow --> fallback[FallbackFlow]
fallback --> devicecode[DeviceCodeFlow<br>RFC 8628]
fallback -- "on 404" --> pkceflow[PkceFlow<br>RFC 7636]
devicecode --> clock{{Clock\Clock}}
devicecode --> client
pkceflow --> client
client --> transport{{Http\Transport}}
client --> signer{{Signing\Signer}}
client --> keys{{Keys\KeyRing}}
transport --> native[NativeTransport<br>PHP https stream]
signer --> sshkeygen[SshKeygenSigner<br>ssh-keygen -Y sign]
keys --> local[LocalKeyRing<br>ssh-add -L, ~/.ssh/*.pub]
native --> devnull[(dev-null.sh)]
Loading
StreamWrapper::useClientFactory() replaces how the wrapper builds its client, e.g. to plug another transport.
Development
composer install composer test # Pest; the signing tests use the real ssh-keygen and skip without it composer analyse # PHPStan, max level composer lint # Pint, dry run (composer fix applies it)