Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8e08af810 | ||
|
|
dde10e522a |
@@ -0,0 +1,14 @@
|
||||
# Copy this file to `.cargo/config.toml` (gitignored, so this stays
|
||||
# per-developer) to build against a local `../invoiceninjars-sdk` checkout
|
||||
# instead of the published crates.io release of `invoiceninja-sdk`. Useful
|
||||
# for testing an SDK change here before it's published.
|
||||
#
|
||||
# Cargo's `paths` override requires the local checkout's `[package] version`
|
||||
# in its Cargo.toml to match the version requirement in this crate's
|
||||
# Cargo.toml (currently "0.2.0") — if you bump the SDK's version locally
|
||||
# while working on the next release, this override stops matching until
|
||||
# this crate's `Cargo.toml` is bumped to match (or the SDK version is reset).
|
||||
#
|
||||
# Delete `.cargo/config.toml` (or just this line) to go back to the
|
||||
# published version.
|
||||
paths = ["../invoiceninjars-sdk"]
|
||||
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.rs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{yml,yaml,toml,nix}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,7 @@
|
||||
/target
|
||||
.devenv/
|
||||
.direnv/
|
||||
*.swp
|
||||
/.cargo/config.toml
|
||||
/result
|
||||
/result-*
|
||||
@@ -0,0 +1,156 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
|
||||
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk),
|
||||
consumed as a normal crates.io dependency (not a path dependency on a sibling
|
||||
checkout — see "Development environment" below for how that changed).
|
||||
That crate's own `GOALS.md` (at `../invoiceninjars-sdk/GOALS.md`, if you have
|
||||
a checkout for reference) explains why it's a separate crate rather than a
|
||||
feature of the library — dependencies, MSRV, and versioning, all pre-1.0.
|
||||
Read that `GOALS.md` before making a design call here; this project exists
|
||||
downstream of decisions made there.
|
||||
|
||||
**This project's own [`GOALS.md`](GOALS.md) states what it is for, what it
|
||||
deliberately is not, and what is still undecided** — read it before making a
|
||||
design call, the same way its counterpart in the SDK works. If a change
|
||||
would contradict a stated goal or settle an open question, update
|
||||
`GOALS.md` in the same change.
|
||||
|
||||
**Early scaffold.** Only the `clients` resource is wired up, as the template
|
||||
for the other ~34. See "Adding a resource" below before writing the next one.
|
||||
|
||||
## Development environment
|
||||
|
||||
Nix-managed, with its own `flake.nix` (not shared with the SDK's):
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
```
|
||||
|
||||
The dev toolchain was originally pinned to match `invoiceninjars-sdk`'s own
|
||||
(`devVersion` in each `flake.nix`), from back when this crate needed a sibling
|
||||
checkout to build at all. That's no longer a hard requirement now that the
|
||||
SDK is on crates.io, but the pin hasn't been revisited — if the two drift,
|
||||
nothing here forces them back in sync, since the two flakes are independent
|
||||
files.
|
||||
|
||||
**As of 0.2.0 (2026-10-25), the SDK is published on crates.io** and this
|
||||
crate depends on it as a normal version requirement
|
||||
(`invoiceninja-sdk = "0.2.0"` in `Cargo.toml`), not a path dependency. That
|
||||
means:
|
||||
|
||||
- No `../invoiceninjars-sdk` checkout is needed to build this crate anymore.
|
||||
`cargo build` fetches `invoiceninja-sdk` from the registry like any other
|
||||
dependency.
|
||||
- Picking up a change made in the SDK's working copy now requires that
|
||||
change to be published as a new crates.io release first by default — there
|
||||
is no more "edit the sibling checkout, rebuild here" loop out of the box.
|
||||
For active co-development across both crates, copy
|
||||
`.cargo/config.toml.example` to `.cargo/config.toml` (gitignored, so it
|
||||
stays per-developer and never affects CI or anyone else's build) to get
|
||||
a `paths` override back onto `../invoiceninjars-sdk`. It requires that
|
||||
checkout's `[package] version` to match this crate's `invoiceninja-sdk`
|
||||
requirement in `Cargo.toml` — bump both together if the SDK's working
|
||||
copy moves to an unreleased version. Delete `.cargo/config.toml` to go
|
||||
back to the published release.
|
||||
- Pre-1.0, a y-bump (`0.2.x` → `0.3.0`) in the SDK is a breaking release per
|
||||
that crate's own `GOALS.md`. The default caret requirement already blocks
|
||||
picking that up automatically; bumping the version in `Cargo.toml` is a
|
||||
deliberate, reviewed step, not something `cargo update` should do on its
|
||||
own.
|
||||
|
||||
**Watch the SDK's own package/crate name.** It has changed at least once
|
||||
already (`invoiceninjars` → `invoiceninja-sdk`, crate path `invoiceninja_sdk`)
|
||||
without a corresponding change here landing first. If a build fails with "no
|
||||
matching package named `invoiceninja-sdk` found" or an unresolved
|
||||
`invoiceninja_sdk::` path, check the crates.io listing (or
|
||||
`../invoiceninjars-sdk/Cargo.toml`'s `[package] name`, if you have a
|
||||
checkout) first.
|
||||
|
||||
## Common commands
|
||||
|
||||
```sh
|
||||
cargo build
|
||||
cargo run -- clients list # needs IN_API_TOKEN, or --token
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets
|
||||
```
|
||||
|
||||
No test suite yet — nothing here has behavior worth testing beyond what
|
||||
`clap`'s derive and the SDK's own test suite already cover. Add one (clap's
|
||||
`Cli::try_parse_from` is the usual way to test argument parsing without
|
||||
spawning a process) when a command does more than translate flags into an
|
||||
SDK call.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/cli.rs` — all `clap` derive types. `Cli` holds the global
|
||||
options (`--base-url`, `--token`, `--api-password`, `--output`) and the
|
||||
top-level `Command` subcommand enum. **`--token` can't be clap-`required`**
|
||||
because clap rejects `required` combined with `global` (global is what
|
||||
lets `--token` appear after the resource subcommand too, not just before
|
||||
it) — the missing case is caught in `config::build_client` instead, with a
|
||||
message naming both the flag and the env var. Don't try to make it
|
||||
`required` again; the comment on the field explains the panic you'd get.
|
||||
- `src/config.rs` — `build_client` turns a parsed `Cli` into an
|
||||
`InvoiceNinjaClient`, including the manual token check above.
|
||||
- `src/body.rs` — how `create`/`update` get their JSON body (`--data`
|
||||
inline, `--file` from disk or `-` for stdin, or stdin by default), plus
|
||||
`merge_shallow`, a one-level-deep object overlay.
|
||||
- `src/output.rs` — `print_json`, shared by every resource's `--output
|
||||
json`. Table rendering is **not** here; it's per-resource (see below),
|
||||
since there's no generic notion of which columns matter.
|
||||
- `src/commands/clients.rs` — the one resource implemented so far, and the
|
||||
template for the rest.
|
||||
|
||||
### The `update` fetch-merge-write pattern
|
||||
|
||||
`update` is not a thin wrapper over the SDK's `update_client`. It:
|
||||
|
||||
1. Fetches the current record with `get_client`.
|
||||
2. Serializes it to `serde_json::Value` and shallow-merges the caller's
|
||||
`--data`/`--file` JSON on top (`body::merge_shallow`).
|
||||
3. Deserializes the merged value back into the model and calls
|
||||
`update_client`.
|
||||
|
||||
This exists because Invoice Ninja's `PUT` is a full replace — there's no
|
||||
PATCH route — and every non-`Option` field on an SDK model decodes a missing
|
||||
key as that type's default (`""`, `0`, `false`; see
|
||||
`invoiceninja_sdk`'s `null_to_default`). Sending the caller's patch alone
|
||||
would silently blank out every field they didn't mention. **Keep this
|
||||
pattern for every resource's `update`** — it's the CLI-level version of the
|
||||
SDK's own "never lose data in a round trip" goal, not a one-off for clients.
|
||||
|
||||
`create` has no such step: a partial JSON body decodes straight into the
|
||||
model, since the same defaulting that makes a bare patch dangerous for
|
||||
`update` is exactly what you want when creating a record whose fields you
|
||||
haven't all thought about (the API does the equivalent server-side).
|
||||
|
||||
### Adding a resource
|
||||
|
||||
Each resource beyond `clients` should follow the same shape:
|
||||
|
||||
1. Add a variant to `Command` in `src/cli.rs` (e.g. `Invoices(ResourceArgs)`)
|
||||
— `ResourceArgs` and `Action` are already generic across resources, reuse
|
||||
them as-is.
|
||||
2. Add `src/commands/<resource>.rs` mirroring `clients.rs`: a `run` dispatcher
|
||||
over `Action`, `list`/`get`/`create`/`update`/`delete` functions calling
|
||||
the matching `list_<resource>`/`get_<resource>`/… methods on
|
||||
`InvoiceNinjaClient`, and a `print_table` choosing that resource's own key
|
||||
columns.
|
||||
3. Register the module in `src/commands/mod.rs` and the dispatch arm in
|
||||
`main.rs`'s `match cli.command`.
|
||||
4. Check `../invoiceninjars-sdk/src/client.rs` for whether the resource has a
|
||||
`/bulk` route (most do; a few — `client_gateway_tokens`, `companies`,
|
||||
`system_logs` — don't, per that crate's `crud_methods!` vs
|
||||
`resource_methods!` split). This CLI doesn't expose bulk actions yet
|
||||
either way; that's a separate addition.
|
||||
|
||||
Resources with entity actions, document uploads, or non-CRUD endpoints
|
||||
(reports, charts, search, statics, PDF downloads, …) will need more than
|
||||
this template once they're added — the SDK's own `CLAUDE.md` describes which
|
||||
resources have which extras.
|
||||
Generated
+1333
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "invoiceninja-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# Matches the SDK's own MSRV. clap 4.6.7 declares 1.85 (see GOALS.md in
|
||||
# invoiceninjars-sdk), which is under this, so the SDK's floor is still the
|
||||
# binding one. Recheck both together if either dependency tree moves.
|
||||
rust-version = "1.88"
|
||||
description = "A command-line client for the Invoice Ninja v5 API, built on invoiceninja-sdk"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/awkawb1/invoiceninja-cli"
|
||||
# Slugs checked against https://crates.io/api/v1/category_slugs, same as
|
||||
# invoiceninja-sdk's own Cargo.toml.
|
||||
categories = ["command-line-utilities"]
|
||||
keywords = ["invoiceninja", "invoicing", "billing", "cli"]
|
||||
|
||||
[dependencies]
|
||||
# Published on crates.io as of 0.2.0 (2026-10-25); no longer a path
|
||||
# dependency on a sibling checkout. Pre-1.0, a y-bump (0.2.x -> 0.3.0) is a
|
||||
# breaking release per that crate's own GOALS.md, so stick with the default
|
||||
# caret requirement rather than widening it — it already blocks that
|
||||
# automatically while still taking 0.2.x patch releases.
|
||||
invoiceninja-sdk = "0.2.0"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,130 @@
|
||||
# Goals
|
||||
|
||||
What this crate is trying to be, what it is deliberately not, and what is
|
||||
still open. Same purpose as `invoiceninjars-sdk`'s own `GOALS.md`: several
|
||||
choices here look like oversights until you know the reasoning, and that
|
||||
reasoning should live here rather than only in commit messages.
|
||||
|
||||
## What this is for
|
||||
|
||||
A command-line way to drive the Invoice Ninja v5 API through
|
||||
`invoiceninja-sdk`, for the cases Rust code is the wrong tool for: a quick
|
||||
"what does this endpoint actually return", a one-off fix from a terminal, or
|
||||
a shell script that doesn't want to be a Rust program. The SDK's own
|
||||
`GOALS.md` names this exact need — its `examples/` cover ad-hoc probing
|
||||
today, and this crate is the standalone version of that.
|
||||
|
||||
It is a client over the SDK, not a second implementation of anything the SDK
|
||||
already does. If a command needs logic beyond "turn flags and JSON into one
|
||||
SDK call", that is usually a sign the logic belongs in the SDK instead.
|
||||
|
||||
## Goals
|
||||
|
||||
### 1. Every command is a thin translation, with one documented exception
|
||||
|
||||
`list`, `get`, `create`, and `delete` do nothing but turn CLI flags into a
|
||||
`ListParams`/model/id and call the matching SDK method. **`update` is the
|
||||
one exception**, and it stays an exception rather than a pattern to extend
|
||||
casually: it fetches the record, overlays the caller's JSON on top, and
|
||||
writes the merged result back, because Invoice Ninja's `PUT` is a full
|
||||
replace and the SDK's models default an omitted field rather than leaving it
|
||||
alone. See the comment in `src/commands/clients.rs` and the matching section
|
||||
in `CLAUDE.md`. Any other deviation from "thin translation" should be
|
||||
written down the same way.
|
||||
|
||||
### 2. `--output json` is always the full, lossless record
|
||||
|
||||
The table view is a convenience subset — whatever columns a resource's
|
||||
`print_table` picks — and it is allowed to leave things out. `--output json`
|
||||
is not: it is always the model's own `Serialize` output, including
|
||||
`extra`, so it is a drop-in input to `--data`/`--file` for a later `update`
|
||||
or `create` and a reliable thing to pipe into `jq`. This is the CLI-level
|
||||
version of the SDK's "never lose data in a round trip" goal.
|
||||
|
||||
### 3. One pattern across resources
|
||||
|
||||
Adding a resource should be mechanical, not a new design each time: a
|
||||
`Command` variant, a `src/commands/<resource>.rs` with the same
|
||||
`run`/`list`/`get`/`create`/`update`/`delete`/`print_table` shape as
|
||||
`clients.rs`, and a registration line in two places. `CLAUDE.md`'s "Adding a
|
||||
resource" section is the checklist; if a resource needs something the
|
||||
template doesn't cover (bulk, uploads, entity actions, non-CRUD endpoints),
|
||||
that is scope for a deliberate addition to the template, not a one-off.
|
||||
|
||||
### 4. Scriptable by default
|
||||
|
||||
Errors go to stderr and a failure exits non-zero; nothing is printed to
|
||||
stdout on an error path that a script could mistake for a result. Secrets
|
||||
(`--token`, `--api-password`) never appear in `--help` or in any echoed
|
||||
default — see `hide_env_values` on each in `src/cli.rs`. The SDK already
|
||||
keeps both out of its own `Debug` output; this crate's job is not to undo
|
||||
that by printing them itself.
|
||||
|
||||
### 5. Inherit the SDK's safety defaults rather than re-deciding them
|
||||
|
||||
This crate does not expose its own retry/timeout policy or add a write path
|
||||
the SDK doesn't already consider safe. If the SDK decides `POST` isn't
|
||||
replayed on a 5xx because it might double-create an invoice, that reasoning
|
||||
applies here unchanged — the CLI is not a place to relax it for convenience.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No Cargo workspace with the SDK.** Explained in `CLAUDE.md`: the SDK is
|
||||
its own git repo with its own CI and crates.io release, mirrored read-only
|
||||
to GitHub. Now that the SDK is published (0.2.0, 2026-10-25), the
|
||||
dependency is a plain crates.io version requirement — there isn't even a
|
||||
path dependency linking the two build graphs anymore, let alone a
|
||||
workspace. This doesn't mean co-development across both crates is gone:
|
||||
`.cargo/config.toml.example` documents a per-developer `paths` override
|
||||
back onto a local `../invoiceninjars-sdk` checkout, gitignored so it never
|
||||
becomes the default for anyone else or for CI. See `CLAUDE.md`.
|
||||
- **No interactive mode or TUI.** Every command should work non-interactively
|
||||
from a script; a prompt-driven mode would be a different tool.
|
||||
- **No local state, cache, or config file — yet.** Credentials and the base
|
||||
URL come from flags or environment variables only, resolved fresh on every
|
||||
invocation. See the open question below on multi-account profiles before
|
||||
assuming this is permanent.
|
||||
- **No stability promise before 1.0.** Same posture as the SDK: flags are a
|
||||
compatibility surface too, and pre-1.0 they can change. Record breaking
|
||||
changes somewhere legible (a `CHANGELOG.md`, once there's enough history to
|
||||
need one) rather than letting them be discovered at the command line.
|
||||
|
||||
## Open questions
|
||||
|
||||
These are genuinely undecided; deciding one is a change to this file.
|
||||
|
||||
- **Bulk actions.** The SDK exposes `bulk_<resource>` (archive/restore/delete
|
||||
plus resource-specific actions) on every resource with a `/bulk` route.
|
||||
Nothing here calls it yet. Worth having, but a bulk `delete` from a CLI
|
||||
invites a worse mistake than a one-record `delete` — whether that needs a
|
||||
confirmation prompt, a `--yes` flag, or a dry-run preview is unresolved.
|
||||
- **`--dry-run` for `create`/`update`/`delete`.** Would print what the merged
|
||||
body or the target of a delete would be without sending the request.
|
||||
Cheap for `create`/`delete`; for `update` it would mean exposing the
|
||||
fetch-merge result for inspection before the write half runs.
|
||||
- **Multi-account profiles.** Flags and env vars cover one account per
|
||||
invocation. A config file keyed by profile name (base URL + token) would
|
||||
help anyone working against more than one instance regularly, at the cost
|
||||
of being the first place this crate stores anything on disk.
|
||||
- **How closely should list flags track `ListParams`?** Right now `clients
|
||||
list` exposes exactly what `ListParams` has typed setters for, plus
|
||||
`--param key=value` for the rest. A per-resource convenience filter (e.g.
|
||||
a `--unpaid` shorthand for `client_status=unpaid`) would read nicer but
|
||||
means hand-picking which filters earn a dedicated flag per resource.
|
||||
- **Whether this crate gets its own CI.** No longer blocked on needing a
|
||||
sibling checkout — the SDK dependency switched from a path to a crates.io
|
||||
version requirement (`invoiceninja-sdk = "0.2.0"`) once 0.2.0 was published
|
||||
on 2026-10-25. A workflow here can now just `cargo build` against the
|
||||
registry like any other Rust project; it doesn't need to check out
|
||||
`invoiceninjars-sdk` at all.
|
||||
- **Whether the Nix packaging goes further than this repo's own flake.**
|
||||
`flake.nix` now has `packages.default`/`devShells.default` (built with a
|
||||
pinned `rust-overlay` toolchain, for parity between `nix build` and
|
||||
`nix develop`) and `overlays.default` (for another flake to pull
|
||||
`invoiceninja-cli` into its own `pkgs`, built with *that* nixpkgs's own
|
||||
`rustPlatform` instead of the pin). The overlay path means this crate's
|
||||
`rust-version = "1.88"` has to be met by whatever rustc the consuming
|
||||
channel ships — there's no guard here if it isn't, just a build failure
|
||||
over there. Whether that's worth submitting to nixpkgs proper (and how an
|
||||
MSRV bump would then need to be communicated beyond this repo) is
|
||||
unresolved.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 bryant
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,2 +1,48 @@
|
||||
# invoiceninja-cli
|
||||
|
||||
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
|
||||
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk).
|
||||
|
||||
> **Early scaffold.** Only the `clients` resource is wired up so far, as the
|
||||
> template for adding the rest — see `CLAUDE.md` for the pattern.
|
||||
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
|
||||
|
||||
## Development
|
||||
|
||||
Enter the dev shell (Rust toolchain via `rust-overlay`, matching the SDK's):
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Every subcommand needs an API token and, for a self-hosted install, a base
|
||||
URL. Both can be passed as flags or environment variables (the same names
|
||||
the SDK's own examples use):
|
||||
|
||||
```sh
|
||||
export IN_API_TOKEN=...
|
||||
# export IN_BASE_URL=https://your-domain.example/api/v1 # self-hosted only
|
||||
|
||||
invoiceninja-cli clients list
|
||||
invoiceninja-cli clients list --all --status active,archived
|
||||
invoiceninja-cli clients get <id>
|
||||
invoiceninja-cli clients create --data '{"name": "Acme Inc"}'
|
||||
invoiceninja-cli clients update <id> --data '{"city": "Springfield"}'
|
||||
invoiceninja-cli clients delete <id>
|
||||
```
|
||||
|
||||
`create` and `update` read the record body as JSON, from `--data` inline,
|
||||
`--file <path>` (`-` for stdin), or stdin if neither flag is given.
|
||||
`update` fetches the current record first and overlays the given fields on
|
||||
top of it, so you only need to mention what's changing — see the comment in
|
||||
`src/body.rs` for why that matters (Invoice Ninja's `PUT` is a full replace,
|
||||
not a patch).
|
||||
|
||||
Add `--output json` to any command for the full record instead of the
|
||||
table view's few key columns:
|
||||
|
||||
```sh
|
||||
invoiceninja-cli --output json clients get <id>
|
||||
```
|
||||
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1791914995,
|
||||
"narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792773339,
|
||||
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792913333,
|
||||
"narHash": "sha256-wjoZGhxH/Yr+OsFRpNyV9DpMGQIdNEjjAOinfzQvadk=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "fad80eb74614f7dc37c1c83df8caae64361eda22",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
description = "invoiceninja-cli: a command-line client for the Invoice Ninja v5 API";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
|
||||
let
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
||||
|
||||
# Shared between `packages.default` (built below with the pinned
|
||||
# rust-overlay toolchain, for parity with `nix develop`) and
|
||||
# `overlays.default` (built with whatever `rustPlatform` the
|
||||
# *consuming* nixpkgs provides, per the usual nixpkgs packaging
|
||||
# convention — see the note on `overlays.default` below). Takes its
|
||||
# inputs as a plain attrset so both call sites, and `callPackage`,
|
||||
# can hand it the pieces however they're sourced.
|
||||
mkInvoiceninjaCli = { lib, rustPlatform, pkg-config, openssl }:
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "invoiceninja-cli";
|
||||
version = cargoToml.package.version;
|
||||
src = self;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
nativeBuildInputs = [ pkg-config ];
|
||||
buildInputs = [ openssl ];
|
||||
|
||||
meta = {
|
||||
description = cargoToml.package.description;
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "invoiceninja-cli";
|
||||
platforms = lib.platforms.unix;
|
||||
};
|
||||
};
|
||||
in
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
# Kept in step with invoiceninjars-sdk's own `devVersion`, so the two
|
||||
# sibling crates build with the same toolchain during development.
|
||||
devVersion = "1.99.1";
|
||||
|
||||
rustToolchain = pkgs.rust-bin.stable.${devVersion}.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
|
||||
};
|
||||
|
||||
# Built with the pinned dev toolchain, not nixpkgs' own rustc, so a
|
||||
# `nix build` matches what `nix develop` compiles with.
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [ rustToolchain pkgs.pkg-config pkgs.openssl ];
|
||||
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
|
||||
# Unlike invoiceninjars-sdk, this crate has a `[[bin]]` target, so
|
||||
# `nix build` produces an installable binary, not just a test gate.
|
||||
# `invoiceninja-sdk` resolves from crates.io (see Cargo.toml), so
|
||||
# unlike before this no longer needs a `../invoiceninjars-sdk`
|
||||
# checkout to be present in the sandbox.
|
||||
packages.default = mkInvoiceninjaCli {
|
||||
inherit (pkgs) lib pkg-config openssl;
|
||||
inherit rustPlatform;
|
||||
};
|
||||
}) // {
|
||||
# For a flake that wants `invoiceninja-cli` in its own `pkgs`, not for
|
||||
# this flake's own `packages.default`/`devShells.default` above (those
|
||||
# keep the pinned rust-overlay toolchain). `callPackage` resolves
|
||||
# `rustPlatform` from the *consuming* nixpkgs, which means it builds
|
||||
# with whatever rustc that channel ships — this crate's `rust-version
|
||||
# = "1.88"` in Cargo.toml has to be met there too, same as any other
|
||||
# nixpkgs Rust package; this overlay doesn't carry the rust-overlay
|
||||
# pin along with it.
|
||||
overlays.default = final: _prev: {
|
||||
invoiceninja-cli = final.callPackage mkInvoiceninjaCli { };
|
||||
};
|
||||
};
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cli::BodyArgs;
|
||||
|
||||
/// Reads the JSON body a `create`/`update` command was given: `--data`
|
||||
/// inline, `--file` from disk (`-` for stdin), or stdin if neither flag was
|
||||
/// passed. Errors if the result isn't a JSON object — every create/update
|
||||
/// endpoint here takes a record, not a bare array or scalar.
|
||||
pub fn load(body: &BodyArgs) -> Result<Value, Box<dyn std::error::Error>> {
|
||||
let text = match (&body.data, &body.file) {
|
||||
(Some(data), None) => data.clone(),
|
||||
(None, Some(path)) if path.as_os_str() == "-" => read_stdin()?,
|
||||
(None, Some(path)) => fs::read_to_string(path)?,
|
||||
(None, None) => read_stdin()?,
|
||||
// clap's `conflicts_with` on both fields rules this out before we get here.
|
||||
(Some(_), Some(_)) => unreachable!("--data and --file are mutually exclusive"),
|
||||
};
|
||||
|
||||
let value: Value = serde_json::from_str(&text)?;
|
||||
if !value.is_object() {
|
||||
return Err(format!("expected a JSON object, got {value}").into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn read_stdin() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Overlays `patch`'s keys onto `base`, one level deep.
|
||||
///
|
||||
/// `update` uses this so a caller can send just the fields they're changing
|
||||
/// instead of retyping the whole record. That matters because Invoice
|
||||
/// Ninja's `PUT` is a full replace — there's no PATCH route — and the SDK's
|
||||
/// models default any field the caller omits to `""` / `0` / `false` rather
|
||||
/// than leaving it alone. Sending the patch by itself would blank out every
|
||||
/// field it didn't mention; merging onto a freshly-fetched copy keeps them.
|
||||
pub fn merge_shallow(mut base: Value, patch: Value) -> Value {
|
||||
if let (Some(base_map), Value::Object(patch_map)) = (base.as_object_mut(), patch) {
|
||||
for (k, v) in patch_map {
|
||||
base_map.insert(k, v);
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
|
||||
/// A command-line client for the Invoice Ninja v5 API, built on the
|
||||
/// `invoiceninja-sdk` crate.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "invoiceninja-cli", version, about)]
|
||||
pub struct Cli {
|
||||
/// API root, including the `/api/v1` suffix. Defaults to the hosted app
|
||||
/// (`invoicing.co`); a self-hosted install must pass this.
|
||||
#[arg(long, env = "IN_BASE_URL", global = true)]
|
||||
pub base_url: Option<String>,
|
||||
|
||||
/// API token. Required for every call.
|
||||
///
|
||||
/// Typed `Option` rather than clap-`required`: clap refuses to combine
|
||||
/// `required` with `global` (needed so this can also be passed after the
|
||||
/// subcommand), so the missing case is checked in `config::build_client`
|
||||
/// instead, with a message that names both ways to supply it.
|
||||
/// `hide_env_values` keeps it out of `--help`, which otherwise echoes
|
||||
/// env-sourced defaults back verbatim.
|
||||
#[arg(long, env = "IN_API_TOKEN", global = true, hide_env_values = true)]
|
||||
pub token: Option<String>,
|
||||
|
||||
/// Account password, needed only for the endpoints Invoice Ninja marks
|
||||
/// `password_protected` (reading or writing a user, purging a client).
|
||||
#[arg(long, env = "IN_API_PASSWORD", global = true, hide_env_values = true)]
|
||||
pub api_password: Option<String>,
|
||||
|
||||
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Table)]
|
||||
pub output: OutputFormat,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum OutputFormat {
|
||||
/// A few key columns, one record per line. Which columns count as "key"
|
||||
/// is a per-resource choice, made in that resource's command module.
|
||||
Table,
|
||||
/// The full record(s), pretty-printed. Always lossless — use this to see
|
||||
/// every field a table view leaves out, including `extra`.
|
||||
Json,
|
||||
}
|
||||
|
||||
/// One variant per resource. Only `clients` is wired up so far; it's the
|
||||
/// template for adding the rest of the SDK's ~34 resources the same way —
|
||||
/// see CLAUDE.md.
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// Manage clients
|
||||
Clients(ResourceArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ResourceArgs {
|
||||
#[command(subcommand)]
|
||||
pub action: Action,
|
||||
}
|
||||
|
||||
/// The five actions every resource exposes. `Create`/`Update` share
|
||||
/// `BodyArgs` because both take a JSON record; see `body::load`.
|
||||
#[derive(Subcommand)]
|
||||
pub enum Action {
|
||||
/// List records (one page by default; pass --all to follow pagination)
|
||||
List(ListArgs),
|
||||
/// Fetch one record by id
|
||||
Get { id: String },
|
||||
/// Create a record from a JSON body
|
||||
Create(BodyArgs),
|
||||
/// Update a record from a JSON body, keeping fields you don't mention.
|
||||
///
|
||||
/// Fetches the record, overlays the given JSON on top, and writes the
|
||||
/// merged result back — see the comment in `commands::clients::update`
|
||||
/// for why a plain PUT of just the patch would blank out the rest.
|
||||
Update {
|
||||
id: String,
|
||||
#[command(flatten)]
|
||||
body: BodyArgs,
|
||||
},
|
||||
/// Delete (soft-delete) a record by id
|
||||
Delete { id: String },
|
||||
}
|
||||
|
||||
/// Mirrors the parameters every `ListParams` accepts in the SDK — see
|
||||
/// `invoiceninja_sdk::models::ListParams`.
|
||||
#[derive(Args)]
|
||||
pub struct ListArgs {
|
||||
#[arg(long)]
|
||||
pub page: Option<u64>,
|
||||
#[arg(long)]
|
||||
pub per_page: Option<u64>,
|
||||
#[arg(long)]
|
||||
pub sort: Option<String>,
|
||||
#[arg(long)]
|
||||
pub filter: Option<String>,
|
||||
/// e.g. "active,archived,deleted" to include archived/deleted records
|
||||
#[arg(long)]
|
||||
pub status: Option<String>,
|
||||
/// Comma-separated relationships to eager-load, e.g. "contacts"
|
||||
#[arg(long)]
|
||||
pub include: Option<String>,
|
||||
/// Extra query parameter, as key=value. Repeatable, for the per-resource
|
||||
/// filters `ListParams::param` covers (`client_id`, `client_status`, …).
|
||||
#[arg(long = "param", value_parser = parse_key_val)]
|
||||
pub params: Vec<(String, String)>,
|
||||
/// Follow pagination and return every matching record instead of one
|
||||
/// page (`list_all_*` in the SDK rather than `list_*`).
|
||||
#[arg(long)]
|
||||
pub all: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct BodyArgs {
|
||||
/// The record body, as a JSON object, given inline.
|
||||
#[arg(long, conflicts_with = "file")]
|
||||
pub data: Option<String>,
|
||||
/// The record body, as a JSON object, read from a file ("-" for stdin).
|
||||
#[arg(long, conflicts_with = "data")]
|
||||
pub file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn parse_key_val(s: &str) -> Result<(String, String), String> {
|
||||
let (k, v) = s
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("expected key=value, got `{s}`"))?;
|
||||
Ok((k.to_string(), v.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! `clients` subcommand. First resource wired up, and the template for
|
||||
//! adding the rest — see the "Adding a resource" section in CLAUDE.md.
|
||||
|
||||
use invoiceninja_sdk::models::{Client, ListParams};
|
||||
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||
|
||||
use crate::body;
|
||||
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
||||
use crate::output;
|
||||
|
||||
pub async fn run(
|
||||
client: &InvoiceNinjaClient,
|
||||
args: ResourceArgs,
|
||||
format: OutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match args.action {
|
||||
Action::List(list_args) => list(client, list_args, format).await,
|
||||
Action::Get { id } => get(client, &id, format).await,
|
||||
Action::Create(body_args) => create(client, body_args, format).await,
|
||||
Action::Update { id, body } => update(client, &id, body, format).await,
|
||||
Action::Delete { id } => delete(client, &id).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(
|
||||
client: &InvoiceNinjaClient,
|
||||
args: ListArgs,
|
||||
format: OutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut params = ListParams::new();
|
||||
if let Some(page) = args.page {
|
||||
params = params.page(page);
|
||||
}
|
||||
if let Some(per_page) = args.per_page {
|
||||
params = params.per_page(per_page);
|
||||
}
|
||||
if let Some(sort) = args.sort {
|
||||
params = params.sort(sort);
|
||||
}
|
||||
if let Some(filter) = args.filter {
|
||||
params = params.filter(filter);
|
||||
}
|
||||
if let Some(status) = args.status {
|
||||
params = params.status(status);
|
||||
}
|
||||
if let Some(include) = args.include {
|
||||
params = params.include(include);
|
||||
}
|
||||
for (k, v) in args.params {
|
||||
params = params.param(k, v);
|
||||
}
|
||||
|
||||
let clients = if args.all {
|
||||
client.list_all_clients(¶ms).await?
|
||||
} else {
|
||||
client.list_clients(¶ms).await?
|
||||
};
|
||||
|
||||
render(&clients, format)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
client: &InvoiceNinjaClient,
|
||||
id: &str,
|
||||
format: OutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let found = client.get_client(id).await?;
|
||||
render(std::slice::from_ref(&found), format)
|
||||
}
|
||||
|
||||
async fn create(
|
||||
client: &InvoiceNinjaClient,
|
||||
body_args: BodyArgs,
|
||||
format: OutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let value = body::load(&body_args)?;
|
||||
// Missing fields in `value` decode fine: every non-Option field on
|
||||
// `Client` is `#[serde(default, deserialize_with = "null_to_default")]`,
|
||||
// so a partial object (e.g. just `{"name": "..."}`) fills the rest with
|
||||
// the model's defaults, same as the API does server-side on create.
|
||||
let model: Client = serde_json::from_value(value)?;
|
||||
let created = client.create_client(&model).await?;
|
||||
render(std::slice::from_ref(&created), format)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
client: &InvoiceNinjaClient,
|
||||
id: &str,
|
||||
body_args: BodyArgs,
|
||||
format: OutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let patch = body::load(&body_args)?;
|
||||
let current = client.get_client(id).await?;
|
||||
let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch);
|
||||
let model: Client = serde_json::from_value(merged)?;
|
||||
let updated = client.update_client(id, &model).await?;
|
||||
render(std::slice::from_ref(&updated), format)
|
||||
}
|
||||
|
||||
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
client.delete_client(id).await?;
|
||||
println!("deleted {id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render(clients: &[Client], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match format {
|
||||
OutputFormat::Json => output::print_json(clients)?,
|
||||
OutputFormat::Table => print_table(clients),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The columns considered "key" for a client. Every resource module picks
|
||||
/// its own this way — there's no generic notion of which fields matter
|
||||
/// enough for a table view.
|
||||
fn print_table(clients: &[Client]) {
|
||||
if clients.is_empty() {
|
||||
println!("(no records)");
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"{:<20} {:<30} {:>12} {:>12}",
|
||||
"id", "name", "balance", "paid_to_date"
|
||||
);
|
||||
for c in clients {
|
||||
println!(
|
||||
"{:<20} {:<30} {:>12.2} {:>12.2}",
|
||||
c.id.as_deref().unwrap_or(""),
|
||||
truncate(&c.name, 30),
|
||||
c.balance,
|
||||
c.paid_to_date
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod clients;
|
||||
@@ -0,0 +1,25 @@
|
||||
use invoiceninja_sdk::{InvoiceNinjaClient, HOSTED_BASE_URL};
|
||||
|
||||
use crate::cli::Cli;
|
||||
|
||||
/// Builds the SDK client from the parsed CLI args: the base URL (defaulting
|
||||
/// to the hosted app), the token, and the optional account password for the
|
||||
/// `password_protected` endpoints.
|
||||
///
|
||||
/// Checks for a missing token itself rather than leaning on clap, since
|
||||
/// `token` can't be marked clap-`required` — see the comment on `Cli::token`.
|
||||
pub fn build_client(cli: &Cli) -> Result<InvoiceNinjaClient, Box<dyn std::error::Error>> {
|
||||
let token = cli
|
||||
.token
|
||||
.clone()
|
||||
.ok_or("missing API token: pass --token or set IN_API_TOKEN")?;
|
||||
let base_url = cli
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| HOSTED_BASE_URL.to_string());
|
||||
let client = InvoiceNinjaClient::new(base_url, token)?;
|
||||
match &cli.api_password {
|
||||
Some(password) => Ok(client.with_api_password(password.clone())?),
|
||||
None => Ok(client),
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
mod body;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod output;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use cli::{Cli, Command};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(err) = run().await {
|
||||
eprintln!("error: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
let client = config::build_client(&cli)?;
|
||||
|
||||
match cli.command {
|
||||
Command::Clients(args) => commands::clients::run(&client, args, cli.output).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde::Serialize;
|
||||
|
||||
/// Prints a value as pretty JSON. Used for every resource's `--output json`
|
||||
/// — the table view is per-resource (see `commands::clients::print_table`),
|
||||
/// but JSON formatting isn't, since it's just the model's own `Serialize`.
|
||||
pub fn print_json<T: Serialize + ?Sized>(value: &T) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("{}", serde_json::to_string_pretty(value)?);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user