@kairon/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { parseArgs, stringFlag } from './args.js';
5
+ import { loadCatalogue } from './catalogue.js';
6
+ import { callTool, ToolFailed, unknownCommand } from './commands/call.js';
7
+ import { login } from './commands/login.js';
8
+ import { logout } from './commands/logout.js';
9
+ import { whoami } from './commands/whoami.js';
10
+ import { CliError } from './errors.js';
11
+ import { commandHelp, groupHelp, rootHelp } from './help.js';
12
+ import { findTool, groupsOf, toolsInGroup } from './naming.js';
13
+ import { resolveServer } from './server.js';
14
+ import { CLI_VERSION } from './version.js';
15
+ /**
16
+ * The router (PRD-57 MR51/MR52).
17
+ *
18
+ * Three built-in commands — `login`, `logout`, `whoami` — and then **everything
19
+ * else comes from the server**. There is no list of tool commands in this file, or
20
+ * anywhere in `apps/cli`: `group verb` is resolved against the cached `tools/list`,
21
+ * so the two doors cannot drift. A tool added server-side becomes a CLI command the
22
+ * next time the cache refreshes, and a tool removed stops resolving. That is the
23
+ * whole design (ADR-0054 §7), and its evidence is the absence of a table here.
24
+ */
25
+ export async function run(argv, out = process.stdout, env = process.env) {
26
+ const { command, positionals, flags } = parseArgs(argv);
27
+ if (flags.version === true) {
28
+ out.write(`${CLI_VERSION}\n`);
29
+ return;
30
+ }
31
+ const server = resolveServer(stringFlag(flags, 'server'), env);
32
+ const refresh = flags.refresh === true;
33
+ const wantsHelp = flags.help === true;
34
+ if (command === undefined || command === 'help') {
35
+ out.write(rootHelp(await catalogueFor(server, refresh, env)));
36
+ return;
37
+ }
38
+ if (!wantsHelp) {
39
+ switch (command) {
40
+ case 'login':
41
+ await login(server, out);
42
+ return;
43
+ case 'logout':
44
+ await logout(server, out);
45
+ return;
46
+ case 'whoami':
47
+ await whoami(server, out);
48
+ return;
49
+ }
50
+ }
51
+ const tools = await catalogueFor(server, refresh, env);
52
+ const verb = positionals[0];
53
+ const tool = findTool(tools, command, verb);
54
+ if (!tool) {
55
+ // A group with no verb is a request to see the group, not a mistake — `kairon
56
+ // list` should list, the way `git remote` does.
57
+ if (verb === undefined && toolsInGroup(tools, command).length > 0) {
58
+ out.write(groupHelp(tools, command));
59
+ return;
60
+ }
61
+ if (wantsHelp && ['login', 'logout', 'whoami'].includes(command)) {
62
+ out.write(rootHelp(tools));
63
+ return;
64
+ }
65
+ throw unknownCommand([command, verb].filter(Boolean).join(' '), groupsOf(tools));
66
+ }
67
+ if (wantsHelp) {
68
+ out.write(commandHelp(tool));
69
+ return;
70
+ }
71
+ await callTool(server, tool, flags, out, env);
72
+ }
73
+ /**
74
+ * The catalogue for this invocation, cache-first (MR57).
75
+ *
76
+ * A cache miss with no credentials must not turn `kairon --help` into a login
77
+ * prompt: help is the command you run *before* you have logged in. So a failed
78
+ * fetch degrades to an empty catalogue, and `rootHelp` says how to populate it.
79
+ * `--refresh` is the exception — it is an explicit request to go and look, so its
80
+ * failure is the answer.
81
+ */
82
+ async function catalogueFor(server, refresh, env) {
83
+ try {
84
+ return await loadCatalogue(server, { refresh, env });
85
+ }
86
+ catch (error) {
87
+ if (refresh)
88
+ throw error;
89
+ return [];
90
+ }
91
+ }
92
+ /**
93
+ * The composition root. Three exit shapes, on purpose:
94
+ *
95
+ * - a predicted CLI failure prints its message and a next step (MR49);
96
+ * - a **tool** failure prints the server's `code` and its JSON payload on stderr
97
+ * and exits non-zero (MR56) — the code first, because that is what a script
98
+ * branches on;
99
+ * - anything else keeps its stack, because an unforeseen crash is the one case
100
+ * where the trace is the useful part.
101
+ *
102
+ * All three write to stderr, so a failed run leaves stdout empty and a pipeline
103
+ * sees nothing rather than half a document.
104
+ */
105
+ async function main() {
106
+ try {
107
+ await run(process.argv.slice(2));
108
+ }
109
+ catch (error) {
110
+ if (error instanceof ToolFailed) {
111
+ process.stderr.write(`kairon ${error.command} failed: ${error.code}\n`);
112
+ if (error.payload !== undefined) {
113
+ process.stderr.write(`${JSON.stringify(error.payload, null, 2)}\n`);
114
+ }
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ if (error instanceof CliError) {
119
+ process.stderr.write(`${error.message}\n`);
120
+ if (error.hint)
121
+ process.stderr.write(`\n ${error.hint}\n`);
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+ process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
126
+ process.exitCode = 1;
127
+ }
128
+ }
129
+ /**
130
+ * Are we the program, or is something importing us?
131
+ *
132
+ * The spec imports {@link run}, and importing must not parse argv or set an exit code —
133
+ * hence a guard. But the guard has to survive the way this file is actually invoked once
134
+ * installed: npm links `bin/kairon` to `dist/index.js`, and **Node reports the symlink
135
+ * path in `argv[1]`, not its target**. A guard that matched on the filename ending in
136
+ * `index.js` therefore answered `false` for every real installation, and `kairon whoami`
137
+ * exited 0 having done nothing at all — silence, not an error.
138
+ *
139
+ * `realpathSync` resolves the link so both sides name the same file. Wrapped because
140
+ * `argv[1]` can be absent (`node -e`) or point at something unstatable.
141
+ */
142
+ function invokedDirectly() {
143
+ const entry = process.argv[1];
144
+ if (!entry)
145
+ return false;
146
+ try {
147
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ if (invokedDirectly()) {
154
+ void main();
155
+ }
package/dist/naming.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tool name → command name (PRD-57 MR52).
3
+ *
4
+ * Deterministic and total, with **no mapping table** — that absence is the point.
5
+ * A table is a second definition of the command surface, and a second definition
6
+ * is a thing that drifts: add `campaign_stats` server-side, forget the table entry,
7
+ * and the CLI silently lacks a command nobody notices until a user asks for it.
8
+ * Derivation cannot forget.
9
+ *
10
+ * Split on the FIRST underscore; the remainder is kebab-cased:
11
+ *
12
+ * ```
13
+ * list_add → list add
14
+ * campaign_run_get → campaign run-get
15
+ * search_sales_navigator → search sales-navigator
16
+ * signal_search → signal search
17
+ * ```
18
+ *
19
+ * The first underscore, not every underscore, because the leading segment is the
20
+ * noun the catalogue already groups by (`list_*`, `campaign_*`, `chat_*`) and the
21
+ * rest is one verb phrase. Splitting on all of them would make
22
+ * `search_sales_navigator` into `search sales navigator` — three levels of
23
+ * subcommand for one operation.
24
+ */
25
+ export function commandNameOf(toolName) {
26
+ const underscore = toolName.indexOf('_');
27
+ // A tool with no underscore is its own command (`kairon whoami`-shaped). None
28
+ // exists today; handling it here means adding one server-side is not a CLI change.
29
+ if (underscore === -1)
30
+ return { group: toolName };
31
+ return {
32
+ group: toolName.slice(0, underscore),
33
+ verb: toolName.slice(underscore + 1).replaceAll('_', '-'),
34
+ };
35
+ }
36
+ /** How the command reads in help and errors: `list add`. */
37
+ export function displayName(toolName) {
38
+ const { group, verb } = commandNameOf(toolName);
39
+ return verb ? `${group} ${verb}` : group;
40
+ }
41
+ /** The tool a `group [verb]` pair addresses, or undefined when nothing matches. */
42
+ export function findTool(tools, group, verb) {
43
+ return tools.find((tool) => {
44
+ const name = commandNameOf(tool.name);
45
+ return name.group === group && name.verb === verb;
46
+ });
47
+ }
48
+ /** Every command in a group, for `kairon list --help`. */
49
+ export function toolsInGroup(tools, group) {
50
+ return tools.filter((tool) => commandNameOf(tool.name).group === group);
51
+ }
52
+ /** The groups a catalogue exposes, in stable order — the top level of `kairon --help`. */
53
+ export function groupsOf(tools) {
54
+ return [...new Set(tools.map((tool) => commandNameOf(tool.name).group))].sort();
55
+ }
@@ -0,0 +1,62 @@
1
+ import { CliError } from '../errors.js';
2
+ async function getJson(url, what) {
3
+ let response;
4
+ try {
5
+ response = await fetch(url, { headers: { accept: 'application/json' } });
6
+ }
7
+ catch (error) {
8
+ throw new CliError(`Could not reach ${url} to read ${what}: ${error instanceof Error ? error.message : String(error)}`, 'Check the server URL and your network, then try again.');
9
+ }
10
+ if (!response.ok)
11
+ return null;
12
+ try {
13
+ return (await response.json());
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /**
20
+ * Find the authorization server for a Kairon deployment, the way an MCP client is
21
+ * supposed to (RFC 9728 → RFC 8414): ask the protected resource who vouches for
22
+ * it, then ask that issuer for its endpoints.
23
+ *
24
+ * Discovering rather than hardcoding is what makes `--server` (MR58) work against
25
+ * a local API, a preview deployment, and production without a build per target.
26
+ *
27
+ * Note what this deployment does **not** publish: no `jwks_uri` (access tokens are
28
+ * opaque and introspected server-side) and no revocation endpoint. So the CLI can
29
+ * neither verify a token locally nor tell the server to forget one — see
30
+ * `commands/logout.ts` for what that means for logout.
31
+ */
32
+ export async function discoverAuthServer(server) {
33
+ const resource = await getJson(`${server}/.well-known/oauth-protected-resource`, 'the protected-resource metadata');
34
+ // Fall back to the server itself as its own issuer: a deployment that serves the
35
+ // authorization-server document but not the resource one is still usable, and
36
+ // that is exactly the shape an older Kairon has.
37
+ const issuer = resource?.authorization_servers?.[0] ?? server;
38
+ const metadata = await getJson(`${issuer.replace(/\/+$/, '')}/.well-known/oauth-authorization-server`, 'the authorization-server metadata');
39
+ if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
40
+ throw new CliError(`${server} does not look like a Kairon server: no OAuth metadata at ${issuer}.`, 'Check --server (or KAIRON_API_URL).');
41
+ }
42
+ return metadata;
43
+ }
44
+ /**
45
+ * The scopes to ask for, intersected with what the server admits it supports.
46
+ *
47
+ * `offline_access` is the load-bearing one: Better Auth only returns a refresh
48
+ * token when it is among the granted scopes, and without a refresh token every
49
+ * access token expiry (30 days) would mean another browser round trip. Asking for
50
+ * a scope the server doesn't list gets the whole request rejected, hence the
51
+ * intersection rather than a fixed string.
52
+ */
53
+ export function scopesToRequest(metadata) {
54
+ const wanted = ['openid', 'profile', 'email', 'offline_access'];
55
+ const supported = metadata.scopes_supported;
56
+ if (!supported || supported.length === 0)
57
+ return wanted.join(' ');
58
+ const granted = wanted.filter((scope) => supported.includes(scope));
59
+ // `openid` alone is the floor: an authorization request with no scope at all is
60
+ // not one Better Auth's provider answers usefully.
61
+ return granted.length > 0 ? granted.join(' ') : 'openid';
62
+ }
@@ -0,0 +1,99 @@
1
+ import { createServer } from 'node:http';
2
+ import { CliError } from '../errors.js';
3
+ /** What the browser tab shows once the code is in hand. */
4
+ function resultPage(title, detail) {
5
+ return `<!doctype html>
6
+ <html lang="en"><head><meta charset="utf-8"><title>${title}</title>
7
+ <style>
8
+ body { font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
9
+ display: grid; place-items: center; min-height: 100vh; margin: 0; color: #1a1a1a; }
10
+ main { max-width: 26rem; padding: 2rem; text-align: center; }
11
+ h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 .5rem; }
12
+ p { margin: 0; color: #666; }
13
+ @media (prefers-color-scheme: dark) { body { background: #111; color: #f5f5f5; } p { color: #999; } }
14
+ </style></head>
15
+ <body><main><h1>${title}</h1><p>${detail}</p></main></body></html>`;
16
+ }
17
+ /**
18
+ * A single-use HTTP listener on `127.0.0.1` to catch the OAuth redirect (MR47).
19
+ *
20
+ * Loopback (RFC 8252 §7.3) is how a native app receives an authorization code
21
+ * without a client secret and without a hosted callback: the browser redirects to
22
+ * a port only this process is listening on.
23
+ *
24
+ * Two choices worth naming:
25
+ * - **`127.0.0.1`, not `localhost`.** `localhost` may resolve to `::1` on a
26
+ * machine with IPv6, and then the browser knocks on a door we never opened.
27
+ * RFC 8252 §8.3 says to use the literal address for exactly this reason.
28
+ * - **Port 0.** The OS picks a free port, so two logins can run at once and a
29
+ * fixed port can never be squatted. The cost is that registration has to wait
30
+ * for the bind (see `register.ts`).
31
+ *
32
+ * `state` is verified here rather than by the caller so that a mismatched redirect
33
+ * can never reach the token exchange at all.
34
+ */
35
+ export async function startLoopback(state) {
36
+ let resolveCode;
37
+ let rejectCode;
38
+ const code = new Promise((resolve, reject) => {
39
+ resolveCode = resolve;
40
+ rejectCode = reject;
41
+ });
42
+ // Mark the promise handled the moment it exists. It is created here but only
43
+ // awaited by the caller *after* dynamic client registration returns, and it can
44
+ // reject inside that window (a stray request to the port, a browser that
45
+ // pre-fetches). Node's default for an unhandled rejection is to kill the
46
+ // process, so without this a bad redirect crashes the CLI instead of printing
47
+ // the error it already knows how to print. Awaiting `code` still rejects
48
+ // normally — this only stops the runtime treating it as nobody's problem.
49
+ code.catch(() => { });
50
+ const server = createServer((req, res) => {
51
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
52
+ // The browser asks for /favicon.ico on the way past; answering 404 keeps it
53
+ // from being mistaken for a malformed callback.
54
+ if (url.pathname !== '/callback') {
55
+ res.writeHead(404).end();
56
+ return;
57
+ }
58
+ const send = (status, title, detail) => {
59
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
60
+ res.end(resultPage(title, detail));
61
+ };
62
+ const error = url.searchParams.get('error');
63
+ if (error) {
64
+ const description = url.searchParams.get('error_description') ?? error;
65
+ send(400, 'Sign-in failed', description);
66
+ rejectCode(new CliError(`Authorization failed: ${description}`));
67
+ return;
68
+ }
69
+ if (url.searchParams.get('state') !== state) {
70
+ // Someone else's redirect, or a replay. Never exchange the code.
71
+ send(400, 'Sign-in failed', 'The response did not match this sign-in attempt.');
72
+ rejectCode(new CliError('Authorization state did not match. Please run kairon login again.'));
73
+ return;
74
+ }
75
+ const received = url.searchParams.get('code');
76
+ if (!received) {
77
+ send(400, 'Sign-in failed', 'No authorization code was returned.');
78
+ rejectCode(new CliError('Authorization returned no code.'));
79
+ return;
80
+ }
81
+ send(200, 'You are signed in', 'You can close this tab and return to your terminal.');
82
+ resolveCode(received);
83
+ });
84
+ await new Promise((resolve, reject) => {
85
+ server.once('error', reject);
86
+ server.listen(0, '127.0.0.1', resolve);
87
+ });
88
+ const address = server.address();
89
+ if (address === null || typeof address === 'string') {
90
+ server.close();
91
+ throw new CliError('Could not open a local port to receive the sign-in redirect.');
92
+ }
93
+ const { port } = address;
94
+ return {
95
+ redirectUri: `http://127.0.0.1:${port}/callback`,
96
+ waitForCode: () => code,
97
+ close: () => server.close(),
98
+ };
99
+ }
@@ -0,0 +1,17 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ /**
3
+ * A PKCE verifier/challenge pair (RFC 7636), always S256.
4
+ *
5
+ * `plain` is never offered: this server rejects it, and a public client that can
6
+ * fall back to `plain` has no proof-of-possession at all — the interception the
7
+ * whole exchange exists to prevent.
8
+ */
9
+ export function createPkcePair() {
10
+ const verifier = randomBytes(32).toString('base64url');
11
+ const challenge = createHash('sha256').update(verifier).digest().toString('base64url');
12
+ return { verifier, challenge, method: 'S256' };
13
+ }
14
+ /** A one-time value binding the browser redirect to this process (CSRF, RFC 6749 §10.12). */
15
+ export function createState() {
16
+ return randomBytes(16).toString('base64url');
17
+ }
@@ -0,0 +1,37 @@
1
+ import { CliError } from '../errors.js';
2
+ /**
3
+ * Register this CLI as a public OAuth client (RFC 7591 Dynamic Client Registration).
4
+ *
5
+ * Registration happens on every `kairon login` rather than once at build time, and
6
+ * that is forced by the loopback redirect: the redirect URI carries the port we
7
+ * just bound, the port is ephemeral, and a registered client only accepts the
8
+ * exact URIs it registered. Registering after binding is the only ordering that
9
+ * works.
10
+ *
11
+ * `token_endpoint_auth_method: 'none'` is the honest declaration — a CLI shipped to
12
+ * users' machines cannot keep a client secret, so it doesn't pretend to have one.
13
+ * PKCE is what proves the token request came from whoever started the flow.
14
+ */
15
+ export async function registerClient(metadata, redirectUri, scope) {
16
+ const endpoint = metadata.registration_endpoint;
17
+ if (!endpoint) {
18
+ throw new CliError('This Kairon server does not offer dynamic client registration.', 'Set KAIRON_TOKEN instead, or upgrade the server.');
19
+ }
20
+ const response = await fetch(endpoint, {
21
+ method: 'POST',
22
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
23
+ body: JSON.stringify({
24
+ client_name: 'Kairon CLI',
25
+ redirect_uris: [redirectUri],
26
+ token_endpoint_auth_method: 'none',
27
+ grant_types: ['authorization_code', 'refresh_token'],
28
+ response_types: ['code'],
29
+ scope,
30
+ }),
31
+ });
32
+ const body = (await response.json().catch(() => ({})));
33
+ if (!response.ok || !body.client_id) {
34
+ throw new CliError(`Could not register with ${endpoint} (HTTP ${response.status}): ${body.error_description ?? body.error ?? 'no client_id returned'}`);
35
+ }
36
+ return body.client_id;
37
+ }
@@ -0,0 +1,100 @@
1
+ import { CliError } from '../errors.js';
2
+ /**
3
+ * Raised when the *refresh* token itself is no longer good — the one failure the
4
+ * CLI cannot fix on the user's behalf, so it has to become "run kairon login"
5
+ * rather than a retry (MR49).
6
+ */
7
+ export class RefreshRejected extends Error {
8
+ }
9
+ /**
10
+ * Access-token lifetime when the server doesn't state one. Deliberately short:
11
+ * guessing long would mean sailing past a real expiry and failing the user's
12
+ * actual command, while guessing short only costs one extra refresh round trip.
13
+ */
14
+ const FALLBACK_EXPIRES_IN_SECONDS = 3600;
15
+ function toCredentials(body, clientId, previousRefreshToken) {
16
+ if (!body.access_token)
17
+ throw new CliError('The server returned no access token.');
18
+ const lifetime = body.expires_in ?? FALLBACK_EXPIRES_IN_SECONDS;
19
+ return {
20
+ clientId,
21
+ accessToken: body.access_token,
22
+ // Refresh-token rotation is allowed but not required; when the server declines
23
+ // to issue a new one, the old one stays valid and dropping it here would log
24
+ // the user out at the next expiry for no reason.
25
+ refreshToken: body.refresh_token ?? previousRefreshToken,
26
+ expiresAt: new Date(Date.now() + lifetime * 1000).toISOString(),
27
+ scope: body.scope,
28
+ };
29
+ }
30
+ async function postForm(endpoint, form) {
31
+ const response = await fetch(endpoint, {
32
+ method: 'POST',
33
+ headers: {
34
+ 'content-type': 'application/x-www-form-urlencoded',
35
+ accept: 'application/json',
36
+ },
37
+ body: new URLSearchParams(form).toString(),
38
+ });
39
+ const body = (await response.json().catch(() => ({})));
40
+ if (!response.ok) {
41
+ const detail = body.error_description ?? body.error ?? `HTTP ${response.status}`;
42
+ // `invalid_grant` is the spec's way of saying the grant is spent, revoked, or
43
+ // expired. On the refresh path that is terminal; `session.ts` turns it into
44
+ // the re-login message.
45
+ if (body.error === 'invalid_grant')
46
+ throw new RefreshRejected(detail);
47
+ throw new CliError(`Token request failed: ${detail}`);
48
+ }
49
+ return body;
50
+ }
51
+ /** Exchange the authorization code for tokens, proving PKCE possession (MR47). */
52
+ export async function exchangeCode(metadata, params) {
53
+ const body = await postForm(metadata.token_endpoint, {
54
+ grant_type: 'authorization_code',
55
+ code: params.code,
56
+ redirect_uri: params.redirectUri,
57
+ client_id: params.clientId,
58
+ code_verifier: params.verifier,
59
+ });
60
+ return toCredentials(body, params.clientId);
61
+ }
62
+ /**
63
+ * Trade a refresh token for a fresh access token (MR49).
64
+ *
65
+ * Throws `RefreshRejected` when the refresh token is dead — distinct from a
66
+ * network blip or a 500, which are worth surfacing as themselves rather than
67
+ * telling the user to log in again over a transient failure.
68
+ */
69
+ export async function refreshCredentials(metadata, credentials) {
70
+ if (!credentials.refreshToken) {
71
+ throw new RefreshRejected('no refresh token was stored');
72
+ }
73
+ const body = await postForm(metadata.token_endpoint, {
74
+ grant_type: 'refresh_token',
75
+ refresh_token: credentials.refreshToken,
76
+ client_id: credentials.clientId,
77
+ });
78
+ return toCredentials(body, credentials.clientId, credentials.refreshToken);
79
+ }
80
+ /**
81
+ * Build the URL the user's browser opens to approve the CLI.
82
+ *
83
+ * The `resource` parameter (RFC 8707) is deliberately absent: this server's
84
+ * authorize endpoint does not consume it, and sending an unrecognised parameter
85
+ * to an authorization endpoint is the sort of thing that fails in production and
86
+ * nowhere else.
87
+ */
88
+ export function authorizeUrl(metadata, params) {
89
+ const url = new URL(metadata.authorization_endpoint);
90
+ url.search = new URLSearchParams({
91
+ response_type: 'code',
92
+ client_id: params.clientId,
93
+ redirect_uri: params.redirectUri,
94
+ code_challenge: params.challenge,
95
+ code_challenge_method: 'S256',
96
+ scope: params.scope,
97
+ state: params.state,
98
+ }).toString();
99
+ return url.toString();
100
+ }
package/dist/server.js ADDED
@@ -0,0 +1,40 @@
1
+ import { CliError } from './errors.js';
2
+ /**
3
+ * Where `kairon` points when nobody says otherwise (MR58).
4
+ *
5
+ * The API and the SPA share this origin — the MCP door is `/mcp` on it, and the
6
+ * OAuth authorization server is the same host (see the discovery chain in
7
+ * `oauth/discovery.ts`).
8
+ */
9
+ export const PRODUCTION_SERVER = 'https://app.heykairon.com';
10
+ /**
11
+ * Resolve the target Kairon: `--server`, else `KAIRON_API_URL`, else production
12
+ * (MR58). Defaulting to production is the deliberate choice — the CLI is for
13
+ * operators, and an operator who has to pass a flag to reach their own data will
14
+ * eventually point a script at the wrong place. Developers opt *out* by env var.
15
+ */
16
+ export function resolveServer(flag, env = process.env) {
17
+ const raw = flag ?? env.KAIRON_API_URL ?? PRODUCTION_SERVER;
18
+ return normalizeServer(raw);
19
+ }
20
+ /**
21
+ * Canonical form of a server URL: origin + path, no trailing slash.
22
+ *
23
+ * Normalising matters beyond tidiness — the credential store is keyed by this
24
+ * string, so `https://app.heykairon.com` and `https://app.heykairon.com/` must
25
+ * not become two entries that each think the other is logged out.
26
+ */
27
+ export function normalizeServer(raw) {
28
+ let url;
29
+ try {
30
+ url = new URL(raw);
31
+ }
32
+ catch {
33
+ throw new CliError(`"${raw}" is not a valid server URL.`, 'kairon --server https://app.heykairon.com');
34
+ }
35
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
36
+ throw new CliError(`Server URL must be http or https, got "${url.protocol}".`);
37
+ }
38
+ const path = url.pathname.replace(/\/+$/, '');
39
+ return `${url.origin}${path}`;
40
+ }
@@ -0,0 +1,54 @@
1
+ import { readCredentials, saveCredentials } from './config.js';
2
+ import { CliError, notLoggedIn } from './errors.js';
3
+ import { discoverAuthServer } from './oauth/discovery.js';
4
+ import { RefreshRejected, refreshCredentials } from './oauth/tokens.js';
5
+ /**
6
+ * Refresh this far before the stated expiry.
7
+ *
8
+ * A token that is valid "for another 5 seconds" is not valid enough: the request
9
+ * still has to cross the network and be introspected, and the server compares
10
+ * against its own clock, which is not exactly ours. Sixty seconds of slack turns a
11
+ * rare, confusing 401 into a refresh nobody notices.
12
+ */
13
+ const REFRESH_SKEW_MS = 60_000;
14
+ export function isExpiring(expiresAt, now = Date.now()) {
15
+ const at = Date.parse(expiresAt);
16
+ // An unparseable timestamp is treated as expired: refreshing needlessly costs
17
+ // one round trip, whereas trusting a corrupt value costs the user's command.
18
+ if (Number.isNaN(at))
19
+ return true;
20
+ return at - REFRESH_SKEW_MS <= now;
21
+ }
22
+ /**
23
+ * The bearer token for the next call, refreshed if it needs to be (MR49, MR50).
24
+ *
25
+ * Order matters. `KAIRON_TOKEN` wins outright and short-circuits everything —
26
+ * no config file is read, none is written, and no refresh is attempted. That is
27
+ * what makes a headless run (CI, cron, a container) work with no `~/.kairon` at
28
+ * all, and it is deliberately the *only* headless story: an API-key scheme would
29
+ * reverse ADR-0017 §2 and is its own decision, not a convenience to slip in here.
30
+ */
31
+ export async function resolveAccessToken(server, env = process.env) {
32
+ const override = env.KAIRON_TOKEN?.trim();
33
+ if (override)
34
+ return override;
35
+ const stored = await readCredentials(server, env);
36
+ if (!stored)
37
+ throw notLoggedIn(server);
38
+ if (!isExpiring(stored.expiresAt))
39
+ return stored.accessToken;
40
+ const metadata = await discoverAuthServer(server);
41
+ try {
42
+ const refreshed = await refreshCredentials(metadata, stored);
43
+ await saveCredentials(server, refreshed, env);
44
+ return refreshed.accessToken;
45
+ }
46
+ catch (error) {
47
+ if (error instanceof RefreshRejected) {
48
+ // The end of the road, and the one place the user has to act. Say so in a
49
+ // sentence and hand them the command, rather than surfacing `invalid_grant`.
50
+ throw new CliError(`Your session for ${server} has expired.`, `kairon login --server ${server}`);
51
+ }
52
+ throw error;
53
+ }
54
+ }
@@ -0,0 +1,3 @@
1
+ /** Reported to the MCP server as the client version, and by `kairon --version`. */
2
+ export const CLI_VERSION = '0.1.0';
3
+ export const CLI_NAME = 'kairon-cli';