# CLI

> s3nd from a terminal: move a file between machines, check that a bucket is set up to hold transfers, and keep the settings in a file instead of in your shell history.

Canonical: https://doc.s3nd.sh/docs/cli · Markdown: https://doc.s3nd.sh/docs/cli.md

`@s3nd/cli` is its own package, built on `s3nd` as a primitive — so nothing a command
line needs ships to your server.

```sh
npm install -g @s3nd/cli
s3nd setup
s3nd put ./report.pdf
```

`setup` asks where transfers are stored, the bucket and the keys, saves them for this machine and
checks the bucket works. Every command then works from any directory. Without installing anything,
`npx @s3nd/cli setup` does the same.

It has no dependencies of its own beyond `s3nd`: `node:util`'s `parseArgs` is the whole
argument parser.

## `setup` and `init`

`setup` asks where transfers are stored (a list you move through with the arrow keys), the bucket
and the keys, one question at a time, with where to find each value. It writes `~/.config/s3nd/config.json`, readable by you only and used
from any directory, then checks the bucket live. The provider list, while it is open:

```
? Where should transfers be stored?
  AWS S3
❯ Cloudflare R2                  no egress fees
  Scaleway
  Wasabi
  MinIO                          on this machine or self-hosted
  Another S3-compatible service  Backblaze B2, DigitalOcean Spaces, Hetzner…
  A s3nd server                  someone else runs it; you only need its URL
  ↑↓ to move · Enter to pick
```

Once picked it folds into one line, and the whole run reads:

```
$ s3nd setup
Set up s3nd on this machine
Saved to ~/.config/s3nd/config.json, used from any directory. Ctrl+C leaves without writing.

? Where should transfers be stored? Cloudflare R2
  Dashboard → R2 → Overview, "Account ID" in the side panel. The S3 API URL works too.
? Cloudflare account ID 8c4f…
  An existing bucket. The check at the end tells you if it is not reachable.
? Bucket name transfers
  R2 → Manage API tokens → Create API token, with Object Read & Write on this bucket.
? Access key ID 5d1e…1a2b
? Secret access key (hidden) ••••••••••••••••

✓ Saved ~/.config/s3nd/config.json (readable by you only)
  Cloudflare R2 · bucket "transfers" · credentials: key …1a2b

Checking it works…
✓ Configuration: bucket "transfers", region "auto", endpoint https://8c4f….r2.cloudflarestorage.com
✓ Credentials: resolved, key ends in 1a2b
✓ Bucket reachable: HeadBucket succeeded
✓ Write, read, delete: round-tripped a probe object
! Expiry cleanup: no lifecycle configuration
  → Add an S3 lifecycle rule that expires objects under "transfers/" after a day or two.

Ready. Send something:
  s3nd put ./a-file
```

`init` asks the same questions for a project instead: it writes `./s3nd.config.json`, meant to be
committed, with `${…}` references to the keys, and the keys themselves in a `.env` beside it. It
warns when no `.gitignore` there lists `.env`.

A flag answers its question in advance: `--provider` (`aws`, `r2`, `scaleway`, `wasabi`, `minio`,
`other` or `remote`), `--bucket`, `--region`, `--endpoint`, `--remote`. An existing file is only
replaced once you say so, or with `--force`. Ctrl+C leaves without writing anything.

Nobody at the keyboard (a pipe, CI, or `--yes`), nothing is asked: the flags write a starter with
`${…}` references, and a missing `--bucket` is an error rather than a placeholder.

```sh
s3nd init --yes --provider r2 --bucket transfers
```

You rarely need to type `setup` at all. Any command that needs a bucket, run on a machine with
none configured, offers to set one up on the spot and then carries on with what you asked for.

[Without a server](https://doc.s3nd.sh/docs/no-server) follows the R2 one end to end.

## `doctor`

The check `setup` ends with, on demand. S3 misconfiguration fails late and vaguely — a policy that looks
right, credentials that resolve to nothing, a region the endpoint disagrees with. `doctor` turns
all of that into a list:

```sh
$ s3nd doctor
Using /home/you/transfers/s3nd.config.json
✓ Configuration: bucket "transfers", region "eu-west-3"
✓ Credentials: resolved, key ends in 1234
✓ Bucket reachable: HeadBucket succeeded
✓ Write, read, delete: round-tripped a probe object
! Expiry cleanup: no enabled expiration rule
  → Add an S3 lifecycle rule that expires objects under this bucket after a day or
    two. Without it, expired transfers stay stored and billed.

1 check(s) failed.
```

It does not read your policy and reason about it; it performs the three operations s3nd
needs and reports what actually happened. The probe object is deleted before it returns.

That last check is the one that earns the command. `expiresIn` stops a transfer being _handed
over_; only a lifecycle rule deletes the object. Nothing surfaces the gap until a bill does.

Pointed at a server, `doctor` checks the only thing that matters there — a real round trip:

```sh
$ s3nd doctor --remote https://drop.example.com/api/transfers --token "$TOKEN"
✓ Server: https://drop.example.com/api/transfers answered
✓ Create, read, delete: round-tripped code 8WTXQC8R
```

## `put`, `get`, `rm`

```sh
$ s3nd put ./report.pdf
report.pdf · 284 kB · expires in 1 hour
K7QP2M4X
```

The code goes to stdout and everything else to stderr, so it composes:

```sh
CODE=$(s3nd put ./report.pdf)
```

`put -` reads stdin instead, which is how a directory or a database dump travels:

```sh
tar cz ./project | s3nd put - --name project.tar.gz
pg_dump notes | gzip | s3nd put - --name notes.sql.gz --expires-in 7d
```

On the other machine:

```sh
$ s3nd get K7QP2M4X
Wrote /home/you/report.pdf · 284 kB

$ s3nd rm K7QP2M4X
Burned K7QP2M4X
```

`get` writes to the stored filename unless you pass `-o`; `-o -` sends the payload to stdout. A
code holding a [snapshot](https://doc.s3nd.sh/docs/snapshots) rather than a file prints its JSON instead.

## `config`

What the CLI resolved, and — the part that ends most confusions — where each value came from:

```sh
$ s3nd config
file         /home/you/transfers/s3nd.config.json (profile "r2")
profiles     r2, local
mode         straight to S3

bucket       transfers                                 $S3ND_BUCKET
region       auto                                      s3nd.config.json (r2)
endpoint     https://8c4….r2.cloudflarestorage.com     s3nd.config.json (r2)
prefix       drops                                     --prefix
credentials  …1a2b                                     s3nd.config.json (r2)
expires in   1 day                                     s3nd.config.json (r2)
```

It never prints a secret: the access key id is masked to its last four characters, and the secret
and the bearer token are only ever reported as set. It reads no credentials and touches no bucket,
so it works before anything else does.

## The configuration file

Everything the flags take, in a file the CLI finds by itself:

```json title="s3nd.config.json"
{
  "bucket": "transfers",
  "region": "auto",
  "endpoint": "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com",
  "prefix": "transfers",
  "expiresIn": "24h",
  "envFile": ".env",
  "credentials": {
    "accessKeyId": "${R2_ACCESS_KEY_ID}",
    "secretAccessKey": "${R2_SECRET_ACCESS_KEY}"
  }
}
```

| Key              | Value                                                                                |
| ---------------- | ------------------------------------------------------------------------------------ |
| `bucket`         | Bucket name.                                                                         |
| `region`         | Region. `"auto"` for providers that ignore it.                                       |
| `endpoint`       | S3-compatible endpoint. Setting it turns on path-style addressing.                   |
| `forcePathStyle` | Override that, for a gateway that wants virtual-host style.                          |
| `prefix`         | Key prefix inside the bucket. One per purpose — it is what a lifecycle rule targets. |
| `publicUrl`      | Public base URL or CDN in front of the bucket.                                       |
| `expiresIn`      | `3600`, `"24h"`, `"7d"`, or `null` for transfers that do not expire.                 |
| `credentials`    | `{ accessKeyId, secretAccessKey, sessionToken? }`. Omit for the AWS provider chain.  |
| `remote`         | Talk to a s3nd server instead of S3. The credentials then live there, not here.      |
| `token`          | Bearer token sent with `remote`.                                                     |
| `envFile`        | Read these `KEY=value` pairs before expanding `${…}`. Relative to the file it is in. |
| `profiles`       | Named overrides — see below.                                                         |

Anything else is rejected by name, with the key it was probably meant to be:

```sh
$ s3nd config
Unknown key "buckett" in /home/you/transfers/s3nd.config.json. Did you mean "bucket"?
```

### Where it is looked for

1. `--config <path>`, or `$S3ND_CONFIG`. An explicit path that is not there is an error, never a
   silent fallback.
2. `s3nd.config.json`, `.s3ndrc.json` or `.s3ndrc`, from the working directory upwards — so a
   config beside the project is found from any directory inside it.
3. `~/.config/s3nd/config.json` (or `$XDG_CONFIG_HOME/s3nd/config.json`), for the machine-wide one.

The first file found wins outright. Two files being merged is the kind of thing nobody can debug
from the outside.

### Secrets stay outside it

`${VAR}` in any string is read from the environment, and `envFile` names a file to load first —
without overwriting anything the shell already set, so an exported variable stays the last word.
A missing one is reported by name before any request is made:

```sh
$ s3nd put ./report.pdf
"endpoint" in /home/you/transfers/s3nd.config.json references ${R2_ACCOUNT_ID}, which is not set.
Set it in /home/you/transfers/.env, or export it before running s3nd.
```

So the file is committable and the keys are not. An env file the config merely _declares_ may be
missing — the variables can just as well be exported — while one passed as `--env-file` must exist.

### Profiles

One file, several setups, chosen with `-p` or `$S3ND_PROFILE`:

```json title="s3nd.config.json"
{
  "bucket": "transfers",
  "prefix": "drops",
  "profiles": {
    "prod": { "remote": "https://drop.example.com/api/transfers", "token": "${S3ND_TOKEN}" },
    "local": {
      "endpoint": "http://localhost:9000",
      "credentials": { "accessKeyId": "minioadmin", "secretAccessKey": "minioadmin" }
    }
  }
}
```

```sh
s3nd -p local doctor
s3nd -p prod put ./report.pdf
```

A profile overrides the root key by key, `credentials` included as one unit: half a key pair,
inherited from two places, is not something anyone means to configure.

## What beats what

A flag beats an environment variable, which beats the configuration file, which beats the default.
`s3nd config` prints the winner of each.

| Setting       | Flag           | Environment                                       |
| ------------- | -------------- | ------------------------------------------------- |
| `bucket`      | `--bucket`     | `S3ND_BUCKET`, `S3_BUCKET`                        |
| `region`      | `--region`     | `S3ND_REGION`, `AWS_REGION`, `AWS_DEFAULT_REGION` |
| `endpoint`    | `--endpoint`   | `S3ND_ENDPOINT`, `S3_ENDPOINT`                    |
| `prefix`      | `--prefix`     | `S3ND_PREFIX`                                     |
| `publicUrl`   | —              | `S3ND_PUBLIC_URL`, `S3_PUBLIC_URL`                |
| `expiresIn`   | `--expires-in` | `S3ND_EXPIRES_IN`                                 |
| `remote`      | `--remote`     | `S3ND_REMOTE`                                     |
| `token`       | `--token`      | `S3ND_TOKEN`                                      |
| `credentials` | —              | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`      |
| the file      | `--config`     | `S3ND_CONFIG`                                     |
| the profile   | `--profile`    | `S3ND_PROFILE`                                    |

Credentials have no flag on purpose: a secret in an argument is a secret in `ps` and in your shell
history. Put them in the environment, in an env file, or leave them to the AWS provider chain —
a profile, an instance role, an SSO session.

## Against your own server

Every command takes `--remote`, which points it at a deployment of
[the transfer protocol](https://doc.s3nd.sh/docs/protocol) instead of at S3:

```sh
s3nd --remote https://drop.example.com/api/transfers put ./report.pdf
```

This is not a second implementation. The CLI has exactly one — the protocol client — wired either
to `fetch` or, without `--remote`, straight into the handler in the same process. The two modes
cannot drift apart, because there is only one of them.

The practical consequence: your laptop needs no S3 credentials. It holds a token for your own
deployment, which holds the credentials. [Setting up a server](https://doc.s3nd.sh/docs/server) is that side of it.

## Options

| Option                 | What it does                                                |
| ---------------------- | ----------------------------------------------------------- |
| `-c, --config <path>`  | Configuration file to read                                  |
| `-p, --profile <name>` | Profile to use inside it                                    |
| `--env-file <path>`    | Read `KEY=value` pairs from this file first                 |
| `--bucket <name>`      | Bucket name                                                 |
| `--prefix <prefix>`    | Key prefix inside the bucket                                |
| `--region <name>`      | Region                                                      |
| `--endpoint <url>`     | S3-compatible endpoint                                      |
| `--expires-in <d>`     | Transfer lifetime: `3600`, `30m`, `24h`, `7d`, or `never`   |
| `--remote <url>`       | Talk to a s3nd server instead of S3 directly                |
| `--token <token>`      | Bearer token sent with `--remote`                           |
| `--name <filename>`    | Filename to store the transfer under                        |
| `-o, --output <path>`  | Where `get` writes. `-` is stdout                           |
| `--json`               | Machine-readable output, for scripts and for `doctor` in CI |
| `--provider <name>`    | Which starter `init` writes                                 |
| `--force`              | Let `init` overwrite an existing file                       |

<Callout>
  Every command exits non-zero when it fails, `doctor` included — so `s3nd doctor --json` works as a deployment smoke
  test. Colour is dropped when the output is not a terminal, and when `NO_COLOR` is set.
</Callout>
