Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 85 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ commit to git.
Built in Rust on [GPUI](https://www.gpui.rs/) (the framework behind the Zed editor) and the
[gpui-component](https://github.com/longbridge/gpui-component) widget library.

![Posel — sending REST and GraphQL requests, switching environments, and inspecting response timing](docs/assets/posel-demo.gif)

<sub>Opening a saved request, sending it with <kbd>Ctrl</kbd>+<kbd>Enter</kbd>, resolving
`{{variables}}` against an environment, inspecting headers and the timing waterfall, running a
GraphQL query, and the command palette. ([higher-quality MP4](docs/assets/posel-demo.mp4))</sub>

## Why another API client?

Existing clients are mostly either cloud-first and heavy, or built on a web runtime. Posel's bet
Expand All @@ -17,7 +23,8 @@ is the opposite corner of the design space:
- **Multi-protocol depth** — REST + GraphQL today, behind a clean *Protocol vs Transport*
boundary so gRPC and WebSocket/SSE slot in additively rather than as bolt-ons.
- **Git-native & local-first** — collections are human-readable `.toml`; collaboration is just
git. Secrets live in your OS keychain, never in the repo.
git. Secrets stay out of the repo — in your OS keychain by default, or another backend you
pick ([Secrets](#secrets)).
- **Keyboard-driven / Zed-like** — command palette, quick-open, env switcher, multi-tab; a
keyboard-first flow with minimal chrome.

Expand All @@ -26,7 +33,7 @@ is the opposite corner of the design space:
Working, in active development. Runs on **macOS, Linux, and Windows**.

- **REST** — request editor (method, query/headers/body), streaming response panel,
environments + variables, secrets in the OS keychain, request history.
environments + variables, pluggable secret storage, request history.
- **GraphQL** — schema introspection from the endpoint, schema-aware autocomplete, query
formatting, inline validation, and a dedicated data/errors viewer.
- **Workspace** — git-friendly `.toml` collections, live reflection of external file edits,
Expand Down Expand Up @@ -61,7 +68,7 @@ The domain logic is headless and unit-tested; GPUI is confined to the edge (`ui`
| `template` | Variable / secret / dynamic-value resolution |
| `engine` | tokio runtime, the `Transport` trait + HTTP transport, REST & GraphQL resolution |
| `schema` | GraphQL brains: introspection → queryable schema, completion, formatting, validation |
| `storage` | git-friendly `.toml` format, OS-keychain secrets, history, file-watching |
| `storage` | git-friendly `.toml` format, pluggable secret backends, history, file-watching |
| `ui` | GPUI widgets: editors, viewers, sidebar, palette (protocol-aware via enum dispatch) |
| `app` | `posel` desktop binary |
| `cli` | `posel-cli` headless engine driver |
Expand All @@ -74,8 +81,18 @@ stays protocol-agnostic. See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md).

Requires a current stable Rust toolchain (GPUI uses recently-stabilized std features; run
`rustup update stable` if a build fails with `E0658`). Builds on macOS, Linux, and Windows;
secrets use the platform keychain (macOS Keychain, Windows Credential Manager, Linux
Secret Service).
secrets default to the platform keychain (macOS Keychain, Windows Credential Manager, Linux
Secret Service) and can be pointed elsewhere — see [Secrets](#secrets).

On **Linux**, GPUI links against system libraries that need their development packages present,
or the build fails in `yeslogic-fontconfig-sys` with *"The pkg-config command could not be
found"*:

```bash
# Debian / Ubuntu
sudo apt install -y pkg-config libfontconfig-dev libfreetype-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libxcb1-dev libx11-dev
```

```bash
cargo run -p posel-app # the desktop app (binary: posel)
Expand All @@ -87,6 +104,69 @@ cargo clippy --workspace -- --deny warnings
A workspace can be opened by passing a path as the first argument to the app, or via the
`POSEL_WORKSPACE` environment variable.

## Secrets

A `{{secret:name}}` reference is resolved at send time; the workspace `.toml` only ever holds the
*reference*, never the value, so collections stay safe to commit. Where the value comes from is
your choice:

| Backend | Writable from Posel | Where values live |
|---------|---------------------|-------------------|
| `keychain` *(default)* | yes | macOS Keychain, Windows Credential Manager, Linux Secret Service |
| `file` | yes | a `0600` TOML file in your user data directory |
| `env` | no | the process environment (`{{secret:github_token}}` → `$POSEL_SECRET_GITHUB_TOKEN`) |
| `command` | no | stdout of a helper command you configure (`pass`, `op`, `gh`, …) |
| `none` | — | nowhere; every `{{secret:…}}` fails |

The keychain is the default and the recommended choice — but not every machine has one. A headless
Linux box, a container, or a CI runner has no Secret Service, which is where
`org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.secrets was not provided by
any .service files` comes from. Pick one of the other backends there.

Configure it under `[secrets]` in `settings.toml` in your user data directory
(`~/.local/share/posel` on Linux, `~/Library/Application Support/posel` on macOS,
`%LOCALAPPDATA%\posel\data` on Windows):

```toml
[secrets]
backend = "file" # keychain | file | env | command | none

env_prefix = "POSEL_SECRET_" # env backend: name → $POSEL_SECRET_<NAME>
command = ["pass", "show", "posel/{name}"] # command backend: stdout is the value
```

`command` runs the given argv once per lookup and takes its stdout (trailing newline trimmed) as
the value; `{name}` is substituted with the secret name in every argument — e.g.
`["op", "read", "op://vault/{name}/token"]` or `["gh", "auth", "token"]`. A non-zero exit surfaces
the helper's stderr.

**About the `file` backend.** It stores values in plaintext at rest — the same posture as
`~/.aws/credentials`, `~/.docker/config.json`, or `gh`'s `hosts.yml`. On Unix the file is *created*
`0600` (owner-only) before any value is written to it, never chmod'ed afterwards, so the plaintext
is not readable by anyone else even momentarily; on Windows it inherits the user-scoped ACL of
`%LOCALAPPDATA%`. Updates go through a temp file and an atomic rename, entries are namespaced per
workspace, and it lives in your user data directory — **never inside a workspace**, so it can't be
committed by accident. Use the keychain where you have one; use this where you don't.

Either way can be overridden for a single run with `POSEL_SECRET_BACKEND`, which wins over
`settings.toml` (an unrecognized value is ignored and the configured backend stands):

```bash
POSEL_SECRET_BACKEND=env cargo run -p posel-app
```

`posel-cli` takes the same choice as a flag on `send`, `login`, `test`, and `codegen`:

```bash
posel-cli send ./ws users/list.toml --secret-backend env # this run only
posel-cli send ./ws users/list.toml --secret token=abc # inline; no store is read
posel-cli send ./ws users/list.toml --keychain # pin the OS keychain
```

Given none of those, the CLI resolves through whatever `settings.toml` configures. If several are
given, `--keychain` wins, then `--secret`, then `--secret-backend`. `--secret-backend` swaps only
the backend — the configured `env_prefix` and `command` still apply.

## A note on the GPUI dependency

GPUI ships only as a git dependency (no crates.io release), and Posel carries three small,
Expand Down
88 changes: 86 additions & 2 deletions crates/cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::path::PathBuf;

use anyhow::{bail, Context as _, Result};
use posel_core::{Body, Method, Request};
use posel_storage::SecretBackend;

const USAGE: &str = "\
posel-cli — headless REST driver
Expand Down Expand Up @@ -36,12 +37,17 @@ SEND (run a saved request, resolving {{vars}}/{{secret:..}}):
<WORKSPACE> Path to the workspace directory
<REQUEST> Request file, relative to the workspace root
--env <NAME> Environment to resolve against
--secret <K=V> Provide a secret inline (repeatable; avoids the OS keychain)
--keychain Resolve {{secret:..}} from the OS keychain instead
--secret <K=V> Provide a secret inline (repeatable; no secret store is read)
--keychain Force the OS keychain, whatever backend is configured
--secret-backend <B> Use this backend for this run instead of the configured one:
keychain | file | env | command | none
--allow-host <HOST> One-off egress allowance past the env policy (repeatable;
a strict env ignores it)
-o, --output <FILE> Write the response body byte-exact to a file

Given none of the above, {{secret:..}} resolves through the backend configured in
settings.toml ([secrets], default: the OS keychain).

EXAMPLES:
posel-cli https://httpbin.org/stream/5
posel-cli -X POST --json '{\"hi\":1}' https://httpbin.org/post
Expand Down Expand Up @@ -103,6 +109,9 @@ pub struct SendArgs {
pub env: Option<String>,
pub secrets: Vec<(String, String)>,
pub keychain: bool,
/// `--secret-backend`: override the configured backend for this run. `None` = use the
/// machine's setting.
pub secret_backend: Option<SecretBackend>,
/// Write the response body byte-exact to this file instead of stdout (`-o`/`--output`).
pub output: Option<PathBuf>,
/// One-off egress allowances (`--allow-host`, repeatable). Ignored when the env is strict.
Expand All @@ -126,6 +135,7 @@ pub struct CodegenArgs {
pub env: Option<String>,
pub secrets: Vec<(String, String)>,
pub keychain: bool,
pub secret_backend: Option<SecretBackend>,
pub target: String,
}

Expand Down Expand Up @@ -236,6 +246,7 @@ fn parse_send<I: Iterator<Item = String>>(mut args: I) -> Result<SendArgs> {
let mut env = None;
let mut secrets = Vec::new();
let mut keychain = false;
let mut secret_backend = None;
let mut output = None;
let mut allow_hosts = Vec::new();

Expand All @@ -244,6 +255,12 @@ fn parse_send<I: Iterator<Item = String>>(mut args: I) -> Result<SendArgs> {
"--env" => env = Some(next_value(&mut args, "--env")?),
"--secret" => secrets.push(parse_pair(&next_value(&mut args, "--secret")?, '=')?),
"--keychain" => keychain = true,
"--secret-backend" => {
secret_backend = Some(parse_secret_backend(&next_value(
&mut args,
"--secret-backend",
)?)?)
}
"--allow-host" => allow_hosts.push(next_value(&mut args, "--allow-host")?),
"-o" | "--output" => output = Some(PathBuf::from(next_value(&mut args, &arg)?)),
other if other.starts_with('-') && other.len() > 1 => bail!("unknown flag: {other}"),
Expand All @@ -261,6 +278,7 @@ fn parse_send<I: Iterator<Item = String>>(mut args: I) -> Result<SendArgs> {
env,
secrets,
keychain,
secret_backend,
output,
allow_hosts,
})
Expand Down Expand Up @@ -297,13 +315,20 @@ fn parse_codegen<I: Iterator<Item = String>>(mut args: I) -> Result<CodegenArgs>
let mut env = None;
let mut secrets = Vec::new();
let mut keychain = false;
let mut secret_backend = None;
let mut target = "curl".to_string();

while let Some(arg) = args.next() {
match arg.as_str() {
"--env" => env = Some(next_value(&mut args, "--env")?),
"--secret" => secrets.push(parse_pair(&next_value(&mut args, "--secret")?, '=')?),
"--keychain" => keychain = true,
"--secret-backend" => {
secret_backend = Some(parse_secret_backend(&next_value(
&mut args,
"--secret-backend",
)?)?)
}
"--target" => target = next_value(&mut args, "--target")?,
other if other.starts_with("--") => bail!("unknown flag: {other}"),
_ => positional.push(arg),
Expand All @@ -320,6 +345,7 @@ fn parse_codegen<I: Iterator<Item = String>>(mut args: I) -> Result<CodegenArgs>
env,
secrets,
keychain,
secret_backend,
target,
})
}
Expand Down Expand Up @@ -371,6 +397,19 @@ fn next_value<I: Iterator<Item = String>>(args: &mut I, flag: &str) -> Result<St
args.next().with_context(|| format!("{flag} needs a value"))
}

/// Parse a `--secret-backend` value. Only the spellings printed in `--help` are accepted: a typo
/// must be a hard error, never a silent fall-through to a backend the user didn't ask for.
fn parse_secret_backend(raw: &str) -> Result<SecretBackend> {
match raw.trim().to_ascii_lowercase().as_str() {
"keychain" => Ok(SecretBackend::Keychain),
"file" => Ok(SecretBackend::File),
"env" => Ok(SecretBackend::Env),
"command" => Ok(SecretBackend::Command),
"none" => Ok(SecretBackend::None),
_ => bail!("unknown secret backend: {raw} (expected keychain|file|env|command|none)"),
}
}

/// Split `"name<sep>value"` into a trimmed key/value pair.
fn parse_pair(raw: &str, sep: char) -> Result<(String, String)> {
let (k, v) = raw
Expand Down Expand Up @@ -461,6 +500,51 @@ mod tests {
assert!(!s.keychain);
}

#[test]
fn secret_backend_is_parsed_for_send_and_codegen() {
let Command::Send(s) = parse_cmd(&["send", "./ws", "r.toml", "--secret-backend", "File"])
else {
panic!("expected send");
};
assert_eq!(s.secret_backend, Some(SecretBackend::File));

let Command::Codegen(c) =
parse_cmd(&["codegen", "./ws", "r.toml", "--secret-backend", "env"])
else {
panic!("expected codegen");
};
assert_eq!(c.secret_backend, Some(SecretBackend::Env));

// Absent flag means "whatever the machine is configured for" — the CLI must not guess.
let Command::Send(s) = parse_cmd(&["send", "./ws", "r.toml"]) else {
panic!("expected send");
};
assert_eq!(s.secret_backend, None);
}

#[test]
fn unknown_or_missing_secret_backend_is_an_error() {
let err = parse(
["send", "./ws", "r.toml", "--secret-backend", "vault"]
.iter()
.map(|s| s.to_string()),
)
.err()
.map(|e| e.to_string())
.unwrap_or_default();
assert!(
err.contains("vault"),
"expected a clean error, got: {err:?}"
);

assert!(parse(
["send", "./ws", "r.toml", "--secret-backend"]
.iter()
.map(|s| s.to_string())
)
.is_err());
}

#[test]
fn send_requires_two_positionals() {
assert!(parse(["send".to_string(), "./ws".into()]).is_err());
Expand Down
27 changes: 20 additions & 7 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ use posel_engine::{
};
use posel_import::{detect_importer, ImportedItem};
use posel_storage::{
history_path, AppSettings, History, HistoryEntry, KeyringSecrets, LayeredTokenStore, Workspace,
history_path, AppSettings, History, HistoryEntry, KeyringSecrets, LayeredTokenStore,
SecretBackend, SecretsConfig, Workspace, WorkspaceSecrets,
};

fn main() -> Result<()> {
Expand Down Expand Up @@ -224,7 +225,7 @@ fn run_login(args: SendArgs) -> Result<()> {
.clone(),
None => Environment::new(""),
};
let secrets = secret_resolver(&workspace, args.keychain, args.secrets);
let secrets = secret_resolver(&workspace, args.keychain, args.secret_backend, args.secrets);

let ctx = with_app_defaults(workspace.effective_context(node_id, &env));
let cfg = oauth::resolve_config(&ctx.auth, &ctx.env, secrets.as_ref(), &ctx.auth_base_url)?
Expand Down Expand Up @@ -305,18 +306,30 @@ fn token_store(workspace: &Workspace) -> LayeredTokenStore {
store
}

/// The secret backend selected by the send-style flags.
/// The secret store selected by the send-style flags, most specific first: `--keychain` pins the OS
/// keychain, `--secret K=V` supplies values inline (no store is read), `--secret-backend` swaps the
/// backend for this run, and otherwise the machine's configured backend applies. That last case is
/// the point: a box set up for `file`/`env` must resolve secrets from the CLI without extra flags.
fn secret_resolver(
workspace: &Workspace,
keychain: bool,
backend: Option<SecretBackend>,
secrets: Vec<(String, String)>,
) -> Box<dyn SecretResolver> {
if keychain {
Box::new(KeyringSecrets::for_workspace(&workspace.name))
} else if !secrets.is_empty() {
Box::new(MapSecrets(secrets.into_iter().collect()))
} else if let Some(backend) = backend {
// Only the backend is overridden — the configured env prefix / helper argv still apply,
// so `--secret-backend command` uses the command the user already set up.
let config = SecretsConfig {
backend,
..AppSettings::load().secrets
};
Box::new(WorkspaceSecrets::new(&workspace.name, &config))
} else {
Box::new(NoSecrets)
Box::new(WorkspaceSecrets::for_workspace(&workspace.name))
}
}

Expand All @@ -336,7 +349,7 @@ fn run_send(send: SendArgs) -> Result<()> {
None => Environment::new(""),
};

let secrets = secret_resolver(&workspace, send.keychain, send.secrets);
let secrets = secret_resolver(&workspace, send.keychain, send.secret_backend, send.secrets);

let ctx = with_app_defaults(workspace.effective_context(node_id, &env));
let engine = EngineHandle::new()?;
Expand Down Expand Up @@ -380,7 +393,7 @@ fn run_codegen(args: CodegenArgs) -> Result<()> {
.clone(),
None => Environment::new(""),
};
let secrets = secret_resolver(&workspace, args.keychain, args.secrets);
let secrets = secret_resolver(&workspace, args.keychain, args.secret_backend, args.secrets);

let ctx = with_app_defaults(workspace.effective_context(node_id, &env));
// Codegen never fetches (it must stay sync/offline); an OAuth2 request emits without a bearer.
Expand Down Expand Up @@ -408,7 +421,7 @@ fn run_test(args: TestArgs) -> Result<()> {
.clone(),
None => Environment::new(""),
};
let secrets = secret_resolver(&workspace, args.keychain, args.secrets);
let secrets = secret_resolver(&workspace, args.keychain, args.secret_backend, args.secrets);

let ctx = with_app_defaults(workspace.effective_context(node_id, &env));
let engine = EngineHandle::new()?;
Expand Down
Loading
Loading