ultrastiching-cli 0.0.0-stage → 0.2.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/src/http.js ADDED
@@ -0,0 +1,129 @@
1
+ import { endpoint } from './registry.js';
2
+ import { normalizeOrigin, resolveBaseUrl, resolveKey } from './config.js';
3
+
4
+ export function redactKey(value, key) {
5
+ if (typeof value !== 'string' || !key) return value;
6
+ return value.replaceAll(key, '[REDACTED_API_KEY]');
7
+ }
8
+
9
+ function filteredHeaders(headers, key) {
10
+ const privateNames = new Set(['authorization', 'proxy-authorization', 'set-cookie', 'cookie', 'x-api-key']);
11
+ return Object.fromEntries(Object.entries(headers ?? {}).map(([name, value]) => [name,
12
+ privateNames.has(name.toLowerCase()) ? '[REDACTED]' : redactKey(value, key)]));
13
+ }
14
+
15
+ export class MeridianHttpError extends Error {
16
+ constructor({ status, statusText, errorCode, requestId, body, headers, url, key }) {
17
+ super(`UltraStiching API returned HTTP ${status}${errorCode ? ` (${errorCode})` : ''}`);
18
+ this.name = 'MeridianHttpError';
19
+ this.status = status;
20
+ this.statusText = statusText;
21
+ this.errorCode = errorCode;
22
+ this.requestId = requestId;
23
+ this.body = body; // Original body is preserved for programmatic callers.
24
+ this.headers = headers;
25
+ this.url = url;
26
+ this.key = key;
27
+ }
28
+ toDiagnostic() {
29
+ return {
30
+ type: this.name, status: this.status, status_text: redactKey(this.statusText, this.key),
31
+ error: redactKey(this.errorCode, this.key), request_id: redactKey(this.requestId, this.key),
32
+ raw_body: redactKey(this.body, this.key),
33
+ response: { status_code: this.status, reason: redactKey(this.statusText, this.key),
34
+ url: redactKey(this.url, this.key), headers: filteredHeaders(this.headers, this.key),
35
+ body: redactKey(this.body, this.key) },
36
+ };
37
+ }
38
+ }
39
+
40
+ export class MeridianTransportError extends Error {
41
+ constructor(cause, key) {
42
+ super('UltraStiching request could not be completed', { cause });
43
+ this.name = 'MeridianTransportError';
44
+ this.key = key;
45
+ }
46
+ toDiagnostic() {
47
+ return { type: this.name, message: this.message, cause: {
48
+ type: this.cause?.name ?? 'Error', message: redactKey(String(this.cause), this.key),
49
+ code: this.cause?.code, errno: this.cause?.errno,
50
+ } };
51
+ }
52
+ }
53
+
54
+ export class MeridianResponseError extends Error {
55
+ constructor({ status, requestId, body, key, cause }) {
56
+ super('UltraStiching API returned a successful response with invalid JSON', { cause });
57
+ this.name = 'MeridianResponseError';
58
+ this.status = status;
59
+ this.requestId = requestId;
60
+ this.body = body;
61
+ this.key = key;
62
+ }
63
+ toDiagnostic() {
64
+ return { type: this.name, status: this.status, request_id: redactKey(this.requestId, this.key), raw_body: redactKey(this.body, this.key) };
65
+ }
66
+ }
67
+
68
+ export async function requestEndpoint(meta, payload, { key, baseUrl, timeout = 60000, fetchImpl = fetch } = {}) {
69
+ if (!meta || meta.protocol !== 'http' || !meta.path?.startsWith('/') || meta.path.startsWith('//')) {
70
+ throw new Error('A known HTTP registry endpoint is required');
71
+ }
72
+ if (meta.response?.content_type === 'text/event-stream') {
73
+ throw new Error(`${meta.name} is SSE; synchronous call is unsupported. Read its contract with ultrastiching info ${meta.name}`);
74
+ }
75
+ const apiKey = resolveKey(key);
76
+ const origin = baseUrl === undefined ? resolveBaseUrl() : normalizeOrigin(baseUrl);
77
+ const ms = Number(timeout);
78
+ if (!Number.isFinite(ms) || ms <= 0) throw new Error('timeout must be a positive number of milliseconds');
79
+ let path = meta.path;
80
+ const query = new URLSearchParams();
81
+ if (meta.method === 'GET') {
82
+ for (const parameter of meta.request.parameters) {
83
+ const value = payload[parameter.name];
84
+ if (value === undefined) continue;
85
+ if (parameter.in === 'path') {
86
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${parameter.name} must be a positive integer`);
87
+ path = path.replace(`{${parameter.name}}`, encodeURIComponent(String(value)));
88
+ } else {
89
+ if (value === null) throw new Error(`${parameter.name} cannot be null in a GET query`);
90
+ query.set(parameter.name, String(value));
91
+ }
92
+ }
93
+ }
94
+ const url = `${origin}${path}${query.size ? `?${query}` : ''}`;
95
+ let response;
96
+ try {
97
+ response = await fetchImpl(url, {
98
+ method: meta.method,
99
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': meta.request?.content_type ?? 'application/json' },
100
+ ...(meta.method === 'GET' ? {} : { body: JSON.stringify(payload) }),
101
+ redirect: 'manual',
102
+ signal: AbortSignal.timeout(ms),
103
+ });
104
+ } catch (cause) { throw new MeridianTransportError(cause, apiKey); }
105
+ let buffer;
106
+ try { buffer = Buffer.from(await response.arrayBuffer()); }
107
+ catch (cause) { throw new MeridianTransportError(cause, apiKey); }
108
+ const requestId = response.headers.get('x-meridian-request-id');
109
+ if (!response.ok) {
110
+ const body = buffer.toString('utf8');
111
+ let errorCode;
112
+ try {
113
+ const parsed = JSON.parse(body);
114
+ errorCode = typeof parsed.error === 'string' ? parsed.error : parsed.error?.code;
115
+ } catch { /* raw body is retained */ }
116
+ throw new MeridianHttpError({ status: response.status, statusText: response.statusText, errorCode, requestId, body,
117
+ headers: Object.fromEntries(response.headers), url: response.url || url, key: apiKey });
118
+ }
119
+ const contentType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
120
+ if (contentType === 'application/vnd.apache.arrow.stream') return { binary: true, data: buffer, requestId };
121
+ try { return { binary: false, data: JSON.parse(buffer.toString('utf8')), requestId }; }
122
+ catch (cause) { throw new MeridianResponseError({ status: response.status, requestId, body: buffer.toString('utf8'), key: apiKey, cause }); }
123
+ }
124
+
125
+ export async function callByName(name, payload, options) {
126
+ const meta = endpoint(name, 'http');
127
+ if (!meta) throw new Error(`Unknown HTTP endpoint ${name}; run ultrastiching list --search ${name}`);
128
+ return requestEndpoint(meta, payload, options);
129
+ }
package/src/main.js ADDED
@@ -0,0 +1,201 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { parseArgs, jsonObject, requireValue } from './args.js';
4
+ import { resolveBaseUrl, resolveKey } from './config.js';
5
+ import { callByName, redactKey } from './http.js';
6
+ import { buildPayload, endpoint, loadRegistry, parseParameter, searchEndpoints } from './registry.js';
7
+ import { streamNewsflash } from './websocket.js';
8
+ import { credentialStatus, configureCredential, syncCredentialEnvironment, mcpHeaders } from './auth.js';
9
+ import { readFileSync } from 'node:fs';
10
+
11
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
+
13
+ const HELP = `ultrastiching — UltraStiching data API CLI
14
+
15
+ ultrastiching --version
16
+ ultrastiching auth status
17
+ ultrastiching auth sync [--from-file]
18
+ ultrastiching auth configure [--replace]
19
+ ultrastiching mcp headers (MCP client only; stdout contains credentials)
20
+ ultrastiching doctor [--probe]
21
+ ultrastiching list [--search text] [--binary] [--verbose]
22
+ ultrastiching info <endpoint>
23
+ ultrastiching call <endpoint> [--parameter value ...] [--args JSON] [--out file]
24
+ ultrastiching arrow <get_* handler> --args '{"params":{...},"fields":[...]}' [--out file]
25
+ ultrastiching ws newsflash [--limit n] [--keyword text] [--count n]
26
+ ultrastiching datasets
27
+ ultrastiching db init|sync|status|validate|repair|sql|export [options]
28
+
29
+ Global: --key, --base-url, --timeout (milliseconds), --compact
30
+ `;
31
+
32
+ function emit(write, value, compact, key) {
33
+ write(`${redactKey(JSON.stringify(value, null, compact ? 0 : 2), key)}\n`);
34
+ }
35
+
36
+ function assertFlags(flags, allowed) {
37
+ const extra = Object.keys(flags).filter((name) => !allowed.has(name));
38
+ if (extra.length) throw new Error(`Unknown option(s): ${extra.map((name) => `--${name.replaceAll('_', '-')}`).join(', ')}`);
39
+ }
40
+
41
+ function valuesFor(meta, flags) {
42
+ const values = jsonObject(flags.args);
43
+ const names = new Set(meta.parameters);
44
+ const allowed = new Set([...names, ...Object.keys(meta.payload), 'args', 'out', 'key', 'base_url', 'timeout', 'compact']);
45
+ assertFlags(flags, allowed);
46
+ for (const name of names) {
47
+ const wireName = Object.entries(meta.payload).find(([, value]) => value?.$param === name)?.[0] ?? name;
48
+ const flagName = Object.hasOwn(flags, name) ? name : wireName;
49
+ if (!Object.hasOwn(flags, flagName)) continue;
50
+ const field = meta.request?.fields?.find((item) => item.name === wireName) ?? { name };
51
+ values[name] = parseParameter(requireValue(flags, flagName), field);
52
+ }
53
+ return values;
54
+ }
55
+
56
+ function outputResult(name, result, flags, write, key) {
57
+ if (result.binary) {
58
+ const target = resolve(flags.out ?? `meridian-out/${name}-${Date.now()}.arrow`);
59
+ mkdirSync(dirname(target), { recursive: true });
60
+ writeFileSync(target, result.data);
61
+ emit(write, { path: target, bytes: result.data.length, format: 'arrow_ipc', request_id: result.requestId }, flags.compact, key);
62
+ } else if (flags.out || rowCount(result.data) > 500) {
63
+ const target = resolve(flags.out ? requireValue(flags, 'out') : `meridian-out/${name}-${Date.now()}.json`);
64
+ mkdirSync(dirname(target), { recursive: true });
65
+ writeFileSync(target, `${JSON.stringify(result.data, null, flags.compact ? 0 : 2)}\n`);
66
+ emit(write, { path: target, format: 'json', request_id: result.requestId }, flags.compact, key);
67
+ } else emit(write, result.data, flags.compact, key);
68
+ }
69
+
70
+ function rowCount(data) {
71
+ if (Array.isArray(data)) return data.length;
72
+ if (!data || typeof data !== 'object') return 0;
73
+ if (Array.isArray(data.data_frame)) return data.data_frame.length;
74
+ if (Array.isArray(data.data)) return data.data.length;
75
+ if (Array.isArray(data.items)) return data.items.length;
76
+ return 0;
77
+ }
78
+
79
+ export async function main(argv, { write = (line) => process.stdout.write(line), fetchImpl, socketFactory } = {}) {
80
+ const { flags, positional } = parseArgs(argv);
81
+ const [command, name] = positional;
82
+ if (flags.version && !command) { assertFlags(flags, new Set(['version'])); write(`${VERSION}\n`); return; }
83
+ if (!command || flags.help) { write(HELP); return; }
84
+ if (command === 'auth') {
85
+ if (positional.length !== 2) throw new Error('Use ultrastiching auth status|sync|configure');
86
+ assertFlags(flags, new Set(name === 'configure' ? ['replace', 'compact'] : name === 'sync' ? ['from_file', 'compact'] : ['compact']));
87
+ if (name === 'configure') {
88
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('请在交互式终端运行 ultrastiching auth configure;Key 不能从参数或管道传入。');
89
+ emit(write, await configureCredential({ replace: !!flags.replace }), flags.compact);
90
+ } else if (name === 'status') emit(write, credentialStatus(), flags.compact);
91
+ else if (name === 'sync') emit(write, syncCredentialEnvironment({ fromFile: !!flags.from_file }), flags.compact);
92
+ else throw new Error('Use ultrastiching auth status|sync|configure');
93
+ return;
94
+ }
95
+ if (command === 'mcp') {
96
+ assertFlags(flags, new Set());
97
+ if (name !== 'headers' || positional.length !== 2) throw new Error('Use ultrastiching mcp headers (MCP client only)');
98
+ write(`${JSON.stringify(mcpHeaders())}\n`);
99
+ return;
100
+ }
101
+ if (command === 'list') {
102
+ assertFlags(flags, new Set(['search', 'binary', 'verbose', 'compact']));
103
+ const items = searchEndpoints({ search: flags.search === undefined ? '' : requireValue(flags, 'search'), binary: !!flags.binary });
104
+ emit(write, { total: items.length, endpoints: items.map((item) => flags.verbose ? {
105
+ name: item.name, title: item.title, protocol: item.protocol, path: item.path,
106
+ response_format: item.response?.return_format,
107
+ } : { name: item.name, title: item.title }) }, flags.compact);
108
+ return;
109
+ }
110
+ if (command === 'info') {
111
+ assertFlags(flags, new Set(['compact']));
112
+ const meta = endpoint(name);
113
+ if (!meta) throw new Error(`Unknown endpoint ${name}; run ultrastiching list --search ${name}`);
114
+ emit(write, meta, flags.compact);
115
+ return;
116
+ }
117
+ if (command === 'doctor') {
118
+ assertFlags(flags, new Set(['key', 'base_url', 'timeout', 'probe', 'compact']));
119
+ const registry = loadRegistry();
120
+ const key = resolveKey(flags.key, { optional: true });
121
+ const result = {
122
+ node: process.version, base_url: resolveBaseUrl(flags.base_url),
123
+ api_key_configured: !!key, registry: registry.protocol_counts,
124
+ duckdb: { available: false }, probe: 'not_requested',
125
+ };
126
+ try {
127
+ const duckdb = await import('@duckdb/node-api');
128
+ result.duckdb = { available: true, version: duckdb.default.version() };
129
+ } catch (error) {
130
+ result.duckdb = { available: false, error: error.message };
131
+ }
132
+ if (flags.probe) {
133
+ if (!key) throw new Error('doctor --probe requires an API Key');
134
+ const meta = endpoint('get_s_stockname', 'http');
135
+ const payload = buildPayload(meta, { code: ['600745.SH'] });
136
+ const response = await callByName(meta.name, payload, { key, baseUrl: flags.base_url, timeout: flags.timeout, fetchImpl });
137
+ result.probe = { status: 'ok', request_id: response.requestId };
138
+ }
139
+ emit(write, result, flags.compact, key);
140
+ return;
141
+ }
142
+ if (command === 'call') {
143
+ const meta = endpoint(name, 'http');
144
+ if (!meta) throw new Error(`Unknown HTTP endpoint ${name}; run ultrastiching list --search ${name}`);
145
+ const values = valuesFor(meta, flags);
146
+ const key = resolveKey(flags.key);
147
+ const response = await callByName(name, buildPayload(meta, values), {
148
+ key, baseUrl: flags.base_url, timeout: flags.timeout, fetchImpl,
149
+ });
150
+ outputResult(name, response, flags, write, key);
151
+ return;
152
+ }
153
+ if (command === 'arrow') {
154
+ assertFlags(flags, new Set(['args', 'out', 'key', 'base_url', 'timeout', 'compact']));
155
+ const handler = endpoint(name, 'http');
156
+ if (!handler || !name.startsWith('get_')) throw new Error('arrow requires a registered get_* handler; use call for native Arrow endpoints');
157
+ const args = jsonObject(flags.args);
158
+ const meta = endpoint('arrow', 'http');
159
+ const payload = buildPayload(meta, { handler: name, ...args });
160
+ const key = resolveKey(flags.key);
161
+ const response = await callByName('arrow', payload, {
162
+ key, baseUrl: flags.base_url, timeout: flags.timeout, fetchImpl,
163
+ });
164
+ if (!response.binary) throw new Error(`arrow returned ${typeof response.data}, expected Arrow IPC stream`);
165
+ outputResult(name, response, flags, write, key);
166
+ return;
167
+ }
168
+ if (command === 'ws') {
169
+ if (name !== 'newsflash') throw new Error('ws currently supports newsflash');
170
+ assertFlags(flags, new Set(['cursor', 'limit', 'keyword', 'min_importance', 'count', 'idle_timeout', 'key', 'ws_base_url', 'compact']));
171
+ const meta = endpoint('ws_newsflash', 'websocket');
172
+ const values = {};
173
+ for (const param of meta.parameters) {
174
+ if (!Object.hasOwn(flags, param)) continue;
175
+ const field = meta.request?.fields?.find((item) => item.name === param) ?? { name: param };
176
+ values[param] = parseParameter(requireValue(flags, param), field);
177
+ }
178
+ const key = resolveKey(flags.key);
179
+ const result = await streamNewsflash(values, {
180
+ key, websocketBaseUrl: flags.ws_base_url, count: flags.count,
181
+ idleTimeout: flags.idle_timeout, socketFactory, onMessage: (line) => write(`${redactKey(line, key)}\n`),
182
+ });
183
+ emit(write, result, flags.compact, key);
184
+ return;
185
+ }
186
+ if (command === 'datasets') {
187
+ assertFlags(flags, new Set(['compact']));
188
+ const { datasets } = await import('./marketdb.js');
189
+ emit(write, { datasets: datasets() }, flags.compact);
190
+ return;
191
+ }
192
+ if (command === 'db') {
193
+ const { dbCommand } = await import('./db-cli.js');
194
+ const result = await dbCommand(name, flags, { fetchImpl });
195
+ const key = flags.key === undefined ? undefined : resolveKey(flags.key);
196
+ emit(write, result, flags.compact, key);
197
+ return result.status === 'incomplete' || result.ok === false || result.repaired?.some((item) => item.status === 'incomplete')
198
+ ? { exitCode: 2 } : undefined;
199
+ }
200
+ throw new Error(`Unknown command ${command}; run ultrastiching --help`);
201
+ }