@pungoyal/kite-cli 0.1.1 → 0.2.1
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 +113 -6
- package/dist/commands/alerts.d.ts +12 -0
- package/dist/commands/alerts.js +529 -0
- package/dist/commands/auth.js +104 -28
- package/dist/commands/config.js +56 -13
- package/dist/commands/profiles.d.ts +10 -0
- package/dist/commands/profiles.js +213 -0
- package/dist/commands/watch.js +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.js +36 -18
- package/dist/core/api.d.ts +249 -42
- package/dist/core/api.js +100 -1
- package/dist/core/config.d.ts +77 -23
- package/dist/core/config.js +53 -21
- package/dist/core/credentials.d.ts +3 -2
- package/dist/core/credentials.js +13 -7
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +2 -2
- package/dist/core/paths.d.ts +9 -2
- package/dist/core/paths.js +12 -3
- package/dist/core/profiles.d.ts +78 -0
- package/dist/core/profiles.js +140 -0
- package/dist/core/schemas.d.ts +78 -5
- package/dist/core/schemas.js +55 -0
- package/dist/core/session.d.ts +3 -2
- package/dist/core/session.js +7 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/run.js +6 -0
- package/dist/safety.js +12 -1
- package/package.json +9 -3
package/dist/commands/auth.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { isCancel, note, password, text } from '@clack/prompts';
|
|
2
2
|
import { buildLoginUrl, computeChecksum, generateState, openBrowser, redirectUrlFor, waitForCallback, } from '../core/auth.js';
|
|
3
|
-
import { SANDBOX_CREDENTIALS, saveConfig } from '../core/config.js';
|
|
3
|
+
import { loadConfig, SANDBOX_CREDENTIALS, saveConfig } from '../core/config.js';
|
|
4
4
|
import { deleteAllSecrets, getSecret, keyringAvailable, setSecret } from '../core/credentials.js';
|
|
5
5
|
import { AbortedError, ExitCode, KiteCliError } from '../core/errors.js';
|
|
6
|
+
import { getProfile, listProfileNames } from '../core/profiles.js';
|
|
6
7
|
import { maskSecret, registerSecret } from '../core/redact.js';
|
|
7
8
|
import { clearSessionMeta, isExpired, loadSessionMeta, nextTokenExpiry, saveSessionMeta, timeUntilExpiry, } from '../core/session.js';
|
|
8
9
|
import { dateTime } from '../output/format.js';
|
|
9
|
-
import { renderKeyValue } from '../output/table.js';
|
|
10
|
+
import { printTable, renderKeyValue } from '../output/table.js';
|
|
10
11
|
export const authCommands = (program, run) => {
|
|
11
12
|
program
|
|
12
13
|
.command('login')
|
|
@@ -20,10 +21,15 @@ export const authCommands = (program, run) => {
|
|
|
20
21
|
.description('Invalidate the session and remove the stored access token')
|
|
21
22
|
.option('--all', 'Also remove the stored API secret')
|
|
22
23
|
.action(run(logout));
|
|
23
|
-
program
|
|
24
|
+
program
|
|
25
|
+
.command('whoami')
|
|
26
|
+
.description('Show the current session and account details')
|
|
27
|
+
.option('--all', 'List every configured profile and its session status')
|
|
28
|
+
.action(run(whoami));
|
|
24
29
|
};
|
|
25
30
|
async function login(ctx, opts) {
|
|
26
31
|
const { io } = ctx;
|
|
32
|
+
const profileName = ctx.profile.name;
|
|
27
33
|
if (!opts.force && ctx.client.hasSession() && ctx.session && !isExpired(ctx.session)) {
|
|
28
34
|
io.success(`Already logged in as ${io.bold(ctx.session.userName ?? ctx.session.userId)} ` +
|
|
29
35
|
`(expires in ${timeUntilExpiry(ctx.session)}).`);
|
|
@@ -31,8 +37,11 @@ async function login(ctx, opts) {
|
|
|
31
37
|
return;
|
|
32
38
|
}
|
|
33
39
|
const isSandbox = ctx.env === 'sandbox';
|
|
40
|
+
if (profileName !== 'default' && !isSandbox) {
|
|
41
|
+
io.info(`Logging in to profile ${io.bold(profileName)}.`);
|
|
42
|
+
}
|
|
34
43
|
// --- credentials -------------------------------------------------------
|
|
35
|
-
let apiKey = opts.apiKey ?? (isSandbox ? SANDBOX_CREDENTIALS.apiKey :
|
|
44
|
+
let apiKey = opts.apiKey ?? (isSandbox ? SANDBOX_CREDENTIALS.apiKey : ctx.profile.apiKey);
|
|
36
45
|
let apiSecret;
|
|
37
46
|
if (isSandbox) {
|
|
38
47
|
apiKey = SANDBOX_CREDENTIALS.apiKey;
|
|
@@ -43,7 +52,7 @@ async function login(ctx, opts) {
|
|
|
43
52
|
if (!apiKey) {
|
|
44
53
|
apiKey = await promptText('Kite Connect API key', 'Create an app at https://developers.kite.trade to get one.');
|
|
45
54
|
}
|
|
46
|
-
const stored = await getSecret('api_secret', {
|
|
55
|
+
const stored = await getSecret('api_secret', { scope: ctx.credentialScope });
|
|
47
56
|
if (stored && !opts.force) {
|
|
48
57
|
apiSecret = stored.value;
|
|
49
58
|
io.info(`Using stored API secret (${maskSecret(apiSecret)}) from the ${stored.backend}.`);
|
|
@@ -83,10 +92,10 @@ async function login(ctx, opts) {
|
|
|
83
92
|
registerSecret(session.access_token);
|
|
84
93
|
// --- persist ------------------------------------------------------------
|
|
85
94
|
const backend = await setSecret('access_token', session.access_token, {
|
|
86
|
-
|
|
95
|
+
scope: ctx.credentialScope,
|
|
87
96
|
});
|
|
88
97
|
if (!isSandbox) {
|
|
89
|
-
await setSecret('api_secret', apiSecret, {
|
|
98
|
+
await setSecret('api_secret', apiSecret, { scope: ctx.credentialScope });
|
|
90
99
|
}
|
|
91
100
|
const expiresAt = nextTokenExpiry();
|
|
92
101
|
await saveSessionMeta({
|
|
@@ -95,25 +104,31 @@ async function login(ctx, opts) {
|
|
|
95
104
|
broker: session.broker,
|
|
96
105
|
env: ctx.env,
|
|
97
106
|
apiKey,
|
|
107
|
+
profile: profileName,
|
|
98
108
|
expiresAt: expiresAt.toISOString(),
|
|
99
109
|
loginTime: session.login_time,
|
|
100
110
|
exchanges: session.exchanges,
|
|
101
111
|
products: session.products,
|
|
102
112
|
});
|
|
103
|
-
|
|
104
|
-
|
|
113
|
+
// Register the profile's api key so subsequent runs find it, and so a new
|
|
114
|
+
// named profile becomes discoverable in `kite profiles`. The sandbox uses the
|
|
115
|
+
// public constant, so there is nothing worth persisting for it.
|
|
116
|
+
if (!isSandbox) {
|
|
117
|
+
await persistProfileApiKey(profileName, ctx.env, apiKey);
|
|
105
118
|
}
|
|
106
119
|
if (io.json) {
|
|
107
120
|
io.writeJson({
|
|
108
121
|
user_id: session.user_id,
|
|
109
122
|
user_name: session.user_name,
|
|
110
123
|
env: ctx.env,
|
|
124
|
+
profile: profileName,
|
|
111
125
|
expires_at: expiresAt.toISOString(),
|
|
112
126
|
storage: backend,
|
|
113
127
|
});
|
|
114
128
|
return;
|
|
115
129
|
}
|
|
116
|
-
|
|
130
|
+
const asProfile = profileName === 'default' ? '' : ` on profile ${io.bold(profileName)}`;
|
|
131
|
+
io.success(`Logged in as ${io.bold(session.user_name ?? session.user_id)}${asProfile}.`);
|
|
117
132
|
io.info(`Session expires ${dateTime(expiresAt)} IST (Kite invalidates all tokens at 6 AM daily).`);
|
|
118
133
|
io.info(backend === 'keyring'
|
|
119
134
|
? 'Credentials stored in your OS keyring.'
|
|
@@ -166,11 +181,12 @@ function extractState(loginUrl) {
|
|
|
166
181
|
}
|
|
167
182
|
async function logout(ctx, opts) {
|
|
168
183
|
const { io } = ctx;
|
|
184
|
+
const profileName = ctx.profile.name;
|
|
169
185
|
// Best-effort server-side invalidation; a failure here must not stop us
|
|
170
186
|
// clearing local state, or the user is stuck with credentials they cannot
|
|
171
187
|
// remove.
|
|
172
188
|
if (ctx.client.hasSession()) {
|
|
173
|
-
const stored = await getSecret('access_token', {
|
|
189
|
+
const stored = await getSecret('access_token', { scope: ctx.credentialScope });
|
|
174
190
|
if (stored) {
|
|
175
191
|
try {
|
|
176
192
|
await ctx.api.invalidateSession(stored.value);
|
|
@@ -183,53 +199,63 @@ async function logout(ctx, opts) {
|
|
|
183
199
|
}
|
|
184
200
|
const { deleteSecret } = await import('../core/credentials.js');
|
|
185
201
|
if (opts.all) {
|
|
186
|
-
await deleteAllSecrets({
|
|
202
|
+
await deleteAllSecrets({ scope: ctx.credentialScope });
|
|
187
203
|
}
|
|
188
204
|
else {
|
|
189
|
-
await deleteSecret('access_token', {
|
|
205
|
+
await deleteSecret('access_token', { scope: ctx.credentialScope });
|
|
190
206
|
}
|
|
191
|
-
await clearSessionMeta();
|
|
207
|
+
await clearSessionMeta(profileName);
|
|
208
|
+
const onProfile = profileName === 'default' ? '' : ` (profile ${io.bold(profileName)})`;
|
|
192
209
|
if (io.json) {
|
|
193
|
-
io.writeJson({ logged_out: true, removed_api_secret: Boolean(opts.all) });
|
|
210
|
+
io.writeJson({ logged_out: true, profile: profileName, removed_api_secret: Boolean(opts.all) });
|
|
194
211
|
return;
|
|
195
212
|
}
|
|
196
|
-
io.success(opts.all ?
|
|
213
|
+
io.success(opts.all ? `Logged out and removed the stored API secret${onProfile}.` : `Logged out${onProfile}.`);
|
|
197
214
|
if (!opts.all)
|
|
198
215
|
io.info('Your API secret is still stored. Use `kite logout --all` to remove it.');
|
|
199
216
|
}
|
|
200
|
-
async function whoami(ctx) {
|
|
217
|
+
async function whoami(ctx, opts) {
|
|
218
|
+
if (opts.all) {
|
|
219
|
+
await whoamiAll(ctx);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
201
222
|
const { io } = ctx;
|
|
202
|
-
const
|
|
223
|
+
const profileName = ctx.profile.name;
|
|
224
|
+
const meta = await loadSessionMeta(profileName);
|
|
203
225
|
if (!ctx.client.hasSession()) {
|
|
204
226
|
// Exit non-zero in both modes: a script checking `kite whoami` must be able
|
|
205
227
|
// to branch on the exit code, not parse stdout.
|
|
206
228
|
process.exitCode = ExitCode.Auth;
|
|
207
229
|
if (io.json) {
|
|
208
|
-
io.writeJson({ logged_in: false });
|
|
230
|
+
io.writeJson({ logged_in: false, profile: profileName });
|
|
209
231
|
return;
|
|
210
232
|
}
|
|
211
|
-
io.error('Not logged in.');
|
|
212
|
-
io.info(
|
|
233
|
+
io.error(profileName === 'default' ? 'Not logged in.' : `Not logged in to profile "${profileName}".`);
|
|
234
|
+
io.info(profileName === 'default'
|
|
235
|
+
? 'Run `kite login` to start a session.'
|
|
236
|
+
: `Run \`kite --profile ${profileName} login\`.`);
|
|
213
237
|
return;
|
|
214
238
|
}
|
|
215
239
|
// Hit the API so this reflects reality, not just what we cached.
|
|
216
|
-
const
|
|
240
|
+
const account = await ctx.api.getProfile(ctx.signal);
|
|
217
241
|
if (io.json) {
|
|
218
242
|
io.writeJson({
|
|
219
243
|
logged_in: true,
|
|
220
244
|
env: ctx.env,
|
|
245
|
+
profile: profileName,
|
|
221
246
|
expires_at: meta?.expiresAt,
|
|
222
|
-
|
|
247
|
+
account,
|
|
223
248
|
});
|
|
224
249
|
return;
|
|
225
250
|
}
|
|
226
251
|
io.line(renderKeyValue(io, [
|
|
227
|
-
['
|
|
228
|
-
['
|
|
229
|
-
['
|
|
252
|
+
['Profile', profileName === 'default' ? profileName : io.bold(profileName)],
|
|
253
|
+
['User', `${account.user_name ?? '—'} (${account.user_id})`],
|
|
254
|
+
['Email', account.email ?? '—'],
|
|
255
|
+
['Broker', account.broker ?? '—'],
|
|
230
256
|
['Environment', ctx.env === 'sandbox' ? io.cyan('sandbox') : 'production'],
|
|
231
|
-
['Exchanges',
|
|
232
|
-
['Products',
|
|
257
|
+
['Exchanges', account.exchanges.join(', ') || '—'],
|
|
258
|
+
['Products', account.products.join(', ') || '—'],
|
|
233
259
|
[
|
|
234
260
|
'Session expires',
|
|
235
261
|
meta ? `${dateTime(meta.expiresAt)} IST (${timeUntilExpiry(meta)} left)` : 'unknown (token from environment)',
|
|
@@ -237,6 +263,56 @@ async function whoami(ctx) {
|
|
|
237
263
|
['Keyring', (await keyringAvailable()) ? 'available' : 'unavailable (using encrypted file)'],
|
|
238
264
|
]));
|
|
239
265
|
}
|
|
266
|
+
/** Enumerate every configured profile and its cached session, no network calls. */
|
|
267
|
+
async function whoamiAll(ctx) {
|
|
268
|
+
const { io } = ctx;
|
|
269
|
+
const config = await loadConfig();
|
|
270
|
+
const rows = await Promise.all(listProfileNames(config).map(async (name) => {
|
|
271
|
+
const meta = await loadSessionMeta(name);
|
|
272
|
+
const status = !meta
|
|
273
|
+
? io.dim('no session')
|
|
274
|
+
: isExpired(meta)
|
|
275
|
+
? io.yellow('expired')
|
|
276
|
+
: `expires in ${timeUntilExpiry(meta)}`;
|
|
277
|
+
return {
|
|
278
|
+
profile: name,
|
|
279
|
+
env: getProfile(config, name).env,
|
|
280
|
+
user: meta?.userName ?? meta?.userId ?? '—',
|
|
281
|
+
session: status,
|
|
282
|
+
current: name === ctx.profile.name,
|
|
283
|
+
};
|
|
284
|
+
}));
|
|
285
|
+
printTable(io, rows, [
|
|
286
|
+
{ header: '', value: (r) => (r.current ? io.green('●') : ' ') },
|
|
287
|
+
{ header: 'Profile', value: (r) => (r.current ? io.bold(r.profile) : r.profile) },
|
|
288
|
+
{ header: 'Env', value: (r) => (r.env === 'sandbox' ? io.cyan(r.env) : r.env) },
|
|
289
|
+
{ header: 'User', value: (r) => r.user },
|
|
290
|
+
{ header: 'Session', value: (r) => r.session },
|
|
291
|
+
], rows.map(({ current: _current, ...rest }) => rest));
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Persist a profile's (semi-public) api key so later runs find it. The default
|
|
295
|
+
* profile keeps its key at the top level of the config for back-compat; a named
|
|
296
|
+
* profile is registered under `profiles`, which also makes it show up in
|
|
297
|
+
* `kite profiles`. Reads the raw config fresh so the in-memory effective
|
|
298
|
+
* trading overlay is never written back.
|
|
299
|
+
*/
|
|
300
|
+
async function persistProfileApiKey(profileName, env, apiKey) {
|
|
301
|
+
const config = await loadConfig();
|
|
302
|
+
if (profileName === 'default') {
|
|
303
|
+
if (config.apiKey === apiKey)
|
|
304
|
+
return;
|
|
305
|
+
await saveConfig({ ...config, apiKey });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const existing = config.profiles[profileName];
|
|
309
|
+
if (existing?.apiKey === apiKey && existing?.env === env)
|
|
310
|
+
return;
|
|
311
|
+
await saveConfig({
|
|
312
|
+
...config,
|
|
313
|
+
profiles: { ...config.profiles, [profileName]: { ...existing, apiKey, env } },
|
|
314
|
+
});
|
|
315
|
+
}
|
|
240
316
|
async function promptText(message, hint) {
|
|
241
317
|
if (!process.stdin.isTTY) {
|
|
242
318
|
throw new KiteCliError(`${message} is required but this is not an interactive terminal.`, ExitCode.ConfirmationRequired, 'Supply it via a flag or environment variable.');
|
package/dist/commands/config.js
CHANGED
|
@@ -29,12 +29,16 @@ async function showConfig(ctx) {
|
|
|
29
29
|
io.writeJson(config);
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
|
-
const secret = await getSecret('api_secret', {
|
|
32
|
+
const secret = await getSecret('api_secret', { scope: ctx.credentialScope });
|
|
33
|
+
// Effective trading config for the active profile (global overlaid with the
|
|
34
|
+
// profile's overrides), so this view matches what a money-moving command sees.
|
|
35
|
+
const trading = ctx.config.trading;
|
|
33
36
|
io.line(heading(io, 'Account'));
|
|
34
37
|
io.line(renderKeyValue(io, [
|
|
35
|
-
['
|
|
38
|
+
['Profile', ctx.profile.name === 'default' ? ctx.profile.name : io.bold(ctx.profile.name)],
|
|
39
|
+
['API key', ctx.profile.apiKey || io.dim('not set')],
|
|
36
40
|
['API secret', secret ? `${maskSecret(secret.value)} (${secret.backend})` : io.dim('not set')],
|
|
37
|
-
['Environment',
|
|
41
|
+
['Environment', ctx.profile.env === 'sandbox' ? io.cyan('sandbox') : 'production'],
|
|
38
42
|
[
|
|
39
43
|
'Keyring',
|
|
40
44
|
(await keyringAvailable()) ? io.green('available') : io.yellow('unavailable — using an encrypted file'),
|
|
@@ -42,11 +46,17 @@ async function showConfig(ctx) {
|
|
|
42
46
|
]));
|
|
43
47
|
io.line(heading(io, 'Trading safety'));
|
|
44
48
|
io.line(renderKeyValue(io, [
|
|
45
|
-
['Trading enabled',
|
|
46
|
-
['Confirm orders',
|
|
47
|
-
['Max order value',
|
|
48
|
-
['Strict confirm above', rupees(
|
|
49
|
+
['Trading enabled', trading.enabled ? io.green('yes') : io.red('no — all order commands will refuse')],
|
|
50
|
+
['Confirm orders', trading.confirm ? 'yes' : io.yellow('no')],
|
|
51
|
+
['Max order value', trading.maxOrderValue ? rupees(trading.maxOrderValue) : io.dim('no cap')],
|
|
52
|
+
['Strict confirm above', rupees(trading.strictConfirmAbove)],
|
|
49
53
|
]));
|
|
54
|
+
const others = Object.keys(config.profiles);
|
|
55
|
+
if (others.length > 0) {
|
|
56
|
+
io.line(heading(io, 'Profiles'));
|
|
57
|
+
io.line(renderKeyValue(io, [['Configured', ['default', 'sandbox', ...others].join(', ')]]));
|
|
58
|
+
io.note(io.dim(' Manage them with `kite profiles`.'));
|
|
59
|
+
}
|
|
50
60
|
io.line(heading(io, 'Login callback'));
|
|
51
61
|
io.line(renderKeyValue(io, [
|
|
52
62
|
['Redirect URL', redirectUrlFor(config.redirectPort, config.redirectPath)],
|
|
@@ -78,7 +88,22 @@ async function setConfig(ctx, _opts, command) {
|
|
|
78
88
|
const config = await loadConfig();
|
|
79
89
|
const coerced = coerce(rawValue, SETTABLE_KEYS[key].type, key);
|
|
80
90
|
const updated = structuredClone(config);
|
|
81
|
-
|
|
91
|
+
const target = ctx.profile.name;
|
|
92
|
+
if (target === 'default') {
|
|
93
|
+
writePath(updated, key, coerced);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// Only account- and trading-scoped keys make sense per profile; global
|
|
97
|
+
// presentation/callback settings would be silently ignored, so refuse them.
|
|
98
|
+
if (!isProfileScopedKey(key)) {
|
|
99
|
+
throw new UsageError(`"${key}" is a global setting.`, 'Drop --profile to set it globally.');
|
|
100
|
+
}
|
|
101
|
+
const profiles = (updated.profiles ?? {});
|
|
102
|
+
updated.profiles = profiles;
|
|
103
|
+
const entry = (profiles[target] ?? {});
|
|
104
|
+
profiles[target] = entry;
|
|
105
|
+
writePath(entry, key, coerced);
|
|
106
|
+
}
|
|
82
107
|
const parsed = ConfigSchema.safeParse(updated);
|
|
83
108
|
if (!parsed.success) {
|
|
84
109
|
const { z } = await import('zod');
|
|
@@ -86,10 +111,11 @@ async function setConfig(ctx, _opts, command) {
|
|
|
86
111
|
}
|
|
87
112
|
await saveConfig(parsed.data);
|
|
88
113
|
if (ctx.io.json) {
|
|
89
|
-
ctx.io.writeJson({ key, value: coerced });
|
|
114
|
+
ctx.io.writeJson({ key, value: coerced, profile: target });
|
|
90
115
|
return;
|
|
91
116
|
}
|
|
92
|
-
|
|
117
|
+
const onProfile = target === 'default' ? '' : ` on profile ${ctx.io.bold(target)}`;
|
|
118
|
+
ctx.io.success(`Set ${ctx.io.bold(key)} to ${JSON.stringify(coerced)}${onProfile}.`);
|
|
93
119
|
if (key === 'trading.enabled' && coerced === false) {
|
|
94
120
|
ctx.io.info('The kill switch is on. All order commands will now refuse before contacting Kite.');
|
|
95
121
|
}
|
|
@@ -107,7 +133,19 @@ async function unsetConfig(ctx, _opts, command) {
|
|
|
107
133
|
}
|
|
108
134
|
const config = await loadConfig();
|
|
109
135
|
const updated = structuredClone(config);
|
|
110
|
-
|
|
136
|
+
const target = ctx.profile.name;
|
|
137
|
+
if (target === 'default') {
|
|
138
|
+
deletePath(updated, key);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
if (!isProfileScopedKey(key)) {
|
|
142
|
+
throw new UsageError(`"${key}" is a global setting.`, 'Drop --profile to unset it globally.');
|
|
143
|
+
}
|
|
144
|
+
const profiles = (updated.profiles ?? {});
|
|
145
|
+
const entry = profiles[target];
|
|
146
|
+
if (entry)
|
|
147
|
+
deletePath(entry, key);
|
|
148
|
+
}
|
|
111
149
|
const parsed = ConfigSchema.safeParse(updated);
|
|
112
150
|
if (!parsed.success) {
|
|
113
151
|
const { z } = await import('zod');
|
|
@@ -115,10 +153,11 @@ async function unsetConfig(ctx, _opts, command) {
|
|
|
115
153
|
}
|
|
116
154
|
await saveConfig(parsed.data);
|
|
117
155
|
if (ctx.io.json) {
|
|
118
|
-
ctx.io.writeJson({ key, unset: true });
|
|
156
|
+
ctx.io.writeJson({ key, unset: true, profile: target });
|
|
119
157
|
return;
|
|
120
158
|
}
|
|
121
|
-
|
|
159
|
+
const onProfile = target === 'default' ? '' : ` on profile ${ctx.io.bold(target)}`;
|
|
160
|
+
ctx.io.success(`Unset ${ctx.io.bold(key)}${onProfile}.`);
|
|
122
161
|
}
|
|
123
162
|
async function showPaths(ctx) {
|
|
124
163
|
const { io } = ctx;
|
|
@@ -139,6 +178,10 @@ async function showPaths(ctx) {
|
|
|
139
178
|
]));
|
|
140
179
|
}
|
|
141
180
|
// ---------------------------------------------------------------------------
|
|
181
|
+
/** Keys that mean something per profile; the rest are global (output, callback). */
|
|
182
|
+
function isProfileScopedKey(key) {
|
|
183
|
+
return key === 'apiKey' || key === 'env' || key.startsWith('trading.');
|
|
184
|
+
}
|
|
142
185
|
/** Coerce an argv string to the type the setting declares. */
|
|
143
186
|
function coerce(raw, type, key) {
|
|
144
187
|
if (type === 'boolean') {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CommandFactory } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Account profile management.
|
|
4
|
+
*
|
|
5
|
+
* Selection itself is stateless (`--profile` / KITE_PROFILE, resolved every
|
|
6
|
+
* run); these commands only manage the persisted set of profiles and the
|
|
7
|
+
* rarely-changed default. Logging in is deliberately left to `kite login` so
|
|
8
|
+
* credential handling lives in exactly one place.
|
|
9
|
+
*/
|
|
10
|
+
export declare const profileCommands: CommandFactory;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { confirm, isCancel } from '@clack/prompts';
|
|
2
|
+
import { EnvironmentSchema, loadConfig, saveConfig } from '../core/config.js';
|
|
3
|
+
import { deleteAllSecrets } from '../core/credentials.js';
|
|
4
|
+
import { AbortedError, ExitCode, KiteCliError, UsageError } from '../core/errors.js';
|
|
5
|
+
import { assertValidProfileName, DEFAULT_PROFILE, getProfile, isKnownProfile, listProfileNames, RESERVED_PROFILES, storagePrefixFor, } from '../core/profiles.js';
|
|
6
|
+
import { clearSessionMeta, isExpired, loadSessionMeta, timeUntilExpiry } from '../core/session.js';
|
|
7
|
+
import { printTable, renderKeyValue } from '../output/table.js';
|
|
8
|
+
/**
|
|
9
|
+
* Account profile management.
|
|
10
|
+
*
|
|
11
|
+
* Selection itself is stateless (`--profile` / KITE_PROFILE, resolved every
|
|
12
|
+
* run); these commands only manage the persisted set of profiles and the
|
|
13
|
+
* rarely-changed default. Logging in is deliberately left to `kite login` so
|
|
14
|
+
* credential handling lives in exactly one place.
|
|
15
|
+
*/
|
|
16
|
+
export const profileCommands = (program, run) => {
|
|
17
|
+
const profiles = program.command('profiles').description('Manage account profiles for multiple Zerodha accounts');
|
|
18
|
+
profiles
|
|
19
|
+
.command('list', { isDefault: true })
|
|
20
|
+
.alias('ls')
|
|
21
|
+
.description('List configured profiles and their session status')
|
|
22
|
+
.action(run(listProfiles));
|
|
23
|
+
profiles
|
|
24
|
+
.command('add')
|
|
25
|
+
.description('Register a new account profile (does not log in)')
|
|
26
|
+
.argument('<name>', 'A short label, e.g. personal or huf')
|
|
27
|
+
.option('--api-key <key>', 'Kite Connect API key for this account')
|
|
28
|
+
// `--env` is a global option; it cannot be redeclared here without being
|
|
29
|
+
// shadowed by the root parser, so `addProfile` reads it from ctx.options.
|
|
30
|
+
.option('--max-order-value <rupees>', 'Per-profile cap on any single order')
|
|
31
|
+
.action(run(addProfile));
|
|
32
|
+
profiles
|
|
33
|
+
.command('remove')
|
|
34
|
+
.alias('rm')
|
|
35
|
+
.description('Delete a profile and its stored credentials')
|
|
36
|
+
.argument('<name>')
|
|
37
|
+
.action(run(removeProfile));
|
|
38
|
+
profiles
|
|
39
|
+
.command('use')
|
|
40
|
+
.description('Set the default profile for future commands')
|
|
41
|
+
.argument('<name>')
|
|
42
|
+
.action(run(useProfile));
|
|
43
|
+
profiles.command('current').description('Show the profile this invocation resolves to').action(run(currentProfile));
|
|
44
|
+
};
|
|
45
|
+
async function listProfiles(ctx) {
|
|
46
|
+
const { io } = ctx;
|
|
47
|
+
const config = await loadConfig();
|
|
48
|
+
const rows = await Promise.all(listProfileNames(config).map(async (name) => {
|
|
49
|
+
const profile = getProfile(config, name);
|
|
50
|
+
const meta = await loadSessionMeta(name);
|
|
51
|
+
return {
|
|
52
|
+
profile: name,
|
|
53
|
+
env: profile.env,
|
|
54
|
+
api_key: Boolean(profile.apiKey),
|
|
55
|
+
default: (config.defaultProfile ?? DEFAULT_PROFILE) === name,
|
|
56
|
+
current: name === ctx.profile.name,
|
|
57
|
+
session: !meta
|
|
58
|
+
? io.dim('no session')
|
|
59
|
+
: isExpired(meta)
|
|
60
|
+
? io.yellow('expired')
|
|
61
|
+
: `expires in ${timeUntilExpiry(meta)}`,
|
|
62
|
+
};
|
|
63
|
+
}));
|
|
64
|
+
printTable(io, rows, [
|
|
65
|
+
{ header: '', value: (r) => (r.current ? io.green('●') : ' ') },
|
|
66
|
+
{ header: 'Profile', value: (r) => (r.current ? io.bold(r.profile) : r.profile) },
|
|
67
|
+
{ header: 'Env', value: (r) => (r.env === 'sandbox' ? io.cyan(r.env) : r.env) },
|
|
68
|
+
{ header: 'API key', value: (r) => (r.api_key ? io.green('set') : io.dim('—')) },
|
|
69
|
+
{ header: 'Default', value: (r) => (r.default ? '✓' : ' ') },
|
|
70
|
+
{ header: 'Session', value: (r) => r.session },
|
|
71
|
+
], rows.map(({ current: _current, ...rest }) => rest));
|
|
72
|
+
if (!io.json) {
|
|
73
|
+
io.note('');
|
|
74
|
+
io.info('Target a profile with `--profile <name>`, or set a default with `kite profiles use <name>`.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function addProfile(ctx, opts, command) {
|
|
78
|
+
const { io } = ctx;
|
|
79
|
+
const name = command.args[0];
|
|
80
|
+
if (!name)
|
|
81
|
+
throw new UsageError('A profile name is required, e.g. `kite profiles add personal`.');
|
|
82
|
+
assertValidProfileName(name);
|
|
83
|
+
if (RESERVED_PROFILES.includes(name)) {
|
|
84
|
+
throw new UsageError(`"${name}" is a reserved profile and always exists.`);
|
|
85
|
+
}
|
|
86
|
+
const config = await loadConfig();
|
|
87
|
+
if (isKnownProfile(config, name)) {
|
|
88
|
+
throw new UsageError(`Profile "${name}" already exists.`, 'Use `kite profiles remove` first, or pick another name.');
|
|
89
|
+
}
|
|
90
|
+
const entry = {};
|
|
91
|
+
if (opts.apiKey)
|
|
92
|
+
entry.apiKey = opts.apiKey;
|
|
93
|
+
// The global `--env` lands in ctx.options (see the command definition). It was
|
|
94
|
+
// already validated by profile resolution, so re-check defensively only.
|
|
95
|
+
if (ctx.options.env) {
|
|
96
|
+
const parsed = EnvironmentSchema.safeParse(ctx.options.env);
|
|
97
|
+
if (!parsed.success) {
|
|
98
|
+
throw new UsageError(`Unknown environment "${ctx.options.env}". Expected "production" or "sandbox".`);
|
|
99
|
+
}
|
|
100
|
+
entry.env = parsed.data;
|
|
101
|
+
}
|
|
102
|
+
if (opts.maxOrderValue !== undefined) {
|
|
103
|
+
const cap = Number(opts.maxOrderValue);
|
|
104
|
+
if (!Number.isFinite(cap) || cap <= 0)
|
|
105
|
+
throw new UsageError(`--max-order-value must be a positive number.`);
|
|
106
|
+
entry.trading = { maxOrderValue: cap };
|
|
107
|
+
}
|
|
108
|
+
await saveConfig({ ...config, profiles: { ...config.profiles, [name]: entry } });
|
|
109
|
+
if (io.json) {
|
|
110
|
+
io.writeJson({ added: name, ...entry });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
io.success(`Added profile ${io.bold(name)}.`);
|
|
114
|
+
io.info(`Log in with \`kite --profile ${name} login\`.`);
|
|
115
|
+
}
|
|
116
|
+
async function removeProfile(ctx, _opts, command) {
|
|
117
|
+
const { io } = ctx;
|
|
118
|
+
const name = command.args[0];
|
|
119
|
+
if (!name)
|
|
120
|
+
throw new UsageError('A profile name is required.');
|
|
121
|
+
if (RESERVED_PROFILES.includes(name)) {
|
|
122
|
+
throw new UsageError(`The reserved profile "${name}" cannot be removed.`);
|
|
123
|
+
}
|
|
124
|
+
const config = await loadConfig();
|
|
125
|
+
if (!isKnownProfile(config, name)) {
|
|
126
|
+
throw new UsageError(`No profile named "${name}".`, 'Run `kite profiles list` to see what exists.');
|
|
127
|
+
}
|
|
128
|
+
await confirmDestructive(ctx, `Remove profile "${name}" and its stored credentials?`);
|
|
129
|
+
// Drop the token and secret for this profile, then its session and config entry.
|
|
130
|
+
const scope = storagePrefixFor(getProfile(config, name));
|
|
131
|
+
await deleteAllSecrets({ scope });
|
|
132
|
+
await clearSessionMeta(name);
|
|
133
|
+
const profiles = { ...config.profiles };
|
|
134
|
+
delete profiles[name];
|
|
135
|
+
const next = { ...config, profiles };
|
|
136
|
+
// If this was the default, fall back to the reserved default rather than
|
|
137
|
+
// leaving a dangling pointer.
|
|
138
|
+
if (config.defaultProfile === name)
|
|
139
|
+
delete next.defaultProfile;
|
|
140
|
+
await saveConfig(next);
|
|
141
|
+
if (io.json) {
|
|
142
|
+
io.writeJson({ removed: name });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
io.success(`Removed profile ${io.bold(name)}.`);
|
|
146
|
+
}
|
|
147
|
+
async function useProfile(ctx, _opts, command) {
|
|
148
|
+
const { io } = ctx;
|
|
149
|
+
const name = command.args[0];
|
|
150
|
+
if (!name)
|
|
151
|
+
throw new UsageError('A profile name is required.');
|
|
152
|
+
assertValidProfileName(name);
|
|
153
|
+
const config = await loadConfig();
|
|
154
|
+
if (!isKnownProfile(config, name)) {
|
|
155
|
+
throw new UsageError(`No profile named "${name}".`, 'Create it first with `kite profiles add`.');
|
|
156
|
+
}
|
|
157
|
+
// Storing the reserved default as the pointer is just noise; clearing it says
|
|
158
|
+
// the same thing.
|
|
159
|
+
const next = { ...config };
|
|
160
|
+
if (name === DEFAULT_PROFILE)
|
|
161
|
+
delete next.defaultProfile;
|
|
162
|
+
else
|
|
163
|
+
next.defaultProfile = name;
|
|
164
|
+
await saveConfig(next);
|
|
165
|
+
const meta = await loadSessionMeta(name);
|
|
166
|
+
if (io.json) {
|
|
167
|
+
io.writeJson({ default_profile: name, user_id: meta?.userId ?? null });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
io.success(`Default profile is now ${io.bold(name)}.`);
|
|
171
|
+
// Loud, because a persisted default silently changes which account every
|
|
172
|
+
// later command targets — the exact thing a multi-account user must not lose
|
|
173
|
+
// track of. The identity is echoed so they can confirm it is who they meant.
|
|
174
|
+
const who = meta ? `${meta.userName ?? meta.userId} (${meta.userId})` : io.dim('not logged in yet');
|
|
175
|
+
io.warn(`Every command without --profile will now use ${io.bold(name)} — ${who}.`);
|
|
176
|
+
}
|
|
177
|
+
async function currentProfile(ctx) {
|
|
178
|
+
const { io, profile } = ctx;
|
|
179
|
+
const meta = await loadSessionMeta(profile.name);
|
|
180
|
+
if (io.json) {
|
|
181
|
+
io.writeJson({
|
|
182
|
+
profile: profile.name,
|
|
183
|
+
env: profile.env,
|
|
184
|
+
logged_in: ctx.client.hasSession(),
|
|
185
|
+
user_id: meta?.userId ?? null,
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
io.line(renderKeyValue(io, [
|
|
190
|
+
['Profile', profile.name === DEFAULT_PROFILE ? profile.name : io.bold(profile.name)],
|
|
191
|
+
['Environment', profile.env === 'sandbox' ? io.cyan('sandbox') : 'production'],
|
|
192
|
+
['API key', profile.apiKey ? io.green('set') : io.dim('not set')],
|
|
193
|
+
[
|
|
194
|
+
'Session',
|
|
195
|
+
!ctx.client.hasSession()
|
|
196
|
+
? io.dim('not logged in')
|
|
197
|
+
: meta
|
|
198
|
+
? `${meta.userName ?? meta.userId} (${meta.userId}), expires in ${timeUntilExpiry(meta)}`
|
|
199
|
+
: 'token from environment',
|
|
200
|
+
],
|
|
201
|
+
]));
|
|
202
|
+
}
|
|
203
|
+
/** A yes/no gate that respects --yes and refuses (rather than proceeds) without a TTY. */
|
|
204
|
+
async function confirmDestructive(ctx, message) {
|
|
205
|
+
if (ctx.options.yes)
|
|
206
|
+
return;
|
|
207
|
+
if (!process.stdin.isTTY || !ctx.io.stderrIsTty) {
|
|
208
|
+
throw new KiteCliError(`${message} needs confirmation, but this is not an interactive terminal.`, ExitCode.ConfirmationRequired, 'Pass --yes to confirm non-interactively.');
|
|
209
|
+
}
|
|
210
|
+
const answer = await confirm({ message, initialValue: false });
|
|
211
|
+
if (isCancel(answer) || answer !== true)
|
|
212
|
+
throw new AbortedError();
|
|
213
|
+
}
|
package/dist/commands/watch.js
CHANGED
|
@@ -72,7 +72,7 @@ async function watch(ctx, opts, command) {
|
|
|
72
72
|
}
|
|
73
73
|
const tokenToKey = new Map(watchlist.map((entry) => [entry.token, entry.key]));
|
|
74
74
|
// --- credentials for the socket ----------------------------------------
|
|
75
|
-
const stored = await getSecret('access_token', {
|
|
75
|
+
const stored = await getSecret('access_token', { scope: ctx.credentialScope });
|
|
76
76
|
if (!stored) {
|
|
77
77
|
throw new KiteCliError('No access token available for streaming.', ExitCode.Auth, 'Run `kite login`.');
|
|
78
78
|
}
|
package/dist/context.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { KiteApi } from './core/api.js';
|
|
|
2
2
|
import { KiteClient } from './core/client.js';
|
|
3
3
|
import { type Config, type Endpoints, type Environment } from './core/config.js';
|
|
4
4
|
import { InstrumentStore } from './core/instruments.js';
|
|
5
|
+
import { type ResolvedProfile } from './core/profiles.js';
|
|
5
6
|
import { type SessionMeta } from './core/session.js';
|
|
6
7
|
import { Io, type IoStreams } from './output/io.js';
|
|
7
8
|
/**
|
|
@@ -18,6 +19,7 @@ export interface GlobalOptions {
|
|
|
18
19
|
quiet?: boolean;
|
|
19
20
|
debug?: boolean;
|
|
20
21
|
env?: string;
|
|
22
|
+
profile?: string;
|
|
21
23
|
yes?: boolean;
|
|
22
24
|
dryRun?: boolean;
|
|
23
25
|
}
|
|
@@ -25,6 +27,10 @@ export interface Context {
|
|
|
25
27
|
io: Io;
|
|
26
28
|
config: Config;
|
|
27
29
|
env: Environment;
|
|
30
|
+
/** The account this invocation targets. */
|
|
31
|
+
profile: ResolvedProfile;
|
|
32
|
+
/** Keyring / file namespace prefix for this profile's secrets. */
|
|
33
|
+
credentialScope: string;
|
|
28
34
|
endpoints: Endpoints;
|
|
29
35
|
client: KiteClient;
|
|
30
36
|
api: KiteApi;
|