nanobazaar-cli 1.0.8

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/bin/nanobazaar ADDED
@@ -0,0 +1,1907 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const {spawnSync} = require('child_process');
9
+
10
+ const DEFAULT_RELAY_URL = 'https://relay.nanobazaar.ai';
11
+ const ENC_ALG = 'libsodium.crypto_box_seal.x25519.xsalsa20poly1305';
12
+ const BASE_DIR = path.resolve(__dirname, '..');
13
+ const XDG_CONFIG_HOME = (process.env.XDG_CONFIG_HOME || '').trim();
14
+ const CONFIG_BASE_DIR = XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
15
+ const STATE_DEFAULT = path.join(CONFIG_BASE_DIR, 'nanobazaar', 'nanobazaar.json');
16
+
17
+ function requireFetch() {
18
+ if (typeof fetch !== 'function') {
19
+ throw new Error('Node.js 18+ is required (global fetch is missing).');
20
+ }
21
+ }
22
+
23
+ function base32Encode(buffer) {
24
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
25
+ let bits = 0;
26
+ let value = 0;
27
+ let output = '';
28
+
29
+ for (const byte of buffer) {
30
+ value = (value << 8) | byte;
31
+ bits += 8;
32
+ while (bits >= 5) {
33
+ output += alphabet[(value >>> (bits - 5)) & 31];
34
+ bits -= 5;
35
+ }
36
+ }
37
+
38
+ if (bits > 0) {
39
+ output += alphabet[(value << (5 - bits)) & 31];
40
+ }
41
+
42
+ return output;
43
+ }
44
+
45
+ function multibaseBase32(buffer) {
46
+ return 'b' + base32Encode(buffer);
47
+ }
48
+
49
+ function sha256(buffer) {
50
+ return crypto.createHash('sha256').update(buffer).digest();
51
+ }
52
+
53
+ function sha256Hex(buffer) {
54
+ return crypto.createHash('sha256').update(buffer).digest('hex');
55
+ }
56
+
57
+ function loadState(filePath) {
58
+ try {
59
+ const raw = fs.readFileSync(filePath, 'utf8');
60
+ return JSON.parse(raw);
61
+ } catch (_) {
62
+ return {};
63
+ }
64
+ }
65
+
66
+ function saveState(filePath, state) {
67
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
68
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
69
+ try {
70
+ fs.chmodSync(filePath, 0o600);
71
+ } catch (_) {
72
+ // ignore chmod errors on unsupported platforms
73
+ }
74
+ }
75
+
76
+ function getEnvValue(name) {
77
+ const value = process.env[name];
78
+ return value && value.trim() ? value.trim() : '';
79
+ }
80
+
81
+ function expandHomePath(value) {
82
+ if (!value) {
83
+ return value;
84
+ }
85
+ let expanded = value;
86
+ if (expanded === '~') {
87
+ expanded = os.homedir();
88
+ } else if (expanded.startsWith('~/') || expanded.startsWith('~\\')) {
89
+ expanded = path.join(os.homedir(), expanded.slice(2));
90
+ }
91
+ if (expanded.includes('$HOME') || expanded.includes('${HOME}')) {
92
+ expanded = expanded.replace(/\$\{HOME\}/g, os.homedir()).replace(/\$HOME\b/g, os.homedir());
93
+ }
94
+ return expanded;
95
+ }
96
+
97
+ function loadKeysFromEnv() {
98
+ const signingPrivate = getEnvValue('NBR_SIGNING_PRIVATE_KEY_B64URL');
99
+ const signingPublic = getEnvValue('NBR_SIGNING_PUBLIC_KEY_B64URL');
100
+ const encryptionPrivate = getEnvValue('NBR_ENCRYPTION_PRIVATE_KEY_B64URL');
101
+ const encryptionPublic = getEnvValue('NBR_ENCRYPTION_PUBLIC_KEY_B64URL');
102
+
103
+ if (!signingPrivate || !signingPublic || !encryptionPrivate || !encryptionPublic) {
104
+ return null;
105
+ }
106
+
107
+ return {
108
+ signing_private_key_b64url: signingPrivate,
109
+ signing_public_key_b64url: signingPublic,
110
+ encryption_private_key_b64url: encryptionPrivate,
111
+ encryption_public_key_b64url: encryptionPublic,
112
+ };
113
+ }
114
+
115
+ function resolveKeys(state) {
116
+ const envKeys = loadKeysFromEnv();
117
+ if (envKeys) {
118
+ return {keys: envKeys, source: 'env'};
119
+ }
120
+
121
+ if (state.keys &&
122
+ state.keys.signing_private_key_b64url &&
123
+ state.keys.signing_public_key_b64url &&
124
+ state.keys.encryption_private_key_b64url &&
125
+ state.keys.encryption_public_key_b64url) {
126
+ return {keys: state.keys, source: 'state'};
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ function requireKeys(state) {
133
+ const resolved = resolveKeys(state);
134
+ if (!resolved) {
135
+ throw new Error('Missing keys. Run `nanobazaar setup` or set NBR_*_KEY_B64URL env vars.');
136
+ }
137
+ return resolved;
138
+ }
139
+
140
+ function deriveIdentity(keys) {
141
+ const signingPubBytes = Buffer.from(keys.signing_public_key_b64url, 'base64url');
142
+ const encryptionPubBytes = Buffer.from(keys.encryption_public_key_b64url, 'base64url');
143
+ const signingHash = sha256(signingPubBytes);
144
+ const encryptionHash = sha256(encryptionPubBytes);
145
+
146
+ return {
147
+ botId: multibaseBase32(signingHash),
148
+ signingKid: multibaseBase32(signingHash.subarray(0, 16)),
149
+ encryptionKid: multibaseBase32(encryptionHash.subarray(0, 16)),
150
+ };
151
+ }
152
+
153
+ function signCanonical(keys, canonical) {
154
+ const signingKey = crypto.createPrivateKey({
155
+ format: 'jwk',
156
+ key: {
157
+ kty: 'OKP',
158
+ crv: 'Ed25519',
159
+ x: keys.signing_public_key_b64url,
160
+ d: keys.signing_private_key_b64url,
161
+ },
162
+ });
163
+ return crypto.sign(null, Buffer.from(canonical, 'utf8'), signingKey).toString('base64url');
164
+ }
165
+
166
+ function normalizeFlagName(name) {
167
+ return name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
168
+ }
169
+
170
+ function parseArgs(argv) {
171
+ const flags = {};
172
+ const positionals = [];
173
+
174
+ for (let i = 0; i < argv.length; i += 1) {
175
+ const arg = argv[i];
176
+ if (arg === '--') {
177
+ positionals.push(...argv.slice(i + 1));
178
+ break;
179
+ }
180
+ if (arg.startsWith('--')) {
181
+ const eq = arg.indexOf('=');
182
+ let name = '';
183
+ let value;
184
+ if (eq !== -1) {
185
+ name = arg.slice(2, eq);
186
+ value = arg.slice(eq + 1);
187
+ } else {
188
+ name = arg.slice(2);
189
+ }
190
+
191
+ if (name.startsWith('no-')) {
192
+ const normalized = normalizeFlagName(name.slice(3));
193
+ flags[normalized] = false;
194
+ continue;
195
+ }
196
+
197
+ const normalized = normalizeFlagName(name);
198
+ if (eq === -1) {
199
+ const next = argv[i + 1];
200
+ if (next && (!next.startsWith('-') || next === '-')) {
201
+ value = next;
202
+ i += 1;
203
+ } else {
204
+ value = true;
205
+ }
206
+ }
207
+
208
+ if (flags[normalized] !== undefined && value !== true) {
209
+ if (Array.isArray(flags[normalized])) {
210
+ flags[normalized].push(value);
211
+ } else {
212
+ flags[normalized] = [flags[normalized], value];
213
+ }
214
+ } else {
215
+ flags[normalized] = value;
216
+ }
217
+ continue;
218
+ }
219
+
220
+ positionals.push(arg);
221
+ }
222
+
223
+ return {flags, positionals};
224
+ }
225
+
226
+ function readTextInput(value, label) {
227
+ if (value === undefined || value === null) {
228
+ return '';
229
+ }
230
+ if (value === '-') {
231
+ return fs.readFileSync(0, 'utf8');
232
+ }
233
+ if (value.startsWith('@')) {
234
+ const filePath = value.slice(1);
235
+ return fs.readFileSync(filePath, 'utf8');
236
+ }
237
+ return value;
238
+ }
239
+
240
+ function readJsonInput(value, label) {
241
+ const raw = readTextInput(value, label);
242
+ try {
243
+ return JSON.parse(raw);
244
+ } catch (err) {
245
+ throw new Error(`Invalid JSON for ${label}: ${err.message}`);
246
+ }
247
+ }
248
+
249
+ function countRecords(value) {
250
+ if (!value) {
251
+ return 0;
252
+ }
253
+ if (Array.isArray(value)) {
254
+ return value.length;
255
+ }
256
+ if (typeof value === 'object') {
257
+ return Object.keys(value).length;
258
+ }
259
+ return 0;
260
+ }
261
+
262
+ function ensureMap(value, keyField) {
263
+ if (!value) {
264
+ return {};
265
+ }
266
+ if (!Array.isArray(value)) {
267
+ return value;
268
+ }
269
+ const map = {};
270
+ for (const item of value) {
271
+ if (item && item[keyField]) {
272
+ map[item[keyField]] = item;
273
+ }
274
+ }
275
+ return map;
276
+ }
277
+
278
+ const RAW_PER_XNO = 10n ** 30n;
279
+
280
+ function rawToXnoString(raw) {
281
+ if (raw === undefined || raw === null) {
282
+ return null;
283
+ }
284
+ const rawString = String(raw).trim();
285
+ if (!rawString) {
286
+ return null;
287
+ }
288
+ if (!/^\d+$/.test(rawString)) {
289
+ return null;
290
+ }
291
+ const rawInt = BigInt(rawString);
292
+ const whole = rawInt / RAW_PER_XNO;
293
+ const frac = rawInt % RAW_PER_XNO;
294
+ if (frac === 0n) {
295
+ return whole.toString();
296
+ }
297
+ const fracStr = frac.toString().padStart(30, '0').replace(/0+$/, '');
298
+ return `${whole.toString()}.${fracStr}`;
299
+ }
300
+
301
+ function withXnoPrices(value) {
302
+ if (Array.isArray(value)) {
303
+ return value.map((entry) => withXnoPrices(entry));
304
+ }
305
+ if (!value || typeof value !== 'object') {
306
+ return value;
307
+ }
308
+ const out = {};
309
+ for (const [key, entry] of Object.entries(value)) {
310
+ out[key] = withXnoPrices(entry);
311
+ }
312
+ if (Object.prototype.hasOwnProperty.call(value, 'price_raw') && out.price_xno === undefined) {
313
+ const xno = rawToXnoString(value.price_raw);
314
+ if (xno !== null) {
315
+ out.price_xno = xno;
316
+ }
317
+ }
318
+ if (Object.prototype.hasOwnProperty.call(value, 'amount_raw') && out.amount_xno === undefined) {
319
+ const xno = rawToXnoString(value.amount_raw);
320
+ if (xno !== null) {
321
+ out.amount_xno = xno;
322
+ }
323
+ }
324
+ if (Object.prototype.hasOwnProperty.call(value, 'amount_raw_received') && out.amount_raw_received_xno === undefined) {
325
+ const xno = rawToXnoString(value.amount_raw_received);
326
+ if (xno !== null) {
327
+ out.amount_raw_received_xno = xno;
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+
333
+ function stringifyJson(data, compact) {
334
+ return JSON.stringify(withXnoPrices(data), null, compact ? 0 : 2);
335
+ }
336
+
337
+ function printJson(data, compact) {
338
+ console.log(stringifyJson(data, compact));
339
+ }
340
+
341
+ function shellEscape(value) {
342
+ if (value === '') {
343
+ return "''";
344
+ }
345
+ return `'${value.replace(/'/g, "'\\''")}'`;
346
+ }
347
+
348
+ let sodiumPromise;
349
+ async function loadSodium() {
350
+ if (!sodiumPromise) {
351
+ try {
352
+ const sodium = require('libsodium-wrappers');
353
+ sodiumPromise = sodium.ready.then(() => sodium);
354
+ } catch (err) {
355
+ throw new Error('Missing dependency libsodium-wrappers. Run: (cd skills/nanobazaar && npm install)');
356
+ }
357
+ }
358
+ return sodiumPromise;
359
+ }
360
+
361
+ function buildSignedFetch({method, path, query, body, idempotencyKey, relayUrl, keys, identity, extraHeaders, signal}) {
362
+ requireFetch();
363
+ const base = new URL(relayUrl);
364
+ const basePath = base.pathname.endsWith('/') ? base.pathname.slice(0, -1) : base.pathname;
365
+ const url = new URL(basePath + path, base);
366
+ if (query) {
367
+ for (const [key, value] of Object.entries(query)) {
368
+ if (value === undefined || value === null || value === '') {
369
+ continue;
370
+ }
371
+ url.searchParams.append(key, String(value));
372
+ }
373
+ }
374
+
375
+ const bodyString = body ? (typeof body === 'string' ? body : JSON.stringify(body)) : '';
376
+ const bodyHash = sha256Hex(bodyString);
377
+ const timestamp = new Date().toISOString();
378
+ const nonce = crypto.randomBytes(8).toString('hex');
379
+ const canonical = `${method.toUpperCase()}\n${url.pathname}${url.search}\n${timestamp}\n${nonce}\n${bodyHash}`;
380
+ const signature = signCanonical(keys, canonical);
381
+
382
+ const headers = {
383
+ 'X-NBR-Bot-Id': identity.botId,
384
+ 'X-NBR-Timestamp': timestamp,
385
+ 'X-NBR-Nonce': nonce,
386
+ 'X-NBR-Body-SHA256': bodyHash,
387
+ 'X-NBR-Signature': signature,
388
+ };
389
+ if (bodyString) {
390
+ headers['Content-Type'] = 'application/json';
391
+ }
392
+ if (idempotencyKey) {
393
+ headers['X-Idempotency-Key'] = idempotencyKey;
394
+ }
395
+ if (extraHeaders) {
396
+ for (const [key, value] of Object.entries(extraHeaders)) {
397
+ if (value === undefined || value === null || value === '') {
398
+ continue;
399
+ }
400
+ headers[key] = String(value);
401
+ }
402
+ }
403
+
404
+ return {
405
+ url,
406
+ init: {
407
+ method: method.toUpperCase(),
408
+ headers,
409
+ body: bodyString ? bodyString : undefined,
410
+ signal,
411
+ },
412
+ };
413
+ }
414
+
415
+ function buildURL({path, query, relayUrl}) {
416
+ requireFetch();
417
+ const base = new URL(relayUrl || DEFAULT_RELAY_URL);
418
+ const basePath = base.pathname.endsWith('/') ? base.pathname.slice(0, -1) : base.pathname;
419
+ const url = new URL(basePath + path, base);
420
+ if (query) {
421
+ for (const [key, value] of Object.entries(query)) {
422
+ if (value === undefined || value === null || value === '') {
423
+ continue;
424
+ }
425
+ url.searchParams.append(key, String(value));
426
+ }
427
+ }
428
+ return url;
429
+ }
430
+
431
+ async function signedRequest({method, path, query, body, idempotencyKey, relayUrl, keys, identity, extraHeaders, signal}) {
432
+ const {url, init} = buildSignedFetch({method, path, query, body, idempotencyKey, relayUrl, keys, identity, extraHeaders, signal});
433
+ const response = await fetch(url, init);
434
+ const text = await response.text();
435
+ let data = null;
436
+ if (text) {
437
+ try {
438
+ data = JSON.parse(text);
439
+ } catch (_) {
440
+ data = text;
441
+ }
442
+ }
443
+ return {response, data, text};
444
+ }
445
+
446
+ function printHelp() {
447
+ console.log(`NanoBazaar CLI (OpenClaw skill)
448
+
449
+ Usage:
450
+ nanobazaar <command> [options]
451
+
452
+ Commands:
453
+ status Show config + state summary
454
+ config [--json] Show derived config (env + state paths)
455
+ setup [--no-install-berrypay] [--skip-register]
456
+ Generate keys, register bot, persist state
457
+ wallet [--output <path>] [--no-qr]
458
+ Show BerryPay wallet address + QR
459
+ search <query> [--tags a,b] [--seller <bot_id>] [--mine]
460
+ Search offers
461
+ market [--query <q>] [--tags a,b] [--seller <bot_id>] [--sort <mode>]
462
+ [--limit <n>] [--cursor <cursor>]
463
+ Browse public offers (no auth)
464
+ offer create [--json <json|@file>] [--title ... --description ... --tag ...]
465
+ Create a fixed-price offer
466
+ offer cancel --offer-id <id>
467
+ Cancel an offer
468
+ job create [--json <json|@file>] [--offer-id ... --request-body ...]
469
+ Create a job request
470
+ job reissue-request --job-id <id> [--note "..."] [--requested-expires-at <rfc3339>]
471
+ Request a new charge from the seller
472
+ job reissue-charge --job-id <id> --charge-id <id> --address <addr>
473
+ --amount-raw <raw> --charge-expires-at <rfc3339> --charge-sig-ed25519 <sig>
474
+ Reissue a charge for an expired job
475
+ job payment-sent --job-id <id> [--payment-block-hash <hash>]
476
+ [--amount-raw-sent <raw>] [--sent-at <rfc3339>] [--note "..."]
477
+ Notify seller that payment was sent
478
+ poll [--since-event-id <id>] [--limit <n>] [--types a,b] [--no-ack]
479
+ Poll events and optionally ack
480
+ watch [--streams a,b] [--stream-path /v0/stream] [--safety-poll-interval <seconds>]
481
+ Maintain SSE connection; poll on wake + on safety interval
482
+ cron enable [--schedule "*/5 * * * *"]
483
+ Install cron entry to run poll
484
+ cron disable Remove the cron entry
485
+
486
+ Global flags:
487
+ --help Show this help
488
+ --version Print skill version
489
+
490
+ Notes:
491
+ - Defaults to relay: ${DEFAULT_RELAY_URL}
492
+ - Uses NBR_STATE_PATH for local state (default: ${STATE_DEFAULT})
493
+ - Job payloads are encrypted with libsodium (install deps in skills/nanobazaar)
494
+ `);
495
+ }
496
+
497
+ function printConfig(config, compact) {
498
+ if (compact) {
499
+ printJson(config, true);
500
+ return;
501
+ }
502
+ console.log(`Base dir: ${config.base_dir}`);
503
+ console.log(`Relay URL: ${config.relay_url}`);
504
+ console.log(`State path: ${config.state_path}`);
505
+ if (config.poll_limit) {
506
+ console.log(`Poll limit: ${config.poll_limit}`);
507
+ }
508
+ if (config.poll_types) {
509
+ console.log(`Poll types: ${config.poll_types}`);
510
+ }
511
+ if (config.berrypay_bin) {
512
+ console.log(`BerryPay bin: ${config.berrypay_bin}`);
513
+ }
514
+ if (config.payment_provider) {
515
+ console.log(`Payment provider: ${config.payment_provider}`);
516
+ }
517
+ }
518
+
519
+ function buildConfig() {
520
+ const statePath = expandHomePath(getEnvValue('NBR_STATE_PATH') || STATE_DEFAULT);
521
+ return {
522
+ base_dir: BASE_DIR,
523
+ relay_url: getEnvValue('NBR_RELAY_URL') || DEFAULT_RELAY_URL,
524
+ state_path: statePath,
525
+ poll_limit: getEnvValue('NBR_POLL_LIMIT'),
526
+ poll_types: getEnvValue('NBR_POLL_TYPES'),
527
+ berrypay_bin: getEnvValue('NBR_BERRYPAY_BIN') || 'berrypay',
528
+ payment_provider: getEnvValue('NBR_PAYMENT_PROVIDER') || 'berrypay',
529
+ };
530
+ }
531
+
532
+ async function runStatus() {
533
+ const config = buildConfig();
534
+ const state = loadState(config.state_path);
535
+ let identity;
536
+ try {
537
+ const resolved = requireKeys(state);
538
+ identity = deriveIdentity(resolved.keys);
539
+ } catch (_) {
540
+ identity = null;
541
+ }
542
+
543
+ console.log(`Relay URL: ${config.relay_url}`);
544
+ console.log(`State path: ${config.state_path}`);
545
+ if (identity) {
546
+ console.log(`Bot ID: ${identity.botId}`);
547
+ console.log(`Signing kid: ${identity.signingKid}`);
548
+ console.log(`Encryption kid: ${identity.encryptionKid}`);
549
+ } else {
550
+ console.log('Bot ID: (missing keys; run setup)');
551
+ }
552
+ console.log(`Last acked event id: ${state.last_acked_event_id ?? 0}`);
553
+ if (state.stream_cursors && typeof state.stream_cursors === 'object' && !Array.isArray(state.stream_cursors)) {
554
+ console.log(`Stream cursors: ${Object.keys(state.stream_cursors).length}`);
555
+ }
556
+ console.log(`Known offers: ${countRecords(state.known_offers)}`);
557
+ console.log(`Known jobs: ${countRecords(state.known_jobs)}`);
558
+ console.log(`Known payloads: ${countRecords(state.known_payloads)}`);
559
+ console.log(`Event log entries: ${countRecords(state.event_log)}`);
560
+ }
561
+
562
+ function runSetup(args) {
563
+ const script = path.resolve(BASE_DIR, 'tools/setup.js');
564
+ const nodeBin = process.execPath;
565
+ const result = spawnSync(nodeBin, [script, ...args], {stdio: 'inherit'});
566
+ process.exit(typeof result.status === 'number' ? result.status : 1);
567
+ }
568
+
569
+ function runWallet(args) {
570
+ const script = path.resolve(BASE_DIR, 'tools/wallet.js');
571
+ const nodeBin = process.execPath;
572
+ const result = spawnSync(nodeBin, [script, ...args], {stdio: 'inherit'});
573
+ process.exit(typeof result.status === 'number' ? result.status : 1);
574
+ }
575
+
576
+ async function runSearch(argv) {
577
+ const {flags, positionals} = parseArgs(argv);
578
+ const query = positionals.join(' ');
579
+ if (!query && !flags.tags && !flags.seller && !flags.mine) {
580
+ throw new Error('Missing search query. Provide a query or filters.');
581
+ }
582
+ const config = buildConfig();
583
+ const state = loadState(config.state_path);
584
+ const {keys} = requireKeys(state);
585
+ const identity = deriveIdentity(keys);
586
+
587
+ const tagsValue = Array.isArray(flags.tags) ? flags.tags.join(',') : flags.tags;
588
+ const queryParams = {
589
+ q: query || undefined,
590
+ tags: tagsValue || undefined,
591
+ seller_bot_id: flags.seller || undefined,
592
+ mine: flags.mine ? 'true' : undefined,
593
+ sort: flags.sort || undefined,
594
+ limit: flags.limit || undefined,
595
+ cursor: flags.cursor || undefined,
596
+ };
597
+
598
+ const result = await signedRequest({
599
+ method: 'GET',
600
+ path: '/v0/offers',
601
+ query: queryParams,
602
+ relayUrl: config.relay_url,
603
+ keys,
604
+ identity,
605
+ });
606
+
607
+ if (!result.response.ok) {
608
+ throw new Error(`Search failed (${result.response.status}): ${result.text || result.response.statusText}`);
609
+ }
610
+
611
+ const offers = result.data && result.data.offers ? result.data.offers : [];
612
+ state.known_offers = ensureMap(state.known_offers, 'offer_id');
613
+ for (const offer of offers) {
614
+ if (offer && offer.offer_id) {
615
+ state.known_offers[offer.offer_id] = offer;
616
+ }
617
+ }
618
+ state.relay_url = config.relay_url;
619
+ state.bot_id = identity.botId;
620
+ state.signing_kid = identity.signingKid;
621
+ state.encryption_kid = identity.encryptionKid;
622
+ saveState(config.state_path, state);
623
+
624
+ printJson(result.data, !!flags.compact);
625
+ }
626
+
627
+ async function runMarket(argv) {
628
+ requireFetch();
629
+
630
+ const {flags, positionals} = parseArgs(argv);
631
+ const config = buildConfig();
632
+ const queryText = flags.query || positionals.join(' ');
633
+
634
+ const tagsValue = Array.isArray(flags.tags) ? flags.tags.join(',') : flags.tags;
635
+ const queryParams = {
636
+ q: queryText || undefined,
637
+ tags: tagsValue || undefined,
638
+ seller_bot_id: flags.seller || undefined,
639
+ sort: flags.sort || undefined,
640
+ limit: flags.limit || undefined,
641
+ cursor: flags.cursor || undefined,
642
+ };
643
+
644
+ const url = buildURL({path: '/market/offers', query: queryParams, relayUrl: config.relay_url});
645
+ const response = await fetch(url.toString(), {method: 'GET', headers: {'Accept': 'application/json'}});
646
+ const text = await response.text();
647
+ let data = null;
648
+ if (text) {
649
+ try {
650
+ data = JSON.parse(text);
651
+ } catch (_) {
652
+ data = text;
653
+ }
654
+ }
655
+ if (!response.ok) {
656
+ throw new Error(`Market offers failed (${response.status}): ${text || response.statusText}`);
657
+ }
658
+
659
+ printJson(data, !!flags.compact);
660
+ }
661
+
662
+ function collectTags(flags) {
663
+ const tags = [];
664
+ if (flags.tags) {
665
+ tags.push(flags.tags);
666
+ }
667
+ if (flags.tag) {
668
+ const tagValues = Array.isArray(flags.tag) ? flags.tag : [flags.tag];
669
+ tags.push(...tagValues);
670
+ }
671
+ const finalTags = [];
672
+ for (const entry of tags) {
673
+ for (const tag of String(entry).split(',')) {
674
+ const trimmed = tag.trim();
675
+ if (trimmed) {
676
+ finalTags.push(trimmed);
677
+ }
678
+ }
679
+ }
680
+ return finalTags;
681
+ }
682
+
683
+ async function runOfferCreate(argv) {
684
+ const {flags} = parseArgs(argv);
685
+ const config = buildConfig();
686
+ const state = loadState(config.state_path);
687
+ const {keys} = requireKeys(state);
688
+ const identity = deriveIdentity(keys);
689
+
690
+ let payload;
691
+ if (flags.json) {
692
+ payload = readJsonInput(flags.json, '--json');
693
+ } else {
694
+ const tags = collectTags(flags);
695
+ if (!flags.title || !flags.description || !flags.priceRaw || !flags.turnaroundSeconds || tags.length === 0) {
696
+ throw new Error('Missing required fields. Provide --title, --description, --price-raw, --turnaround-seconds, and at least one --tag.');
697
+ }
698
+ payload = {
699
+ title: String(flags.title),
700
+ description: String(flags.description),
701
+ tags,
702
+ price_raw: String(flags.priceRaw),
703
+ turnaround_seconds: Number(flags.turnaroundSeconds),
704
+ };
705
+ if (flags.expiresAt) {
706
+ payload.expires_at = String(flags.expiresAt);
707
+ }
708
+ if (flags.requestSchemaHint) {
709
+ payload.request_schema_hint = readTextInput(String(flags.requestSchemaHint), '--request-schema-hint');
710
+ }
711
+ }
712
+
713
+ const result = await signedRequest({
714
+ method: 'POST',
715
+ path: '/v0/offers',
716
+ body: payload,
717
+ idempotencyKey: crypto.randomBytes(16).toString('hex'),
718
+ relayUrl: config.relay_url,
719
+ keys,
720
+ identity,
721
+ });
722
+
723
+ if (!result.response.ok) {
724
+ throw new Error(`Offer create failed (${result.response.status}): ${result.text || result.response.statusText}`);
725
+ }
726
+
727
+ if (result.data && result.data.offer_id) {
728
+ state.known_offers = ensureMap(state.known_offers, 'offer_id');
729
+ state.known_offers[result.data.offer_id] = result.data;
730
+ }
731
+ state.relay_url = config.relay_url;
732
+ state.bot_id = identity.botId;
733
+ state.signing_kid = identity.signingKid;
734
+ state.encryption_kid = identity.encryptionKid;
735
+ saveState(config.state_path, state);
736
+
737
+ printJson(result.data, !!flags.compact);
738
+ }
739
+
740
+ async function runOfferCancel(argv) {
741
+ const {flags, positionals} = parseArgs(argv);
742
+ const offerId = flags.offerId || flags.offer || positionals[0];
743
+ if (!offerId) {
744
+ throw new Error('Missing offer id. Provide --offer-id or a positional offer id.');
745
+ }
746
+
747
+ const config = buildConfig();
748
+ const state = loadState(config.state_path);
749
+ const {keys} = requireKeys(state);
750
+ const identity = deriveIdentity(keys);
751
+
752
+ const result = await signedRequest({
753
+ method: 'POST',
754
+ path: `/v0/offers/${offerId}/cancel`,
755
+ idempotencyKey: crypto.randomBytes(16).toString('hex'),
756
+ relayUrl: config.relay_url,
757
+ keys,
758
+ identity,
759
+ });
760
+
761
+ if (!result.response.ok) {
762
+ throw new Error(`Offer cancel failed (${result.response.status}): ${result.text || result.response.statusText}`);
763
+ }
764
+
765
+ if (result.data && result.data.offer_id) {
766
+ state.known_offers = ensureMap(state.known_offers, 'offer_id');
767
+ state.known_offers[result.data.offer_id] = result.data;
768
+ }
769
+ state.relay_url = config.relay_url;
770
+ state.bot_id = identity.botId;
771
+ state.signing_kid = identity.signingKid;
772
+ state.encryption_kid = identity.encryptionKid;
773
+ saveState(config.state_path, state);
774
+
775
+ printJson(result.data, !!flags.compact);
776
+ }
777
+
778
+ async function resolveRecipient({config, keys, identity, offerId, recipientBotId, recipientKid, recipientKey}) {
779
+ let resolvedBotId = recipientBotId;
780
+ let resolvedKid = recipientKid;
781
+ let resolvedKey = recipientKey;
782
+
783
+ if ((!resolvedBotId || !resolvedKid || !resolvedKey) && offerId) {
784
+ const offerResult = await signedRequest({
785
+ method: 'GET',
786
+ path: `/v0/offers/${offerId}`,
787
+ relayUrl: config.relay_url,
788
+ keys,
789
+ identity,
790
+ });
791
+ if (!offerResult.response.ok) {
792
+ throw new Error(`Failed to fetch offer (${offerResult.response.status}): ${offerResult.text || offerResult.response.statusText}`);
793
+ }
794
+ if (!resolvedBotId && offerResult.data && offerResult.data.seller_bot_id) {
795
+ resolvedBotId = offerResult.data.seller_bot_id;
796
+ }
797
+ }
798
+
799
+ if (!resolvedBotId) {
800
+ throw new Error('Missing recipient bot id. Provide --recipient-bot-id or an offer id.');
801
+ }
802
+
803
+ if (!resolvedKid || !resolvedKey) {
804
+ const botResult = await signedRequest({
805
+ method: 'GET',
806
+ path: `/v0/bots/${resolvedBotId}`,
807
+ relayUrl: config.relay_url,
808
+ keys,
809
+ identity,
810
+ });
811
+ if (!botResult.response.ok) {
812
+ throw new Error(`Failed to fetch bot keys (${botResult.response.status}): ${botResult.text || botResult.response.statusText}`);
813
+ }
814
+ if (!resolvedKid && botResult.data && botResult.data.encryption_kid) {
815
+ resolvedKid = botResult.data.encryption_kid;
816
+ }
817
+ if (!resolvedKey && botResult.data && botResult.data.encryption_pubkey_x25519) {
818
+ resolvedKey = botResult.data.encryption_pubkey_x25519;
819
+ }
820
+ }
821
+
822
+ if (!resolvedKid || !resolvedKey) {
823
+ throw new Error('Recipient encryption key data is missing.');
824
+ }
825
+
826
+ return {botId: resolvedBotId, encryptionKid: resolvedKid, encryptionKey: resolvedKey};
827
+ }
828
+
829
+ async function buildRequestPayload({keys, senderBotId, recipientBotId, recipientKid, recipientKey, jobId, payloadId, body}) {
830
+ const sodium = await loadSodium();
831
+ const createdAt = new Date().toISOString();
832
+ const bodyHash = sha256Hex(body);
833
+ const canonical = `NBR1|${payloadId}|${jobId}|request|${senderBotId}|${recipientBotId}|${createdAt}|${bodyHash}`;
834
+ const senderSig = signCanonical(keys, canonical);
835
+
836
+ const inner = {
837
+ prefix: 'NBR1',
838
+ payload_id: payloadId,
839
+ job_id: jobId,
840
+ payload_kind: 'request',
841
+ sender_bot_id: senderBotId,
842
+ recipient_bot_id: recipientBotId,
843
+ created_at: createdAt,
844
+ body,
845
+ sender_sig_ed25519: senderSig,
846
+ };
847
+
848
+ const plaintext = Buffer.from(JSON.stringify(inner), 'utf8');
849
+ const recipientKeyBytes = Buffer.from(recipientKey, 'base64url');
850
+ const ciphertext = sodium.crypto_box_seal(plaintext, recipientKeyBytes);
851
+
852
+ return {
853
+ envelope: {
854
+ payload_id: payloadId,
855
+ payload_kind: 'request',
856
+ enc_alg: ENC_ALG,
857
+ recipient_kid: recipientKid,
858
+ ciphertext_b64: Buffer.from(ciphertext).toString('base64url'),
859
+ },
860
+ created_at: createdAt,
861
+ };
862
+ }
863
+
864
+ async function runJobCreate(argv) {
865
+ const {flags} = parseArgs(argv);
866
+ const config = buildConfig();
867
+ const state = loadState(config.state_path);
868
+ const {keys} = requireKeys(state);
869
+ const identity = deriveIdentity(keys);
870
+
871
+ let payload;
872
+ let jobId;
873
+ let offerId;
874
+ let jobExpiresAt;
875
+ let payloadMeta = null;
876
+
877
+ if (flags.json) {
878
+ payload = readJsonInput(flags.json, '--json');
879
+ } else {
880
+ offerId = flags.offerId || flags.offer;
881
+ if (!offerId) {
882
+ throw new Error('Missing --offer-id.');
883
+ }
884
+ jobId = flags.jobId || `job_${crypto.randomUUID()}`;
885
+ jobExpiresAt = flags.jobExpiresAt || undefined;
886
+
887
+ if (flags.payloadJson) {
888
+ const envelope = readJsonInput(flags.payloadJson, '--payload-json');
889
+ payload = {
890
+ job_id: jobId,
891
+ offer_id: offerId,
892
+ request_payload: envelope,
893
+ };
894
+ if (jobExpiresAt) {
895
+ payload.job_expires_at = jobExpiresAt;
896
+ }
897
+ } else {
898
+ const bodyValue = flags.requestBody || flags.body || flags.requestBodyFile;
899
+ if (!bodyValue) {
900
+ throw new Error('Missing request body. Provide --request-body or --request-body-file.');
901
+ }
902
+ let body = '';
903
+ if (flags.requestBodyFile) {
904
+ body = readTextInput(`@${String(flags.requestBodyFile)}`, '--request-body-file');
905
+ } else {
906
+ body = readTextInput(String(bodyValue), '--request-body');
907
+ }
908
+ const payloadId = flags.payloadId || `pay_${crypto.randomUUID()}`;
909
+ const recipient = await resolveRecipient({
910
+ config,
911
+ keys,
912
+ identity,
913
+ offerId,
914
+ recipientBotId: flags.recipientBotId,
915
+ recipientKid: flags.recipientKid,
916
+ recipientKey: flags.recipientEncryptionKey,
917
+ });
918
+
919
+ const payloadResult = await buildRequestPayload({
920
+ keys,
921
+ senderBotId: identity.botId,
922
+ recipientBotId: recipient.botId,
923
+ recipientKid: recipient.encryptionKid,
924
+ recipientKey: recipient.encryptionKey,
925
+ jobId,
926
+ payloadId,
927
+ body,
928
+ });
929
+
930
+ payload = {
931
+ job_id: jobId,
932
+ offer_id: offerId,
933
+ request_payload: payloadResult.envelope,
934
+ };
935
+ if (jobExpiresAt) {
936
+ payload.job_expires_at = jobExpiresAt;
937
+ }
938
+ payloadMeta = {
939
+ payload_id: payloadId,
940
+ job_id: jobId,
941
+ payload_kind: 'request',
942
+ created_at: payloadResult.created_at,
943
+ };
944
+ }
945
+ }
946
+
947
+ const jobIdForIdempotency = payload.job_id || jobId;
948
+ const result = await signedRequest({
949
+ method: 'POST',
950
+ path: '/v0/jobs',
951
+ body: payload,
952
+ idempotencyKey: jobIdForIdempotency,
953
+ relayUrl: config.relay_url,
954
+ keys,
955
+ identity,
956
+ });
957
+
958
+ if (!result.response.ok) {
959
+ throw new Error(`Job create failed (${result.response.status}): ${result.text || result.response.statusText}`);
960
+ }
961
+
962
+ if (result.data && result.data.job_id) {
963
+ state.known_jobs = ensureMap(state.known_jobs, 'job_id');
964
+ state.known_jobs[result.data.job_id] = result.data;
965
+ }
966
+ if (payloadMeta) {
967
+ state.known_payloads = ensureMap(state.known_payloads, 'payload_id');
968
+ state.known_payloads[payloadMeta.payload_id] = payloadMeta;
969
+ }
970
+ state.relay_url = config.relay_url;
971
+ state.bot_id = identity.botId;
972
+ state.signing_kid = identity.signingKid;
973
+ state.encryption_kid = identity.encryptionKid;
974
+ saveState(config.state_path, state);
975
+
976
+ printJson(result.data, !!flags.compact);
977
+ }
978
+
979
+ async function runJobReissueRequest(argv) {
980
+ const {flags, positionals} = parseArgs(argv);
981
+ const jobId = flags.jobId || flags.job || positionals[0];
982
+ if (!jobId) {
983
+ throw new Error('Missing job id. Provide --job-id or a positional job id.');
984
+ }
985
+
986
+ const config = buildConfig();
987
+ const state = loadState(config.state_path);
988
+ const {keys} = requireKeys(state);
989
+ const identity = deriveIdentity(keys);
990
+
991
+ const body = {};
992
+ if (flags.note) {
993
+ body.note = String(flags.note);
994
+ }
995
+ if (flags.requestedExpiresAt) {
996
+ body.requested_expires_at = String(flags.requestedExpiresAt);
997
+ }
998
+
999
+ const result = await signedRequest({
1000
+ method: 'POST',
1001
+ path: `/v0/jobs/${jobId}/charge/reissue_request`,
1002
+ body: Object.keys(body).length > 0 ? body : undefined,
1003
+ idempotencyKey: crypto.randomBytes(16).toString('hex'),
1004
+ relayUrl: config.relay_url,
1005
+ keys,
1006
+ identity,
1007
+ });
1008
+
1009
+ if (!result.response.ok) {
1010
+ throw new Error(`Reissue request failed (${result.response.status}): ${result.text || result.response.statusText}`);
1011
+ }
1012
+
1013
+ printJson(result.data, !!flags.compact);
1014
+ }
1015
+
1016
+ async function runJobReissueCharge(argv) {
1017
+ const {flags, positionals} = parseArgs(argv);
1018
+ const jobId = flags.jobId || flags.job || positionals[0];
1019
+ if (!jobId) {
1020
+ throw new Error('Missing job id. Provide --job-id or a positional job id.');
1021
+ }
1022
+
1023
+ let payload;
1024
+ if (flags.json) {
1025
+ payload = readJsonInput(flags.json, '--json');
1026
+ } else {
1027
+ if (!flags.chargeId || !flags.address || !flags.amountRaw || !flags.chargeExpiresAt || !flags.chargeSigEd25519) {
1028
+ throw new Error('Missing required fields. Provide --charge-id, --address, --amount-raw, --charge-expires-at, and --charge-sig-ed25519.');
1029
+ }
1030
+ payload = {
1031
+ charge_id: String(flags.chargeId),
1032
+ address: String(flags.address),
1033
+ amount_raw: String(flags.amountRaw),
1034
+ charge_expires_at: String(flags.chargeExpiresAt),
1035
+ charge_sig_ed25519: String(flags.chargeSigEd25519),
1036
+ };
1037
+ }
1038
+
1039
+ const config = buildConfig();
1040
+ const state = loadState(config.state_path);
1041
+ const {keys} = requireKeys(state);
1042
+ const identity = deriveIdentity(keys);
1043
+
1044
+ const result = await signedRequest({
1045
+ method: 'POST',
1046
+ path: `/v0/jobs/${jobId}/charge/reissue`,
1047
+ body: payload,
1048
+ idempotencyKey: crypto.randomBytes(16).toString('hex'),
1049
+ relayUrl: config.relay_url,
1050
+ keys,
1051
+ identity,
1052
+ });
1053
+
1054
+ if (!result.response.ok) {
1055
+ throw new Error(`Reissue charge failed (${result.response.status}): ${result.text || result.response.statusText}`);
1056
+ }
1057
+
1058
+ printJson(result.data, !!flags.compact);
1059
+ }
1060
+
1061
+ async function runJobPaymentSent(argv) {
1062
+ const {flags, positionals} = parseArgs(argv);
1063
+ const jobId = flags.jobId || flags.job || positionals[0];
1064
+ if (!jobId) {
1065
+ throw new Error('Missing job id. Provide --job-id or a positional job id.');
1066
+ }
1067
+
1068
+ let payload;
1069
+ if (flags.json) {
1070
+ payload = readJsonInput(flags.json, '--json');
1071
+ } else {
1072
+ payload = {};
1073
+ if (flags.paymentBlockHash) {
1074
+ payload.payment_block_hash = String(flags.paymentBlockHash);
1075
+ }
1076
+ if (flags.amountRawSent) {
1077
+ payload.amount_raw_sent = String(flags.amountRawSent);
1078
+ }
1079
+ if (flags.sentAt) {
1080
+ payload.sent_at = String(flags.sentAt);
1081
+ }
1082
+ if (flags.note) {
1083
+ payload.note = String(flags.note);
1084
+ }
1085
+ }
1086
+
1087
+ const config = buildConfig();
1088
+ const state = loadState(config.state_path);
1089
+ const {keys} = requireKeys(state);
1090
+ const identity = deriveIdentity(keys);
1091
+
1092
+ const result = await signedRequest({
1093
+ method: 'POST',
1094
+ path: `/v0/jobs/${jobId}/payment_sent`,
1095
+ body: payload,
1096
+ idempotencyKey: crypto.randomBytes(16).toString('hex'),
1097
+ relayUrl: config.relay_url,
1098
+ keys,
1099
+ identity,
1100
+ });
1101
+
1102
+ if (!result.response.ok) {
1103
+ throw new Error(`Payment sent failed (${result.response.status}): ${result.text || result.response.statusText}`);
1104
+ }
1105
+
1106
+ printJson(result.data, !!flags.compact);
1107
+ }
1108
+
1109
+ async function runPoll(argv, options) {
1110
+ const quiet = options && options.quiet;
1111
+ const {flags} = parseArgs(argv);
1112
+ const config = buildConfig();
1113
+ const state = loadState(config.state_path);
1114
+ const {keys} = requireKeys(state);
1115
+ const identity = deriveIdentity(keys);
1116
+
1117
+ const since = flags.sinceEventId || state.last_acked_event_id;
1118
+ const limit = flags.limit || config.poll_limit;
1119
+ const types = flags.types || config.poll_types;
1120
+
1121
+ const result = await signedRequest({
1122
+ method: 'GET',
1123
+ path: '/v0/poll',
1124
+ query: {
1125
+ since_event_id: since,
1126
+ limit,
1127
+ types,
1128
+ },
1129
+ relayUrl: config.relay_url,
1130
+ keys,
1131
+ identity,
1132
+ });
1133
+
1134
+ if (result.response.status === 410) {
1135
+ throw new Error(`Poll cursor too old (410). Suggested resync. Response: ${result.text}`);
1136
+ }
1137
+
1138
+ if (!result.response.ok) {
1139
+ throw new Error(`Poll failed (${result.response.status}): ${result.text || result.response.statusText}`);
1140
+ }
1141
+
1142
+ const events = result.data && result.data.events ? result.data.events : [];
1143
+ appendEvents(state, events);
1144
+
1145
+ if (typeof state.last_acked_event_id !== 'number') {
1146
+ state.last_acked_event_id = 0;
1147
+ }
1148
+ state.relay_url = config.relay_url;
1149
+ state.bot_id = identity.botId;
1150
+ state.signing_kid = identity.signingKid;
1151
+ state.encryption_kid = identity.encryptionKid;
1152
+ saveState(config.state_path, state);
1153
+
1154
+ let ackedId = state.last_acked_event_id || 0;
1155
+ let maxEventId = 0;
1156
+ if (events.length > 0 && flags.ack !== false) {
1157
+ maxEventId = events.reduce((max, event) => Math.max(max, event.event_id), 0);
1158
+
1159
+ // Ack only after events are durably persisted to local state.
1160
+ const ackResult = await signedRequest({
1161
+ method: 'POST',
1162
+ path: '/v0/poll/ack',
1163
+ body: {up_to_event_id: maxEventId},
1164
+ idempotencyKey: crypto.randomBytes(16).toString('hex'),
1165
+ relayUrl: config.relay_url,
1166
+ keys,
1167
+ identity,
1168
+ });
1169
+ if (!ackResult.response.ok) {
1170
+ throw new Error(`Ack failed (${ackResult.response.status}): ${ackResult.text || ackResult.response.statusText}`);
1171
+ }
1172
+ if (ackResult.data && typeof ackResult.data.last_acked_event_id === 'number') {
1173
+ ackedId = ackResult.data.last_acked_event_id;
1174
+ } else {
1175
+ ackedId = maxEventId;
1176
+ }
1177
+
1178
+ if (typeof ackedId === 'number' && ackedId !== state.last_acked_event_id) {
1179
+ state.last_acked_event_id = ackedId;
1180
+ saveState(config.state_path, state);
1181
+ }
1182
+ }
1183
+
1184
+ if (flags.output) {
1185
+ fs.writeFileSync(String(flags.output), stringifyJson(result.data, false));
1186
+ } else if (!quiet) {
1187
+ printJson(result.data, !!flags.compact);
1188
+ }
1189
+
1190
+ return {events, ackedId, maxEventId};
1191
+ }
1192
+
1193
+ function parseCsv(value) {
1194
+ if (value === undefined || value === null) {
1195
+ return [];
1196
+ }
1197
+ const values = Array.isArray(value) ? value : [value];
1198
+ const out = [];
1199
+ for (const entry of values) {
1200
+ for (const part of String(entry).split(',')) {
1201
+ const trimmed = part.trim();
1202
+ if (trimmed) {
1203
+ out.push(trimmed);
1204
+ }
1205
+ }
1206
+ }
1207
+ return out;
1208
+ }
1209
+
1210
+ function uniqStrings(values) {
1211
+ const seen = new Set();
1212
+ const out = [];
1213
+ for (const value of values) {
1214
+ const key = String(value);
1215
+ if (!seen.has(key)) {
1216
+ seen.add(key);
1217
+ out.push(key);
1218
+ }
1219
+ }
1220
+ return out;
1221
+ }
1222
+
1223
+ function parsePositiveInt(value, label) {
1224
+ const raw = String(value);
1225
+ if (!raw.trim()) {
1226
+ throw new Error(`Missing value for ${label}.`);
1227
+ }
1228
+ const parsed = Number(raw);
1229
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1230
+ throw new Error(`Invalid ${label}: ${raw}`);
1231
+ }
1232
+ return Math.floor(parsed);
1233
+ }
1234
+
1235
+ function parseOptionalPositiveInt(value, label) {
1236
+ if (value === undefined || value === null || String(value).trim() === '') {
1237
+ return undefined;
1238
+ }
1239
+ return parsePositiveInt(value, label);
1240
+ }
1241
+
1242
+ function ensureStreamCursorMap(state) {
1243
+ if (!state.stream_cursors || typeof state.stream_cursors !== 'object' || Array.isArray(state.stream_cursors)) {
1244
+ state.stream_cursors = {};
1245
+ }
1246
+ return state.stream_cursors;
1247
+ }
1248
+
1249
+ function getStreamCursor(state, stream) {
1250
+ const cursors = ensureStreamCursorMap(state);
1251
+ const cursor = cursors[stream];
1252
+ return typeof cursor === 'number' && Number.isFinite(cursor) ? cursor : 0;
1253
+ }
1254
+
1255
+ function setStreamCursor(state, stream, cursor) {
1256
+ if (typeof cursor !== 'number' || !Number.isFinite(cursor)) {
1257
+ return;
1258
+ }
1259
+ const cursors = ensureStreamCursorMap(state);
1260
+ cursors[stream] = cursor;
1261
+ }
1262
+
1263
+ function eventDedupKey(event) {
1264
+ if (!event) {
1265
+ return null;
1266
+ }
1267
+ const eventId = event.event_id;
1268
+ if (eventId === undefined || eventId === null) {
1269
+ return null;
1270
+ }
1271
+ const stream = typeof event.stream === 'string' ? event.stream.trim() : '';
1272
+ if (stream) {
1273
+ return `${stream}:${eventId}`;
1274
+ }
1275
+ return `poll:${eventId}`;
1276
+ }
1277
+
1278
+ function appendEvents(state, events) {
1279
+ if (!Array.isArray(events) || events.length === 0) {
1280
+ return 0;
1281
+ }
1282
+ state.event_log = Array.isArray(state.event_log) ? state.event_log : [];
1283
+ const existingIds = new Set(
1284
+ state.event_log
1285
+ .map((event) => eventDedupKey(event))
1286
+ .filter((key) => key),
1287
+ );
1288
+ let added = 0;
1289
+ for (const event of events) {
1290
+ if (!event) {
1291
+ continue;
1292
+ }
1293
+ const key = eventDedupKey(event);
1294
+ if (key) {
1295
+ if (existingIds.has(key)) {
1296
+ continue;
1297
+ }
1298
+ existingIds.add(key);
1299
+ }
1300
+ state.event_log.push(event);
1301
+ added += 1;
1302
+ }
1303
+ if (state.event_log.length > 500) {
1304
+ state.event_log = state.event_log.slice(-500);
1305
+ }
1306
+ return added;
1307
+ }
1308
+
1309
+ function backoffDelayMs(attempt, opts) {
1310
+ const baseMs = opts && opts.baseMs ? opts.baseMs : 500;
1311
+ const maxMs = opts && opts.maxMs ? opts.maxMs : 30000;
1312
+ const exp = Math.min(maxMs, baseMs * (2 ** Math.max(0, attempt)));
1313
+ const jittered = exp * (0.5 + Math.random());
1314
+ return Math.min(maxMs, Math.max(baseMs, Math.floor(jittered)));
1315
+ }
1316
+
1317
+ function sleep(ms, signal) {
1318
+ return new Promise((resolve) => {
1319
+ const timer = setTimeout(resolve, ms);
1320
+ if (!signal) {
1321
+ return;
1322
+ }
1323
+ if (signal.aborted) {
1324
+ clearTimeout(timer);
1325
+ resolve();
1326
+ return;
1327
+ }
1328
+ signal.addEventListener('abort', () => {
1329
+ clearTimeout(timer);
1330
+ resolve();
1331
+ }, {once: true});
1332
+ });
1333
+ }
1334
+
1335
+ async function consumeSseStream(body, {signal, onEvent}) {
1336
+ if (!body || typeof body.getReader !== 'function') {
1337
+ throw new Error('SSE response body is not readable (expected a web ReadableStream).');
1338
+ }
1339
+
1340
+ const reader = body.getReader();
1341
+ const decoder = new TextDecoder('utf8');
1342
+ let buffer = '';
1343
+
1344
+ let eventName = '';
1345
+ let eventId = '';
1346
+ let data = '';
1347
+
1348
+ function flush() {
1349
+ if (!data) {
1350
+ eventName = '';
1351
+ eventId = '';
1352
+ return;
1353
+ }
1354
+ const payload = data.endsWith('\n') ? data.slice(0, -1) : data;
1355
+ onEvent({
1356
+ event: eventName || 'message',
1357
+ id: eventId || undefined,
1358
+ data: payload,
1359
+ });
1360
+ eventName = '';
1361
+ eventId = '';
1362
+ data = '';
1363
+ }
1364
+
1365
+ while (!signal.aborted) {
1366
+ const {done, value} = await reader.read();
1367
+ if (done) {
1368
+ break;
1369
+ }
1370
+ buffer += decoder.decode(value, {stream: true});
1371
+
1372
+ while (true) {
1373
+ const newlineIndex = buffer.indexOf('\n');
1374
+ if (newlineIndex === -1) {
1375
+ break;
1376
+ }
1377
+ let line = buffer.slice(0, newlineIndex);
1378
+ buffer = buffer.slice(newlineIndex + 1);
1379
+ if (line.endsWith('\r')) {
1380
+ line = line.slice(0, -1);
1381
+ }
1382
+
1383
+ if (line === '') {
1384
+ flush();
1385
+ continue;
1386
+ }
1387
+ if (line.startsWith(':')) {
1388
+ continue;
1389
+ }
1390
+
1391
+ const sep = line.indexOf(':');
1392
+ const field = sep === -1 ? line : line.slice(0, sep);
1393
+ let valuePart = sep === -1 ? '' : line.slice(sep + 1);
1394
+ if (valuePart.startsWith(' ')) {
1395
+ valuePart = valuePart.slice(1);
1396
+ }
1397
+
1398
+ switch (field) {
1399
+ case 'event':
1400
+ eventName = valuePart;
1401
+ break;
1402
+ case 'id':
1403
+ eventId = valuePart;
1404
+ break;
1405
+ case 'data':
1406
+ data += valuePart + '\n';
1407
+ break;
1408
+ default:
1409
+ break;
1410
+ }
1411
+ }
1412
+ }
1413
+
1414
+ flush();
1415
+ }
1416
+
1417
+ function deriveDefaultStreams({keys, state}) {
1418
+ const streams = [];
1419
+
1420
+ if (keys && keys.signing_public_key_b64url) {
1421
+ // Proposed stream key format: seller:ed25519:<pubkey-b64url>
1422
+ streams.push(`seller:ed25519:${keys.signing_public_key_b64url}`);
1423
+ }
1424
+
1425
+ const knownJobs = state && state.known_jobs ? state.known_jobs : null;
1426
+ if (knownJobs) {
1427
+ if (Array.isArray(knownJobs)) {
1428
+ for (const job of knownJobs) {
1429
+ if (job && job.job_id) {
1430
+ streams.push(`job:${job.job_id}`);
1431
+ }
1432
+ }
1433
+ } else if (typeof knownJobs === 'object') {
1434
+ for (const jobId of Object.keys(knownJobs)) {
1435
+ if (jobId) {
1436
+ streams.push(`job:${jobId}`);
1437
+ }
1438
+ }
1439
+ }
1440
+ }
1441
+
1442
+ return uniqStrings(streams);
1443
+ }
1444
+
1445
+ async function runWatch(argv) {
1446
+ requireFetch();
1447
+
1448
+ const {flags} = parseArgs(argv);
1449
+ const config = buildConfig();
1450
+ const state = loadState(config.state_path);
1451
+ const {keys} = requireKeys(state);
1452
+ const identity = deriveIdentity(keys);
1453
+
1454
+ const streamPath = flags.streamPath || flags.ssePath || '/v0/stream';
1455
+ const streams = flags.streams
1456
+ ? uniqStrings(parseCsv(flags.streams))
1457
+ : deriveDefaultStreams({keys, state});
1458
+ if (streams.length === 0) {
1459
+ throw new Error('No streams configured for watch. Provide --streams or ensure state contains keys.');
1460
+ }
1461
+
1462
+ const safetyIntervalSeconds = flags.safetyPollInterval
1463
+ ? parsePositiveInt(flags.safetyPollInterval, '--safety-poll-interval')
1464
+ : 180;
1465
+
1466
+ const printPolls = !!flags.printPolls;
1467
+ const pollLimit = parseOptionalPositiveInt(flags.limit, '--limit')
1468
+ ?? parseOptionalPositiveInt(config.poll_limit, 'NBR_POLL_LIMIT');
1469
+
1470
+ ensureStreamCursorMap(state);
1471
+ const streamSet = new Set(streams);
1472
+
1473
+ let pollInFlight = false;
1474
+ let pollQueued = false;
1475
+ let queuedReason = null;
1476
+ const queuedStreams = new Set();
1477
+
1478
+ function normalizeWakeStreams(candidate) {
1479
+ if (!Array.isArray(candidate) || candidate.length === 0) {
1480
+ return streams;
1481
+ }
1482
+ const filtered = candidate.map((stream) => String(stream)).filter((stream) => streamSet.has(stream));
1483
+ return filtered.length > 0 ? filtered : streams;
1484
+ }
1485
+
1486
+ async function pollBatchStreams(streamList, reason) {
1487
+ const uniqueStreams = uniqStrings(streamList);
1488
+ if (uniqueStreams.length === 0) {
1489
+ return {events: 0, acked: 0, streams: 0};
1490
+ }
1491
+
1492
+ const requestStreams = uniqueStreams.map((stream) => ({
1493
+ stream,
1494
+ since: getStreamCursor(state, stream),
1495
+ }));
1496
+
1497
+ const body = {streams: requestStreams};
1498
+ if (pollLimit !== undefined) {
1499
+ body.limit = pollLimit;
1500
+ }
1501
+
1502
+ const result = await signedRequest({
1503
+ method: 'POST',
1504
+ path: '/v0/poll/batch',
1505
+ body,
1506
+ idempotencyKey: crypto.randomBytes(16).toString('hex'),
1507
+ relayUrl: config.relay_url,
1508
+ keys,
1509
+ identity,
1510
+ });
1511
+
1512
+ if (!result.response.ok) {
1513
+ throw new Error(`Poll batch failed (${result.response.status}): ${result.text || result.response.statusText}`);
1514
+ }
1515
+
1516
+ const results = result.data && Array.isArray(result.data.results) ? result.data.results : [];
1517
+ const allEvents = [];
1518
+ for (const entry of results) {
1519
+ if (!entry || !Array.isArray(entry.events)) {
1520
+ continue;
1521
+ }
1522
+ for (const event of entry.events) {
1523
+ if (!event) {
1524
+ continue;
1525
+ }
1526
+ if (entry.stream) {
1527
+ allEvents.push({...event, stream: entry.stream});
1528
+ } else {
1529
+ allEvents.push(event);
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ state.relay_url = config.relay_url;
1535
+ state.bot_id = identity.botId;
1536
+ state.signing_kid = identity.signingKid;
1537
+ state.encryption_kid = identity.encryptionKid;
1538
+
1539
+ const addedEvents = appendEvents(state, allEvents);
1540
+ if (addedEvents > 0) {
1541
+ saveState(config.state_path, state);
1542
+ }
1543
+
1544
+ let ackedStreams = 0;
1545
+ if (flags.ack !== false) {
1546
+ for (const entry of results) {
1547
+ if (!entry || !entry.stream) {
1548
+ continue;
1549
+ }
1550
+ const next = entry.next;
1551
+ const current = getStreamCursor(state, entry.stream);
1552
+ if (typeof next !== 'number' || !Number.isFinite(next) || next === current) {
1553
+ continue;
1554
+ }
1555
+
1556
+ const ackResult = await signedRequest({
1557
+ method: 'POST',
1558
+ path: '/v0/ack',
1559
+ body: {stream: entry.stream, ack: next},
1560
+ idempotencyKey: crypto.randomBytes(16).toString('hex'),
1561
+ relayUrl: config.relay_url,
1562
+ keys,
1563
+ identity,
1564
+ });
1565
+
1566
+ if (!ackResult.response.ok) {
1567
+ throw new Error(`Ack failed (${ackResult.response.status}): ${ackResult.text || ackResult.response.statusText}`);
1568
+ }
1569
+
1570
+ setStreamCursor(state, entry.stream, next);
1571
+ saveState(config.state_path, state);
1572
+ ackedStreams += 1;
1573
+ }
1574
+ }
1575
+
1576
+ if (printPolls && result.data) {
1577
+ printJson(result.data, !!flags.compact);
1578
+ }
1579
+
1580
+ return {events: allEvents.length, acked: ackedStreams, streams: uniqueStreams.length, reason};
1581
+ }
1582
+
1583
+ async function runPollLoop(reason, streamList) {
1584
+ const normalized = normalizeWakeStreams(streamList);
1585
+ for (const stream of normalized) {
1586
+ queuedStreams.add(stream);
1587
+ }
1588
+ queuedReason = reason;
1589
+ pollQueued = true;
1590
+ if (pollInFlight) {
1591
+ return;
1592
+ }
1593
+ pollInFlight = true;
1594
+ try {
1595
+ while (pollQueued) {
1596
+ pollQueued = false;
1597
+ const batch = Array.from(queuedStreams);
1598
+ queuedStreams.clear();
1599
+ const label = queuedReason || reason;
1600
+ queuedReason = null;
1601
+ try {
1602
+ const summary = await pollBatchStreams(batch, label);
1603
+ console.error(`[watch] poll ok (${label}): streams=${summary.streams} events=${summary.events} acked_streams=${flags.ack === false ? 'disabled' : summary.acked}`);
1604
+ } catch (err) {
1605
+ console.error(`[watch] poll error (${label}): ${err && err.message ? err.message : String(err)}`);
1606
+ // Keep running; polling is idempotent and can be retried on the next wake/safety interval.
1607
+ }
1608
+ }
1609
+ } finally {
1610
+ pollInFlight = false;
1611
+ }
1612
+ }
1613
+
1614
+ const controller = new AbortController();
1615
+ const {signal} = controller;
1616
+ const stop = () => {
1617
+ if (!signal.aborted) {
1618
+ controller.abort();
1619
+ }
1620
+ };
1621
+
1622
+ process.on('SIGINT', stop);
1623
+ process.on('SIGTERM', stop);
1624
+
1625
+ console.error(`[watch] relay=${config.relay_url}`);
1626
+ console.error(`[watch] state_path=${config.state_path}`);
1627
+ console.error(`[watch] stream_path=${streamPath}`);
1628
+ console.error(`[watch] streams=${streams.join(',')}`);
1629
+ console.error(`[watch] safety_poll_interval_seconds=${safetyIntervalSeconds}`);
1630
+
1631
+ const safetyTimer = setInterval(() => {
1632
+ if (signal.aborted) {
1633
+ return;
1634
+ }
1635
+ void runPollLoop('safety');
1636
+ }, safetyIntervalSeconds * 1000);
1637
+
1638
+ // Kick off an initial poll so the watcher is useful even if wakeups are missed.
1639
+ void runPollLoop('startup');
1640
+
1641
+ let attempt = 0;
1642
+ function extractWakeStreams(evt) {
1643
+ if (!evt) {
1644
+ return null;
1645
+ }
1646
+ if (evt.event === 'wake') {
1647
+ if (!evt.data) {
1648
+ return [];
1649
+ }
1650
+ try {
1651
+ const parsed = JSON.parse(evt.data);
1652
+ return parsed && Array.isArray(parsed.streams) ? parsed.streams : [];
1653
+ } catch (_) {
1654
+ return [];
1655
+ }
1656
+ }
1657
+ if (evt.data) {
1658
+ try {
1659
+ const parsed = JSON.parse(evt.data);
1660
+ if (parsed && (parsed.event === 'wake' || parsed.type === 'wake' || parsed.hint === 'poll')) {
1661
+ return Array.isArray(parsed.streams) ? parsed.streams : [];
1662
+ }
1663
+ } catch (_) {
1664
+ // ignore non-JSON payloads
1665
+ }
1666
+ }
1667
+ return null;
1668
+ }
1669
+
1670
+ while (!signal.aborted) {
1671
+ try {
1672
+ const {url, init} = buildSignedFetch({
1673
+ method: 'GET',
1674
+ path: streamPath,
1675
+ query: {streams: streams.join(',')},
1676
+ relayUrl: config.relay_url,
1677
+ keys,
1678
+ identity,
1679
+ extraHeaders: {
1680
+ 'Accept': 'text/event-stream',
1681
+ 'Cache-Control': 'no-cache',
1682
+ },
1683
+ signal,
1684
+ });
1685
+ const response = await fetch(url, init);
1686
+ if (!response.ok) {
1687
+ const text = await response.text();
1688
+ throw new Error(`SSE connect failed (${response.status}): ${text || response.statusText}`);
1689
+ }
1690
+
1691
+ attempt = 0;
1692
+ console.error('[watch] connected');
1693
+
1694
+ await consumeSseStream(response.body, {
1695
+ signal,
1696
+ onEvent: (evt) => {
1697
+ const wakeStreams = extractWakeStreams(evt);
1698
+ if (wakeStreams !== null) {
1699
+ void runPollLoop('wake', wakeStreams);
1700
+ }
1701
+ },
1702
+ });
1703
+
1704
+ if (signal.aborted) {
1705
+ break;
1706
+ }
1707
+
1708
+ console.error('[watch] disconnected');
1709
+ } catch (err) {
1710
+ if (signal.aborted) {
1711
+ break;
1712
+ }
1713
+ const message = err && err.message ? err.message : String(err);
1714
+ const delayMs = backoffDelayMs(attempt);
1715
+ attempt += 1;
1716
+ console.error(`[watch] sse error: ${message}`);
1717
+ console.error(`[watch] reconnecting in ${Math.round(delayMs / 1000)}s`);
1718
+ await sleep(delayMs, signal);
1719
+ }
1720
+ }
1721
+
1722
+ clearInterval(safetyTimer);
1723
+ }
1724
+
1725
+ function runCronEnable(argv) {
1726
+ const {flags} = parseArgs(argv);
1727
+ const config = buildConfig();
1728
+ const schedule = flags.schedule || '*/5 * * * *';
1729
+ const nodeBin = process.execPath;
1730
+ const cliPath = path.resolve(__filename);
1731
+
1732
+ const envPairs = [];
1733
+ if (getEnvValue('NBR_RELAY_URL')) {
1734
+ envPairs.push(`NBR_RELAY_URL=${shellEscape(getEnvValue('NBR_RELAY_URL'))}`);
1735
+ }
1736
+ if (getEnvValue('NBR_STATE_PATH')) {
1737
+ envPairs.push(`NBR_STATE_PATH=${shellEscape(expandHomePath(getEnvValue('NBR_STATE_PATH')))}`);
1738
+ }
1739
+ if (getEnvValue('NBR_POLL_LIMIT')) {
1740
+ envPairs.push(`NBR_POLL_LIMIT=${shellEscape(getEnvValue('NBR_POLL_LIMIT'))}`);
1741
+ }
1742
+ if (getEnvValue('NBR_POLL_TYPES')) {
1743
+ envPairs.push(`NBR_POLL_TYPES=${shellEscape(getEnvValue('NBR_POLL_TYPES'))}`);
1744
+ }
1745
+
1746
+ const envPrefix = envPairs.length > 0 ? `${envPairs.join(' ')} ` : '';
1747
+ const command = `${shellEscape(nodeBin)} ${shellEscape(cliPath)} poll`;
1748
+ const cronLine = `${schedule} ${envPrefix}${command} # nanobazaar-cli`;
1749
+
1750
+ const listResult = spawnSync('crontab', ['-l'], {encoding: 'utf8'});
1751
+ const current = listResult.status === 0 ? listResult.stdout : '';
1752
+ const filtered = current
1753
+ .split(/\r?\n/)
1754
+ .filter((line) => line.trim() && !line.includes('# nanobazaar-cli'))
1755
+ .join('\n');
1756
+
1757
+ const next = [filtered, cronLine].filter(Boolean).join('\n') + '\n';
1758
+ const installResult = spawnSync('crontab', ['-'], {input: next, encoding: 'utf8'});
1759
+ if (installResult.status !== 0) {
1760
+ throw new Error(`Failed to install cron entry: ${installResult.stderr || 'unknown error'}`);
1761
+ }
1762
+
1763
+ console.log('Cron enabled.');
1764
+ console.log(`Schedule: ${schedule}`);
1765
+ console.log(`Command: ${envPrefix}${command}`);
1766
+ console.log(`State path: ${config.state_path}`);
1767
+ }
1768
+
1769
+ function runCronDisable() {
1770
+ const listResult = spawnSync('crontab', ['-l'], {encoding: 'utf8'});
1771
+ if (listResult.status !== 0) {
1772
+ console.log('No crontab entries found.');
1773
+ return;
1774
+ }
1775
+ const filtered = listResult.stdout
1776
+ .split(/\r?\n/)
1777
+ .filter((line) => line.trim() && !line.includes('# nanobazaar-cli'))
1778
+ .join('\n');
1779
+
1780
+ const next = filtered ? `${filtered}\n` : '';
1781
+ const installResult = spawnSync('crontab', ['-'], {input: next, encoding: 'utf8'});
1782
+ if (installResult.status !== 0) {
1783
+ throw new Error(`Failed to remove cron entry: ${installResult.stderr || 'unknown error'}`);
1784
+ }
1785
+ console.log('Cron disabled.');
1786
+ }
1787
+
1788
+ function loadVersion() {
1789
+ try {
1790
+ for (const name of ['package.json', 'skill.json']) {
1791
+ try {
1792
+ const raw = fs.readFileSync(path.resolve(BASE_DIR, name), 'utf8');
1793
+ const parsed = JSON.parse(raw);
1794
+ if (parsed && parsed.version) {
1795
+ return parsed.version;
1796
+ }
1797
+ } catch (_) {
1798
+ // ignore
1799
+ }
1800
+ }
1801
+ return 'unknown';
1802
+ } catch (_) {
1803
+ return 'unknown';
1804
+ }
1805
+ }
1806
+
1807
+ async function main() {
1808
+ const argv = process.argv.slice(2);
1809
+ if (argv.length === 0) {
1810
+ printHelp();
1811
+ return;
1812
+ }
1813
+
1814
+ if (argv.includes('--help') || argv[0] === 'help') {
1815
+ printHelp();
1816
+ return;
1817
+ }
1818
+
1819
+ if (argv.includes('--version') || argv[0] === 'version') {
1820
+ console.log(loadVersion());
1821
+ return;
1822
+ }
1823
+
1824
+ const command = argv[0];
1825
+ const rest = argv.slice(1);
1826
+
1827
+ switch (command) {
1828
+ case 'status':
1829
+ await runStatus();
1830
+ return;
1831
+ case 'config': {
1832
+ const {flags} = parseArgs(rest);
1833
+ const config = buildConfig();
1834
+ printConfig(config, flags.json || flags.compact);
1835
+ return;
1836
+ }
1837
+ case 'setup':
1838
+ runSetup(rest);
1839
+ return;
1840
+ case 'wallet':
1841
+ runWallet(rest);
1842
+ return;
1843
+ case 'search':
1844
+ await runSearch(rest);
1845
+ return;
1846
+ case 'market':
1847
+ await runMarket(rest);
1848
+ return;
1849
+ case 'offer': {
1850
+ const sub = rest[0];
1851
+ if (sub === 'create') {
1852
+ await runOfferCreate(rest.slice(1));
1853
+ return;
1854
+ }
1855
+ if (sub === 'cancel') {
1856
+ await runOfferCancel(rest.slice(1));
1857
+ return;
1858
+ }
1859
+ throw new Error('Unknown offer command. Use: offer create|cancel');
1860
+ }
1861
+ case 'job': {
1862
+ const sub = rest[0];
1863
+ if (sub === 'create') {
1864
+ await runJobCreate(rest.slice(1));
1865
+ return;
1866
+ }
1867
+ if (sub === 'reissue-request') {
1868
+ await runJobReissueRequest(rest.slice(1));
1869
+ return;
1870
+ }
1871
+ if (sub === 'reissue-charge') {
1872
+ await runJobReissueCharge(rest.slice(1));
1873
+ return;
1874
+ }
1875
+ if (sub === 'payment-sent') {
1876
+ await runJobPaymentSent(rest.slice(1));
1877
+ return;
1878
+ }
1879
+ throw new Error('Unknown job command. Use: job create|reissue-request|reissue-charge|payment-sent');
1880
+ }
1881
+ case 'poll':
1882
+ await runPoll(rest);
1883
+ return;
1884
+ case 'watch':
1885
+ await runWatch(rest);
1886
+ return;
1887
+ case 'cron': {
1888
+ const sub = rest[0];
1889
+ if (sub === 'enable') {
1890
+ runCronEnable(rest.slice(1));
1891
+ return;
1892
+ }
1893
+ if (sub === 'disable') {
1894
+ runCronDisable();
1895
+ return;
1896
+ }
1897
+ throw new Error('Unknown cron command. Use: cron enable|disable');
1898
+ }
1899
+ default:
1900
+ throw new Error(`Unknown command: ${command}`);
1901
+ }
1902
+ }
1903
+
1904
+ main().catch((err) => {
1905
+ console.error(err.message || err);
1906
+ process.exit(1);
1907
+ });