Skip to content

Runlet 0.1.0

Choose a tag to compare

@filipac filipac released this 02 Oct 19:15
· 114 commits to main since this release

Runlet 0.1.0 adds SSH servers, a run inspector, a Run Log, and a lot of polish since 0.0.1. Website: https://filipac.github.io/runlet/

Highlights

  • SSH targets. Run snippets on your servers with the system ssh. Logins from ~/.ssh/config, jump hosts, ssh-agent, and 1Password work as they do in your terminal. Password and 2FA logins are done once with Connect… and stay open until you Disconnect. Each host has:

    • a local project folder for completion, drivers, snippets, host commands, and clickable server paths;
    • Detect and Browse… to find its folder on the server;
    • project commands and Shell on Host;
    • optionally, a Docker container on that server.

    Manage Profiles… covers Docker and SSH and imports hosts from ~/.ssh/config. The opt-in Keep compiled PHP on the server speeds up large apps.

  • Production guard for every target: a red badge, and a confirmation before each run. Project commands always ask.

  • Run inspector. SQL queries with timings, bindings, and N+1 hints, from Laravel, Eloquent without Laravel, Doctrine, or WordPress. Mail and HTML previews in a locked-down viewer, with optional mail interception, and logs. Drivers can add their own sections. Output exports as JSON, PHP, CSV, or Markdown, and Run ▸ Save Output As… saves it.

  • Run Log (Run ▸ Show Run Log): the exact launch command, driver choice, boot timing (WordPress per phase and plugin), stderr, and the exit. An exit() during boot now says why: WordPress redirects and the last file loaded. WordPress runs use the site's real host, so plugins such as W3 Total Cache no longer redirect them.

  • Terminal:

    • commands wait until your shell is ready;
    • command tabs stay open after they exit;
    • ⌘W closes the focused terminal tab.
  • Palette:

    • a click outside closes it;
    • ⌘P and ⇧⌘P switch modes;
    • better matching;
    • ! searches history, and @ lists Docker and SSH.
  • Library: History per project or for all projects, without duplicates, and keyboard-first. You choose where double-click opens entries. The Commands pane shows the driver's variables. hostCommands() lets drivers add commands that run on your Mac, such as your own CLIs. There's also a gitRevision() helper.

  • More:

    • the runlet command-line tool;
    • file-backed tabs follow changes on disk;
    • Float on Top, and recent projects in the Dock menu;
    • the editor/output split is remembered.
  • Fixes:

    • focus stays in Runlet after closing a window;
    • no more crash when opening the sidebar;
    • quitting can't leave Runlet running in the background;
    • SSH connects only on the first run.

Install

Download Runlet-0.1.0.dmg (drag Runlet to Applications) or Runlet-0.1.0.zip. Requires macOS 26 or later, on Apple silicon or Intel.

This build is ad-hoc signed and not notarized, so macOS blocks it the first time. Either right-click Runlet.app ▸ Open ▸ Open, or run:

xattr -dr com.apple.quarantine /Applications/Runlet.app

Checksums (SHA-256)

dbdd689e087d78c1fb53b51e95c3032c2ecf1aa4686cffa6f2ab18f2de4b7aa1  Runlet-0.1.0.dmg
3342e1a00dba723aef40d45ac4cf63c796d3a3dee41dbce8a61e77ecd5609334  Runlet-0.1.0.zip

Notes

  • Early preview. Package tests (384) and the packaged self-test pass; the UI test suite was not re-run for this build.
  • Free and open source under the MIT License.
Full changelog for 0.1.0

2026-10-02 — Keep compiled PHP on the server (SSH)

  • SSH profiles have an opt-in Speed ▸ Keep compiled PHP on the server. Runs then use
    PHP's opcode cache with a file cache in ~/.cache/runlet/opcache (mode 0700, only the
    SSH user), so big apps such as WordPress with many plugins don't recompile every file on
    each run.
  • Timestamps are checked on every run, so edits are picked up. When the folder can't be
    created or PHP has no opcache extension, runs go on uncached.
  • Only Runlet's runs use the cache; the server's PHP settings are untouched. It's off by
    default because it writes to the server, and not offered with a container step.
  • The Run Log's WordPress boot line shows the file cache when it is in use.

2026-10-02 — Remembered for the session: driver and WordPress site URL

  • Runs remember what they worked out per target until Runlet quits, and the runner reuses
    it once it checks it still applies:
    • the built-in driver it chose, while no .runlet driver is added or edited;
    • a WordPress site URL, while wp-config.php has the same modification time and size.
      This skips the wp-config.php evaluation and database lookup, about 50 ms per run.
  • The Run Log says "remembered for this session". A run that fails while booting forgets
    that target's values, so the next run detects everything again.

2026-10-02 — Where WordPress's boot time goes

  • The Run Log has a WordPress boot breakdown:
    • time per phase: core and must-use plugins, plugins, plugins_loaded hooks, theme,
      user and init setup, init hooks, wp_loaded hooks, and Runlet's admin APIs;
    • the slowest plugins to load, by folder;
    • whether PHP's opcode cache is on for the command line. It is usually off
      (opcache.enable_cli), so every run compiles every file the site loads, while web
      requests keep them compiled.

2026-10-02 — Quitting really quits; SSH connects on the first run

  • Quitting closes the shared SSH connections that runs opened by themselves (profiles using
    ssh-agent, 1Password, or keys). Nothing stays connected after Runlet quits, and the next
    launch shows Disconnected until the first run on that host connects. Logins made with
    Connect… (passwords, 2FA) still stay open until Disconnect.
  • Quitting never waits more than 4 seconds for runs, language servers, or SSH connections to
    stop, so Runlet can't be left "Running in Background" after ⌘Q.

2026-10-02 — Profiles window for Docker and SSH, ~/.ssh/config import (SSH-7)

  • Library ▸ Manage Profiles… opens one Profiles window (formerly Docker Profiles) for every
    Docker and SSH profile: the list has Docker and SSH Hosts sections (SSH rows show the
    connection status, read on this Mac, and the environment); the left side edits the selected
    profile with its usual form. Same model as before: a draft until Save (↩/⌘S), Revert,
    Save / Don't Save / Cancel on switching or closing; + creates either kind (hold for the menu)
    or imports hosts; ⋯ duplicates, uses in the current tab, or connects and disconnects.
  • Import SSH Hosts from ~/.ssh/config… (Library menu, the Profiles window, Settings ▸
    Targets, the command palette): the config's Host aliases with their ssh -G summary;
    tick hosts, optionally enter each directory (or use Detect later), and check the
    environment, preselected from words such as prod and staging in the alias or host
    name. Nothing connects.
  • Settings ▸ Targets: Import and Manage Profiles… for SSH hosts, which also show when they're
    connected; SSH hosts can be the default target for new tabs (a new tab never connects).
  • Debug builds: RUNLET_SSH_EXECUTABLE replaces /usr/bin/ssh. Tests/Fixtures/fake-ssh/ssh is
    a loopback fake for screenshot tours (made-up ssh -G, a fake shared connection and password
    prompt, commands run on this Mac; never a server), and the visual tour uses it with a made-up
    SSH config and adds a Profiles-window shot.
  • Tests: the import helpers (environment guess, ssh -G with a test config) and the fake ssh
    driving a run, Test Connection, status, and Disconnect.

2026-10-02 — SSH: Docker on the server (SSH-6)

  • SSH profiles can run inside a Docker container on the host: turn it on in "Docker on
    This Host", click List Containers… (lists the server's running containers over SSH,
    grouped by Compose project), and choose one. Runs use docker exec into it through the
    profile's SSH connection (no prompts, strict host keys, the shared login), with the
    container's PHP, user, working directory, and TMPDIR, and an optional sudo -n docker.
  • The container is found the way Docker profiles find theirs: by Compose project and
    service (or name), checked again right before launch, and never switched silently; several
    replicas or a replaced container ask which one to use (the chosen replica stays chosen while
    it runs, also for Docker profiles).
  • Stop signals PHP inside the container on the server; the container keeps running. Test
    Connection also finds the container and probes it (a server without PHP of its own is
    fine). Browse… lists folders inside the container.
  • File links map container paths to the local folder through the server directory's bind
    mount. Project commands run inside the container (docker exec -it over ssh -t); the
    terminal's + menu offers a shell in the container and one on the server itself.
  • Tab cards show "SSH · Docker", the target's container appears in the production
    confirmation, ⌘P, Settings ▸ Targets, and the status bar, and workspace files carry the
    container step (by Compose identity or name, never an ID).
  • Docker problems on the server are explained (Docker not found, no permission on the Docker
    socket, sudo asking for a password, daemon not running).
  • Tests: a fake docker installed on the SSH fixture (made-up containers that are folders
    of the fixture) covers listing, resolution (replicas, recreation, a replaced name-only
    container), runs, Stop, a vanished container, probes, facts, folder listings, command
    listing, and a project command in the container; plus unit tests for the model,
    validation, workspaces, and path mapping.

2026-10-02 — SSH: project commands and shells on the server (SSH-5)

  • Commands listed for an SSH host now run on the server: a terminal tab runs ssh -t
    (still BatchMode, strict host keys, and the shared connection: no password prompts, no
    unknown host keys) with /bin/sh -lc 'cd <directory> …; <command>', a leading php
    replaced by the profile's PHP. The login shell's profile applies, so Composer's global bin
    and similar PATH additions work. A missing directory is explained in the tab. Commands
    that need arguments open a login shell in the directory with the command typed.
  • Shell on Host: a login shell on the server in the profile's directory, from the
    terminal's + menu, the target menu, the Commands panel, the Library menu, and the command
    palette ("Open Shell on SSH Host"). Terminal tabs running ssh show a server icon.
  • Commands panel for SSH hosts: "List Commands on " (Connect… first for a password
    host that isn't logged in), a note when the host is production, and a server icon on
    commands that run there.
  • Production hosts ask before every command, every listing, and every shell (the 10-minute
    grace covers snippet runs only).
  • Tests: the exact terminal argv runs under script(1) against the SSH fixture (odd
    directory names, the server's PHP, a missing directory, a login shell, an unknown host
    key), plus unit tests of the argv and its quoting, including the container form.

2026-10-02 — Run Log, and why an app exits while booting

  • Run ▸ Show Run Log is a toggle, also in the palette and the output pane's share menu, and
    available in release builds too. It shows a log under the output for the current run:

    • the exact launch command as one shell line (/usr/bin/ssh …, docker exec …, or the
      local PHP), the working directory, and the runner script's size on stdin;
    • the driver the runner chose and why, boot time, and variables;
    • stderr, errors, and how the process ended (status, reason, exit code, time).

    Environment values are never shown. Copy copies the whole log.

  • An exit() during bootstrap now explains itself. The error names the project file loaded
    last, usually the plugin, config file, or bootstrap script that exited. For WordPress, it
    also gives the redirect WordPress tried (URL, status, and the file and line that sent it),
    with advice for an install.php redirect (WordPress found no installation in the database
    wp-config.php points to, as the command line sees it) and for forced-HTTPS or
    canonical-host redirects.

  • WordPress runs present the site's real host and scheme instead of http://localhost, so
    canonical-host and force-HTTPS code (page caches such as W3 Total Cache, SSL plugins) no
    longer redirects and exits. The host comes from WP_HOME/WP_SITEURL (or
    DOMAIN_CURRENT_SITE as wp-config.php really defines them. wp-config.php is evaluated in a
    separate PHP process, the way WP-CLI does it: the line that loads WordPress is removed,
    __DIR__/__FILE__ point at the real file, and output is discarded. That means
    conditionals, environment variables, and included files count, and commented-out or
    local-only definitions don't. Without those constants, the home option is read from
    MySQL/MariaDB with the real database settings (one read-only query). The config is read as
    text, ignoring comments, when the probe can't run, and home is applied once WordPress
    connects as a last resort. Plugins that cache the host early, such as W3 Total Cache, see
    the right one. The request used, and where it came from, is in the Run Log.

  • Drivers can add Run Log lines with $this->log() and explain exits with
    bootstrapExitHint().

2026-10-02 — Website

  • website/: a one-page site for https://filipac.github.io/runlet/ (plain HTML, CSS, and a
    small script; system fonts, automatic light and dark, no trackers or external requests):
    run anywhere, run inspector, editor and completion, drivers and commands, more features,
    open source, install (with the steps for an ad-hoc signed build), and FAQ. The "Early
    preview" badge is one element near the top of index.html.
  • Screenshots are real windows of a Debug build with demo content, in light and dark (WebP,
    1200 and 2400 px wide), plus an Open Graph image and icons. No personal data: a demo
    Laravel project, made-up Docker containers, and the runlet-fixtures SSH host under
    app.example.com / shop.example.com.
  • .github/workflows/pages.yml deploys website/ to GitHub Pages on pushes to main that
    change it (and on demand). Pages must use "GitHub Actions" as its source.
  • scripts/website-screenshots/shoot.sh regenerates the screenshots: it builds the app with
    its own bundle identifier, seeds a scratch RUNLET_DATA_DIR, and runs it hidden in the
    background, so nothing appears on screen and your Runlet data, ~/.ssh, and containers are
    never touched.
  • Debug builds: RUNLET_DEBUG_STEPS gains screenshot steps in DebugSteps.swift: ghost
    (windows keep drawing but stay invisible, click-through, and out of the Dock),
    appearance:, frame:, scale:, caret:, palette:, complete, segment:,
    command:, and shot:<name> (the main window with its sheet, palette, and popups composited
    into one PNG, web previews and terminals included).

2026-10-02 — MIT license and readme overview

  • Runlet is licensed under the MIT License (LICENSE).
  • The readme now opens with an overview, features, install instructions (including opening
    an ad-hoc signed build), and license notes, ahead of the existing development guide.

2026-10-02 — SSH profiles: directory validation, Detect, and Browse…

  • Fixed: the SSH profile's Directory could look filled in while Save stayed disabled. The
    field was empty and showed its gray example (/var/www/app), which read like a value.
    The placeholder now says "Absolute path on the server", and the message says what is
    wrong: empty ("enter the application's folder… Detect and Browse… find it"), relative, or
    starting with ~ (Runlet doesn't expand ~ on the server). Every value is checked as it is
    saved (surrounding whitespace, a pasted newline, and a trailing / don't count), with unit
    tests for the validator.
  • Detect next to Directory connects (BatchMode, through the shared connection) and runs
    a read-only PHP check: it fills an empty Directory with your home folder on the server
    (or replaces a leading ~), and a popover lists the home folder first, then folders that
    look like PHP applications (artisan, bin/console, wp-config.php, composer.json,
    .runlet), with Forge's current symlinks kept. A wrong PHP still finds the home folder.
  • Browse… opens a folder picker on the server: a path field (~ works), breadcrumb,
    enclosing folder and home, double-click to enter, badges for PHP applications, symlinks
    shown and kept as chosen, hidden folders on request, and inline errors (permission
    denied, missing folder, not connected, unreachable host). Nothing is written.
  • Password and 2FA profiles: Connect… from the profile sheet (or Detect's popover) no longer
    needs a savable profile. The sheet steps aside for the login in the terminal and reopens
    with your values once it succeeds.
  • Tests: fixture tests for Detect and listing (home folder, symlinked current, a folder
    name with quotes, $, and backticks, permission denied, missing folders, files, relative
    paths, a missing PHP, and an unknown host key).

2026-10-02 — Output export: rows as JSON or PHP, Markdown, Save Output As…, links

  • Table view: right-click a row for Copy Row as JSON, Copy Row as PHP Array (keys
    kept, types kept: numbers, booleans, null, nested arrays), Copy Row as CSV, and Copy Cell.
  • Result and dump cards: next to Copy, a menu copies the value as JSON, PHP, or Markdown.
  • Copy Output as Markdown (Run menu, command palette, and the output header's export
    menu): each card becomes a heading with its content in a fenced block, tabular values become
    Markdown tables, followed by the run inspector's queries and mail.
  • Run ▸ Save Output As…: saves the output as Markdown (.md), plain text, or raw
    stdout/stderr (.txt), picked in the save panel.
  • Web links (http, https, mailto, written with their scheme) in stdout/stderr cards and
    in the Plain and Raw transcripts are clickable and open in the default browser.

2026-10-02 — Run inspector in the output pane: queries, mail, logs, previews; mail interception setting

  • The output pane gets a row of sections once a run reports any: Output, Queries,
    Mail, Log, and the driver's own sections (Cache, HTTP calls, …), each with its
    count. Clear Output clears them too.
  • Queries: count, total time, and repeated statements at the top; each statement with
    its time (the slowest in orange), connection, the snippet line that ran it (click to go
    there), and the SQL with its bindings inlined for reading. Expand a row for the SQL with
    placeholders and the typed bindings. Copy SQL, Copy SQL with Bindings, Copy Bindings as
    JSON. Statements run again with the same bindings are flagged "identical"; a similar
    SELECT run 3 or more times with different bindings is flagged "N+1?" with an eager-loading
    hint, and the hint chips filter the list to that statement. Group Similar shows one row per
    statement shape. The finished line adds "N queries (x ms)".
  • Mail: each message with its status (sent, intercepted, or queued), headers, mailable,
    mailer, attachments, and a preview. Mail also appears in the output stream as one line
    each ("Mail intercepted (not sent): “Welcome” to ada@example.com"), which opens the section.
  • Previews: a returned or dumped mailable, mail notification, view, Htmlable, or HTML
    response shows its rendering first (Preview, Tree, Table). Previews use a WKWebView with
    JavaScript off, nothing loaded but data: URLs (blocked by a content rule list and a
    Content Security Policy; remote images can be allowed per preview), no navigation (clicked
    links open in the browser), with HTML, Text, and Source views and Open in Window.
  • Settings ▸ General ▸ Run Inspector: Record queries, mail, and logs (on), Intercept
    mail
    (off by default, as docs/next-release-ideas.md N02 suggests: interception changes
    what a run does), and Preview returned mail, views, and HTML (on). Local projects, Docker
    profiles, and SSH profiles can override Intercept mail (Default / Intercept / Send). While it applies,
    the output header shows an orange "Intercepting Mail" chip, the run header says "mail
    intercepted", intercepted messages are marked in the output and the Mail section, and a
    warning appears when the project's driver can't intercept mail. Run ▸ Toggle Mail
    Interception, Show Queries, and Show Mail are in the command palette and remappable.
  • Fixed: without a VarDumper (no symfony/var-dumper and no global dump tool), dump() and
    dd() reported line 1 instead of the line that called them: frames inside the runner's own
    evaluated fallback dump() counted as the snippet. Only code evaluated on the snippet's
    eval() line counts now.
  • Debug builds: RUNLET_DEBUG_STEPS gains project:<dir>, code:<file>, run,
    section:<name>, and intercept:on|off.

2026-10-02 — Run inspector: driver API, queries without Laravel, mail and previews in the runner

  • Drivers get a run inspector (Runlet\Inspector, in the new Resources/Runner/src/Inspector.php):
    a new Driver::inspect(Inspector $inspector) hook runs after bootstrap() and before the
    snippet (never when commands are listed) and reports SQL queries, mail, log messages, HTML,
    and sections of the driver's own (record('Cache', 'hit users', $value)). Snippets reach it
    through Inspector::current(). Its methods never throw; a throwing inspect() is a notice
    and the run continues. Documented in docs/drivers.md ("Run inspector").
  • Queries are found without any driver code where possible: Laravel (the app's events),
    Eloquent without Laravel (Capsule, as in Slim or PHP-DI apps: live QueryExecuted
    events, adding a dispatcher for the run when the connections have none, or the query log
    without illuminate/events), WordPress ($wpdb with SAVEQUERIES), and Symfony's Doctrine
    connections. Drivers can call inspectEloquent(), inspectDoctrine() (DBAL 2, 3, and 4),
    and inspectWordPress() themselves, and $inspector->watchPdo($pdo) records a plain PDO
    connection's prepared statements. Each record carries the snippet line that caused it.
  • Laravel's driver also records mail (MessageSending, with subject, addresses, HTML and text
    bodies, attachments), log messages, and mail pushed to an asynchronous queue. With mail
    interception requested, its MessageSending listener returns false: the message is built
    and recorded, never sent. Symfony Mailer is recorded too (and intercepted on 6.3+).
  • Returned or dumped mailables, mail notifications, views, Htmlable/Renderable objects,
    and HTML Symfony responses carry a rendered HTML preview (Driver::preview(), overridable).
  • Protocol: run requests carry inspector options (enabled, interceptMail, previews)
    and new limits (2,000 queries, 2,000 other records, 8 MiB of records, 2 MiB per body); new
    frames inspector, record, and recordLimit; result and dump gain preview. The app
    decodes them into RunEvent.Kind.inspector (RunInspection, QueryAnalysis with duplicate
    and N+1 hints) and drops records past the limits itself if a driver bypasses the runner's.
  • Fixtures: Tests/Fixtures/eloquent-app (Capsule with illuminate/events and DBAL 3, PHP 7.4
    compatible) and eloquent-app-modern (illuminate/database 13 without events, DBAL 4),
    installed by scripts/setup-fixtures.sh.

2026-10-02 — gitRevision() driver helper

  • Runlet\Driver::gitRevision($projectPath) returns "main @ 3f2a1c9" (or just the short
    commit for a detached HEAD). It reads the .git files directly, including packed refs and
    linked worktrees, and runs no git command, so it works well as version(), which tab
    cards, the status bar, and the Commands pane show. Documented in docs/drivers.md, with
    tests for each git layout.

2026-10-02 — Driver variables in the Commands pane

  • The Commands pane header lists the driver's snippet variables (variables()) for the
    current target, each with its class, e.g. $_app App. Clicking one inserts it at the
    editor's cursor (undoable). The tooltip shows the full class.
  • Loading commands now teaches completion the driver's variables too, not only runs.

2026-10-02 — Target environments and the production guard (N14, SSH-4)

  • Every target (local projects, Docker profiles, SSH profiles) has an environment —
    development, staging, or production — and an optional colour, in the project options and
    both profile forms. Workspaces keep an SSH profile's environment.
  • Production targets show a red PRODUCTION badge next to the target menu, on tab cards (with
    a red stripe) and horizontal tabs, in the target menu, ⌘P, and Settings ▸ Targets, and a
    red-tinted status bar. Staging gets an orange badge; a colour draws a stripe on tab cards
    and the status bar.
  • Each run on a production target asks first, showing the target, where it runs, and the
    first 12 lines of the code or selection. ⌘↩ runs it; ↩ and Esc cancel. "Don't ask again
    for 10 minutes" covers snippet runs on that target only, lives in memory, and ends on
    quit or when the target is edited.
  • Project commands on production always ask, every time: listing (it boots the app), each
    command, and host commands run for that target on this Mac.
  • Stricter defaults: the Commands panel never lists a production target by itself, and
    Runlet doesn't look inside a production Docker container for tab facts (it reads the
    local source instead).

2026-10-02 — SSH: the local folder, suggestions, and drift (SSH-3)

  • An SSH profile's local folder (its checkout on this Mac) powers the same features as a
    local project: PHPantom completion and diagnostics, framework and driver facts read from
    local files (no network), project snippets and Save Snippet to Project…, host commands,
    Open Project in Editor, and the terminal's start folder. Without one, the profile runs in
    limited mode and says why.
  • File links in output map server paths to the local folder, from both the profile's
    directory and the real path PHP reports, so Forge-style …/current and
    …/releases/<id>/ paths open the same local file.
  • Folder suggestions for profiles without a local folder: folders Runlet knows plus a
    shallow scan of ~/Code, ~/Projects, ~/Sites, ~/Herd, and similar, matched by the
    server's git remote, composer.json name (both after Test Connection), or folder name
    (including Forge site folders). Offered above the editor ("Use for Completion") and in
    the profile; never applied on its own.
  • Optional drift warning (off by default): after Connect…, Test Connection, and the first
    run of a session, Runlet compares the local folder's branch and commit (or
    composer.lock, for deployments without .git) with the server's, read by the same
    read-only PHP check (no git runs on the server), and shows a yellow banner when they
    differ. It never blocks a run.
  • Test Connection also reports the server checkout's git remote, branch, commit, and a
    composer.lock CRC-32.

2026-10-02 — SSH: Connect… and Disconnect for passwords and 2FA (SSH-2)

  • SSH profiles that log in with a password, keyboard-interactive answers, a one-time code,
    or a key passphrase no agent holds use Connect…: a terminal tab runs
    ssh -M -N -f with Runlet's control socket, and OpenSSH asks its own questions there.
    Runlet never reads, stores, or logs what you type. Once logged in, ssh moves to the
    background, the tab closes, and runs reuse the login without prompts.
  • The login stays until Disconnect (ssh -O exit; asks first when runs are in
    progress). Quitting Runlet doesn't end it, and Runlet finds it again after a restart.
    When the network drops it shows "Login ended" and the next run asks to Connect again.
  • Status (Connected, Not connected, Login ended) is read from the control socket on this Mac,
    so checking never starts ssh or contacts the server. It shows in the status bar, the
    target menu, and the profile; a banner above the editor offers Connect… when a
    password profile isn't connected, while a login is in progress, and after a run failed
    for a reason Connect… fixes.
  • Unknown host keys: Connect… forces OpenSSH's fingerprint question
    (StrictHostKeyChecking=ask, whatever ~/.ssh/config says), so a key is only ever added
    by your answer. Runs still refuse unknown keys.
  • New commands: Connect to SSH Host… and Disconnect from SSH Host (Library menu and the
    command palette). The profile sheet saves and closes before Connect… so you can type in
    the terminal. Debug step runner: connect:<profile>, disconnect:<profile>,
    select:<tab>, and run.

2026-10-02 — SSH targets: run snippets on a server (SSH-1)

  • New target kind: SSH hosts. Library ▸ New SSH Profile… (also in the target menu,
    the command palette, and Settings ▸ Targets) saves a host (a ~/.ssh/config alias or a
    host name, with optional user, port, and jump-host overrides), the application's
    directory on the server, the server's PHP, and an optional local folder. Tabs, ⌘P, the
    target menu, tab cards ("SSH" chip, user@host:directory), the status bar, workspaces,
    and history know them. Saving or opening a profile never connects.
  • Runs use the system /usr/bin/ssh, so ~/.ssh/config (aliases, ProxyJump,
    IdentityAgent, Include), ssh-agent, the 1Password agent, key files, known_hosts, and
    UseKeychain work as in Terminal. Runlet stores no keys or passwords. The runner is
    streamed to the server's PHP on stdin (nothing is written on the server), with
    BatchMode=yes, StrictHostKeyChecking=yes (never accepts an unknown host key), short
    connect and keep-alive timeouts, LogLevel=ERROR (no login banner in the output), and
    compression. Output, dumps, dd, exit, fatals, and limits behave as in local runs.
  • One shared OpenSSH connection (ControlMaster) per profile serves runs, Stop, and Test
    Connection: agent and key profiles open it on the first run and keep it for 10 minutes
    (configurable, or until Disconnect). Sockets live in Application Support/Runlet/SSH.
  • Stop on a server signals the runner and everything the snippet started (every process
    carrying the run's RUNLET_RUN_ID), after checking /proc, with Docker's
    SIGTERM/SIGKILL timing. Servers without /proc are left alone and the stop is reported
    as unconfirmed.
  • ssh failures are explained in plain words (unknown or changed host key, rejected keys,
    unresolvable or unreachable host, lost connection, missing directory, PHP not found),
    with OpenSSH's own message kept below.
  • Test Connection runs one read-only php -r on the server: PHP version and binary, user,
    OS, the directory and its real path (Forge's current), framework, tokenizer, Stop
    support, round-trip time, and the application folders and PHP binaries it finds.
  • The Commands panel never lists an SSH host's commands by itself ("List Commands on
    "); host commands run on this Mac in the local folder. Running server-side commands
    from the panel comes later.
  • Tests: a disposable runlet-fixtures service ssh (OpenSSH + PHP 8.4, 127.0.0.1:2222
    only) that the SSH tests start when needed, with a throwaway key, their own ssh -F
    config and known_hosts, and no agent. They cover runs, dumps, dd, exit, fatals,
    quoting, Stop with children, concurrent runs, a dead link, unknown host keys, rejected
    logins, and Test Connection. Debug builds read RUNLET_SSH_CONFIG instead of
    ~/.ssh/config, and RUNLET_DEBUG_STEPS gained ssh:new/ssh:<name>.
  • Docs: new docs/ssh.md; architecture and drivers updated.

2026-10-02 — The runlet command-line tool

  • runlet opens things in Runlet from a terminal: runlet or runlet . opens the current
    folder as a local project, runlet <folder> another folder, runlet <file.php> a file
    (saving writes back to it), and runlet <name.runlet> a workspace. -t/--target opens
    files (or, alone, a new tab) on sandbox, a project, or a Docker profile, by name or
    folder; -n/--new-window opens a new window. Folders reuse an already saved project and a
    blank current tab. Nothing runs. See docs/cli.md.
  • Runlet ▸ Install Command-Line Tool… (also Settings ▸ General ▸ Command-Line Tool and the
    Command Palette) creates one symbolic link to the tool in a folder you pick:
    /usr/local/bin (macOS asks for an administrator password when needed), ~/.local/bin
    (with a note when it isn't on your shell's PATH), or another folder. It shows the exact
    link first, never replaces a file that isn't Runlet's link, and replaces a link to another
    copy of Runlet only on Replace. Remove Link deletes it.
  • The tool talks to the running Runlet with a distributed notification and waits for its
    answer, so it prints what couldn't be opened (an unknown target, an unreadable file) and
    exits with a status. When Runlet isn't running, it starts it through Launch Services.
  • A folder dropped on Runlet's Dock icon (or open -a Runlet <folder>) now opens as a
    project too.
  • The tool is the new RunletCLI target, copied into Contents/Helpers/runlet;
    scripts/package.sh checks it (universal, --version), and so does
    Runlet --self-test.

2026-10-02 — Float on Top, recent projects in the Dock

  • Window ▸ Float on Top keeps the current window above other apps' windows, for example
    next to a browser while you try things. It is per window, has a checkmark in the menu,
    shows "On" in the Command Palette, can get a shortcut in Settings ▸ Shortcuts, and lasts
    until you turn it off or quit.
  • The Dock icon's menu lists recently used projects: local projects and Docker profiles,
    most recent first. Choosing one opens it in the current tab when that tab is blank, or in
    a new tab. Nothing runs.
  • Commands can now be on/off items (AppCommand.isChecked), shown with a checkmark.

2026-10-02 — Tabs follow their files on disk

  • A tab opened from a file now notices when another app changes, replaces (an atomic save,
    as editors and git do), deletes, or restores that file. Contents are compared, so a
    touch or Runlet's own save changes nothing.
    • No unsaved edits: the tab reloads silently, keeping the caret and scroll position
      (⌘Z brings the previous code back).
    • Unsaved edits: a banner offers Reload or Keep Mine. Keep Mine keeps the tab's code, and
      the next ⌘S replaces the file without asking again.
    • The file is gone: a banner says so; the code stays, the tab counts as unsaved, and Save
      writes it back.
    • A file tab restored from the last session whose file now differs gets the Reload /
      Keep Mine banner, since unsaved edits and a change made while Runlet was closed look
      the same.
  • ⌘S never silently replaces a file that changed on disk: it asks first (Save Anyway /
    Cancel). Cancelling no longer opens a Save As panel for a tab that has a file.
  • File ▸ Reload from Disk (also in the palette) shows the file's version in the current tab.
  • Opened and saved PHP files are now added to the recent documents, so Open Anything (⌘P)
    lists them under Recent; before, only workspaces were.
  • Files are checked again whenever Runlet becomes active, in case an event was missed.
    Nothing is ever written or run without the user.

2026-10-02 — Keyboard-first History and Snippets, history in ⌘P

  • Show History (⌘Y) and Show Snippets (⇧⌘L) now put the keyboard in the pane's search
    field, with its text selected. While typing, the best match is selected, ↑ and ↓ move the
    selection, ↩ opens it where Settings ▸ General says (like double-click), ⌘↩ opens it in a
    new tab, and ⇧↩ inserts it at the cursor (without its <?php tag). After opening, the
    editor gets the keyboard. Esc clears the search, and a second esc goes back to the editor.
  • In the list itself (Tab from the search field, or a click), ↩ opens, ⌘↩ and ⇧↩ work the
    same way, ⌫ deletes (History at once; personal snippets after asking) and selects the
    next row, and typing a letter continues the search.
  • Open Anything (⌘P) searches History behind a ! prefix: runs on the current tab's
    target come first, the code itself is searched, and ↩ / ⌘↩ open an entry like the History
    pane does. Nothing runs.
  • LibraryKeyboardUITests covers these keys, ! in ⌘P, and a file tab following its file.
    It compiles with the suite but hasn't been run yet (the UI suite takes over the keyboard).
  • Debug builds: RUNLET_DEBUG_STEPS gains perform:<command>, key:<keys>, type:<text>,
    state (focus and tabs), open:<path>, and file steps (write, replace, remove), in
    DebugSteps.swift. Key events are queued like real ones.

2026-10-02 — Docs: next-release ideas and SSH design

  • docs/next-release-ideas.md: a prioritized list of post-0.0.1 ideas from a full review of
    Tinkerwell's v5 docs and changelog, with a Tinkerwell→Runlet gap table, ideas grouped by
    theme (each with behaviour, fit in Runlet's code, size, safety notes, and priority), what
    to skip, and sources. Includes a detailed SSH targets design: system ssh with
    ControlMaster, password/2FA through a terminal login, an optional remote docker exec
    step, a local project folder per host, production guard rails, and milestones.