@anyslate/cli 0.3.1 → 0.4.1

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,29 @@ 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 credential refresh + the two requests a submission costs
43
+ * (initialize + tools/call). Deliberately far below the 15s every other
44
+ * command gets: a hook runs in front of a human, and a slow server must not
45
+ * become a slow editor. `ANYSLATE_HOOK_TIMEOUT_MS` raises it for pathologically
46
+ * slow links. Auth (lock wait / peer poll / refresh HTTP) shares this budget
47
+ * with the MCP call — it is not stacked on top.
48
+ */
49
+ export const HOOK_TIMEOUT_MS = 5_000;
50
+
51
+ /**
52
+ * How long a hook will queue behind another process's token refresh. Kept well
53
+ * below HOOK_TIMEOUT_MS so an unlocked loser still has time for a short peer
54
+ * poll + refresh HTTP + the activity_submit call inside the same wall budget.
55
+ */
56
+ export const HOOK_LOCK_WAIT_MS = 1_500;
57
+
58
+ /** @param {NodeJS.ProcessEnv} env */
59
+ function hookTimeoutMs(env) {
60
+ const raw = Number(env.ANYSLATE_HOOK_TIMEOUT_MS);
61
+ return Number.isFinite(raw) && raw > 0 ? raw : HOOK_TIMEOUT_MS;
62
+ }
63
+
32
64
  /**
33
65
  * @param {string[]} argv arguments after `hook`
34
66
  * @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
@@ -107,10 +139,31 @@ export async function runHook(argv, deps = {}) {
107
139
  toolName: 'activity_submit',
108
140
  args,
109
141
  fetchImpl: deps.fetchImpl,
142
+ timeoutMs: hookTimeoutMs(env),
143
+ retry: HOOK_RETRY,
144
+ lockWaitMs: HOOK_LOCK_WAIT_MS,
110
145
  });
111
146
  if (res.authWarning) err.write(`${res.authWarning}\n`);
147
+
148
+ // A locally-refused call made no request, so there is no run outcome to
149
+ // record — the run log is a record of round trips, and filling it with
150
+ // "we did not try" would bury the failure that caused the pause. The user
151
+ // is told once per pause; after that a paused hook is completely silent,
152
+ // which is the whole contract: cost the server nothing, cost the user
153
+ // nothing, keep the fix on screen exactly once.
154
+ if (res.breakerOpen || res.rateCapped) {
155
+ if (!res.breakerOpen || res.breaker?.noticeDue) {
156
+ err.write(formatFailure(prefix, res));
157
+ if (res.breakerOpen) {
158
+ markNotified(env);
159
+ escalatePause(out, sub, res);
160
+ }
161
+ }
162
+ return strict ? 1 : 0;
163
+ }
164
+
112
165
  if (!res.ok) {
113
- return fail(formatAuthFailure(prefix, res) ?? formatCallFailure(prefix, res), {
166
+ return fail(formatFailure(prefix, res), {
114
167
  status: res.status,
115
168
  isError: !!res.isError,
116
169
  networkError: !!res.networkError,
@@ -142,6 +195,23 @@ function escalateIfNeeded(out, sub, state, error) {
142
195
  out.write(escalationPayload({ ...state, error }));
143
196
  }
144
197
 
198
+ /**
199
+ * A paused breaker is the one state where stderr is guaranteed to be read by
200
+ * nobody AND the fix is a single command. Say it through the channel Claude
201
+ * Code renders, once, at the only point in a session where acting on it is
202
+ * cheap.
203
+ */
204
+ function escalatePause(out, sub, res) {
205
+ if (sub !== 'session-start') return;
206
+ out.write(
207
+ escalationPayload({
208
+ consecutiveFailures: 0,
209
+ failingSince: null,
210
+ error: typeof res.data === 'string' ? res.data : 'capture paused',
211
+ }),
212
+ );
213
+ }
214
+
145
215
  /** @param {string[]} argv */
146
216
  function parseFlags(argv) {
147
217
  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,
@@ -199,6 +200,10 @@ async function runTokenLogin(flags, deps) {
199
200
  err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
200
201
  return 1;
201
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);
202
207
 
203
208
  out.write(`anyslate: wrote ${path}\n`);
204
209
  out.write(` apiUrl: ${next.apiUrl}\n`);
@@ -386,6 +391,8 @@ async function runOauthLogin(flags, deps) {
386
391
  err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
387
392
  return 1;
388
393
  }
394
+ // See the static path: a fresh sign-in cancels the pause its predecessor earned.
395
+ clearBreaker(env);
389
396
 
390
397
  out.write(`anyslate: wrote ${path}\n`);
391
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.