@funyard-org/cli 0.0.0-stage → 0.6.3
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/README.md +154 -2
- package/package.json +35 -4
- package/src/agent-selection.js +74 -0
- package/src/agent-setup.js +41 -0
- package/src/agent-skill.md +23 -0
- package/src/agents.js +14 -0
- package/src/browser-login.js +107 -0
- package/src/capabilities.js +3 -0
- package/src/cli-loopback.js +37 -0
- package/src/cli.js +339 -0
- package/src/client.d.ts +100 -0
- package/src/client.js +506 -0
- package/src/config.js +81 -0
- package/src/mcp.js +405 -0
- package/src/protocol/index.d.ts +5654 -0
- package/src/protocol/index.js +843 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { KNOWN_CAPABILITY_IDS } from './capabilities.js';
|
|
2
|
+
import {
|
|
3
|
+
REQUEST_ID,
|
|
4
|
+
CALL_ID,
|
|
5
|
+
FIELD_ID,
|
|
6
|
+
matches,
|
|
7
|
+
callIdentitySchema,
|
|
8
|
+
submissionSchema,
|
|
9
|
+
resultMatches,
|
|
10
|
+
fieldsResultMatches,
|
|
11
|
+
exploreRequestSchema,
|
|
12
|
+
fieldsQuoteRequestSchema,
|
|
13
|
+
fieldsQuoteSchema,
|
|
14
|
+
} from './protocol/index.js';
|
|
15
|
+
|
|
16
|
+
/** Comma list or array of canonical field ids, normalized the way the server fingerprints it. */
|
|
17
|
+
export function fieldList(value) {
|
|
18
|
+
const ids = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
|
|
19
|
+
const normalized = [...new Set(ids.map(id => (typeof id === 'string' ? id.trim() : '')).filter(Boolean))].sort();
|
|
20
|
+
if (!normalized.length || normalized.length > 64 || !normalized.every(id => FIELD_ID.test(id)))
|
|
21
|
+
throw new FunyardError('Provide 1-64 canonical field ids such as finance.quote.price.', {
|
|
22
|
+
code: 'INVALID_ARGUMENTS',
|
|
23
|
+
});
|
|
24
|
+
return normalized.join(',');
|
|
25
|
+
}
|
|
26
|
+
export { REQUEST_ID, CALL_ID } from './protocol/index.js';
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_BASE_URL = 'https://funyard.org';
|
|
29
|
+
const CAPABILITY_NAME = /^[a-z][a-z0-9_]{0,31}\.[a-z][a-z0-9_]{0,31}$/;
|
|
30
|
+
const SAFE_API_CODES = new Set([
|
|
31
|
+
'INVALID_KEY',
|
|
32
|
+
'UNAUTHORIZED',
|
|
33
|
+
'FORBIDDEN',
|
|
34
|
+
'ACCOUNT_UNAVAILABLE',
|
|
35
|
+
'CAPABILITY_DENIED',
|
|
36
|
+
'CAPABILITY_NOT_FOUND',
|
|
37
|
+
'CAPABILITY_UNAVAILABLE',
|
|
38
|
+
'INVALID_ARGUMENTS',
|
|
39
|
+
'INVALID_REQUEST_ID',
|
|
40
|
+
'IDEMPOTENCY_CONFLICT',
|
|
41
|
+
'INSUFFICIENT_BALANCE',
|
|
42
|
+
'QUEUE_LIMIT',
|
|
43
|
+
'HOLD_LIMIT',
|
|
44
|
+
'RATE_LIMITED',
|
|
45
|
+
'PROVIDER_CAPACITY',
|
|
46
|
+
'NOT_CANCELLABLE',
|
|
47
|
+
'CALL_NOT_FOUND',
|
|
48
|
+
'NOT_FOUND',
|
|
49
|
+
'KEY_IN_URL_FORBIDDEN',
|
|
50
|
+
'REQUEST_TOO_LARGE',
|
|
51
|
+
'INVALID_INTEGER',
|
|
52
|
+
'INVALID_WINDOW',
|
|
53
|
+
'INVALID_JSON',
|
|
54
|
+
'INGRESS_RATE_LIMITED',
|
|
55
|
+
'INTERNAL_ERROR',
|
|
56
|
+
'ACCOUNTING_UNAVAILABLE',
|
|
57
|
+
'INVALID_API_KEY',
|
|
58
|
+
'ACCOUNT_DISABLED',
|
|
59
|
+
'CAPABILITY_FORBIDDEN',
|
|
60
|
+
'INVALID_REQUEST',
|
|
61
|
+
'INVALID_CONTENT_TYPE',
|
|
62
|
+
'REQUEST_ID_CONFLICT',
|
|
63
|
+
'CALL_ALREADY_DISPATCHED',
|
|
64
|
+
'PROVIDER_NOT_CONFIGURED',
|
|
65
|
+
'PROVIDER_DISABLED',
|
|
66
|
+
'PROVIDER_COOLDOWN',
|
|
67
|
+
'SERVICE_UNAVAILABLE',
|
|
68
|
+
'CANCELLED',
|
|
69
|
+
'QUEUE_EXPIRED',
|
|
70
|
+
'RESULT_UNKNOWN',
|
|
71
|
+
'INGRESS_RPM',
|
|
72
|
+
'RESULT_READ_RPM',
|
|
73
|
+
'SUBMISSION_RPM',
|
|
74
|
+
'CANCEL_RPM',
|
|
75
|
+
'ACCOUNT_RPM',
|
|
76
|
+
'KEY_RPM',
|
|
77
|
+
'ACCOUNT_DAILY_CALLS',
|
|
78
|
+
'KEY_DAILY_CALLS',
|
|
79
|
+
'ACCOUNT_DAILY_POINTS',
|
|
80
|
+
'KEY_DAILY_POINTS',
|
|
81
|
+
'ACCOUNT_QUEUE_LIMIT',
|
|
82
|
+
'KEY_QUEUE_LIMIT',
|
|
83
|
+
'ACCOUNT_HELD_LIMIT',
|
|
84
|
+
'KEY_HELD_LIMIT',
|
|
85
|
+
'OUTCOME_PENDING',
|
|
86
|
+
'INVALID_QUERY',
|
|
87
|
+
'PROVIDER_QUOTA',
|
|
88
|
+
'PROVIDER_CYCLE_NOT_STARTED',
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
export class FunyardError extends Error {
|
|
92
|
+
constructor(message, { status, code, requestId, callId, reason, retryAfter } = {}) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = 'FunyardError';
|
|
95
|
+
this.status = status;
|
|
96
|
+
this.code = code;
|
|
97
|
+
this.requestId = requestId;
|
|
98
|
+
this.callId = callId;
|
|
99
|
+
this.reason = reason;
|
|
100
|
+
this.retryAfter = retryAfter;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function validateBaseURL(value) {
|
|
105
|
+
let url;
|
|
106
|
+
try {
|
|
107
|
+
url = new URL(value);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new FunyardError('Invalid API base URL.');
|
|
110
|
+
}
|
|
111
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
112
|
+
if (
|
|
113
|
+
(url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) ||
|
|
114
|
+
url.username ||
|
|
115
|
+
url.password ||
|
|
116
|
+
url.search ||
|
|
117
|
+
url.hash ||
|
|
118
|
+
(url.pathname !== '/' && url.pathname !== '')
|
|
119
|
+
) {
|
|
120
|
+
throw new FunyardError(
|
|
121
|
+
'API base URL must be an HTTPS origin (HTTP is allowed only on localhost), without credentials, path, query or fragment.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return url.origin;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function identifier(value, name) {
|
|
128
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 256 || /[\x00-\x1f\x7f]/.test(value))
|
|
129
|
+
throw new FunyardError(`Invalid ${name}.`);
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function requestIdentifier(value) {
|
|
134
|
+
if (typeof value !== 'string' || !REQUEST_ID.test(value))
|
|
135
|
+
throw new FunyardError(
|
|
136
|
+
'Supply an explicit request ID: 1–64 lowercase letters, digits, underscores or hyphens, beginning with a letter or digit.',
|
|
137
|
+
{ code: 'INVALID_REQUEST_ID' },
|
|
138
|
+
);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function callIdentifier(value) {
|
|
143
|
+
if (typeof value !== 'string' || !CALL_ID.test(value))
|
|
144
|
+
throw new FunyardError('A valid call_ ID is required.', { code: 'INVALID_CALL_ID' });
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function trustedCall(response, expected) {
|
|
149
|
+
if (
|
|
150
|
+
!matches(callIdentitySchema, response) ||
|
|
151
|
+
Object.entries(expected).some(([field, value]) => response[field] !== value)
|
|
152
|
+
) {
|
|
153
|
+
throw new FunyardError(
|
|
154
|
+
'The response does not match the original call. Query the same ID before doing anything else.',
|
|
155
|
+
{
|
|
156
|
+
code: 'RESPONSE_UNKNOWN',
|
|
157
|
+
reason: 'CALL_IDENTITY_INVALID',
|
|
158
|
+
requestId: expected.request_id,
|
|
159
|
+
callId: expected.call_id,
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
// Capabilities this build knows are checked strictly; a field capability the server added later
|
|
164
|
+
// is accepted only in the shared field-result shape. Anything else fails closed.
|
|
165
|
+
const known = KNOWN_CAPABILITY_IDS.includes(response.capability);
|
|
166
|
+
if (
|
|
167
|
+
Object.hasOwn(response, 'data') &&
|
|
168
|
+
!(known
|
|
169
|
+
? resultMatches(response.capability, response.status, response.data)
|
|
170
|
+
: fieldsResultMatches(response.status, response.data))
|
|
171
|
+
) {
|
|
172
|
+
throw new FunyardError(
|
|
173
|
+
'The result does not match the V1 capability contract. Query the same ID before doing anything else.',
|
|
174
|
+
{
|
|
175
|
+
code: 'RESPONSE_UNKNOWN',
|
|
176
|
+
reason: 'CALL_RESULT_INVALID',
|
|
177
|
+
requestId: expected.request_id ?? response.request_id,
|
|
178
|
+
callId: expected.call_id ?? response.call_id,
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return response;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function httpError(response, data, requestId) {
|
|
186
|
+
const candidate = data?.error?.code ?? data?.code;
|
|
187
|
+
const code = SAFE_API_CODES.has(candidate) ? candidate : 'HTTP_ERROR';
|
|
188
|
+
const header = response.headers.get('retry-after');
|
|
189
|
+
let retryAfter;
|
|
190
|
+
if (header && /^\d+$/.test(header)) {
|
|
191
|
+
const seconds = Number(header);
|
|
192
|
+
if (Number.isSafeInteger(seconds) && seconds <= 86400) retryAfter = seconds;
|
|
193
|
+
} else if (
|
|
194
|
+
header &&
|
|
195
|
+
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(
|
|
196
|
+
header,
|
|
197
|
+
)
|
|
198
|
+
) {
|
|
199
|
+
const seconds = Math.max(0, Math.ceil((Date.parse(header) - Date.now()) / 1000));
|
|
200
|
+
if (Number.isSafeInteger(seconds) && seconds <= 86400) retryAfter = seconds;
|
|
201
|
+
}
|
|
202
|
+
return new FunyardError(`API request failed (HTTP ${response.status}).`, {
|
|
203
|
+
status: response.status,
|
|
204
|
+
code,
|
|
205
|
+
requestId,
|
|
206
|
+
retryAfter,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validFieldQuotePath(path) {
|
|
211
|
+
if (typeof path !== 'string' || !path.startsWith('/fields/quote?') || path.includes('#') || path.length > 16384)
|
|
212
|
+
return false;
|
|
213
|
+
const query = new URLSearchParams(path.slice('/fields/quote?'.length));
|
|
214
|
+
if (
|
|
215
|
+
[...new Set(query.keys())].some(key => query.getAll(key).length !== 1) ||
|
|
216
|
+
[...query.keys()].some(key => !['fields', 'sources', 'capability', 'arguments'].includes(key))
|
|
217
|
+
)
|
|
218
|
+
return false;
|
|
219
|
+
try {
|
|
220
|
+
fieldList(query.get('fields'));
|
|
221
|
+
if (!query.has('sources')) return [...query.keys()].length === 1;
|
|
222
|
+
const input = {
|
|
223
|
+
fields: query.get('fields'),
|
|
224
|
+
sources: query.get('sources'),
|
|
225
|
+
capability: query.get('capability'),
|
|
226
|
+
arguments: JSON.parse(query.get('arguments') ?? '{}'),
|
|
227
|
+
};
|
|
228
|
+
return matches(fieldsQuoteRequestSchema, input) && input.sources === 'all';
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export class FunyardClient {
|
|
235
|
+
constructor(options = process.env, extra = {}) {
|
|
236
|
+
const apiKey = options.apiKey ?? options.FUNYARD_API_KEY ?? process.env.FUNYARD_API_KEY;
|
|
237
|
+
const baseURL = options.baseURL ?? options.FUNYARD_BASE_URL ?? process.env.FUNYARD_BASE_URL ?? DEFAULT_BASE_URL;
|
|
238
|
+
const timeoutMs = extra.timeoutMs ?? options.timeoutMs ?? 30_000;
|
|
239
|
+
const fetchImpl = extra.fetch ?? options.fetch ?? globalThis.fetch;
|
|
240
|
+
this.baseURL = validateBaseURL(baseURL);
|
|
241
|
+
if (typeof apiKey !== 'string' || !apiKey.trim() || /[\s\x00-\x1f\x7f]/.test(apiKey))
|
|
242
|
+
throw new FunyardError('A valid API key is required. Use FUNYARD_API_KEY or funyard login.');
|
|
243
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300_000)
|
|
244
|
+
throw new FunyardError('Invalid request timeout.');
|
|
245
|
+
Object.defineProperty(this, 'apiKey', { value: apiKey });
|
|
246
|
+
this.timeoutMs = timeoutMs;
|
|
247
|
+
this.fetch = fetchImpl;
|
|
248
|
+
const diagnostic = extra.onDiagnostic ?? options.onDiagnostic;
|
|
249
|
+
if (diagnostic !== undefined && typeof diagnostic !== 'function')
|
|
250
|
+
throw new FunyardError('Invalid diagnostic callback.');
|
|
251
|
+
this.onDiagnostic = diagnostic ?? (event => process.stderr.write(`${JSON.stringify(event)}\n`));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async request(path, options = {}, legacyOptions = {}) {
|
|
255
|
+
// Preserve the published SDK's request(method, route, options) entry point.
|
|
256
|
+
if (['GET', 'POST'].includes(path) && typeof options === 'string') {
|
|
257
|
+
const method = path;
|
|
258
|
+
path = options;
|
|
259
|
+
options = { ...legacyOptions, method, requestId: legacyOptions.requestID, callId: legacyOptions.callID };
|
|
260
|
+
}
|
|
261
|
+
const { method = 'GET', body, requestId, callId, signal } = options;
|
|
262
|
+
if (
|
|
263
|
+
!['GET', 'POST'].includes(method) ||
|
|
264
|
+
typeof path !== 'string' ||
|
|
265
|
+
(!validFieldQuotePath(path) &&
|
|
266
|
+
!/^\/(?:me|usage(?:\?[^#]*)?|capabilities(?:\/[a-z.]+)?|fields(?:\?category=[a-z]{1,32})?|fields\/quote\?fields=[A-Za-z0-9_.,%]+|explore(?:\?(?:domain|capability|group|q)=[A-Za-z0-9_.%-]{1,900})?|calls(?:\?request_id=[a-z0-9_-]+|\/call_[A-Za-z0-9_-]+(?:\/cancel)?)?)$/.test(
|
|
267
|
+
path,
|
|
268
|
+
))
|
|
269
|
+
)
|
|
270
|
+
throw new FunyardError('Unsupported API route.', { code: 'INVALID_ARGUMENTS' });
|
|
271
|
+
if (body !== undefined && Buffer.byteLength(JSON.stringify(body)) > 65536)
|
|
272
|
+
throw new FunyardError('Request body exceeds 64 KiB.', { code: 'REQUEST_TOO_LARGE', requestId, callId });
|
|
273
|
+
const uncertain = reason =>
|
|
274
|
+
new FunyardError(
|
|
275
|
+
'No trustworthy response was received. Query the original ID; never submit a replacement automatically.',
|
|
276
|
+
{ code: 'RESPONSE_UNKNOWN', reason, requestId, callId },
|
|
277
|
+
);
|
|
278
|
+
const retry = async (reason, status) => {
|
|
279
|
+
const event = { event: 'funyard_retry', method: 'GET', attempt: 2, reason, ...(status ? { status } : {}) };
|
|
280
|
+
try {
|
|
281
|
+
this.onDiagnostic(event);
|
|
282
|
+
} catch {
|
|
283
|
+
/* Diagnostics never change request semantics. */
|
|
284
|
+
}
|
|
285
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
286
|
+
};
|
|
287
|
+
const connectionInterrupted = error =>
|
|
288
|
+
error?.name === 'TypeError' ||
|
|
289
|
+
['ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'EAI_AGAIN'].includes(
|
|
290
|
+
error?.cause?.code ?? error?.code,
|
|
291
|
+
);
|
|
292
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
293
|
+
let response;
|
|
294
|
+
try {
|
|
295
|
+
response = await this.fetch(`${this.baseURL}/funyard/v1${path}`, {
|
|
296
|
+
method,
|
|
297
|
+
headers: {
|
|
298
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
299
|
+
Accept: 'application/json',
|
|
300
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
301
|
+
},
|
|
302
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
303
|
+
signal: signal
|
|
304
|
+
? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)])
|
|
305
|
+
: AbortSignal.timeout(this.timeoutMs),
|
|
306
|
+
// Manual mode refuses redirects without turning a redirect into a retryable transport error.
|
|
307
|
+
redirect: 'manual',
|
|
308
|
+
});
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (method === 'GET' && attempt === 0 && !signal?.aborted && connectionInterrupted(error)) {
|
|
311
|
+
await retry('connection_interrupted');
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
throw uncertain(signal?.aborted ? 'ABORT_ERR' : 'NETWORK_ERROR');
|
|
315
|
+
}
|
|
316
|
+
let data;
|
|
317
|
+
if (response.status >= 300 && response.status < 400) throw uncertain('REDIRECT_REJECTED');
|
|
318
|
+
if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? ''))
|
|
319
|
+
throw uncertain('CONTENT_TYPE_INVALID');
|
|
320
|
+
try {
|
|
321
|
+
const reader = response.body?.getReader();
|
|
322
|
+
let size = 0;
|
|
323
|
+
const chunks = [];
|
|
324
|
+
if (reader)
|
|
325
|
+
for (;;) {
|
|
326
|
+
const { done, value } = await reader.read();
|
|
327
|
+
if (done) break;
|
|
328
|
+
size += value.byteLength;
|
|
329
|
+
if (size > 1_048_576) {
|
|
330
|
+
await reader.cancel();
|
|
331
|
+
throw uncertain('RESPONSE_TOO_LARGE');
|
|
332
|
+
}
|
|
333
|
+
chunks.push(Buffer.from(value));
|
|
334
|
+
}
|
|
335
|
+
data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (error instanceof FunyardError) throw error;
|
|
338
|
+
if (method === 'GET' && attempt === 0 && response.ok && connectionInterrupted(error)) {
|
|
339
|
+
await retry('connection_interrupted');
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (!response.ok) throw httpError(response, undefined, requestId);
|
|
343
|
+
throw uncertain('JSON_INVALID');
|
|
344
|
+
}
|
|
345
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw uncertain('RESPONSE_SHAPE_INVALID');
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
// Only parsed JSON responses in this explicit GET allowlist may retry.
|
|
348
|
+
if (method === 'GET' && attempt === 0 && [408, 502, 503, 504].includes(response.status)) {
|
|
349
|
+
await retry('http_transient', response.status);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
throw httpError(response, data, requestId);
|
|
353
|
+
}
|
|
354
|
+
return JSON.parse(redact(data, this.apiKey));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
me(signal) {
|
|
359
|
+
return this.request('/me', { signal });
|
|
360
|
+
}
|
|
361
|
+
async capabilities(id, { signal } = {}) {
|
|
362
|
+
const response = await this.request(
|
|
363
|
+
id === undefined ? '/capabilities' : `/capabilities/${encodeURIComponent(identifier(id, 'capability'))}`,
|
|
364
|
+
{ signal },
|
|
365
|
+
);
|
|
366
|
+
return response;
|
|
367
|
+
}
|
|
368
|
+
usage(options = {}) {
|
|
369
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).length)
|
|
370
|
+
throw new FunyardError('V1 usage returns the latest 100 account ledger entries and does not accept filters.', {
|
|
371
|
+
code: 'INVALID_ARGUMENTS',
|
|
372
|
+
});
|
|
373
|
+
return this.request('/usage');
|
|
374
|
+
}
|
|
375
|
+
/** Field catalog: ids, their billing group and the group's price. Optional category filter. */
|
|
376
|
+
fields(category, { signal } = {}) {
|
|
377
|
+
if (category !== undefined && !/^[a-z]{1,32}$/.test(category))
|
|
378
|
+
throw new FunyardError('Invalid field category.', { code: 'INVALID_ARGUMENTS' });
|
|
379
|
+
return this.request(category === undefined ? '/fields' : `/fields?category=${category}`, { signal });
|
|
380
|
+
}
|
|
381
|
+
/** Progressive discovery: no option lists domains; one of domain, capability, group or q opens that level. */
|
|
382
|
+
explore(options = {}, { signal } = {}) {
|
|
383
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
384
|
+
throw new FunyardError('Explore options must be an object.', { code: 'INVALID_ARGUMENTS' });
|
|
385
|
+
const entries = Object.entries(options).filter(([, value]) => value !== undefined);
|
|
386
|
+
if (entries.length > 1 || !matches(exploreRequestSchema, Object.fromEntries(entries)))
|
|
387
|
+
throw new FunyardError('Use at most one of domain, capability, group or q.', { code: 'INVALID_ARGUMENTS' });
|
|
388
|
+
const [entry] = entries;
|
|
389
|
+
return this.request(entry ? `/explore?${entry[0]}=${encodeURIComponent(entry[1])}` : '/explore', { signal });
|
|
390
|
+
}
|
|
391
|
+
/** Dry-run price for a field list; nothing is reserved or charged. */
|
|
392
|
+
fieldsQuote(fields, { signal, sources, capability, arguments: args } = {}) {
|
|
393
|
+
const normalized = fieldList(fields);
|
|
394
|
+
let path = `/fields/quote?fields=${encodeURIComponent(normalized)}`;
|
|
395
|
+
if (sources !== undefined || capability !== undefined || args !== undefined) {
|
|
396
|
+
const input = { fields: normalized, sources, capability, arguments: args ?? {} };
|
|
397
|
+
if (sources !== 'all' || !matches(fieldsQuoteRequestSchema, input))
|
|
398
|
+
throw new FunyardError('Multi-source quotes require sources=all, a capability and string arguments.', {
|
|
399
|
+
code: 'INVALID_ARGUMENTS',
|
|
400
|
+
});
|
|
401
|
+
path += `&sources=all&capability=${encodeURIComponent(capability)}&arguments=${encodeURIComponent(JSON.stringify(args ?? {}))}`;
|
|
402
|
+
}
|
|
403
|
+
return this.request(path, { signal }).then(result => {
|
|
404
|
+
if (
|
|
405
|
+
sources === 'all' &&
|
|
406
|
+
(!matches(fieldsQuoteSchema, result) || result.sources !== 'all' || result.lines.some(line => !line.source))
|
|
407
|
+
)
|
|
408
|
+
throw new FunyardError('Server did not confirm a multi-source quote.', { code: 'INVALID_RESPONSE' });
|
|
409
|
+
return result;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async call(capability, args = {}, { requestId, signal } = {}) {
|
|
413
|
+
// The server decides what exists; this only refuses malformed names.
|
|
414
|
+
if (typeof capability !== 'string' || !CAPABILITY_NAME.test(capability))
|
|
415
|
+
throw new FunyardError('Unknown capability.', { code: 'INVALID_ARGUMENTS' });
|
|
416
|
+
requestIdentifier(requestId);
|
|
417
|
+
if (!matches(submissionSchema, { request_id: requestId, capability, arguments: args }))
|
|
418
|
+
throw new FunyardError('Arguments must be an object of strings.');
|
|
419
|
+
return trustedCall(
|
|
420
|
+
await this.request('/calls', {
|
|
421
|
+
method: 'POST',
|
|
422
|
+
body: { request_id: requestId, capability, arguments: args },
|
|
423
|
+
requestId,
|
|
424
|
+
signal,
|
|
425
|
+
}),
|
|
426
|
+
{ request_id: requestId, capability },
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
create(capability, requestId, args = {}, signal) {
|
|
430
|
+
return this.call(capability, args, { requestId: requestIdentifier(requestId), signal });
|
|
431
|
+
}
|
|
432
|
+
getCall(args = {}, signal) {
|
|
433
|
+
if (
|
|
434
|
+
!args ||
|
|
435
|
+
typeof args !== 'object' ||
|
|
436
|
+
Array.isArray(args) ||
|
|
437
|
+
Object.keys(args).length !== 1 ||
|
|
438
|
+
!Object.keys(args).every(key => ['call_id', 'request_id'].includes(key))
|
|
439
|
+
)
|
|
440
|
+
throw new FunyardError('Provide exactly one call ID or request ID.');
|
|
441
|
+
return this.status({ callId: args.call_id, requestId: args.request_id }, signal);
|
|
442
|
+
}
|
|
443
|
+
status({ callId, requestId } = {}, signal) {
|
|
444
|
+
if ((callId !== undefined) === (requestId !== undefined))
|
|
445
|
+
throw new FunyardError('Provide exactly one call ID or request ID.');
|
|
446
|
+
const expected =
|
|
447
|
+
callId !== undefined ? { call_id: callIdentifier(callId) } : { request_id: requestIdentifier(requestId) };
|
|
448
|
+
return this.request(callId !== undefined ? `/calls/${callId}` : `/calls?request_id=${requestId}`, {
|
|
449
|
+
requestId,
|
|
450
|
+
callId,
|
|
451
|
+
signal,
|
|
452
|
+
}).then(response => trustedCall(response, expected));
|
|
453
|
+
}
|
|
454
|
+
cancel(callId, signal) {
|
|
455
|
+
callIdentifier(callId);
|
|
456
|
+
return this.request(`/calls/${callId}/cancel`, { method: 'POST', body: {}, callId, signal }).then(response =>
|
|
457
|
+
trustedCall(response, { call_id: callId }),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function safeError(error) {
|
|
463
|
+
if (!(error instanceof FunyardError)) return { error: { message: 'Operation failed.' } };
|
|
464
|
+
return {
|
|
465
|
+
error: {
|
|
466
|
+
message: error.message,
|
|
467
|
+
...(error.code ? { code: error.code } : {}),
|
|
468
|
+
...(error.status ? { status: error.status } : {}),
|
|
469
|
+
...(error.retryAfter !== undefined ? { retry_after: error.retryAfter } : {}),
|
|
470
|
+
...(error.requestId
|
|
471
|
+
? {
|
|
472
|
+
request_id: error.requestId,
|
|
473
|
+
recovery:
|
|
474
|
+
'Use funyard status --request-id with this request ID and the same API base URL before resubmitting.',
|
|
475
|
+
}
|
|
476
|
+
: {}),
|
|
477
|
+
...(error.callId
|
|
478
|
+
? {
|
|
479
|
+
call_id: error.callId,
|
|
480
|
+
recovery: 'Use funyard status --call-id with the original call ID before resubmitting.',
|
|
481
|
+
}
|
|
482
|
+
: {}),
|
|
483
|
+
},
|
|
484
|
+
...(error.reason ? { transport_reason: error.reason } : {}),
|
|
485
|
+
...(error.requestId
|
|
486
|
+
? { request_id: error.requestId, recovery: { command: 'status', request_id: error.requestId } }
|
|
487
|
+
: error.callId
|
|
488
|
+
? { call_id: error.callId, recovery: { command: 'status', call_id: error.callId } }
|
|
489
|
+
: {}),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function redact(value, secret) {
|
|
494
|
+
// Apply to successful responses too: an upstream must not cause key disclosure.
|
|
495
|
+
let encoded = JSON.stringify(value);
|
|
496
|
+
if (encoded === undefined) return 'null';
|
|
497
|
+
if (secret)
|
|
498
|
+
for (const variant of new Set([
|
|
499
|
+
secret,
|
|
500
|
+
encodeURIComponent(secret),
|
|
501
|
+
Buffer.from(secret).toString('base64'),
|
|
502
|
+
Buffer.from(secret).toString('base64url'),
|
|
503
|
+
]))
|
|
504
|
+
encoded = encoded.split(JSON.stringify(variant).slice(1, -1)).join('[REDACTED]');
|
|
505
|
+
return encoded;
|
|
506
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { open, mkdir, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { FunyardError, validateBaseURL } from './client.js';
|
|
7
|
+
|
|
8
|
+
export function configPath(env = process.env) {
|
|
9
|
+
return env.FUNYARD_CONFIG_FILE || join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'funyard', 'config.json');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readConfig(path = configPath()) {
|
|
13
|
+
let file;
|
|
14
|
+
try {
|
|
15
|
+
file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
16
|
+
const stat = await file.stat();
|
|
17
|
+
if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || (process.getuid && stat.uid !== process.getuid()))
|
|
18
|
+
throw new FunyardError('Config must be owned by this user and have permissions 600.');
|
|
19
|
+
const config = JSON.parse(await file.readFile('utf8'));
|
|
20
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('invalid');
|
|
21
|
+
if (config.baseURL) validateBaseURL(config.baseURL);
|
|
22
|
+
return config;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error.code === 'ENOENT') return {};
|
|
25
|
+
if (error instanceof FunyardError) throw error;
|
|
26
|
+
throw new FunyardError('Unable to read secure config file.');
|
|
27
|
+
} finally {
|
|
28
|
+
await file?.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeConfig(config, path = configPath()) {
|
|
33
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
34
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
35
|
+
let file;
|
|
36
|
+
try {
|
|
37
|
+
file = await open(temporary, 'wx', 0o600);
|
|
38
|
+
await file.writeFile(`${JSON.stringify(config, null, 2)}\n`);
|
|
39
|
+
await file.sync();
|
|
40
|
+
await file.close();
|
|
41
|
+
file = undefined;
|
|
42
|
+
await rename(temporary, path);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new FunyardError('Unable to save secure config file.');
|
|
45
|
+
} finally {
|
|
46
|
+
await file?.close();
|
|
47
|
+
await unlink(temporary).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readSecret(input = process.stdin, output = process.stderr) {
|
|
52
|
+
if (!input.isTTY || !input.setRawMode)
|
|
53
|
+
throw new FunyardError('Login requires an interactive terminal; for automation use FUNYARD_API_KEY.');
|
|
54
|
+
output.write('API key (hidden): ');
|
|
55
|
+
const wasRaw = input.isRaw;
|
|
56
|
+
input.setRawMode(true);
|
|
57
|
+
input.resume();
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let secret = '';
|
|
60
|
+
const finish = error => {
|
|
61
|
+
input.off('data', onData);
|
|
62
|
+
input.off('end', onEnd);
|
|
63
|
+
input.setRawMode(wasRaw);
|
|
64
|
+
input.pause();
|
|
65
|
+
output.write('\n');
|
|
66
|
+
error ? reject(error) : resolve(secret);
|
|
67
|
+
};
|
|
68
|
+
const onEnd = () => finish(new FunyardError('Login input ended.'));
|
|
69
|
+
const onData = chunk => {
|
|
70
|
+
for (const char of chunk.toString('utf8')) {
|
|
71
|
+
if (char === '\u0003' || char === '\u0004') return finish(new FunyardError('Login cancelled.'));
|
|
72
|
+
if (char === '\r' || char === '\n') return finish();
|
|
73
|
+
if (char === '\u007f' || char === '\b') secret = secret.slice(0, -1);
|
|
74
|
+
else if (char >= ' ') secret += char;
|
|
75
|
+
}
|
|
76
|
+
if (secret.length > 8192) finish(new FunyardError('API key is too long.'));
|
|
77
|
+
};
|
|
78
|
+
input.on('data', onData);
|
|
79
|
+
input.once('end', onEnd);
|
|
80
|
+
});
|
|
81
|
+
}
|