@pungoyal/kite-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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +292 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +7 -0
  5. package/dist/commands/auth.d.ts +2 -0
  6. package/dist/commands/auth.js +265 -0
  7. package/dist/commands/config.d.ts +2 -0
  8. package/dist/commands/config.js +182 -0
  9. package/dist/commands/gtt.d.ts +12 -0
  10. package/dist/commands/gtt.js +276 -0
  11. package/dist/commands/market.d.ts +2 -0
  12. package/dist/commands/market.js +389 -0
  13. package/dist/commands/orders.d.ts +2 -0
  14. package/dist/commands/orders.js +679 -0
  15. package/dist/commands/portfolio.d.ts +2 -0
  16. package/dist/commands/portfolio.js +286 -0
  17. package/dist/commands/types.d.ts +16 -0
  18. package/dist/commands/types.js +1 -0
  19. package/dist/commands/watch.d.ts +18 -0
  20. package/dist/commands/watch.js +285 -0
  21. package/dist/context.d.ts +40 -0
  22. package/dist/context.js +126 -0
  23. package/dist/core/api.d.ts +843 -0
  24. package/dist/core/api.js +472 -0
  25. package/dist/core/auth.d.ts +68 -0
  26. package/dist/core/auth.js +220 -0
  27. package/dist/core/client.d.ts +76 -0
  28. package/dist/core/client.js +285 -0
  29. package/dist/core/config.d.ts +114 -0
  30. package/dist/core/config.js +163 -0
  31. package/dist/core/credentials.d.ts +27 -0
  32. package/dist/core/credentials.js +182 -0
  33. package/dist/core/errors.d.ts +83 -0
  34. package/dist/core/errors.js +152 -0
  35. package/dist/core/instruments.d.ts +87 -0
  36. package/dist/core/instruments.js +288 -0
  37. package/dist/core/paths.d.ts +11 -0
  38. package/dist/core/paths.js +56 -0
  39. package/dist/core/ratelimit.d.ts +57 -0
  40. package/dist/core/ratelimit.js +196 -0
  41. package/dist/core/redact.d.ts +48 -0
  42. package/dist/core/redact.js +181 -0
  43. package/dist/core/schemas.d.ts +662 -0
  44. package/dist/core/schemas.js +433 -0
  45. package/dist/core/secretstore.d.ts +11 -0
  46. package/dist/core/secretstore.js +138 -0
  47. package/dist/core/session.d.ts +37 -0
  48. package/dist/core/session.js +102 -0
  49. package/dist/core/ticker.d.ts +131 -0
  50. package/dist/core/ticker.js +367 -0
  51. package/dist/index.d.ts +17 -0
  52. package/dist/index.js +17 -0
  53. package/dist/output/format.d.ts +31 -0
  54. package/dist/output/format.js +162 -0
  55. package/dist/output/io.d.ts +70 -0
  56. package/dist/output/io.js +143 -0
  57. package/dist/output/table.d.ts +28 -0
  58. package/dist/output/table.js +73 -0
  59. package/dist/run.d.ts +15 -0
  60. package/dist/run.js +161 -0
  61. package/dist/safety.d.ts +93 -0
  62. package/dist/safety.js +154 -0
  63. package/package.json +85 -0
@@ -0,0 +1,220 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { ExitCode, KiteCliError, UsageError } from './errors.js';
4
+ import { registerSecret } from './redact.js';
5
+ /**
6
+ * The Kite Connect login handshake.
7
+ *
8
+ * 1. Open https://kite.zerodha.com/connect/login?v=3&api_key=...
9
+ * 2. User authenticates in their browser (including mandatory TOTP 2FA).
10
+ * 3. Kite redirects to the registered URL with ?request_token=...
11
+ * 4. POST /session/token with checksum = SHA256(api_key + request_token + api_secret)
12
+ *
13
+ * The CLI never sees the user's password or TOTP. We deliberately do not offer
14
+ * to store a TOTP seed: holding the 2FA secret alongside the API secret would
15
+ * collapse both factors into one, which is precisely what the SEBI 2FA mandate
16
+ * exists to prevent.
17
+ */
18
+ /** checksum = SHA256(api_key + request_token + api_secret), hex. */
19
+ export function computeChecksum(apiKey, requestToken, apiSecret) {
20
+ return createHash('sha256').update(`${apiKey}${requestToken}${apiSecret}`).digest('hex');
21
+ }
22
+ /**
23
+ * Postback checksum — note this is a DIFFERENT concatenation from the login
24
+ * checksum: SHA256(order_id + order_timestamp + api_secret).
25
+ */
26
+ export function computePostbackChecksum(orderId, orderTimestamp, apiSecret) {
27
+ return createHash('sha256').update(`${orderId}${orderTimestamp}${apiSecret}`).digest('hex');
28
+ }
29
+ export function verifyPostbackChecksum(received, orderId, orderTimestamp, apiSecret) {
30
+ return safeCompare(received, computePostbackChecksum(orderId, orderTimestamp, apiSecret));
31
+ }
32
+ /**
33
+ * Constant-time string comparison that cannot throw.
34
+ *
35
+ * `timingSafeEqual` requires equal BYTE lengths and throws RangeError
36
+ * otherwise, so the length guard must be on the encoded buffers — not on
37
+ * `String.length`, which counts UTF-16 code units and can match while the byte
38
+ * lengths differ. Both call sites compare attacker-influenced input.
39
+ */
40
+ export function safeCompare(a, b) {
41
+ const bufA = Buffer.from(a, 'utf8');
42
+ const bufB = Buffer.from(b, 'utf8');
43
+ if (bufA.length !== bufB.length)
44
+ return false;
45
+ return timingSafeEqual(bufA, bufB);
46
+ }
47
+ export function buildLoginUrl(opts) {
48
+ const url = new URL(opts.endpoints.login);
49
+ // v=3 is required for a v3 session.
50
+ url.searchParams.set('v', '3');
51
+ url.searchParams.set('api_key', opts.apiKey);
52
+ // redirect_params is echoed back verbatim on the redirect, which gives us a
53
+ // CSRF state parameter even though Kite has no first-class `state`.
54
+ url.searchParams.set('redirect_params', `state=${opts.state}`);
55
+ return url.toString();
56
+ }
57
+ export function generateState() {
58
+ return randomBytes(16).toString('hex');
59
+ }
60
+ /**
61
+ * Run a one-shot loopback HTTP server to capture the request_token.
62
+ *
63
+ * Kite's developer console requires HTTPS for redirect URLs but explicitly
64
+ * permits plain http:// for localhost and 127.0.0.1, which is the standard
65
+ * pattern for native apps. We bind to 127.0.0.1 specifically (never 0.0.0.0),
66
+ * so the callback is not reachable from the network.
67
+ */
68
+ export function waitForCallback(opts) {
69
+ let server;
70
+ let settled = false;
71
+ let timer;
72
+ const close = () => {
73
+ if (timer)
74
+ clearTimeout(timer);
75
+ server?.close();
76
+ server?.closeAllConnections?.();
77
+ };
78
+ const promise = new Promise((resolve, reject) => {
79
+ const settle = (fn) => {
80
+ if (settled)
81
+ return;
82
+ settled = true;
83
+ close();
84
+ fn();
85
+ };
86
+ const handler = (req, res) => {
87
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${opts.port}`);
88
+ if (url.pathname !== opts.path) {
89
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
90
+ res.end('Not found');
91
+ return;
92
+ }
93
+ const status = url.searchParams.get('status');
94
+ const requestToken = url.searchParams.get('request_token');
95
+ const receivedState = url.searchParams.get('state');
96
+ // Constant-time state comparison. A mismatch means this callback did not
97
+ // originate from the login we started.
98
+ //
99
+ // The length guard compares BYTE lengths, not string lengths: a string of
100
+ // 32 multi-byte characters has the same `.length` as our 32-char hex
101
+ // state but a different Buffer size, and timingSafeEqual throws
102
+ // RangeError on mismatched buffers. That would crash the callback server
103
+ // on input an attacker fully controls.
104
+ const stateOk = receivedState !== null && safeCompare(receivedState, opts.state);
105
+ if (!stateOk) {
106
+ respondHtml(res, 400, 'Login failed', 'State mismatch — this callback did not come from the login this CLI started.');
107
+ settle(() => reject(new KiteCliError('Login callback failed a CSRF state check.', ExitCode.Auth, 'Run `kite login` again, and complete it in a single browser session.')));
108
+ return;
109
+ }
110
+ if (status !== 'success' || !requestToken) {
111
+ respondHtml(res, 400, 'Login failed', 'Kite did not return a request token.');
112
+ settle(() => reject(new KiteCliError('Kite did not return a request token.', ExitCode.Auth)));
113
+ return;
114
+ }
115
+ registerSecret(requestToken);
116
+ respondHtml(res, 200, 'Logged in', 'You can close this tab and return to your terminal.');
117
+ settle(() => resolve({ requestToken }));
118
+ };
119
+ server = createServer(handler);
120
+ server.on('error', (err) => {
121
+ if (err.code === 'EADDRINUSE') {
122
+ settle(() => reject(new UsageError(`Port ${opts.port} is already in use.`, 'Set a different port with `kite config set redirectPort <port>`, and update the redirect URL in your Kite developer console to match.')));
123
+ return;
124
+ }
125
+ settle(() => reject(err));
126
+ });
127
+ server.listen(opts.port, '127.0.0.1');
128
+ const timeoutMs = opts.timeoutMs ?? 300_000;
129
+ timer = setTimeout(() => {
130
+ settle(() => reject(new KiteCliError(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser callback.`, ExitCode.Auth, 'Run `kite login --manual` to paste the request token by hand instead.')));
131
+ }, timeoutMs);
132
+ timer.unref?.();
133
+ });
134
+ return { promise, close };
135
+ }
136
+ function respondHtml(res, status, title, detail) {
137
+ const body = `<!doctype html>
138
+ <html lang="en">
139
+ <head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
140
+ <style>
141
+ body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; display: grid;
142
+ place-items: center; min-height: 100vh; margin: 0; background: #0f1115; color: #e6e8eb; }
143
+ .card { text-align: center; padding: 2.5rem 3rem; border-radius: 12px; background: #171a21;
144
+ box-shadow: 0 8px 32px rgba(0,0,0,.4); }
145
+ h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
146
+ p { margin: 0; color: #9aa4b2; font-size: .925rem; }
147
+ </style></head>
148
+ <body><div class="card"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p></div></body>
149
+ </html>`;
150
+ res.writeHead(status, {
151
+ 'Content-Type': 'text/html; charset=utf-8',
152
+ // This page is generated locally and references nothing external.
153
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
154
+ 'Cache-Control': 'no-store',
155
+ 'Referrer-Policy': 'no-referrer',
156
+ });
157
+ res.end(body);
158
+ }
159
+ function escapeHtml(value) {
160
+ return value.replace(/[&<>"']/g, (char) => {
161
+ switch (char) {
162
+ case '&':
163
+ return '&amp;';
164
+ case '<':
165
+ return '&lt;';
166
+ case '>':
167
+ return '&gt;';
168
+ case '"':
169
+ return '&quot;';
170
+ default:
171
+ return '&#39;';
172
+ }
173
+ });
174
+ }
175
+ export function redirectUrlFor(port, path) {
176
+ return `http://127.0.0.1:${port}${path}`;
177
+ }
178
+ /**
179
+ * Open a URL in the user's default browser.
180
+ *
181
+ * The URL is passed as an argv element to a fixed binary, never through a
182
+ * shell, so a crafted URL cannot become command injection.
183
+ */
184
+ export async function openBrowser(url) {
185
+ const { spawn } = await import('node:child_process');
186
+ let command;
187
+ let args;
188
+ switch (process.platform) {
189
+ case 'darwin':
190
+ command = 'open';
191
+ args = [url];
192
+ break;
193
+ case 'win32':
194
+ // start is a cmd builtin; the empty string is the window-title argument,
195
+ // without which a quoted URL is treated as the title.
196
+ command = 'cmd';
197
+ args = ['/c', 'start', '', url];
198
+ break;
199
+ default:
200
+ command = 'xdg-open';
201
+ args = [url];
202
+ break;
203
+ }
204
+ return new Promise((resolve) => {
205
+ try {
206
+ const child = spawn(command, args, {
207
+ stdio: 'ignore',
208
+ detached: true,
209
+ shell: false,
210
+ });
211
+ child.on('error', () => resolve(false));
212
+ child.unref();
213
+ // spawn errors surface asynchronously; give it a moment before claiming success.
214
+ setTimeout(() => resolve(true), 250).unref?.();
215
+ }
216
+ catch {
217
+ resolve(false);
218
+ }
219
+ });
220
+ }
@@ -0,0 +1,76 @@
1
+ import { type Dispatcher } from 'undici';
2
+ import { z } from 'zod';
3
+ import type { Endpoints } from './config.js';
4
+ import { type RateCategory, RateLimiter } from './ratelimit.js';
5
+ /**
6
+ * HTTP client for the Kite Connect v3 API.
7
+ *
8
+ * Transport notes that shaped this file:
9
+ *
10
+ * - Kite is form-encoded everywhere EXCEPT /margins/* and /charges/orders,
11
+ * which take JSON bodies. `json` vs `form` on RequestOptions selects.
12
+ * - `X-Kite-Version: 3` is mandatory on every request.
13
+ * - The sandbox serves all routes under an /oms prefix except /instruments.
14
+ * - GTT passes JSON-encoded strings *inside form fields*, not a JSON body.
15
+ */
16
+ export interface RequestOptions<S extends z.ZodType> {
17
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
18
+ path: string;
19
+ category?: RateCategory;
20
+ query?: Record<string, string | number | boolean | undefined | string[]>;
21
+ /**
22
+ * Form fields. An array value is encoded as a REPEATED field
23
+ * (`isin=A&isin=B`), which is how Kite expects multi-valued form input —
24
+ * notably the holdings authorisation endpoint.
25
+ */
26
+ form?: Record<string, string | number | boolean | string[] | undefined>;
27
+ json?: unknown;
28
+ schema: S;
29
+ signal?: AbortSignal | undefined;
30
+ /** Skip the /oms sandbox prefix. Only /instruments needs this. */
31
+ noPrefix?: boolean;
32
+ /** Per-request timeout override, milliseconds. */
33
+ timeoutMs?: number;
34
+ }
35
+ export interface ClientOptions {
36
+ apiKey: string;
37
+ accessToken?: string | undefined;
38
+ endpoints: Endpoints;
39
+ limiter?: RateLimiter;
40
+ /** Print redacted request/response diagnostics to stderr. */
41
+ debug?: boolean;
42
+ onDebug?: (message: string) => void;
43
+ timeoutMs?: number;
44
+ }
45
+ /** Test hook: swap the dispatcher, e.g. for an undici MockAgent. */
46
+ export declare function setDispatcher(d: Dispatcher | undefined): void;
47
+ export declare class KiteClient {
48
+ readonly apiKey: string;
49
+ readonly endpoints: Endpoints;
50
+ readonly limiter: RateLimiter;
51
+ private accessToken;
52
+ private readonly debug;
53
+ private readonly onDebug;
54
+ private readonly timeoutMs;
55
+ constructor(opts: ClientOptions);
56
+ setAccessToken(token: string | undefined): void;
57
+ hasSession(): boolean;
58
+ private buildUrl;
59
+ private headers;
60
+ /** Perform a request and validate the `data` payload against `schema`. */
61
+ request<S extends z.ZodType>(opts: RequestOptions<S>): Promise<z.infer<S>>;
62
+ private toNetworkError;
63
+ private handleResponse;
64
+ /**
65
+ * Fetch a raw (non-JSON) body. Used only for the instrument dump, which is a
66
+ * gzipped CSV rather than an API envelope.
67
+ */
68
+ requestText(opts: {
69
+ path: string;
70
+ signal?: AbortSignal | undefined;
71
+ noPrefix?: boolean;
72
+ timeoutMs?: number;
73
+ }): Promise<string>;
74
+ /** Redacted diagnostics for `--debug`. Never includes the Authorization header. */
75
+ describe(): Record<string, unknown>;
76
+ }
@@ -0,0 +1,285 @@
1
+ import { Agent, fetch, interceptors } from 'undici';
2
+ import { z } from 'zod';
3
+ import { ExitCode, hintForApiError, KiteApiError, KiteCliError, NetworkError } from './errors.js';
4
+ import { RateLimiter } from './ratelimit.js';
5
+ import { redact, redactString, registerSecret } from './redact.js';
6
+ import { EnvelopeSchema, ErrorEnvelopeSchema } from './schemas.js';
7
+ const DEFAULT_TIMEOUT_MS = 30_000;
8
+ /**
9
+ * A shared dispatcher with sane timeouts.
10
+ *
11
+ * undici's defaults are far too permissive for a CLI: headersTimeout and
12
+ * bodyTimeout both default to 300 seconds, which would leave a user staring at
13
+ * a hung terminal.
14
+ *
15
+ * The retry policy is deliberately narrow. undici retries GET, HEAD, OPTIONS,
16
+ * PUT, DELETE and TRACE by default — but in this API PUT is "modify order" and
17
+ * DELETE is "cancel order". Kite caps modifications at 25 per order, and there
18
+ * is no idempotency key anywhere in the API, so an automatic retry of a mutating
19
+ * verb is a real-money bug. We retry reads only; every write is retried
20
+ * deliberately by the caller, or not at all.
21
+ */
22
+ function createDispatcher() {
23
+ return new Agent({
24
+ connectTimeout: 5_000,
25
+ headersTimeout: 15_000,
26
+ bodyTimeout: 30_000,
27
+ keepAliveTimeout: 10_000,
28
+ connections: 8,
29
+ }).compose(interceptors.retry({
30
+ maxRetries: 3,
31
+ minTimeout: 300,
32
+ maxTimeout: 5_000,
33
+ timeoutFactor: 2,
34
+ retryAfter: true,
35
+ methods: ['GET', 'HEAD'],
36
+ statusCodes: [429, 500, 502, 503, 504],
37
+ errorCodes: ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN', 'ENETUNREACH', 'EPIPE'],
38
+ }));
39
+ }
40
+ let sharedDispatcher;
41
+ function dispatcher() {
42
+ sharedDispatcher ??= createDispatcher();
43
+ return sharedDispatcher;
44
+ }
45
+ /** Test hook: swap the dispatcher, e.g. for an undici MockAgent. */
46
+ export function setDispatcher(d) {
47
+ sharedDispatcher = d;
48
+ }
49
+ export class KiteClient {
50
+ apiKey;
51
+ endpoints;
52
+ limiter;
53
+ accessToken;
54
+ debug;
55
+ onDebug;
56
+ timeoutMs;
57
+ constructor(opts) {
58
+ this.apiKey = opts.apiKey;
59
+ this.accessToken = opts.accessToken;
60
+ this.endpoints = opts.endpoints;
61
+ this.limiter = opts.limiter ?? new RateLimiter();
62
+ this.debug = opts.debug ?? false;
63
+ this.onDebug = opts.onDebug ?? ((m) => process.stderr.write(`${m}\n`));
64
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
65
+ // Register here rather than relying on the credential store to have done
66
+ // it. A token can reach the client by other routes — a library consumer
67
+ // constructing it directly, or a future code path — and an unregistered
68
+ // token survives redaction if it turns up somewhere no pattern matches
69
+ // (notably a Kite error message that echoes our input back).
70
+ registerSecret(this.accessToken);
71
+ }
72
+ setAccessToken(token) {
73
+ this.accessToken = token;
74
+ registerSecret(token);
75
+ }
76
+ hasSession() {
77
+ return Boolean(this.accessToken);
78
+ }
79
+ buildUrl(path, query, noPrefix) {
80
+ const prefix = noPrefix ? '' : this.endpoints.routePrefix;
81
+ const url = new URL(`${this.endpoints.api}${prefix}${path}`);
82
+ if (query) {
83
+ for (const [key, value] of Object.entries(query)) {
84
+ if (value === undefined)
85
+ continue;
86
+ if (Array.isArray(value)) {
87
+ // Repeated params, e.g. ?i=NSE:INFY&i=NSE:TCS
88
+ for (const item of value)
89
+ url.searchParams.append(key, item);
90
+ }
91
+ else {
92
+ url.searchParams.append(key, String(value));
93
+ }
94
+ }
95
+ }
96
+ return url;
97
+ }
98
+ headers(extra) {
99
+ const headers = {
100
+ 'X-Kite-Version': '3',
101
+ Accept: 'application/json',
102
+ 'User-Agent': 'kite-cli',
103
+ ...extra,
104
+ };
105
+ if (this.accessToken) {
106
+ // NEVER log this header. It is `token <api_key>:<access_token>` and is
107
+ // attached to every request, making it the single likeliest leak path.
108
+ headers['Authorization'] = `token ${this.apiKey}:${this.accessToken}`;
109
+ }
110
+ return headers;
111
+ }
112
+ /** Perform a request and validate the `data` payload against `schema`. */
113
+ async request(opts) {
114
+ const category = opts.category ?? 'default';
115
+ await this.limiter.acquire(category, opts.signal);
116
+ const url = this.buildUrl(opts.path, opts.query, opts.noPrefix ?? false);
117
+ let body;
118
+ const extraHeaders = {};
119
+ if (opts.json !== undefined) {
120
+ body = JSON.stringify(opts.json);
121
+ extraHeaders['Content-Type'] = 'application/json';
122
+ }
123
+ else if (opts.form) {
124
+ const params = new URLSearchParams();
125
+ for (const [key, value] of Object.entries(opts.form)) {
126
+ if (value === undefined)
127
+ continue;
128
+ if (Array.isArray(value)) {
129
+ for (const item of value)
130
+ params.append(key, item);
131
+ }
132
+ else {
133
+ params.append(key, String(value));
134
+ }
135
+ }
136
+ body = params.toString();
137
+ extraHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
138
+ }
139
+ const timeout = AbortSignal.timeout(opts.timeoutMs ?? this.timeoutMs);
140
+ // Compose the user's Ctrl-C signal with our deadline so both work.
141
+ const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
142
+ if (this.debug) {
143
+ this.onDebug(`→ ${opts.method} ${redactString(url.toString())}${body ? ` body=${redactString(body)}` : ''}`);
144
+ }
145
+ let response;
146
+ try {
147
+ // Use undici's own `fetch`, not the global one. The global fetch only
148
+ // began honouring the per-request `dispatcher` option in recent Node
149
+ // releases; on older supported versions it silently ignores it and uses
150
+ // the default agent — which would drop our timeouts and retry policy at
151
+ // runtime, and bypass MockAgent in tests. undici's fetch always honours
152
+ // it, so behaviour is identical on every supported Node version.
153
+ response = await fetch(url, {
154
+ method: opts.method,
155
+ headers: this.headers(extraHeaders),
156
+ body,
157
+ signal,
158
+ dispatcher: dispatcher(),
159
+ });
160
+ }
161
+ catch (err) {
162
+ throw this.toNetworkError(err, opts.method, url);
163
+ }
164
+ const text = await response.text();
165
+ if (this.debug) {
166
+ this.onDebug(`← ${response.status} ${redactString(text.slice(0, 2000))}`);
167
+ }
168
+ return this.handleResponse(response, text, opts.schema, url);
169
+ }
170
+ toNetworkError(err, method, url) {
171
+ if (err instanceof Error && err.name === 'TimeoutError') {
172
+ return new NetworkError(`Request timed out: ${method} ${redactString(url.pathname)}`, method === 'POST'
173
+ ? 'The order may still have been received. Run `kite orders list` to check before retrying.'
174
+ : 'Check your connection and retry.');
175
+ }
176
+ if (err instanceof Error && err.name === 'AbortError') {
177
+ return new KiteCliError('Cancelled.', ExitCode.Aborted);
178
+ }
179
+ const message = err instanceof Error ? err.message : String(err);
180
+ return new NetworkError(`Could not reach Kite: ${redactString(message)}`, 'Check your network connection.');
181
+ }
182
+ handleResponse(response, text, schema, url) {
183
+ let payload;
184
+ try {
185
+ payload = JSON.parse(text);
186
+ }
187
+ catch {
188
+ // A non-JSON body means an infrastructure error page, not the API.
189
+ if (!response.ok) {
190
+ throw new KiteApiError({
191
+ message: `Kite returned HTTP ${response.status} with a non-JSON body.`,
192
+ status: response.status,
193
+ errorType: 'GeneralException',
194
+ hint: hintForApiError(response.status, 'GeneralException'),
195
+ });
196
+ }
197
+ throw new KiteApiError({
198
+ message: `Could not parse Kite's response for ${redactString(url.pathname)}.`,
199
+ status: response.status,
200
+ errorType: 'DataException',
201
+ });
202
+ }
203
+ if (!response.ok) {
204
+ const parsed = ErrorEnvelopeSchema.safeParse(payload);
205
+ const message = parsed.success ? parsed.data.message : `HTTP ${response.status}`;
206
+ const errorType = parsed.success ? parsed.data.error_type : 'GeneralException';
207
+ throw new KiteApiError({
208
+ message: redactString(message),
209
+ status: response.status,
210
+ errorType,
211
+ hint: hintForApiError(response.status, errorType),
212
+ });
213
+ }
214
+ const envelope = EnvelopeSchema.safeParse(payload);
215
+ if (!envelope.success) {
216
+ throw new KiteApiError({
217
+ message: `Unexpected response shape from ${redactString(url.pathname)}.`,
218
+ status: response.status,
219
+ errorType: 'DataException',
220
+ });
221
+ }
222
+ // Kite can return HTTP 200 with status:"error" in the envelope.
223
+ if (envelope.data.status === 'error') {
224
+ const errorType = envelope.data.error_type ?? 'GeneralException';
225
+ throw new KiteApiError({
226
+ message: redactString(envelope.data.message ?? 'Unknown error'),
227
+ status: response.status,
228
+ errorType,
229
+ hint: hintForApiError(response.status, errorType),
230
+ });
231
+ }
232
+ const result = schema.safeParse(envelope.data.data);
233
+ if (!result.success) {
234
+ throw new KiteApiError({
235
+ message: `Kite's response did not match the expected shape for ${redactString(url.pathname)}.\n` +
236
+ z.prettifyError(result.error),
237
+ status: response.status,
238
+ errorType: 'DataException',
239
+ hint: 'This usually means the API changed. Please open an issue at https://github.com/pungoyal/kite-cli/issues',
240
+ });
241
+ }
242
+ return result.data;
243
+ }
244
+ /**
245
+ * Fetch a raw (non-JSON) body. Used only for the instrument dump, which is a
246
+ * gzipped CSV rather than an API envelope.
247
+ */
248
+ async requestText(opts) {
249
+ await this.limiter.acquire('default', opts.signal);
250
+ const url = this.buildUrl(opts.path, undefined, opts.noPrefix ?? false);
251
+ const timeout = AbortSignal.timeout(opts.timeoutMs ?? 120_000);
252
+ const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
253
+ let response;
254
+ try {
255
+ response = await fetch(url, {
256
+ method: 'GET',
257
+ headers: this.headers(),
258
+ signal,
259
+ // undici's fetch (see request()) so the dispatcher is always honoured.
260
+ dispatcher: dispatcher(),
261
+ });
262
+ }
263
+ catch (err) {
264
+ throw this.toNetworkError(err, 'GET', url);
265
+ }
266
+ if (!response.ok) {
267
+ throw new KiteApiError({
268
+ message: `Could not download ${redactString(url.pathname)} (HTTP ${response.status}).`,
269
+ status: response.status,
270
+ errorType: 'GeneralException',
271
+ hint: hintForApiError(response.status, 'GeneralException'),
272
+ });
273
+ }
274
+ // fetch transparently decompresses gzip via Content-Encoding.
275
+ return response.text();
276
+ }
277
+ /** Redacted diagnostics for `--debug`. Never includes the Authorization header. */
278
+ describe() {
279
+ return redact({
280
+ apiKey: this.apiKey,
281
+ hasSession: this.hasSession(),
282
+ endpoints: this.endpoints,
283
+ });
284
+ }
285
+ }