@anyslate/cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -32
- package/package.json +2 -2
- package/src/auth.mjs +189 -32
- package/src/backoff.mjs +178 -0
- package/src/commands/checkpoint.mjs +13 -3
- package/src/commands/doctor.mjs +19 -0
- package/src/commands/hook.mjs +71 -3
- package/src/commands/login.mjs +36 -4
- package/src/commands/logout.mjs +4 -0
- package/src/commands/upload-artifact.mjs +12 -4
- package/src/credentials.mjs +32 -5
- package/src/guard.mjs +381 -0
- package/src/index.mjs +12 -0
- package/src/mcp-client.mjs +16 -5
package/src/backoff.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Retry policy — the client half of the 2026-08-02 database overload.
|
|
2
|
+
//
|
|
3
|
+
// The request tail from that outage is ONE machine running anyslate-cli/0.3.1
|
|
4
|
+
// with a stale refresh token, emitting `POST /oauth/token` followed by
|
|
5
|
+
// `POST /mcp` roughly fifty times a minute, for hours. Nothing in the client
|
|
6
|
+
// ever said "wait": an auth failure re-minted a token and re-issued the call
|
|
7
|
+
// immediately, so the only ceiling on request rate was how fast a process could
|
|
8
|
+
// loop. When the database then answered "overloaded", every client retried at
|
|
9
|
+
// once and deepened the overload.
|
|
10
|
+
//
|
|
11
|
+
// Nothing here is about making a call succeed. It is about making failure cheap
|
|
12
|
+
// for the server:
|
|
13
|
+
//
|
|
14
|
+
// * a HARD attempt cap per process — no path in this CLI may loop unbounded,
|
|
15
|
+
// * exponential backoff with jitter between attempts, so a fleet recovering
|
|
16
|
+
// from one outage does not resynchronise into the next one,
|
|
17
|
+
// * `Retry-After` on a 429/503 OVERRIDES our own schedule, and when the
|
|
18
|
+
// server asks for longer than the whole retry budget we do NOT sleep on it —
|
|
19
|
+
// the wait is handed to the circuit breaker (guard.mjs), the process exits,
|
|
20
|
+
// and the *next* process is the one that honours it. Sleeping instead would
|
|
21
|
+
// hold a hook open across the user's editor.
|
|
22
|
+
//
|
|
23
|
+
// Pure module: no I/O, no ambient timers, injectable clock/randomness, so the
|
|
24
|
+
// policy is unit-testable without a server.
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The default budget, used by the user-initiated commands (`checkpoint`,
|
|
28
|
+
* `upload-artifact`) where an exit code is meaningful and a second or two of
|
|
29
|
+
* waiting is cheaper than a lost capture.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_RETRY = {
|
|
32
|
+
maxAttempts: 3,
|
|
33
|
+
baseDelayMs: 500,
|
|
34
|
+
maxDelayMs: 8_000,
|
|
35
|
+
/** Ceiling on time spent sleeping across ALL attempts of one call. */
|
|
36
|
+
maxTotalDelayMs: 15_000,
|
|
37
|
+
/** A `Retry-After` longer than this is never slept on — the breaker holds it. */
|
|
38
|
+
maxRetryAfterSleepMs: 5_000,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Hooks get NO retries at all.
|
|
43
|
+
*
|
|
44
|
+
* A hook fires on every tool call, in front of the user's editor, and the event
|
|
45
|
+
* it carries is one row in an activity feed. Re-sending it doubles the request
|
|
46
|
+
* rate the incident was made of, in exchange for a row nobody will miss. One
|
|
47
|
+
* attempt, short timeout, move on.
|
|
48
|
+
*/
|
|
49
|
+
export const HOOK_RETRY = {
|
|
50
|
+
...DEFAULT_RETRY,
|
|
51
|
+
maxAttempts: 1,
|
|
52
|
+
maxTotalDelayMs: 0,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Why a call failed, in the only four buckets that change what we do next.
|
|
57
|
+
*
|
|
58
|
+
* auth — 401/invalid_token. One paired refresh may fix it; repetition
|
|
59
|
+
* never will, so this is what trips the breaker.
|
|
60
|
+
* throttled — 429/503. The server named a wait; honour it, never retry inside it.
|
|
61
|
+
* transient — 5xx, or the request never landed. Worth one backed-off retry.
|
|
62
|
+
* fatal — 4xx we caused (bad payload, unknown kind, denied handle). The
|
|
63
|
+
* server already spent the work; re-sending is pure waste.
|
|
64
|
+
*
|
|
65
|
+
* @param {{ok?: boolean, status?: number, networkError?: boolean}} res a callTool result
|
|
66
|
+
* @returns {'auth'|'throttled'|'transient'|'fatal'|null} null when the call succeeded
|
|
67
|
+
*/
|
|
68
|
+
export function classifyFailure(res) {
|
|
69
|
+
if (!res || res.ok) return null;
|
|
70
|
+
if (res.networkError) return 'transient';
|
|
71
|
+
const status = Number(res.status);
|
|
72
|
+
if (status === 401) return 'auth';
|
|
73
|
+
if (status === 429 || status === 503) return 'throttled';
|
|
74
|
+
if (status === 0 || status >= 500) return 'transient';
|
|
75
|
+
return 'fatal';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The wait the server asked for, in milliseconds.
|
|
80
|
+
*
|
|
81
|
+
* `callTool` already parses `Retry-After` and the body's `retry_after_seconds`
|
|
82
|
+
* into `retryAfterSeconds` (both the transport 429 and the tool-level one), so
|
|
83
|
+
* this only has to normalise it.
|
|
84
|
+
*
|
|
85
|
+
* @param {{retryAfterSeconds?: number|null}} res
|
|
86
|
+
* @returns {number|null}
|
|
87
|
+
*/
|
|
88
|
+
export function retryAfterMsOf(res) {
|
|
89
|
+
const seconds = Number(res?.retryAfterSeconds);
|
|
90
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
|
91
|
+
return Math.round(seconds * 1000);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Exponential backoff with jitter.
|
|
96
|
+
*
|
|
97
|
+
* Equal jitter (half fixed, half random) rather than full jitter: it still
|
|
98
|
+
* decorrelates a fleet of clients that all failed on the same server-side
|
|
99
|
+
* second, while guaranteeing that attempt N really does wait longer than
|
|
100
|
+
* attempt N-1. Full jitter can draw ~0ms and re-send instantly, which is the
|
|
101
|
+
* behaviour this module exists to remove.
|
|
102
|
+
*
|
|
103
|
+
* @param {number} attempt 1-based; the delay BEFORE attempt+1
|
|
104
|
+
* @param {typeof DEFAULT_RETRY} policy
|
|
105
|
+
* @param {() => number} [random]
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
export function backoffDelayMs(attempt, policy = DEFAULT_RETRY, random = Math.random) {
|
|
109
|
+
const exponential = policy.baseDelayMs * 2 ** Math.max(0, attempt - 1);
|
|
110
|
+
const capped = Math.min(policy.maxDelayMs, exponential);
|
|
111
|
+
return Math.round(capped / 2 + random() * (capped / 2));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The whole retry decision for one failed attempt.
|
|
116
|
+
*
|
|
117
|
+
* Returns `retry: false` for every reason a request must NOT be repeated, and
|
|
118
|
+
* says which one — the caller records that reason, so a machine that stops
|
|
119
|
+
* retrying can explain itself instead of just going quiet.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} opts
|
|
122
|
+
* @param {'auth'|'throttled'|'transient'|'fatal'|null} opts.kind
|
|
123
|
+
* @param {number} opts.attempt how many attempts have already been made
|
|
124
|
+
* @param {number} [opts.spentDelayMs] time already slept on this call
|
|
125
|
+
* @param {number|null} [opts.retryAfterMs]
|
|
126
|
+
* @param {typeof DEFAULT_RETRY} [opts.policy]
|
|
127
|
+
* @param {() => number} [opts.random]
|
|
128
|
+
* @returns {{retry: boolean, delayMs: number, reason: string}}
|
|
129
|
+
*/
|
|
130
|
+
export function planRetry({
|
|
131
|
+
kind,
|
|
132
|
+
attempt,
|
|
133
|
+
spentDelayMs = 0,
|
|
134
|
+
retryAfterMs = null,
|
|
135
|
+
policy = DEFAULT_RETRY,
|
|
136
|
+
random = Math.random,
|
|
137
|
+
}) {
|
|
138
|
+
if (kind !== 'throttled' && kind !== 'transient') {
|
|
139
|
+
return { retry: false, delayMs: 0, reason: kind === 'auth' ? 'auth' : 'not_retryable' };
|
|
140
|
+
}
|
|
141
|
+
if (attempt >= policy.maxAttempts) {
|
|
142
|
+
return { retry: false, delayMs: 0, reason: 'attempt_cap' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A named wait always wins over our schedule — but only if we can afford to
|
|
146
|
+
// sit through it. A server that says "60s" gets an exiting process and a
|
|
147
|
+
// persisted cool-down, not a hook blocking the editor for a minute.
|
|
148
|
+
if (retryAfterMs != null) {
|
|
149
|
+
if (retryAfterMs > policy.maxRetryAfterSleepMs) {
|
|
150
|
+
return { retry: false, delayMs: 0, reason: 'retry_after_too_long' };
|
|
151
|
+
}
|
|
152
|
+
if (spentDelayMs + retryAfterMs > policy.maxTotalDelayMs) {
|
|
153
|
+
return { retry: false, delayMs: 0, reason: 'delay_budget' };
|
|
154
|
+
}
|
|
155
|
+
return { retry: true, delayMs: retryAfterMs, reason: 'retry_after' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const delayMs = backoffDelayMs(attempt, policy, random);
|
|
159
|
+
if (spentDelayMs + delayMs > policy.maxTotalDelayMs) {
|
|
160
|
+
return { retry: false, delayMs: 0, reason: 'delay_budget' };
|
|
161
|
+
}
|
|
162
|
+
return { retry: true, delayMs, reason: 'backoff' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Deliberately a REF'd timer. An `unref`ed one lets Node drain its event loop
|
|
167
|
+
* and exit mid-backoff, which would turn "wait, then retry" into "exit 0 having
|
|
168
|
+
* silently dropped the call". The bound on how long this can hold a process is
|
|
169
|
+
* `maxTotalDelayMs`, not the timer flag.
|
|
170
|
+
*
|
|
171
|
+
* @param {number} ms
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
export function sleep(ms) {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
setTimeout(resolve, ms);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// stdout carries results only. Every error goes to stderr.
|
|
13
13
|
|
|
14
14
|
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { callToolWithAuth, formatFailure } from '../auth.mjs';
|
|
16
|
+
import { markNotified } from '../guard.mjs';
|
|
17
17
|
import { recordRun } from '../runlog.mjs';
|
|
18
18
|
import { VERSION } from '../version.mjs';
|
|
19
19
|
import { makeIo } from '../io.mjs';
|
|
@@ -84,8 +84,18 @@ export async function runCheckpoint(argv, deps = {}) {
|
|
|
84
84
|
fetchImpl: deps.fetchImpl,
|
|
85
85
|
});
|
|
86
86
|
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
87
|
+
|
|
88
|
+
// A paused breaker or the local ceiling means no request was made, so there
|
|
89
|
+
// is no round trip to record. Unlike `hook` this is a command the user just
|
|
90
|
+
// typed, so it always says why rather than staying quiet.
|
|
91
|
+
if (res.breakerOpen || res.rateCapped) {
|
|
92
|
+
err.write(formatFailure('anyslate checkpoint', res));
|
|
93
|
+
if (res.breakerOpen) markNotified(env);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
if (!res.ok) {
|
|
88
|
-
const message =
|
|
98
|
+
const message = formatFailure('anyslate checkpoint', res);
|
|
89
99
|
err.write(message);
|
|
90
100
|
recordRun(
|
|
91
101
|
{
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from '../config.mjs';
|
|
28
28
|
import { checkUrlShape, hasWriteScope, isValidTokenFormat, probeVerify, tokenPreview } from '../verify.mjs';
|
|
29
29
|
import { hasOauthCredentials, isExpired, minutesUntilExpiry, resolveBearer } from '../auth.mjs';
|
|
30
|
+
import { breakerGate, guardPath } from '../guard.mjs';
|
|
30
31
|
import { lastRunPath, readLastRun } from '../runlog.mjs';
|
|
31
32
|
import { callTool } from '../mcp-client.mjs';
|
|
32
33
|
import { VERSION, USER_AGENT } from '../version.mjs';
|
|
@@ -128,6 +129,24 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// ---- 1b. Circuit breaker ------------------------------------------------
|
|
133
|
+
// Pure local state, so it is answerable when the service is down — which is
|
|
134
|
+
// exactly when it will be open. It sits this early because "capture is paused
|
|
135
|
+
// and no request is being made" reframes every check below it: the token may
|
|
136
|
+
// be perfect and capture still dead until the pause expires or `login` runs.
|
|
137
|
+
// `doctor` itself is never gated by it; diagnosing must always be possible.
|
|
138
|
+
const pause = breakerGate({ cfg, env });
|
|
139
|
+
if (pause.open) {
|
|
140
|
+
add(
|
|
141
|
+
WARN,
|
|
142
|
+
'breaker',
|
|
143
|
+
`Capture is paused until ${pause.until} (${pause.reason}${pause.detail ? `: ${pause.detail}` : ''}) — no requests are being sent.`,
|
|
144
|
+
pause.reason === 'auth'
|
|
145
|
+
? 'Run `anyslate login` to sign in again; that clears the pause immediately.'
|
|
146
|
+
: `The pause clears itself, or delete ${guardPath(env)} to clear it now.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
// ---- 2. Token present --------------------------------------------------
|
|
132
151
|
// "Present" now has three shapes: an env/static token, a live OAuth access
|
|
133
152
|
// token, or OAuth credentials whose access token has aged out but which carry
|
package/src/commands/hook.mjs
CHANGED
|
@@ -17,10 +17,19 @@
|
|
|
17
17
|
// Tool-level errors (HTTP 200 + result.isError) now count as failures and are
|
|
18
18
|
// therefore visible to --strict — previously a 403 handle denial printed
|
|
19
19
|
// nothing and exited 0.
|
|
20
|
+
//
|
|
21
|
+
// COST CEILING (2026-08-02). This command is the one the incident was made of:
|
|
22
|
+
// PostToolUse fires per tool call, so a busy session runs it dozens of times a
|
|
23
|
+
// minute, and every run is a fresh process with no memory of the last. It is
|
|
24
|
+
// therefore the strictest caller in the CLI — one attempt, never a retry, a
|
|
25
|
+
// short timeout, and a persisted breaker that makes a hopeless call cost the
|
|
26
|
+
// server nothing at all. A dropped activity row is one line in a feed; a hook
|
|
27
|
+
// that retries is a doubled request rate in front of the user's editor.
|
|
20
28
|
|
|
21
29
|
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
30
|
+
import { callToolWithAuth, formatFailure } from '../auth.mjs';
|
|
31
|
+
import { HOOK_RETRY } from '../backoff.mjs';
|
|
32
|
+
import { markNotified } from '../guard.mjs';
|
|
24
33
|
import { buildHookSubmission, parseHookEvent } from '../hooks.mjs';
|
|
25
34
|
import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
|
|
26
35
|
import { recordRun, shouldEscalate, escalationPayload } from '../runlog.mjs';
|
|
@@ -29,6 +38,27 @@ import { makeIo } from '../io.mjs';
|
|
|
29
38
|
|
|
30
39
|
const ALLOWED = new Set(['session-start', 'post-tool-use', 'stop']);
|
|
31
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Total budget for the two requests a submission costs (initialize +
|
|
43
|
+
* tools/call). Deliberately far below the 15s every other command gets: a hook
|
|
44
|
+
* runs in front of a human, and a slow server must not become a slow editor.
|
|
45
|
+
* `ANYSLATE_HOOK_TIMEOUT_MS` raises it for pathologically slow links.
|
|
46
|
+
*/
|
|
47
|
+
export const HOOK_TIMEOUT_MS = 5_000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How long a hook will queue behind another process's token refresh. The shared
|
|
51
|
+
* default is 10s, which on a machine where every refresh is failing means every
|
|
52
|
+
* hook waits the full ten seconds to learn nothing.
|
|
53
|
+
*/
|
|
54
|
+
export const HOOK_LOCK_WAIT_MS = 2_000;
|
|
55
|
+
|
|
56
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
57
|
+
function hookTimeoutMs(env) {
|
|
58
|
+
const raw = Number(env.ANYSLATE_HOOK_TIMEOUT_MS);
|
|
59
|
+
return Number.isFinite(raw) && raw > 0 ? raw : HOOK_TIMEOUT_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
/**
|
|
33
63
|
* @param {string[]} argv arguments after `hook`
|
|
34
64
|
* @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
|
|
@@ -107,10 +137,31 @@ export async function runHook(argv, deps = {}) {
|
|
|
107
137
|
toolName: 'activity_submit',
|
|
108
138
|
args,
|
|
109
139
|
fetchImpl: deps.fetchImpl,
|
|
140
|
+
timeoutMs: hookTimeoutMs(env),
|
|
141
|
+
retry: HOOK_RETRY,
|
|
142
|
+
lockWaitMs: HOOK_LOCK_WAIT_MS,
|
|
110
143
|
});
|
|
111
144
|
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
145
|
+
|
|
146
|
+
// A locally-refused call made no request, so there is no run outcome to
|
|
147
|
+
// record — the run log is a record of round trips, and filling it with
|
|
148
|
+
// "we did not try" would bury the failure that caused the pause. The user
|
|
149
|
+
// is told once per pause; after that a paused hook is completely silent,
|
|
150
|
+
// which is the whole contract: cost the server nothing, cost the user
|
|
151
|
+
// nothing, keep the fix on screen exactly once.
|
|
152
|
+
if (res.breakerOpen || res.rateCapped) {
|
|
153
|
+
if (!res.breakerOpen || res.breaker?.noticeDue) {
|
|
154
|
+
err.write(formatFailure(prefix, res));
|
|
155
|
+
if (res.breakerOpen) {
|
|
156
|
+
markNotified(env);
|
|
157
|
+
escalatePause(out, sub, res);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return strict ? 1 : 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
112
163
|
if (!res.ok) {
|
|
113
|
-
return fail(
|
|
164
|
+
return fail(formatFailure(prefix, res), {
|
|
114
165
|
status: res.status,
|
|
115
166
|
isError: !!res.isError,
|
|
116
167
|
networkError: !!res.networkError,
|
|
@@ -142,6 +193,23 @@ function escalateIfNeeded(out, sub, state, error) {
|
|
|
142
193
|
out.write(escalationPayload({ ...state, error }));
|
|
143
194
|
}
|
|
144
195
|
|
|
196
|
+
/**
|
|
197
|
+
* A paused breaker is the one state where stderr is guaranteed to be read by
|
|
198
|
+
* nobody AND the fix is a single command. Say it through the channel Claude
|
|
199
|
+
* Code renders, once, at the only point in a session where acting on it is
|
|
200
|
+
* cheap.
|
|
201
|
+
*/
|
|
202
|
+
function escalatePause(out, sub, res) {
|
|
203
|
+
if (sub !== 'session-start') return;
|
|
204
|
+
out.write(
|
|
205
|
+
escalationPayload({
|
|
206
|
+
consecutiveFailures: 0,
|
|
207
|
+
failingSince: null,
|
|
208
|
+
error: typeof res.data === 'string' ? res.data : 'capture paused',
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
145
213
|
/** @param {string[]} argv */
|
|
146
214
|
function parseFlags(argv) {
|
|
147
215
|
const out = { strict: false };
|
package/src/commands/login.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { join } from 'node:path';
|
|
|
26
26
|
import { DEFAULT_API_URL, anyslateDir, isCaptureDisabled, normalizeApiRoot } from '../config.mjs';
|
|
27
27
|
import { checkUrlShape, isValidTokenFormat, probeVerify, scopeWarning, tokenPreview } from '../verify.mjs';
|
|
28
28
|
import { readConfigFile, writeConfigFile } from '../credentials.mjs';
|
|
29
|
+
import { clearBreaker } from '../guard.mjs';
|
|
29
30
|
import {
|
|
30
31
|
DEFAULT_CALLBACK_TIMEOUT_S,
|
|
31
32
|
REGISTERED_REDIRECT_URI,
|
|
@@ -68,14 +69,39 @@ export async function runLogin(argv, deps = {}) {
|
|
|
68
69
|
// ---------------------------------------------------------------------------
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
|
-
* Resolve the service root
|
|
72
|
-
*
|
|
73
|
-
*
|
|
72
|
+
* Resolve the service root for `login`.
|
|
73
|
+
*
|
|
74
|
+
* `--api-url` wins. When it is ABSENT the answer is always production — the
|
|
75
|
+
* stored value is deliberately NOT inherited.
|
|
76
|
+
*
|
|
77
|
+
* Inheriting it (the pre-0.3.1 behaviour) meant that once anything had written
|
|
78
|
+
* a non-production apiUrl — a past `--api-url` run, or a stale config from an
|
|
79
|
+
* older build — a bare `anyslate login` silently kept signing in to that
|
|
80
|
+
* environment. A user who typed the shortest possible command got a non-obvious
|
|
81
|
+
* endpoint, and a non-production hostname ended up in the URL printed to the
|
|
82
|
+
* terminal and opened in a browser. Sign-in is the one place the target must be
|
|
83
|
+
* explicit rather than sticky: pass `--api-url` for dev or local, omit it for
|
|
84
|
+
* production. Every other command still reads the stored apiUrl as before —
|
|
85
|
+
* this override applies to `login` only.
|
|
74
86
|
*/
|
|
75
87
|
function resolveRoot(flags, existing, io) {
|
|
76
|
-
const
|
|
88
|
+
const stored = existing.apiUrl ?? existing.api_url ?? null;
|
|
89
|
+
const merged = flags.apiUrl ?? DEFAULT_API_URL;
|
|
77
90
|
const shape = checkUrlShape(merged);
|
|
78
91
|
const root = shape.ok ? shape.root : normalizeApiRoot(merged);
|
|
92
|
+
|
|
93
|
+
// Say so when we are switching them off a stored non-production endpoint, so
|
|
94
|
+
// the change of target is never silent in either direction.
|
|
95
|
+
if (!flags.apiUrl && stored) {
|
|
96
|
+
const storedRoot = normalizeApiRoot(stored);
|
|
97
|
+
if (storedRoot && storedRoot !== root) {
|
|
98
|
+
io.out.write(
|
|
99
|
+
`anyslate: signing in to production (${root}).\n` +
|
|
100
|
+
`anyslate: your config points at ${storedRoot} — pass \`--api-url ${storedRoot}\` to sign in there instead.\n`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
79
105
|
if (shape.ok && shape.normalized) {
|
|
80
106
|
io.out.write(
|
|
81
107
|
`anyslate: apiUrl "${shape.original}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
|
|
@@ -174,6 +200,10 @@ async function runTokenLogin(flags, deps) {
|
|
|
174
200
|
err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
|
|
175
201
|
return 1;
|
|
176
202
|
}
|
|
203
|
+
// A pause earned by the credential the user has just replaced must not
|
|
204
|
+
// outlive it — otherwise the documented fix ("run `anyslate login`") appears
|
|
205
|
+
// to do nothing for the next fifteen minutes.
|
|
206
|
+
clearBreaker(env);
|
|
177
207
|
|
|
178
208
|
out.write(`anyslate: wrote ${path}\n`);
|
|
179
209
|
out.write(` apiUrl: ${next.apiUrl}\n`);
|
|
@@ -361,6 +391,8 @@ async function runOauthLogin(flags, deps) {
|
|
|
361
391
|
err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
|
|
362
392
|
return 1;
|
|
363
393
|
}
|
|
394
|
+
// See the static path: a fresh sign-in cancels the pause its predecessor earned.
|
|
395
|
+
clearBreaker(env);
|
|
364
396
|
|
|
365
397
|
out.write(`anyslate: wrote ${path}\n`);
|
|
366
398
|
out.write(` apiUrl: ${root}\n`);
|
package/src/commands/logout.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { join } from 'node:path';
|
|
23
23
|
import { anyslateDir, loadConfig } from '../config.mjs';
|
|
24
24
|
import { readConfigFile, writeConfigFile } from '../credentials.mjs';
|
|
25
|
+
import { clearBreaker } from '../guard.mjs';
|
|
25
26
|
import { discover, revocationEndpointFor, revokeToken } from '../oauth.mjs';
|
|
26
27
|
import { makeIo } from '../io.mjs';
|
|
27
28
|
|
|
@@ -101,6 +102,9 @@ export async function runLogout(argv = [], deps = {}) {
|
|
|
101
102
|
err.write(`anyslate logout: could not update ${path} (${e?.message ?? e})\n`);
|
|
102
103
|
return 1;
|
|
103
104
|
}
|
|
105
|
+
// The breaker is state about a credential that no longer exists. Leaving it
|
|
106
|
+
// behind would make the next `login` look broken.
|
|
107
|
+
clearBreaker(env);
|
|
104
108
|
|
|
105
109
|
const cleared = [hadOauth && 'OAuth session', hadStatic && 'static token'].filter(Boolean).join(' and ');
|
|
106
110
|
out.write(`anyslate: cleared the ${cleared} from ${path}.\n`);
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
import { readFileSync } from 'node:fs';
|
|
17
17
|
import { basename } from 'node:path';
|
|
18
18
|
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
19
|
+
import { callToolWithAuth, formatFailure } from '../auth.mjs';
|
|
20
|
+
import { markNotified } from '../guard.mjs';
|
|
21
21
|
import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
|
|
22
22
|
import { recordRun } from '../runlog.mjs';
|
|
23
23
|
import { VERSION } from '../version.mjs';
|
|
@@ -143,9 +143,17 @@ export async function runUploadArtifact(argv, deps = {}) {
|
|
|
143
143
|
fetchImpl: deps.fetchImpl,
|
|
144
144
|
});
|
|
145
145
|
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
146
|
+
|
|
147
|
+
// No request left the machine, so nothing is recorded as a round trip. See
|
|
148
|
+
// the same branch in checkpoint.mjs.
|
|
149
|
+
if (res.breakerOpen || res.rateCapped) {
|
|
150
|
+
err.write(formatFailure('anyslate upload-artifact', res));
|
|
151
|
+
if (res.breakerOpen) markNotified(env);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
146
155
|
if (!res.ok) {
|
|
147
|
-
const message =
|
|
148
|
-
formatAuthFailure('anyslate upload-artifact', res) ?? formatCallFailure('anyslate upload-artifact', res);
|
|
156
|
+
const message = formatFailure('anyslate upload-artifact', res);
|
|
149
157
|
err.write(message);
|
|
150
158
|
recordRun(
|
|
151
159
|
{
|
package/src/credentials.mjs
CHANGED
|
@@ -40,12 +40,21 @@ export function cliConfigPath(env = process.env) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
+
* Read any JSON file out of `~/.anyslate`, tolerating every way it can be
|
|
44
|
+
* missing or malformed.
|
|
45
|
+
*
|
|
46
|
+
* Generic because the credential file is no longer the only state the CLI keeps
|
|
47
|
+
* across processes: the circuit breaker (guard.mjs) has to survive a process
|
|
48
|
+
* exit for the same reason a refresh token does, and it must inherit the same
|
|
49
|
+
* atomic-write discipline rather than growing its own.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} name file name inside the AnySlate directory
|
|
43
52
|
* @param {NodeJS.ProcessEnv} [env]
|
|
44
53
|
* @returns {Record<string, any>}
|
|
45
54
|
*/
|
|
46
|
-
export function
|
|
55
|
+
export function readJsonFile(name, env = process.env) {
|
|
47
56
|
try {
|
|
48
|
-
const parsed = JSON.parse(readFileSync(
|
|
57
|
+
const parsed = JSON.parse(readFileSync(join(anyslateDir(env), name), 'utf8'));
|
|
49
58
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
50
59
|
} catch {
|
|
51
60
|
return {};
|
|
@@ -55,15 +64,16 @@ export function readConfigFile(env = process.env) {
|
|
|
55
64
|
/**
|
|
56
65
|
* Serialize + rename. Never writes the target path in place.
|
|
57
66
|
*
|
|
67
|
+
* @param {string} name
|
|
58
68
|
* @param {Record<string, any>} next
|
|
59
69
|
* @param {NodeJS.ProcessEnv} [env]
|
|
60
70
|
* @returns {string} the path written
|
|
61
71
|
*/
|
|
62
|
-
export function
|
|
72
|
+
export function writeJsonFile(name, next, env = process.env) {
|
|
63
73
|
const dir = anyslateDir(env);
|
|
64
74
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
65
|
-
const path = join(dir,
|
|
66
|
-
const tmp = join(dir, `${
|
|
75
|
+
const path = join(dir, name);
|
|
76
|
+
const tmp = join(dir, `${name}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
67
77
|
try {
|
|
68
78
|
writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
69
79
|
renameSync(tmp, path);
|
|
@@ -78,6 +88,23 @@ export function writeConfigFile(next, env = process.env) {
|
|
|
78
88
|
return path;
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
/**
|
|
92
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
93
|
+
* @returns {Record<string, any>}
|
|
94
|
+
*/
|
|
95
|
+
export function readConfigFile(env = process.env) {
|
|
96
|
+
return readJsonFile(CONFIG_FILE, env);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {Record<string, any>} next
|
|
101
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
102
|
+
* @returns {string} the path written
|
|
103
|
+
*/
|
|
104
|
+
export function writeConfigFile(next, env = process.env) {
|
|
105
|
+
return writeJsonFile(CONFIG_FILE, next, env);
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
/**
|
|
82
109
|
* Read-modify-write. `mutate` receives the CURRENT on-disk object (not a
|
|
83
110
|
* snapshot the caller took earlier) and returns the object to persist.
|