# PaperBoy
A Rust-native alternative to Postman. Collections are [Hurl](https://hurl.dev)
`.hurl` files and environments are `.vars` files (`KEY=value`), so everything is
plain text you can commit, diff and review. No hosted service, no telemetry,
nothing leaves your machine.
One binary, three front-ends over the same core:
| Terminal UI | `paperboy` | The default. Full client. |
| Graphical UI | `paperboy -g` | eframe/egui. Behind the `gui` Cargo feature. |
| Headless runner | `paperboy -c collection.hurl` | For scripts and CI. Also runs reports (`-r`). Exits non-zero on failure. |
Collections, environments, themes and the git workflows behave identically in
all three.
- [Install](#install)
- [Concepts](#concepts)
- [Terminal UI](#terminal-ui)
- [Graphical UI](#graphical-ui)
- [Environments and secrets](#environments-and-secrets)
- [Git remotes](#git-remotes)
- [Importing from Postman](#importing-from-postman)
- [Headless runner](#headless-runner)
- [Changelog](CHANGELOG.md)
## Install
```sh
cargo install paperboy --locked # terminal UI + headless runner
cargo install paperboy --locked --features gui # …and the graphical UI
```
`--locked` is recommended: it builds the dependency versions PaperBoy was
tested against rather than re-resolving to whatever is newest. (A yanked
`arrayref` release took plain `cargo install` down on 2026-08-20 while
`--locked` kept working — and it took the terminal-only build with it, since
Cargo resolves optional dependencies whether or not their feature is on.)
The `gui` feature is opt-in because eframe/winit/wgpu roughly double the
dependency tree. Both builds share the same state file, so you lose nothing by
switching. Running `--gui` without it prints the command to install one with it.
### Build prerequisites
Five things Cargo can't fetch for you:
| macOS | `xcode-select --install` then `brew install pkg-config` |
| Debian/Ubuntu | `sudo apt install build-essential pkg-config libxml2-dev libclang-dev perl` |
| Fedora/RHEL | `sudo dnf install pkgconf-pkg-config gcc make perl libxml2-devel clang-devel` |
| Arch | `sudo pacman -S pkgconf base-devel perl libxml2 clang` |
| Alpine | `sudo apk add build-base pkgconfig perl libxml2-dev clang-dev` |
| Windows (MSVC) | `vcpkg install libxml2:x64-windows-static-md` |
**libxml2 + `pkg-config`** because `hurl`/`hurl_core` depend unconditionally on
the `libxml` crate (Hurl's XPath asserts *are* libxml2's XPath engine), and that
crate is a binding to a system libxml2 rather than a vendored copy.
**libclang** because `bindgen` generates those bindings at build time. **A C
compiler, `perl` and `make`** because PaperBoy pulls `curl` in directly with
`static-curl`/`static-ssl`, so libcurl and OpenSSL are compiled from vendored
sources — which is why there is no `libcurl-dev` row above. The `gui` feature
adds no build-time requirement; its X11/Wayland libraries are `dlopen`ed at
runtime.
On macOS the Command Line Tools cover everything except `pkg-config`, which is
the failure most people hit. If your libxml2 came from Homebrew rather than the
SDK:
```sh
export PKG_CONFIG_PATH="$(brew --prefix libxml2)/lib/pkgconfig:$PKG_CONFIG_PATH"
```
PaperBoy's `build.rs` checks for all five before the build gets going and fails
with the package-manager command your machine actually wants (it detects
Homebrew/MacPorts, apt, dnf, yum, zypper, pacman, apk). It fails rather than
warns because Cargo runs build scripts concurrently and doesn't replay their
warnings — a warning lands dozens of `Compiling …` lines above the real error.
It never installs anything, and it can't prompt: a build script has no terminal.
Checks that could be wrong (cross-compilation, target-suffixed `PKG_CONFIG_*`,
the libclang heuristic) only warn, and `PAPERBOY_SKIP_DEP_CHECK=1` disables it
entirely.
If you have a libxml2 lying around and want to skip `pkg-config` and `bindgen`,
`libxml`'s build script takes an explicit path — `--config` reaches transitive
build scripts:
```sh
cargo install paperboy --locked \
--config 'env.LIBXML2="/opt/homebrew/opt/libxml2/lib/libxml2.dylib"'
```
Vendoring libxml2 instead isn't possible from here: the crate has no vendored
build, no `libxml2-src` exists, and it declares no `links` key, so there's no
`DEP_*` channel to reach into it with. It would have to be added upstream.
From a checkout:
```sh
cargo run # terminal UI
cargo run --features gui -- --gui # graphical UI
cargo test # add --features gui for the GUI's tests
```
## Concepts
- **Collection** — a `.hurl` file: an ordered list of requests with method,
URL, headers, cookies, body/form fields and optional `[Captures]`/`[Asserts]`.
Postman `.json` exports open directly and are converted on the way in.
- **Environment** — a `.vars` file of `KEY=value` lines supplying `{{ VAR }}`
values. See [Environments and secrets](#environments-and-secrets).
- **Workspace** — a folder of collections, reports and environments, browsed
through a single tab as a filesystem tree.
- **Report** — a `.trail` file: a PaperTrail script that runs requests from a
collection, loops over environments or data, and writes CSV/JSON/HTML/XLSX.
Editable as text or as [blocks](#the-papertrail-block-editor); runnable from
the UI or [headlessly](#reports).
- **Generated value** — a `# [Gen]` row: an expression evaluated just before a
request is sent, supplying the nonces, timestamps and signatures a pre-request
script used to. See [Generated values](#generated-values).
- **Scratch Space** — tab 0. A collection with no file behind it until you save
it.
- **Request names encode folders.** `Auth/Tokens/Refresh` browses as a folder
path; Postman's folder structure imports into this automatically.
## Terminal UI
Press `?` or `F1` for the full, current key list. The essentials:
| `Tab` / `Shift+Tab` | Move focus between panes |
| `↑`/`↓`, `j`/`k` | Move selection |
| `←`/`→`, `h`/`l` | Switch tabs / scroll list text horizontally |
| `Enter` | Edit the selected request (or descend into a folder row) |
| `Shift+R` | Edit as raw Hurl text — for anything the form doesn't expose |
| `F5`, `Ctrl+Enter` | Run the current request |
| `Alt+F5` | Run the whole collection in one Hurl execution |
| `n` / `b` | New request / set the base URL |
| `f` / `s` | File menu / Settings menu |
| `Ctrl+S` | Save the open report, else the active collection |
| `[` / `]`, `PageUp`/`PageDown` | Previous / next tab |
| `Ctrl+Shift+←`/`→` | Reorder the active tab |
| `x` / `u` | Delete / undo — requests, tabs and environments each keep their own undo stack |
| `/` | Find a request anywhere in the collection (whole tree on a Workspace tab) |
| `Alt+↑`/`↓` | Reorder requests — the order `Alt+F5` and the CLI follow |
| `m` / `c` | Move / copy a request to another collection in the workspace |
| `p` (Requests) | Link an environment to this collection |
| `a` (Response) | Build an assert or a capture from what came back |
| `a` (Env pane) | Make an environment active |
| `r` (Env pane) | Retry a failed secret lookup |
| `w` (Workspace tab) | Reopen the file-tree picker |
| `+`/`-`, `<`/`>` | Resize the response pane / left column |
| `q`, `Ctrl+C` | Quit |
In the request wizard:
| `[`/`]`, `PageUp`/`PageDown` | Switch section tab (`All│Headers│Cookies│Queries│Options│Form│Body│Asserts│Captures│Reports│Generated`). `[`/`]` only when focus isn't on a text field, so brackets stay typable |
| `Alt+1`–`9`, `Alt+0` | Jump straight to a section (`Alt` because most terminals can't report `Ctrl`+digit) |
| `Ctrl+↑`/`↓` | Previous / next section |
| `Ctrl+D` / `Ctrl+E` | Delete a row / toggle its enabled checkbox |
| `Ctrl+Z` / `Ctrl+Shift+Z` | Undo / redo within the focused text cell |
| `←` from a Key cell | Reach the enabled checkbox — it's the leftmost column |
| `Ctrl+F` or `Enter` on a File value | Open a file picker |
| `F2`, `Ctrl+Enter` | Save |
| `Esc` | Cancel (asks first if there are unsaved edits) |
Worth knowing:
- **`[Form]`/`[Multipart]`, `[Cookies]`, `[Captures]`, `[Asserts]` and
`[BasicAuth]`** are all editable as tables in the wizard; the expected status
is just an assert (`status == 200`). Saving picks the right Hurl section:
all-text fields become `[Form]`, any file field promotes it to `[Multipart]`.
File paths are colour-coded by whether they resolve and are readable
(relative to the collection's directory, matching where Hurl looks). A
`Base64 File` field is encoded at send time behind a configurable prefix, so
`data:image/png;base64,` yields a ready-made data URI.
- **Asserts and captures can be built from a response.** With a reply on
screen, `a` in the Response pane opens a two-step palette: pick a value the
server actually sent — status, duration, any header, any value in the JSON
body, listed beside what it currently is — then pick what to say about it.
The rows are the Hurl lines themselves (`jsonpath "$.data.token" == "ey…"`),
so what you choose is what gets written. Typing narrows the list, and
anything selected in the body pre-fills the filter. The last row on every
value is *keep it in a variable*, which adds a `[Captures]` row under a name
taken from the field itself — the fastest way to chain one request into the
next. Choosing the status sets the `HTTP <status>` line rather than adding a
competing assert.
- **The request preview substitutes `{{ VAR }}`** and colours each by status —
green loaded, cyan literal, orange loading, red missing — while the editor
keeps the original text. Secrets are masked as eight dots.
- **Sections start empty** and dropdowns only auto-open on an empty cell, so
arrowing through a populated table doesn't keep reopening them.
- **Settings ▸ Preferences** persists: confirm on exit/clear, confirm before
deleting an environment or a request, always-save-when-prompted, whether
`Alt+F5` runs the collection in batch mode (chaining cookies and captures),
whether Esc discards request edits without asking (off by default), and the
default Request view (JSON or Hurl).
- **Settings ▸ Theme** ships three presets — Britannia, Parisian Purple,
Dannebrog — one per UI language (English/French/Danish), and follows the
language until you pick one by hand. `Ctrl+N` clones a preset into an
editable custom theme; `Enter` on a colour opens an RGB picker that previews
live and auto-saves. `Ctrl+D` deletes a custom theme.
- **Saving.** **Save** overwrites the file the tab came from without
confirmation; **Save As…** always prompts, and confirms an overwrite. Every
File-menu item has a bracketed mnemonic that both selects and activates it.
## Graphical UI
`paperboy -g`, from a build with the `gui` feature. Feature-for-feature
equivalent to the terminal UI — same tabs, folder tree, request editor,
response viewer, environments panel, reports, theme editor, git remotes, and
the same three languages. What differs:
- **Panels and result columns are resized by dragging**; double-click a column
border to hand it back to the automatic fit. Hand-set widths persist as long
as the report keeps producing the same columns.
- **`Tab`/`Shift+Tab` cycle panels** in the terminal UI's order. The focused
request list is arrow-driven: `Home`/`End`, `Enter` to run, `F2` to rename,
`Delete` to delete, `Ctrl+Z` to undo. A Workspace tree adds `Left`/`Right` to
collapse and expand, and `PageUp`/`PageDown` for ten rows.
- **Single-letter shortcuts don't carry over** — in a desktop window those keys
are text. Globally: `F5`/`Ctrl+Enter` run, `Ctrl+S`/`Ctrl+Shift+S` save,
`Ctrl+W` closes, `Ctrl+Z` undoes a delete, `Alt+F` opens the File menu, `F1`
shows every shortcut.
- **Asserts and captures are built by right-clicking the response.** Click a
value in the body and choose *Assert this…* — the field under the caret is
worked out from the raw JSON, so it works on a minified body as well as a
pretty-printed one. Right-clicking a header row does the same for that
header, and the **Assert…** button beside Copy opens the same builder on the
whole list of values the reply carried. The list is filterable by name or by
value, and "keep it in a variable" adds the `[Captures]` row.
- **The File menu is grouped by verb** (New / Import / Open / Save). Open ▸
Collection and Load ▸ Environment take Postman exports too — they work out
what the file holds. Every dialog reopens where you left it.
- **Workspaces are editable in place**: New adds a collection, report or
environment; drag files and folders onto another folder to move them, or onto
the empty space to move them back to the root. Nothing escapes the workspace
root and nothing is silently overwritten.
- **Reports bind to their collection by a relative path** (`../apis/billing.hurl`
included), so a workspace survives being zipped up or handed over. The
`collection` dropdown offers the report's own workspace first and hides
outside collections behind a toggle.
- **The window remembers itself** — size, every splitter you dragged, the open
view, the selected report/request and the Workspace node.
### The PaperTrail block editor
Reports get a **Blocks** view alongside **Source** and **Results**: a
drag-and-drop editor where blocks are dragged from a palette, reordered, nested
inside `FOR` loops (which move as one, body included) and dropped on the trash
bar to delete. The drag outline and the drop marker are both drawn as the
block's own silhouette, at its real width and indent.
Editable on the blocks: the request a step runs, its alias, response format and
`SHOW(…)`/`HIDE(…)`/`STATISTICS(…)` lists; a `FOR` loop's binder, source, roles
and `PARALLEL(n)` concurrency; and the report's own settings — `collection`,
`output`, `environment`, `root`, `baseline`, `columns` — in a boxed panel at the
top of the flow. Those apply to the report rather than running as a step, so
they're deliberately not blocks. `output` names a *format* (`csv`, `json`,
`html`, `xlsx`), not a filename; only the CLI's `-o` takes a path. Everything has
hover help, and **Source** is highlighted with the terminal UI's colours,
underlining whatever the parser rejected.
### Desktop icon on Linux
Wayland has no per-window icon protocol, so shells match the window's app id
against an installed `.desktop` file. The first GUI launch writes
`$XDG_DATA_HOME/paperboy/paperboy_logo.png` and
`$XDG_DATA_HOME/applications/paperboy.desktop` (with `StartupWMClass=paperboy`
for X11) if they aren't already there, and never touches them again — so you
can customise them. The shell may need a rescan (log out, or restart it) to
notice. Delete both and relaunch to regenerate, which is also how you refresh
`Exec=` after moving the binary.
## Environments and secrets
A `.vars` file is one `KEY=value` per line. Values can be:
| Literal | `USERNAME=demo` | — |
| Process env var | `BASE_URL={{ env:DEMO_BASE_URL }}` | The process environment |
| 1Password | `API_TOKEN={{ op://Vault/Item/field }}` | The local `op` CLI |
| AWS SSM | `DB_PASSWORD={{ ssm:/path/to/param }}` | Local AWS auth |
Provider references resolve in the background at load time, and the resolved
values are never persisted — `state.json` keeps only the reference. Every
1Password reference across every open collection resolves in a single `op
inject` call, so you get one authorization prompt rather than one per
collection. `r` in the Environment panel retries a single failed entry. Editing
a value into something that looks like a reference triggers a load attempt, and
a "still secret?" checkbox decides whether the new value stays masked.
**Loading a `.vars` file substitutes nothing on its own.** It only joins the
Global Environments list. It has to be either:
- **active** — `a` in the Global Environments panel (GUI: the **Active**
button). One at a time, shared by every tab; or
- **linked** — `p` in the Requests list pins one to the active collection (GUI:
**Linked**).
Both at once merge, with the linked value winning. A collection still showing
raw `{{ VAR }}`, or a red "variables in this request are undefined" band, nearly
always means this step was missed.
A variable that is *defined but empty* is not undefined and warns about nothing
— it substitutes as an empty string. With Basic Auth that produces a
well-formed request that comes back `401`.
## Generated values
Some values can't be written down: a nonce, a timestamp, an HMAC over the two.
Postman uses a pre-request script; PaperBoy uses a `# [Gen]` block of named
expressions, evaluated immediately before the request is sent.
```hurl
# [Gen] 3
# nonce = random_hex(16)
# ts = timestamp
# sig = hmac_sha256_b64(API_SECRET, concat(nonce, ts))
POST https://api.example.com/orders
X-Nonce: {{nonce}}
X-Timestamp: {{ts}}
Authorization: HMAC {{sig}}
```
The expressions stay in comments and the request refers to results as ordinary
`{{name}}` placeholders, so the file remains a plain `.hurl` file: stock `hurl`
parses it byte for byte and runs it given `--variable nonce=… --variable sig=…`.
Nothing else could work — Hurl reads a placeholder only as far as the first
character outside `A-Za-z0-9_-` and discards the rest silently, so
`{{ hmac_sha256(K, M) }}` would be sent as the value of `hmac_sha256`. PaperBoy
now refuses to save such a placeholder rather than let it truncate.
The block may sit above the request line, as here, or immediately below it;
both are read. PaperBoy writes it below when it saves, so a hand-written file
in the other order moves its block down the first time it is saved and is
otherwise unchanged.
A bare identifier is a variable reference — an environment variable, a request
parameter, or an earlier row in the same block. Calls nest. Rows are evaluated
in order and a row may only refer to one above it. Values are computed per run,
never previewed, and never written to `state.json`; a secret read through
`{{ op://… }}` is no more exposed by signing with it than by sending it.
What a block computes stays available to the rest of the session, exactly as a
`[Captures]` value does: sign a request, and the request after it can echo the
same `{{nonce}}` — including when you run it on its own. (Memory only, for the
reason above: a fresh PaperBoy computes fresh values.) A `[Captures]` row of
the same name is the later, more specific statement and wins.
One request per name, though. "Run All" and `paperboy -c` normally run one
request at a time, so each block is evaluated in its own window and two
requests may each have their own `nonce`. A **batch** run (the `--batch` flag,
or the Run All batch preference) is a single Hurl call over the whole file with
one variable set, so there the two share the first request's value — a
signature computed over another request's nonce. Both front-ends say so before
starting such a run, and `--batch` prints the warning too; the fix is usually
to not use batch.
The same applies to a name the environment already defines. Running one request
at a time, a block's value overrides the environment's from that request
onwards; a batch has one variable set for the whole file, so it cannot override
from partway through without changing what the *earlier* requests send. Batch
therefore leaves the environment's value in place and says which names it
did that to.
`counter` counts within the process, not within a run: it starts at 1 the first
time it is evaluated and keeps going for as long as PaperBoy is open, so sending
the same request three times gives 1, 2, 3. It is a sequence, not a setting, and
is not saved — a restarted PaperBoy counts from 1 again.
Edit the block in the request wizard's **Generated** section (`Alt+0`), in the
GUI editor's **Generated** tab, or as text. Both editors offer the functions as
you type — with their arguments named — and the GUI's **Function…** menu lists
them all; either way the call is written at the caret, over any part-typed
name, with the caret left between the brackets. Both say what is wrong with a
row while
it is still a typo rather than leaving it to be a 401: an unknown function, the
wrong number of arguments, an expression that doesn't parse. Placeholders that a
generator will fill render in the theme's *generated* colour and keep their
braces, because the value doesn't exist yet.
| Time | `timestamp`, `timestamp_ms`, `iso8601`, `date(fmt)` (strftime, UTC) |
| Random | `uuid`, `counter`, `random_int(lo, hi)`, `random_hex(n)`, `random_alnum(n)`, `random_base64(n)` |
| Encoding | `base64`, `base64url`, `base64_decode`, `hex`, `urlencode`, `urldecode`, `json_string` |
| Hashes | `md5`, `sha1`, `sha256`, `sha512` |
| MACs | `hmac_sha1(key, msg)`, `hmac_sha256`, `hmac_sha512` |
| Text | `concat(…)`, `upper`, `lower`, `trim` |
Every hash and MAC returns lowercase hex — matching `sha256sum` and CryptoJS's
`.toString()`, so a ported Postman script lands right — and each has a `_b64`
variant returning standard padded Base64. The encoding is in the name rather
than a default because a signature in the wrong one is the right length,
entirely plausible to look at, and rejected with the same `401` as a wrong
secret. Note that `base64(sha256(m))` is *not* `sha256_b64(m)`: the first
encodes 64 hex characters, the second the 32 bytes they spell.
**Canonicalisation is yours.** PaperBoy signs exactly the bytes you assemble; it
will not build a canonical request from the live headers, so AWS SigV4 and
friends are out of scope. Chaining a MAC into the *key* of the next one isn't
expressible either, since every value here is text.
The block works headlessly too. `paperboy -c …` evaluates each request's rows
in its own window, so a generator can read a value an earlier request captured
and two requests each get their own nonce. `--batch` is a single Hurl call over
the whole file and has no such window: there every block is evaluated once
before the run, a name computed by two requests takes the first one's value for
both, and the run says so before it starts.
A row that fails — unknown function, wrong arity, a name nothing defines —
reports rather than blocks the send. It binds nothing, so `{{sig}}` goes out
literally and comes back a loud `401`, which is easier to diagnose than a
refusal.
Importing from Postman maps the dynamic variables that have an exact equivalent:
`$guid`/`$randomUUID` and `$isoTimestamp` become Hurl's own `{{newUuid}}` and
`{{newDate}}`, while `$timestamp`, `$randomInt` and `$randomAlphaNumeric` become
`[Gen]` rows. The rest
are renamed and listed in `CONVERSION-NOTES.md` as values you must supply —
guessing at `$randomFirstName` would send a plausible wrong value, which is
harder to notice than a request that won't run.
Worked examples — collections to import, and the `.hurl` file they should become
— are in [`examples/postman/`](examples/postman/).
## Git remotes
Load and save collections, environments and whole workspaces straight from a
remote, with **no local clone**: PaperBoy lists refs, fetches just enough
history to read the file tree, and checks out only the files you actually
asked for. Nothing else in the repo touches your disk, however large it is.
**Loading** (File ▸ Load ▸ *kind* ▸ From Git…): give the URL — `https://…` or
`git@…`, with an optional access token used only for that fetch (GitHub-style
`https://x-access-token:<token>@host/…` is handled for you) — then pick a ref
and a file, both filterable as you type. `↓` on the URL field offers your
recent URLs. Loading a collection then offers to pair an environment from the
same listing, with no second round-trip. Anything loaded from git shows a ⎇ in
its tab title and remembers its origin.
**Workspaces** ask which files to fetch first — `.hurl` and `.json` (default),
`.hurl` only, `.json` only, or everything — and then whether to keep the
download temporarily or copy it somewhere permanent immediately.
> **A temporary workspace is never cleaned up.** Its files live in a temp
> folder for as long as the tab exists — including across a close and undo, and
> across restarts. They accumulate. Choose "save to a permanent location" when
> asked, or later via File ▸ Save ▸ Workspace ▸ Save As…, which copies the
> folder and stops tracking it as temporary.
**Saving** (File ▸ Save ▸ Collection ▸ To Git…) pushes a commit directly to the
remote. The URL is prefilled from where the collection came from, so you can
redirect it to a fork. You choose the in-repo path, whether the attached
environment goes in the same commit, and a branch or tag:
- A **branch** defaults to the one you loaded from, so `Enter` just appends a
commit. `↓` lists the remote's branches. No merge or rebase is attempted — a
non-fast-forward is reported as an error.
- A **tag** must be new. The remote is re-fetched immediately before the check,
and an existing tag is always rejected with no way to force it.
The message defaults to `Update <name> via PaperBoy` and is editable. The author
is your git identity, or `PaperBoy <paperboy@localhost>` if you have none. A
branch push updates the remembered origin and clears the modified markers; a tag
push clears the markers but leaves the origin on your working branch.
To Git… only works for something loaded from git. For anything else, Save As…
into your own clone and use git normally.
## Importing from Postman
Already have an export? Just open it — **Open ▸ Collection** and **Load ▸
Environment** both work out what the file holds, and File ▸ Import from Postman
▸ *From an exported file* says so explicitly. No API key, no account.
To pull from an account, File ▸ Import ▸ Postman account… (terminal) or File ▸
Import from Postman ▸ From my Postman account… (GUI). Give it an API key, pick a
workspace, choose what to bring and where, and the result opens as a workspace.
Paste a workspace id — or its Postman address — on the first step to skip the
listing entirely.
Migrating off Postman altogether: `Ctrl+A` on the workspace list, or **Import
all** in the GUI. Everything the list is *showing* is imported (so the filter is
honoured), each workspace into its own folder, so two "Billing API" collections
from different workspaces both survive.
Postman rate-limits its API, so the wizard shows what it found and roughly how
long the download will take before fetching anything, then reports the remaining
time from the rate it is actually achieving and says when it is pausing to stay
inside the limit. A Postman API key carries its owner's full access and can't be
scoped, so a missing workspace is one your account isn't a member of.
The same import runs headlessly:
```sh
export POSTMAN_API_KEY='PMAK-…'
paperboy --postman-import # list workspaces
paperboy --postman-import --postman-workspace 12ece9e1-… -o ~/API
paperboy --postman-import --postman-all -o ~/Postman # every workspace
```
| `--postman-key` | The key, instead of `$POSTMAN_API_KEY`. Takes the same `{{ … }}` provider references as a `.vars` file — `'{{ op://Private/Postman/credential }}'` keeps it out of your shell history. Never written to disk, stripped from error messages. |
| `--postman-all` | Every visible workspace, each into its own folder under `-o`. Empty workspaces are skipped and inaccessible ones reported rather than fatal, so forty workspaces aren't stopped by one. Excludes `--postman-workspace`. |
| `--postman-what` | `collections`, `environments` or `all`. |
| `--postman-format` | `postman` (default) keeps the JSON byte for byte; `hurl` converts. |
| `--overwrite` | Replace a non-empty destination, which is otherwise refused. |
| `--postman-base-url` | Another tenant, e.g. `https://api.eu.postman.com` for EU Enterprise. |
The result is a folder of `Collections/` and `Environments/`; open it with Open
▸ Workspace.
### Converting to Hurl
`--postman-format hurl` brings across requests, folders (as `Folder/Name`
titles), headers, query parameters, raw bodies and form/multipart fields, plus:
- **Auth, including inheritance.** Collection- and folder-level auth is applied
to requests that don't set their own, and `noauth` opts back out. `basic`,
`bearer` and `apikey` (header or query) are mapped.
- **Collection variables**, which have nowhere to live in a `.hurl` file, as
`<name> (collection variables).vars` beside the environments.
- `pm.<store>.set("NAME", body.a.b)` calls in test scripts, as `[Captures]`.
- **Dynamic variables.** `{{$guid}}` and `{{$isoTimestamp}}` become Hurl's own
`{{newUuid}}`/`{{newDate}}`; `{{$timestamp}}`, `{{$randomInt}}` and
`{{$randomAlphaNumeric}}` become [generated values](#generated-values). The rest
are listed as values to supply.
- **Pre-request scripts**, as far as they reduce to values PaperBoy can compute:
`pm.environment.set("id", uuid.v4())`, `Date.now()`,
`Math.floor(Date.now() / 1000)`, `new Date().toISOString()`,
`pm.variables.replaceIn("{{$guid}}")` and literals become
[generated values](#generated-values).
- **Test scripts**, as the status and assertions they always make:
`pm.response.to.have.status(400)` becomes the request's expected status, and
`pm.expect(...)` checks on the body, headers and response time become
`[Asserts]`. A deep equality against a literal document
(`.to.eql({ id: 7, name: "Ada" })`) is written out one leaf at a time, since
Hurl has no predicate that takes a document — which also makes a failure name
the field that differed. Only checks that run *unconditionally* are taken —
anything inside an `if`, a loop or a helper function is left for you, since an
assertion that was meant for one branch fails every run.
- **`setNextRequest`**, as a note saying which of the four things it was doing:
polling (which Hurl writes as `[Options] retry`), an order you can write down
in the file or as `REQUEST` lines in a
[PaperTrail flow](#the-papertrail-block-editor), a run that stopped early, or
a request name built as the script ran. PaperBoy runs a
collection in file order, so none of them convert — but they are four
different problems with four different fixes.
- **Scripts on a folder or on the collection**, which Postman runs for every
request inside; they are converted for each request they cover, and reported
once against the folder that holds them.
Hurl doesn't cover everything Postman does. Anything dropped — the rest of a
script, OAuth 2, GraphQL bodies — is listed per request in
`CONVERSION-NOTES.md` at the root of the import; no file means nothing was lost.
A collection this build can't read is written out as its original JSON, so
converting can't cost you data.
## Headless runner
```sh
paperboy -c collection.hurl
paperboy -c collection.hurl -e environment.vars
paperboy -c collection.hurl --batch
```
`-c` takes a `.hurl` file or a Postman export. `-e` supplies the environment.
Exit status is `0` only if every request passed.
By default each request's method, URL, status, asserts, captures and truncated
body print as it finishes, coloured unless the output isn't a terminal or
`NO_COLOR` is set. Streaming runs one request at a time through the same `hurl`
runner, so captures still chain — but it can't carry Hurl's automatic cookie jar
between requests, and says so at startup. An explicit `[Cookies]` section is
unaffected. `-b`/`--batch` runs the collection as a single Hurl call, trading
incremental output for cookie continuity.
### Reports
`-r report.trail` runs a PaperTrail report and exits.
```sh
paperboy -r report.trail # collection from the report's headers
paperboy -c api.hurl -r report.trail -o out.csv # or given explicitly; - is stdout
paperboy -c api.hurl -e prod.vars -e staging.vars -r report.trail
paperboy -c api.hurl -r report.trail --dry-run # expand it, send nothing
```
Without `-c`/`-e` the report's own `# collection:` / `# environment:` headers
apply, resolved relative to the report. `-e` is repeatable: each file is named
by its stem and becomes selectable in an `ENVS` loop, so `-e prod.vars -e
staging.vars` satisfies `FOR … IN ENVS BASELINE("prod"), COMPARISON("staging")`;
the first is also the base variable layer. `-o`'s extension picks the format
(`.csv`, `.json`, `.html`, `.xlsx`), `-` writes CSV to stdout, and omitting it
derives the filename from the report's own headers.