Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f9c59bf0 |
@@ -1,14 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/target
|
|
||||||
.devenv/
|
|
||||||
.direnv/
|
|
||||||
*.swp
|
|
||||||
/.cargo/config.toml
|
|
||||||
/result
|
|
||||||
/result-*
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# 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
@@ -1,27 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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,48 +1,2 @@
|
|||||||
# invoiceninja-cli
|
# 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
@@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
{
|
|
||||||
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
@@ -1,50 +0,0 @@
|
|||||||
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
@@ -1,130 +0,0 @@
|
|||||||
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()))
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
//! `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>() + "…"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub mod clients;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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
@@ -1,26 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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