@cruxy/cli 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/login.js +18 -5
- package/dist/cli/commands/run.js +93 -22
- package/dist/config/credential-lifetime.js +42 -0
- package/dist/config/credentials.js +66 -0
- package/dist/errors/boundary.js +4 -4
- package/dist/errors/constructors.js +87 -13
- package/dist/errors/types.js +18 -0
- package/dist/index.js +27 -1
- package/dist/limits/cache.js +21 -5
- package/dist/onboarding/flow.js +121 -6
- package/dist/onboarding/steps.js +112 -0
- package/dist/session/index.js +3 -3
- package/dist/session/list.js +109 -23
- package/dist/session/log.js +28 -9
- package/dist/session/replay.js +71 -21
- package/dist/session/resume.js +74 -12
- package/dist/session/types.js +45 -0
- package/dist/tui/limits-panel.js +9 -0
- package/package.json +2 -2
|
@@ -5,15 +5,27 @@ import { logger } from "../../utils/logger.js";
|
|
|
5
5
|
import { loadConfig } from "../../config/index.js";
|
|
6
6
|
import { createDefaultDeps, defaultOnboardingIO, runOnboarding, } from "../../onboarding/index.js";
|
|
7
7
|
/**
|
|
8
|
-
* `cruxy login` —
|
|
9
|
-
* of onboarding (always, even if a key already resolves — this is how you
|
|
10
|
-
* re-key / switch)
|
|
8
|
+
* `cruxy login` — sign in and persist a credential on demand (U.6). Runs the key
|
|
9
|
+
* step of onboarding (always, even if a key already resolves — this is how you
|
|
10
|
+
* re-key / switch) and persists the result to the credentials store.
|
|
11
11
|
* Non-interactive invocations fail loud rather than hang.
|
|
12
|
+
*
|
|
13
|
+
* THE DEFAULT IS THE DEVICE FLOW: approve in a browser, and the gateway mints a
|
|
14
|
+
* subscription-bucket credential that draws on the plan pool the same human's
|
|
15
|
+
* web chat and desktop already use. A pasted key cannot be one — the admin
|
|
16
|
+
* issue-key route it comes from refuses to produce that kind — so the paste path
|
|
17
|
+
* is kept as a fallback rather than the front door.
|
|
18
|
+
*
|
|
19
|
+
* `--paste` keeps that fallback reachable, because the device flow cannot serve
|
|
20
|
+
* every case: an air-gapped machine has no browser to approve in, and someone
|
|
21
|
+
* deliberately using a long-lived admin key does not want a 90-day device
|
|
22
|
+
* credential written over it.
|
|
12
23
|
*/
|
|
13
24
|
export function loginCommand() {
|
|
14
25
|
return new Command("login")
|
|
15
|
-
.description("set or replace your
|
|
16
|
-
.
|
|
26
|
+
.description("set or replace your cruxy credential (saved to ~/.cruxy)")
|
|
27
|
+
.option("--paste", "paste an existing API key instead of approving in a browser")
|
|
28
|
+
.action(async (opts) => {
|
|
17
29
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
18
30
|
if (!process.stdin.isTTY) {
|
|
19
31
|
logger.print(t.muted("cruxy login is interactive — run it in a terminal, or export your key as an environment variable."));
|
|
@@ -27,6 +39,7 @@ export function loginCommand() {
|
|
|
27
39
|
forceKey: true,
|
|
28
40
|
offerFirstWin: false,
|
|
29
41
|
offerScaffold: false,
|
|
42
|
+
preferPaste: opts.paste === true,
|
|
30
43
|
io: defaultOnboardingIO(),
|
|
31
44
|
deps: createDefaultDeps({ config, cwd: process.cwd() }),
|
|
32
45
|
});
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -3,10 +3,10 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { LimitsClient } from "@cruxy/sdk";
|
|
4
4
|
import { LimitsCache } from "../../limits/index.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
|
-
import { SessionLog, listSessions,
|
|
6
|
+
import { SessionLog, listSessions, loadResume, resolveSessionId, resumePicker, shortId, } from "../../session/index.js";
|
|
7
7
|
/** Sessions shown in the TUI sidebar — the same depth as the resume picker. */
|
|
8
8
|
const SIDEBAR_SESSIONS = 10;
|
|
9
|
-
import { globalDir, loadConfig, resolveApiKey } from "../../config/index.js";
|
|
9
|
+
import { classifyCredentialLifetime, globalDir, loadConfig, readCredentialMeta, resolveApiKey, } from "../../config/index.js";
|
|
10
10
|
import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
|
|
11
11
|
import { createRenderer } from "../../render/index.js";
|
|
12
12
|
import { themeForColor } from "../../theme/index.js";
|
|
@@ -56,6 +56,56 @@ export async function executeRun(promptParts, opts) {
|
|
|
56
56
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
57
57
|
const invokedAs = opts.commandName ?? "cruxy run";
|
|
58
58
|
const interactive = prompt === "";
|
|
59
|
+
// ORDER OF COMPLAINTS (#172 item 3). Three things can refuse this run before
|
|
60
|
+
// it starts, and they are checked most-specific first:
|
|
61
|
+
//
|
|
62
|
+
// 1. a bad `--root` (the workspace cannot even be built)
|
|
63
|
+
// 2. a bad `--resume <id>` (the session named does not exist)
|
|
64
|
+
// 3. no prompt, and no TTY (nothing to do, no way to be asked)
|
|
65
|
+
//
|
|
66
|
+
// The terminal check USED to come first, which meant
|
|
67
|
+
// `cruxy --resume no-such-id < /dev/null` was told to add a message — advice
|
|
68
|
+
// that, followed, only surfaces the real error one run later. `program.ts`
|
|
69
|
+
// deliberately routes `--resume` past the guidance banner so a bad id fails
|
|
70
|
+
// loud; the guard sitting in front of it undid exactly that.
|
|
71
|
+
//
|
|
72
|
+
// The tradeoff this reordering accepts: `--root` errors now precede the
|
|
73
|
+
// terminal message too, because id validation needs `primaryRoot` and so the
|
|
74
|
+
// workspace has to be built first. That is the right way round — a
|
|
75
|
+
// non-existent root is a more specific complaint than "needs a terminal", and
|
|
76
|
+
// it was already the first thing to fail on every OTHER path.
|
|
77
|
+
//
|
|
78
|
+
// The guard is NOT simply moved below resume resolution instead. That would
|
|
79
|
+
// put `loadConfig` and the missing-key check in front of it, so a keyless
|
|
80
|
+
// non-TTY run would be told to authenticate rather than that it has nothing
|
|
81
|
+
// to do — trading one misleading diagnostic for another.
|
|
82
|
+
// Declared workspace roots (C.26). This is the ONE place `run` reads the
|
|
83
|
+
// process working directory — the invocation directory is the base for
|
|
84
|
+
// resolving `--root` paths and the sole root when none are declared. Every
|
|
85
|
+
// subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
|
|
86
|
+
// re-reads the invocation directory, so nothing can silently split-brain to
|
|
87
|
+
// a different dir than the roots the tools see (the guard test pins this).
|
|
88
|
+
const invocationCwd = process.cwd();
|
|
89
|
+
// No --root → a trivial single-root workspace (byte-identical to pre-C.26).
|
|
90
|
+
// --root builds a genuine multi-root Workspace and fails fast HERE — a
|
|
91
|
+
// missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
|
|
92
|
+
// onboarding or the session starts, never a half-built session.
|
|
93
|
+
const workspace = opts.root.length
|
|
94
|
+
? await buildWorkspace(opts.root, { cwd: invocationCwd })
|
|
95
|
+
: sessionWorkspace(invocationCwd);
|
|
96
|
+
const primaryRoot = workspace.primary().absPath;
|
|
97
|
+
// VALIDATION ONLY — deliberately not the load. `resolveSessionId` answers
|
|
98
|
+
// "does this id name a session?" from one meta line per file; the replay that
|
|
99
|
+
// actually restores the conversation happens far below, once we know the run
|
|
100
|
+
// is going to proceed. A valid `--resume <id> < /dev/null` with no message is
|
|
101
|
+
// still going to be turned away by the guard just below, and it should not
|
|
102
|
+
// have paid for a full `replaySession` to get there.
|
|
103
|
+
//
|
|
104
|
+
// Bare `--resume` (no id) cannot be validated here and is not: the picker
|
|
105
|
+
// needs a terminal, so it stays below the guard where it has always been.
|
|
106
|
+
const resumeTarget = typeof opts.resume === "string" && opts.resume !== ""
|
|
107
|
+
? resolveSessionId(primaryRoot, opts.resume)
|
|
108
|
+
: null;
|
|
59
109
|
// No prompt and stdin isn't a terminal: there's no way to read input and
|
|
60
110
|
// nothing to do — fail fast instead of hanging on a line that never comes.
|
|
61
111
|
//
|
|
@@ -78,21 +128,6 @@ export async function executeRun(promptParts, opts) {
|
|
|
78
128
|
const loaded = loadConfig();
|
|
79
129
|
const { config, sources } = loaded;
|
|
80
130
|
let apiKey = resolveApiKey(config.model.provider);
|
|
81
|
-
// Declared workspace roots (C.26). This is the ONE place `run` reads the
|
|
82
|
-
// process working directory — the invocation directory is the base for
|
|
83
|
-
// resolving `--root` paths and the sole root when none are declared. Every
|
|
84
|
-
// subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
|
|
85
|
-
// re-reads the invocation directory, so nothing can silently split-brain to
|
|
86
|
-
// a different dir than the roots the tools see (the guard test pins this).
|
|
87
|
-
const invocationCwd = process.cwd();
|
|
88
|
-
// No --root → a trivial single-root workspace (byte-identical to pre-C.26).
|
|
89
|
-
// --root builds a genuine multi-root Workspace and fails fast HERE — a
|
|
90
|
-
// missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
|
|
91
|
-
// onboarding or the session starts, never a half-built session.
|
|
92
|
-
const workspace = opts.root.length
|
|
93
|
-
? await buildWorkspace(opts.root, { cwd: invocationCwd })
|
|
94
|
-
: sessionWorkspace(invocationCwd);
|
|
95
|
-
const primaryRoot = workspace.primary().absPath;
|
|
96
131
|
logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
|
|
97
132
|
logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
|
|
98
133
|
// Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
|
|
@@ -147,10 +182,12 @@ export async function executeRun(promptParts, opts) {
|
|
|
147
182
|
// a repo moved would be worse than saying so plainly.
|
|
148
183
|
let resumed = null;
|
|
149
184
|
if (opts.resume !== undefined && opts.resume !== false) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
185
|
+
// `resumeTarget` is the id already validated above the guard; loading it is
|
|
186
|
+
// all that is left. Bare `--resume` resolves here, where the TTY it needs is
|
|
187
|
+
// guaranteed — the guard above turned away every run that lacked one.
|
|
188
|
+
resumed = resumeTarget
|
|
189
|
+
? loadResume(resumeTarget, primaryRoot)
|
|
190
|
+
: await resumePicker(primaryRoot, { logger });
|
|
154
191
|
if (resumed) {
|
|
155
192
|
for (const warning of resumed.warnings)
|
|
156
193
|
logger.warn(warning);
|
|
@@ -351,13 +388,26 @@ export async function executeRun(promptParts, opts) {
|
|
|
351
388
|
// The cache is still attached without a key so the panel can say "not signed
|
|
352
389
|
// in" — a fact worth stating, and one that costs no request to establish.
|
|
353
390
|
const limitsKey = config.model.provider === "cruxy" ? apiKey : undefined;
|
|
391
|
+
// The stored expiry of the credential in use, read lazily. It is what lets a
|
|
392
|
+
// 401 be reported as "your sign-in expired" instead of "your key is wrong" —
|
|
393
|
+
// the gateway refuses both identically and will not say which.
|
|
394
|
+
const credentialExpiresAt = () => readCredentialMeta(config.model.provider)?.expiresAt;
|
|
354
395
|
const limits = new LimitsCache(limitsKey === undefined
|
|
355
396
|
? undefined
|
|
356
397
|
: (signal) => new LimitsClient({
|
|
357
398
|
apiKey: limitsKey,
|
|
358
399
|
gatewayUrl: config.cruxy.gatewayUrl,
|
|
359
|
-
}).read(signal));
|
|
400
|
+
}).read(signal), { credentialExpiresAt });
|
|
360
401
|
tui?.attachLimits(limits);
|
|
402
|
+
// Say it BEFORE the session starts, while a re-login is a 30-second detour
|
|
403
|
+
// rather than an interruption. A device credential is minted with a finite
|
|
404
|
+
// lifetime and the gateway echoes it back precisely so a client can do this;
|
|
405
|
+
// the alternative is the user meeting their expiry mid-turn, as a 401.
|
|
406
|
+
//
|
|
407
|
+
// Only when we actually know: an absent or unparseable expiry classifies as
|
|
408
|
+
// `unknown` and says nothing, which is the right answer for every pasted key
|
|
409
|
+
// and every credential stored before expiries were recorded.
|
|
410
|
+
noteCredentialExpiry(credentialExpiresAt());
|
|
361
411
|
// The SAME cache backs the session's weighted-token budget (P10 track 3), so
|
|
362
412
|
// the rail's headroom bar and `/budget`'s server line are one reading rather
|
|
363
413
|
// than two probes that can disagree — and so admission control at the fan-out
|
|
@@ -498,3 +548,24 @@ function printRunUsage(record) {
|
|
|
498
548
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
499
549
|
logger.print(renderSummary(summarizeRuns([record]), t));
|
|
500
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* Warn, once per run and before the session starts, that the stored credential
|
|
553
|
+
* is about to expire.
|
|
554
|
+
*
|
|
555
|
+
* ONLY IN THE `expiring` WINDOW. `live` is silent because a warning 80 days out
|
|
556
|
+
* is noise that trains people to ignore the line, and `expired` is silent here
|
|
557
|
+
* because a request is about to fail with a far better message than a preamble
|
|
558
|
+
* could give — `authExpired` names the date and the fix. `unknown` is silent
|
|
559
|
+
* because it is the honest state for every pasted key and every credential
|
|
560
|
+
* stored before expiries were recorded, and nagging those users about a lifetime
|
|
561
|
+
* nobody ever stated is exactly the false alarm this whole distinction exists to
|
|
562
|
+
* avoid.
|
|
563
|
+
*/
|
|
564
|
+
function noteCredentialExpiry(expiresAt) {
|
|
565
|
+
const lifetime = classifyCredentialLifetime(expiresAt);
|
|
566
|
+
if (lifetime.state !== "expiring")
|
|
567
|
+
return;
|
|
568
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
569
|
+
const days = Math.max(1, Math.round(lifetime.msRemaining / 86_400_000));
|
|
570
|
+
logger.print(t.warning(`your cruxy sign-in expires in ${days} day${days === 1 ? "" : "s"}`) + t.muted(" — run `cruxy login` to renew it"));
|
|
571
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How close a stored credential is to the end of its life — a pure function of a
|
|
3
|
+
* timestamp and a clock.
|
|
4
|
+
*
|
|
5
|
+
* IT LIVES IN ITS OWN MODULE, AND IMPORTS NOTHING, so that `errors/` can reach
|
|
6
|
+
* it. `config/credentials.ts` imports `errors/` (for the unprotected-store
|
|
7
|
+
* refusal), so an error constructor importing the credentials store back would
|
|
8
|
+
* close a cycle. The rule this module needs to state is not about the store at
|
|
9
|
+
* all — it is arithmetic on one timestamp — so it sits below both and each side
|
|
10
|
+
* imports it directly.
|
|
11
|
+
*
|
|
12
|
+
* ONE CLASSIFIER, because every caller that acts on expiry must reach the same
|
|
13
|
+
* verdict from the same value: the 401 classifier deciding whether to say "your
|
|
14
|
+
* key is wrong" or "your login expired", the pre-session nudge deciding whether
|
|
15
|
+
* to warn, and the limits panel deciding what to render. Three private
|
|
16
|
+
* `Date.parse` comparisons would be three chances to disagree about whether a
|
|
17
|
+
* credential is dead.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* How long before expiry a credential is worth mentioning.
|
|
21
|
+
*
|
|
22
|
+
* Seven days against the gateway's 90-day device-key lifetime: long enough that
|
|
23
|
+
* someone who uses cruxy weekly sees the notice at least once before anything
|
|
24
|
+
* breaks, short enough that it is not background noise for the other 83 days.
|
|
25
|
+
*/
|
|
26
|
+
export const CREDENTIAL_EXPIRY_WARNING_MS = 7 * 24 * 60 * 60 * 1000;
|
|
27
|
+
export function classifyCredentialLifetime(expiresAt, now = Date.now(), warnWithinMs = CREDENTIAL_EXPIRY_WARNING_MS) {
|
|
28
|
+
if (expiresAt === undefined || expiresAt === "")
|
|
29
|
+
return { state: "unknown" };
|
|
30
|
+
const at = Date.parse(expiresAt);
|
|
31
|
+
// A timestamp this build cannot read is a thing we do not know, not a thing
|
|
32
|
+
// that has happened.
|
|
33
|
+
if (Number.isNaN(at))
|
|
34
|
+
return { state: "unknown" };
|
|
35
|
+
const msRemaining = at - now;
|
|
36
|
+
if (msRemaining <= 0)
|
|
37
|
+
return { state: "expired", expiresAt };
|
|
38
|
+
if (msRemaining <= warnWithinMs) {
|
|
39
|
+
return { state: "expiring", expiresAt, msRemaining };
|
|
40
|
+
}
|
|
41
|
+
return { state: "live", expiresAt, msRemaining };
|
|
42
|
+
}
|
|
@@ -3,8 +3,13 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { CREDENTIALS_FILE_NAME } from "../constants.js";
|
|
4
4
|
import { credentialsUnprotected } from "../errors/index.js";
|
|
5
5
|
import { logger } from "../utils/logger.js";
|
|
6
|
+
import { classifyCredentialLifetime, } from "./credential-lifetime.js";
|
|
6
7
|
import { enforceOwnerOnly, isOwnerOnly } from "./owner-only.js";
|
|
7
8
|
import { globalDir } from "./paths.js";
|
|
9
|
+
// Re-exported so `config/index.js` remains the one import site for credential
|
|
10
|
+
// concerns; the classifier itself lives in a leaf module that imports nothing,
|
|
11
|
+
// because `errors/` needs it too and this file imports `errors/`.
|
|
12
|
+
export { classifyCredentialLifetime, CREDENTIAL_EXPIRY_WARNING_MS, } from "./credential-lifetime.js";
|
|
8
13
|
/**
|
|
9
14
|
* The credentials store (U.6) — the one place a provider API key is persisted.
|
|
10
15
|
* It lives **outside** `config.json` on purpose: config is secret-free by design
|
|
@@ -89,15 +94,76 @@ export function writeMcpCredential(ref, token, file = credentialsPath()) {
|
|
|
89
94
|
store.mcp[ref] = token;
|
|
90
95
|
}, "mcp");
|
|
91
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* What we know about the stored key for `provider`, or `undefined`.
|
|
99
|
+
*
|
|
100
|
+
* Tolerant of a store written by a newer build or corrupted by hand: an entry
|
|
101
|
+
* that is not an object, or whose fields are the wrong type, reads as absent
|
|
102
|
+
* rather than throwing. This backs an expiry warning, and a warning that can
|
|
103
|
+
* crash the CLI is worse than no warning. Never throws.
|
|
104
|
+
*/
|
|
105
|
+
export function readCredentialMeta(provider, file = credentialsPath()) {
|
|
106
|
+
const store = readStore(file);
|
|
107
|
+
const raw = store?.keyMeta?.[provider];
|
|
108
|
+
if (raw === null || typeof raw !== "object")
|
|
109
|
+
return undefined;
|
|
110
|
+
const entry = raw;
|
|
111
|
+
const meta = {};
|
|
112
|
+
if (typeof entry.expiresAt === "string" && entry.expiresAt !== "") {
|
|
113
|
+
meta.expiresAt = entry.expiresAt;
|
|
114
|
+
}
|
|
115
|
+
if (typeof entry.keyId === "string" && entry.keyId !== "") {
|
|
116
|
+
meta.keyId = entry.keyId;
|
|
117
|
+
}
|
|
118
|
+
if (entry.source === "device" || entry.source === "paste") {
|
|
119
|
+
meta.source = entry.source;
|
|
120
|
+
}
|
|
121
|
+
return meta;
|
|
122
|
+
}
|
|
92
123
|
/**
|
|
93
124
|
* Persist `key` for `provider`, merging into any existing store. Written
|
|
94
125
|
* owner-only (see {@link writeInto}); refuses loudly if that can't be enforced.
|
|
126
|
+
*
|
|
127
|
+
* A bare write CLEARS any metadata held for that provider, because a write with
|
|
128
|
+
* nothing to say about the key's lifetime is a statement that we do not know it.
|
|
129
|
+
* The alternative — leaving whatever was there — is the one way this store can
|
|
130
|
+
* lie: paste an eternal admin key over a slot a device login used, and the
|
|
131
|
+
* stale 90-day expiry would have us announce that a perfectly good credential
|
|
132
|
+
* had died. Key and metadata therefore only ever move together, through
|
|
133
|
+
* {@link writeCredentialWithMeta}.
|
|
95
134
|
*/
|
|
96
135
|
export function writeCredential(provider, key, file = credentialsPath()) {
|
|
136
|
+
writeCredentialWithMeta(provider, key, {}, file);
|
|
137
|
+
}
|
|
138
|
+
/** Persist `key` for `provider` together with what is known about it. */
|
|
139
|
+
export function writeCredentialWithMeta(provider, key, meta, file = credentialsPath()) {
|
|
97
140
|
writeInto(file, (store) => {
|
|
98
141
|
store.keys[provider] = key;
|
|
142
|
+
const entry = {};
|
|
143
|
+
if (meta.expiresAt)
|
|
144
|
+
entry.expiresAt = meta.expiresAt;
|
|
145
|
+
if (meta.keyId)
|
|
146
|
+
entry.keyId = meta.keyId;
|
|
147
|
+
if (meta.source)
|
|
148
|
+
entry.source = meta.source;
|
|
149
|
+
if (Object.keys(entry).length === 0) {
|
|
150
|
+
// Nothing known. Drop any prior entry rather than keeping a fact about
|
|
151
|
+
// a key that no longer exists in this slot.
|
|
152
|
+
if (store.keyMeta) {
|
|
153
|
+
delete store.keyMeta[provider];
|
|
154
|
+
if (Object.keys(store.keyMeta).length === 0)
|
|
155
|
+
delete store.keyMeta;
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
store.keyMeta ??= {};
|
|
160
|
+
store.keyMeta[provider] = entry;
|
|
99
161
|
}, "provider");
|
|
100
162
|
}
|
|
163
|
+
/** The lifetime of the credential currently stored for `provider`. */
|
|
164
|
+
export function credentialLifetime(provider, now = Date.now(), file = credentialsPath()) {
|
|
165
|
+
return classifyCredentialLifetime(readCredentialMeta(provider, file)?.expiresAt, now);
|
|
166
|
+
}
|
|
101
167
|
/**
|
|
102
168
|
* Merge `mutate` into the store and persist it owner-only. The one write path
|
|
103
169
|
* shared by every credential namespace, and the single place the owner-only
|
package/dist/errors/boundary.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CommanderError } from "commander";
|
|
2
|
-
import { classifyProviderError, internal, usageError } from "./constructors.js";
|
|
2
|
+
import { classifyProviderError, internal, usageError, } from "./constructors.js";
|
|
3
3
|
import { shouldUseColor, terminalFormatter } from "./format.js";
|
|
4
4
|
import { CruxyError } from "./types.js";
|
|
5
5
|
/**
|
|
@@ -16,13 +16,13 @@ import { CruxyError } from "./types.js";
|
|
|
16
16
|
* - a known provider/transport error → its mapped code;
|
|
17
17
|
* - anything else → CRUXY_E_INTERNAL, preserving the original as `underlying`.
|
|
18
18
|
*/
|
|
19
|
-
export function fromUnknown(err) {
|
|
19
|
+
export function fromUnknown(err, ctx = {}) {
|
|
20
20
|
if (err instanceof CruxyError)
|
|
21
21
|
return err;
|
|
22
22
|
if (err instanceof CommanderError) {
|
|
23
23
|
return usageError(stripErrorPrefix(err.message));
|
|
24
24
|
}
|
|
25
|
-
return classifyProviderError(err) ?? internal(err);
|
|
25
|
+
return classifyProviderError(err, ctx) ?? internal(err);
|
|
26
26
|
}
|
|
27
27
|
/** A Commander "error" that's actually success (`--help`, `--version`). */
|
|
28
28
|
export function isCommanderSuccess(err) {
|
|
@@ -34,7 +34,7 @@ export function isCommanderSuccess(err) {
|
|
|
34
34
|
* touching the real process.
|
|
35
35
|
*/
|
|
36
36
|
export function handleFatal(err, opts = {}) {
|
|
37
|
-
const cruxy = fromUnknown(err);
|
|
37
|
+
const cruxy = fromUnknown(err, opts.context ?? {});
|
|
38
38
|
const verbose = opts.verbose ?? isVerbose();
|
|
39
39
|
const color = opts.color ?? shouldUseColor();
|
|
40
40
|
const formatter = opts.formatter ?? terminalFormatter;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { ApiError, AuthError, BudgetExhaustedError, InvalidRequestError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
|
|
2
2
|
import { scrubModelNames } from "../brand/index.js";
|
|
3
|
+
// Imported from the leaf module DIRECTLY, not via `config/index.js`:
|
|
4
|
+
// `config/credentials.ts` imports this file, so going through the barrel would
|
|
5
|
+
// close an import cycle. `credential-lifetime.ts` imports nothing.
|
|
6
|
+
import { classifyCredentialLifetime } from "../config/credential-lifetime.js";
|
|
3
7
|
import { CruxyError, ErrorCode } from "./types.js";
|
|
4
8
|
/**
|
|
5
9
|
* Helper constructors for {@link CruxyError}. Each encodes the title, the human
|
|
@@ -137,11 +141,36 @@ export function authInvalid(underlying) {
|
|
|
137
141
|
cause: scrubbedMessageOf(underlying),
|
|
138
142
|
nextSteps: [
|
|
139
143
|
"verify your API key is correct and active",
|
|
144
|
+
"run `cruxy login` to sign in again and replace it",
|
|
140
145
|
"re-export the key and try again",
|
|
141
146
|
],
|
|
142
147
|
underlying,
|
|
143
148
|
});
|
|
144
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* The credential expired. The 401 that surfaced it is byte-identical to the one
|
|
152
|
+
* a wrong key produces (see {@link ErrorCode.AuthExpired}); what separates them
|
|
153
|
+
* is the expiry this CLI stored when the key was minted, so this constructor is
|
|
154
|
+
* only ever reached by a caller that checked it.
|
|
155
|
+
*
|
|
156
|
+
* `expiresAt` is echoed because "it expired" invites "when?", and a user who
|
|
157
|
+
* sees a date three weeks past recognises immediately that this is not a key
|
|
158
|
+
* they typed wrong today.
|
|
159
|
+
*/
|
|
160
|
+
export function authExpired(expiresAt, underlying) {
|
|
161
|
+
const when = expiresAt ? ` on ${expiresAt}` : "";
|
|
162
|
+
return new CruxyError({
|
|
163
|
+
code: ErrorCode.AuthExpired,
|
|
164
|
+
title: "your cruxy login has expired",
|
|
165
|
+
cause: `the stored credential reached the end of its lifetime${when}, and the gateway now refuses it`,
|
|
166
|
+
nextSteps: [
|
|
167
|
+
"run `cruxy login` to sign in again — it mints a fresh credential",
|
|
168
|
+
"or export a long-lived key as CRUXY_API_KEY (the environment always wins over the store)",
|
|
169
|
+
],
|
|
170
|
+
underlying,
|
|
171
|
+
meta: expiresAt !== undefined ? { expiresAt } : undefined,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
145
174
|
/**
|
|
146
175
|
* A credential could not be persisted with owner-only permissions, so it was
|
|
147
176
|
* NOT written (C.27c). Chiefly a Windows case: the store's ACL could not be
|
|
@@ -360,25 +389,61 @@ export function budgetExhausted(underlying) {
|
|
|
360
389
|
if (err?.miraAvailable) {
|
|
361
390
|
nextSteps.push("switch to the mira tier, which stays available: `/model mira`");
|
|
362
391
|
}
|
|
392
|
+
// WHICH CEILING BOUND. The gateway renders three of them through one body,
|
|
393
|
+
// separated only by `code`, so this is the one place that can tell them apart —
|
|
394
|
+
// and they take genuinely different advice. "Move to a higher plan" is right
|
|
395
|
+
// for the subscription pool and WRONG for a per-key cap: a device-login
|
|
396
|
+
// credential carries its own monthly ceiling stamped on it at mint, and no
|
|
397
|
+
// plan a user buys will raise it. Saying so anyway sends someone to spend
|
|
398
|
+
// money on something that cannot fix their problem.
|
|
399
|
+
const ceiling = err?.apiType;
|
|
400
|
+
const isKeyCap = ceiling === "key_spend_cap_exceeded";
|
|
401
|
+
const isWorkspaceCap = ceiling === "workspace_spend_cap_exceeded";
|
|
363
402
|
const window = err?.window === "burst" ? "burst" : err?.window;
|
|
364
|
-
const windowPhrase =
|
|
403
|
+
const windowPhrase = isKeyCap
|
|
404
|
+
? "this key's monthly spend cap"
|
|
405
|
+
: isWorkspaceCap
|
|
406
|
+
? "the workspace's monthly spend cap"
|
|
407
|
+
: window
|
|
408
|
+
? `your ${window} budget window`
|
|
409
|
+
: "your budget";
|
|
410
|
+
// "resets" for a cap (a calendar ceiling that refills on the 1st) and
|
|
411
|
+
// "recovers" for the pool (a window that trickles back) — each the verb that
|
|
412
|
+
// is actually true of the ceiling being described.
|
|
413
|
+
const verb = isKeyCap || isWorkspaceCap ? "reset" : "recover";
|
|
365
414
|
nextSteps.push(waitMs !== undefined && waitMs > 0
|
|
366
|
-
? `wait ~${humanWait(waitMs)} — ${windowPhrase}
|
|
367
|
-
: `wait for ${windowPhrase} to
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
415
|
+
? `wait ~${humanWait(waitMs)} — ${windowPhrase} ${verb}s then`
|
|
416
|
+
: `wait for ${windowPhrase} to ${verb}`);
|
|
417
|
+
if (isKeyCap) {
|
|
418
|
+
// Deliberately NOT "run `cruxy login` for a fresh key". A new credential
|
|
419
|
+
// would carry a new counter, which is cap evasion dressed up as
|
|
420
|
+
// troubleshooting — and the cap exists to bound a leaked key.
|
|
421
|
+
nextSteps.push("or ask an org admin to raise this key's spend cap — a plan upgrade does not lift a per-key ceiling");
|
|
422
|
+
}
|
|
423
|
+
else if (isWorkspaceCap) {
|
|
424
|
+
nextSteps.push("or ask an org admin to raise the workspace's spend cap — a plan upgrade does not lift it");
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
// Only where it is true. A subscription pool is not something a user can add
|
|
428
|
+
// to; a plan change is the only lever, and it is a different action from
|
|
429
|
+
// "top up" with a different place to do it.
|
|
430
|
+
nextSteps.push("or move to a higher plan for a larger allowance");
|
|
431
|
+
}
|
|
372
432
|
return new CruxyError({
|
|
373
433
|
code: ErrorCode.BudgetExhausted,
|
|
374
|
-
title:
|
|
375
|
-
?
|
|
376
|
-
:
|
|
434
|
+
title: isKeyCap
|
|
435
|
+
? "this API key's monthly spend cap is reached"
|
|
436
|
+
: isWorkspaceCap
|
|
437
|
+
? "this workspace's monthly spend cap is reached"
|
|
438
|
+
: window
|
|
439
|
+
? `your ${window} Cruxy budget is exhausted`
|
|
440
|
+
: "your Cruxy budget is exhausted",
|
|
377
441
|
cause: scrubbedMessageOf(underlying),
|
|
378
442
|
nextSteps,
|
|
379
443
|
underlying,
|
|
380
444
|
meta: err
|
|
381
445
|
? {
|
|
446
|
+
...(ceiling !== undefined ? { ceiling } : {}),
|
|
382
447
|
...(err.window !== undefined ? { window: err.window } : {}),
|
|
383
448
|
...(err.resetAt !== undefined ? { resetAt: err.resetAt } : {}),
|
|
384
449
|
...(err.miraAvailable !== undefined
|
|
@@ -1502,9 +1567,18 @@ export function agentIncomplete(info) {
|
|
|
1502
1567
|
* {@link CruxyError}, or `null` if it isn't one. Order matters: specific
|
|
1503
1568
|
* subclasses before the `ApiError` base.
|
|
1504
1569
|
*/
|
|
1505
|
-
export function classifyProviderError(underlying) {
|
|
1506
|
-
if (underlying instanceof AuthError)
|
|
1507
|
-
|
|
1570
|
+
export function classifyProviderError(underlying, ctx = {}) {
|
|
1571
|
+
if (underlying instanceof AuthError) {
|
|
1572
|
+
// An expired credential and a wrong one produce the SAME 401 — the auth gate
|
|
1573
|
+
// refuses both without saying which, so that a prober cannot learn a key was
|
|
1574
|
+
// ever valid. The expiry we recorded at login is the only evidence there is,
|
|
1575
|
+
// and without it a user whose 90-day device login simply ran out is told to
|
|
1576
|
+
// go check a key they never mistyped.
|
|
1577
|
+
const lifetime = classifyCredentialLifetime(ctx.credentialExpiresAt?.(), ctx.now);
|
|
1578
|
+
return lifetime.state === "expired"
|
|
1579
|
+
? authExpired(lifetime.expiresAt, underlying)
|
|
1580
|
+
: authInvalid(underlying);
|
|
1581
|
+
}
|
|
1508
1582
|
if (underlying instanceof RateLimitError)
|
|
1509
1583
|
return apiRateLimit(underlying);
|
|
1510
1584
|
if (underlying instanceof OverloadedError)
|
package/dist/errors/types.js
CHANGED
|
@@ -32,6 +32,23 @@ export const ErrorCode = {
|
|
|
32
32
|
// auth (exit 4)
|
|
33
33
|
AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY",
|
|
34
34
|
AuthInvalid: "CRUXY_E_AUTH_INVALID",
|
|
35
|
+
/**
|
|
36
|
+
* The stored credential's own lifetime has run out.
|
|
37
|
+
*
|
|
38
|
+
* A DISTINCT CODE FROM {@link AuthInvalid} because the two take opposite
|
|
39
|
+
* advice. "Your key is wrong" sends someone to check what they pasted; this
|
|
40
|
+
* one is a key that was right, worked for months, and reached the expiry the
|
|
41
|
+
* server stamped on it at login — the fix is to log in again, and telling that
|
|
42
|
+
* user to double-check their key sends them looking for a mistake they did not
|
|
43
|
+
* make.
|
|
44
|
+
*
|
|
45
|
+
* IT IS DECIDED CLIENT-SIDE, and it has to be. An expired key falls out of the
|
|
46
|
+
* auth gate's query exactly as a revoked or fabricated one does, and answers
|
|
47
|
+
* with the same bare 401 — deliberately, so a prober cannot learn that a key
|
|
48
|
+
* was ever valid. The server will never say "expired", so the only evidence is
|
|
49
|
+
* the expiry we stored when the credential was minted.
|
|
50
|
+
*/
|
|
51
|
+
AuthExpired: "CRUXY_E_AUTH_EXPIRED",
|
|
35
52
|
ForgeAuth: "CRUXY_E_FORGE_AUTH",
|
|
36
53
|
/** A credential could not be persisted with owner-only permissions (C.27c) —
|
|
37
54
|
* e.g. on Windows the store's ACL could not be restricted to the current user
|
|
@@ -296,6 +313,7 @@ const EXIT_CODES = {
|
|
|
296
313
|
[ErrorCode.ConfigInvalid]: 3,
|
|
297
314
|
[ErrorCode.AuthMissingKey]: 4,
|
|
298
315
|
[ErrorCode.AuthInvalid]: 4,
|
|
316
|
+
[ErrorCode.AuthExpired]: 4,
|
|
299
317
|
[ErrorCode.ForgeAuth]: 4,
|
|
300
318
|
[ErrorCode.CredentialsUnprotected]: 4,
|
|
301
319
|
[ErrorCode.GatewayUnreachable]: 5,
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { buildProgram } from "./cli/program.js";
|
|
3
|
+
import { loadConfig, readCredentialMeta } from "./config/index.js";
|
|
3
4
|
import { handleFatal, isCommanderSuccess, isVerbose } from "./errors/index.js";
|
|
4
5
|
async function main() {
|
|
5
6
|
const program = buildProgram();
|
|
@@ -12,5 +13,30 @@ main().catch((err) => {
|
|
|
12
13
|
// with the raw stack shown only under --verbose.
|
|
13
14
|
if (isCommanderSuccess(err))
|
|
14
15
|
process.exit(0);
|
|
15
|
-
handleFatal(err, {
|
|
16
|
+
handleFatal(err, {
|
|
17
|
+
verbose: isVerbose(),
|
|
18
|
+
context: {
|
|
19
|
+
// Resolved LAZILY — the classifier calls this only when the failure turns
|
|
20
|
+
// out to be a 401, so a run that fails any other way never reads config or
|
|
21
|
+
// the credentials store.
|
|
22
|
+
//
|
|
23
|
+
// It is needed because the gateway refuses an expired credential with
|
|
24
|
+
// exactly the 401 a wrong one gets, and deliberately will not say which.
|
|
25
|
+
// The expiry recorded at login is the only thing that can turn "your key
|
|
26
|
+
// is wrong" into "your login expired, run `cruxy login`".
|
|
27
|
+
//
|
|
28
|
+
// Wrapped, because this runs while we are already reporting a failure: an
|
|
29
|
+
// unreadable config or store here must not replace the user's real error
|
|
30
|
+
// with a crash inside the error handler.
|
|
31
|
+
credentialExpiresAt: () => {
|
|
32
|
+
try {
|
|
33
|
+
const { config } = loadConfig();
|
|
34
|
+
return readCredentialMeta(config.model.provider)?.expiresAt;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
16
42
|
});
|
package/dist/limits/cache.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyCredentialLifetime } from "../config/credential-lifetime.js";
|
|
1
2
|
import { reduceLimits } from "./reduce.js";
|
|
2
3
|
/**
|
|
3
4
|
* How long a probe's answer is treated as current. The pool moves when the user
|
|
@@ -9,6 +10,7 @@ const MIN_INTERVAL_MS = 15_000;
|
|
|
9
10
|
export class LimitsCache {
|
|
10
11
|
probe;
|
|
11
12
|
now;
|
|
13
|
+
credentialExpiresAt;
|
|
12
14
|
minIntervalMs;
|
|
13
15
|
state;
|
|
14
16
|
/** The refresh in flight, so concurrent triggers share one request. */
|
|
@@ -23,6 +25,7 @@ export class LimitsCache {
|
|
|
23
25
|
probe, opts = {}) {
|
|
24
26
|
this.probe = probe;
|
|
25
27
|
this.now = opts.now ?? Date.now;
|
|
28
|
+
this.credentialExpiresAt = opts.credentialExpiresAt;
|
|
26
29
|
this.minIntervalMs = opts.minIntervalMs ?? MIN_INTERVAL_MS;
|
|
27
30
|
this.state = probe
|
|
28
31
|
? { status: "pending" }
|
|
@@ -76,12 +79,15 @@ export class LimitsCache {
|
|
|
76
79
|
// moment ago; only report an error when there is nothing else to say.
|
|
77
80
|
if (this.state.status === "ready")
|
|
78
81
|
return;
|
|
79
|
-
this.state = {
|
|
82
|
+
this.state = {
|
|
83
|
+
status: "error",
|
|
84
|
+
reason: classify(err, this.credentialExpiresAt, this.now()),
|
|
85
|
+
};
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
/**
|
|
84
|
-
* Why the probe failed, in the
|
|
90
|
+
* Why the probe failed, in the distinctions a user can act on.
|
|
85
91
|
*
|
|
86
92
|
* Matched on the SDK's error class names rather than `instanceof`, so this stays
|
|
87
93
|
* a pure classification with no import of the transport into a module the TUI
|
|
@@ -89,11 +95,21 @@ export class LimitsCache {
|
|
|
89
95
|
* serve `/limits` at all — an older deployment, or a base URL pointed somewhere
|
|
90
96
|
* else entirely — and telling that user "you are offline" would send them
|
|
91
97
|
* debugging a network that is working fine.
|
|
98
|
+
*
|
|
99
|
+
* A 401 SPLITS ON EVIDENCE THE SERVER DOES NOT PROVIDE. The auth gate refuses an
|
|
100
|
+
* expired credential with exactly the 401 a wrong one gets, and says nothing
|
|
101
|
+
* about which — deliberately, so a prober cannot learn that a key was ever
|
|
102
|
+
* valid. So the split is made from the expiry the CLI recorded at login, and
|
|
103
|
+
* `expired` is claimed ONLY when that stored timestamp has actually passed:
|
|
104
|
+
* absence, or a timestamp this build cannot parse, leaves the answer as
|
|
105
|
+
* `unauthenticated` rather than guessing at a lifetime nobody stated.
|
|
92
106
|
*/
|
|
93
|
-
function classify(err) {
|
|
107
|
+
function classify(err, credentialExpiresAt, now) {
|
|
94
108
|
const e = err;
|
|
95
|
-
if (e?.name === "AuthError")
|
|
96
|
-
|
|
109
|
+
if (e?.name === "AuthError") {
|
|
110
|
+
const lifetime = classifyCredentialLifetime(credentialExpiresAt?.(), now);
|
|
111
|
+
return lifetime.state === "expired" ? "expired" : "unauthenticated";
|
|
112
|
+
}
|
|
97
113
|
if (e?.status === 404 || e?.status === 501)
|
|
98
114
|
return "unsupported";
|
|
99
115
|
return "unreachable";
|