@anyforge/control 0.2.4 → 0.3.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.
@@ -1,524 +0,0 @@
1
- import { promises as fs } from 'fs';
2
- import { execFileSync } from 'child_process';
3
- import { homedir } from 'os';
4
- import { join } from 'path';
5
- import prompts from 'prompts';
6
- import { dim, green, yellow } from 'kleur/colors';
7
- /**
8
- * Patch local agent configurations so they route through AnyForge.
9
- *
10
- * Scope:
11
- * - 'global' → ~/.claude/settings.json, plus optional shell-rc
12
- * hints for Cursor / Cline / Continue (which don't have a
13
- * repo-local config we can target).
14
- * - 'repo' → <repoRoot>/.claude/settings.local.json (gitignored
15
- * by Claude Code's own conventions) plus <repoRoot>/.env. We
16
- * skip Cursor/Cline shell-rc patches in this scope so the
17
- * install stays scoped to the repo with no global side effects.
18
- *
19
- * Auth header: Claude Code has two env vars for credentials.
20
- * - ANTHROPIC_API_KEY → sent as `X-Api-Key`. Triggers an "Auth
21
- * conflict: Using ANTHROPIC_API_KEY instead of Anthropic
22
- * Console key" warning when an OAuth login is also present,
23
- * and (per the docs) can cascade into a "selected model may
24
- * not exist" error.
25
- * - ANTHROPIC_AUTH_TOKEN → sent as `Authorization: Bearer …`.
26
- * Documented for "routing through an LLM gateway or proxy"
27
- * and matches AnyForge's bearer-token shape.
28
- * We use ANTHROPIC_AUTH_TOKEN. We also explicitly delete any
29
- * stale ANTHROPIC_API_KEY from the env map on every run so users
30
- * upgrading from earlier installs don't keep both set.
31
- *
32
- * IMPORTANT precedence caveat: ANTHROPIC_AUTH_TOKEN (#2 in Claude
33
- * Code's auth order) OUT-RANKS a Claude Pro/Max subscription login
34
- * (#6). Patching it for a subscription user silently disables their
35
- * subscription and points Claude Code at the BYOK-only proxy, which
36
- * has no upstream key for them → "please login". The caller
37
- * (`init`) therefore gates patching behind `detectClaudeSubscription()`
38
- * and steers subscription users to the MCP server instead. We always
39
- * write a `.anyforge-bak` before patching so `logout` can revert.
40
- *
41
- * The actual API key is inlined into the env map. We used to write
42
- * a `${ANYFORGE_API_KEY}` placeholder and rely on a shell export,
43
- * which (a) needed a fresh shell to take effect and (b) failed
44
- * silently when the export never landed because the user only had
45
- * Claude Code installed.
46
- */
47
- // Sentinel markers wrapping the block we own in .env / shell-rc files,
48
- // so re-running replaces in place and `logout` can strip it cleanly.
49
- const SENTINEL_BEGIN = '# === AnyForge Control — added by `anyforge init` ===';
50
- const SENTINEL_END = '# === end AnyForge Control ===';
51
- // Suffix for the pre-patch backup of a Claude Code settings file.
52
- const BACKUP_SUFFIX = '.anyforge-bak';
53
- // The Claude.ai subscription org types that an ANTHROPIC_AUTH_TOKEN
54
- // patch would shadow. A Console (API-key) login does not carry these,
55
- // so BYOK users are unaffected.
56
- const SUBSCRIPTION_ORG_TYPES = /claude_(max|pro|team|enterprise)/i;
57
- // Env keys we set when enabling Claude Code → AnyForge usage telemetry
58
- // (OpenTelemetry metrics only — no prompt/response content). Removed on
59
- // `logout` when the OTLP endpoint points at AnyForge.
60
- const TELEMETRY_KEYS = [
61
- 'CLAUDE_CODE_ENABLE_TELEMETRY',
62
- 'OTEL_METRICS_EXPORTER',
63
- 'OTEL_EXPORTER_OTLP_PROTOCOL',
64
- 'OTEL_EXPORTER_OTLP_ENDPOINT',
65
- 'OTEL_EXPORTER_OTLP_HEADERS',
66
- 'OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE',
67
- ];
68
- export async function detectAndPatchConfigs(opts) {
69
- const summary = [];
70
- const cc = await patchClaudeCode(opts);
71
- if (cc)
72
- summary.push(cc);
73
- if (opts.scope === 'repo') {
74
- const env = await patchRepoEnv(opts);
75
- if (env)
76
- summary.push(env);
77
- return summary;
78
- }
79
- // Global scope only — Cursor / Cline have no per-repo config we
80
- // can usefully target, so we leave them out of the repo flow.
81
- for (const detector of OPTIONAL_GLOBAL_DETECTORS) {
82
- const found = await detector.detect();
83
- if (!found)
84
- continue;
85
- const proceed = await prompts({
86
- type: 'confirm',
87
- name: 'go',
88
- message: `Patch ${detector.label} via shell rc (${dim(found.path)}) ?`,
89
- initial: true,
90
- });
91
- if (!proceed.go)
92
- continue;
93
- try {
94
- await detector.patch(found, opts);
95
- summary.push({ label: detector.label, path: found.path });
96
- // eslint-disable-next-line no-console
97
- console.log(green(' ✔') + ' ' + detector.label);
98
- }
99
- catch (err) {
100
- // eslint-disable-next-line no-console
101
- console.warn(' ⚠ Skipped ' + detector.label + ': ' + err.message);
102
- }
103
- }
104
- return summary;
105
- }
106
- async function patchClaudeCode(opts) {
107
- const target = opts.scope === 'repo' && opts.repoRoot
108
- ? { dir: join(opts.repoRoot, '.claude'), file: 'settings.local.json' }
109
- : { dir: join(homedir(), '.claude'), file: 'settings.json' };
110
- const path = join(target.dir, target.file);
111
- const proceed = await prompts({
112
- type: 'confirm',
113
- name: 'go',
114
- message: `Patch Claude Code config at ${dim(path)} ?`,
115
- initial: true,
116
- });
117
- if (!proceed.go)
118
- return null;
119
- await fs.mkdir(target.dir, { recursive: true });
120
- const txt = await fs.readFile(path, 'utf8').catch(() => '{}');
121
- let json;
122
- try {
123
- json = JSON.parse(txt);
124
- }
125
- catch {
126
- json = {};
127
- }
128
- const env = (json.env ?? {});
129
- // Detect an existing local LLM endpoint (Ollama, LM Studio, etc.) and warn
130
- // before clobbering it. The AnyForge proxy only routes Anthropic Claude
131
- // models — local models (Gemma, Llama, etc.) won't work through it.
132
- const existingUrl = env.ANTHROPIC_BASE_URL;
133
- const isLocalUrl = existingUrl &&
134
- !existingUrl.includes(opts.endpoint) &&
135
- (existingUrl.includes('localhost') || existingUrl.includes('127.0.0.1') || existingUrl.includes('0.0.0.0'));
136
- if (isLocalUrl) {
137
- // eslint-disable-next-line no-console
138
- console.log();
139
- // eslint-disable-next-line no-console
140
- console.log(yellow(' ⚠ Local LLM proxy detected: ') + dim(existingUrl));
141
- // eslint-disable-next-line no-console
142
- console.log(dim(' AnyForge only routes Anthropic Claude models. Local models (Ollama,'));
143
- // eslint-disable-next-line no-console
144
- console.log(dim(' LM Studio, etc.) will not work in Claude Code after this patch.'));
145
- // eslint-disable-next-line no-console
146
- console.log(dim(' To restore: remove ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN'));
147
- // eslint-disable-next-line no-console
148
- console.log(dim(` from ${path}`));
149
- // eslint-disable-next-line no-console
150
- console.log();
151
- const overwrite = await prompts({
152
- type: 'confirm',
153
- name: 'go',
154
- message: 'Overwrite existing local LLM proxy config?',
155
- initial: false,
156
- });
157
- if (!overwrite.go)
158
- return null;
159
- }
160
- // Back up the pre-patch file once so `anyforge logout` (or the user)
161
- // can restore it verbatim. Only the first patch writes a backup.
162
- if ((await fileExists(path)) && !(await fileExists(path + BACKUP_SUFFIX))) {
163
- await fs.copyFile(path, path + BACKUP_SUFFIX);
164
- }
165
- env.ANTHROPIC_BASE_URL = `${opts.endpoint}/v1/anthropic`;
166
- env.ANTHROPIC_AUTH_TOKEN = opts.apiKey;
167
- // Strip stale ANTHROPIC_API_KEY from prior installs — keeping
168
- // both set re-triggers the auth-conflict warning even though
169
- // ANTHROPIC_AUTH_TOKEN is what we actually want Claude Code
170
- // to use.
171
- delete env.ANTHROPIC_API_KEY;
172
- json.env = env;
173
- await fs.writeFile(path, JSON.stringify(json, null, 2) + '\n');
174
- // eslint-disable-next-line no-console
175
- console.log(green(' ✔') + ' Claude Code');
176
- return {
177
- label: opts.scope === 'repo' ? 'Claude Code (repo)' : 'Claude Code (global)',
178
- path,
179
- };
180
- }
181
- /**
182
- * Enable Claude Code → AnyForge usage telemetry by writing the standard
183
- * OpenTelemetry env into ~/.claude/settings.json. This does NOT touch
184
- * auth (no ANTHROPIC_* keys), so it is safe alongside a Pro/Max
185
- * subscription login — it just exports `claude_code.token.usage` /
186
- * `claude_code.cost.usage` metrics (counts + estimated cost, no prompt
187
- * content) to AnyForge's OTLP collector. Returns null if a non-AnyForge
188
- * OTel endpoint is already configured (we never clobber a user's own).
189
- */
190
- export async function patchClaudeTelemetry(opts) {
191
- const dir = join(homedir(), '.claude');
192
- const path = join(dir, 'settings.json');
193
- await fs.mkdir(dir, { recursive: true });
194
- const txt = await fs.readFile(path, 'utf8').catch(() => '{}');
195
- let json;
196
- try {
197
- json = JSON.parse(txt);
198
- }
199
- catch {
200
- json = {};
201
- }
202
- const env = (json.env ?? {});
203
- const existing = env.OTEL_EXPORTER_OTLP_ENDPOINT;
204
- if (existing && !isAnyforgeValue(existing, safeHost(opts.endpoint))) {
205
- /* eslint-disable no-console */
206
- console.log(yellow(' ⚠ An OpenTelemetry endpoint is already configured: ') + dim(existing));
207
- console.log(dim(' Leaving it untouched. To send Claude Code usage to AnyForge, point'));
208
- console.log(dim(' OTEL_EXPORTER_OTLP_ENDPOINT at ' + opts.endpoint + '/api/otel'));
209
- /* eslint-enable no-console */
210
- return null;
211
- }
212
- // Back up once so `logout` can restore verbatim.
213
- if ((await fileExists(path)) && !(await fileExists(path + BACKUP_SUFFIX))) {
214
- await fs.copyFile(path, path + BACKUP_SUFFIX);
215
- }
216
- // Metrics only — we deliberately do NOT set OTEL_LOGS_EXPORTER, which
217
- // is the channel that can carry prompt/response content.
218
- // - http/json so our collector can parse the body with req.json()
219
- // and needs no protobuf decoder.
220
- // - delta temporality so each 60s export carries the tokens spent in
221
- // that interval (cumulative would force the collector to diff).
222
- env.CLAUDE_CODE_ENABLE_TELEMETRY = '1';
223
- env.OTEL_METRICS_EXPORTER = 'otlp';
224
- env.OTEL_EXPORTER_OTLP_PROTOCOL = 'http/json';
225
- env.OTEL_EXPORTER_OTLP_ENDPOINT = `${opts.endpoint}/api/otel`;
226
- env.OTEL_EXPORTER_OTLP_HEADERS = `Authorization=Bearer ${opts.apiKey}`;
227
- env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = 'delta';
228
- json.env = env;
229
- await fs.writeFile(path, JSON.stringify(json, null, 2) + '\n');
230
- return { label: 'Claude Code usage telemetry (global)', path };
231
- }
232
- async function patchRepoEnv(opts) {
233
- if (!opts.repoRoot)
234
- return null;
235
- const envPath = join(opts.repoRoot, '.env');
236
- const proceed = await prompts({
237
- type: 'confirm',
238
- name: 'go',
239
- message: `Write AnyForge env block to ${dim(envPath)} ?`,
240
- initial: true,
241
- });
242
- if (!proceed.go)
243
- return null;
244
- const block = [
245
- SENTINEL_BEGIN,
246
- `ANYFORGE_API_KEY=${opts.apiKey}`,
247
- `ANTHROPIC_BASE_URL=${opts.endpoint}/v1/anthropic`,
248
- `ANTHROPIC_AUTH_TOKEN=${opts.apiKey}`,
249
- `OPENAI_BASE_URL=${opts.endpoint}/v1/openai`,
250
- `OPENAI_API_KEY=${opts.apiKey}`,
251
- SENTINEL_END,
252
- '',
253
- ].join('\n');
254
- let cur = '';
255
- try {
256
- cur = await fs.readFile(envPath, 'utf8');
257
- }
258
- catch { /* missing is fine */ }
259
- let next;
260
- if (cur.includes(SENTINEL_BEGIN)) {
261
- // Idempotent: re-running replaces the existing block in place.
262
- next = cur.replace(new RegExp(`${SENTINEL_BEGIN}[\\s\\S]*?${SENTINEL_END}\\n?`), block);
263
- }
264
- else if (cur.length === 0) {
265
- next = block;
266
- }
267
- else {
268
- next = cur.endsWith('\n') ? `${cur}${block}` : `${cur}\n${block}`;
269
- }
270
- if (next !== cur)
271
- await fs.writeFile(envPath, next);
272
- // The .env contains a live API key — flag it if it isn't gitignored.
273
- await warnIfNotGitignored(opts.repoRoot);
274
- // eslint-disable-next-line no-console
275
- console.log(green(' ✔') + ' .env');
276
- return { label: '.env (repo)', path: envPath };
277
- }
278
- async function warnIfNotGitignored(repoRoot) {
279
- const gi = join(repoRoot, '.gitignore');
280
- try {
281
- const txt = await fs.readFile(gi, 'utf8');
282
- const lines = txt.split('\n').map(l => l.trim());
283
- const ignored = lines.some(l => l === '.env' || l === '/.env' || l === '*.env');
284
- if (!ignored) {
285
- // eslint-disable-next-line no-console
286
- console.log(yellow(' ⚠ .env is not in .gitignore — add it before committing.'));
287
- }
288
- }
289
- catch {
290
- // eslint-disable-next-line no-console
291
- console.log(yellow(' ⚠ No .gitignore found — make sure .env is excluded before committing.'));
292
- }
293
- }
294
- // Optional best-effort detectors for tools that don't expose a config
295
- // file we can patch directly. They just append exports to the user's
296
- // shell rc; the API key is inlined so a brand-new shell picks it up
297
- // without depending on jq or the credentials file being readable.
298
- const OPTIONAL_GLOBAL_DETECTORS = [
299
- {
300
- label: 'Cursor (shell hint)',
301
- async detect() {
302
- const path = join(homedir(), '.cursor');
303
- const exists = await fileExists(path);
304
- return exists ? { path: '.cursor (shell hint)' } : null;
305
- },
306
- async patch(_found, opts) {
307
- await ensureShellRcExports(opts);
308
- },
309
- },
310
- {
311
- label: 'Cline / Continue (shell hint)',
312
- async detect() {
313
- const a = await fileExists(join(homedir(), '.cline'));
314
- const b = await fileExists(join(homedir(), '.continue'));
315
- return a || b ? { path: '~/.cline or ~/.continue (shell hint)' } : null;
316
- },
317
- async patch(_found, opts) {
318
- await ensureShellRcExports(opts);
319
- },
320
- },
321
- ];
322
- async function ensureShellRcExports(opts) {
323
- // Idempotent: re-running the CLI shouldn't append duplicate
324
- // exports. We tag our block with a sentinel comment so we can
325
- // detect + replace cleanly on subsequent runs.
326
- const block = [
327
- SENTINEL_BEGIN,
328
- `export ANYFORGE_API_KEY="${opts.apiKey}"`,
329
- `export ANTHROPIC_BASE_URL="${opts.endpoint}/v1/anthropic"`,
330
- `export ANTHROPIC_AUTH_TOKEN="${opts.apiKey}"`,
331
- `export OPENAI_BASE_URL="${opts.endpoint}/v1/openai"`,
332
- `export OPENAI_API_KEY="${opts.apiKey}"`,
333
- `unset ANTHROPIC_API_KEY 2>/dev/null || true`,
334
- SENTINEL_END,
335
- '',
336
- ].join('\n');
337
- for (const rc of ['.zshrc', '.bashrc']) {
338
- const path = join(homedir(), rc);
339
- if (!(await fileExists(path)))
340
- continue;
341
- const cur = await fs.readFile(path, 'utf8');
342
- let next;
343
- if (cur.includes(SENTINEL_BEGIN)) {
344
- next = cur.replace(new RegExp(`${SENTINEL_BEGIN}[\\s\\S]*?${SENTINEL_END}\\n?`), block);
345
- }
346
- else {
347
- next = cur.endsWith('\n') ? `${cur}\n${block}` : `${cur}\n\n${block}`;
348
- }
349
- if (next !== cur)
350
- await fs.writeFile(path, next);
351
- }
352
- }
353
- async function fileExists(p) {
354
- try {
355
- await fs.access(p);
356
- return true;
357
- }
358
- catch {
359
- return false;
360
- }
361
- }
362
- /**
363
- * Detect a Claude Pro/Max/Team/Enterprise (subscription) login.
364
- *
365
- * `~/.claude.json` carries an `oauthAccount` block whenever the user
366
- * logged in with a Claude.ai account. It lives in the plain-JSON config
367
- * on every platform, so — unlike the token itself, which is in the macOS
368
- * Keychain — we can read it without triggering a credential prompt. A
369
- * Console (API-key) login does not carry these subscription org types,
370
- * so BYOK users are correctly left to proceed.
371
- */
372
- export async function detectClaudeSubscription() {
373
- try {
374
- const txt = await fs.readFile(join(homedir(), '.claude.json'), 'utf8');
375
- const json = JSON.parse(txt);
376
- const acct = json.oauthAccount;
377
- const orgType = acct?.organizationType ?? '';
378
- if (acct && SUBSCRIPTION_ORG_TYPES.test(orgType)) {
379
- const plan = /max/i.test(orgType) ? 'Max' :
380
- /pro/i.test(orgType) ? 'Pro' :
381
- /enterprise/i.test(orgType) ? 'Enterprise' :
382
- /team/i.test(orgType) ? 'Team' :
383
- orgType;
384
- return { isSubscription: true, plan, email: acct.emailAddress ?? null };
385
- }
386
- }
387
- catch {
388
- // No config / unreadable / unparseable → assume no subscription.
389
- }
390
- return { isSubscription: false, plan: null, email: null };
391
- }
392
- /**
393
- * Undo what `init` patched: strip the proxy env keys from Claude Code's
394
- * global + repo settings, and remove the sentinel block from .env and
395
- * shell rc files. Only touches keys whose value points at the AnyForge
396
- * proxy, so a hand-edited base URL is left alone. Restores any
397
- * `.anyforge-bak` cleanup afterwards.
398
- */
399
- export async function revertClaudeCodeConfigs(opts = {}) {
400
- const host = opts.endpoint ? safeHost(opts.endpoint) : null;
401
- const reverted = [];
402
- // Global Claude Code settings + shell-rc exports.
403
- const globalSettings = join(homedir(), '.claude', 'settings.json');
404
- if (await revertSettingsFile(globalSettings, host))
405
- reverted.push(globalSettings);
406
- for (const rc of ['.zshrc', '.bashrc']) {
407
- const p = join(homedir(), rc);
408
- if (await stripSentinelBlock(p))
409
- reverted.push(p);
410
- }
411
- // Repo-scoped settings + .env, when invoked inside a working tree.
412
- const repoRoot = opts.repoRoot ?? findGitRootSafe(process.cwd());
413
- if (repoRoot) {
414
- const localSettings = join(repoRoot, '.claude', 'settings.local.json');
415
- if (await revertSettingsFile(localSettings, host))
416
- reverted.push(localSettings);
417
- const envPath = join(repoRoot, '.env');
418
- if (await stripSentinelBlock(envPath))
419
- reverted.push(envPath);
420
- }
421
- return { reverted };
422
- }
423
- function isAnyforgeValue(v, host) {
424
- if (!v)
425
- return false;
426
- if (v.includes('anyforge'))
427
- return true;
428
- return host ? v.includes(host) : false;
429
- }
430
- /** Remove the proxy keys we injected from a Claude Code settings JSON,
431
- * preferring a verbatim restore from the `.anyforge-bak` backup when it
432
- * exists. Returns true if the file changed. */
433
- async function revertSettingsFile(path, host) {
434
- if (!(await fileExists(path)))
435
- return false;
436
- // Prefer a clean restore from the pre-patch backup.
437
- if (await fileExists(path + BACKUP_SUFFIX)) {
438
- await fs.copyFile(path + BACKUP_SUFFIX, path);
439
- await fs.unlink(path + BACKUP_SUFFIX).catch(() => { });
440
- return true;
441
- }
442
- // No backup (e.g. patched by an older CLI): surgically drop our keys.
443
- let json;
444
- try {
445
- json = JSON.parse(await fs.readFile(path, 'utf8'));
446
- }
447
- catch {
448
- return false;
449
- }
450
- const env = json.env;
451
- if (!env)
452
- return false;
453
- let changed = false;
454
- const pairs = [
455
- ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'],
456
- ['OPENAI_BASE_URL', 'OPENAI_API_KEY'],
457
- ];
458
- for (const [urlKey, tokenKey] of pairs) {
459
- if (isAnyforgeValue(env[urlKey], host)) {
460
- delete env[urlKey];
461
- delete env[tokenKey];
462
- changed = true;
463
- }
464
- }
465
- if (env.ANYFORGE_API_KEY) {
466
- delete env.ANYFORGE_API_KEY;
467
- changed = true;
468
- }
469
- // Telemetry we injected — only when the OTLP endpoint is AnyForge's, so
470
- // we never strip a user's own OpenTelemetry configuration.
471
- if (isAnyforgeValue(env.OTEL_EXPORTER_OTLP_ENDPOINT, host)) {
472
- for (const k of TELEMETRY_KEYS) {
473
- if (k in env) {
474
- delete env[k];
475
- changed = true;
476
- }
477
- }
478
- }
479
- if (!changed)
480
- return false;
481
- if (Object.keys(env).length === 0)
482
- delete json.env;
483
- await fs.writeFile(path, JSON.stringify(json, null, 2) + '\n');
484
- return true;
485
- }
486
- /** Remove the `# === AnyForge Control === … === end === ` block from a
487
- * .env or shell-rc file. Returns true if the file changed. */
488
- async function stripSentinelBlock(path) {
489
- let txt;
490
- try {
491
- txt = await fs.readFile(path, 'utf8');
492
- }
493
- catch {
494
- return false;
495
- }
496
- if (!txt.includes(SENTINEL_BEGIN))
497
- return false;
498
- const next = txt
499
- .replace(new RegExp(`\\n?${escapeRe(SENTINEL_BEGIN)}[\\s\\S]*?${escapeRe(SENTINEL_END)}\\n?`), '\n')
500
- .replace(/\n{3,}/g, '\n\n');
501
- await fs.writeFile(path, next);
502
- return true;
503
- }
504
- function escapeRe(s) {
505
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
506
- }
507
- function safeHost(endpoint) {
508
- try {
509
- return new URL(endpoint).host;
510
- }
511
- catch {
512
- return null;
513
- }
514
- }
515
- function findGitRootSafe(cwd) {
516
- try {
517
- return execFileSync('git', ['rev-parse', '--show-toplevel'], {
518
- cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
519
- }).trim();
520
- }
521
- catch {
522
- return null;
523
- }
524
- }
@@ -1,40 +0,0 @@
1
- import { promises as fs } from 'fs';
2
- import { homedir } from 'os';
3
- import { join, dirname } from 'path';
4
- const DIR = join(homedir(), '.anyforge');
5
- const FILE = join(DIR, 'credentials');
6
- export async function saveCredentials(c) {
7
- await fs.mkdir(DIR, { recursive: true, mode: 0o700 });
8
- await fs.writeFile(FILE, JSON.stringify(c, null, 2), { mode: 0o600 });
9
- }
10
- export async function loadCredentials() {
11
- try {
12
- const text = await fs.readFile(FILE, 'utf8');
13
- return JSON.parse(text);
14
- }
15
- catch (err) {
16
- if (err.code === 'ENOENT')
17
- return null;
18
- throw err;
19
- }
20
- }
21
- export async function clearCredentials() {
22
- try {
23
- await fs.unlink(FILE);
24
- return true;
25
- }
26
- catch (err) {
27
- if (err.code === 'ENOENT')
28
- return false;
29
- throw err;
30
- }
31
- }
32
- export function credentialsPath() {
33
- return FILE;
34
- }
35
- export function credentialsDir() {
36
- return DIR;
37
- }
38
- // Exported so test code can stub this at the module boundary if
39
- // needed without monkey-patching node's `fs`.
40
- export const __internal = { DIR, FILE, dirname };