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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to the `nirium-cli` package are documented here.

- `nirium anchor` ([#133](https://github.com/nirium-protocol/nirium/issues/133)): anchors a sha-256 hash (`--hash`), a file's sha-256 (`--file`, the file itself is never uploaded), or a small JSON record (`--record`, max 8KB, enforced client-side) to IPFS via an Audit Trail node's `POST /api/audit/log`. Optional `--tx`/`--tag`; no `--network` flag by design - the agent box decides its own network. Prints the CID, content hash, gateway URL, and a `nirium verify` suggestion.
- `nirium verify --gateway` is now repeatable, tried in order as fallbacks, with `--retries`/`--timeout` ([#135](https://github.com/nirium-protocol/nirium/issues/135)). A `404` retries the *same* gateway with backoff (a fresh pin can take a moment to propagate); any other failure moves straight to the next gateway. When every gateway 404s, that's reported as `availability: "not_yet_available"`, distinct from a real hash mismatch - nothing was fetched to compare in the first place.
- `nirium report summary` and `nirium report export anchors|payments|payroll` ([#136](https://github.com/nirium-protocol/nirium/issues/136)): thin client for the Reporting node. `export anchors` prints exactly one CID per line on stdout (nothing else), pipeable straight into `nirium verify`; the row count and the "not certified regulatory reporting" disclaimer print to stderr so they never land in that pipe. `--csv <file>` writes the server's CSV instead; `--json` prints the full response.

## 3.0.1 - 2026-10-24

Expand Down
47 changes: 47 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,53 @@ of the two checks actually ran and succeeded, a matching `--hash`/
was verified, so `verify` prints `NOTHING VERIFIED`, exits non-zero, and
sets `verified: false` in `--json`, instead of reporting a pass.

## Reporting: summaries and exports

```bash
nirium report summary [--from <date>] [--to <date>] [--network <testnet|mainnet>] [--json]
nirium report export anchors|payments|payroll [--from <date>] [--to <date>] [--limit <n>] [--csv out.csv] [--json]
```

Thin client for the Reporting node's `GET /api/reporting/summary` and
`GET /api/reporting/export` - an aggregation layer over what the
protocol has already anchored (payroll runs, x402/MPP receipts, IPFS
anchors). **This is institutional-format output, not certified
regulatory reporting** - anything filed with an authority remains the
caller's own responsibility (the server's own disclaimer is reprinted in
both commands' output).

`report summary` prints settled payroll runs and totals, payment
receipts by gateway, and anchor counts for the period. `report export
<type>` pulls the underlying rows:

- Without `--csv`, rows print one per line on stdout. For `export
anchors` specifically, that line is **just the CID** - nothing else -
so it pipes straight into `nirium verify`:

```bash
nirium report export anchors --from 2026-10-01 | xargs -n1 nirium verify
```

The row count and disclaimer print to **stderr**, not stdout, so they
never end up in that pipe.
- With `--csv <file>`, the server's CSV is written to that file instead
(nothing on stdout but a confirmation).
- `--json` prints the full response (`type`, `period`, `rowCount`,
`rows`, `disclaimer`) instead of one row per line; mutually exclusive
with `--csv`.

| Option (both subcommands) | Purpose | Default |
|---|---|---|
| `--from <date>` / `--to <date>` | ISO date period | 30-day window |
| `--network <testnet\|mainnet>` | restrict to one network | all |
| `-u, --url <url>` | agent URL | `http://localhost:3002` |
| `--json` | machine-readable full response | off |

| Option (`export` only) | Purpose | Default |
|---|---|---|
| `--limit <n>` | max rows (server caps at 1000) | server default |
| `--csv <file>` | write the CSV export here instead of printing rows | off |

## Manage stored credentials

```bash
Expand Down
179 changes: 179 additions & 0 deletions packages/cli/bin/nirium.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,38 @@ program
await executeConfigCommand(action, key, value);
});

// --- COMMAND: report ---
const reportCommand = program
.command('report')
.description('📊 Institutional-format summaries and exports from the Reporting node - not certified regulatory reporting');

reportCommand
.command('summary')
.description('Aggregate payroll, payments, and anchors for a period')
.option('--from <date>', 'ISO date, start of period (default: a 30-day window)')
.option('--to <date>', 'ISO date, end of period')
.option('--network <network>', 'testnet or mainnet (default: all)')
.option('-u, --url <url>', 'Agent URL', 'http://localhost:3002')
.option('--json', 'Output as JSON')
.action(async (options) => {
await executeReportSummaryCommand(options);
});

reportCommand
.command('export')
.argument('<type>', 'anchors, payments, or payroll')
.description('Export rows for a period - "export anchors" prints one CID per line, pipeable to `nirium verify`')
.option('--from <date>', 'ISO date, start of period (default: a 30-day window)')
.option('--to <date>', 'ISO date, end of period')
.option('--limit <n>', 'Max rows (server caps at 1000)')
.option('--network <network>', 'testnet or mainnet (default: all)')
.option('--csv <file>', 'Write the CSV export to this file instead of printing rows')
.option('-u, --url <url>', 'Agent URL', 'http://localhost:3002')
.option('--json', 'Output the full JSON response instead of one row per line')
.action(async (type, options) => {
await executeReportExportCommand(type, options);
});

// Un servidor que COBRA, no uno que escucha. Es el camino corto de
// "instalé algo" a "me pagaron": levantas esto, le pegas con un cliente
// x402 y el pago se liquida on-chain antes de que salga la respuesta.
Expand Down Expand Up @@ -1869,4 +1901,151 @@ async function maybeBuildAgentAttestation(_options, _body, _fail) {
return undefined;
}

// ═══════════════════════════════════════════════════════════════
// report — summaries and exports from the Reporting node
// ═══════════════════════════════════════════════════════════════

function buildQueryString(params) {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
}
const str = qs.toString();
return str ? `?${str}` : '';
}

async function fetchReportJson(url, fail) {
let response;
try {
response = await fetch(url, { signal: AbortSignal.timeout(20_000) });
} catch (err) {
const msg = (err?.name === 'TimeoutError' || err?.name === 'AbortError')
? `Timed out waiting for ${url}`
: `Agent unreachable: ${err?.message || String(err)}`;
fail(msg);
return null;
}
let data = null;
try {
data = await response.json();
} catch { /* empty or non-JSON body */ }
if (!response.ok) {
fail(data?.error || `Agent returned HTTP ${response.status}`);
return null;
}
return data;
}

async function executeReportSummaryCommand(options) {
const fail = (error) => {
if (options.json) {
console.log(JSON.stringify({ ok: false, error }));
} else {
console.error(`❌ Error: ${error}`);
}
process.exit(1);
};

const qs = buildQueryString({ from: options.from, to: options.to, network: options.network });
const data = await fetchReportJson(`${options.url}/api/reporting/summary${qs}`, fail);
if (!data) return; // fail() already exited

if (options.json) {
console.log(JSON.stringify(data, null, 2));
return;
}

console.log('📊 Nirium Reporting Summary');
console.log(` Network: ${data.network}`);
console.log(` Period: ${data.period?.from || '(default window)'} -> ${data.period?.to || 'now'}`);
console.log(` Payroll: ${data.payroll?.settledRuns ?? 0} settled run(s), ${data.payroll?.recipientsPaid ?? 0} recipient(s) paid`);
for (const [asset, amount] of Object.entries(data.payroll?.totalsByAsset || {})) {
console.log(` ${asset}: ${amount}`);
}
console.log(` Payments: ${data.payments?.count ?? 0} receipt(s), ${data.payments?.totalUsdc ?? '0'} USDC total`);
console.log(` Anchors: ${data.anchors?.count ?? 0} (latest: ${data.anchors?.latestCid || 'none'})`);
if (Array.isArray(data.warnings) && data.warnings.length > 0) {
console.log(' ⚠ Warnings:');
for (const w of data.warnings) console.log(` - ${w}`);
}
console.log(`\n ${data.disclaimer || ''}`);
}

const REPORT_EXPORT_TYPES = new Set(['payroll', 'payments', 'anchors']);

async function executeReportExportCommand(type, options) {
const fail = (error) => {
if (options.json) {
console.log(JSON.stringify({ ok: false, error }));
} else {
console.error(`❌ Error: ${error}`);
}
process.exit(1);
};

if (!REPORT_EXPORT_TYPES.has(type)) {
fail('`type` must be one of: payroll | payments | anchors');
return;
}
if (options.csv && options.json) {
fail('--csv and --json are mutually exclusive - --csv writes a file, --json prints the response.');
return;
}
if (options.limit !== undefined) {
const n = Number(options.limit);
if (!Number.isInteger(n) || n <= 0) {
fail(`Invalid --limit '${options.limit}': must be a positive integer.`);
return;
}
}

const format = options.csv ? 'csv' : 'json';
const qs = buildQueryString({
type, format, from: options.from, to: options.to, limit: options.limit, network: options.network,
});
const url = `${options.url}/api/reporting/export${qs}`;

if (format === 'csv') {
let response;
try {
response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
} catch (err) {
fail(`Agent unreachable: ${err?.message || String(err)}`);
return;
}
if (!response.ok) {
let data = null;
try { data = await response.json(); } catch { /* non-JSON error body */ }
fail(data?.error || `Agent returned HTTP ${response.status}`);
return;
}
const text = await response.text();
try {
fs.writeFileSync(options.csv, text);
} catch (err) {
fail(`Could not write --csv '${options.csv}': ${err?.message || String(err)}`);
return;
}
console.log(`✅ Wrote ${options.csv}`);
return;
}

const data = await fetchReportJson(url, fail);
if (!data) return; // fail() already exited

if (options.json) {
console.log(JSON.stringify(data, null, 2));
return;
}

// `export anchors` prints exactly one CID per line on stdout - nothing
// else - so it pipes straight into `nirium verify` (xargs -n1 or a
// while-read loop). Row count and the disclaimer go to stderr instead
// of stdout for the same reason: stdout must stay pure for piping.
for (const row of data.rows || []) {
console.log(type === 'anchors' ? (row.cid || '') : JSON.stringify(row));
}
console.error(`# ${data.rowCount ?? (data.rows || []).length} row(s). ${data.disclaimer || ''}`);
}

program.parse();
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"scripts": {
"prepublishOnly": "node --check bin/nirium.js",
"test": "node --test test/doctor-seller.test.js test/config-secret.test.js test/serve-api-key.test.js test/verify-hash-only.test.js test/verify-retry-gateway.test.js test/pay-agent-construction.test.js test/pay-status-and-cap.test.js test/status.test.js test/anchor.test.js"
"test": "node --test test/doctor-seller.test.js test/config-secret.test.js test/serve-api-key.test.js test/verify-hash-only.test.js test/verify-retry-gateway.test.js test/pay-agent-construction.test.js test/pay-status-and-cap.test.js test/status.test.js test/anchor.test.js test/report.test.js"
},
"keywords": [
"stellar",
Expand Down
Loading
Loading