Initial commit: clients resource scaffold over invoiceninja-sdk
A command-line client for the Invoice Ninja v5 API, built on the invoiceninja-sdk crate (consumed from crates.io, 0.2.0). Only the clients resource is wired up so far, as the template the rest follow - see CLAUDE.md and GOALS.md for the reasoning behind the shape of things (the update fetch-merge-write pattern, why there's no Cargo workspace with the SDK, the local-checkout override for co-development, and the Nix flake packaging via packages.default and overlays.default). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
+26
@@ -0,0 +1,26 @@
|
|||||||
|
[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"
|
||||||
|
# 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.
|
||||||
@@ -0,0 +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