@usesotto/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +1619 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.cjs +1625 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +183 -0
- package/dist/index.d.ts +183 -0
- package/dist/index.js +1600 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'util';
|
|
3
|
+
import { readFileSync, rmSync, mkdirSync, writeFileSync, chmodSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
|
|
7
|
+
var SottoApiError = class extends Error {
|
|
8
|
+
status;
|
|
9
|
+
code;
|
|
10
|
+
detail;
|
|
11
|
+
body;
|
|
12
|
+
constructor(status, body, fallbackCode) {
|
|
13
|
+
const code = typeof body.error === "string" ? body.error : fallbackCode;
|
|
14
|
+
const detail = typeof body.detail === "string" ? body.detail : void 0;
|
|
15
|
+
super(detail === void 0 ? code : `${code}: ${detail}`);
|
|
16
|
+
this.name = "SottoApiError";
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.detail = detail;
|
|
20
|
+
this.body = body;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var SottoResponseShapeError = class extends Error {
|
|
24
|
+
constructor(context) {
|
|
25
|
+
super(`The API response did not carry the expected shape: ${context}`);
|
|
26
|
+
this.name = "SottoResponseShapeError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var SottoTransportError = class extends Error {
|
|
30
|
+
cause;
|
|
31
|
+
constructor(context, cause) {
|
|
32
|
+
super(`The Sotto API was unreachable: ${context}`);
|
|
33
|
+
this.name = "SottoTransportError";
|
|
34
|
+
this.cause = cause;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var SottoResponseTooLargeError = class extends Error {
|
|
38
|
+
constructor(limitBytes) {
|
|
39
|
+
super(
|
|
40
|
+
`The API response exceeded the bounded read limit of ${limitBytes} bytes`
|
|
41
|
+
);
|
|
42
|
+
this.name = "SottoResponseTooLargeError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var TERMINAL_ATTEMPT_STATES = Object.freeze([
|
|
46
|
+
"wallet-rejected",
|
|
47
|
+
"wallet-unsupported",
|
|
48
|
+
"settlement-reconciled",
|
|
49
|
+
"settlement-rejected"
|
|
50
|
+
]);
|
|
51
|
+
function isTerminalAttemptState(state) {
|
|
52
|
+
return TERMINAL_ATTEMPT_STATES.includes(state);
|
|
53
|
+
}
|
|
54
|
+
function pairedOutcome(attemptState, deliveryClaimState) {
|
|
55
|
+
const settled = attemptState === "settlement-reconciled";
|
|
56
|
+
const delivered = deliveryClaimState === "delivered";
|
|
57
|
+
const deliveryFailed = deliveryClaimState === "delivery-failed" || deliveryClaimState === "delivery-unknown";
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
settled,
|
|
60
|
+
settlementRejected: attemptState === "settlement-rejected",
|
|
61
|
+
delivered,
|
|
62
|
+
deliveryFailed,
|
|
63
|
+
deliveryPending: settled && !delivered && !deliveryFailed
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
var DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
67
|
+
function normalizedOrigin(origin) {
|
|
68
|
+
const url = new URL(origin);
|
|
69
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
70
|
+
throw new SottoTransportError(`unsupported origin ${origin}`, void 0);
|
|
71
|
+
}
|
|
72
|
+
return url.origin;
|
|
73
|
+
}
|
|
74
|
+
async function readBounded(response, limitBytes) {
|
|
75
|
+
const body = response.body;
|
|
76
|
+
if (body === null) return "";
|
|
77
|
+
const reader = body.getReader();
|
|
78
|
+
const chunks = [];
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (; ; ) {
|
|
81
|
+
const { done, value } = await reader.read();
|
|
82
|
+
if (done) break;
|
|
83
|
+
total += value.byteLength;
|
|
84
|
+
if (total > limitBytes) {
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
throw new SottoResponseTooLargeError(limitBytes);
|
|
87
|
+
}
|
|
88
|
+
chunks.push(value);
|
|
89
|
+
}
|
|
90
|
+
return new TextDecoder().decode(
|
|
91
|
+
chunks.length === 1 ? chunks[0] : concat(chunks, total)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
function concat(chunks, total) {
|
|
95
|
+
const merged = new Uint8Array(total);
|
|
96
|
+
let offset = 0;
|
|
97
|
+
for (const chunk of chunks) {
|
|
98
|
+
merged.set(chunk, offset);
|
|
99
|
+
offset += chunk.byteLength;
|
|
100
|
+
}
|
|
101
|
+
return merged;
|
|
102
|
+
}
|
|
103
|
+
function createTransport(options) {
|
|
104
|
+
const origin = normalizedOrigin(options.origin);
|
|
105
|
+
const fetchImpl = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
106
|
+
const limit = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
107
|
+
const authorizationHeader = () => {
|
|
108
|
+
const token = options.token?.();
|
|
109
|
+
return token === void 0 ? {} : { authorization: `Bearer ${token}` };
|
|
110
|
+
};
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
origin,
|
|
113
|
+
authorizationHeader,
|
|
114
|
+
request: async ({ method, path, body, signal, headers }) => {
|
|
115
|
+
let response;
|
|
116
|
+
try {
|
|
117
|
+
response = await fetchImpl(`${origin}${path}`, {
|
|
118
|
+
method,
|
|
119
|
+
redirect: "error",
|
|
120
|
+
headers: {
|
|
121
|
+
accept: "application/json",
|
|
122
|
+
...body === void 0 ? {} : { "content-type": "application/json" },
|
|
123
|
+
...authorizationHeader(),
|
|
124
|
+
...headers
|
|
125
|
+
},
|
|
126
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
127
|
+
...signal === void 0 ? {} : { signal }
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new SottoTransportError(`${method} ${path}`, error);
|
|
131
|
+
}
|
|
132
|
+
if (response.status === 204) return Object.freeze({});
|
|
133
|
+
const text2 = await readBounded(response, limit);
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = text2 === "" ? {} : JSON.parse(text2);
|
|
137
|
+
} catch {
|
|
138
|
+
throw new SottoResponseShapeError(
|
|
139
|
+
`${method} ${path} answered non-JSON with status ${response.status}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
143
|
+
throw new SottoResponseShapeError(
|
|
144
|
+
`${method} ${path} answered a non-object body`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const record = parsed;
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
throw new SottoApiError(
|
|
150
|
+
response.status,
|
|
151
|
+
record,
|
|
152
|
+
`http-${response.status}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return record;
|
|
156
|
+
},
|
|
157
|
+
fetchRaw: async (path, headers, signal) => {
|
|
158
|
+
try {
|
|
159
|
+
return await fetchImpl(`${origin}${path}`, {
|
|
160
|
+
method: "GET",
|
|
161
|
+
redirect: "error",
|
|
162
|
+
headers: { ...authorizationHeader(), ...headers },
|
|
163
|
+
...signal === void 0 ? {} : { signal }
|
|
164
|
+
});
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new SottoTransportError(`GET ${path}`, error);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
var MAX_EVENT_BYTES = 64 * 1024;
|
|
172
|
+
function createSseParser() {
|
|
173
|
+
let buffer = "";
|
|
174
|
+
return {
|
|
175
|
+
feed(chunk) {
|
|
176
|
+
buffer += chunk.replaceAll("\r\n", "\n");
|
|
177
|
+
if (buffer.length > MAX_EVENT_BYTES) {
|
|
178
|
+
throw new SottoResponseShapeError("SSE frame exceeded 64KiB");
|
|
179
|
+
}
|
|
180
|
+
const events = [];
|
|
181
|
+
for (; ; ) {
|
|
182
|
+
const boundary = buffer.indexOf("\n\n");
|
|
183
|
+
if (boundary === -1) break;
|
|
184
|
+
const frame = buffer.slice(0, boundary);
|
|
185
|
+
buffer = buffer.slice(boundary + 2);
|
|
186
|
+
let id;
|
|
187
|
+
let event;
|
|
188
|
+
const data = [];
|
|
189
|
+
for (const line of frame.split("\n")) {
|
|
190
|
+
if (line.startsWith(":")) continue;
|
|
191
|
+
if (line.startsWith("id:")) id = line.slice(3).trim();
|
|
192
|
+
else if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
193
|
+
else if (line.startsWith("data:")) data.push(line.slice(5).trim());
|
|
194
|
+
}
|
|
195
|
+
if (id !== void 0 || event !== void 0 || data.length > 0) {
|
|
196
|
+
events.push(Object.freeze({ id, event, data: data.join("\n") }));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return events;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function parseAttemptEvent(frame) {
|
|
204
|
+
let parsed;
|
|
205
|
+
try {
|
|
206
|
+
parsed = JSON.parse(frame.data);
|
|
207
|
+
} catch {
|
|
208
|
+
throw new SottoResponseShapeError("SSE data was not JSON");
|
|
209
|
+
}
|
|
210
|
+
const record = parsed;
|
|
211
|
+
if (typeof record.sequence !== "number" || typeof record.type !== "string" || typeof record.recordedAt !== "string") {
|
|
212
|
+
throw new SottoResponseShapeError("SSE event missed journal fields");
|
|
213
|
+
}
|
|
214
|
+
return Object.freeze({
|
|
215
|
+
sequence: record.sequence,
|
|
216
|
+
type: record.type,
|
|
217
|
+
recordedAt: record.recordedAt,
|
|
218
|
+
updateId: typeof record.updateId === "string" ? record.updateId : null
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
var delay = (ms, signal) => new Promise((resolve) => {
|
|
222
|
+
const timer = setTimeout(done, ms);
|
|
223
|
+
function done() {
|
|
224
|
+
signal?.removeEventListener("abort", done);
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
resolve();
|
|
227
|
+
}
|
|
228
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
229
|
+
});
|
|
230
|
+
async function* followPurchaseEvents(transport, attemptId, options = {}) {
|
|
231
|
+
let cursor = options.lastEventId ?? 0;
|
|
232
|
+
const signal = options.signal;
|
|
233
|
+
const aborted = () => signal?.aborted === true;
|
|
234
|
+
const reconnectDelayMs = options.reconnectDelayMs ?? 1e3;
|
|
235
|
+
let reconnectsLeft = options.maxReconnects ?? 30;
|
|
236
|
+
for (; ; ) {
|
|
237
|
+
if (aborted()) return;
|
|
238
|
+
const response = await transport.fetchRaw(
|
|
239
|
+
`/v1/purchases/${encodeURIComponent(attemptId)}/events`,
|
|
240
|
+
{
|
|
241
|
+
accept: "text/event-stream",
|
|
242
|
+
...cursor > 0 ? { "last-event-id": String(cursor) } : {}
|
|
243
|
+
},
|
|
244
|
+
signal
|
|
245
|
+
);
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
const text2 = await readBounded(response, MAX_EVENT_BYTES);
|
|
248
|
+
let body = {};
|
|
249
|
+
try {
|
|
250
|
+
body = JSON.parse(text2);
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
253
|
+
throw new SottoApiError(response.status, body, `http-${response.status}`);
|
|
254
|
+
}
|
|
255
|
+
if (response.body === null) {
|
|
256
|
+
throw new SottoResponseShapeError("event stream had no body");
|
|
257
|
+
}
|
|
258
|
+
const parser = createSseParser();
|
|
259
|
+
const reader = response.body.getReader();
|
|
260
|
+
const decoder = new TextDecoder();
|
|
261
|
+
const abort = () => void reader.cancel().catch(() => void 0);
|
|
262
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
263
|
+
try {
|
|
264
|
+
for (; ; ) {
|
|
265
|
+
const { done, value } = await reader.read();
|
|
266
|
+
if (done) break;
|
|
267
|
+
for (const frame of parser.feed(
|
|
268
|
+
decoder.decode(value, { stream: true })
|
|
269
|
+
)) {
|
|
270
|
+
if (frame.data === "") continue;
|
|
271
|
+
const event = parseAttemptEvent(frame);
|
|
272
|
+
if (event.sequence <= cursor) continue;
|
|
273
|
+
cursor = event.sequence;
|
|
274
|
+
yield event;
|
|
275
|
+
if (isTerminalAttemptState(event.type)) return;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
signal?.removeEventListener("abort", abort);
|
|
280
|
+
}
|
|
281
|
+
if (aborted()) return;
|
|
282
|
+
if (reconnectsLeft <= 0) {
|
|
283
|
+
throw new SottoResponseShapeError(
|
|
284
|
+
"event stream ended before a terminal journal event and the reconnect budget is exhausted"
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
reconnectsLeft -= 1;
|
|
288
|
+
await delay(reconnectDelayMs, signal);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function requireArray(value, context) {
|
|
292
|
+
if (!Array.isArray(value)) throw new SottoResponseShapeError(context);
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
function requireRecord(value, context) {
|
|
296
|
+
if (typeof value !== "object" || value === null) {
|
|
297
|
+
throw new SottoResponseShapeError(context);
|
|
298
|
+
}
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
function createSottoClient(options) {
|
|
302
|
+
const transport = createTransport(options);
|
|
303
|
+
const get = (path, signal) => transport.request({
|
|
304
|
+
method: "GET",
|
|
305
|
+
path,
|
|
306
|
+
...signal === void 0 ? {} : { signal }
|
|
307
|
+
});
|
|
308
|
+
const client = {
|
|
309
|
+
origin: transport.origin,
|
|
310
|
+
health: async (signal) => requireRecord(await get("/healthz", signal), "healthz"),
|
|
311
|
+
session: Object.freeze({
|
|
312
|
+
verify: async (signal) => {
|
|
313
|
+
try {
|
|
314
|
+
await get("/v1/purchases?limit=1", signal);
|
|
315
|
+
return true;
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error instanceof Error && "code" in error && error.code === "session-required") {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
logout: async (signal) => {
|
|
324
|
+
await transport.request({
|
|
325
|
+
method: "DELETE",
|
|
326
|
+
path: "/v1/session",
|
|
327
|
+
...signal === void 0 ? {} : { signal }
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}),
|
|
331
|
+
catalog: Object.freeze({
|
|
332
|
+
listResources: async (signal) => requireArray(
|
|
333
|
+
(await get("/v1/resources", signal)).resources,
|
|
334
|
+
"resources"
|
|
335
|
+
),
|
|
336
|
+
resourceByListing: async (listingId, signal) => requireRecord(
|
|
337
|
+
(await get(`/v1/resources/${encodeURIComponent(listingId)}`, signal)).resource,
|
|
338
|
+
"resource"
|
|
339
|
+
),
|
|
340
|
+
resourceHealth: async (listingId, signal) => {
|
|
341
|
+
const body = await get(
|
|
342
|
+
`/v1/resources/${encodeURIComponent(listingId)}/health`,
|
|
343
|
+
signal
|
|
344
|
+
);
|
|
345
|
+
return body.health ?? null;
|
|
346
|
+
}
|
|
347
|
+
}),
|
|
348
|
+
purchases: Object.freeze({
|
|
349
|
+
initiate: async (listingId, signal) => requireRecord(
|
|
350
|
+
await transport.request({
|
|
351
|
+
method: "POST",
|
|
352
|
+
path: "/v1/purchases",
|
|
353
|
+
body: { listingId },
|
|
354
|
+
...signal === void 0 ? {} : { signal }
|
|
355
|
+
}),
|
|
356
|
+
"purchase initiation"
|
|
357
|
+
),
|
|
358
|
+
get: async (attemptId, signal) => requireRecord(
|
|
359
|
+
await get(`/v1/purchases/${encodeURIComponent(attemptId)}`, signal),
|
|
360
|
+
"purchase detail"
|
|
361
|
+
),
|
|
362
|
+
list: async (limit = 50, signal) => requireArray(
|
|
363
|
+
(await get(`/v1/purchases?limit=${limit}`, signal)).attempts,
|
|
364
|
+
"attempts"
|
|
365
|
+
),
|
|
366
|
+
follow: (attemptId, followOptions) => followPurchaseEvents(transport, attemptId, followOptions)
|
|
367
|
+
}),
|
|
368
|
+
attempts: Object.freeze({
|
|
369
|
+
listPublic: async (limit = 50, signal) => requireArray(
|
|
370
|
+
(await get(`/v1/attempts?limit=${limit}`, signal)).attempts,
|
|
371
|
+
"public attempts"
|
|
372
|
+
),
|
|
373
|
+
evidence: async (attemptId, signal) => requireRecord(
|
|
374
|
+
(await get(`/v1/attempts/${encodeURIComponent(attemptId)}`, signal)).attempt,
|
|
375
|
+
"attempt evidence"
|
|
376
|
+
)
|
|
377
|
+
}),
|
|
378
|
+
stats: Object.freeze({
|
|
379
|
+
read: async (window = "7d", signal) => requireRecord(
|
|
380
|
+
await get(`/v1/stats?window=${encodeURIComponent(window)}`, signal),
|
|
381
|
+
"stats"
|
|
382
|
+
)
|
|
383
|
+
})
|
|
384
|
+
};
|
|
385
|
+
return Object.freeze(client);
|
|
386
|
+
}
|
|
387
|
+
var TOKEN = /^[0-9a-f]{64}$/u;
|
|
388
|
+
function configPath(env) {
|
|
389
|
+
const base = env.SOTTO_CONFIG_DIR ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "sotto");
|
|
390
|
+
return join(base, "config.json");
|
|
391
|
+
}
|
|
392
|
+
function readConfig(env) {
|
|
393
|
+
let raw;
|
|
394
|
+
try {
|
|
395
|
+
raw = readFileSync(configPath(env), "utf8");
|
|
396
|
+
} catch {
|
|
397
|
+
return Object.freeze({});
|
|
398
|
+
}
|
|
399
|
+
let parsed;
|
|
400
|
+
try {
|
|
401
|
+
parsed = JSON.parse(raw);
|
|
402
|
+
} catch {
|
|
403
|
+
return Object.freeze({});
|
|
404
|
+
}
|
|
405
|
+
if (typeof parsed !== "object" || parsed === null) return Object.freeze({});
|
|
406
|
+
const record = parsed;
|
|
407
|
+
return Object.freeze({
|
|
408
|
+
...typeof record.apiOrigin === "string" ? { apiOrigin: record.apiOrigin } : {},
|
|
409
|
+
...typeof record.token === "string" && TOKEN.test(record.token) ? { token: record.token } : {},
|
|
410
|
+
...typeof record.walletUrl === "string" ? { walletUrl: record.walletUrl } : {}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
function writeConfig(env, config) {
|
|
414
|
+
const path = configPath(env);
|
|
415
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
416
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}
|
|
417
|
+
`, {
|
|
418
|
+
encoding: "utf8",
|
|
419
|
+
mode: 384
|
|
420
|
+
});
|
|
421
|
+
chmodSync(path, 384);
|
|
422
|
+
return path;
|
|
423
|
+
}
|
|
424
|
+
function deleteConfig(env) {
|
|
425
|
+
rmSync(configPath(env), { force: true });
|
|
426
|
+
}
|
|
427
|
+
function resolveSettings(env, flags = {}) {
|
|
428
|
+
const config = readConfig(env);
|
|
429
|
+
const envToken = env.SOTTO_SESSION_TOKEN;
|
|
430
|
+
const token = envToken !== void 0 && TOKEN.test(envToken) ? envToken : config.token;
|
|
431
|
+
return Object.freeze({
|
|
432
|
+
apiOrigin: flags.apiOrigin ?? env.SOTTO_API_ORIGIN ?? config.apiOrigin,
|
|
433
|
+
token,
|
|
434
|
+
walletUrl: env.SOTTO_WALLET_URL ?? config.walletUrl,
|
|
435
|
+
tokenSource: envToken !== void 0 && TOKEN.test(envToken) ? "env" : config.token !== void 0 ? "config" : "absent"
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
function isValidToken(candidate) {
|
|
439
|
+
return TOKEN.test(candidate);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/core.ts
|
|
443
|
+
var CliUsageError = class extends Error {
|
|
444
|
+
};
|
|
445
|
+
var CliAuthError = class extends Error {
|
|
446
|
+
};
|
|
447
|
+
function buildClient(env, flags = {}, fetchImpl) {
|
|
448
|
+
const settings = resolveSettings(env, flags);
|
|
449
|
+
if (settings.apiOrigin === void 0) {
|
|
450
|
+
throw new CliUsageError(
|
|
451
|
+
"No API origin is configured. Set SOTTO_API_ORIGIN, pass --api-origin, or run `sotto login --api-origin <url> --token <token>`."
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const client = createSottoClient({
|
|
455
|
+
origin: settings.apiOrigin,
|
|
456
|
+
token: () => settings.token,
|
|
457
|
+
...fetchImpl === void 0 ? {} : { fetch: fetchImpl }
|
|
458
|
+
});
|
|
459
|
+
return Object.freeze({ client, settings });
|
|
460
|
+
}
|
|
461
|
+
function requireToken(settings) {
|
|
462
|
+
if (settings.token === void 0) {
|
|
463
|
+
throw new CliAuthError(
|
|
464
|
+
"No owner session token is configured. Copy the session token from the Sotto app (or the /v1/session/verify response) and run `sotto login --token <token>`, or export SOTTO_SESSION_TOKEN."
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
return settings.token;
|
|
468
|
+
}
|
|
469
|
+
function filterResources(resources, filters) {
|
|
470
|
+
const query = filters.query?.toLowerCase();
|
|
471
|
+
return resources.filter((resource) => {
|
|
472
|
+
if (filters.method !== void 0 && resource.method.toUpperCase() !== filters.method.toUpperCase()) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
if (filters.maxPriceAtomic !== void 0 && BigInt(resource.amountAtomic) > filters.maxPriceAtomic) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
if (query === void 0 || query === "") return true;
|
|
479
|
+
return [
|
|
480
|
+
resource.name,
|
|
481
|
+
resource.description,
|
|
482
|
+
resource.providerDisplayName,
|
|
483
|
+
resource.normalizedOrigin,
|
|
484
|
+
resource.routeTemplate,
|
|
485
|
+
resource.method
|
|
486
|
+
].join("\n").toLowerCase().includes(query);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
490
|
+
async function resolveResource(client, reference, signal) {
|
|
491
|
+
if (UUID.test(reference)) {
|
|
492
|
+
return client.catalog.resourceByListing(reference, signal);
|
|
493
|
+
}
|
|
494
|
+
let url;
|
|
495
|
+
try {
|
|
496
|
+
url = new URL(reference);
|
|
497
|
+
} catch {
|
|
498
|
+
throw new CliUsageError(
|
|
499
|
+
`"${reference}" is neither a listing ID nor a resource URL. Pass the listing ID from \`sotto search\` or the resource's canonical URL.`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const resources = await client.catalog.listResources(signal);
|
|
503
|
+
const match = resources.find(
|
|
504
|
+
(resource) => resource.normalizedOrigin === url.origin && resource.routeTemplate === url.pathname
|
|
505
|
+
);
|
|
506
|
+
if (match === void 0) {
|
|
507
|
+
throw new CliUsageError(
|
|
508
|
+
`No verified resource matches ${url.origin}${url.pathname}. Browse \`sotto search\` for the published catalog.`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
return match;
|
|
512
|
+
}
|
|
513
|
+
function parseMaxPrice(raw) {
|
|
514
|
+
if (raw === void 0) return void 0;
|
|
515
|
+
if (!/^[0-9]{1,38}$/u.test(raw)) {
|
|
516
|
+
throw new CliUsageError(
|
|
517
|
+
"--max-price takes an atomic-unit integer (the catalog's amountAtomic)."
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
return BigInt(raw);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/exit-codes.ts
|
|
524
|
+
var EXIT = Object.freeze({
|
|
525
|
+
/** Read succeeded, or the purchase settled AND delivered. */
|
|
526
|
+
ok: 0,
|
|
527
|
+
/** Transport failure, API failure, or an unexpected error. */
|
|
528
|
+
failure: 1,
|
|
529
|
+
/** The command line itself was invalid. */
|
|
530
|
+
usage: 2,
|
|
531
|
+
/** No usable owner session (token absent, expired, or revoked). */
|
|
532
|
+
auth: 3,
|
|
533
|
+
/** The human wallet rejected this exact prepared call. */
|
|
534
|
+
walletRejected: 4,
|
|
535
|
+
/** The wallet cannot sign this prepared transaction shape. */
|
|
536
|
+
walletUnsupported: 5,
|
|
537
|
+
/** Canton rejected the settlement; no value moved. */
|
|
538
|
+
settlementRejected: 6,
|
|
539
|
+
/** The execute-before deadline passed without a terminal journal event. */
|
|
540
|
+
expired: 7,
|
|
541
|
+
/** Settled but not delivered, or otherwise ambiguous — reconcile, never retry. */
|
|
542
|
+
ambiguous: 8
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// src/help.ts
|
|
546
|
+
var HELP_TEXT = `sotto \u2014 Canton x402 marketplace CLI (thin client over the Sotto API)
|
|
547
|
+
|
|
548
|
+
USAGE
|
|
549
|
+
sotto <command> [arguments] [flags]
|
|
550
|
+
|
|
551
|
+
SESSION (copy-token flow \u2014 no device authorization exists server-side yet)
|
|
552
|
+
login [--api-origin <url>] --token <token> [--wallet-url <url>]
|
|
553
|
+
Store the owner-session token you copied from the Sotto app's
|
|
554
|
+
session response. Stored 0600 in ~/.config/sotto/config.json and
|
|
555
|
+
sent only as "Authorization: Bearer" to that API origin.
|
|
556
|
+
whoami Report API origin, token presence, and session validity.
|
|
557
|
+
logout Revoke the server session and remove the local token.
|
|
558
|
+
|
|
559
|
+
CATALOG
|
|
560
|
+
search [query] [--method <m>] [--max-price <atomic>]
|
|
561
|
+
List verified resources. --tag is not supported: the catalog
|
|
562
|
+
carries no tags yet and the CLI will say so instead of ignoring it.
|
|
563
|
+
inspect <listingId|resource-url>
|
|
564
|
+
Show one resource with its fresh server-observed price + timestamp.
|
|
565
|
+
try <resource-url>
|
|
566
|
+
Inspect the listing behind a canonical URL and print the exact
|
|
567
|
+
prepare command. Alias of inspect with purchase guidance.
|
|
568
|
+
|
|
569
|
+
PURCHASING (a HUMAN approves every spend at the wallet; the CLI never signs)
|
|
570
|
+
buy <listingId> [--max-price <atomic>] [--no-wait]
|
|
571
|
+
Initiate one exact purchase, print the prepared call + request
|
|
572
|
+
commitment (+ prepared hash once verified), then follow the journal
|
|
573
|
+
as a text rail until a terminal state.
|
|
574
|
+
status <attemptId> [--follow]
|
|
575
|
+
Show or follow the full journal state.
|
|
576
|
+
evidence <attemptId>
|
|
577
|
+
Paired settlement/delivery outcome, update ID, explorer URL.
|
|
578
|
+
stats [--window 24h|7d|30d|all]
|
|
579
|
+
Real persisted aggregates; settlement and delivery rates separate.
|
|
580
|
+
|
|
581
|
+
AGENTS
|
|
582
|
+
mcp serve Buyer MCP server on stdio (JSON-RPC on stdout, logs on
|
|
583
|
+
stderr). Same token via config or SOTTO_SESSION_TOKEN.
|
|
584
|
+
|
|
585
|
+
GLOBAL FLAGS
|
|
586
|
+
--json Machine-readable output on every read command.
|
|
587
|
+
--api-origin Override the API origin for this invocation.
|
|
588
|
+
--version Print the CLI version. --help This text.
|
|
589
|
+
|
|
590
|
+
ENVIRONMENT
|
|
591
|
+
SOTTO_API_ORIGIN, SOTTO_SESSION_TOKEN, SOTTO_WALLET_URL, NO_COLOR
|
|
592
|
+
|
|
593
|
+
EXIT CODES
|
|
594
|
+
0 delivered/ok 1 failure 2 usage 3 no session 4 wallet-rejected
|
|
595
|
+
5 wallet-unsupported 6 settlement-rejected 7 expired
|
|
596
|
+
8 ambiguous or settled-undelivered (reconcile; NEVER auto-retried)`;
|
|
597
|
+
|
|
598
|
+
// src/output.ts
|
|
599
|
+
function colorEnabled(io2) {
|
|
600
|
+
return io2.isTTY && io2.env.NO_COLOR === void 0;
|
|
601
|
+
}
|
|
602
|
+
function bold(io2, text2) {
|
|
603
|
+
return colorEnabled(io2) ? `\x1B[1m${text2}\x1B[0m` : text2;
|
|
604
|
+
}
|
|
605
|
+
function truncateUpdateId(updateId) {
|
|
606
|
+
if (updateId.length <= 14) return updateId;
|
|
607
|
+
return `${updateId.slice(0, 8)}\u2026${updateId.slice(-4)}`;
|
|
608
|
+
}
|
|
609
|
+
function amountWithAsset(atomic, asset) {
|
|
610
|
+
return `${atomic} ${asset} (atomic units)`;
|
|
611
|
+
}
|
|
612
|
+
function resourceUrl(origin, route) {
|
|
613
|
+
return `${origin}${route}`;
|
|
614
|
+
}
|
|
615
|
+
function printJson(io2, value) {
|
|
616
|
+
io2.stdout(JSON.stringify(value, null, 2));
|
|
617
|
+
}
|
|
618
|
+
function railLine(mark, timestamp, label) {
|
|
619
|
+
const box = mark === "done" ? "[x]" : mark === "active" ? "[>]" : "[ ]";
|
|
620
|
+
const at = timestamp === null ? " " : timestamp;
|
|
621
|
+
return `${box} ${at} ${label}`;
|
|
622
|
+
}
|
|
623
|
+
function table(rows, header) {
|
|
624
|
+
const all = [header, ...rows];
|
|
625
|
+
const widths = header.map(
|
|
626
|
+
(_, column) => Math.max(...all.map((row) => (row[column] ?? "").length))
|
|
627
|
+
);
|
|
628
|
+
return all.map(
|
|
629
|
+
(row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd()
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/commands/rail.ts
|
|
634
|
+
var STATION_LABELS = Object.freeze({
|
|
635
|
+
"intent-created": "Intent journaled",
|
|
636
|
+
"prepared-hash-verified": "Prepared transaction hash verified",
|
|
637
|
+
"approval-requested": "Human wallet approval requested",
|
|
638
|
+
"wallet-rejected": "Wallet rejected this exact call",
|
|
639
|
+
"wallet-unsupported": "Wallet cannot sign this transaction shape",
|
|
640
|
+
"signature-verified": "Wallet signature verified",
|
|
641
|
+
"execution-started": "Canton execution started",
|
|
642
|
+
"settlement-reconciled": "Settlement reconciled on Canton",
|
|
643
|
+
"settlement-rejected": "Settlement rejected by Canton"
|
|
644
|
+
});
|
|
645
|
+
function renderEvent(io2, event) {
|
|
646
|
+
io2.stdout(
|
|
647
|
+
railLine(
|
|
648
|
+
"done",
|
|
649
|
+
event.recordedAt,
|
|
650
|
+
`${STATION_LABELS[event.type] ?? event.type}${event.updateId === null ? "" : ` \u2014 update ${event.updateId}`}`
|
|
651
|
+
)
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
function renderApprovalBlock(io2, walletUrl) {
|
|
655
|
+
io2.stdout("");
|
|
656
|
+
io2.stdout(bold(io2, "\u2500\u2500 HUMAN APPROVAL REQUIRED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
657
|
+
io2.stdout("A human must approve this exact prepared call in the Sotto");
|
|
658
|
+
io2.stdout("wallet before any value moves. This CLI cannot sign anything.");
|
|
659
|
+
if (walletUrl !== void 0) {
|
|
660
|
+
io2.stdout(bold(io2, `Wallet approval page: ${walletUrl}`));
|
|
661
|
+
} else {
|
|
662
|
+
io2.stdout(
|
|
663
|
+
"Open the wallet link from your Sotto onboarding to approve (store it once with `sotto login --wallet-url <url>`)."
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
io2.stdout(bold(io2, "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
667
|
+
io2.stdout("");
|
|
668
|
+
}
|
|
669
|
+
function reconcileGuidance(io2, attemptId) {
|
|
670
|
+
io2.stderr(
|
|
671
|
+
"This outcome is not final proof of delivery. Do NOT retry the purchase \u2014 a second attempt can pay twice for one call."
|
|
672
|
+
);
|
|
673
|
+
io2.stderr(`Reconcile with: sotto status ${attemptId}`);
|
|
674
|
+
io2.stderr(`Evidence: sotto evidence ${attemptId}`);
|
|
675
|
+
}
|
|
676
|
+
var DELIVERY_POLL_MS = 2e3;
|
|
677
|
+
var DELIVERY_WAIT_MS = 12e4;
|
|
678
|
+
async function settleDeliveryExit(io2, client, attemptId, waitMs = DELIVERY_WAIT_MS, pollMs = DELIVERY_POLL_MS) {
|
|
679
|
+
const deadline = Date.now() + waitMs;
|
|
680
|
+
for (; ; ) {
|
|
681
|
+
const detail = await client.purchases.get(attemptId);
|
|
682
|
+
const outcome = pairedOutcome(
|
|
683
|
+
detail.attempt.state,
|
|
684
|
+
detail.delivery?.claimState ?? null
|
|
685
|
+
);
|
|
686
|
+
if (outcome.delivered) {
|
|
687
|
+
io2.stdout(
|
|
688
|
+
railLine(
|
|
689
|
+
"done",
|
|
690
|
+
detail.delivery?.respondedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
691
|
+
"Delivered \u2014 paid provider response recorded"
|
|
692
|
+
)
|
|
693
|
+
);
|
|
694
|
+
io2.stdout("Settled: yes. Delivered: yes.");
|
|
695
|
+
return EXIT.ok;
|
|
696
|
+
}
|
|
697
|
+
if (outcome.deliveryFailed) {
|
|
698
|
+
io2.stdout(
|
|
699
|
+
`Settled: yes. Delivered: NO \u2014 delivery ${detail.delivery?.claimState ?? "unknown"} (${detail.delivery?.failureCode ?? "no failure code"}).`
|
|
700
|
+
);
|
|
701
|
+
reconcileGuidance(io2, attemptId);
|
|
702
|
+
return EXIT.ambiguous;
|
|
703
|
+
}
|
|
704
|
+
if (Date.now() >= deadline) {
|
|
705
|
+
io2.stdout(
|
|
706
|
+
"Settled: yes. Delivered: not yet \u2014 the delivery worker has not reported a terminal state within the wait budget."
|
|
707
|
+
);
|
|
708
|
+
reconcileGuidance(io2, attemptId);
|
|
709
|
+
return EXIT.ambiguous;
|
|
710
|
+
}
|
|
711
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function terminalExit(eventType) {
|
|
715
|
+
if (!isTerminalAttemptState(eventType)) return void 0;
|
|
716
|
+
switch (eventType) {
|
|
717
|
+
case "wallet-rejected":
|
|
718
|
+
return EXIT.walletRejected;
|
|
719
|
+
case "wallet-unsupported":
|
|
720
|
+
return EXIT.walletUnsupported;
|
|
721
|
+
case "settlement-rejected":
|
|
722
|
+
return EXIT.settlementRejected;
|
|
723
|
+
default:
|
|
724
|
+
return EXIT.ok;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/commands/buy.ts
|
|
729
|
+
function renderPreparedCall(io2, client, initiated, listingId) {
|
|
730
|
+
io2.stdout(bold(io2, "Prepared paid call (exact, one call):"));
|
|
731
|
+
io2.stdout(` Listing: ${listingId}`);
|
|
732
|
+
io2.stdout(` Attempt: ${initiated.attemptId}`);
|
|
733
|
+
io2.stdout(` Command ID: ${initiated.commandId}`);
|
|
734
|
+
io2.stdout(
|
|
735
|
+
` Observed price: ${initiated.price.observed.amountAtomic} (atomic) at ${initiated.price.observed.observedAt}`
|
|
736
|
+
);
|
|
737
|
+
io2.stdout(` Recipient: ${initiated.price.observed.recipient}`);
|
|
738
|
+
io2.stdout(` Execute before: ${initiated.executeBefore}`);
|
|
739
|
+
io2.stdout(` API origin: ${client.origin}`);
|
|
740
|
+
}
|
|
741
|
+
async function renderCommitments(io2, client, attemptId) {
|
|
742
|
+
const detail = await client.purchases.get(attemptId);
|
|
743
|
+
io2.stdout(` Request commitment: ${detail.attempt.requestCommitment}`);
|
|
744
|
+
io2.stdout(` Purchase commit.: ${detail.attempt.purchaseCommitment}`);
|
|
745
|
+
io2.stdout(
|
|
746
|
+
` Prepared tx hash: ${detail.attempt.preparedTransactionHash ?? "not prepared yet \u2014 appears at prepared-hash-verified"}`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
async function buyCommand(input) {
|
|
750
|
+
const { io: io2, flags } = input;
|
|
751
|
+
const listingId = input.positionals[0];
|
|
752
|
+
if (listingId === void 0) {
|
|
753
|
+
throw new CliUsageError(
|
|
754
|
+
"Pass the listing ID of the verified resource: sotto buy <listingId>"
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
if (flags.input !== void 0) {
|
|
758
|
+
throw new CliUsageError(
|
|
759
|
+
"The purchasing API binds no request input yet \u2014 only parameterless resources are purchasable, so --input has nothing to carry. Drop --input to purchase the exact listed call."
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
const context = buildClient(
|
|
763
|
+
input.env,
|
|
764
|
+
typeof flags["api-origin"] === "string" ? { apiOrigin: flags["api-origin"] } : {},
|
|
765
|
+
input.fetchImpl
|
|
766
|
+
);
|
|
767
|
+
requireToken(context.settings);
|
|
768
|
+
const { client } = context;
|
|
769
|
+
const maxPrice = parseMaxPrice(
|
|
770
|
+
typeof flags["max-price"] === "string" ? flags["max-price"] : void 0
|
|
771
|
+
);
|
|
772
|
+
if (maxPrice !== void 0) {
|
|
773
|
+
const resource = await client.catalog.resourceByListing(listingId);
|
|
774
|
+
if (BigInt(resource.amountAtomic) > maxPrice) {
|
|
775
|
+
io2.stderr(
|
|
776
|
+
`Local policy stop (not a ledger limit): the indexed price ${amountWithAsset(resource.amountAtomic, resource.asset)} exceeds --max-price ${maxPrice}. Nothing was initiated.`
|
|
777
|
+
);
|
|
778
|
+
return EXIT.usage;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const initiated = await client.purchases.initiate(
|
|
782
|
+
listingId
|
|
783
|
+
);
|
|
784
|
+
renderPreparedCall(io2, client, initiated, listingId);
|
|
785
|
+
await renderCommitments(io2, client, initiated.attemptId);
|
|
786
|
+
io2.stdout("");
|
|
787
|
+
if (flags["no-wait"] === true) {
|
|
788
|
+
if (flags.json === true) printJson(io2, initiated);
|
|
789
|
+
io2.stdout(`Follow it with: sotto status ${initiated.attemptId} --follow`);
|
|
790
|
+
return EXIT.ok;
|
|
791
|
+
}
|
|
792
|
+
return followToExit(io2, client, initiated.attemptId, {
|
|
793
|
+
executeBefore: initiated.executeBefore,
|
|
794
|
+
walletUrl: context.settings.walletUrl,
|
|
795
|
+
json: flags.json === true
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
async function followToExit(io2, client, attemptId, options) {
|
|
799
|
+
io2.stdout(railLine("active", null, "Following the purchase journal\u2026"));
|
|
800
|
+
let lastType;
|
|
801
|
+
const expiresAt = options.executeBefore === void 0 ? void 0 : Date.parse(options.executeBefore);
|
|
802
|
+
const controller = new AbortController();
|
|
803
|
+
const expiry = expiresAt === void 0 ? void 0 : setTimeout(
|
|
804
|
+
() => controller.abort(),
|
|
805
|
+
// Small grace after execute-before: a settlement event recorded
|
|
806
|
+
// right at the deadline still arrives before the stream stops.
|
|
807
|
+
Math.max(expiresAt - Date.now() + 5e3, 1e3)
|
|
808
|
+
);
|
|
809
|
+
try {
|
|
810
|
+
for await (const event of client.purchases.follow(attemptId, {
|
|
811
|
+
signal: controller.signal,
|
|
812
|
+
...options.lastEventId === void 0 ? {} : { lastEventId: options.lastEventId }
|
|
813
|
+
})) {
|
|
814
|
+
renderEvent(io2, event);
|
|
815
|
+
lastType = event.type;
|
|
816
|
+
if (event.type === "approval-requested") {
|
|
817
|
+
renderApprovalBlock(io2, options.walletUrl);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
} catch (error) {
|
|
821
|
+
if (!controller.signal.aborted) {
|
|
822
|
+
if (error instanceof SottoApiError) throw error;
|
|
823
|
+
io2.stderr(String(error instanceof Error ? error.message : error));
|
|
824
|
+
reconcileGuidance(io2, attemptId);
|
|
825
|
+
return EXIT.ambiguous;
|
|
826
|
+
}
|
|
827
|
+
} finally {
|
|
828
|
+
if (expiry !== void 0) clearTimeout(expiry);
|
|
829
|
+
}
|
|
830
|
+
if (controller.signal.aborted && terminalExit(lastType ?? "") === void 0) {
|
|
831
|
+
io2.stdout(
|
|
832
|
+
"The execute-before deadline passed without a terminal journal event. The attempt can no longer settle as prepared."
|
|
833
|
+
);
|
|
834
|
+
reconcileGuidance(io2, attemptId);
|
|
835
|
+
return EXIT.expired;
|
|
836
|
+
}
|
|
837
|
+
const exit = terminalExit(lastType ?? "");
|
|
838
|
+
if (exit === void 0) {
|
|
839
|
+
reconcileGuidance(io2, attemptId);
|
|
840
|
+
return EXIT.ambiguous;
|
|
841
|
+
}
|
|
842
|
+
if (exit !== EXIT.ok) {
|
|
843
|
+
if (options.json === true) {
|
|
844
|
+
const detail = await client.purchases.get(attemptId);
|
|
845
|
+
printJson(io2, detail);
|
|
846
|
+
}
|
|
847
|
+
return exit;
|
|
848
|
+
}
|
|
849
|
+
const deliveryExit = await settleDeliveryExit(io2, client, attemptId);
|
|
850
|
+
if (options.json === true) {
|
|
851
|
+
const detail = await client.purchases.get(attemptId);
|
|
852
|
+
printJson(io2, {
|
|
853
|
+
...detail,
|
|
854
|
+
pairedOutcome: pairedOutcome(
|
|
855
|
+
detail.attempt.state,
|
|
856
|
+
detail.delivery?.claimState ?? null
|
|
857
|
+
)
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
return deliveryExit;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/commands/catalog.ts
|
|
864
|
+
function apiOriginFlag(flags) {
|
|
865
|
+
return typeof flags["api-origin"] === "string" ? { apiOrigin: flags["api-origin"] } : {};
|
|
866
|
+
}
|
|
867
|
+
async function searchCommand(input) {
|
|
868
|
+
const { io: io2, flags } = input;
|
|
869
|
+
if (flags.tag !== void 0) {
|
|
870
|
+
throw new CliUsageError(
|
|
871
|
+
"The verified catalog carries no tags yet, so --tag cannot filter anything. Filter by --method, --max-price, or free text instead."
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
const { client } = buildClient(
|
|
875
|
+
input.env,
|
|
876
|
+
apiOriginFlag(flags),
|
|
877
|
+
input.fetchImpl
|
|
878
|
+
);
|
|
879
|
+
const maxPrice = parseMaxPrice(
|
|
880
|
+
typeof flags["max-price"] === "string" ? flags["max-price"] : void 0
|
|
881
|
+
);
|
|
882
|
+
const resources = filterResources(await client.catalog.listResources(), {
|
|
883
|
+
...input.positionals[0] === void 0 ? {} : { query: input.positionals[0] },
|
|
884
|
+
...typeof flags.method === "string" ? { method: flags.method } : {},
|
|
885
|
+
...maxPrice === void 0 ? {} : { maxPriceAtomic: maxPrice }
|
|
886
|
+
});
|
|
887
|
+
if (flags.json === true) {
|
|
888
|
+
printJson(io2, { resources });
|
|
889
|
+
return EXIT.ok;
|
|
890
|
+
}
|
|
891
|
+
if (resources.length === 0) {
|
|
892
|
+
io2.stdout("No verified resources match. The catalog answer is honest \u2014");
|
|
893
|
+
io2.stdout("nothing is hidden and nothing is sampled.");
|
|
894
|
+
return EXIT.ok;
|
|
895
|
+
}
|
|
896
|
+
for (const line of table(
|
|
897
|
+
resources.map((resource) => [
|
|
898
|
+
resource.listingId,
|
|
899
|
+
resource.method,
|
|
900
|
+
resourceUrl(resource.normalizedOrigin, resource.routeTemplate),
|
|
901
|
+
amountWithAsset(resource.amountAtomic, resource.asset),
|
|
902
|
+
resource.lastVerifiedAt
|
|
903
|
+
]),
|
|
904
|
+
["LISTING", "METHOD", "RESOURCE", "PRICE", "LAST VERIFIED"]
|
|
905
|
+
)) {
|
|
906
|
+
io2.stdout(line);
|
|
907
|
+
}
|
|
908
|
+
return EXIT.ok;
|
|
909
|
+
}
|
|
910
|
+
function renderResource(io2, resource, health) {
|
|
911
|
+
io2.stdout(`${resource.name} \u2014 ${resource.providerDisplayName}`);
|
|
912
|
+
io2.stdout(resource.description);
|
|
913
|
+
io2.stdout("");
|
|
914
|
+
io2.stdout(
|
|
915
|
+
`Resource: ${resource.method} ${resourceUrl(resource.normalizedOrigin, resource.routeTemplate)}`
|
|
916
|
+
);
|
|
917
|
+
io2.stdout(`Network: ${resource.network}`);
|
|
918
|
+
io2.stdout(
|
|
919
|
+
`Observed price: ${amountWithAsset(resource.amountAtomic, resource.asset)}`
|
|
920
|
+
);
|
|
921
|
+
io2.stdout(`Recipient: ${resource.recipient}`);
|
|
922
|
+
io2.stdout(`Last verified: ${resource.lastVerifiedAt} (server-observed)`);
|
|
923
|
+
io2.stdout(
|
|
924
|
+
`Health: ${health === null ? "no observation recorded yet" : JSON.stringify(health)}`
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
async function inspectCommand(input, withPrepareGuidance) {
|
|
928
|
+
const reference = input.positionals[0];
|
|
929
|
+
if (reference === void 0) {
|
|
930
|
+
throw new CliUsageError(
|
|
931
|
+
"Pass a listing ID or the resource's canonical URL: sotto inspect <resource> | sotto try <resource-url>"
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
const { client } = buildClient(
|
|
935
|
+
input.env,
|
|
936
|
+
apiOriginFlag(input.flags),
|
|
937
|
+
input.fetchImpl
|
|
938
|
+
);
|
|
939
|
+
const resource = await resolveResource(client, reference);
|
|
940
|
+
const health = await client.catalog.resourceHealth(resource.listingId);
|
|
941
|
+
if (input.flags.json === true) {
|
|
942
|
+
printJson(input.io, { resource, health });
|
|
943
|
+
return EXIT.ok;
|
|
944
|
+
}
|
|
945
|
+
renderResource(input.io, resource, health);
|
|
946
|
+
if (withPrepareGuidance) {
|
|
947
|
+
input.io.stdout("");
|
|
948
|
+
input.io.stdout("To prepare this exact paid call for human approval:");
|
|
949
|
+
input.io.stdout(` sotto buy ${resource.listingId}`);
|
|
950
|
+
input.io.stdout(
|
|
951
|
+
"Purchase initiation re-observes the live 402; a changed price stops before anything is journaled."
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
return EXIT.ok;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/commands/session.ts
|
|
958
|
+
async function loginCommand(input) {
|
|
959
|
+
const { io: io2, env, flags } = input;
|
|
960
|
+
const token = typeof flags.token === "string" ? flags.token : void 0;
|
|
961
|
+
const apiOrigin = typeof flags["api-origin"] === "string" ? flags["api-origin"] : void 0;
|
|
962
|
+
const walletUrl = typeof flags["wallet-url"] === "string" ? flags["wallet-url"] : void 0;
|
|
963
|
+
const existing = readConfig(env);
|
|
964
|
+
const origin = apiOrigin ?? env.SOTTO_API_ORIGIN ?? existing.apiOrigin;
|
|
965
|
+
if (token === void 0) {
|
|
966
|
+
io2.stdout("To connect this CLI to your Sotto owner session:");
|
|
967
|
+
io2.stdout("");
|
|
968
|
+
io2.stdout(
|
|
969
|
+
` 1. Open the Sotto app${origin === void 0 ? "" : ` for ${origin}`} and establish an owner session (hosted onboarding or party proof).`
|
|
970
|
+
);
|
|
971
|
+
io2.stdout(
|
|
972
|
+
" 2. Copy the one-time session token from the session response (the app shows it once; it is the same opaque token the browser cookie carries)."
|
|
973
|
+
);
|
|
974
|
+
io2.stdout(" 3. Run: sotto login --api-origin <api-url> --token <token>");
|
|
975
|
+
io2.stdout("");
|
|
976
|
+
io2.stdout(
|
|
977
|
+
"The token is stored 0600 in ~/.config/sotto/config.json and sent only as an Authorization: Bearer header to that API origin."
|
|
978
|
+
);
|
|
979
|
+
return EXIT.ok;
|
|
980
|
+
}
|
|
981
|
+
if (!isValidToken(token)) {
|
|
982
|
+
throw new CliUsageError(
|
|
983
|
+
"The session token must be the 64-hex value from the session response. Do not paste a cookie header or a signing key."
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
if (origin === void 0) {
|
|
987
|
+
throw new CliUsageError(
|
|
988
|
+
"Pass --api-origin <url> (or set SOTTO_API_ORIGIN) so the token is bound to one API origin."
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
const { client } = buildClient(env, { apiOrigin: origin }, input.fetchImpl);
|
|
992
|
+
const path = writeConfig(env, {
|
|
993
|
+
apiOrigin: origin,
|
|
994
|
+
token,
|
|
995
|
+
...walletUrl === void 0 ? existing.walletUrl === void 0 ? {} : { walletUrl: existing.walletUrl } : { walletUrl }
|
|
996
|
+
});
|
|
997
|
+
io2.stdout(`Owner session token stored (0600) at ${path}.`);
|
|
998
|
+
io2.stdout(`API origin: ${origin}`);
|
|
999
|
+
io2.stdout("Verify it with: sotto whoami");
|
|
1000
|
+
return EXIT.ok;
|
|
1001
|
+
}
|
|
1002
|
+
async function whoamiCommand(input) {
|
|
1003
|
+
const { io: io2, env, flags } = input;
|
|
1004
|
+
const context = buildClient(
|
|
1005
|
+
env,
|
|
1006
|
+
typeof flags["api-origin"] === "string" ? { apiOrigin: flags["api-origin"] } : {},
|
|
1007
|
+
input.fetchImpl
|
|
1008
|
+
);
|
|
1009
|
+
const hasToken = context.settings.token !== void 0;
|
|
1010
|
+
const sessionValid = hasToken ? await context.client.session.verify() : false;
|
|
1011
|
+
const report = {
|
|
1012
|
+
apiOrigin: context.client.origin,
|
|
1013
|
+
tokenConfigured: hasToken,
|
|
1014
|
+
tokenSource: context.settings.tokenSource,
|
|
1015
|
+
sessionValid
|
|
1016
|
+
};
|
|
1017
|
+
if (flags.json === true) {
|
|
1018
|
+
printJson(io2, report);
|
|
1019
|
+
} else {
|
|
1020
|
+
io2.stdout(`API origin: ${report.apiOrigin}`);
|
|
1021
|
+
io2.stdout(
|
|
1022
|
+
`Token: ${hasToken ? `configured (${report.tokenSource})` : "absent"}`
|
|
1023
|
+
);
|
|
1024
|
+
io2.stdout(
|
|
1025
|
+
`Session: ${sessionValid ? "valid owner session" : hasToken ? "rejected \u2014 expired or revoked; run `sotto login` again" : "absent \u2014 run `sotto login`"}`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
return sessionValid ? EXIT.ok : EXIT.auth;
|
|
1029
|
+
}
|
|
1030
|
+
async function logoutCommand(input) {
|
|
1031
|
+
const { io: io2, env } = input;
|
|
1032
|
+
const config = readConfig(env);
|
|
1033
|
+
if (config.token !== void 0 && config.apiOrigin !== void 0) {
|
|
1034
|
+
try {
|
|
1035
|
+
const { client } = buildClient(env, {}, input.fetchImpl);
|
|
1036
|
+
await client.session.logout();
|
|
1037
|
+
io2.stdout("Server session revoked.");
|
|
1038
|
+
} catch {
|
|
1039
|
+
io2.stderr(
|
|
1040
|
+
"The API did not confirm revocation; the local token is removed anyway. The session still expires server-side on its own TTL."
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (config.apiOrigin === void 0 && config.walletUrl === void 0) {
|
|
1045
|
+
deleteConfig(env);
|
|
1046
|
+
} else {
|
|
1047
|
+
writeConfig(env, {
|
|
1048
|
+
...config.apiOrigin === void 0 ? {} : { apiOrigin: config.apiOrigin },
|
|
1049
|
+
...config.walletUrl === void 0 ? {} : { walletUrl: config.walletUrl }
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
io2.stdout("Local session token removed.");
|
|
1053
|
+
return EXIT.ok;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// src/commands/status.ts
|
|
1057
|
+
function originFlag(flags) {
|
|
1058
|
+
return typeof flags["api-origin"] === "string" ? { apiOrigin: flags["api-origin"] } : {};
|
|
1059
|
+
}
|
|
1060
|
+
async function statusCommand(input) {
|
|
1061
|
+
const attemptId = input.positionals[0];
|
|
1062
|
+
if (attemptId === void 0) {
|
|
1063
|
+
throw new CliUsageError("Pass the attempt ID: sotto status <attemptId>");
|
|
1064
|
+
}
|
|
1065
|
+
const context = buildClient(
|
|
1066
|
+
input.env,
|
|
1067
|
+
originFlag(input.flags),
|
|
1068
|
+
input.fetchImpl
|
|
1069
|
+
);
|
|
1070
|
+
requireToken(context.settings);
|
|
1071
|
+
const detail = await context.client.purchases.get(attemptId);
|
|
1072
|
+
const outcome = pairedOutcome(
|
|
1073
|
+
detail.attempt.state,
|
|
1074
|
+
detail.delivery?.claimState ?? null
|
|
1075
|
+
);
|
|
1076
|
+
if (input.flags.json === true) {
|
|
1077
|
+
printJson(input.io, { ...detail, pairedOutcome: outcome });
|
|
1078
|
+
return EXIT.ok;
|
|
1079
|
+
}
|
|
1080
|
+
const { io: io2 } = input;
|
|
1081
|
+
io2.stdout(`Attempt ${detail.attempt.attemptId}`);
|
|
1082
|
+
io2.stdout(`State: ${detail.attempt.state}`);
|
|
1083
|
+
io2.stdout(`Created: ${detail.attempt.createdAt}`);
|
|
1084
|
+
io2.stdout(`Execute before: ${detail.attempt.executeBefore}`);
|
|
1085
|
+
for (const event of detail.events) {
|
|
1086
|
+
io2.stdout(
|
|
1087
|
+
railLine(
|
|
1088
|
+
"done",
|
|
1089
|
+
event.recordedAt,
|
|
1090
|
+
STATION_LABELS[event.type] ?? event.type
|
|
1091
|
+
)
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
io2.stdout(
|
|
1095
|
+
`Settlement: ${detail.settlement?.state ?? "not submitted"}` + (detail.settlement?.updateId == null ? "" : ` \u2014 update ${truncateUpdateId(detail.settlement.updateId)}`)
|
|
1096
|
+
);
|
|
1097
|
+
io2.stdout(
|
|
1098
|
+
`Delivery: ${detail.delivery?.claimState ?? "not started"}` + (detail.delivery?.failureCode == null ? "" : ` (${detail.delivery.failureCode})`)
|
|
1099
|
+
);
|
|
1100
|
+
if (outcome.deliveryPending || outcome.deliveryFailed) {
|
|
1101
|
+
reconcileGuidance(io2, detail.attempt.attemptId);
|
|
1102
|
+
}
|
|
1103
|
+
if (input.flags.follow === true && detail.events.length > 0) {
|
|
1104
|
+
const lastSequence = detail.events[detail.events.length - 1]?.sequence ?? 0;
|
|
1105
|
+
return followToExit(io2, context.client, attemptId, {
|
|
1106
|
+
executeBefore: detail.attempt.executeBefore,
|
|
1107
|
+
walletUrl: context.settings.walletUrl,
|
|
1108
|
+
lastEventId: lastSequence
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
return EXIT.ok;
|
|
1112
|
+
}
|
|
1113
|
+
async function evidenceCommand(input) {
|
|
1114
|
+
const attemptId = input.positionals[0];
|
|
1115
|
+
if (attemptId === void 0) {
|
|
1116
|
+
throw new CliUsageError("Pass the attempt ID: sotto evidence <attemptId>");
|
|
1117
|
+
}
|
|
1118
|
+
const context = buildClient(
|
|
1119
|
+
input.env,
|
|
1120
|
+
originFlag(input.flags),
|
|
1121
|
+
input.fetchImpl
|
|
1122
|
+
);
|
|
1123
|
+
const evidence = await context.client.attempts.evidence(attemptId);
|
|
1124
|
+
if (input.flags.json === true) {
|
|
1125
|
+
printJson(input.io, { attempt: evidence });
|
|
1126
|
+
return EXIT.ok;
|
|
1127
|
+
}
|
|
1128
|
+
const { io: io2 } = input;
|
|
1129
|
+
io2.stdout(`Attempt ${evidence.attemptId}`);
|
|
1130
|
+
if (evidence.resource !== null) {
|
|
1131
|
+
io2.stdout(
|
|
1132
|
+
`Resource: ${evidence.resource.method} ${evidence.resource.origin}${evidence.resource.route}`
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
if (evidence.amount !== null) {
|
|
1136
|
+
io2.stdout(
|
|
1137
|
+
`Amount: ${evidence.amount.atomic} ${evidence.amount.asset} (atomic units)`
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
io2.stdout(
|
|
1141
|
+
`Settlement: ${evidence.settlement.status}` + (evidence.settlement.updateId === null ? "" : ` \u2014 update ${evidence.settlement.updateId}`)
|
|
1142
|
+
);
|
|
1143
|
+
if (evidence.settlement.explorerUrl !== null) {
|
|
1144
|
+
io2.stdout(`Explorer: ${evidence.settlement.explorerUrl}`);
|
|
1145
|
+
}
|
|
1146
|
+
io2.stdout(
|
|
1147
|
+
`Delivery: ${evidence.delivery.status}` + (evidence.delivery.failureCode === null ? "" : ` (${evidence.delivery.failureCode})`)
|
|
1148
|
+
);
|
|
1149
|
+
for (const entry of evidence.timeline) {
|
|
1150
|
+
io2.stdout(
|
|
1151
|
+
railLine("done", entry.recordedAt, `${entry.type} [${entry.source}]`)
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
for (const redaction of evidence.redactions) {
|
|
1155
|
+
io2.stdout(`Withheld: ${redaction.field} \u2014 ${redaction.reason}`);
|
|
1156
|
+
}
|
|
1157
|
+
return EXIT.ok;
|
|
1158
|
+
}
|
|
1159
|
+
async function statsCommand(input) {
|
|
1160
|
+
const context = buildClient(
|
|
1161
|
+
input.env,
|
|
1162
|
+
originFlag(input.flags),
|
|
1163
|
+
input.fetchImpl
|
|
1164
|
+
);
|
|
1165
|
+
const window = typeof input.flags.window === "string" ? input.flags.window : "7d";
|
|
1166
|
+
const stats = await context.client.stats.read(window);
|
|
1167
|
+
if (input.flags.json === true) {
|
|
1168
|
+
printJson(input.io, stats);
|
|
1169
|
+
return EXIT.ok;
|
|
1170
|
+
}
|
|
1171
|
+
const { io: io2 } = input;
|
|
1172
|
+
const rate = (value) => value === null ? "unavailable (no denominator)" : `${(value * 100).toFixed(1)}%`;
|
|
1173
|
+
io2.stdout(`Window: ${stats.window}`);
|
|
1174
|
+
io2.stdout(
|
|
1175
|
+
`Attempts: ${stats.attempts.total} total, ${stats.attempts.executed} executed`
|
|
1176
|
+
);
|
|
1177
|
+
io2.stdout(`Settlement rate: ${rate(stats.attempts.settlementRate)}`);
|
|
1178
|
+
io2.stdout(`Delivery rate: ${rate(stats.attempts.deliveryRate)}`);
|
|
1179
|
+
io2.stdout(
|
|
1180
|
+
`Probes: ${stats.probes.observations} observations, healthy rate ${rate(stats.probes.healthyRate)}`
|
|
1181
|
+
);
|
|
1182
|
+
io2.stdout(`Source commit: ${stats.sourceCommit}`);
|
|
1183
|
+
return EXIT.ok;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/version.ts
|
|
1187
|
+
var CLI_VERSION = "0.1.0";
|
|
1188
|
+
|
|
1189
|
+
// src/mcp/protocol.ts
|
|
1190
|
+
var MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
1191
|
+
var JsonRpcError = class extends Error {
|
|
1192
|
+
code;
|
|
1193
|
+
constructor(code, message) {
|
|
1194
|
+
super(message);
|
|
1195
|
+
this.code = code;
|
|
1196
|
+
this.name = "JsonRpcError";
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
function parseMessage(line) {
|
|
1200
|
+
const trimmed = line.trim();
|
|
1201
|
+
if (trimmed === "") return void 0;
|
|
1202
|
+
let parsed;
|
|
1203
|
+
try {
|
|
1204
|
+
parsed = JSON.parse(trimmed);
|
|
1205
|
+
} catch {
|
|
1206
|
+
throw new JsonRpcError(-32700, "parse error: the line was not JSON");
|
|
1207
|
+
}
|
|
1208
|
+
if (typeof parsed !== "object" || parsed === null || parsed.jsonrpc !== "2.0") {
|
|
1209
|
+
throw new JsonRpcError(-32600, "invalid request: jsonrpc 2.0 required");
|
|
1210
|
+
}
|
|
1211
|
+
return parsed;
|
|
1212
|
+
}
|
|
1213
|
+
async function handleMessage(definition, message) {
|
|
1214
|
+
const id = message.id;
|
|
1215
|
+
const isRequest = id !== void 0;
|
|
1216
|
+
if (typeof message.method !== "string") {
|
|
1217
|
+
return isRequest ? respondError(id, -32600, "invalid request: method missing") : void 0;
|
|
1218
|
+
}
|
|
1219
|
+
if (message.method.startsWith("notifications/")) return void 0;
|
|
1220
|
+
const handler = message.method === "initialize" ? initializeHandler(definition) : message.method === "ping" ? () => ({}) : definition.methods[message.method];
|
|
1221
|
+
if (handler === void 0) {
|
|
1222
|
+
return isRequest ? respondError(id, -32601, `method not found: ${message.method}`) : void 0;
|
|
1223
|
+
}
|
|
1224
|
+
try {
|
|
1225
|
+
const result = await handler(message.params ?? {});
|
|
1226
|
+
return isRequest ? JSON.stringify({ jsonrpc: "2.0", id, result }) : void 0;
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
if (!isRequest) return void 0;
|
|
1229
|
+
if (error instanceof JsonRpcError) {
|
|
1230
|
+
return respondError(id, error.code, error.message);
|
|
1231
|
+
}
|
|
1232
|
+
return respondError(
|
|
1233
|
+
id,
|
|
1234
|
+
-32603,
|
|
1235
|
+
error instanceof Error ? error.message : "internal error"
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
function initializeHandler(definition) {
|
|
1240
|
+
return (params) => {
|
|
1241
|
+
const requested = params.protocolVersion;
|
|
1242
|
+
return {
|
|
1243
|
+
protocolVersion: typeof requested === "string" && requested !== "" ? requested : MCP_PROTOCOL_VERSION,
|
|
1244
|
+
capabilities: { tools: { listChanged: false } },
|
|
1245
|
+
serverInfo: definition.serverInfo
|
|
1246
|
+
};
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
function respondError(id, code, message) {
|
|
1250
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
|
1251
|
+
}
|
|
1252
|
+
async function serveJsonRpc(definition, streams) {
|
|
1253
|
+
let buffer = "";
|
|
1254
|
+
const decoder = new TextDecoder();
|
|
1255
|
+
for await (const chunk of streams.input) {
|
|
1256
|
+
buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
1257
|
+
for (; ; ) {
|
|
1258
|
+
const newline = buffer.indexOf("\n");
|
|
1259
|
+
if (newline === -1) break;
|
|
1260
|
+
const line = buffer.slice(0, newline);
|
|
1261
|
+
buffer = buffer.slice(newline + 1);
|
|
1262
|
+
let message;
|
|
1263
|
+
try {
|
|
1264
|
+
message = parseMessage(line);
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
if (error instanceof JsonRpcError) {
|
|
1267
|
+
streams.write(
|
|
1268
|
+
`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: error.code, message: error.message } })}
|
|
1269
|
+
`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
if (message === void 0) continue;
|
|
1275
|
+
const response = await handleMessage(definition, message);
|
|
1276
|
+
if (response !== void 0) streams.write(`${response}
|
|
1277
|
+
`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// src/mcp/tools.ts
|
|
1283
|
+
var text = (value) => ({
|
|
1284
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
|
|
1285
|
+
});
|
|
1286
|
+
var failure = (message) => ({
|
|
1287
|
+
content: [{ type: "text", text: message }],
|
|
1288
|
+
isError: true
|
|
1289
|
+
});
|
|
1290
|
+
var str = (value) => typeof value === "string" && value !== "" ? value : void 0;
|
|
1291
|
+
var TOOL_DEFINITIONS = Object.freeze([
|
|
1292
|
+
{
|
|
1293
|
+
name: "search_resources",
|
|
1294
|
+
title: "Search verified Canton x402 resources",
|
|
1295
|
+
description: "Search Sotto's verified catalog. Read-only; an empty catalog answers an honest empty list, never samples.",
|
|
1296
|
+
inputSchema: {
|
|
1297
|
+
type: "object",
|
|
1298
|
+
properties: {
|
|
1299
|
+
query: { type: "string", description: "Free-text filter" },
|
|
1300
|
+
method: { type: "string", description: "HTTP method filter" },
|
|
1301
|
+
maxPriceAtomic: {
|
|
1302
|
+
type: "string",
|
|
1303
|
+
description: "Upper price bound in atomic units"
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1306
|
+
additionalProperties: false
|
|
1307
|
+
},
|
|
1308
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
1309
|
+
},
|
|
1310
|
+
{
|
|
1311
|
+
name: "inspect_resource",
|
|
1312
|
+
title: "Inspect one verified resource",
|
|
1313
|
+
description: "Fetch one resource by listing ID or canonical URL: method, route, fresh server-observed price with its timestamp, recipient, and the latest health observation. Read-only.",
|
|
1314
|
+
inputSchema: {
|
|
1315
|
+
type: "object",
|
|
1316
|
+
properties: {
|
|
1317
|
+
resource: {
|
|
1318
|
+
type: "string",
|
|
1319
|
+
description: "Listing ID or canonical resource URL"
|
|
1320
|
+
}
|
|
1321
|
+
},
|
|
1322
|
+
required: ["resource"],
|
|
1323
|
+
additionalProperties: false
|
|
1324
|
+
},
|
|
1325
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
name: "purchase",
|
|
1329
|
+
title: "Purchase (moves real money after human wallet approval)",
|
|
1330
|
+
description: "Initiate ONE exact purchase of a verified resource. This journals a real payment intent; a HUMAN must approve the prepared call at the Sotto wallet before any value moves. This tool cannot sign, has no key, and never retries. Ask the human before calling it.",
|
|
1331
|
+
inputSchema: {
|
|
1332
|
+
type: "object",
|
|
1333
|
+
properties: {
|
|
1334
|
+
listingId: { type: "string", description: "Verified listing ID" },
|
|
1335
|
+
maxPriceAtomic: {
|
|
1336
|
+
type: "string",
|
|
1337
|
+
description: "Local policy bound (atomic units) \u2014 refused client-side, not a ledger-enforced limit"
|
|
1338
|
+
}
|
|
1339
|
+
},
|
|
1340
|
+
required: ["listingId"],
|
|
1341
|
+
additionalProperties: false
|
|
1342
|
+
},
|
|
1343
|
+
annotations: {
|
|
1344
|
+
readOnlyHint: false,
|
|
1345
|
+
destructiveHint: false,
|
|
1346
|
+
idempotentHint: false,
|
|
1347
|
+
openWorldHint: true
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
name: "purchase_status",
|
|
1352
|
+
title: "Read a purchase's full journal state",
|
|
1353
|
+
description: "Full lifecycle for one attempt: journal events, settlement and delivery as separate facts, including the honest settled-undelivered case with reconcile guidance. Read-only.",
|
|
1354
|
+
inputSchema: {
|
|
1355
|
+
type: "object",
|
|
1356
|
+
properties: { attemptId: { type: "string" } },
|
|
1357
|
+
required: ["attemptId"],
|
|
1358
|
+
additionalProperties: false
|
|
1359
|
+
},
|
|
1360
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
name: "get_evidence",
|
|
1364
|
+
title: "Read attempt evidence",
|
|
1365
|
+
description: "Paired settlement/delivery outcome, source-labeled timeline, update ID, and public explorer URL when indexed. Read-only.",
|
|
1366
|
+
inputSchema: {
|
|
1367
|
+
type: "object",
|
|
1368
|
+
properties: { attemptId: { type: "string" } },
|
|
1369
|
+
required: ["attemptId"],
|
|
1370
|
+
additionalProperties: false
|
|
1371
|
+
},
|
|
1372
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
1373
|
+
}
|
|
1374
|
+
]);
|
|
1375
|
+
async function callTool(client, name, args) {
|
|
1376
|
+
try {
|
|
1377
|
+
switch (name) {
|
|
1378
|
+
case "search_resources": {
|
|
1379
|
+
const maxPrice = parseMaxPrice(str(args.maxPriceAtomic));
|
|
1380
|
+
const query = str(args.query);
|
|
1381
|
+
const method = str(args.method);
|
|
1382
|
+
const resources = filterResources(
|
|
1383
|
+
await client.catalog.listResources(),
|
|
1384
|
+
{
|
|
1385
|
+
...query === void 0 ? {} : { query },
|
|
1386
|
+
...method === void 0 ? {} : { method },
|
|
1387
|
+
...maxPrice === void 0 ? {} : { maxPriceAtomic: maxPrice }
|
|
1388
|
+
}
|
|
1389
|
+
);
|
|
1390
|
+
return text({ resources });
|
|
1391
|
+
}
|
|
1392
|
+
case "inspect_resource": {
|
|
1393
|
+
const reference = str(args.resource);
|
|
1394
|
+
if (reference === void 0) return failure("resource is required");
|
|
1395
|
+
const resource = await resolveResource(client, reference);
|
|
1396
|
+
const health = await client.catalog.resourceHealth(resource.listingId);
|
|
1397
|
+
return text({ resource, health });
|
|
1398
|
+
}
|
|
1399
|
+
case "purchase": {
|
|
1400
|
+
const listingId = str(args.listingId);
|
|
1401
|
+
if (listingId === void 0) return failure("listingId is required");
|
|
1402
|
+
const maxPrice = parseMaxPrice(str(args.maxPriceAtomic));
|
|
1403
|
+
if (maxPrice !== void 0) {
|
|
1404
|
+
const resource = await client.catalog.resourceByListing(listingId);
|
|
1405
|
+
if (BigInt(resource.amountAtomic) > maxPrice) {
|
|
1406
|
+
return failure(
|
|
1407
|
+
`local-policy-stop: the indexed price ${resource.amountAtomic} ${resource.asset} (atomic) exceeds maxPriceAtomic ${maxPrice}. Nothing was initiated. This is local policy, not a ledger-enforced limit. Report the block to the human instead of seeking another transfer path.`
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
const initiated = await client.purchases.initiate(listingId);
|
|
1412
|
+
return text({
|
|
1413
|
+
...initiated,
|
|
1414
|
+
humanApproval: "A HUMAN must approve this exact prepared call at the Sotto wallet before any value moves. This tool cannot sign and will not retry. Follow progress with purchase_status."
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
case "purchase_status": {
|
|
1418
|
+
const attemptId = str(args.attemptId);
|
|
1419
|
+
if (attemptId === void 0) return failure("attemptId is required");
|
|
1420
|
+
const detail = await client.purchases.get(attemptId);
|
|
1421
|
+
const outcome = pairedOutcome(
|
|
1422
|
+
detail.attempt.state,
|
|
1423
|
+
detail.delivery?.claimState ?? null
|
|
1424
|
+
);
|
|
1425
|
+
return text({
|
|
1426
|
+
...detail,
|
|
1427
|
+
pairedOutcome: outcome,
|
|
1428
|
+
...outcome.deliveryPending || outcome.deliveryFailed ? {
|
|
1429
|
+
reconcileGuidance: "Settled but not delivered. Do NOT purchase again \u2014 a second attempt can pay twice. Reconcile with get_evidence and report the state to the human."
|
|
1430
|
+
} : {}
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
case "get_evidence": {
|
|
1434
|
+
const attemptId = str(args.attemptId);
|
|
1435
|
+
if (attemptId === void 0) return failure("attemptId is required");
|
|
1436
|
+
return text({ attempt: await client.attempts.evidence(attemptId) });
|
|
1437
|
+
}
|
|
1438
|
+
default:
|
|
1439
|
+
return failure(`unknown tool: ${name}`);
|
|
1440
|
+
}
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
if (error instanceof SottoApiError) {
|
|
1443
|
+
return failure(`${error.code}: ${error.detail ?? "no detail"}`);
|
|
1444
|
+
}
|
|
1445
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// src/mcp/serve.ts
|
|
1450
|
+
function buildMcpDefinition(env, fetchImpl) {
|
|
1451
|
+
const context = buildClient(env, {}, fetchImpl);
|
|
1452
|
+
requireToken(context.settings);
|
|
1453
|
+
return Object.freeze({
|
|
1454
|
+
serverInfo: Object.freeze({ name: "sotto", version: CLI_VERSION }),
|
|
1455
|
+
methods: Object.freeze({
|
|
1456
|
+
"tools/list": () => ({
|
|
1457
|
+
tools: TOOL_DEFINITIONS.map((tool) => ({
|
|
1458
|
+
name: tool.name,
|
|
1459
|
+
title: tool.title,
|
|
1460
|
+
description: tool.description,
|
|
1461
|
+
inputSchema: tool.inputSchema,
|
|
1462
|
+
annotations: tool.annotations
|
|
1463
|
+
}))
|
|
1464
|
+
}),
|
|
1465
|
+
"tools/call": async (params) => {
|
|
1466
|
+
const name = typeof params.name === "string" ? params.name : "";
|
|
1467
|
+
const args = typeof params.arguments === "object" && params.arguments !== null ? params.arguments : {};
|
|
1468
|
+
return callTool(context.client, name, args);
|
|
1469
|
+
}
|
|
1470
|
+
})
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
async function serveMcp(env, streams) {
|
|
1474
|
+
const definition = buildMcpDefinition(env);
|
|
1475
|
+
streams.logError(
|
|
1476
|
+
`sotto mcp serve: stdio JSON-RPC against ${env.SOTTO_API_ORIGIN ?? "configured origin"} \u2014 logs on stderr only`
|
|
1477
|
+
);
|
|
1478
|
+
await serveJsonRpc(definition, streams);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// src/run.ts
|
|
1482
|
+
var FLAG_OPTIONS = {
|
|
1483
|
+
"api-origin": { type: "string" },
|
|
1484
|
+
"max-price": { type: "string" },
|
|
1485
|
+
"no-wait": { type: "boolean" },
|
|
1486
|
+
"wallet-url": { type: "string" },
|
|
1487
|
+
follow: { type: "boolean" },
|
|
1488
|
+
help: { type: "boolean" },
|
|
1489
|
+
input: { type: "string" },
|
|
1490
|
+
json: { type: "boolean" },
|
|
1491
|
+
method: { type: "string" },
|
|
1492
|
+
tag: { type: "string" },
|
|
1493
|
+
token: { type: "string" },
|
|
1494
|
+
version: { type: "boolean" },
|
|
1495
|
+
window: { type: "string" }
|
|
1496
|
+
};
|
|
1497
|
+
function mapError(io2, error) {
|
|
1498
|
+
if (error instanceof CliUsageError) {
|
|
1499
|
+
io2.stderr(error.message);
|
|
1500
|
+
return EXIT.usage;
|
|
1501
|
+
}
|
|
1502
|
+
if (error instanceof CliAuthError) {
|
|
1503
|
+
io2.stderr(error.message);
|
|
1504
|
+
return EXIT.auth;
|
|
1505
|
+
}
|
|
1506
|
+
if (error instanceof SottoApiError) {
|
|
1507
|
+
if (error.code === "session-required") {
|
|
1508
|
+
io2.stderr(
|
|
1509
|
+
"The owner session is absent, expired, or revoked. Run `sotto login` with a fresh token from the Sotto app."
|
|
1510
|
+
);
|
|
1511
|
+
return EXIT.auth;
|
|
1512
|
+
}
|
|
1513
|
+
io2.stderr(`${error.code}: ${error.detail ?? `HTTP ${error.status}`}`);
|
|
1514
|
+
return EXIT.failure;
|
|
1515
|
+
}
|
|
1516
|
+
if (error instanceof SottoTransportError) {
|
|
1517
|
+
io2.stderr(error.message);
|
|
1518
|
+
return EXIT.failure;
|
|
1519
|
+
}
|
|
1520
|
+
io2.stderr(error instanceof Error ? error.message : String(error));
|
|
1521
|
+
return EXIT.failure;
|
|
1522
|
+
}
|
|
1523
|
+
async function run(argv, options) {
|
|
1524
|
+
const { io: io2, env } = options;
|
|
1525
|
+
let parsed;
|
|
1526
|
+
try {
|
|
1527
|
+
parsed = parseArgs({
|
|
1528
|
+
args: [...argv],
|
|
1529
|
+
options: FLAG_OPTIONS,
|
|
1530
|
+
allowPositionals: true,
|
|
1531
|
+
strict: true
|
|
1532
|
+
});
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
io2.stderr(error instanceof Error ? error.message : String(error));
|
|
1535
|
+
io2.stderr("Run `sotto --help` for usage.");
|
|
1536
|
+
return EXIT.usage;
|
|
1537
|
+
}
|
|
1538
|
+
const flags = parsed.values;
|
|
1539
|
+
const [command, ...positionals] = parsed.positionals;
|
|
1540
|
+
if (flags.version === true) {
|
|
1541
|
+
io2.stdout(CLI_VERSION);
|
|
1542
|
+
return EXIT.ok;
|
|
1543
|
+
}
|
|
1544
|
+
if (command === void 0 || flags.help === true) {
|
|
1545
|
+
io2.stdout(HELP_TEXT);
|
|
1546
|
+
return command === void 0 && flags.help !== true ? EXIT.usage : EXIT.ok;
|
|
1547
|
+
}
|
|
1548
|
+
const input = {
|
|
1549
|
+
io: io2,
|
|
1550
|
+
env,
|
|
1551
|
+
positionals,
|
|
1552
|
+
flags,
|
|
1553
|
+
...options.fetchImpl === void 0 ? {} : { fetchImpl: options.fetchImpl }
|
|
1554
|
+
};
|
|
1555
|
+
try {
|
|
1556
|
+
switch (command) {
|
|
1557
|
+
case "login":
|
|
1558
|
+
return await loginCommand(input);
|
|
1559
|
+
case "whoami":
|
|
1560
|
+
return await whoamiCommand(input);
|
|
1561
|
+
case "logout":
|
|
1562
|
+
return await logoutCommand(input);
|
|
1563
|
+
case "search":
|
|
1564
|
+
return await searchCommand(input);
|
|
1565
|
+
case "inspect":
|
|
1566
|
+
return await inspectCommand(input, false);
|
|
1567
|
+
case "try":
|
|
1568
|
+
return await inspectCommand(input, true);
|
|
1569
|
+
case "buy":
|
|
1570
|
+
return await buyCommand(input);
|
|
1571
|
+
case "status":
|
|
1572
|
+
return await statusCommand(input);
|
|
1573
|
+
case "evidence":
|
|
1574
|
+
return await evidenceCommand(input);
|
|
1575
|
+
case "stats":
|
|
1576
|
+
return await statsCommand(input);
|
|
1577
|
+
case "mcp": {
|
|
1578
|
+
if (positionals[0] !== "serve") {
|
|
1579
|
+
io2.stderr("Usage: sotto mcp serve");
|
|
1580
|
+
return EXIT.usage;
|
|
1581
|
+
}
|
|
1582
|
+
if (options.mcpStreams === void 0) {
|
|
1583
|
+
io2.stderr("MCP stdio streams are unavailable in this environment.");
|
|
1584
|
+
return EXIT.failure;
|
|
1585
|
+
}
|
|
1586
|
+
await serveMcp(env, options.mcpStreams);
|
|
1587
|
+
return EXIT.ok;
|
|
1588
|
+
}
|
|
1589
|
+
default:
|
|
1590
|
+
io2.stderr(`Unknown command: ${command}. Run \`sotto --help\`.`);
|
|
1591
|
+
return EXIT.usage;
|
|
1592
|
+
}
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
return mapError(io2, error);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// src/bin.ts
|
|
1599
|
+
var io = {
|
|
1600
|
+
stdout: (line) => process.stdout.write(`${line}
|
|
1601
|
+
`),
|
|
1602
|
+
stderr: (line) => process.stderr.write(`${line}
|
|
1603
|
+
`),
|
|
1604
|
+
env: process.env,
|
|
1605
|
+
isTTY: process.stdout.isTTY === true
|
|
1606
|
+
};
|
|
1607
|
+
var exitCode = await run(process.argv.slice(2), {
|
|
1608
|
+
io,
|
|
1609
|
+
env: process.env,
|
|
1610
|
+
mcpStreams: {
|
|
1611
|
+
input: process.stdin,
|
|
1612
|
+
write: (frame) => void process.stdout.write(frame),
|
|
1613
|
+
logError: (line) => void process.stderr.write(`${line}
|
|
1614
|
+
`)
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1617
|
+
process.exitCode = exitCode;
|
|
1618
|
+
//# sourceMappingURL=bin.js.map
|
|
1619
|
+
//# sourceMappingURL=bin.js.map
|