@2kw/ai 5.2.0-dev.8 → 5.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/README.md +31 -6
- package/dist/commands/auth.d.ts +91 -1
- package/dist/commands/auth.js +326 -53
- package/dist/commands/config.d.ts +16 -0
- package/dist/commands/config.js +52 -19
- package/dist/commands/context.d.ts +25 -0
- package/dist/commands/context.js +108 -14
- package/dist/commands/convert.js +3 -2
- package/dist/commands/docs.js +10 -6
- package/dist/commands/transcribe.js +3 -1
- package/dist/lib/auth-service.d.ts +131 -0
- package/dist/lib/auth-service.js +240 -0
- package/dist/lib/auth-session.d.ts +139 -0
- package/dist/lib/auth-session.js +275 -0
- package/dist/lib/client.d.ts +57 -0
- package/dist/lib/client.js +81 -5
- package/dist/lib/config.d.ts +64 -5
- package/dist/lib/config.js +105 -15
- package/dist/lib/errors.d.ts +2 -1
- package/dist/lib/errors.js +66 -11
- package/dist/lib/redact.d.ts +35 -0
- package/dist/lib/redact.js +54 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ The CLI notifies you when a newer version is available.
|
|
|
19
19
|
## Quick Start
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
# Authenticate
|
|
22
|
+
# Authenticate — opens your browser, no API key needed (7-day free trial)
|
|
23
23
|
2kw auth login
|
|
24
24
|
|
|
25
25
|
# Define a schema and run an extraction
|
|
@@ -38,21 +38,46 @@ All commands work identically as `bb` for quick access — e.g. `bb schemas list
|
|
|
38
38
|
|
|
39
39
|
## Authentication
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
Sign in via the browser (recommended):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
2kw auth login
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This opens your browser, asks you to confirm a short code, and signs the CLI
|
|
48
|
+
in with your user account — no API key to copy. Your session lasts 7 days of
|
|
49
|
+
inactivity and renews itself while you keep using the CLI. If you belong to
|
|
50
|
+
several organizations you pick one at login, and can switch any time with
|
|
51
|
+
`2kw context set-org` — no re-login needed. `2kw auth logout` clears the local
|
|
52
|
+
credentials and signs the session out server-side (best-effort).
|
|
53
|
+
|
|
54
|
+
For CI, scripts, or air-gapped machines, store an org API key instead
|
|
55
|
+
(create one under Settings → API Keys in the web app):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
2kw auth login --api-key sk_... # save a key directly
|
|
59
|
+
2kw auth login --manual # prompted setup (legacy flow)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Explicit API keys always take priority over a stored browser session
|
|
63
|
+
(highest priority first):
|
|
42
64
|
|
|
43
65
|
| Method | Example |
|
|
44
66
|
|--------|---------|
|
|
45
67
|
| CLI flags | `--api-key sk_... --base-url https://...` |
|
|
46
68
|
| Environment variables | `AI_2KW_API_KEY`, `AI_2KW_BASE_URL` (legacy: `BACKBONE_*`) |
|
|
47
69
|
| Local `.2kw` file | JSON file in the current directory (legacy: `.backbone`) |
|
|
48
|
-
| Config store | Set via `2kw auth login` |
|
|
70
|
+
| Config store | Set via `2kw auth login` (browser session or saved key) |
|
|
49
71
|
|
|
50
72
|
```bash
|
|
51
|
-
2kw auth
|
|
52
|
-
2kw auth
|
|
53
|
-
2kw auth logout # Clear stored credentials
|
|
73
|
+
2kw auth status # Verify credentials (works for both auth types)
|
|
74
|
+
2kw auth logout # Clear credentials and send a server-side sign-out
|
|
54
75
|
```
|
|
55
76
|
|
|
77
|
+
Note: `config get apiKey --json` returns a masked value — stored secrets do
|
|
78
|
+
not round-trip through stdout. Scripts needing the raw key should use
|
|
79
|
+
`AI_2KW_API_KEY` or read the config file (path shown by `config list`).
|
|
80
|
+
|
|
56
81
|
## Contexts
|
|
57
82
|
|
|
58
83
|
kubectl-style contexts switch between organizations and environments:
|
package/dist/commands/auth.d.ts
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
|
|
2
|
+
import { setContext, setActiveContext, type ResolvedConfig } from "../lib/config.js";
|
|
3
|
+
import { getSessionInfo, listOrganizations, pollDeviceToken, requestDeviceCode, revokeSession, setActiveOrganization } from "../lib/auth-service.js";
|
|
4
|
+
import { runDevicePolling, selectOrganization } from "../lib/auth-session.js";
|
|
5
|
+
type SessionConfig = Extract<ResolvedConfig, {
|
|
6
|
+
kind: "session";
|
|
7
|
+
}>;
|
|
8
|
+
/**
|
|
9
|
+
* Group an 8-character device user code as XXXX-XXXX.
|
|
10
|
+
*
|
|
11
|
+
* Purely presentational: the code is read off a terminal and typed into a
|
|
12
|
+
* browser, and a grouped one survives that trip with fewer mistakes. Anything
|
|
13
|
+
* that is not exactly 8 characters (a service configured for another length,
|
|
14
|
+
* or a code that already carries its own separator) is shown untouched.
|
|
15
|
+
*/
|
|
16
|
+
export declare function formatUserCode(code: string): string;
|
|
17
|
+
/** Injection points for {@link deviceFlowLogin}; all default to the real thing. */
|
|
18
|
+
export interface DeviceLoginDeps {
|
|
19
|
+
requestCode?: typeof requestDeviceCode;
|
|
20
|
+
poll?: typeof pollDeviceToken;
|
|
21
|
+
runPolling?: typeof runDevicePolling;
|
|
22
|
+
sessionInfo?: typeof getSessionInfo;
|
|
23
|
+
listOrgs?: typeof listOrganizations;
|
|
24
|
+
chooseOrg?: typeof selectOrganization;
|
|
25
|
+
setActiveOrg?: typeof setActiveOrganization;
|
|
26
|
+
/** Used to clean up a session that never got an organization. */
|
|
27
|
+
revoke?: typeof revokeSession;
|
|
28
|
+
openBrowser?: (url: string) => Promise<void>;
|
|
29
|
+
saveContext?: typeof setContext;
|
|
30
|
+
activateContext?: typeof setActiveContext;
|
|
31
|
+
}
|
|
32
|
+
export interface DeviceLoginTarget {
|
|
33
|
+
contextName: string;
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
authUrl: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The browser login, end to end: ask for a device code, wait for the user to
|
|
39
|
+
* approve it, then record who they are and which organization they act as.
|
|
40
|
+
*
|
|
41
|
+
* A refused or expired approval is reported and swallowed (exit code 1, no
|
|
42
|
+
* context written) because it is the user's answer, not a fault. Everything
|
|
43
|
+
* else — an unreachable auth service, a rejected client id — propagates to
|
|
44
|
+
* `runAction`, which renders it once and machine-readably under `--json`.
|
|
45
|
+
*
|
|
46
|
+
* By design this is an interactive surface: it prints a code, opens a browser,
|
|
47
|
+
* and may ask which organization to use. Its own outcomes are therefore prose
|
|
48
|
+
* even under `--json` — a device approval that was denied is a conversation,
|
|
49
|
+
* not a payload. Faults still leave through handleError and are rendered as
|
|
50
|
+
* JSON there.
|
|
51
|
+
*/
|
|
52
|
+
export declare function deviceFlowLogin(target: DeviceLoginTarget, deps?: DeviceLoginDeps): Promise<void>;
|
|
53
|
+
/** Injection point for {@link performLogout}; defaults to the real call. */
|
|
54
|
+
export interface LogoutDeps {
|
|
55
|
+
revoke?: typeof revokeSession;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Sign out: drop the local credentials, then tell the auth service.
|
|
59
|
+
*
|
|
60
|
+
* That order is deliberate. `revokeSession` has no timeout, so an auth host
|
|
61
|
+
* that black-holes the request would hang the command with the credentials
|
|
62
|
+
* still on disk — while the user, having read "Credentials cleared", believes
|
|
63
|
+
* they are signed out. Clearing first makes the local half unconditional and
|
|
64
|
+
* leaves the network call as what it is: best-effort, and the only part that
|
|
65
|
+
* can fail. A host that refuses the connection fails fast either way.
|
|
66
|
+
*
|
|
67
|
+
* The token and URL are copied out first because the store no longer holds
|
|
68
|
+
* them by the time they are used.
|
|
69
|
+
*/
|
|
70
|
+
export declare function performLogout(deps?: LogoutDeps): Promise<void>;
|
|
71
|
+
/** How the caller wants the status rendered. */
|
|
72
|
+
export interface SessionStatusView {
|
|
73
|
+
json: boolean;
|
|
74
|
+
contextName: string;
|
|
75
|
+
multiContext: boolean;
|
|
76
|
+
}
|
|
77
|
+
/** Injection points for {@link sessionStatus}. */
|
|
78
|
+
export interface SessionStatusDeps {
|
|
79
|
+
/** The probe that decides "authenticated" — normally a bound
|
|
80
|
+
* {@link countModels}. Required: it is the whole test. */
|
|
81
|
+
countModels: () => Promise<number>;
|
|
82
|
+
/** Identity lookup; defaults to the real auth-service call. */
|
|
83
|
+
sessionInfo?: typeof getSessionInfo;
|
|
84
|
+
}
|
|
85
|
+
/** `auth status` for a browser-session context. */
|
|
86
|
+
export declare function sessionStatus(config: SessionConfig, view: SessionStatusView, deps: SessionStatusDeps): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* @param deviceDeps forwarded to {@link deviceFlowLogin} — a seam for tests
|
|
89
|
+
* that need to prove the browser flow was NOT entered.
|
|
90
|
+
*/
|
|
91
|
+
export declare function makeAuthCommand(deviceDeps?: DeviceLoginDeps): Command;
|
|
92
|
+
export {};
|
|
3
93
|
//# sourceMappingURL=auth.d.ts.map
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
-
import { store, resolveConfig, isJsonOutput, getActiveContextName, getActiveContext, getContextCount, setContext, setActiveContext, deleteContext, DEFAULT_BASE_URL, } from "../lib/config.js";
|
|
3
|
+
import { store, resolveConfig, isJsonOutput, getActiveContextName, getActiveContext, getContextCount, setContext, setActiveContext, deleteContext, defaultAuthUrlFor, DEFAULT_BASE_URL, } from "../lib/config.js";
|
|
4
4
|
import { getClient, runAction } from "../lib/client.js";
|
|
5
5
|
import { classifyAuthFailure } from "../lib/errors.js";
|
|
6
|
+
import { CLI_CLIENT_ID, getSessionInfo, listOrganizations, pollDeviceToken, requestDeviceCode, revokeSession, setActiveOrganization, } from "../lib/auth-service.js";
|
|
7
|
+
import { DeviceApprovalError, runDevicePolling, selectOrganization, SessionExpiredError, } from "../lib/auth-session.js";
|
|
8
|
+
import { withSpinner } from "../lib/output.js";
|
|
9
|
+
import { maskApiKey } from "../lib/redact.js";
|
|
6
10
|
import { createInterface } from "node:readline/promises";
|
|
7
11
|
// Human-readable, actionable line per failure kind for the non-JSON output.
|
|
8
12
|
const FAILURE_HINTS = {
|
|
@@ -11,62 +15,326 @@ const FAILURE_HINTS = {
|
|
|
11
15
|
UNREACHABLE: "Could not reach the host. Check the base URL and your connection.",
|
|
12
16
|
UNKNOWN: 'Validation failed. Run "2kw auth login" and, if it persists, check the base URL.',
|
|
13
17
|
};
|
|
14
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Group an 8-character device user code as XXXX-XXXX.
|
|
20
|
+
*
|
|
21
|
+
* Purely presentational: the code is read off a terminal and typed into a
|
|
22
|
+
* browser, and a grouped one survives that trip with fewer mistakes. Anything
|
|
23
|
+
* that is not exactly 8 characters (a service configured for another length,
|
|
24
|
+
* or a code that already carries its own separator) is shown untouched.
|
|
25
|
+
*/
|
|
26
|
+
export function formatUserCode(code) {
|
|
27
|
+
return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
|
|
28
|
+
}
|
|
29
|
+
/** Trailing slashes make `${authUrl}/api/auth/...` double up — drop them once, here. */
|
|
30
|
+
function stripTrailingSlash(url) {
|
|
31
|
+
return url.replace(/\/+$/, "");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Open the approval page in the user's browser, and shrug if that is not
|
|
35
|
+
* possible.
|
|
36
|
+
*
|
|
37
|
+
* `open` is imported dynamically so a headless box never pays for it, and every
|
|
38
|
+
* failure is swallowed: over SSH or in a container there is no browser to open,
|
|
39
|
+
* which is precisely the situation the device grant exists for — the URL and
|
|
40
|
+
* code are already on screen.
|
|
41
|
+
*/
|
|
42
|
+
async function tryOpenBrowser(url) {
|
|
43
|
+
try {
|
|
44
|
+
const { default: open } = await import("open");
|
|
45
|
+
await open(url);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// No browser, no display, no default handler. The printed URL still works.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The browser login, end to end: ask for a device code, wait for the user to
|
|
53
|
+
* approve it, then record who they are and which organization they act as.
|
|
54
|
+
*
|
|
55
|
+
* A refused or expired approval is reported and swallowed (exit code 1, no
|
|
56
|
+
* context written) because it is the user's answer, not a fault. Everything
|
|
57
|
+
* else — an unreachable auth service, a rejected client id — propagates to
|
|
58
|
+
* `runAction`, which renders it once and machine-readably under `--json`.
|
|
59
|
+
*
|
|
60
|
+
* By design this is an interactive surface: it prints a code, opens a browser,
|
|
61
|
+
* and may ask which organization to use. Its own outcomes are therefore prose
|
|
62
|
+
* even under `--json` — a device approval that was denied is a conversation,
|
|
63
|
+
* not a payload. Faults still leave through handleError and are rendered as
|
|
64
|
+
* JSON there.
|
|
65
|
+
*/
|
|
66
|
+
export async function deviceFlowLogin(target, deps = {}) {
|
|
67
|
+
const { requestCode = requestDeviceCode, poll = pollDeviceToken, runPolling = runDevicePolling, sessionInfo = getSessionInfo, listOrgs = listOrganizations, chooseOrg = selectOrganization, setActiveOrg = setActiveOrganization, revoke = revokeSession, openBrowser = tryOpenBrowser, saveContext = setContext, activateContext = setActiveContext, } = deps;
|
|
68
|
+
const { contextName, baseUrl, authUrl } = target;
|
|
69
|
+
const device = await requestCode(authUrl, CLI_CLIENT_ID);
|
|
70
|
+
console.log("");
|
|
71
|
+
console.log(`Open ${chalk.cyan(device.verification_uri_complete)}`);
|
|
72
|
+
console.log(`and confirm the code ${chalk.bold(formatUserCode(device.user_code))}`);
|
|
73
|
+
console.log("");
|
|
74
|
+
await openBrowser(device.verification_uri_complete);
|
|
75
|
+
let sessionToken;
|
|
76
|
+
try {
|
|
77
|
+
sessionToken = await withSpinner("Waiting for approval in the browser...", () => runPolling({
|
|
78
|
+
poll: () => poll(authUrl, CLI_CLIENT_ID, device.device_code),
|
|
79
|
+
intervalSec: device.interval,
|
|
80
|
+
expiresInSec: device.expires_in,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
// The user's own answer — say it plainly and stop. Anything else (a refused
|
|
85
|
+
// client id, a network that never came back) is a fault, and belongs to
|
|
86
|
+
// handleError, which is what puts it in front of a --json consumer.
|
|
87
|
+
if (!(err instanceof DeviceApprovalError))
|
|
88
|
+
throw err;
|
|
89
|
+
console.error(chalk.red(err.message));
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const { email } = await sessionInfo(authUrl, sessionToken);
|
|
94
|
+
// Everything from here to the successful write is guarded: until the session
|
|
95
|
+
// token reaches the config store, a failure leaves it live server-side with
|
|
96
|
+
// nothing on this machine able to use it — an account with no organization at
|
|
97
|
+
// all being the case that guarantees it, a refused set-active or a read-only
|
|
98
|
+
// store the ones that merely make it likely. Revoking is best-effort: the
|
|
99
|
+
// failure that got us here is the one worth reporting, not a failed cleanup.
|
|
100
|
+
let org;
|
|
101
|
+
try {
|
|
102
|
+
org = await chooseOrg(await listOrgs(authUrl, sessionToken));
|
|
103
|
+
await setActiveOrg(authUrl, sessionToken, org.id);
|
|
104
|
+
// A whole entry, not a patch: this context may have held an API key, and a
|
|
105
|
+
// leftover one would silently outrank the session on the next command.
|
|
106
|
+
saveContext(contextName, {
|
|
107
|
+
baseUrl,
|
|
108
|
+
authUrl,
|
|
109
|
+
sessionToken,
|
|
110
|
+
organizationId: org.id,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
try {
|
|
115
|
+
await revoke(authUrl, sessionToken);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Nothing to do; the session expires on its own.
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
// Outside the guard on purpose: the session is on disk now, so a failure to
|
|
123
|
+
// mark the context active still leaves a credential the user can reach.
|
|
124
|
+
activateContext(contextName);
|
|
125
|
+
console.log(chalk.green(`Logged in as ${email}`));
|
|
126
|
+
console.log(chalk.dim(` — organization: ${org.name}`));
|
|
127
|
+
console.log(chalk.dim(`Config stored at: ${store.path}`));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The pre-device-flow login: take an API key from the flag or the terminal and
|
|
131
|
+
* store it. Still the path for CI-style credentials and for anyone whose
|
|
132
|
+
* machine cannot run the browser flow.
|
|
133
|
+
*/
|
|
134
|
+
async function manualLogin(opts) {
|
|
135
|
+
let apiKey = opts.apiKey;
|
|
136
|
+
let baseUrl = opts.baseUrl;
|
|
137
|
+
const contextName = getActiveContextName();
|
|
138
|
+
const currentCtx = getActiveContext();
|
|
139
|
+
if (!apiKey || !baseUrl) {
|
|
140
|
+
const rl = createInterface({
|
|
141
|
+
input: process.stdin,
|
|
142
|
+
output: process.stdout,
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
if (!baseUrl) {
|
|
146
|
+
const defaultUrl = currentCtx?.baseUrl ?? DEFAULT_BASE_URL;
|
|
147
|
+
baseUrl = await rl.question(`Base URL [${defaultUrl}]: `);
|
|
148
|
+
if (!baseUrl)
|
|
149
|
+
baseUrl = defaultUrl;
|
|
150
|
+
}
|
|
151
|
+
if (!apiKey) {
|
|
152
|
+
apiKey = await rl.question("API Key (sk_...): ");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
rl.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!apiKey) {
|
|
160
|
+
console.error(chalk.red("API key is required."));
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
setContext(contextName, { apiKey, baseUrl });
|
|
164
|
+
setActiveContext(contextName);
|
|
165
|
+
console.log(chalk.green("Credentials saved successfully."));
|
|
166
|
+
console.log(chalk.dim(`Config stored at: ${store.path}`));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Sign out: drop the local credentials, then tell the auth service.
|
|
170
|
+
*
|
|
171
|
+
* That order is deliberate. `revokeSession` has no timeout, so an auth host
|
|
172
|
+
* that black-holes the request would hang the command with the credentials
|
|
173
|
+
* still on disk — while the user, having read "Credentials cleared", believes
|
|
174
|
+
* they are signed out. Clearing first makes the local half unconditional and
|
|
175
|
+
* leaves the network call as what it is: best-effort, and the only part that
|
|
176
|
+
* can fail. A host that refuses the connection fails fast either way.
|
|
177
|
+
*
|
|
178
|
+
* The token and URL are copied out first because the store no longer holds
|
|
179
|
+
* them by the time they are used.
|
|
180
|
+
*/
|
|
181
|
+
export async function performLogout(deps = {}) {
|
|
182
|
+
const { revoke = revokeSession } = deps;
|
|
183
|
+
const contextName = getActiveContextName();
|
|
184
|
+
const ctx = getActiveContext();
|
|
185
|
+
const sessionToken = ctx?.sessionToken;
|
|
186
|
+
const authUrl = ctx?.authUrl;
|
|
187
|
+
if (getContextCount() <= 1) {
|
|
188
|
+
// Last context — clear it by resetting the store
|
|
189
|
+
store.store = { activeContext: "default", contexts: {} };
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
deleteContext(contextName);
|
|
193
|
+
console.log(chalk.dim(`Removed context "${contextName}". Switched to "${getActiveContextName()}".`));
|
|
194
|
+
}
|
|
195
|
+
console.log(chalk.green("Credentials cleared."));
|
|
196
|
+
// The wording claims only what happened — better-auth answers 200 whether or
|
|
197
|
+
// not the bearer resolved to a session, so "revoked" would be a guess. A
|
|
198
|
+
// session that was already dead is not a failed logout either.
|
|
199
|
+
if (sessionToken && authUrl) {
|
|
200
|
+
try {
|
|
201
|
+
await revoke(authUrl, sessionToken);
|
|
202
|
+
console.log(chalk.dim("Sign-out sent to the auth service."));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
console.log(chalk.yellow("Could not revoke the server-side session (already expired?)."));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** The credential check both status paths run: cheap, and it needs real auth. */
|
|
210
|
+
async function countModels(command) {
|
|
211
|
+
const client = getClient(command);
|
|
212
|
+
const { data } = await client.GET("/v1/models");
|
|
213
|
+
return data?.data?.length ?? 0;
|
|
214
|
+
}
|
|
215
|
+
/** `auth status` for a browser-session context. */
|
|
216
|
+
export async function sessionStatus(config, view, deps) {
|
|
217
|
+
const { json, contextName, multiContext } = view;
|
|
218
|
+
const { countModels: probe, sessionInfo = getSessionInfo } = deps;
|
|
219
|
+
// Best-effort identity: better-auth answers 200 with a null body for a dead
|
|
220
|
+
// session, which auth-service turns into an error — so any failure here means
|
|
221
|
+
// "no user to name", never a reason to stop.
|
|
222
|
+
let email;
|
|
223
|
+
try {
|
|
224
|
+
email = (await sessionInfo(config.authUrl, config.sessionToken)).email;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
email = undefined;
|
|
228
|
+
}
|
|
229
|
+
const identity = {
|
|
230
|
+
...(multiContext ? { context: contextName } : {}),
|
|
231
|
+
baseUrl: config.baseUrl,
|
|
232
|
+
authType: "session",
|
|
233
|
+
...(email ? { email } : {}),
|
|
234
|
+
...(config.organizationId ? { organizationId: config.organizationId } : {}),
|
|
235
|
+
authUrl: config.authUrl,
|
|
236
|
+
};
|
|
237
|
+
const printIdentityLines = () => {
|
|
238
|
+
if (multiContext) {
|
|
239
|
+
console.log(` Context: ${contextName}`);
|
|
240
|
+
}
|
|
241
|
+
console.log(` Base URL: ${config.baseUrl}`);
|
|
242
|
+
console.log(` Auth: browser session (${config.authUrl})`);
|
|
243
|
+
if (email) {
|
|
244
|
+
console.log(` User: ${email}`);
|
|
245
|
+
}
|
|
246
|
+
if (config.organizationId) {
|
|
247
|
+
console.log(` Org: ${config.organizationId}`);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
try {
|
|
251
|
+
const modelCount = await probe();
|
|
252
|
+
if (json) {
|
|
253
|
+
console.log(JSON.stringify({ authenticated: true, ...identity, modelCount }));
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
console.log(chalk.green("Authenticated"));
|
|
257
|
+
printIdentityLines();
|
|
258
|
+
console.log(` Models: ${modelCount} available`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
// A dead session already has its own rendering in handleError — catching it
|
|
263
|
+
// here would print the diagnosis twice, once as a guess.
|
|
264
|
+
if (err instanceof SessionExpiredError)
|
|
265
|
+
throw err;
|
|
266
|
+
// The same classification the human hint is chosen by, reported instead of
|
|
267
|
+
// kept — a --json caller should not have to string-match a hint it cannot
|
|
268
|
+
// even see to learn that the host was unreachable. Same key as the API-key
|
|
269
|
+
// branch below, so one consumer handles both auth types.
|
|
270
|
+
const failureKind = classifyAuthFailure(err);
|
|
271
|
+
process.exitCode = 1;
|
|
272
|
+
if (json) {
|
|
273
|
+
console.log(JSON.stringify({
|
|
274
|
+
authenticated: false,
|
|
275
|
+
...identity,
|
|
276
|
+
failureKind,
|
|
277
|
+
error: "Failed to validate credentials",
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
console.log(chalk.yellow("Credentials configured but validation failed."));
|
|
282
|
+
printIdentityLines();
|
|
283
|
+
// A host that never answered says nothing about the session, so do not
|
|
284
|
+
// send the user off to re-login over what is probably a dropped network.
|
|
285
|
+
const hint = failureKind === "UNREACHABLE"
|
|
286
|
+
? FAILURE_HINTS.UNREACHABLE
|
|
287
|
+
: 'Session may have expired — run "2kw auth login".';
|
|
288
|
+
console.log(chalk.dim(` ${hint}`));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* @param deviceDeps forwarded to {@link deviceFlowLogin} — a seam for tests
|
|
294
|
+
* that need to prove the browser flow was NOT entered.
|
|
295
|
+
*/
|
|
296
|
+
export function makeAuthCommand(deviceDeps = {}) {
|
|
15
297
|
const cmd = new Command("auth").description("Manage authentication");
|
|
16
298
|
cmd
|
|
17
299
|
.command("login")
|
|
18
|
-
.description("
|
|
19
|
-
.option("--api-key <key>", "API key")
|
|
300
|
+
.description("Sign in through the browser (or with an API key)")
|
|
301
|
+
.option("--api-key <key>", "Save an API key instead of signing in through the browser")
|
|
302
|
+
.option("--manual", "Prompt for an API key instead of signing in through the browser")
|
|
20
303
|
.option("--base-url <url>", "Base URL")
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
apiKey = await rl.question("API Key (sk_...): ");
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
finally {
|
|
43
|
-
rl.close();
|
|
304
|
+
.option("--auth-url <url>", "Auth service URL (defaults to the one matching the base URL)")
|
|
305
|
+
.action(async (_opts, command) => {
|
|
306
|
+
// Merged, not own: the root program declares --api-key and --base-url as
|
|
307
|
+
// global options, and commander binds a flag to the first command that
|
|
308
|
+
// declares it while parsing — the root. This subcommand's own opts are
|
|
309
|
+
// therefore structurally EMPTY for exactly the two flags it documents, so
|
|
310
|
+
// `auth login --api-key sk_x` used to fall through to the browser flow.
|
|
311
|
+
//
|
|
312
|
+
// Reading them merged conflates the two spellings, and that is the
|
|
313
|
+
// intended behaviour: at the root, --api-key means "authenticate this one
|
|
314
|
+
// invocation", but combined with `auth login` the user has supplied a key
|
|
315
|
+
// AND asked to sign in, and saving it is the only reading that does
|
|
316
|
+
// anything at all.
|
|
317
|
+
const opts = command.optsWithGlobals();
|
|
318
|
+
await runAction(command, async () => {
|
|
319
|
+
if (opts.apiKey || opts.manual) {
|
|
320
|
+
await manualLogin(opts);
|
|
321
|
+
return;
|
|
44
322
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
323
|
+
const contextName = getActiveContextName();
|
|
324
|
+
const currentCtx = getActiveContext();
|
|
325
|
+
const baseUrl = opts.baseUrl ?? currentCtx?.baseUrl ?? DEFAULT_BASE_URL;
|
|
326
|
+
const authUrl = stripTrailingSlash(opts.authUrl ?? currentCtx?.authUrl ?? defaultAuthUrlFor(baseUrl));
|
|
327
|
+
// Which service is about to be handed the login is not obvious once it
|
|
328
|
+
// is derived from the base URL, so say it before opening a browser.
|
|
329
|
+
console.log(chalk.dim(`Auth service: ${authUrl} (override with --auth-url)`));
|
|
330
|
+
await deviceFlowLogin({ contextName, baseUrl, authUrl }, deviceDeps);
|
|
331
|
+
});
|
|
54
332
|
});
|
|
55
333
|
cmd
|
|
56
334
|
.command("logout")
|
|
57
335
|
.description("Clear stored credentials")
|
|
58
|
-
.action(() => {
|
|
59
|
-
|
|
60
|
-
const count = getContextCount();
|
|
61
|
-
if (count <= 1) {
|
|
62
|
-
// Last context — clear it by resetting the store
|
|
63
|
-
store.store = { activeContext: "default", contexts: {} };
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
deleteContext(contextName);
|
|
67
|
-
console.log(chalk.dim(`Removed context "${contextName}". Switched to "${getActiveContextName()}".`));
|
|
68
|
-
}
|
|
69
|
-
console.log(chalk.green("Credentials cleared."));
|
|
336
|
+
.action(async (_opts, command) => {
|
|
337
|
+
await runAction(command, () => performLogout());
|
|
70
338
|
});
|
|
71
339
|
cmd
|
|
72
340
|
.command("status")
|
|
@@ -90,15 +358,20 @@ export function makeAuthCommand() {
|
|
|
90
358
|
}
|
|
91
359
|
return;
|
|
92
360
|
}
|
|
93
|
-
const { apiKey, baseUrl } = config;
|
|
94
|
-
const keyPreview = apiKey.slice(0, 7) + "..." + apiKey.slice(-4);
|
|
95
361
|
const multiContext = getContextCount() > 1;
|
|
96
362
|
const contextName = getActiveContextName();
|
|
363
|
+
if (config.kind === "session") {
|
|
364
|
+
await sessionStatus(config, { json, contextName, multiContext }, { countModels: () => countModels(command) });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const { apiKey, baseUrl } = config;
|
|
368
|
+
// The shared mask, not a local slice: on a key of 11 characters or
|
|
369
|
+
// fewer the two slices overlap and print it in full — every one of
|
|
370
|
+
// these four uses, --json included.
|
|
371
|
+
const keyPreview = maskApiKey(apiKey);
|
|
97
372
|
// Try to validate by listing models (lightweight call)
|
|
98
373
|
try {
|
|
99
|
-
const
|
|
100
|
-
const { data } = await client.GET("/v1/models");
|
|
101
|
-
const modelCount = data?.data?.length ?? 0;
|
|
374
|
+
const modelCount = await countModels(command);
|
|
102
375
|
if (json) {
|
|
103
376
|
console.log(JSON.stringify({
|
|
104
377
|
authenticated: true,
|
|
@@ -1,3 +1,19 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
declare const ALLOWED_KEYS: readonly ["apiKey", "baseUrl"];
|
|
3
|
+
type ConfigKey = (typeof ALLOWED_KEYS)[number];
|
|
4
|
+
/**
|
|
5
|
+
* Write one config key onto the active context.
|
|
6
|
+
*
|
|
7
|
+
* Setting `apiKey` also tears down any browser session on that context.
|
|
8
|
+
* Resolution checks `sessionToken` before `apiKey` (see
|
|
9
|
+
* resolveConfigFromSources), so a key stored alongside a live session would be
|
|
10
|
+
* shadowed forever — the user would have set a credential that never gets used
|
|
11
|
+
* and never gets mentioned. Clearing the session makes the write mean what it
|
|
12
|
+
* says. Reported back so the caller can say it out loud.
|
|
13
|
+
*/
|
|
14
|
+
export declare function applyConfigSet(key: ConfigKey, value: string): {
|
|
15
|
+
clearedSession: boolean;
|
|
16
|
+
};
|
|
2
17
|
export declare function makeConfigCommand(): Command;
|
|
18
|
+
export {};
|
|
3
19
|
//# sourceMappingURL=config.d.ts.map
|