@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.
- 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 +115 -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 +28 -4
package/dist/lib/config.d.ts
CHANGED
|
@@ -2,16 +2,41 @@ import Conf from "conf";
|
|
|
2
2
|
import type { Command } from "commander";
|
|
3
3
|
export interface ContextEntry {
|
|
4
4
|
baseUrl: string;
|
|
5
|
-
|
|
5
|
+
/** Present on API-key contexts (CI/scripts and manual logins). */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
/** Present on session contexts created by the browser device-flow login. */
|
|
8
|
+
sessionToken?: string;
|
|
9
|
+
/** Auth service base URL for this context (e.g. https://auth.2kw.ai). */
|
|
10
|
+
authUrl?: string;
|
|
11
|
+
/** Active organization chosen at login / via set-org. */
|
|
12
|
+
organizationId?: string;
|
|
13
|
+
/** Short-lived org JWT cache — avoids one auth round-trip between rapid commands. */
|
|
14
|
+
cachedJwt?: string;
|
|
15
|
+
/** Epoch millis when cachedJwt expires. */
|
|
16
|
+
cachedJwtExp?: number;
|
|
6
17
|
}
|
|
7
18
|
export interface BackboneConfigStore {
|
|
8
19
|
activeContext: string;
|
|
9
20
|
contexts: Record<string, ContextEntry>;
|
|
10
21
|
}
|
|
11
|
-
|
|
22
|
+
/**
|
|
23
|
+
* What a command actually authenticates with. Either a raw API key, or a
|
|
24
|
+
* browser session that a later step exchanges for a short-lived org JWT.
|
|
25
|
+
*/
|
|
26
|
+
export type ResolvedConfig = {
|
|
27
|
+
kind: "apiKey";
|
|
12
28
|
apiKey: string;
|
|
13
29
|
baseUrl: string;
|
|
14
|
-
}
|
|
30
|
+
} | {
|
|
31
|
+
kind: "session";
|
|
32
|
+
contextName: string;
|
|
33
|
+
sessionToken: string;
|
|
34
|
+
authUrl: string;
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
organizationId?: string;
|
|
37
|
+
cachedJwt?: string;
|
|
38
|
+
cachedJwtExp?: number;
|
|
39
|
+
};
|
|
15
40
|
/**
|
|
16
41
|
* Fallback base URL when the user has no context, env var, or local config.
|
|
17
42
|
* Override at runtime by setting AI_2KW_DEFAULT_BASE_URL (or the legacy
|
|
@@ -19,6 +44,12 @@ export interface BackboneConfig {
|
|
|
19
44
|
* deployment.
|
|
20
45
|
*/
|
|
21
46
|
export declare const DEFAULT_BASE_URL: string;
|
|
47
|
+
/**
|
|
48
|
+
* Fallback auth-service URL used when the API host is not one we recognise.
|
|
49
|
+
* Override at runtime with AI_2KW_DEFAULT_AUTH_URL.
|
|
50
|
+
*/
|
|
51
|
+
export declare const DEFAULT_AUTH_URL: string;
|
|
52
|
+
export declare function defaultAuthUrlFor(baseUrl: string): string;
|
|
22
53
|
declare const store: Conf<BackboneConfigStore>;
|
|
23
54
|
export { store };
|
|
24
55
|
export declare function validateContextName(name: string): void;
|
|
@@ -27,18 +58,46 @@ export declare function getActiveContext(): ContextEntry | undefined;
|
|
|
27
58
|
export declare function getAllContexts(): Record<string, ContextEntry>;
|
|
28
59
|
export declare function getContextCount(): number;
|
|
29
60
|
export declare function setContext(name: string, entry: ContextEntry): void;
|
|
61
|
+
/**
|
|
62
|
+
* Merge a partial update into an existing context, leaving unmentioned fields
|
|
63
|
+
* intact.
|
|
64
|
+
*
|
|
65
|
+
* Note the spread semantics: a key present in `patch` with the value
|
|
66
|
+
* `undefined` CLEARS that field rather than being ignored. That is deliberate
|
|
67
|
+
* and relied upon — e.g. `updateContext(name, { cachedJwt: undefined,
|
|
68
|
+
* cachedJwtExp: undefined })` drops a stale cached JWT.
|
|
69
|
+
*/
|
|
70
|
+
export declare function updateContext(name: string, patch: Partial<ContextEntry>): void;
|
|
30
71
|
export declare function deleteContext(name: string): void;
|
|
31
72
|
export declare function renameContext(oldName: string, newName: string): void;
|
|
32
73
|
export declare function setActiveContext(name: string): void;
|
|
74
|
+
/**
|
|
75
|
+
* Every input resolution considers, made explicit so the precedence rules can
|
|
76
|
+
* be exercised without touching the store, the environment, or the cwd.
|
|
77
|
+
*
|
|
78
|
+
* Flags, env vars, and the local file are API-key-only by design: session
|
|
79
|
+
* credentials are issued by the browser login and only ever live in the store.
|
|
80
|
+
*/
|
|
81
|
+
export interface ConfigSources {
|
|
82
|
+
flagApiKey?: string;
|
|
83
|
+
flagBaseUrl?: string;
|
|
84
|
+
envApiKey?: string;
|
|
85
|
+
envBaseUrl?: string;
|
|
86
|
+
localApiKey?: string;
|
|
87
|
+
localBaseUrl?: string;
|
|
88
|
+
activeContextName: string;
|
|
89
|
+
activeContext?: ContextEntry;
|
|
90
|
+
}
|
|
91
|
+
export declare function resolveConfigFromSources(s: ConfigSources): ResolvedConfig;
|
|
33
92
|
/**
|
|
34
93
|
* Resolve configuration with priority:
|
|
35
94
|
* 1. CLI flags (--api-key, --base-url)
|
|
36
95
|
* 2. Environment variables (AI_2KW_API_KEY then legacy BACKBONE_API_KEY,
|
|
37
96
|
* same for AI_2KW_BASE_URL / BACKBONE_BASE_URL)
|
|
38
97
|
* 3. Local .2kw file (or legacy .backbone)
|
|
39
|
-
* 4. Active context from config store
|
|
98
|
+
* 4. Active context from config store (API key or browser session)
|
|
40
99
|
*/
|
|
41
|
-
export declare function resolveConfig(command: Command):
|
|
100
|
+
export declare function resolveConfig(command: Command): ResolvedConfig;
|
|
42
101
|
/** Check if --json flag is set on root command */
|
|
43
102
|
export declare function isJsonOutput(command: Command): boolean;
|
|
44
103
|
//# sourceMappingURL=config.d.ts.map
|
package/dist/lib/config.js
CHANGED
|
@@ -10,6 +10,32 @@ import { resolve } from "node:path";
|
|
|
10
10
|
export const DEFAULT_BASE_URL = process.env.AI_2KW_DEFAULT_BASE_URL ??
|
|
11
11
|
process.env.BACKBONE_DEFAULT_BASE_URL ??
|
|
12
12
|
"https://backbone.manfred-kunze.dev/api";
|
|
13
|
+
/**
|
|
14
|
+
* Fallback auth-service URL used when the API host is not one we recognise.
|
|
15
|
+
* Override at runtime with AI_2KW_DEFAULT_AUTH_URL.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_AUTH_URL = process.env.AI_2KW_DEFAULT_AUTH_URL ?? "https://auth.2kw.ai";
|
|
18
|
+
/** Known API-host → auth-service mappings for interactive login defaults. */
|
|
19
|
+
const AUTH_URL_BY_API_HOST = {
|
|
20
|
+
"api.2kw.ai": "https://auth.2kw.ai",
|
|
21
|
+
"api-dev.2kw.ai": "https://auth-dev.2kw.ai",
|
|
22
|
+
"backbone.manfred-kunze.dev": "https://auth.2kw.ai",
|
|
23
|
+
"localhost:8080": "http://localhost:3001",
|
|
24
|
+
"127.0.0.1:8080": "http://localhost:3001",
|
|
25
|
+
};
|
|
26
|
+
export function defaultAuthUrlFor(baseUrl) {
|
|
27
|
+
try {
|
|
28
|
+
const host = new URL(baseUrl).host;
|
|
29
|
+
// hasOwn, not a bare index: a host like "constructor" or "toString" would
|
|
30
|
+
// otherwise resolve through Object.prototype and yield a bogus auth URL.
|
|
31
|
+
return Object.hasOwn(AUTH_URL_BY_API_HOST, host)
|
|
32
|
+
? AUTH_URL_BY_API_HOST[host]
|
|
33
|
+
: DEFAULT_AUTH_URL;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return DEFAULT_AUTH_URL;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
13
39
|
// One-shot flag so we only print the deprecation warning once per process,
|
|
14
40
|
// even if resolveConfig is called multiple times across commands.
|
|
15
41
|
let legacyEnvWarned = false;
|
|
@@ -22,13 +48,30 @@ function warnLegacyEnv(legacyName, canonicalName) {
|
|
|
22
48
|
`Switch to ${canonicalName} — the BACKBONE_* names will be removed ` +
|
|
23
49
|
`in a future release.\n`);
|
|
24
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Optional override for where the config store lives. Set AI_2KW_CONFIG_DIR to
|
|
53
|
+
* relocate the store — e.g. to isolate an environment, or so the test suite
|
|
54
|
+
* never touches the real user profile. When set, it applies to both the active
|
|
55
|
+
* store and the legacy store the migration reads, so a redirected run stays
|
|
56
|
+
* fully self-contained.
|
|
57
|
+
*/
|
|
58
|
+
const CONFIG_DIR_OVERRIDE = process.env.AI_2KW_CONFIG_DIR;
|
|
59
|
+
/**
|
|
60
|
+
* Owner-only, like an ssh private key: this file holds live credentials — an
|
|
61
|
+
* API key, or a session token and the org JWT minted from it. conf defaults to
|
|
62
|
+
* 0o666, which on any shared POSIX box makes every account on it a reader.
|
|
63
|
+
* Windows largely ignores mode bits, where this is simply inert.
|
|
64
|
+
*/
|
|
65
|
+
const CONFIG_FILE_MODE = 0o600;
|
|
25
66
|
const store = new Conf({
|
|
26
67
|
projectName: "2kw",
|
|
27
68
|
projectSuffix: "",
|
|
69
|
+
configFileMode: CONFIG_FILE_MODE,
|
|
28
70
|
defaults: {
|
|
29
71
|
activeContext: "default",
|
|
30
72
|
contexts: {},
|
|
31
73
|
},
|
|
74
|
+
...(CONFIG_DIR_OVERRIDE ? { cwd: CONFIG_DIR_OVERRIDE } : {}),
|
|
32
75
|
});
|
|
33
76
|
// One-shot migration: copy any legacy ~/.config/backbone/ store into the new
|
|
34
77
|
// ~/.config/2kw/ store on first run. Triggered only if the new store has no
|
|
@@ -47,7 +90,11 @@ const store = new Conf({
|
|
|
47
90
|
const legacy = new Conf({
|
|
48
91
|
projectName: "backbone",
|
|
49
92
|
projectSuffix: "",
|
|
93
|
+
// Same credentials, same restriction — this instance only computes a path
|
|
94
|
+
// today, but it must not be the one that widens a file if it ever writes.
|
|
95
|
+
configFileMode: CONFIG_FILE_MODE,
|
|
50
96
|
defaults: { activeContext: "default", contexts: {} },
|
|
97
|
+
...(CONFIG_DIR_OVERRIDE ? { cwd: CONFIG_DIR_OVERRIDE } : {}),
|
|
51
98
|
});
|
|
52
99
|
if (!existsSync(legacy.path))
|
|
53
100
|
return;
|
|
@@ -107,6 +154,24 @@ export function setContext(name, entry) {
|
|
|
107
154
|
contexts[name] = entry;
|
|
108
155
|
store.set("contexts", contexts);
|
|
109
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Merge a partial update into an existing context, leaving unmentioned fields
|
|
159
|
+
* intact.
|
|
160
|
+
*
|
|
161
|
+
* Note the spread semantics: a key present in `patch` with the value
|
|
162
|
+
* `undefined` CLEARS that field rather than being ignored. That is deliberate
|
|
163
|
+
* and relied upon — e.g. `updateContext(name, { cachedJwt: undefined,
|
|
164
|
+
* cachedJwtExp: undefined })` drops a stale cached JWT.
|
|
165
|
+
*/
|
|
166
|
+
export function updateContext(name, patch) {
|
|
167
|
+
const contexts = getAllContexts();
|
|
168
|
+
const existing = contexts[name];
|
|
169
|
+
if (!existing) {
|
|
170
|
+
throw new Error(`Context "${name}" does not exist.`);
|
|
171
|
+
}
|
|
172
|
+
contexts[name] = { ...existing, ...patch };
|
|
173
|
+
store.set("contexts", contexts);
|
|
174
|
+
}
|
|
110
175
|
export function deleteContext(name) {
|
|
111
176
|
const contexts = getAllContexts();
|
|
112
177
|
if (!contexts[name]) {
|
|
@@ -176,32 +241,67 @@ function readEnv(canonical, legacy) {
|
|
|
176
241
|
}
|
|
177
242
|
return undefined;
|
|
178
243
|
}
|
|
244
|
+
const NO_AUTH_ERROR = 'No credentials configured. Run "2kw auth login" (browser sign-in), ' +
|
|
245
|
+
'"2kw auth login --api-key <key>", or set AI_2KW_API_KEY.';
|
|
246
|
+
export function resolveConfigFromSources(s) {
|
|
247
|
+
const baseUrl = s.flagBaseUrl ??
|
|
248
|
+
s.envBaseUrl ??
|
|
249
|
+
s.localBaseUrl ??
|
|
250
|
+
s.activeContext?.baseUrl ??
|
|
251
|
+
DEFAULT_BASE_URL;
|
|
252
|
+
// Explicit API keys (flag > env > local file) always win — they are the
|
|
253
|
+
// CI/script escape hatch and must never be shadowed by a stored session.
|
|
254
|
+
const explicitApiKey = s.flagApiKey ?? s.envApiKey ?? s.localApiKey;
|
|
255
|
+
if (explicitApiKey) {
|
|
256
|
+
return { kind: "apiKey", apiKey: explicitApiKey, baseUrl };
|
|
257
|
+
}
|
|
258
|
+
const ctx = s.activeContext;
|
|
259
|
+
if (ctx?.sessionToken) {
|
|
260
|
+
// A session without an auth URL cannot be exchanged for a JWT — treat it
|
|
261
|
+
// as unusable rather than failing later with an opaque request error.
|
|
262
|
+
if (!ctx.authUrl)
|
|
263
|
+
throw new Error(NO_AUTH_ERROR);
|
|
264
|
+
return {
|
|
265
|
+
kind: "session",
|
|
266
|
+
contextName: s.activeContextName,
|
|
267
|
+
sessionToken: ctx.sessionToken,
|
|
268
|
+
authUrl: ctx.authUrl,
|
|
269
|
+
baseUrl,
|
|
270
|
+
organizationId: ctx.organizationId,
|
|
271
|
+
cachedJwt: ctx.cachedJwt,
|
|
272
|
+
cachedJwtExp: ctx.cachedJwtExp,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (ctx?.apiKey) {
|
|
276
|
+
return { kind: "apiKey", apiKey: ctx.apiKey, baseUrl };
|
|
277
|
+
}
|
|
278
|
+
throw new Error(NO_AUTH_ERROR);
|
|
279
|
+
}
|
|
179
280
|
/**
|
|
180
281
|
* Resolve configuration with priority:
|
|
181
282
|
* 1. CLI flags (--api-key, --base-url)
|
|
182
283
|
* 2. Environment variables (AI_2KW_API_KEY then legacy BACKBONE_API_KEY,
|
|
183
284
|
* same for AI_2KW_BASE_URL / BACKBONE_BASE_URL)
|
|
184
285
|
* 3. Local .2kw file (or legacy .backbone)
|
|
185
|
-
* 4. Active context from config store
|
|
286
|
+
* 4. Active context from config store (API key or browser session)
|
|
186
287
|
*/
|
|
187
288
|
export function resolveConfig(command) {
|
|
188
289
|
const root = getRootCommand(command);
|
|
189
290
|
const opts = root.opts();
|
|
190
291
|
const local = readLocalFile();
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
readEnv("
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
return { apiKey, baseUrl };
|
|
292
|
+
// readEnv now runs even when a flag overrides it, so the BACKBONE_* legacy
|
|
293
|
+
// deprecation warning can fire in cases the old short-circuit chain silenced.
|
|
294
|
+
// Accepted: the variable IS set and IS deprecated, flag or no flag.
|
|
295
|
+
return resolveConfigFromSources({
|
|
296
|
+
flagApiKey: opts.apiKey,
|
|
297
|
+
flagBaseUrl: opts.baseUrl,
|
|
298
|
+
envApiKey: readEnv("AI_2KW_API_KEY", "BACKBONE_API_KEY"),
|
|
299
|
+
envBaseUrl: readEnv("AI_2KW_BASE_URL", "BACKBONE_BASE_URL"),
|
|
300
|
+
localApiKey: local.apiKey,
|
|
301
|
+
localBaseUrl: local.baseUrl,
|
|
302
|
+
activeContextName: getActiveContextName(),
|
|
303
|
+
activeContext: getActiveContext(),
|
|
304
|
+
});
|
|
205
305
|
}
|
|
206
306
|
/** Walk up Commander's parent chain to find root program */
|
|
207
307
|
function getRootCommand(cmd) {
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -10,5 +10,23 @@ export declare class BackboneApiError extends Error {
|
|
|
10
10
|
readonly timestamp: string;
|
|
11
11
|
constructor(body: ApiErrorBody);
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Discriminated cause of a failed credential check. Lets callers give a
|
|
15
|
+
* precise message instead of guessing, because `2kw auth status` otherwise
|
|
16
|
+
* collapses every failure into one string.
|
|
17
|
+
*/
|
|
18
|
+
export type AuthFailureKind = "UNAUTHORIZED" | "FORBIDDEN" | "UNREACHABLE" | "UNKNOWN";
|
|
19
|
+
/**
|
|
20
|
+
* Classify why a credential check failed, from the thrown error alone.
|
|
21
|
+
*
|
|
22
|
+
* - An HTTP rejection arrives as {@link BackboneApiError} (the API) or
|
|
23
|
+
* {@link AuthServiceError} (the auth service): 401 → UNAUTHORIZED,
|
|
24
|
+
* 403 → FORBIDDEN, anything else → UNKNOWN.
|
|
25
|
+
* - A network fault (fetch throws before any response) → UNREACHABLE, detected
|
|
26
|
+
* by undici's `fetch failed` TypeError or a known network error code on the
|
|
27
|
+
* error or its `cause`.
|
|
28
|
+
* - Everything else → UNKNOWN.
|
|
29
|
+
*/
|
|
30
|
+
export declare function classifyAuthFailure(err: unknown): AuthFailureKind;
|
|
13
31
|
export declare function handleError(err: unknown, json: boolean): void;
|
|
14
32
|
//# sourceMappingURL=errors.d.ts.map
|
package/dist/lib/errors.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import { AuthServiceError } from "./auth-service.js";
|
|
3
|
+
import { SessionExpiredError } from "./auth-session.js";
|
|
4
|
+
import { getActiveContext, getActiveContextName, updateContext } from "./config.js";
|
|
2
5
|
export class BackboneApiError extends Error {
|
|
3
6
|
status;
|
|
4
7
|
errorType;
|
|
@@ -11,8 +14,62 @@ export class BackboneApiError extends Error {
|
|
|
11
14
|
this.timestamp = body.timestamp;
|
|
12
15
|
}
|
|
13
16
|
}
|
|
17
|
+
// Node/undici surface network faults through the error's `code`. A DNS or
|
|
18
|
+
// connection failure means the host was never reached — distinct from the
|
|
19
|
+
// API rejecting the key.
|
|
20
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
21
|
+
"ENOTFOUND",
|
|
22
|
+
"ECONNREFUSED",
|
|
23
|
+
"EAI_AGAIN",
|
|
24
|
+
"ETIMEDOUT",
|
|
25
|
+
"ECONNRESET",
|
|
26
|
+
"ENETUNREACH",
|
|
27
|
+
"EHOSTUNREACH",
|
|
28
|
+
]);
|
|
29
|
+
/** Shared status → kind mapping: the two rejection types agree on what a 401
|
|
30
|
+
* and a 403 mean, and differ only in who sent them. */
|
|
31
|
+
function classifyStatus(status) {
|
|
32
|
+
if (status === 401)
|
|
33
|
+
return "UNAUTHORIZED";
|
|
34
|
+
if (status === 403)
|
|
35
|
+
return "FORBIDDEN";
|
|
36
|
+
return "UNKNOWN";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Classify why a credential check failed, from the thrown error alone.
|
|
40
|
+
*
|
|
41
|
+
* - An HTTP rejection arrives as {@link BackboneApiError} (the API) or
|
|
42
|
+
* {@link AuthServiceError} (the auth service): 401 → UNAUTHORIZED,
|
|
43
|
+
* 403 → FORBIDDEN, anything else → UNKNOWN.
|
|
44
|
+
* - A network fault (fetch throws before any response) → UNREACHABLE, detected
|
|
45
|
+
* by undici's `fetch failed` TypeError or a known network error code on the
|
|
46
|
+
* error or its `cause`.
|
|
47
|
+
* - Everything else → UNKNOWN.
|
|
48
|
+
*/
|
|
49
|
+
export function classifyAuthFailure(err) {
|
|
50
|
+
if (err instanceof BackboneApiError)
|
|
51
|
+
return classifyStatus(err.status);
|
|
52
|
+
if (err instanceof AuthServiceError)
|
|
53
|
+
return classifyStatus(err.status);
|
|
54
|
+
if (isNetworkError(err))
|
|
55
|
+
return "UNREACHABLE";
|
|
56
|
+
return "UNKNOWN";
|
|
57
|
+
}
|
|
58
|
+
function isNetworkError(err) {
|
|
59
|
+
if (!(err instanceof Error))
|
|
60
|
+
return false;
|
|
61
|
+
// undici throws `TypeError: fetch failed` when the request never completes.
|
|
62
|
+
if (err instanceof TypeError && /fetch failed/i.test(err.message))
|
|
63
|
+
return true;
|
|
64
|
+
const code = err.code;
|
|
65
|
+
if (typeof code === "string" && NETWORK_ERROR_CODES.has(code))
|
|
66
|
+
return true;
|
|
67
|
+
const cause = err.cause;
|
|
68
|
+
const causeCode = cause?.code;
|
|
69
|
+
return typeof causeCode === "string" && NETWORK_ERROR_CODES.has(causeCode);
|
|
70
|
+
}
|
|
14
71
|
const HINTS = {
|
|
15
|
-
401: 'Invalid or missing API key. Run "
|
|
72
|
+
401: 'Invalid or missing API key. Run "2kw auth login" to configure credentials.',
|
|
16
73
|
402: "Billing limit reached. Check your plan limits or upgrade at the dashboard.",
|
|
17
74
|
403: "You don't have permission for this action. Check your organization role.",
|
|
18
75
|
404: "Resource not found. Verify the ID is correct.",
|
|
@@ -20,8 +77,39 @@ const HINTS = {
|
|
|
20
77
|
422: "Validation error. Check the input values.",
|
|
21
78
|
429: "Rate limit exceeded. Wait a moment and try again.",
|
|
22
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* Drop the cached org JWT of the active session context.
|
|
82
|
+
*
|
|
83
|
+
* Called when the session behind that JWT turns out to be dead: keeping the
|
|
84
|
+
* token would only make the next command spend a request discovering the same
|
|
85
|
+
* 401. Best-effort by design — a read-only store is not worth a second error on
|
|
86
|
+
* top of the one being reported.
|
|
87
|
+
*/
|
|
88
|
+
function clearCachedJwt() {
|
|
89
|
+
try {
|
|
90
|
+
const ctx = getActiveContext();
|
|
91
|
+
// API-key contexts have no session and no cache to clear.
|
|
92
|
+
if (!ctx?.sessionToken)
|
|
93
|
+
return;
|
|
94
|
+
updateContext(getActiveContextName(), { cachedJwt: undefined, cachedJwtExp: undefined });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Nothing actionable: the message below is still the one the user needs.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
23
100
|
export function handleError(err, json) {
|
|
24
|
-
if (err instanceof
|
|
101
|
+
if (err instanceof SessionExpiredError) {
|
|
102
|
+
clearCachedJwt();
|
|
103
|
+
// Reached by every command once the stored session dies, so it gets the
|
|
104
|
+
// one line that names the fix — never a stack trace.
|
|
105
|
+
if (json) {
|
|
106
|
+
console.error(JSON.stringify({ error: "session_expired", message: err.message }));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.error(chalk.yellow(err.message));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (err instanceof BackboneApiError) {
|
|
25
113
|
if (json) {
|
|
26
114
|
console.error(JSON.stringify({
|
|
27
115
|
error: err.errorType,
|
|
@@ -40,7 +128,21 @@ export function handleError(err, json) {
|
|
|
40
128
|
}
|
|
41
129
|
else if (err instanceof Error) {
|
|
42
130
|
if (json) {
|
|
43
|
-
|
|
131
|
+
// Errors from outside the typed client (AuthServiceError, undici network
|
|
132
|
+
// faults) carry their own status/code. Passing them through is what lets
|
|
133
|
+
// a scripted caller tell an "invalid_client" apart from a dead DNS name
|
|
134
|
+
// instead of string-matching the message.
|
|
135
|
+
const status = err.status;
|
|
136
|
+
// undici hangs the real code off `cause`, so read both — same pair
|
|
137
|
+
// isNetworkError checks above.
|
|
138
|
+
const code = err.code ??
|
|
139
|
+
err.cause?.code;
|
|
140
|
+
console.error(JSON.stringify({
|
|
141
|
+
error: err.name,
|
|
142
|
+
message: err.message,
|
|
143
|
+
...(typeof status === "number" ? { status } : {}),
|
|
144
|
+
...(typeof code === "string" ? { code } : {}),
|
|
145
|
+
}));
|
|
44
146
|
}
|
|
45
147
|
else {
|
|
46
148
|
console.error(chalk.red(`Error: ${err.message}`));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that decides how a stored credential is allowed to appear —
|
|
3
|
+
* on screen, and in `--json` output.
|
|
4
|
+
*
|
|
5
|
+
* Every surface that prints a {@link ContextEntry} goes through here (`context
|
|
6
|
+
* list`, `context current`, `config list`), because a secret that reaches a
|
|
7
|
+
* terminal also reaches scrollback, CI logs, and pasted bug reports. Keeping
|
|
8
|
+
* the rules next to the shape they redact is what stops the next surface from
|
|
9
|
+
* re-deriving them and forgetting a field.
|
|
10
|
+
*/
|
|
11
|
+
import type { ContextEntry } from "./config.js";
|
|
12
|
+
/**
|
|
13
|
+
* Enough of an API key to recognise which one it is, never enough to use it.
|
|
14
|
+
* A key too short to keep both ends of is blanked outright rather than
|
|
15
|
+
* printed with its middle "hidden".
|
|
16
|
+
*/
|
|
17
|
+
export declare function maskApiKey(key: string | undefined): string;
|
|
18
|
+
/**
|
|
19
|
+
* Session tokens are opaque and long; the leading characters are enough to
|
|
20
|
+
* tell two apart in a bug report, and are useless on their own.
|
|
21
|
+
*/
|
|
22
|
+
export declare function maskSessionToken(token: string): string;
|
|
23
|
+
/** The credential column of `context list` / `context current`, either kind. */
|
|
24
|
+
export declare function credentialSummary(ctx: ContextEntry): string;
|
|
25
|
+
/**
|
|
26
|
+
* A {@link ContextEntry} safe to serialize.
|
|
27
|
+
*
|
|
28
|
+
* The shape is otherwise the stored one so scripts reading `context list
|
|
29
|
+
* --json` keep working: `apiKey` and `sessionToken` are masked in place,
|
|
30
|
+
* `cachedJwt` is dropped entirely (it is a bearer token for the API and has no
|
|
31
|
+
* identifying value at all), and `cachedJwtExp` stays — knowing when the cache
|
|
32
|
+
* turns over is useful and expiries are not secret.
|
|
33
|
+
*/
|
|
34
|
+
export declare function redactContext(ctx: ContextEntry): Record<string, unknown>;
|
|
35
|
+
//# sourceMappingURL=redact.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that decides how a stored credential is allowed to appear —
|
|
3
|
+
* on screen, and in `--json` output.
|
|
4
|
+
*
|
|
5
|
+
* Every surface that prints a {@link ContextEntry} goes through here (`context
|
|
6
|
+
* list`, `context current`, `config list`), because a secret that reaches a
|
|
7
|
+
* terminal also reaches scrollback, CI logs, and pasted bug reports. Keeping
|
|
8
|
+
* the rules next to the shape they redact is what stops the next surface from
|
|
9
|
+
* re-deriving them and forgetting a field.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Enough of an API key to recognise which one it is, never enough to use it.
|
|
13
|
+
* A key too short to keep both ends of is blanked outright rather than
|
|
14
|
+
* printed with its middle "hidden".
|
|
15
|
+
*/
|
|
16
|
+
export function maskApiKey(key) {
|
|
17
|
+
if (!key)
|
|
18
|
+
return "-";
|
|
19
|
+
if (key.length <= 11)
|
|
20
|
+
return "****";
|
|
21
|
+
return key.slice(0, 7) + "..." + key.slice(-4);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Session tokens are opaque and long; the leading characters are enough to
|
|
25
|
+
* tell two apart in a bug report, and are useless on their own.
|
|
26
|
+
*/
|
|
27
|
+
export function maskSessionToken(token) {
|
|
28
|
+
return token.length <= 8 ? "****" : token.slice(0, 8) + "...";
|
|
29
|
+
}
|
|
30
|
+
/** The credential column of `context list` / `context current`, either kind. */
|
|
31
|
+
export function credentialSummary(ctx) {
|
|
32
|
+
if (ctx.sessionToken)
|
|
33
|
+
return `session ${maskSessionToken(ctx.sessionToken)}`;
|
|
34
|
+
return maskApiKey(ctx.apiKey);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A {@link ContextEntry} safe to serialize.
|
|
38
|
+
*
|
|
39
|
+
* The shape is otherwise the stored one so scripts reading `context list
|
|
40
|
+
* --json` keep working: `apiKey` and `sessionToken` are masked in place,
|
|
41
|
+
* `cachedJwt` is dropped entirely (it is a bearer token for the API and has no
|
|
42
|
+
* identifying value at all), and `cachedJwtExp` stays — knowing when the cache
|
|
43
|
+
* turns over is useful and expiries are not secret.
|
|
44
|
+
*/
|
|
45
|
+
export function redactContext(ctx) {
|
|
46
|
+
const { apiKey, sessionToken, cachedJwt, ...rest } = ctx;
|
|
47
|
+
void cachedJwt; // destructured only to keep it out of `rest`.
|
|
48
|
+
return {
|
|
49
|
+
...rest,
|
|
50
|
+
...(apiKey !== undefined ? { apiKey: maskApiKey(apiKey) } : {}),
|
|
51
|
+
...(sessionToken !== undefined ? { sessionToken: maskSessionToken(sessionToken) } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=redact.js.map
|
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
const PKG_NAME = "@
|
|
5
|
+
const PKG_NAME = "@2kw/ai";
|
|
6
6
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 1 day
|
|
7
7
|
const CACHE_DIR = join(homedir(), ".config", "backbone");
|
|
8
8
|
const CACHE_FILE = join(CACHE_DIR, "update-check.json");
|
package/package.json
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2kw/ai",
|
|
3
|
-
"version": "5.
|
|
4
|
-
"description": "CLI for
|
|
3
|
+
"version": "5.2.0-dev.10",
|
|
4
|
+
"description": "CLI for 2kw.ai — schema-driven document extraction, an OpenAI-compatible EU LLM gateway, transcription, prompts, datasets, and experiments from your terminal or agentic workflows. Ships as 2kw, backbone, and bb.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"llm-gateway",
|
|
8
|
+
"ai-gateway",
|
|
9
|
+
"document-extraction",
|
|
10
|
+
"structured-data-extraction",
|
|
11
|
+
"pdf-extraction",
|
|
12
|
+
"openai-compatible",
|
|
13
|
+
"transcription",
|
|
14
|
+
"prompt-management",
|
|
15
|
+
"llm-evaluation",
|
|
16
|
+
"llm-observability",
|
|
17
|
+
"agentic-workflows",
|
|
18
|
+
"gdpr",
|
|
19
|
+
"eu-data-residency",
|
|
20
|
+
"2kw",
|
|
21
|
+
"backbone"
|
|
22
|
+
],
|
|
5
23
|
"type": "module",
|
|
6
24
|
"main": "dist/index.js",
|
|
7
25
|
"bin": {
|
|
@@ -17,13 +35,18 @@
|
|
|
17
35
|
"generate:types": "openapi-typescript openapi/openapi.json -o src/generated/openapi.d.ts",
|
|
18
36
|
"generate": "npm run generate:spec && npm run generate:types",
|
|
19
37
|
"check:spec": "tsx --env-file=.env openapi/scripts/check-drift.ts",
|
|
20
|
-
"typecheck": "tsc --noEmit"
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest",
|
|
40
|
+
"test:run": "vitest run",
|
|
41
|
+
"test:unit": "vitest run tests/unit",
|
|
42
|
+
"test:contract": "vitest run tests/contract"
|
|
21
43
|
},
|
|
22
44
|
"dependencies": {
|
|
23
45
|
"chalk": "^5.6.2",
|
|
24
46
|
"cli-table3": "^0.6.5",
|
|
25
47
|
"commander": "^13.1.0",
|
|
26
48
|
"conf": "^13.1.0",
|
|
49
|
+
"open": "^10.2.0",
|
|
27
50
|
"openapi-fetch": "^0.13.5",
|
|
28
51
|
"ora": "^8.2.0"
|
|
29
52
|
},
|
|
@@ -31,7 +54,8 @@
|
|
|
31
54
|
"@types/node": "^22.19.17",
|
|
32
55
|
"openapi-typescript": "^7.13.0",
|
|
33
56
|
"tsx": "^4.21.1",
|
|
34
|
-
"typescript": "^5.9.3"
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"vitest": "^4.1.10"
|
|
35
59
|
},
|
|
36
60
|
"engines": {
|
|
37
61
|
"node": ">=18.20.8"
|