nanobazaar-cli 1.0.13 → 1.0.15

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 ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to `nanobazaar-cli` are documented in this file.
4
+
5
+ This project follows Semantic Versioning.
6
+
7
+ ## [1.0.15] - 2026-02-07
8
+
9
+ ### Added
10
+ - `nanobazaar payload list` to list payload metadata for the current bot.
11
+ - `nanobazaar payload fetch` to fetch, decrypt, verify, and cache payloads locally.
12
+ - Automatic payload fetch/decrypt/verify/cache during `nanobazaar poll` and `nanobazaar watch` (disable via `--no-fetch-payloads`).
13
+
14
+ ### Changed
15
+ - `payload fetch --job-id ...` now falls back to querying the relay when local state/event logs are missing or truncated.
16
+
17
+ ### Fixed
18
+ - Avoid rewriting the state file when content is unchanged (prevents mtime-only updates that can spam local wakeups).
19
+
20
+ ## [1.0.14] - 2026-02-05
21
+
22
+ ### Added
23
+ - `nanobazaar poll ack --up-to-event-id <id>` helper to advance the server-side poll cursor (used for 410 resync scenarios).
24
+
25
+ ### Changed
26
+ - `nanobazaar poll` now defaults to relying on the relay's server-side cursor unless `--since-event-id` is explicitly provided.
27
+
28
+ ### Fixed
29
+ - Prevented cursor regressions when multiple processes update local state (server-side `last_acked_event_id` wins).
30
+
31
+ ## [1.0.13] - 2026-02-05
32
+
33
+ ### Added
34
+ - `--debug` (or `NBR_DEBUG=1`) to enable verbose logging for `poll` and `watch`.
35
+ - Support for global flags appearing before the command.
36
+
37
+ ## [1.0.12] - 2026-02-05
38
+
39
+ ### Changed
40
+ - Unified watcher behavior so `nanobazaar watch` can optionally use `fswatch` for local wakeups when available, while continuing to work in SSE-only mode.
41
+
42
+ ## [1.0.11] - 2026-02-05
43
+
44
+ ### Removed
45
+ - CLI cron helpers and related docs.
46
+
47
+ ## [1.0.10] - 2026-02-05
48
+
49
+ ### Changed
50
+ - Version bump for npm release.
51
+
package/README.md CHANGED
@@ -16,3 +16,4 @@ nanobazaar --help
16
16
  See the skill docs for full command behavior and examples.
17
17
 
18
18
  - Skill docs: `skills/nanobazaar/docs/COMMANDS.md`
19
+ - Changelog: `packages/nanobazaar-cli/CHANGELOG.md`
package/bin/nanobazaar CHANGED
@@ -153,7 +153,29 @@ function withStateLock(filePath, fn) {
153
153
 
154
154
  function saveState(filePath, state) {
155
155
  fs.mkdirSync(path.dirname(filePath), {recursive: true});
156
- fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
156
+
157
+ // Avoid touching mtime (and triggering fswatch/OpenClaw wakeups) when state
158
+ // content did not actually change.
159
+ const json = JSON.stringify(state, null, 2);
160
+ try {
161
+ const existing = fs.readFileSync(filePath, 'utf8');
162
+ if (existing === json) {
163
+ // Still enforce file permissions without rewriting the file.
164
+ try {
165
+ const mode = fs.statSync(filePath).mode & 0o777;
166
+ if (mode !== 0o600) {
167
+ fs.chmodSync(filePath, 0o600);
168
+ }
169
+ } catch (_) {
170
+ // ignore stat/chmod errors on unsupported platforms
171
+ }
172
+ return;
173
+ }
174
+ } catch (_) {
175
+ // ignore read errors; we'll (re)create the file below
176
+ }
177
+
178
+ fs.writeFileSync(filePath, json);
157
179
  try {
158
180
  fs.chmodSync(filePath, 0o600);
159
181
  } catch (_) {
@@ -700,6 +722,387 @@ async function signedRequest({method, path, query, body, idempotencyKey, relayUr
700
722
  return {response, data, text};
701
723
  }
702
724
 
725
+ function isPlainObject(value) {
726
+ return !!value && typeof value === 'object' && !Array.isArray(value);
727
+ }
728
+
729
+ function decodeBase64url(value, label) {
730
+ try {
731
+ return Buffer.from(String(value), 'base64url');
732
+ } catch (err) {
733
+ throw new Error(`Invalid base64url for ${label}: ${err.message}`);
734
+ }
735
+ }
736
+
737
+ const PAYLOAD_CACHE_DIRNAME = 'payloads';
738
+ const PAYLOAD_CIPHERTEXT_MAX_BYTES = 64 * 1024; // best-effort cap; relay should enforce on ingest.
739
+
740
+ function ensurePrivateDir(dirPath) {
741
+ fs.mkdirSync(dirPath, {recursive: true});
742
+ try {
743
+ fs.chmodSync(dirPath, 0o700);
744
+ } catch (_) {
745
+ // ignore chmod errors on unsupported platforms
746
+ }
747
+ }
748
+
749
+ function ensurePrivateFile(filePath) {
750
+ try {
751
+ fs.chmodSync(filePath, 0o600);
752
+ } catch (_) {
753
+ // ignore chmod errors on unsupported platforms
754
+ }
755
+ }
756
+
757
+ function resolvePayloadCacheDir(statePath) {
758
+ const dir = path.join(path.dirname(statePath), PAYLOAD_CACHE_DIRNAME);
759
+ ensurePrivateDir(dir);
760
+ return dir;
761
+ }
762
+
763
+ function safePayloadCacheFilename(payloadId) {
764
+ const raw = String(payloadId);
765
+ const trimmed = raw.trim();
766
+ if (trimmed && trimmed.length <= 160 && /^[A-Za-z0-9._-]+$/.test(trimmed)) {
767
+ return `${trimmed}.json`;
768
+ }
769
+ return `payload_${sha256Hex(raw)}.json`;
770
+ }
771
+
772
+ function readJsonFile(filePath, label) {
773
+ try {
774
+ const raw = fs.readFileSync(filePath, 'utf8');
775
+ return JSON.parse(raw);
776
+ } catch (err) {
777
+ throw new Error(`Failed to read ${label} (${filePath}): ${err.message}`);
778
+ }
779
+ }
780
+
781
+ function writeJsonFile(filePath, data, compact) {
782
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
783
+ fs.writeFileSync(filePath, JSON.stringify(data, null, compact ? 0 : 2));
784
+ ensurePrivateFile(filePath);
785
+ }
786
+
787
+ async function fetchBot({botId, config, keys, identity}) {
788
+ const result = await signedRequest({
789
+ method: 'GET',
790
+ path: `/v0/bots/${botId}`,
791
+ relayUrl: config.relay_url,
792
+ keys,
793
+ identity,
794
+ });
795
+ if (!result.response.ok) {
796
+ throw new Error(`Failed to fetch bot (${result.response.status}): ${result.text || result.response.statusText}`);
797
+ }
798
+ if (!isPlainObject(result.data)) {
799
+ throw new Error('Bot response was not an object.');
800
+ }
801
+ return result.data;
802
+ }
803
+
804
+ async function fetchPayloadEnvelope({payloadId, config, keys, identity}) {
805
+ const result = await signedRequest({
806
+ method: 'GET',
807
+ path: `/v0/payloads/${payloadId}`,
808
+ relayUrl: config.relay_url,
809
+ keys,
810
+ identity,
811
+ });
812
+
813
+ if (!result.response.ok) {
814
+ throw new Error(`Payload fetch failed (${result.response.status}): ${result.text || result.response.statusText}`);
815
+ }
816
+
817
+ if (!isPlainObject(result.data)) {
818
+ throw new Error('Payload response was not an object.');
819
+ }
820
+
821
+ const envelope = result.data;
822
+ if (envelope.payload_id && String(envelope.payload_id) !== String(payloadId)) {
823
+ throw new Error(`Payload response payload_id mismatch: expected=${payloadId} got=${envelope.payload_id}`);
824
+ }
825
+ if (envelope.enc_alg && String(envelope.enc_alg) !== ENC_ALG) {
826
+ throw new Error(`Unsupported enc_alg: ${envelope.enc_alg}`);
827
+ }
828
+ return envelope;
829
+ }
830
+
831
+ async function listPayloadMetadata({status, jobId, limit, cursor, config, keys, identity}) {
832
+ const result = await signedRequest({
833
+ method: 'GET',
834
+ path: '/v0/payloads',
835
+ query: {
836
+ status,
837
+ job_id: jobId,
838
+ limit,
839
+ cursor,
840
+ },
841
+ relayUrl: config.relay_url,
842
+ keys,
843
+ identity,
844
+ });
845
+
846
+ if (!result.response.ok) {
847
+ throw new Error(`Payload list failed (${result.response.status}): ${result.text || result.response.statusText}`);
848
+ }
849
+
850
+ if (!isPlainObject(result.data)) {
851
+ throw new Error('Payload list response was not an object.');
852
+ }
853
+
854
+ return result.data;
855
+ }
856
+
857
+ async function decryptPayloadCiphertext({ciphertextB64, keys}) {
858
+ const ciphertext = decodeBase64url(ciphertextB64, 'ciphertext_b64');
859
+ if (ciphertext.length > PAYLOAD_CIPHERTEXT_MAX_BYTES) {
860
+ throw new Error(`ciphertext_b64 too large (${ciphertext.length} bytes decoded).`);
861
+ }
862
+
863
+ const sodium = await loadSodium();
864
+ const publicKey = decodeBase64url(keys.encryption_public_key_b64url, 'encryption_public_key_b64url');
865
+ const privateKey = decodeBase64url(keys.encryption_private_key_b64url, 'encryption_private_key_b64url');
866
+ const plaintext = sodium.crypto_box_seal_open(ciphertext, publicKey, privateKey);
867
+ if (!plaintext) {
868
+ throw new Error('Decryption failed. Are you the intended recipient?');
869
+ }
870
+ return Buffer.from(plaintext);
871
+ }
872
+
873
+ function parseDecryptedPayload(plaintext) {
874
+ const raw = Buffer.from(plaintext).toString('utf8');
875
+ try {
876
+ const parsed = JSON.parse(raw);
877
+ if (!isPlainObject(parsed)) {
878
+ throw new Error('decrypted payload was not an object');
879
+ }
880
+ return parsed;
881
+ } catch (err) {
882
+ const preview = raw.length > 200 ? raw.slice(0, 200) + '...' : raw;
883
+ throw new Error(`Invalid decrypted payload JSON: ${err.message}. Preview: ${preview}`);
884
+ }
885
+ }
886
+
887
+ function canonicalPayloadString(inner) {
888
+ if (!inner || typeof inner !== 'object') {
889
+ throw new Error('Missing inner payload.');
890
+ }
891
+ if (inner.prefix !== 'NBR1') {
892
+ throw new Error(`Invalid payload prefix: ${inner.prefix}`);
893
+ }
894
+ if (typeof inner.body !== 'string') {
895
+ throw new Error('Invalid payload body (expected string).');
896
+ }
897
+ const bodyHash = sha256Hex(inner.body);
898
+ return `NBR1|${inner.payload_id}|${inner.job_id}|${inner.payload_kind}|${inner.sender_bot_id}|${inner.recipient_bot_id}|${inner.created_at}|${bodyHash}`;
899
+ }
900
+
901
+ function verifyPayloadInner({inner, envelope, senderSigningPubkeyEd25519}) {
902
+ if (!isPlainObject(inner)) {
903
+ throw new Error('Invalid decrypted payload (expected object).');
904
+ }
905
+ if (!isPlainObject(envelope)) {
906
+ throw new Error('Invalid payload envelope (expected object).');
907
+ }
908
+
909
+ const requiredInner = ['payload_id', 'job_id', 'payload_kind', 'sender_bot_id', 'recipient_bot_id', 'created_at', 'sender_sig_ed25519', 'prefix'];
910
+ for (const field of requiredInner) {
911
+ if (inner[field] === undefined || inner[field] === null || inner[field] === '') {
912
+ throw new Error(`Decrypted payload missing ${field}.`);
913
+ }
914
+ }
915
+ if (inner.body === undefined || inner.body === null) {
916
+ throw new Error('Decrypted payload missing body.');
917
+ }
918
+
919
+ if (inner.prefix !== 'NBR1') {
920
+ throw new Error(`Invalid payload prefix: ${inner.prefix}`);
921
+ }
922
+
923
+ const matches = [
924
+ ['payload_id', inner.payload_id, envelope.payload_id],
925
+ ['job_id', inner.job_id, envelope.job_id],
926
+ ['payload_kind', inner.payload_kind, envelope.payload_kind],
927
+ ['sender_bot_id', inner.sender_bot_id, envelope.sender_bot_id],
928
+ ['recipient_bot_id', inner.recipient_bot_id, envelope.recipient_bot_id],
929
+ ];
930
+ for (const [field, innerValue, outerValue] of matches) {
931
+ if (innerValue !== outerValue) {
932
+ throw new Error(`Payload ${field} mismatch: inner=${innerValue} outer=${outerValue}`);
933
+ }
934
+ }
935
+
936
+ const signature = decodeBase64url(inner.sender_sig_ed25519, 'sender_sig_ed25519');
937
+ let pubKey;
938
+ try {
939
+ pubKey = crypto.createPublicKey({
940
+ format: 'jwk',
941
+ key: {kty: 'OKP', crv: 'Ed25519', x: String(senderSigningPubkeyEd25519)},
942
+ });
943
+ } catch (err) {
944
+ throw new Error(`Invalid signing_pubkey_ed25519: ${err.message}`);
945
+ }
946
+
947
+ const canonical = canonicalPayloadString(inner);
948
+ const ok = crypto.verify(null, Buffer.from(canonical, 'utf8'), pubKey, signature);
949
+ if (!ok) {
950
+ throw new Error('Invalid sender_sig_ed25519 for decrypted payload.');
951
+ }
952
+ }
953
+
954
+ async function fetchDecryptVerifyPayload({payloadId, config, keys, identity}) {
955
+ const envelope = await fetchPayloadEnvelope({payloadId, config, keys, identity});
956
+ if (!envelope.ciphertext_b64) {
957
+ throw new Error('Payload response missing ciphertext_b64.');
958
+ }
959
+
960
+ const plaintext = await decryptPayloadCiphertext({ciphertextB64: envelope.ciphertext_b64, keys});
961
+ const inner = parseDecryptedPayload(plaintext);
962
+
963
+ const senderBotId = inner.sender_bot_id || envelope.sender_bot_id;
964
+ if (!senderBotId) {
965
+ throw new Error('Missing sender_bot_id for signature verification.');
966
+ }
967
+ const senderBot = await fetchBot({botId: senderBotId, config, keys, identity});
968
+ if (!senderBot.signing_pubkey_ed25519) {
969
+ throw new Error(`Bot ${senderBotId} missing signing_pubkey_ed25519.`);
970
+ }
971
+
972
+ verifyPayloadInner({inner, envelope, senderSigningPubkeyEd25519: senderBot.signing_pubkey_ed25519});
973
+ return {envelope, inner, senderBot};
974
+ }
975
+
976
+ function extractEventType(event) {
977
+ if (!event) {
978
+ return '';
979
+ }
980
+ if (event.event_type) {
981
+ return String(event.event_type);
982
+ }
983
+ if (event.data && event.data.event_type) {
984
+ return String(event.data.event_type);
985
+ }
986
+ return '';
987
+ }
988
+
989
+ function extractEventData(event) {
990
+ if (!event) {
991
+ return {};
992
+ }
993
+ if (isPlainObject(event.data)) {
994
+ return event.data;
995
+ }
996
+ if (isPlainObject(event)) {
997
+ return event;
998
+ }
999
+ return {};
1000
+ }
1001
+
1002
+ function extractPayloadRefsFromEvents(events) {
1003
+ const out = [];
1004
+ if (!Array.isArray(events)) {
1005
+ return out;
1006
+ }
1007
+ for (const event of events) {
1008
+ const eventType = extractEventType(event);
1009
+ const data = extractEventData(event);
1010
+ if (eventType === 'job.payload_available' && data.payload_id) {
1011
+ out.push({payload_id: String(data.payload_id), job_id: data.job_id ? String(data.job_id) : null, payload_kind: data.payload_kind ? String(data.payload_kind) : null});
1012
+ continue;
1013
+ }
1014
+ if (eventType === 'job.requested' && data.request_payload_id) {
1015
+ out.push({payload_id: String(data.request_payload_id), job_id: data.job_id ? String(data.job_id) : null, payload_kind: 'request'});
1016
+ continue;
1017
+ }
1018
+ }
1019
+ return out;
1020
+ }
1021
+
1022
+ async function resolvePayloadIdForJob({jobId, state, config, keys, identity}) {
1023
+ const trimmed = String(jobId).trim();
1024
+ if (!trimmed) {
1025
+ throw new Error('Missing job id.');
1026
+ }
1027
+
1028
+ // 1) Local known payload map.
1029
+ if (state && state.known_payloads) {
1030
+ const map = ensureMap(state.known_payloads, 'payload_id');
1031
+ let best = null;
1032
+ for (const entry of Object.values(map)) {
1033
+ if (!entry || entry.job_id !== trimmed || !entry.payload_id) {
1034
+ continue;
1035
+ }
1036
+ if (!best) {
1037
+ best = entry;
1038
+ continue;
1039
+ }
1040
+ const bestTs = best.created_at ? Date.parse(best.created_at) : NaN;
1041
+ const entryTs = entry.created_at ? Date.parse(entry.created_at) : NaN;
1042
+ if (Number.isFinite(entryTs) && (!Number.isFinite(bestTs) || entryTs > bestTs)) {
1043
+ best = entry;
1044
+ }
1045
+ }
1046
+ if (best && best.payload_id) {
1047
+ return String(best.payload_id);
1048
+ }
1049
+ }
1050
+
1051
+ // 2) Scan local event log (may be truncated).
1052
+ const eventLog = state && Array.isArray(state.event_log) ? state.event_log : [];
1053
+ for (let i = eventLog.length - 1; i >= 0; i -= 1) {
1054
+ const event = eventLog[i];
1055
+ if (!event) {
1056
+ continue;
1057
+ }
1058
+ const eventType = extractEventType(event);
1059
+ const data = extractEventData(event);
1060
+ if (eventType === 'job.payload_available' && data.job_id === trimmed && data.payload_id) {
1061
+ return String(data.payload_id);
1062
+ }
1063
+ if (eventType === 'job.requested' && data.job_id === trimmed && data.request_payload_id) {
1064
+ return String(data.request_payload_id);
1065
+ }
1066
+ }
1067
+
1068
+ // 3) Ask the relay for payload metadata for this job.
1069
+ const listed = await listPayloadMetadata({
1070
+ status: 'all',
1071
+ jobId: trimmed,
1072
+ limit: 25,
1073
+ cursor: undefined,
1074
+ config,
1075
+ keys,
1076
+ identity,
1077
+ });
1078
+ const payloads = Array.isArray(listed.payloads) ? listed.payloads : [];
1079
+ if (payloads.length === 0) {
1080
+ throw new Error(`No payloads found for job ${trimmed}.`);
1081
+ }
1082
+ if (!payloads[0] || !payloads[0].payload_id) {
1083
+ throw new Error(`Relay payload list returned an invalid entry for job ${trimmed}.`);
1084
+ }
1085
+ return String(payloads[0].payload_id);
1086
+ }
1087
+
1088
+ function getCachedPayloadPath(state, payloadId) {
1089
+ const map = ensureMap(state && state.known_payloads ? state.known_payloads : {}, 'payload_id');
1090
+ const entry = map[payloadId];
1091
+ if (!entry || !entry.cached_path) {
1092
+ return null;
1093
+ }
1094
+ const candidate = String(entry.cached_path);
1095
+ return candidate ? candidate : null;
1096
+ }
1097
+
1098
+ function upsertPayloadMeta(state, payloadId, meta) {
1099
+ state.known_payloads = ensureMap(state.known_payloads, 'payload_id');
1100
+ const current = state.known_payloads[payloadId] && typeof state.known_payloads[payloadId] === 'object'
1101
+ ? state.known_payloads[payloadId]
1102
+ : {payload_id: payloadId};
1103
+ state.known_payloads[payloadId] = {...current, ...meta, payload_id: payloadId};
1104
+ }
1105
+
703
1106
  function printHelp() {
704
1107
  console.log(`NanoBazaar CLI (OpenClaw skill)
705
1108
 
@@ -732,11 +1135,17 @@ Commands:
732
1135
  job payment-sent --job-id <id> [--payment-block-hash <hash>]
733
1136
  [--amount-raw-sent <raw>] [--sent-at <rfc3339>] [--note "..."]
734
1137
  Notify seller that payment was sent
735
- poll [--since-event-id <id>] [--limit <n>] [--types a,b] [--no-ack]
1138
+ payload list [--status unfetched|fetched|all] [--job-id <id>] [--limit <n>] [--cursor <cursor>]
1139
+ List payload metadata for the current bot
1140
+ payload fetch (--payload-id <id> | --job-id <id>) [--raw] [--compact] [--no-cache] [--cache-only]
1141
+ Fetch, decrypt, verify, and cache a payload
1142
+ poll [--since-event-id <id>] [--limit <n>] [--types a,b] [--no-ack] [--no-fetch-payloads]
736
1143
  Poll events and optionally ack
1144
+ poll ack --up-to-event-id <id>
1145
+ Advance the server-side poll cursor (used for 410 resync)
737
1146
  watch [--streams a,b] [--stream-path /v0/stream] [--safety-poll-interval <seconds>]
738
1147
  [--state-path <path>] [--openclaw-bin <bin>] [--fswatch-bin <bin>]
739
- [--event-text <text>] [--mode now|next] [--debounce-ms <ms>]
1148
+ [--event-text <text>] [--mode now|next] [--debounce-ms <ms>] [--no-fetch-payloads]
740
1149
  Maintain SSE connection; poll on wake + on safety interval.
741
1150
  If fswatch is available, also watch local state and trigger OpenClaw wakeups.
742
1151
 
@@ -748,7 +1157,9 @@ Global flags:
748
1157
  Notes:
749
1158
  - Defaults to relay: ${DEFAULT_RELAY_URL}
750
1159
  - Uses NBR_STATE_PATH for local state (default: ${STATE_DEFAULT})
1160
+ - Decrypted payloads are cached under: (dirname NBR_STATE_PATH)/${PAYLOAD_CACHE_DIRNAME}/
751
1161
  - Job payloads are encrypted with libsodium (install deps in skills/nanobazaar)
1162
+ - If --since-event-id is omitted, /v0/poll uses the relay's server-side cursor (last_acked_event_id)
752
1163
  `);
753
1164
  }
754
1165
 
@@ -1364,6 +1775,160 @@ async function runJobPaymentSent(argv) {
1364
1775
  printJson(result.data, !!flags.compact);
1365
1776
  }
1366
1777
 
1778
+ async function cachePayloadToDisk({payloadId, envelope, inner, state, statePath, compact}) {
1779
+ const cacheDir = resolvePayloadCacheDir(statePath);
1780
+ const cachePath = path.join(cacheDir, safePayloadCacheFilename(payloadId));
1781
+ writeJsonFile(cachePath, inner, compact);
1782
+
1783
+ const now = new Date().toISOString();
1784
+ upsertPayloadMeta(state, payloadId, {
1785
+ job_id: envelope && envelope.job_id ? envelope.job_id : undefined,
1786
+ payload_kind: envelope && envelope.payload_kind ? envelope.payload_kind : undefined,
1787
+ created_at: envelope && envelope.created_at ? envelope.created_at : undefined,
1788
+ sender_bot_id: envelope && envelope.sender_bot_id ? envelope.sender_bot_id : undefined,
1789
+ recipient_bot_id: envelope && envelope.recipient_bot_id ? envelope.recipient_bot_id : undefined,
1790
+ cached_path: cachePath,
1791
+ verified: true,
1792
+ verified_at: now,
1793
+ fetch_error: null,
1794
+ fetched_at: now,
1795
+ });
1796
+
1797
+ saveStateMerged(statePath, state);
1798
+ return cachePath;
1799
+ }
1800
+
1801
+ async function ensurePayloadCached({payloadId, state, statePath, config, keys, identity, debugLog}) {
1802
+ const existingPath = getCachedPayloadPath(state, payloadId);
1803
+ if (existingPath && fs.existsSync(existingPath)) {
1804
+ return {payload_id: payloadId, cached_path: existingPath, from_cache: true};
1805
+ }
1806
+
1807
+ const now = new Date().toISOString();
1808
+ try {
1809
+ const {envelope, inner} = await fetchDecryptVerifyPayload({payloadId, config, keys, identity});
1810
+ const cachedPath = await cachePayloadToDisk({
1811
+ payloadId,
1812
+ envelope,
1813
+ inner,
1814
+ state,
1815
+ statePath,
1816
+ compact: false,
1817
+ });
1818
+ if (debugLog) {
1819
+ debugLog('payload cached', {payload_id: payloadId, cached_path: cachedPath});
1820
+ }
1821
+ return {payload_id: payloadId, cached_path: cachedPath, from_cache: false};
1822
+ } catch (err) {
1823
+ upsertPayloadMeta(state, payloadId, {
1824
+ fetch_error: err && err.message ? err.message : String(err),
1825
+ fetch_attempted_at: now,
1826
+ });
1827
+ saveStateMerged(statePath, state);
1828
+ if (debugLog) {
1829
+ debugLog('payload cache error', {payload_id: payloadId, error: err && err.message ? err.message : String(err)});
1830
+ }
1831
+ return {payload_id: payloadId, error: err && err.message ? err.message : String(err)};
1832
+ }
1833
+ }
1834
+
1835
+ async function runPayloadList(argv) {
1836
+ const {flags} = parseArgs(argv);
1837
+ const config = buildConfig();
1838
+ const state = loadState(config.state_path);
1839
+ const {keys} = requireKeys(state);
1840
+ const identity = deriveIdentity(keys);
1841
+
1842
+ const status = flags.status ? String(flags.status) : 'unfetched';
1843
+ const jobId = flags.jobId || flags.job || undefined;
1844
+ const limit = flags.limit || undefined;
1845
+ const cursor = flags.cursor || undefined;
1846
+
1847
+ const data = await listPayloadMetadata({
1848
+ status,
1849
+ jobId: jobId ? String(jobId) : undefined,
1850
+ limit,
1851
+ cursor,
1852
+ config,
1853
+ keys,
1854
+ identity,
1855
+ });
1856
+
1857
+ printJson(data, !!flags.compact);
1858
+ }
1859
+
1860
+ async function runPayloadFetch(argv) {
1861
+ const {flags} = parseArgs(argv);
1862
+ const config = buildConfig();
1863
+ const statePath = config.state_path;
1864
+ const state = loadState(statePath);
1865
+ const {keys} = requireKeys(state);
1866
+ const identity = deriveIdentity(keys);
1867
+
1868
+ const cacheEnabled = flags.cache !== false;
1869
+ const cacheOnly = !!flags.cacheOnly;
1870
+
1871
+ let payloadId = flags.payloadId || flags.payload;
1872
+ if (!payloadId && flags.jobId) {
1873
+ payloadId = await resolvePayloadIdForJob({
1874
+ jobId: String(flags.jobId),
1875
+ state,
1876
+ config,
1877
+ keys,
1878
+ identity,
1879
+ });
1880
+ }
1881
+
1882
+ if (!payloadId) {
1883
+ throw new Error('Missing payload id. Provide --payload-id or --job-id.');
1884
+ }
1885
+
1886
+ if (cacheEnabled) {
1887
+ const cachedPath = getCachedPayloadPath(state, String(payloadId));
1888
+ if (cachedPath && fs.existsSync(cachedPath)) {
1889
+ const inner = readJsonFile(cachedPath, 'cached payload');
1890
+ if (flags.raw) {
1891
+ if (inner.body !== undefined) {
1892
+ console.log(typeof inner.body === 'string' ? inner.body : JSON.stringify(inner.body));
1893
+ } else {
1894
+ console.log(JSON.stringify(inner));
1895
+ }
1896
+ return;
1897
+ }
1898
+ printJson(inner, !!flags.compact);
1899
+ return;
1900
+ }
1901
+ if (cacheOnly) {
1902
+ throw new Error(`Payload not cached locally: ${payloadId}`);
1903
+ }
1904
+ } else if (cacheOnly) {
1905
+ throw new Error('--cache-only requires cache to be enabled (omit --no-cache).');
1906
+ }
1907
+
1908
+ const {envelope, inner} = await fetchDecryptVerifyPayload({payloadId: String(payloadId), config, keys, identity});
1909
+ if (cacheEnabled) {
1910
+ await cachePayloadToDisk({
1911
+ payloadId: String(payloadId),
1912
+ envelope,
1913
+ inner,
1914
+ state,
1915
+ statePath,
1916
+ compact: false,
1917
+ });
1918
+ }
1919
+
1920
+ if (flags.raw) {
1921
+ if (inner.body !== undefined) {
1922
+ console.log(typeof inner.body === 'string' ? inner.body : JSON.stringify(inner.body));
1923
+ } else {
1924
+ console.log(JSON.stringify(inner));
1925
+ }
1926
+ return;
1927
+ }
1928
+
1929
+ printJson(inner, !!flags.compact);
1930
+ }
1931
+
1367
1932
  async function runPoll(argv, options) {
1368
1933
  const quiet = options && options.quiet;
1369
1934
  const {flags} = parseArgs(argv);
@@ -1373,7 +1938,10 @@ async function runPoll(argv, options) {
1373
1938
  const {keys} = requireKeys(state);
1374
1939
  const identity = deriveIdentity(keys);
1375
1940
 
1376
- const since = flags.sinceEventId || state.last_acked_event_id;
1941
+ // NOTE: By default, rely on the relay's server-side cursor (last_acked_event_id).
1942
+ // Passing a local cursor can silently skip events if the state file is reused
1943
+ // across relays/bots. Use --since-event-id explicitly to override.
1944
+ const since = flags.sinceEventId;
1377
1945
  const limit = flags.limit || config.poll_limit;
1378
1946
  const types = flags.types || config.poll_types;
1379
1947
 
@@ -1413,7 +1981,7 @@ async function runPoll(argv, options) {
1413
1981
  const addedEvents = appendEvents(state, events);
1414
1982
  debugLog('response', {status: result.response.status, events: events.length, added: addedEvents});
1415
1983
 
1416
- if (typeof state.last_acked_event_id !== 'number') {
1984
+ if (typeof state.last_acked_event_id !== 'number' || !Number.isFinite(state.last_acked_event_id)) {
1417
1985
  state.last_acked_event_id = 0;
1418
1986
  }
1419
1987
  state.relay_url = config.relay_url;
@@ -1422,6 +1990,27 @@ async function runPoll(argv, options) {
1422
1990
  state.encryption_kid = identity.encryptionKid;
1423
1991
  saveStateMerged(config.state_path, state);
1424
1992
 
1993
+ const fetchPayloads = flags.fetchPayloads !== false;
1994
+ if (fetchPayloads && events.length > 0) {
1995
+ const refs = extractPayloadRefsFromEvents(events);
1996
+ if (refs.length > 0) {
1997
+ debugLog('payloads detected', {count: refs.length, payload_ids: refs.map((ref) => ref.payload_id)});
1998
+ for (const ref of refs) {
1999
+ // Best-effort caching: poll remains useful even if a payload decrypt fails.
2000
+ // Errors are persisted in known_payloads to aid follow-up debugging.
2001
+ await ensurePayloadCached({
2002
+ payloadId: ref.payload_id,
2003
+ state,
2004
+ statePath: config.state_path,
2005
+ config,
2006
+ keys,
2007
+ identity,
2008
+ debugLog,
2009
+ });
2010
+ }
2011
+ }
2012
+ }
2013
+
1425
2014
  let ackedId = state.last_acked_event_id || 0;
1426
2015
  let maxEventId = 0;
1427
2016
  if (events.length > 0 && flags.ack !== false) {
@@ -1448,9 +2037,16 @@ async function runPoll(argv, options) {
1448
2037
  }
1449
2038
  debugLog('ack ok', {last_acked_event_id: ackedId});
1450
2039
 
1451
- if (typeof ackedId === 'number' && ackedId !== state.last_acked_event_id) {
2040
+ if (typeof ackedId === 'number' && Number.isFinite(ackedId) && ackedId !== state.last_acked_event_id) {
1452
2041
  state.last_acked_event_id = ackedId;
1453
- saveStateMerged(config.state_path, state);
2042
+ // Write the authoritative server-side cursor as-is (even if it moves "backwards"
2043
+ // compared to a stale local file).
2044
+ saveStateMerged(config.state_path, state, {
2045
+ mergeLastAcked: false,
2046
+ mergeStreamCursors: true,
2047
+ mergeKnownMaps: true,
2048
+ mergeEventLog: true,
2049
+ });
1454
2050
  }
1455
2051
  }
1456
2052
 
@@ -1463,6 +2059,56 @@ async function runPoll(argv, options) {
1463
2059
  return {events, ackedId, maxEventId};
1464
2060
  }
1465
2061
 
2062
+ async function runPollAck(argv) {
2063
+ const {flags, positionals} = parseArgs(argv);
2064
+ const value = flags.upToEventId ?? flags.upTo ?? flags.eventId ?? flags.id ?? positionals[0];
2065
+ if (value === undefined || value === null || String(value).trim() === '') {
2066
+ throw new Error('Missing up_to_event_id. Provide --up-to-event-id or a positional id.');
2067
+ }
2068
+ const parsed = Number(String(value));
2069
+ if (!Number.isFinite(parsed) || parsed < 0) {
2070
+ throw new Error(`Invalid up_to_event_id: ${value}`);
2071
+ }
2072
+ const upToEventId = Math.floor(parsed);
2073
+
2074
+ const config = buildConfig();
2075
+ const state = loadState(config.state_path);
2076
+ const {keys} = requireKeys(state);
2077
+ const identity = deriveIdentity(keys);
2078
+
2079
+ const result = await signedRequest({
2080
+ method: 'POST',
2081
+ path: '/v0/poll/ack',
2082
+ body: {up_to_event_id: upToEventId},
2083
+ idempotencyKey: crypto.randomBytes(16).toString('hex'),
2084
+ relayUrl: config.relay_url,
2085
+ keys,
2086
+ identity,
2087
+ });
2088
+
2089
+ if (!result.response.ok) {
2090
+ throw new Error(`Ack failed (${result.response.status}): ${result.text || result.response.statusText}`);
2091
+ }
2092
+
2093
+ const ackedId = result.data && typeof result.data.last_acked_event_id === 'number'
2094
+ ? result.data.last_acked_event_id
2095
+ : upToEventId;
2096
+
2097
+ state.last_acked_event_id = ackedId;
2098
+ state.relay_url = config.relay_url;
2099
+ state.bot_id = identity.botId;
2100
+ state.signing_kid = identity.signingKid;
2101
+ state.encryption_kid = identity.encryptionKid;
2102
+ saveStateMerged(config.state_path, state, {
2103
+ mergeLastAcked: false,
2104
+ mergeStreamCursors: true,
2105
+ mergeKnownMaps: true,
2106
+ mergeEventLog: true,
2107
+ });
2108
+
2109
+ printJson(result.data, !!flags.compact);
2110
+ }
2111
+
1466
2112
  function parseCsv(value) {
1467
2113
  if (value === undefined || value === null) {
1468
2114
  return [];
@@ -1968,6 +2614,25 @@ async function runWatch(argv) {
1968
2614
  saveStateMerged(statePath, state);
1969
2615
  }
1970
2616
 
2617
+ const fetchPayloads = flags.fetchPayloads !== false;
2618
+ if (fetchPayloads && allEvents.length > 0) {
2619
+ const refs = extractPayloadRefsFromEvents(allEvents);
2620
+ if (refs.length > 0) {
2621
+ debugLog('payloads detected', {count: refs.length, payload_ids: refs.map((ref) => ref.payload_id)});
2622
+ for (const ref of refs) {
2623
+ await ensurePayloadCached({
2624
+ payloadId: ref.payload_id,
2625
+ state,
2626
+ statePath,
2627
+ config,
2628
+ keys,
2629
+ identity,
2630
+ debugLog,
2631
+ });
2632
+ }
2633
+ }
2634
+ }
2635
+
1971
2636
  let ackedStreams = 0;
1972
2637
  if (flags.ack !== false) {
1973
2638
  for (const entry of results) {
@@ -2169,6 +2834,55 @@ async function runWatch(argv) {
2169
2834
  stateWatcher.stop();
2170
2835
  }
2171
2836
 
2837
+ const DISPATCH_BOOL_FLAGS = new Set([
2838
+ 'compact',
2839
+ 'raw',
2840
+ 'cache-only',
2841
+ 'print-polls',
2842
+ 'fetch-payloads',
2843
+ 'skip-register',
2844
+ ]);
2845
+
2846
+ function splitSubcommand(argv, allowed) {
2847
+ const allowedSet = new Set(allowed || []);
2848
+ let i = 0;
2849
+ while (i < argv.length) {
2850
+ const arg = argv[i];
2851
+ if (!arg || arg === '-') {
2852
+ i += 1;
2853
+ continue;
2854
+ }
2855
+ if (arg === '--') {
2856
+ const candidate = argv[i + 1];
2857
+ if (candidate && allowedSet.has(candidate)) {
2858
+ return {sub: candidate, argv: argv.slice(0, i + 1).concat(argv.slice(i + 2))};
2859
+ }
2860
+ return {sub: null, argv};
2861
+ }
2862
+ if (arg.startsWith('--')) {
2863
+ const eq = arg.indexOf('=');
2864
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
2865
+ if (name.startsWith('no-') || eq !== -1 || DISPATCH_BOOL_FLAGS.has(name)) {
2866
+ i += 1;
2867
+ continue;
2868
+ }
2869
+ const next = argv[i + 1];
2870
+ if (next && (!next.startsWith('-') || next === '-')) {
2871
+ i += 2;
2872
+ continue;
2873
+ }
2874
+ i += 1;
2875
+ continue;
2876
+ }
2877
+ if (allowedSet.has(arg)) {
2878
+ return {sub: arg, argv: argv.slice(0, i).concat(argv.slice(i + 1))};
2879
+ }
2880
+ // First positional is not a recognized subcommand.
2881
+ return {sub: null, argv};
2882
+ }
2883
+ return {sub: null, argv};
2884
+ }
2885
+
2172
2886
  function loadVersion() {
2173
2887
  try {
2174
2888
  for (const name of ['package.json', 'skill.json']) {
@@ -2189,7 +2903,26 @@ function loadVersion() {
2189
2903
  }
2190
2904
 
2191
2905
  async function main() {
2192
- const argv = process.argv.slice(2);
2906
+ let argv = process.argv.slice(2);
2907
+ // Treat --debug as a global flag (it can appear anywhere without stealing positionals).
2908
+ let debugValue = null;
2909
+ const filtered = [];
2910
+ for (const arg of argv) {
2911
+ if (arg === '--debug') {
2912
+ debugValue = '1';
2913
+ continue;
2914
+ }
2915
+ if (arg.startsWith('--debug=')) {
2916
+ debugValue = arg.slice('--debug='.length) || '1';
2917
+ continue;
2918
+ }
2919
+ filtered.push(arg);
2920
+ }
2921
+ if (debugValue !== null) {
2922
+ process.env.NBR_DEBUG = debugValue;
2923
+ argv = filtered;
2924
+ }
2925
+
2193
2926
  if (argv.length === 0) {
2194
2927
  printHelp();
2195
2928
  return;
@@ -2236,38 +2969,62 @@ async function main() {
2236
2969
  await runMarket(rest);
2237
2970
  return;
2238
2971
  case 'offer': {
2239
- const sub = rest[0];
2972
+ const {sub, argv: subArgv} = splitSubcommand(rest, ['create', 'cancel']);
2240
2973
  if (sub === 'create') {
2241
- await runOfferCreate(rest.slice(1));
2974
+ await runOfferCreate(subArgv);
2242
2975
  return;
2243
2976
  }
2244
2977
  if (sub === 'cancel') {
2245
- await runOfferCancel(rest.slice(1));
2978
+ await runOfferCancel(subArgv);
2246
2979
  return;
2247
2980
  }
2248
2981
  throw new Error('Unknown offer command. Use: offer create|cancel');
2249
2982
  }
2250
2983
  case 'job': {
2251
- const sub = rest[0];
2984
+ const {sub, argv: subArgv} = splitSubcommand(rest, [
2985
+ 'create',
2986
+ 'reissue-request',
2987
+ 'reissue-charge',
2988
+ 'payment-sent',
2989
+ ]);
2252
2990
  if (sub === 'create') {
2253
- await runJobCreate(rest.slice(1));
2991
+ await runJobCreate(subArgv);
2254
2992
  return;
2255
2993
  }
2256
2994
  if (sub === 'reissue-request') {
2257
- await runJobReissueRequest(rest.slice(1));
2995
+ await runJobReissueRequest(subArgv);
2258
2996
  return;
2259
2997
  }
2260
2998
  if (sub === 'reissue-charge') {
2261
- await runJobReissueCharge(rest.slice(1));
2999
+ await runJobReissueCharge(subArgv);
2262
3000
  return;
2263
3001
  }
2264
3002
  if (sub === 'payment-sent') {
2265
- await runJobPaymentSent(rest.slice(1));
3003
+ await runJobPaymentSent(subArgv);
2266
3004
  return;
2267
3005
  }
2268
3006
  throw new Error('Unknown job command. Use: job create|reissue-request|reissue-charge|payment-sent');
2269
3007
  }
3008
+ case 'payload': {
3009
+ const {sub, argv: subArgv} = splitSubcommand(rest, ['list', 'fetch']);
3010
+ if (sub === 'list') {
3011
+ await runPayloadList(subArgv);
3012
+ return;
3013
+ }
3014
+ if (sub === 'fetch') {
3015
+ await runPayloadFetch(subArgv);
3016
+ return;
3017
+ }
3018
+ throw new Error('Unknown payload command. Use: payload list|fetch');
3019
+ }
2270
3020
  case 'poll':
3021
+ {
3022
+ const split = splitSubcommand(rest, ['ack']);
3023
+ if (split.sub === 'ack') {
3024
+ await runPollAck(split.argv);
3025
+ return;
3026
+ }
3027
+ }
2271
3028
  await runPoll(rest);
2272
3029
  return;
2273
3030
  case 'watch':
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "nanobazaar-cli",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "NanoBazaar CLI for the NanoBazaar Relay and OpenClaw skill.",
5
+ "homepage": "https://github.com/nanobazaar/nanobazaar/tree/main/packages/nanobazaar-cli#readme",
5
6
  "license": "UNLICENSED",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/nanobazaar/nanobazaar.git",
10
+ "directory": "packages/nanobazaar-cli"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/nanobazaar/nanobazaar/issues"
14
+ },
6
15
  "bin": {
7
- "nanobazaar": "./bin/nanobazaar"
16
+ "nanobazaar": "bin/nanobazaar"
8
17
  },
9
18
  "engines": {
10
19
  "node": ">=18"
@@ -12,7 +21,8 @@
12
21
  "files": [
13
22
  "bin/",
14
23
  "tools/",
15
- "README.md"
24
+ "README.md",
25
+ "CHANGELOG.md"
16
26
  ],
17
27
  "dependencies": {
18
28
  "libsodium-wrappers": "^0.7.11"