codefree-proxy 0.1.3 → 0.1.4

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 CHANGED
@@ -1,138 +1,90 @@
1
1
  # codefree-proxy
2
2
 
3
- OpenAI-compatible local proxy for SRD Cloud CodeFree.
3
+ 把本机 **CodeFree-O** 的登录态转换成一个本地 OpenAI 兼容接口,任何支持 OpenAI API 的客户端(Cherry Studio、Open WebUI、Continue、opencode 等)都能直接用研发云的模型。
4
4
 
5
- ## Quick start
5
+ 只监听 `127.0.0.1`,不需要填 API Key。
6
6
 
7
- Run in the background:
7
+ ## 前置条件
8
8
 
9
- ```bash
10
- npx codefree-proxy start --port 8788 --host 127.0.0.1
11
- ```
12
-
13
- Or install globally:
9
+ 1. **Node.js >= 18**(`node -v` 检查)
10
+ 2. **本机已安装并登录 CodeFree-O**:先跑一次 `codefree-o login`,确认能正常对话
14
11
 
15
- ```bash
16
- npm install -g codefree-proxy
17
- codefree-proxy start --port 8788 --host 127.0.0.1
18
- ```
12
+ 代理不保存密码,也不需要你提供任何密钥:它读取 CodeFree-O 在本机的登录态(`~/.codefree-o/.../codefree.json`,AES-256-GCM 加密),用其中的 refresh token 换取访问令牌。因此 **CodeFree-O 必须先登录过**。
19
13
 
20
- Foreground mode is also available when you want logs printed in the current terminal:
14
+ ## 快速开始
21
15
 
22
16
  ```bash
23
- npx codefree-proxy --port 8788 --host 127.0.0.1
17
+ npx codefree-proxy doctor # 1. 自检:Node、登录态、端口、网络
18
+ npx codefree-proxy start # 2. 后台启动
24
19
  ```
25
20
 
26
- The server prints the local base URL and endpoints after it starts.
21
+ 启动后把客户端指向:
27
22
 
28
23
  ```text
29
- Server running at: http://127.0.0.1:8788
30
- OpenAI Base URL: http://127.0.0.1:8788/v1
31
- Chat endpoint: POST http://127.0.0.1:8788/v1/chat/completions
32
- Models endpoint: GET http://127.0.0.1:8788/v1/models
33
- Health check: GET http://127.0.0.1:8788/health
34
- Stop command: codefree-proxy stop --port 8788
24
+ Base URL: http://127.0.0.1:8788/v1
25
+ API Key: 随意填或留空
35
26
  ```
36
27
 
37
- Use this OpenAI-compatible base URL in clients:
38
-
39
- ```text
40
- http://127.0.0.1:8788/v1
41
- ```
42
-
43
- Use any API key value in OpenAI-compatible clients, or omit it if the client allows.
44
-
45
- ## Login
46
-
47
- The proxy first tries existing local credentials. If credentials are missing, run:
28
+ 停止与查看:
48
29
 
49
30
  ```bash
50
- npx codefree-proxy login
31
+ npx codefree-proxy status # 登录态(只读,不会触发登录)
32
+ npx codefree-proxy stop # 停止 8788 上的代理
51
33
  ```
52
34
 
53
- The login flow matches CodeFree-O: it opens SRD Cloud OAuth in a browser, waits for the local callback, fetches `encryptedApiKey`, decrypts it, and saves credentials locally.
54
-
55
- Check current credential status:
56
-
57
- ```bash
58
- npx codefree-proxy status
59
- ```
35
+ ## 可用模型
60
36
 
61
- Remove saved credentials:
37
+ - `DeepSeek-V4-Flash-0731`
38
+ - `DeepSeek-V4.1-Flash-1017`(支持图片)
39
+ - `GLM-5.2`
40
+ - `GLM-5.3`
41
+ - `Qwen3.8-27B`(支持图片)
62
42
 
63
- ```bash
64
- npx codefree-proxy logout
65
- ```
43
+ 写错模型名会直接返回 400 并列出可用模型,不会悄悄换成别的模型。
66
44
 
67
- ## Background mode
45
+ ## 命令
68
46
 
69
- Start the server in the background:
47
+ | 命令 | 说明 |
48
+ | --- | --- |
49
+ | `doctor` | 环境自检,输出 ✓/✗ 与结论 |
50
+ | `serve` | 前台启动,日志打在终端 |
51
+ | `start` | 后台启动,并确认端口真的监听成功 |
52
+ | `login` | 从 CodeFree-O 导入登录态 |
53
+ | `status` | 查看登录态(不写文件、不联网) |
54
+ | `logout` | 删除本代理保存的登录态 |
55
+ | `stop` | 停止代理(只停自己,端口被别的程序占用会拒绝) |
70
56
 
71
- ```bash
72
- npx codefree-proxy start
73
- npx codefree-proxy start --port 8788 --host 127.0.0.1
74
- ```
57
+ 通用选项:`--host`(默认 127.0.0.1)、`--port`(默认 8788)、`--timeout`(秒,默认 120)、`--no-mirror`、`--help`。
75
58
 
76
- Background logs are written to:
59
+ ## 接口
77
60
 
78
- ```text
79
- ~/.codefree-proxy/logs/proxy.log
80
- ```
61
+ - `POST /v1/chat/completions`(支持 `stream`)
62
+ - `POST /v1/messages`(Anthropic 兼容)
63
+ - `GET /v1/models`
64
+ - `GET /health`
81
65
 
82
- The process id is written to:
66
+ ## 常见问题
83
67
 
84
- ```text
85
- ~/.codefree-proxy/proxy.pid
86
- ```
68
+ **提示"未找到可用的 CodeFree-O 登录态"**
69
+ 运行 `codefree-proxy login`:它会自动调用 `codefree-o login` 打开研发云登录页,登录成功后把登录态导入,再 `codefree-proxy start` 即可。
87
70
 
88
- ## Stop the server
71
+ **`codefree-proxy login` 是什么行为**
72
+ 有可导入的登录态就直接导入;没有就代跑一次原生 `codefree-o login`(会弹浏览器,需要你完成登录)。`start` / `serve` 不会自动弹浏览器,只在未登录时立刻报错并提示上一步。
89
73
 
90
- Default port:
74
+ **提示"登录态已失效,无法续期"**
75
+ CodeFree-O 的 refresh token 过期了,重新 `codefree-o login` 后重启代理。
91
76
 
92
- ```bash
93
- npx codefree-proxy stop
94
- ```
77
+ **端口被占用**
78
+ `codefree-proxy start --port 8789`,或按提示确认占用进程。
95
79
 
96
- Custom port:
80
+ **不想让代理动 CodeFree-O 的登录态**
81
+ 默认会把轮换后的 token 回写到 CodeFree-O,让两边都能继续用。若你同时在跑原生 CLI,可加 `--no-mirror` 关闭回写(此时原生 CLI 可能需要重新登录)。
97
82
 
98
- ```bash
99
- npx codefree-proxy stop --port 8788
100
- ```
83
+ **日志**
84
+ `~/.codefree-proxy/logs/proxy.log`;登录态缓存在 `~/.codefree-proxy/config.json`(权限 0600)。
101
85
 
102
- ## Commands
86
+ ## 更新
103
87
 
104
88
  ```bash
105
- codefree-proxy --port 8788 --host 127.0.0.1
106
- codefree-proxy start
107
- codefree-proxy start --port 8788 --host 127.0.0.1
108
- codefree-proxy login
109
- codefree-proxy logout
110
- codefree-proxy status
111
- codefree-proxy stop
112
- codefree-proxy stop --port 8788
89
+ npm i -g codefree-proxy@latest
113
90
  ```
114
-
115
- ## Endpoints
116
-
117
- - `POST /v1/chat/completions`
118
- - `GET /v1/models`
119
- - `GET /health`
120
- - `GET /auth/status`
121
- - `POST /auth/refresh`
122
-
123
- ## Models
124
-
125
- - `GLM-5.1`
126
- - `GLM-4.7`
127
- - `Qwen3.5-122B-A10B`
128
-
129
- Unknown model names are mapped to `GLM-5.1`.
130
-
131
- ## Credentials
132
-
133
- Credentials are stored at:
134
-
135
- - macOS/Linux: `~/.codefree-proxy/config.json`
136
- - Windows: `%USERPROFILE%\.codefree-proxy\config.json`
137
-
138
- The proxy can also import CodeFree-O credentials from `~/.codefree-o/.local/share/codefree.json` when available.
@@ -1,61 +1,141 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
1
3
  import { deleteCredentials, loadCredentials, saveCredentials } from './config-store.js';
2
- import { loadCodeFreeOCredentials } from './codefree-o.js';
3
- import { authorizeWithBrowser } from './browser-login.js';
4
+ import { inspectCodeFreeOStore, loadCodeFreeOCredentials } from './codefree-o.js';
5
+
6
+ const CACHE_TTL_MS = 30_000;
7
+ const NATIVE_KEEP_FIELDS = ['userId', 'refreshCfToken', 'refreshCfTokenExpiresAt', 'baseUrlSnapshot', 'clientId'];
8
+
9
+ export function nativeCredentialPaths(homeDir = homedir()) {
10
+ return [
11
+ join(homeDir, '.codefree-o', '.local', 'share', 'codefree.json'),
12
+ join(homeDir, '.codefree-o', '.config', 'codefree.json'),
13
+ ];
14
+ }
15
+
16
+ export class CredentialsError extends Error {
17
+ constructor(message, detail = {}) {
18
+ super(message);
19
+ this.name = 'CredentialsError';
20
+ this.code = 'CODEFREE_NO_CREDENTIALS';
21
+ this.detail = detail;
22
+ }
23
+ }
24
+
25
+ const LOGIN_HINT = '请运行 codefree-proxy login 完成登录(它会调用 codefree-o login)。';
26
+
4
27
  export class AuthManager {
5
28
  cache = null;
29
+ cacheAt = 0;
6
30
  source;
31
+ nativeState = null;
7
32
  deps;
8
33
  constructor(deps = {}) {
9
34
  this.deps = {
10
35
  loadConfig: deps.loadConfig ?? loadCredentials,
11
36
  saveConfig: deps.saveConfig ?? saveCredentials,
12
37
  deleteConfig: deps.deleteConfig ?? deleteCredentials,
13
- loadCodeFreeO: deps.loadCodeFreeO ?? loadCodeFreeOCredentials,
14
- browserLogin: deps.browserLogin ?? authorizeWithBrowser
38
+ loadCodeFreeO: deps.loadCodeFreeO ?? ((path) => loadCodeFreeOCredentials(path)),
39
+ paths: deps.paths ?? nativeCredentialPaths()
40
+ };
41
+ }
42
+ /** Read every known CodeFree-O store; first usable one wins. */
43
+ async readNative() {
44
+ const checked = [];
45
+ for (const path of this.deps.paths) {
46
+ const info = await inspectCodeFreeOStore(path);
47
+ checked.push(info);
48
+ if (info.ok) {
49
+ const creds = await this.deps.loadCodeFreeO(path);
50
+ if (creds) {
51
+ this.nativeState = { ok: true, path, checked, userId: creds.userId };
52
+ return { creds, checked };
53
+ }
54
+ info.ok = false;
55
+ info.reason = '登录态无法读取,请重新运行 codefree-o login';
56
+ }
57
+ }
58
+ const credentialFile = checked.find((c) => c.exists && c.credentialFile);
59
+ this.nativeState = {
60
+ ok: false,
61
+ checked,
62
+ reason: credentialFile?.reason ?? `未找到 CodeFree-O 登录态文件(已检查 ${checked.map((c) => c.path).join('、')})`
15
63
  };
64
+ return { creds: null, checked };
16
65
  }
17
66
  async getCredentials() {
18
- if (this.cache)
67
+ if (this.cache && Date.now() - this.cacheAt < CACHE_TTL_MS)
19
68
  return this.cache;
20
69
  const config = await this.deps.loadConfig();
21
- if (config) {
70
+ const { creds: native, checked } = await this.readNative();
71
+ if (native?.refreshCfToken) {
72
+ const preserved = {};
73
+ for (const field of NATIVE_KEEP_FIELDS) {
74
+ if (native[field] !== undefined)
75
+ preserved[field] = native[field];
76
+ }
77
+ this.cache = { ...(config ?? {}), ...native, ...preserved };
78
+ this.cacheAt = Date.now();
79
+ this.source = config ? 'config+codefree-o' : 'codefree-o';
80
+ await this.deps.saveConfig(this.cache);
81
+ return this.cache;
82
+ }
83
+ if (config?.refreshCfToken) {
22
84
  this.cache = config;
85
+ this.cacheAt = Date.now();
23
86
  this.source = 'config';
24
87
  return config;
25
88
  }
26
- const codefreeO = await this.deps.loadCodeFreeO();
27
- if (codefreeO) {
28
- this.cache = codefreeO;
29
- this.source = 'codefree-o';
30
- await this.deps.saveConfig(codefreeO);
31
- return codefreeO;
89
+ if (config?.apiKey) {
90
+ throw new CredentialsError(`本机保存的是旧版 CodeFree-O 登录态(只有 apiKey,无法续期)。${LOGIN_HINT}`, { checked });
32
91
  }
33
- return this.login();
92
+ const reason = this.nativeState?.reason ? `${this.nativeState.reason}。` : '';
93
+ const hint = reason.includes('codefree-proxy login') ? '' : LOGIN_HINT;
94
+ throw new CredentialsError(`未找到可用的 CodeFree-O 登录态。${reason}${hint}`, { checked });
34
95
  }
35
- async login() {
36
- const credentials = await this.deps.browserLogin();
37
- this.cache = credentials;
38
- this.source = 'browser';
39
- await this.deps.saveConfig(credentials);
40
- return credentials;
96
+ async invalidate() {
97
+ this.cache = null;
98
+ this.cacheAt = 0;
99
+ return true;
41
100
  }
42
101
  async logout() {
43
102
  this.cache = null;
103
+ this.cacheAt = 0;
44
104
  this.source = undefined;
45
105
  await this.deps.deleteConfig();
46
106
  }
47
107
  async refresh() {
48
- this.cache = null;
108
+ this.invalidate();
49
109
  return this.getCredentials();
50
110
  }
111
+ /** Read-only: never logs in, never writes, never touches the network. */
51
112
  async status() {
52
- try {
53
- const credentials = await this.getCredentials();
54
- return { status: 'ok', source: this.source, userId: credentials.userId };
55
- }
56
- catch {
57
- return { status: 'missing' };
113
+ const config = await this.deps.loadConfig().catch(() => null);
114
+ const { checked } = await this.readNative();
115
+ const usable = Boolean(config?.refreshCfToken) || Boolean(this.nativeState?.ok);
116
+ return {
117
+ status: usable ? 'ok' : 'missing',
118
+ source: this.nativeState?.ok ? 'codefree-o' : (config?.refreshCfToken ? 'config' : null),
119
+ userId: this.nativeState?.userId ?? config?.userId ?? null,
120
+ codefreeO: this.nativeState?.ok
121
+ ? { ok: true, path: this.nativeState.path }
122
+ : { ok: false, reason: this.nativeState?.reason ?? '未找到登录态文件', checked: checked.map((c) => ({ path: c.path, exists: c.exists, ok: c.ok, reason: c.reason })) },
123
+ proxyConfig: config
124
+ ? { ok: Boolean(config.refreshCfToken), userId: config.userId ?? null, updatedAt: config.updatedAt ?? null }
125
+ : { ok: false },
126
+ ...(usable ? {} : { hint: LOGIN_HINT })
127
+ };
128
+ }
129
+ /** Explicit import of the native CodeFree-O store (used by `login`). */
130
+ async login() {
131
+ const { creds, checked } = await this.readNative();
132
+ if (!creds?.refreshCfToken) {
133
+ throw new CredentialsError(`未找到可导入的 CodeFree-O 登录态。${LOGIN_HINT}`, { checked });
58
134
  }
135
+ this.cache = creds;
136
+ this.cacheAt = Date.now();
137
+ this.source = 'codefree-o';
138
+ await this.deps.saveConfig(creds);
139
+ return creds;
59
140
  }
60
141
  }
61
- //# sourceMappingURL=auth-manager.js.map
@@ -1,25 +1,123 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- import { createDecipheriv } from 'node:crypto';
5
- const AES_KEY = Buffer.from('Xtpa6sS&+D.NAo%CP8LA:7pk', 'utf8');
6
- const AES_IV = Buffer.from('%1KJIrl3!XUxr04V', 'utf8');
4
+ import { createDecipheriv, createCipheriv, createHash, randomBytes } from 'node:crypto';
5
+ // CodeFree-O >= 0.3 stores its refresh token as v1:<iv>:<ciphertext>:<tag>,
6
+ // AES-256-GCM with a fixed local key derived from a constant string. The key is
7
+ // part of the on-disk format, so every CodeFree-O build derives the same one.
8
+ const MODERN_KEY = createHash('sha256')
9
+ .update('codefree-o-refresh-token-local-encryption-key-v1', 'utf8')
10
+ .digest();
7
11
  export function getCodeFreeOCredentialPath(homeDir = homedir()) {
8
12
  return join(homeDir, '.codefree-o', '.local', 'share', 'codefree.json');
9
13
  }
10
- export function decryptApiKey(encrypted) {
11
- const decipher = createDecipheriv('aes-192-cbc', AES_KEY, AES_IV);
12
- return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64')), decipher.final()]).toString('utf8');
14
+ export function decryptRefreshCfToken(encrypted) {
15
+ const parts = String(encrypted).split(':');
16
+ if (parts.length !== 4 || parts[0] !== 'v1')
17
+ throw new Error('unsupported token format');
18
+ const decipher = createDecipheriv('aes-256-gcm', MODERN_KEY, Buffer.from(parts[1], 'base64'));
19
+ decipher.setAuthTag(Buffer.from(parts[3], 'base64'));
20
+ return Buffer.concat([decipher.update(Buffer.from(parts[2], 'base64')), decipher.final()]).toString('utf8');
21
+ }
22
+ export function encryptRefreshCfToken(refreshCfToken) {
23
+ const iv = randomBytes(12);
24
+ const cipher = createCipheriv('aes-256-gcm', MODERN_KEY, iv);
25
+ const ciphertext = Buffer.concat([cipher.update(String(refreshCfToken), 'utf8'), cipher.final()]);
26
+ const tag = cipher.getAuthTag();
27
+ return `v1:${iv.toString('base64')}:${ciphertext.toString('base64')}:${tag.toString('base64')}`;
28
+ }
29
+ /**
30
+ * Persist a rotated refresh token back into the native CodeFree-O store so the
31
+ * native CLI keeps working after the proxy refreshes. Merges into the existing
32
+ * file (mode 0600) instead of replacing it, so userId/version survive.
33
+ */
34
+ export async function saveCodeFreeORefreshToken(refreshCfToken, refreshCfTokenExpiresAt, path = getCodeFreeOCredentialPath()) {
35
+ let existing = {};
36
+ try {
37
+ existing = JSON.parse(await readFile(path, 'utf8'));
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ const next = {
43
+ ...existing,
44
+ encryptedRefreshCfToken: encryptRefreshCfToken(refreshCfToken),
45
+ refreshCfTokenExpiresAt: Number(refreshCfTokenExpiresAt) || existing.refreshCfTokenExpiresAt || 0
46
+ };
47
+ delete next.refreshCfToken;
48
+ await writeFile(path, JSON.stringify(next), { mode: 0o600 });
49
+ return true;
50
+ }
51
+ /**
52
+ * Read-only inspection for `doctor` / `status`. Never throws; reports why a
53
+ * store cannot be used so the CLI can fail fast with an actionable message.
54
+ */
55
+ export async function inspectCodeFreeOStore(path = getCodeFreeOCredentialPath()) {
56
+ let raw;
57
+ try {
58
+ raw = await readFile(path, 'utf8');
59
+ }
60
+ catch (error) {
61
+ const code = error?.code;
62
+ return {
63
+ path,
64
+ exists: false,
65
+ ok: false,
66
+ reason: code === 'ENOENT'
67
+ ? '未找到 CodeFree-O 登录态文件,请运行 codefree-proxy login 完成登录'
68
+ : `读取 CodeFree-O 登录态文件失败(${code ?? 'unknown'})`
69
+ };
70
+ }
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(raw);
74
+ }
75
+ catch {
76
+ return { path, exists: true, ok: false, reason: 'CodeFree-O 登录态文件不是合法 JSON,请运行 codefree-proxy login 重新登录' };
77
+ }
78
+ const encrypted = parsed.encryptedRefreshCfToken;
79
+ const userId = parsed.userId ? String(parsed.userId) : undefined;
80
+ if (!encrypted) {
81
+ return { path, exists: true, ok: false, userId, credentialFile: Boolean(parsed.encryptedApiKey || parsed.refreshCfToken),
82
+ reason: parsed.encryptedApiKey || parsed.refreshCfToken
83
+ ? '登录态里没有 refreshCfToken(CodeFree-O 版本过旧),请升级 CodeFree-O 后重新登录'
84
+ : '该文件不是 CodeFree-O 登录态(缺少 refreshCfToken)' };
85
+ }
86
+ let token;
87
+ try {
88
+ token = decryptRefreshCfToken(encrypted);
89
+ }
90
+ catch (error) {
91
+ return { path, exists: true, ok: false, userId, credentialFile: true,
92
+ reason: `登录态无法解密(${error?.message ?? '格式不支持'}),请运行 codefree-proxy login 重新登录` };
93
+ }
94
+ if (!token) {
95
+ return { path, exists: true, ok: false, userId, credentialFile: true, reason: '登录态里的 refreshCfToken 为空,请运行 codefree-proxy login 重新登录' };
96
+ }
97
+ const expiresAt = Number(parsed.refreshCfTokenExpiresAt) || 0;
98
+ return {
99
+ path,
100
+ exists: true,
101
+ ok: true,
102
+ userId,
103
+ expiresAt,
104
+ expiresAtIso: expiresAt > 0 ? new Date(expiresAt).toISOString() : undefined
105
+ };
13
106
  }
14
107
  export async function loadCodeFreeOCredentials(path = getCodeFreeOCredentialPath()) {
15
108
  try {
16
109
  const parsed = JSON.parse(await readFile(path, 'utf8'));
17
- if (!parsed.userId || !parsed.encryptedApiKey)
110
+ if (!parsed.userId || !parsed.encryptedRefreshCfToken)
111
+ return null;
112
+ const refreshCfToken = decryptRefreshCfToken(parsed.encryptedRefreshCfToken);
113
+ if (typeof refreshCfToken !== 'string' || refreshCfToken.length === 0)
18
114
  return null;
19
115
  return {
20
- userId: parsed.userId,
21
- apiKey: decryptApiKey(parsed.encryptedApiKey),
22
- sessionId: parsed.sessionId ?? '',
116
+ userId: String(parsed.userId),
117
+ refreshCfToken,
118
+ refreshCfTokenExpiresAt: Number(parsed.refreshCfTokenExpiresAt) || 0,
119
+ ...(parsed.baseUrlSnapshot ? { baseUrlSnapshot: parsed.baseUrlSnapshot } : {}),
120
+ ...(parsed.clientId ? { clientId: parsed.clientId } : {}),
23
121
  updatedAt: Date.now()
24
122
  };
25
123
  }
@@ -27,4 +125,3 @@ export async function loadCodeFreeOCredentials(path = getCodeFreeOCredentialPath
27
125
  return null;
28
126
  }
29
127
  }
30
- //# sourceMappingURL=codefree-o.js.map
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
1
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  function isStoredCredentials(value) {
@@ -27,8 +27,18 @@ export async function loadCredentials(path = getConfigPath()) {
27
27
  }
28
28
  }
29
29
  export async function saveCredentials(credentials, path = getConfigPath()) {
30
- await mkdir(dirname(path), { recursive: true });
31
- await writeFile(path, `${JSON.stringify(credentials, null, 2)}\n`, 'utf8');
30
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
31
+ let previous = {};
32
+ try {
33
+ previous = JSON.parse(await readFile(path, 'utf8'));
34
+ }
35
+ catch {
36
+ // A first login has no prior record; write the new credentials below.
37
+ }
38
+ const merged = { ...previous, ...credentials };
39
+ // Credentials file holds cfToken / refreshCfToken: keep it owner-only.
40
+ await writeFile(path, `${JSON.stringify(merged, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
41
+ await chmod(path, 0o600).catch(() => { });
32
42
  }
33
43
  export async function deleteCredentials(path = getConfigPath()) {
34
44
  await rm(path, { force: true });