@2kw/ai 5.1.0 → 5.2.0-dev.10

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.
@@ -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
- export function makeAuthCommand() {
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("Configure API credentials")
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
- .action(async (opts) => {
14
- let apiKey = opts.apiKey;
15
- let baseUrl = opts.baseUrl;
16
- const contextName = getActiveContextName();
17
- const currentCtx = getActiveContext();
18
- if (!apiKey || !baseUrl) {
19
- const rl = createInterface({
20
- input: process.stdin,
21
- output: process.stdout,
22
- });
23
- try {
24
- if (!baseUrl) {
25
- const defaultUrl = currentCtx?.baseUrl ?? DEFAULT_BASE_URL;
26
- baseUrl = await rl.question(`Base URL [${defaultUrl}]: `);
27
- if (!baseUrl)
28
- baseUrl = defaultUrl;
29
- }
30
- if (!apiKey) {
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
- if (!apiKey) {
39
- console.error(chalk.red("API key is required."));
40
- process.exit(1);
41
- }
42
- setContext(contextName, { apiKey, baseUrl });
43
- setActiveContext(contextName);
44
- console.log(chalk.green("Credentials saved successfully."));
45
- console.log(chalk.dim(`Config stored at: ${store.path}`));
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
- const contextName = getActiveContextName();
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 client = getClient(command);
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
@@ -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
- // Update the active context
29
- const contextName = getActiveContextName();
30
- const ctx = getActiveContext() ?? {
31
- apiKey: "",
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({ key: validKey, value }));
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} = ${validKey === "apiKey" ? "****" : value}`));
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 value = ctx?.[validKey];
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(validKey === "apiKey" ? "****" : value);
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 = !value
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 "backbone context list" to see all)`));
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