@slothmoney/agent-cli 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +47 -16
- package/dist/args.js +3 -7
- package/dist/cli.js +128 -34
- package/dist/contracts.js +161 -12
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.0 - 2026-08-25
|
|
4
|
+
|
|
5
|
+
- Add optional `--period YYYY-MM` to `budget status` for current and historical
|
|
6
|
+
Sloth budget periods.
|
|
7
|
+
- Replace signed spending-only status with clear money-in, money-out, and net
|
|
8
|
+
activity for Income, Transfer, None, ordinary, custom, and uncategorized
|
|
9
|
+
transactions.
|
|
10
|
+
- Accept the old current-status response temporarily during the coordinated
|
|
11
|
+
backend rollout; historical responses use the new strict contract.
|
|
12
|
+
- Add a release preflight that compares local, packed, globally installed, and
|
|
13
|
+
npm-registry versions and checks every nested help route.
|
|
14
|
+
|
|
15
|
+
## 0.17.0 - 2026-08-25
|
|
16
|
+
|
|
17
|
+
- Run applied transaction-assignment batches through resumable server
|
|
18
|
+
operations while keeping the existing `assign --input ... --apply` command
|
|
19
|
+
and terminal `succeeded`/`failed` JSON output unchanged.
|
|
20
|
+
- Retry transient submission and status requests with a deterministic
|
|
21
|
+
request key, so re-running an interrupted command resumes the same operation.
|
|
22
|
+
- Strictly validate operation progress, expiry, counts, and ordered terminal
|
|
23
|
+
item receipts before printing an assignment result.
|
|
24
|
+
|
|
3
25
|
## 0.16.0 - 2026-08-21
|
|
4
26
|
|
|
5
27
|
- Add receipt image extraction plus read, preview-by-default attach, and remove
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ sloth-agent --version
|
|
|
15
15
|
For a one-off pinned run:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
npm exec --yes --package=@slothmoney/agent-cli@0.
|
|
18
|
+
npm exec --yes --package=@slothmoney/agent-cli@0.17.0 -- sloth-agent --help
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Authenticate
|
|
@@ -302,6 +302,10 @@ assignment, set `"assignmentScope": "joint"` in the assignment payload and use
|
|
|
302
302
|
|
|
303
303
|
These are placeholders. Do not submit the example values.
|
|
304
304
|
|
|
305
|
+
Each `transactionRef` may appear only once in an assignment file. Split one
|
|
306
|
+
transaction across categories with `categorySplits` instead of adding the same
|
|
307
|
+
transaction twice.
|
|
308
|
+
|
|
305
309
|
4. Preview the assignment without writing:
|
|
306
310
|
|
|
307
311
|
```bash
|
|
@@ -321,7 +325,17 @@ sloth-agent assign --input assignments.json --apply
|
|
|
321
325
|
|
|
322
326
|
This step requires a token created with **Allow changes**.
|
|
323
327
|
|
|
324
|
-
|
|
328
|
+
The CLI submits a durable server operation and polls its authenticated status
|
|
329
|
+
until every item has finished. It then prints the same `succeeded` and `failed`
|
|
330
|
+
arrays as before, so existing agent workflows do not need to change. Inspect
|
|
331
|
+
every item in both arrays.
|
|
332
|
+
|
|
333
|
+
If the command is interrupted or a request times out, re-run the same command
|
|
334
|
+
with the same assignment input. The CLI derives the same request key
|
|
335
|
+
from the validated assignments, so the server resumes the existing operation
|
|
336
|
+
instead of applying the batch again. The server retains operation status and
|
|
337
|
+
item receipts for seven days. Changing the assignments creates a different
|
|
338
|
+
operation.
|
|
325
339
|
|
|
326
340
|
6. Check the result in the same assignment scope that you changed. Successful
|
|
327
341
|
assignments update the category and optional budget line item on the
|
|
@@ -414,20 +428,27 @@ The result includes the budget period and status, currency, the effective plan,
|
|
|
414
428
|
stored funding amounts when available, categories, line items, and planned
|
|
415
429
|
amounts in pence.
|
|
416
430
|
|
|
417
|
-
Read
|
|
418
|
-
transactions yourself:
|
|
431
|
+
Read booked activity for the current or a historical Sloth period:
|
|
419
432
|
|
|
420
433
|
```bash
|
|
421
434
|
sloth-agent budget status --scope personal
|
|
435
|
+
sloth-agent budget status --scope personal --period 2026-07
|
|
422
436
|
```
|
|
423
437
|
|
|
424
|
-
The
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
`
|
|
430
|
-
|
|
438
|
+
The period key names the calendar month containing that Sloth period's start
|
|
439
|
+
boundary. Its end date can fall in the following month.
|
|
440
|
+
|
|
441
|
+
The current period uses Sloth's normal once-per-UTC-day transaction refresh;
|
|
442
|
+
historical periods are cache-only and return `refresh: null`. Activity contains
|
|
443
|
+
nonnegative `moneyInPence` and `moneyOutPence` plus their difference as
|
|
444
|
+
`netPence`. Income, Transfer, explicit None, budget categories, and observed
|
|
445
|
+
custom categories are normal rows. A transaction with no category at all is
|
|
446
|
+
reported separately under `activity.uncategorized`.
|
|
447
|
+
|
|
448
|
+
`budget` contains assigned, spent, and available category amounts when a
|
|
449
|
+
trustworthy period plan exists. It is `null` when it does not; activity still
|
|
450
|
+
returns. The response includes only the period budget currency and silently
|
|
451
|
+
ignores rows in other currencies. This command is read-only.
|
|
431
452
|
|
|
432
453
|
Update selected line-item amounts by creating `budget.json`:
|
|
433
454
|
|
|
@@ -624,9 +645,12 @@ sloth-agent investments --account-ref sloth_account_v1_...
|
|
|
624
645
|
Investment reads are cache-only and do not refresh a brokerage. Holding
|
|
625
646
|
quantities, unit prices, market values, currencies, and freshness are returned
|
|
626
647
|
in provider-native terms. They are not converted or guaranteed to reconcile
|
|
627
|
-
to an account total reported in another currency.
|
|
628
|
-
|
|
629
|
-
|
|
648
|
+
to an account total reported in another currency. An investment account total
|
|
649
|
+
and its nested holdings describe the same portfolio, so do not add them
|
|
650
|
+
together. Do not add values in different currencies without an explicit
|
|
651
|
+
conversion. Caller-owned personal and joint linked investment accounts are
|
|
652
|
+
included; partner-owned accounts, manual holdings, and investment activities
|
|
653
|
+
are not.
|
|
630
654
|
|
|
631
655
|
List your goals:
|
|
632
656
|
|
|
@@ -765,19 +789,26 @@ Command results are JSON on stdout. Diagnostics are written to stderr.
|
|
|
765
789
|
| `2` | Invalid command, option, URL, date, auth input, goal input, or assignment input |
|
|
766
790
|
| `3` | No credential or native secure storage is unavailable |
|
|
767
791
|
|
|
768
|
-
Assignment writes
|
|
769
|
-
returns exit code `1`
|
|
792
|
+
Assignment writes run as durable, best-effort operations. The CLI waits for the
|
|
793
|
+
terminal result and returns exit code `1` when any item failed, while preserving
|
|
794
|
+
the complete `succeeded` and `failed` arrays on stdout. Re-running an interrupted
|
|
795
|
+
command with the same input resumes the same server operation.
|
|
770
796
|
|
|
771
797
|
## Development
|
|
772
798
|
|
|
773
799
|
```bash
|
|
774
800
|
npm ci
|
|
775
801
|
npm run verify
|
|
802
|
+
npm run release:preflight
|
|
776
803
|
```
|
|
777
804
|
|
|
778
805
|
`npm run test:package` packs the exact npm artifact, installs it into a clean
|
|
779
806
|
temporary project, and runs the installed binary.
|
|
780
807
|
|
|
808
|
+
`npm run release:preflight` reports the local, packed, globally installed, and
|
|
809
|
+
npm-registry versions, then exercises every parser command's nested help. It
|
|
810
|
+
fails when a parent help page stops advertising one of its child commands.
|
|
811
|
+
|
|
781
812
|
## Releasing
|
|
782
813
|
|
|
783
814
|
Releases are published only through the trusted `Publish npm release` GitHub
|
package/dist/args.js
CHANGED
|
@@ -486,7 +486,7 @@ function parseBudget(args, baseUrl) {
|
|
|
486
486
|
? 'budget move'
|
|
487
487
|
: 'budget';
|
|
488
488
|
const { values, apply } = parseNamedOptions(args, commandLabel, new Set(status
|
|
489
|
-
? ['--scope']
|
|
489
|
+
? ['--scope', '--period']
|
|
490
490
|
: update
|
|
491
491
|
? ['--scope', '--period', '--input']
|
|
492
492
|
: move
|
|
@@ -498,17 +498,13 @@ function parseBudget(args, baseUrl) {
|
|
|
498
498
|
if (scope !== 'personal' && scope !== 'joint') {
|
|
499
499
|
throw new UsageError('--scope must be personal or joint');
|
|
500
500
|
}
|
|
501
|
-
if (status) {
|
|
502
|
-
return withBaseUrl({
|
|
503
|
-
command: 'budget-status',
|
|
504
|
-
scope: scope,
|
|
505
|
-
}, baseUrl);
|
|
506
|
-
}
|
|
507
501
|
const period = values.get('--period');
|
|
508
502
|
const common = {
|
|
509
503
|
scope: scope,
|
|
510
504
|
...(period === undefined ? {} : { periodKey: parseGoalMonthKey(period, '--period') }),
|
|
511
505
|
};
|
|
506
|
+
if (status)
|
|
507
|
+
return withBaseUrl({ command: 'budget-status', ...common }, baseUrl);
|
|
512
508
|
if (!update && !move)
|
|
513
509
|
return withBaseUrl({ command: 'budget', ...common }, baseUrl);
|
|
514
510
|
if (move) {
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import nodePath from 'node:path';
|
|
3
4
|
import { parseArgs, resolveBaseUrl, } from './args.js';
|
|
4
5
|
import { ICON_KEYS } from './category-metadata.js';
|
|
5
|
-
import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
|
|
6
|
+
import { parseApiResponse, parseAssignmentOperationResponse, toLegacyAssignmentResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
|
|
6
7
|
import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
|
|
7
8
|
import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
|
|
8
|
-
export const CLI_VERSION = '0.
|
|
9
|
+
export const CLI_VERSION = '0.18.0';
|
|
9
10
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
10
11
|
const MAX_CONTRACT_PDF_BYTES = 6_000_000;
|
|
11
12
|
const API_ORIGIN_HELP_LINES = [
|
|
@@ -16,6 +17,8 @@ const API_ORIGIN_HELP_LINES = [
|
|
|
16
17
|
' Use an origin-only URL with no credentials, path, query, or fragment.',
|
|
17
18
|
' HTTPS is required except for localhost development.',
|
|
18
19
|
];
|
|
20
|
+
const ASSIGNMENT_REQUEST_ATTEMPTS = 3;
|
|
21
|
+
const ASSIGNMENT_RETRY_DELAY_MS = 500;
|
|
19
22
|
export function usageText() {
|
|
20
23
|
return [
|
|
21
24
|
'Sloth Agent CLI',
|
|
@@ -32,7 +35,7 @@ export function usageText() {
|
|
|
32
35
|
' sloth-agent accounts remove --account-ref REF [--apply]',
|
|
33
36
|
' sloth-agent investments [--account-ref REF] [--base-url URL]',
|
|
34
37
|
' sloth-agent budget --scope personal|joint [--period YYYY-MM] [--base-url URL]',
|
|
35
|
-
' sloth-agent budget status --scope personal|joint [--base-url URL]',
|
|
38
|
+
' sloth-agent budget status --scope personal|joint [--period YYYY-MM] [--base-url URL]',
|
|
36
39
|
' sloth-agent budget update --scope personal|joint [--period YYYY-MM]',
|
|
37
40
|
' --input budget.json [--apply] [--base-url URL]',
|
|
38
41
|
' sloth-agent budget move --scope personal|joint [--period YYYY-MM]',
|
|
@@ -458,7 +461,7 @@ export function budgetHelpText() {
|
|
|
458
461
|
'',
|
|
459
462
|
'Options:',
|
|
460
463
|
' --scope personal|joint Required. Budget ownership scope.',
|
|
461
|
-
' --period YYYY-MM Optional.
|
|
464
|
+
' --period YYYY-MM Optional. Month containing the period start; defaults to the current Sloth period.',
|
|
462
465
|
' --base-url URL Optional. Override the API origin.',
|
|
463
466
|
' -h, --help Show this help.',
|
|
464
467
|
...API_ORIGIN_HELP_LINES,
|
|
@@ -477,29 +480,28 @@ export function budgetStatusHelpText() {
|
|
|
477
480
|
return [
|
|
478
481
|
'Sloth Agent CLI — budget status',
|
|
479
482
|
'',
|
|
480
|
-
'Read
|
|
483
|
+
'Read booked activity and any trustworthy budget for one Sloth budget period.',
|
|
481
484
|
'',
|
|
482
485
|
'Usage:',
|
|
483
|
-
' sloth-agent budget status --scope personal|joint [--base-url URL]',
|
|
486
|
+
' sloth-agent budget status --scope personal|joint [--period YYYY-MM] [--base-url URL]',
|
|
484
487
|
'',
|
|
485
488
|
'Options:',
|
|
486
489
|
' --scope personal|joint Required. Budget ownership scope.',
|
|
490
|
+
' --period YYYY-MM Optional. Month containing the period start; defaults to the current Sloth period.',
|
|
487
491
|
' --base-url URL Optional. Override the API origin.',
|
|
488
492
|
' -h, --help Show this help.',
|
|
489
493
|
...API_ORIGIN_HELP_LINES,
|
|
490
494
|
'',
|
|
491
495
|
'Access and freshness:',
|
|
492
496
|
' This command is read-only, requires agent:read, and never changes the budget.',
|
|
493
|
-
'
|
|
494
|
-
'
|
|
497
|
+
' Current periods use the normal transaction refresh policy; historical periods are cache-only.',
|
|
498
|
+
' A historical response returns refresh as null.',
|
|
495
499
|
'',
|
|
496
500
|
'Output:',
|
|
497
|
-
' categories
|
|
498
|
-
'
|
|
499
|
-
'
|
|
500
|
-
'
|
|
501
|
-
' activity contains the period dates, transaction count, uncategorizedSpentPence,',
|
|
502
|
-
' and unmappedSpentPence. Review either nonzero value before moving money.',
|
|
501
|
+
' activity.categories includes Income, Transfer, None, and ordinary categories.',
|
|
502
|
+
' moneyInPence and moneyOutPence are nonnegative; netPence is money in minus money out.',
|
|
503
|
+
' A transaction with no category appears under activity.uncategorized.',
|
|
504
|
+
' budget contains the period plan and balances, or budget is null when no trustworthy plan exists.',
|
|
503
505
|
].join('\n');
|
|
504
506
|
}
|
|
505
507
|
export function budgetUpdateHelpText() {
|
|
@@ -654,13 +656,19 @@ export function assignHelpText() {
|
|
|
654
656
|
' the payload it would send. It does not contact Sloth Money, verify the',
|
|
655
657
|
' transactionRef or category values, or write anything.',
|
|
656
658
|
' A successful preview does not guarantee that applying it will succeed.',
|
|
657
|
-
' With --apply,
|
|
658
|
-
'
|
|
659
|
+
' With --apply, the CLI submits one durable server operation, then polls authenticated status',
|
|
660
|
+
' until every item finishes. Transient submission and status failures are retried.',
|
|
661
|
+
' Re-run the same command with the same assignment input after an interruption;',
|
|
662
|
+
' the CLI resumes the same operation instead of duplicating its work.',
|
|
663
|
+
' Operation status and item receipts remain available on the server for seven days.',
|
|
664
|
+
' Assignments are best-effort; any failed item makes the command exit with code 1',
|
|
665
|
+
' while the complete terminal result remains available on stdout.',
|
|
659
666
|
' Applying requires a write-enabled token created with Allow changes.',
|
|
660
667
|
'',
|
|
661
668
|
'Input:',
|
|
662
669
|
' The top-level object must contain an assignments array.',
|
|
663
670
|
' Each assignment requires transactionRef and at least one category operation or sharing object.',
|
|
671
|
+
' Each transactionRef may appear only once in the assignments array.',
|
|
664
672
|
' sharing.isShared is required. shareRatio is optional from 0 to 1 and is your share.',
|
|
665
673
|
' userExclusiveAmountPence and partnerExclusiveAmountPence are optional nonnegative integers.',
|
|
666
674
|
' Omitted split values use saved defaults for a first share and preserve an existing split.',
|
|
@@ -702,7 +710,7 @@ export function assignHelpText() {
|
|
|
702
710
|
'',
|
|
703
711
|
'Output:',
|
|
704
712
|
' Preview mode returns dryRun, endpoint, and the validated payload.',
|
|
705
|
-
' Apply mode returns succeeded and failed
|
|
713
|
+
' Apply mode waits for the durable operation and returns succeeded and failed arrays.',
|
|
706
714
|
' Successful assignments update the original transaction. See the result in',
|
|
707
715
|
' Sloth Money → Transactions or read the transaction again through the CLI.',
|
|
708
716
|
' Assignments do not create a separate list.',
|
|
@@ -953,11 +961,11 @@ export function rulesHelpText() {
|
|
|
953
961
|
'Rules do not create transactions or recurring predictions.',
|
|
954
962
|
'',
|
|
955
963
|
'Commands:',
|
|
956
|
-
' rules list List every saved notification rule.',
|
|
957
|
-
' rules get Read the rule for one transaction.',
|
|
958
|
-
' rules set Preview or save a rule for one transaction.',
|
|
959
|
-
' rules delete Preview or remove a rule.',
|
|
960
|
-
' rules scan-contract Validate or scan a PDF for its renewal date.',
|
|
964
|
+
' sloth-agent rules list List every saved notification rule.',
|
|
965
|
+
' sloth-agent rules get Read the rule for one transaction.',
|
|
966
|
+
' sloth-agent rules set Preview or save a rule for one transaction.',
|
|
967
|
+
' sloth-agent rules delete Preview or remove a rule.',
|
|
968
|
+
' sloth-agent rules scan-contract Validate or scan a PDF for its renewal date.',
|
|
961
969
|
'',
|
|
962
970
|
'Use the exact transactionRef returned by sloth-agent transactions.',
|
|
963
971
|
...API_ORIGIN_HELP_LINES,
|
|
@@ -1407,6 +1415,99 @@ function requestHeaders(token) {
|
|
|
1407
1415
|
'User-Agent': `sloth-agent/${CLI_VERSION}`,
|
|
1408
1416
|
};
|
|
1409
1417
|
}
|
|
1418
|
+
function assignmentIdempotencyKey(payload) {
|
|
1419
|
+
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
|
1420
|
+
}
|
|
1421
|
+
function isRetryableAssignmentRequestError(error) {
|
|
1422
|
+
if (error instanceof ApiError) {
|
|
1423
|
+
return error.status !== undefined
|
|
1424
|
+
&& [408, 425, 429, 499, 500, 502, 503, 504].includes(error.status);
|
|
1425
|
+
}
|
|
1426
|
+
return error instanceof TypeError
|
|
1427
|
+
|| (error instanceof Error && error.name === 'AbortError');
|
|
1428
|
+
}
|
|
1429
|
+
async function withAssignmentRequestRecovery(request, sleep) {
|
|
1430
|
+
let lastError;
|
|
1431
|
+
for (let attempt = 1; attempt <= ASSIGNMENT_REQUEST_ATTEMPTS; attempt += 1) {
|
|
1432
|
+
try {
|
|
1433
|
+
return await request();
|
|
1434
|
+
}
|
|
1435
|
+
catch (error) {
|
|
1436
|
+
lastError = error;
|
|
1437
|
+
if (!isRetryableAssignmentRequestError(error) || attempt === ASSIGNMENT_REQUEST_ATTEMPTS) {
|
|
1438
|
+
throw error;
|
|
1439
|
+
}
|
|
1440
|
+
await sleep(ASSIGNMENT_RETRY_DELAY_MS * attempt);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
throw lastError;
|
|
1444
|
+
}
|
|
1445
|
+
async function parseAssignmentOperationHttpResponse(response, token, expectedStatus) {
|
|
1446
|
+
const data = await parseHttpResponse(response, token);
|
|
1447
|
+
if (response.status !== expectedStatus) {
|
|
1448
|
+
throw new ApiError(`Agent API returned status ${response.status}; expected ${expectedStatus}`, response.status);
|
|
1449
|
+
}
|
|
1450
|
+
return parseAssignmentOperationResponse(data);
|
|
1451
|
+
}
|
|
1452
|
+
function assertAssignmentOperationMatchesPayload(operation, payload, expectedOperationId) {
|
|
1453
|
+
if (operation.itemCount !== payload.assignments.length
|
|
1454
|
+
|| (expectedOperationId !== undefined && operation.operationId !== expectedOperationId)
|
|
1455
|
+
|| (operation.status === 'completed'
|
|
1456
|
+
&& operation.results?.some((result, index) => (result.transactionRef !== payload.assignments[index]?.transactionRef)))) {
|
|
1457
|
+
throw new ApiError('Assignment operation response did not match the submitted assignment order');
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
async function applyAssignments(fetchImplementation, sleep, baseUrl, token, payload) {
|
|
1461
|
+
const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
|
|
1462
|
+
const idempotencyKey = assignmentIdempotencyKey(payload);
|
|
1463
|
+
let operation;
|
|
1464
|
+
try {
|
|
1465
|
+
operation = await withAssignmentRequestRecovery(async () => {
|
|
1466
|
+
const response = await fetchImplementation(endpoint, {
|
|
1467
|
+
method: 'POST',
|
|
1468
|
+
headers: {
|
|
1469
|
+
...requestHeaders(token),
|
|
1470
|
+
'Content-Type': 'application/json',
|
|
1471
|
+
'Idempotency-Key': idempotencyKey,
|
|
1472
|
+
},
|
|
1473
|
+
body: JSON.stringify(payload),
|
|
1474
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1475
|
+
});
|
|
1476
|
+
return parseAssignmentOperationHttpResponse(response, token, 202);
|
|
1477
|
+
}, sleep);
|
|
1478
|
+
}
|
|
1479
|
+
catch (error) {
|
|
1480
|
+
if (!isRetryableAssignmentRequestError(error))
|
|
1481
|
+
throw error;
|
|
1482
|
+
throw new ApiError('Assignment submission could not be confirmed. '
|
|
1483
|
+
+ 'Re-run the same command with the same assignment input to resume it.');
|
|
1484
|
+
}
|
|
1485
|
+
assertAssignmentOperationMatchesPayload(operation, payload);
|
|
1486
|
+
const operationId = operation.operationId;
|
|
1487
|
+
while (operation.status !== 'completed') {
|
|
1488
|
+
await sleep(operation.pollAfterMs);
|
|
1489
|
+
const statusEndpoint = `${endpoint}/${encodeURIComponent(operationId)}`;
|
|
1490
|
+
try {
|
|
1491
|
+
const nextOperation = await withAssignmentRequestRecovery(async () => {
|
|
1492
|
+
const response = await fetchImplementation(statusEndpoint, {
|
|
1493
|
+
method: 'GET',
|
|
1494
|
+
headers: requestHeaders(token),
|
|
1495
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1496
|
+
});
|
|
1497
|
+
return parseAssignmentOperationHttpResponse(response, token, 200);
|
|
1498
|
+
}, sleep);
|
|
1499
|
+
assertAssignmentOperationMatchesPayload(nextOperation, payload, operationId);
|
|
1500
|
+
operation = nextOperation;
|
|
1501
|
+
}
|
|
1502
|
+
catch (error) {
|
|
1503
|
+
if (!isRetryableAssignmentRequestError(error))
|
|
1504
|
+
throw error;
|
|
1505
|
+
throw new ApiError('Assignment status could not be recovered. '
|
|
1506
|
+
+ 'Re-run the same command with the same assignment input to resume it.');
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
return toLegacyAssignmentResponse(operation);
|
|
1510
|
+
}
|
|
1410
1511
|
async function validateCredentialRemotely(fetchImplementation, origin, token) {
|
|
1411
1512
|
const response = await fetchImplementation(`${origin}/api/agent/v1/categories`, {
|
|
1412
1513
|
method: 'GET',
|
|
@@ -1442,6 +1543,9 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1442
1543
|
?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
1443
1544
|
const readSecret = options.readSecret ?? defaultReadSecret;
|
|
1444
1545
|
const readStdin = options.readStdin ?? defaultReadStdin;
|
|
1546
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => {
|
|
1547
|
+
setTimeout(resolve, milliseconds);
|
|
1548
|
+
}));
|
|
1445
1549
|
const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value));
|
|
1446
1550
|
const writeStderr = options.writeStderr ?? ((value) => process.stderr.write(value));
|
|
1447
1551
|
let token;
|
|
@@ -1884,17 +1988,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1884
1988
|
}
|
|
1885
1989
|
if (parsed.command === 'assign') {
|
|
1886
1990
|
const payload = assignmentPayload;
|
|
1887
|
-
const
|
|
1888
|
-
const response = await fetchImplementation(endpoint, {
|
|
1889
|
-
method: 'POST',
|
|
1890
|
-
headers: {
|
|
1891
|
-
...headers,
|
|
1892
|
-
'Content-Type': 'application/json',
|
|
1893
|
-
},
|
|
1894
|
-
body: JSON.stringify(payload),
|
|
1895
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1896
|
-
});
|
|
1897
|
-
const data = parseApiResponse('assign', await parseHttpResponse(response, token));
|
|
1991
|
+
const data = await applyAssignments(fetchImplementation, sleep, baseUrl, token, payload);
|
|
1898
1992
|
writeJson(writeStdout, data);
|
|
1899
1993
|
return hasFailures(data) ? 1 : 0;
|
|
1900
1994
|
}
|
|
@@ -1970,7 +2064,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
1970
2064
|
}
|
|
1971
2065
|
if (parsed.command === 'budget' || parsed.command === 'budget-status') {
|
|
1972
2066
|
const query = new URLSearchParams({ scope: parsed.scope });
|
|
1973
|
-
if (parsed.
|
|
2067
|
+
if (parsed.periodKey !== undefined) {
|
|
1974
2068
|
query.set('periodKey', parsed.periodKey);
|
|
1975
2069
|
}
|
|
1976
2070
|
const response = await fetchImplementation(`${baseUrl}/api/agent/v1/${parsed.command === 'budget' ? 'budgets' : 'budget-status'}?${query.toString()}`, {
|
package/dist/contracts.js
CHANGED
|
@@ -269,7 +269,15 @@ export function validateAssignmentPayload(value) {
|
|
|
269
269
|
if (payload.assignments.length < 1 || payload.assignments.length > 100) {
|
|
270
270
|
throw new UsageError('assignments must contain between 1 and 100 items');
|
|
271
271
|
}
|
|
272
|
-
|
|
272
|
+
const assignments = payload.assignments.map(validateAssignment);
|
|
273
|
+
const transactionRefs = new Set();
|
|
274
|
+
for (const assignment of assignments) {
|
|
275
|
+
if (transactionRefs.has(assignment.transactionRef)) {
|
|
276
|
+
throw new UsageError('Each assignments[].transactionRef must be unique');
|
|
277
|
+
}
|
|
278
|
+
transactionRefs.add(assignment.transactionRef);
|
|
279
|
+
}
|
|
280
|
+
return { assignments };
|
|
273
281
|
}
|
|
274
282
|
export function validateBudgetUpdatePayload(value) {
|
|
275
283
|
const payload = requireObject(value, 'budget update payload');
|
|
@@ -505,6 +513,73 @@ function isAssignmentResponse(value) {
|
|
|
505
513
|
&& typeof item.error === 'string'
|
|
506
514
|
&& (item.transactionRef === undefined || typeof item.transactionRef === 'string'))));
|
|
507
515
|
}
|
|
516
|
+
function isAssignmentOperationResult(value) {
|
|
517
|
+
if (!isObject(value) || typeof value.transactionRef !== 'string')
|
|
518
|
+
return false;
|
|
519
|
+
const { status, ...legacyResult } = value;
|
|
520
|
+
if (status === 'succeeded') {
|
|
521
|
+
return isAssignmentResponse({ succeeded: [legacyResult], failed: [] });
|
|
522
|
+
}
|
|
523
|
+
if (status === 'failed') {
|
|
524
|
+
return isAssignmentResponse({ succeeded: [], failed: [legacyResult] });
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
export function parseAssignmentOperationResponse(value) {
|
|
529
|
+
const validBase = isObject(value)
|
|
530
|
+
&& hasOnlyFields(value, [
|
|
531
|
+
'operationId', 'status', 'itemCount', 'completedCount', 'failedCount',
|
|
532
|
+
'expiresAt', 'pollAfterMs', 'results',
|
|
533
|
+
])
|
|
534
|
+
&& typeof value.operationId === 'string'
|
|
535
|
+
&& /^[a-f0-9]{64}$/.test(value.operationId)
|
|
536
|
+
&& (value.status === 'pending'
|
|
537
|
+
|| value.status === 'processing'
|
|
538
|
+
|| value.status === 'completed')
|
|
539
|
+
&& Number.isSafeInteger(value.itemCount)
|
|
540
|
+
&& Number(value.itemCount) >= 1
|
|
541
|
+
&& Number(value.itemCount) <= 100
|
|
542
|
+
&& isNonnegativeSafeInteger(value.completedCount)
|
|
543
|
+
&& Number(value.completedCount) <= Number(value.itemCount)
|
|
544
|
+
&& isNonnegativeSafeInteger(value.failedCount)
|
|
545
|
+
&& Number(value.failedCount) <= Number(value.completedCount)
|
|
546
|
+
&& isIsoDateTime(value.expiresAt)
|
|
547
|
+
&& isNonnegativeSafeInteger(value.pollAfterMs)
|
|
548
|
+
&& Number(value.pollAfterMs) >= 100
|
|
549
|
+
&& Number(value.pollAfterMs) <= 10_000;
|
|
550
|
+
if (!validBase) {
|
|
551
|
+
throw new ApiError('Invalid assignment operation response from the Agent API');
|
|
552
|
+
}
|
|
553
|
+
const isComplete = value.status === 'completed';
|
|
554
|
+
const resultsAreValid = isComplete
|
|
555
|
+
? (Number(value.completedCount) === Number(value.itemCount)
|
|
556
|
+
&& Array.isArray(value.results)
|
|
557
|
+
&& value.results.length === Number(value.itemCount)
|
|
558
|
+
&& value.results.every(isAssignmentOperationResult)
|
|
559
|
+
&& value.results.filter((result) => result.status === 'failed').length
|
|
560
|
+
=== Number(value.failedCount))
|
|
561
|
+
: value.results === undefined;
|
|
562
|
+
if (!resultsAreValid) {
|
|
563
|
+
throw new ApiError('Invalid assignment operation response from the Agent API');
|
|
564
|
+
}
|
|
565
|
+
return value;
|
|
566
|
+
}
|
|
567
|
+
export function toLegacyAssignmentResponse(value) {
|
|
568
|
+
const operation = parseAssignmentOperationResponse(value);
|
|
569
|
+
if (operation.status !== 'completed' || !operation.results) {
|
|
570
|
+
throw new ApiError('Assignment operation is not complete');
|
|
571
|
+
}
|
|
572
|
+
const succeeded = [];
|
|
573
|
+
const failed = [];
|
|
574
|
+
for (const result of operation.results) {
|
|
575
|
+
const { status, ...legacyResult } = result;
|
|
576
|
+
if (status === 'succeeded')
|
|
577
|
+
succeeded.push(legacyResult);
|
|
578
|
+
else
|
|
579
|
+
failed.push(legacyResult);
|
|
580
|
+
}
|
|
581
|
+
return { succeeded, failed };
|
|
582
|
+
}
|
|
508
583
|
function isHttpUrl(value) {
|
|
509
584
|
if (typeof value !== 'string')
|
|
510
585
|
return false;
|
|
@@ -591,6 +666,12 @@ function isBudgetCategory(value) {
|
|
|
591
666
|
&& Array.isArray(value.lineItems)
|
|
592
667
|
&& value.lineItems.every(isBudgetLineItem));
|
|
593
668
|
}
|
|
669
|
+
function isBudgetFunding(value) {
|
|
670
|
+
return value === null || (isObject(value)
|
|
671
|
+
&& hasOnlyFields(value, ['toAssignPence', 'nextPeriodReservePence'])
|
|
672
|
+
&& isSafeInteger(value.toAssignPence)
|
|
673
|
+
&& isSafeInteger(value.nextPeriodReservePence));
|
|
674
|
+
}
|
|
594
675
|
function isBudgetResponse(value) {
|
|
595
676
|
return (isObject(value)
|
|
596
677
|
&& hasOnlyFields(value, [
|
|
@@ -611,15 +692,11 @@ function isBudgetResponse(value) {
|
|
|
611
692
|
&& isCurrency(value.currency)
|
|
612
693
|
&& typeof value.effectiveFromPeriodKey === 'string'
|
|
613
694
|
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.effectiveFromPeriodKey)
|
|
614
|
-
&& (value.funding
|
|
615
|
-
|| (isObject(value.funding)
|
|
616
|
-
&& hasOnlyFields(value.funding, ['toAssignPence', 'nextPeriodReservePence'])
|
|
617
|
-
&& isSafeInteger(value.funding.toAssignPence)
|
|
618
|
-
&& isSafeInteger(value.funding.nextPeriodReservePence)))
|
|
695
|
+
&& isBudgetFunding(value.funding)
|
|
619
696
|
&& Array.isArray(value.categories)
|
|
620
697
|
&& value.categories.every(isBudgetCategory));
|
|
621
698
|
}
|
|
622
|
-
function
|
|
699
|
+
function isLegacyBudgetStatusResponse(value) {
|
|
623
700
|
if (!isObject(value)
|
|
624
701
|
|| !hasOnlyFields(value, [
|
|
625
702
|
'scope',
|
|
@@ -640,11 +717,7 @@ function isBudgetStatusResponse(value) {
|
|
|
640
717
|
&& isCurrency(value.currency)
|
|
641
718
|
&& typeof value.effectiveFromPeriodKey === 'string'
|
|
642
719
|
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.effectiveFromPeriodKey)
|
|
643
|
-
&& (value.funding
|
|
644
|
-
|| (isObject(value.funding)
|
|
645
|
-
&& hasOnlyFields(value.funding, ['toAssignPence', 'nextPeriodReservePence'])
|
|
646
|
-
&& isSafeInteger(value.funding.toAssignPence)
|
|
647
|
-
&& isSafeInteger(value.funding.nextPeriodReservePence)))
|
|
720
|
+
&& isBudgetFunding(value.funding)
|
|
648
721
|
&& isObject(value.activity)
|
|
649
722
|
&& hasOnlyFields(value.activity, [
|
|
650
723
|
'startDate',
|
|
@@ -681,6 +754,82 @@ function isBudgetStatusResponse(value) {
|
|
|
681
754
|
&& Number.isSafeInteger(category.assignedPence - category.spentPence)
|
|
682
755
|
&& category.availablePence === category.assignedPence - category.spentPence)));
|
|
683
756
|
}
|
|
757
|
+
function isActivityAmounts(value) {
|
|
758
|
+
return isObject(value)
|
|
759
|
+
&& hasOnlyFields(value, ['moneyInPence', 'moneyOutPence', 'netPence'])
|
|
760
|
+
&& isNonnegativeSafeInteger(value.moneyInPence)
|
|
761
|
+
&& isNonnegativeSafeInteger(value.moneyOutPence)
|
|
762
|
+
&& isSafeInteger(value.netPence)
|
|
763
|
+
&& Number.isSafeInteger(value.moneyInPence - value.moneyOutPence)
|
|
764
|
+
&& value.netPence === value.moneyInPence - value.moneyOutPence;
|
|
765
|
+
}
|
|
766
|
+
function isBudgetActivityStatusResponse(value) {
|
|
767
|
+
if (!isObject(value) || !hasOnlyFields(value, [
|
|
768
|
+
'scope', 'periodKey', 'periodStatus', 'currency', 'period', 'refresh', 'activity', 'budget',
|
|
769
|
+
]))
|
|
770
|
+
return false;
|
|
771
|
+
if (!isObject(value.period) || !hasOnlyFields(value.period, [
|
|
772
|
+
'startDate', 'endDate', 'dateRangeSource',
|
|
773
|
+
]))
|
|
774
|
+
return false;
|
|
775
|
+
if (!isObject(value.activity) || !hasOnlyFields(value.activity, [
|
|
776
|
+
'transactionCount', 'categories', 'uncategorized',
|
|
777
|
+
]))
|
|
778
|
+
return false;
|
|
779
|
+
const activityCategories = value.activity.categories;
|
|
780
|
+
if (!Array.isArray(activityCategories) || !activityCategories.every((category) => (isObject(category)
|
|
781
|
+
&& hasOnlyFields(category, ['id', 'name', 'moneyInPence', 'moneyOutPence', 'netPence'])
|
|
782
|
+
&& typeof category.id === 'string'
|
|
783
|
+
&& category.id.trim().length > 0
|
|
784
|
+
&& typeof category.name === 'string'
|
|
785
|
+
&& category.name.trim().length > 0
|
|
786
|
+
&& isActivityAmounts({
|
|
787
|
+
moneyInPence: category.moneyInPence,
|
|
788
|
+
moneyOutPence: category.moneyOutPence,
|
|
789
|
+
netPence: category.netPence,
|
|
790
|
+
}))))
|
|
791
|
+
return false;
|
|
792
|
+
const budget = value.budget;
|
|
793
|
+
if (budget !== null && (!isObject(budget)
|
|
794
|
+
|| !hasOnlyFields(budget, ['effectiveFromPeriodKey', 'funding', 'categories'])
|
|
795
|
+
|| typeof budget.effectiveFromPeriodKey !== 'string'
|
|
796
|
+
|| !/^\d{4}-(0[1-9]|1[0-2])$/.test(budget.effectiveFromPeriodKey)
|
|
797
|
+
|| !isBudgetFunding(budget.funding)
|
|
798
|
+
|| !Array.isArray(budget.categories)
|
|
799
|
+
|| !budget.categories.every((category) => (isObject(category)
|
|
800
|
+
&& hasOnlyFields(category, [
|
|
801
|
+
'id', 'name', 'plannedPence', 'assignedPence', 'spentPence', 'availablePence',
|
|
802
|
+
])
|
|
803
|
+
&& typeof category.id === 'string'
|
|
804
|
+
&& category.id.trim().length > 0
|
|
805
|
+
&& typeof category.name === 'string'
|
|
806
|
+
&& category.name.trim().length > 0
|
|
807
|
+
&& isNonnegativeSafeInteger(category.plannedPence)
|
|
808
|
+
&& isSafeInteger(category.assignedPence)
|
|
809
|
+
&& isSafeInteger(category.spentPence)
|
|
810
|
+
&& isSafeInteger(category.availablePence)
|
|
811
|
+
&& Number.isSafeInteger(category.assignedPence - category.spentPence)
|
|
812
|
+
&& category.availablePence === category.assignedPence - category.spentPence))))
|
|
813
|
+
return false;
|
|
814
|
+
return (value.scope === 'personal' || value.scope === 'joint')
|
|
815
|
+
&& typeof value.periodKey === 'string'
|
|
816
|
+
&& /^\d{4}-(0[1-9]|1[0-2])$/.test(value.periodKey)
|
|
817
|
+
&& (value.periodStatus === 'current' || value.periodStatus === 'historical')
|
|
818
|
+
&& isCurrency(value.currency)
|
|
819
|
+
&& isIsoDate(value.period.startDate)
|
|
820
|
+
&& isIsoDate(value.period.endDate)
|
|
821
|
+
&& value.period.startDate <= value.period.endDate
|
|
822
|
+
&& (value.period.dateRangeSource === 'stored'
|
|
823
|
+
|| value.period.dateRangeSource === 'legacy_settings_fallback')
|
|
824
|
+
&& isNonnegativeSafeInteger(value.activity.transactionCount)
|
|
825
|
+
&& isActivityAmounts(value.activity.uncategorized)
|
|
826
|
+
&& (value.periodStatus === 'historical'
|
|
827
|
+
? value.refresh === null
|
|
828
|
+
: isRefreshStatus(value.refresh));
|
|
829
|
+
}
|
|
830
|
+
function isBudgetStatusResponse(value) {
|
|
831
|
+
return isLegacyBudgetStatusResponse(value) || isBudgetActivityStatusResponse(value);
|
|
832
|
+
}
|
|
684
833
|
function isBudgetMovementResponse(value) {
|
|
685
834
|
return (isObject(value)
|
|
686
835
|
&& hasOnlyFields(value, [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@slothmoney/agent-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Command-line access to the Sloth Money Agent API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"test": "vitest run",
|
|
18
18
|
"test:package": "node scripts/test-package.mjs",
|
|
19
19
|
"test:registry": "node scripts/test-registry-package.mjs",
|
|
20
|
+
"release:preflight": "node scripts/release-preflight.mjs",
|
|
20
21
|
"typecheck": "tsc --noEmit",
|
|
21
22
|
"verify": "npm run lint && npm run typecheck && npm test && npm run build && npm run test:package"
|
|
22
23
|
},
|