@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.
@@ -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 { formatCallFailure } from '../mcp-client.mjs';
16
- import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
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 = formatAuthFailure('anyslate checkpoint', res) ?? formatCallFailure('anyslate checkpoint', res);
98
+ const message = formatFailure('anyslate checkpoint', res);
89
99
  err.write(message);
90
100
  recordRun(
91
101
  {
@@ -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
@@ -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 { formatCallFailure } from '../mcp-client.mjs';
23
- import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
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(formatAuthFailure(prefix, res) ?? formatCallFailure(prefix, res), {
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 };
@@ -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 exactly as the static path always has: an explicit
72
- * --api-url wins, else the stored value is inherited AND re-normalized so a
73
- * previously-broken `/mcp`-suffixed apiUrl cannot survive a re-login.
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 merged = flags.apiUrl ?? existing.apiUrl ?? existing.api_url ?? DEFAULT_API_URL;
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`);
@@ -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 { formatCallFailure } from '../mcp-client.mjs';
20
- import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
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
  {
@@ -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 readConfigFile(env = process.env) {
55
+ export function readJsonFile(name, env = process.env) {
47
56
  try {
48
- const parsed = JSON.parse(readFileSync(cliConfigPath(env), 'utf8'));
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 writeConfigFile(next, env = process.env) {
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, CONFIG_FILE);
66
- const tmp = join(dir, `${CONFIG_FILE}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
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.