@2kw/ai 5.2.0-dev.6 → 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 +79 -33
- package/dist/commands/auth.d.ts +91 -1
- package/dist/commands/auth.js +344 -54
- 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 +18 -0
- package/dist/lib/errors.js +105 -3
- package/dist/lib/redact.d.ts +35 -0
- package/dist/lib/redact.js +54 -0
- package/dist/lib/update-notifier.js +1 -1
- package/package.json +21 -2
package/dist/commands/auth.js
CHANGED
|
@@ -1,64 +1,340 @@
|
|
|
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
|
+
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";
|
|
5
10
|
import { createInterface } from "node:readline/promises";
|
|
6
|
-
|
|
11
|
+
// Human-readable, actionable line per failure kind for the non-JSON output.
|
|
12
|
+
const FAILURE_HINTS = {
|
|
13
|
+
UNAUTHORIZED: 'Key rejected. Run "2kw auth login" to re-authenticate.',
|
|
14
|
+
FORBIDDEN: "Key is valid but lacks access. Check the key's organization in the 2kw UI.",
|
|
15
|
+
UNREACHABLE: "Could not reach the host. Check the base URL and your connection.",
|
|
16
|
+
UNKNOWN: 'Validation failed. Run "2kw auth login" and, if it persists, check the base URL.',
|
|
17
|
+
};
|
|
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 = {}) {
|
|
7
297
|
const cmd = new Command("auth").description("Manage authentication");
|
|
8
298
|
cmd
|
|
9
299
|
.command("login")
|
|
10
|
-
.description("
|
|
11
|
-
.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")
|
|
12
303
|
.option("--base-url <url>", "Base URL")
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
apiKey = await rl.question("API Key (sk_...): ");
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
finally {
|
|
35
|
-
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;
|
|
36
322
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
});
|
|
46
332
|
});
|
|
47
333
|
cmd
|
|
48
334
|
.command("logout")
|
|
49
335
|
.description("Clear stored credentials")
|
|
50
|
-
.action(() => {
|
|
51
|
-
|
|
52
|
-
const count = getContextCount();
|
|
53
|
-
if (count <= 1) {
|
|
54
|
-
// Last context — clear it by resetting the store
|
|
55
|
-
store.store = { activeContext: "default", contexts: {} };
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
deleteContext(contextName);
|
|
59
|
-
console.log(chalk.dim(`Removed context "${contextName}". Switched to "${getActiveContextName()}".`));
|
|
60
|
-
}
|
|
61
|
-
console.log(chalk.green("Credentials cleared."));
|
|
336
|
+
.action(async (_opts, command) => {
|
|
337
|
+
await runAction(command, () => performLogout());
|
|
62
338
|
});
|
|
63
339
|
cmd
|
|
64
340
|
.command("status")
|
|
@@ -71,6 +347,8 @@ export function makeAuthCommand() {
|
|
|
71
347
|
config = resolveConfig(command);
|
|
72
348
|
}
|
|
73
349
|
catch {
|
|
350
|
+
// No credentials configured at all — distinct from a rejected key.
|
|
351
|
+
process.exitCode = 1;
|
|
74
352
|
if (json) {
|
|
75
353
|
console.log(JSON.stringify({ authenticated: false }));
|
|
76
354
|
}
|
|
@@ -80,15 +358,20 @@ export function makeAuthCommand() {
|
|
|
80
358
|
}
|
|
81
359
|
return;
|
|
82
360
|
}
|
|
83
|
-
const { apiKey, baseUrl } = config;
|
|
84
|
-
const keyPreview = apiKey.slice(0, 7) + "..." + apiKey.slice(-4);
|
|
85
361
|
const multiContext = getContextCount() > 1;
|
|
86
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);
|
|
87
372
|
// Try to validate by listing models (lightweight call)
|
|
88
373
|
try {
|
|
89
|
-
const
|
|
90
|
-
const { data } = await client.GET("/v1/models");
|
|
91
|
-
const modelCount = data?.data?.length ?? 0;
|
|
374
|
+
const modelCount = await countModels(command);
|
|
92
375
|
if (json) {
|
|
93
376
|
console.log(JSON.stringify({
|
|
94
377
|
authenticated: true,
|
|
@@ -108,13 +391,19 @@ export function makeAuthCommand() {
|
|
|
108
391
|
console.log(` Models: ${modelCount} available`);
|
|
109
392
|
}
|
|
110
393
|
}
|
|
111
|
-
catch {
|
|
394
|
+
catch (err) {
|
|
395
|
+
// Credentials are present but the check failed. Surface WHY, so
|
|
396
|
+
// callers stop guessing among a bad key, missing permissions, and an
|
|
397
|
+
// unreachable host.
|
|
398
|
+
const failureKind = classifyAuthFailure(err);
|
|
399
|
+
process.exitCode = 1;
|
|
112
400
|
if (json) {
|
|
113
401
|
console.log(JSON.stringify({
|
|
114
402
|
authenticated: false,
|
|
115
403
|
...(multiContext ? { context: contextName } : {}),
|
|
116
404
|
baseUrl,
|
|
117
405
|
apiKeyPreview: keyPreview,
|
|
406
|
+
failureKind,
|
|
118
407
|
error: "Failed to validate credentials",
|
|
119
408
|
}));
|
|
120
409
|
}
|
|
@@ -125,6 +414,7 @@ export function makeAuthCommand() {
|
|
|
125
414
|
}
|
|
126
415
|
console.log(` Base URL: ${baseUrl}`);
|
|
127
416
|
console.log(` API Key: ${keyPreview}`);
|
|
417
|
+
console.log(chalk.yellow(` ${FAILURE_HINTS[failureKind]}`));
|
|
128
418
|
}
|
|
129
419
|
}
|
|
130
420
|
});
|
|
@@ -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
|
package/dist/commands/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { store, isJsonOutput, getActiveContext, getActiveContextName, getContextCount, setContext, DEFAULT_BASE_URL, } from "../lib/config.js";
|
|
4
|
+
import { maskApiKey } from "../lib/redact.js";
|
|
4
5
|
const ALLOWED_KEYS = ["apiKey", "baseUrl"];
|
|
5
6
|
const KEY_ALIASES = {
|
|
6
7
|
"api-key": "apiKey",
|
|
@@ -16,6 +17,36 @@ function validateKey(key) {
|
|
|
16
17
|
}
|
|
17
18
|
return normalized;
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Write one config key onto the active context.
|
|
22
|
+
*
|
|
23
|
+
* Setting `apiKey` also tears down any browser session on that context.
|
|
24
|
+
* Resolution checks `sessionToken` before `apiKey` (see
|
|
25
|
+
* resolveConfigFromSources), so a key stored alongside a live session would be
|
|
26
|
+
* shadowed forever — the user would have set a credential that never gets used
|
|
27
|
+
* and never gets mentioned. Clearing the session makes the write mean what it
|
|
28
|
+
* says. Reported back so the caller can say it out loud.
|
|
29
|
+
*/
|
|
30
|
+
export function applyConfigSet(key, value) {
|
|
31
|
+
const contextName = getActiveContextName();
|
|
32
|
+
const entry = { ...(getActiveContext() ?? { apiKey: "", baseUrl: DEFAULT_BASE_URL }) };
|
|
33
|
+
if (key === "apiKey") {
|
|
34
|
+
const clearedSession = Boolean(entry.sessionToken);
|
|
35
|
+
entry.apiKey = value;
|
|
36
|
+
// All five together: a leftover authUrl or cached JWT describes a session
|
|
37
|
+
// that no longer exists here.
|
|
38
|
+
entry.sessionToken = undefined;
|
|
39
|
+
entry.authUrl = undefined;
|
|
40
|
+
entry.organizationId = undefined;
|
|
41
|
+
entry.cachedJwt = undefined;
|
|
42
|
+
entry.cachedJwtExp = undefined;
|
|
43
|
+
setContext(contextName, entry);
|
|
44
|
+
return { clearedSession };
|
|
45
|
+
}
|
|
46
|
+
entry[key] = value;
|
|
47
|
+
setContext(contextName, entry);
|
|
48
|
+
return { clearedSession: false };
|
|
49
|
+
}
|
|
19
50
|
export function makeConfigCommand() {
|
|
20
51
|
const cmd = new Command("config").description("Manage CLI configuration");
|
|
21
52
|
cmd
|
|
@@ -25,19 +56,22 @@ export function makeConfigCommand() {
|
|
|
25
56
|
.argument("<value>", "Config value")
|
|
26
57
|
.action((key, value, _opts, command) => {
|
|
27
58
|
const validKey = validateKey(key);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
baseUrl: DEFAULT_BASE_URL,
|
|
33
|
-
};
|
|
34
|
-
ctx[validKey] = value;
|
|
35
|
-
setContext(contextName, ctx);
|
|
59
|
+
const { clearedSession } = applyConfigSet(validKey, value);
|
|
60
|
+
// Never echo the key back, in either mode: `config set apiKey` is exactly
|
|
61
|
+
// the command whose output ends up in a CI log.
|
|
62
|
+
const shown = validKey === "apiKey" ? maskApiKey(value) : value;
|
|
36
63
|
if (isJsonOutput(command)) {
|
|
37
|
-
console.log(JSON.stringify({
|
|
64
|
+
console.log(JSON.stringify({
|
|
65
|
+
key: validKey,
|
|
66
|
+
value: shown,
|
|
67
|
+
...(clearedSession ? { clearedSession: true } : {}),
|
|
68
|
+
}));
|
|
38
69
|
}
|
|
39
70
|
else {
|
|
40
|
-
console.log(chalk.green(`Set ${validKey} = ${
|
|
71
|
+
console.log(chalk.green(`Set ${validKey} = ${shown}`));
|
|
72
|
+
if (clearedSession) {
|
|
73
|
+
console.log(chalk.dim("Cleared the browser session on this context."));
|
|
74
|
+
}
|
|
41
75
|
}
|
|
42
76
|
});
|
|
43
77
|
cmd
|
|
@@ -47,12 +81,13 @@ export function makeConfigCommand() {
|
|
|
47
81
|
.action((key, _opts, command) => {
|
|
48
82
|
const validKey = validateKey(key);
|
|
49
83
|
const ctx = getActiveContext();
|
|
50
|
-
const
|
|
84
|
+
const raw = ctx?.[validKey];
|
|
85
|
+
const value = raw && validKey === "apiKey" ? maskApiKey(raw) : raw;
|
|
51
86
|
if (isJsonOutput(command)) {
|
|
52
87
|
console.log(JSON.stringify({ key: validKey, value: value ?? null }));
|
|
53
88
|
}
|
|
54
89
|
else if (value) {
|
|
55
|
-
console.log(
|
|
90
|
+
console.log(value);
|
|
56
91
|
}
|
|
57
92
|
else {
|
|
58
93
|
console.log(chalk.dim("(not set)"));
|
|
@@ -63,8 +98,10 @@ export function makeConfigCommand() {
|
|
|
63
98
|
.description("Show all configuration values")
|
|
64
99
|
.action((_opts, command) => {
|
|
65
100
|
const ctx = getActiveContext();
|
|
101
|
+
// Masked at the source, so neither branch below can grow a path that
|
|
102
|
+
// prints the raw key — and `--json` is the branch that ends up in a log.
|
|
66
103
|
const values = {
|
|
67
|
-
apiKey: ctx?.apiKey,
|
|
104
|
+
apiKey: ctx?.apiKey ? maskApiKey(ctx.apiKey) : undefined,
|
|
68
105
|
baseUrl: ctx?.baseUrl,
|
|
69
106
|
};
|
|
70
107
|
if (isJsonOutput(command)) {
|
|
@@ -72,15 +109,11 @@ export function makeConfigCommand() {
|
|
|
72
109
|
}
|
|
73
110
|
else {
|
|
74
111
|
for (const [key, value] of Object.entries(values)) {
|
|
75
|
-
const display =
|
|
76
|
-
? chalk.dim("(not set)")
|
|
77
|
-
: key === "apiKey"
|
|
78
|
-
? value.slice(0, 7) + "..." + value.slice(-4)
|
|
79
|
-
: value;
|
|
112
|
+
const display = value ?? chalk.dim("(not set)");
|
|
80
113
|
console.log(`${chalk.cyan(key)}: ${display}`);
|
|
81
114
|
}
|
|
82
115
|
if (getContextCount() > 1) {
|
|
83
|
-
console.log(chalk.dim(`\nContext: ${getActiveContextName()} (use "
|
|
116
|
+
console.log(chalk.dim(`\nContext: ${getActiveContextName()} (use "2kw context list" to see all)`));
|
|
84
117
|
}
|
|
85
118
|
console.log(chalk.dim(`\nConfig file: ${store.path}`));
|
|
86
119
|
}
|
|
@@ -1,3 +1,28 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { updateContext } from "../lib/config.js";
|
|
3
|
+
import { listOrganizations, setActiveOrganization } from "../lib/auth-service.js";
|
|
4
|
+
import { selectOrganization, type Asker } from "../lib/auth-session.js";
|
|
5
|
+
/** Injection points for {@link performSetOrg}; all default to the real thing. */
|
|
6
|
+
export interface SetOrgDeps {
|
|
7
|
+
listOrgs?: typeof listOrganizations;
|
|
8
|
+
chooseOrg?: typeof selectOrganization;
|
|
9
|
+
setActiveOrg?: typeof setActiveOrganization;
|
|
10
|
+
/** Store writer; defaults to {@link updateContext}. */
|
|
11
|
+
persist?: typeof updateContext;
|
|
12
|
+
/** Question function for the picker; defaults to reading a line from stdin. */
|
|
13
|
+
ask?: Asker;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Point the active session context at a different organization.
|
|
17
|
+
*
|
|
18
|
+
* The switch is server-side state (the session's active organization), so the
|
|
19
|
+
* local write that follows is not a cache but the record of what the server was
|
|
20
|
+
* told. A cached JWT minted for the previous organization would still be
|
|
21
|
+
* accepted by the API, silently answering as the old org, so it is dropped in
|
|
22
|
+
* the same write.
|
|
23
|
+
*
|
|
24
|
+
* @param orgArg an organization id or slug; omitted means "prompt".
|
|
25
|
+
*/
|
|
26
|
+
export declare function performSetOrg(orgArg: string | undefined, deps?: SetOrgDeps): Promise<void>;
|
|
2
27
|
export declare function makeContextCommand(): Command;
|
|
3
28
|
//# sourceMappingURL=context.d.ts.map
|