creditdaddy 1.2.0 → 1.3.2

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/src/qoderUmid.js CHANGED
@@ -1,198 +1,198 @@
1
- /**
2
- * Qoder 设备身份组件 —— 给没有 Qoder 客户端的 Linux(fnOS / NAS)提供国际版签到所需的风控身份。
3
- *
4
- * Qoder 国际版只向带 Cosy-MachineToken / Code / Type 的请求下发「每天领 100 Credits」,
5
- * 这三个值由 Qoder 的 UMID 程序(runtime-info)生成。桌面端客户端自带它;官方 CLI
6
- * @qoder-ai/qodercli 则把各平台的 UMID 二进制以 base64 内嵌在 bundle/qodercli.js 里,Linux x64 / arm64 都有。
7
- *
8
- * 这里在用户确认后从 npm 仓库下载官方 qodercli 包(校验 npm 的 sha512 integrity),
9
- * 按本机 CPU 架构取出对应的 ELF,试跑一次确认能产出身份,再存到 ~/.creditdaddy/qoder-umid/。
10
- * 不随 CreditDaddy 分发该二进制;调用方式与 qodercli 一致:runtime-info <env>,第一行输出 JSON。
11
- * env:国际版 4(SINGAPORE,qodercli 的全球区取值),国内版 0(ONLINE)。
12
- */
13
-
14
- import fs from 'node:fs';
15
- import path from 'node:path';
16
- import crypto from 'node:crypto';
17
- import zlib from 'node:zlib';
18
- import { execFile } from 'node:child_process';
19
- import { dataDir } from './store.js';
20
- import { logger } from './logger.js';
21
-
22
- const PKG = '@qoder-ai/qodercli';
23
- const REGISTRIES = ['https://registry.npmmirror.com', 'https://registry.npmjs.org'];
24
- const AUTH_REGISTRY = 'https://registry.npmjs.org'; // integrity 的权威来源(镜像只用于加速下载)
25
- const ELF_MACHINE = { x64: 62, arm64: 183 };
26
- const BUNDLE_ENTRY = 'package/bundle/qodercli.js';
27
- const FETCH_TIMEOUT_MS = 180_000;
28
- const META_TIMEOUT_MS = 15_000;
29
- export const CLI_RISK_ENV = { qoder: 4, 'qoder-cn': 0 };
30
-
31
- export function umidSupported(platform = process.platform, arch = process.arch) {
32
- return platform === 'linux' && Boolean(ELF_MACHINE[arch]);
33
- }
34
-
35
- const dir = () => path.join(dataDir(), 'qoder-umid');
36
- const binPath = () => path.join(dir(), 'runtime-info');
37
- const manifestPath = () => path.join(dir(), 'manifest.json');
38
-
39
- /** 已安装的组件:{ path, version, arch, sha256, installedAt, registry };未安装返回 null */
40
- export function installedUmid() {
41
- if (!umidSupported()) return null;
42
- try {
43
- const m = JSON.parse(fs.readFileSync(manifestPath(), 'utf8'));
44
- if (!fs.existsSync(binPath()) || m.arch !== process.arch) return null;
45
- return { ...m, path: binPath() };
46
- } catch { return null; }
47
- }
48
-
49
- // ── 纯函数(便于单测) ──
50
-
51
- /** 最小 tar 解析:按名字取一个普通文件的内容 */
52
- export function tarEntry(tar, wanted) {
53
- let off = 0;
54
- const cstr = (b, s, n) => { const x = b.subarray(s, s + n); const z = x.indexOf(0); return x.subarray(0, z < 0 ? n : z).toString('utf8'); };
55
- let longName = null;
56
- while (off + 512 <= tar.length) {
57
- const h = tar.subarray(off, off + 512);
58
- if (h.every((b) => b === 0)) break;
59
- const size = parseInt(cstr(h, 124, 12).trim() || '0', 8);
60
- const type = String.fromCharCode(h[156] || 48);
61
- const prefix = cstr(h, 345, 155);
62
- let name = longName || (prefix ? prefix + '/' : '') + cstr(h, 0, 100);
63
- longName = null;
64
- const body = tar.subarray(off + 512, off + 512 + size);
65
- off += 512 + Math.ceil(size / 512) * 512;
66
- if (type === 'L') { longName = body.toString('utf8').replace(/\0+$/, ''); continue; }
67
- if (type === 'x') { // pax 扩展头里的 path 覆盖下一个条目的名字
68
- const m = /\d+ path=([^\n]+)\n/.exec(body.toString('utf8'));
69
- if (m) longName = m[1];
70
- continue;
71
- }
72
- if ((type === '0' || type === '\0') && name === wanted) return body;
73
- }
74
- return null;
75
- }
76
-
77
- /** 在 qodercli bundle 文本里找内嵌的 ELF(base64 字面量),按 e_machine 挑出本机架构 */
78
- export function extractElf(jsText, machine) {
79
- const MARK = '"f0VMRgIB'; // "\x7fELF" + 64 位 + 小端 的 base64 开头
80
- let i = jsText.indexOf(MARK);
81
- while (i >= 0) {
82
- const start = i + 1;
83
- const end = jsText.indexOf('"', start);
84
- if (end < 0) break;
85
- const head = Buffer.from(jsText.slice(start, start + 28), 'base64');
86
- if (head.length >= 20 && head.readUInt16LE(18) === machine) {
87
- return Buffer.from(jsText.slice(start, end), 'base64');
88
- }
89
- i = jsText.indexOf(MARK, end);
90
- }
91
- return null;
92
- }
93
-
94
- /** npm integrity(sha512-<base64>)校验 */
95
- export function checkIntegrity(buf, integrity) {
96
- const m = /^sha512-(.+)$/.exec(String(integrity || '').split(/\s+/)[0]);
97
- if (!m) return false;
98
- return crypto.createHash('sha512').update(buf).digest('base64') === m[1];
99
- }
100
-
101
- // ── 安装 ──
102
-
103
- async function fetchBuf(url, timeoutMs = FETCH_TIMEOUT_MS) {
104
- const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
105
- if (!res.ok) throw new Error(`HTTP ${res.status}:${url}`);
106
- return Buffer.from(await res.arrayBuffer());
107
- }
108
-
109
- /**
110
- * 从 npmjs 拉权威 integrity。镜像(npmmirror)的元数据与 tarball 同源,
111
- * 镜像被污染时它自己的 integrity 校验形同虚设,所以校验值以 npmjs 为准;
112
- * npmjs 不可达时返回 null,退回镜像自身 integrity(与旧行为一致)。
113
- */
114
- async function authoritativeIntegrity() {
115
- try {
116
- const meta = JSON.parse((await fetchBuf(`${AUTH_REGISTRY}/${PKG.replace('/', '%2f')}/latest`, META_TIMEOUT_MS)).toString('utf8'));
117
- if (meta?.dist?.integrity && meta?.version) return { integrity: meta.dist.integrity, version: meta.version };
118
- } catch {}
119
- return null;
120
- }
121
-
122
- function testRun(file) {
123
- return new Promise((resolve, reject) => {
124
- execFile(file, [String(CLI_RISK_ENV.qoder)], { timeout: 30_000, maxBuffer: 1 << 20 }, (err, stdout) => {
125
- try {
126
- const j = JSON.parse(String(stdout).split('\n')[0]);
127
- if (typeof j.machineToken === 'string' && j.machineToken && j.machineCode && j.machineType) return resolve(j);
128
- } catch {}
129
- reject(new Error('组件试运行失败' + (err ? ':' + err.message : '(输出里没有设备身份)')));
130
- });
131
- });
132
- }
133
-
134
- let installing = null;
135
-
136
- /** 下载官方 qodercli 包并提取本机架构的 UMID;并发调用共用同一次安装 */
137
- export function installUmid() {
138
- if (!installing) installing = doInstall().finally(() => { installing = null; });
139
- return installing;
140
- }
141
- export const umidInstalling = () => Boolean(installing);
142
-
143
- async function doInstall() {
144
- if (!umidSupported()) throw new Error(`当前平台(${process.platform}/${process.arch})不需要或不支持该组件`);
145
- const authority = await authoritativeIntegrity();
146
- const errors = [];
147
- for (const registry of REGISTRIES) {
148
- try {
149
- const meta = JSON.parse((await fetchBuf(`${registry}/${PKG.replace('/', '%2f')}/latest`, META_TIMEOUT_MS)).toString('utf8'));
150
- const { tarball, integrity } = meta.dist || {};
151
- if (!tarball || !integrity) throw new Error('包元数据缺少 tarball / integrity');
152
- // 镜像版本与 npmjs 不一致时没有权威校验值可比,跳过镜像直接用官方源
153
- if (authority && registry !== AUTH_REGISTRY && meta.version !== authority.version) {
154
- throw new Error(`镜像版本(${meta.version})与 npmjs(${authority.version})不一致`);
155
- }
156
- const expected = authority && authority.version === meta.version ? authority.integrity : integrity;
157
- logger.info('UMID', `下载 ${PKG}@${meta.version}(${registry})`);
158
- const tgz = await fetchBuf(tarball);
159
- if (!checkIntegrity(tgz, expected)) throw new Error('下载内容与 npm integrity 不一致');
160
- const bundle = tarEntry(zlib.gunzipSync(tgz), BUNDLE_ENTRY);
161
- if (!bundle) throw new Error(`包里没有 ${BUNDLE_ENTRY}(qodercli 结构可能变化)`);
162
- const elf = extractElf(bundle.toString('latin1'), ELF_MACHINE[process.arch]);
163
- if (!elf) throw new Error(`qodercli ${meta.version} 里没有 ${process.arch} 的 UMID 程序(结构可能变化)`);
164
-
165
- fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
166
- const tmp = binPath() + '.' + process.pid + '.tmp';
167
- fs.writeFileSync(tmp, elf, { mode: 0o755 });
168
- try { await testRun(tmp); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
169
- fs.renameSync(tmp, binPath());
170
- const manifest = {
171
- version: meta.version, arch: process.arch, registry,
172
- sha256: crypto.createHash('sha256').update(elf).digest('hex'),
173
- installedAt: new Date().toISOString(),
174
- };
175
- fs.writeFileSync(manifestPath(), JSON.stringify(manifest, null, 2), { mode: 0o600 });
176
- logger.info('UMID', `设备身份组件已安装:qodercli ${meta.version} / ${process.arch}`);
177
- return { ...manifest, path: binPath() };
178
- } catch (e) {
179
- errors.push(`${registry}:${e.message}`);
180
- logger.warn('UMID', `从 ${registry} 安装失败:${e.message}`);
181
- }
182
- }
183
- throw new Error('设备身份组件安装失败 —— ' + errors.join(';'));
184
- }
185
-
186
- export function removeUmid() {
187
- fs.rmSync(dir(), { recursive: true, force: true });
188
- }
189
-
190
- /** 面板 / 状态接口用的摘要 */
191
- export function umidInfo() {
192
- const m = installedUmid();
193
- return {
194
- supported: umidSupported(),
195
- installing: umidInstalling(),
196
- installed: m ? { version: m.version, arch: m.arch, installedAt: m.installedAt } : null,
197
- };
198
- }
1
+ /**
2
+ * Qoder 设备身份组件 —— 给没有 Qoder 客户端的 Linux(fnOS / NAS)提供国际版签到所需的风控身份。
3
+ *
4
+ * Qoder 国际版只向带 Cosy-MachineToken / Code / Type 的请求下发「每天领 100 Credits」,
5
+ * 这三个值由 Qoder 的 UMID 程序(runtime-info)生成。桌面端客户端自带它;官方 CLI
6
+ * @qoder-ai/qodercli 则把各平台的 UMID 二进制以 base64 内嵌在 bundle/qodercli.js 里,Linux x64 / arm64 都有。
7
+ *
8
+ * 这里在用户确认后从 npm 仓库下载官方 qodercli 包(校验 npm 的 sha512 integrity),
9
+ * 按本机 CPU 架构取出对应的 ELF,试跑一次确认能产出身份,再存到 ~/.creditdaddy/qoder-umid/。
10
+ * 不随 CreditDaddy 分发该二进制;调用方式与 qodercli 一致:runtime-info <env>,第一行输出 JSON。
11
+ * env:国际版 4(SINGAPORE,qodercli 的全球区取值),国内版 0(ONLINE)。
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import crypto from 'node:crypto';
17
+ import zlib from 'node:zlib';
18
+ import { execFile } from 'node:child_process';
19
+ import { dataDir } from './store.js';
20
+ import { logger } from './logger.js';
21
+
22
+ const PKG = '@qoder-ai/qodercli';
23
+ const REGISTRIES = ['https://registry.npmmirror.com', 'https://registry.npmjs.org'];
24
+ const AUTH_REGISTRY = 'https://registry.npmjs.org'; // integrity 的权威来源(镜像只用于加速下载)
25
+ const ELF_MACHINE = { x64: 62, arm64: 183 };
26
+ const BUNDLE_ENTRY = 'package/bundle/qodercli.js';
27
+ const FETCH_TIMEOUT_MS = 180_000;
28
+ const META_TIMEOUT_MS = 15_000;
29
+ export const CLI_RISK_ENV = { qoder: 4, 'qoder-cn': 0 };
30
+
31
+ export function umidSupported(platform = process.platform, arch = process.arch) {
32
+ return platform === 'linux' && Boolean(ELF_MACHINE[arch]);
33
+ }
34
+
35
+ const dir = () => path.join(dataDir(), 'qoder-umid');
36
+ const binPath = () => path.join(dir(), 'runtime-info');
37
+ const manifestPath = () => path.join(dir(), 'manifest.json');
38
+
39
+ /** 已安装的组件:{ path, version, arch, sha256, installedAt, registry };未安装返回 null */
40
+ export function installedUmid() {
41
+ if (!umidSupported()) return null;
42
+ try {
43
+ const m = JSON.parse(fs.readFileSync(manifestPath(), 'utf8'));
44
+ if (!fs.existsSync(binPath()) || m.arch !== process.arch) return null;
45
+ return { ...m, path: binPath() };
46
+ } catch { return null; }
47
+ }
48
+
49
+ // ── 纯函数(便于单测) ──
50
+
51
+ /** 最小 tar 解析:按名字取一个普通文件的内容 */
52
+ export function tarEntry(tar, wanted) {
53
+ let off = 0;
54
+ const cstr = (b, s, n) => { const x = b.subarray(s, s + n); const z = x.indexOf(0); return x.subarray(0, z < 0 ? n : z).toString('utf8'); };
55
+ let longName = null;
56
+ while (off + 512 <= tar.length) {
57
+ const h = tar.subarray(off, off + 512);
58
+ if (h.every((b) => b === 0)) break;
59
+ const size = parseInt(cstr(h, 124, 12).trim() || '0', 8);
60
+ const type = String.fromCharCode(h[156] || 48);
61
+ const prefix = cstr(h, 345, 155);
62
+ let name = longName || (prefix ? prefix + '/' : '') + cstr(h, 0, 100);
63
+ longName = null;
64
+ const body = tar.subarray(off + 512, off + 512 + size);
65
+ off += 512 + Math.ceil(size / 512) * 512;
66
+ if (type === 'L') { longName = body.toString('utf8').replace(/\0+$/, ''); continue; }
67
+ if (type === 'x') { // pax 扩展头里的 path 覆盖下一个条目的名字
68
+ const m = /\d+ path=([^\n]+)\n/.exec(body.toString('utf8'));
69
+ if (m) longName = m[1];
70
+ continue;
71
+ }
72
+ if ((type === '0' || type === '\0') && name === wanted) return body;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** 在 qodercli bundle 文本里找内嵌的 ELF(base64 字面量),按 e_machine 挑出本机架构 */
78
+ export function extractElf(jsText, machine) {
79
+ const MARK = '"f0VMRgIB'; // "\x7fELF" + 64 位 + 小端 的 base64 开头
80
+ let i = jsText.indexOf(MARK);
81
+ while (i >= 0) {
82
+ const start = i + 1;
83
+ const end = jsText.indexOf('"', start);
84
+ if (end < 0) break;
85
+ const head = Buffer.from(jsText.slice(start, start + 28), 'base64');
86
+ if (head.length >= 20 && head.readUInt16LE(18) === machine) {
87
+ return Buffer.from(jsText.slice(start, end), 'base64');
88
+ }
89
+ i = jsText.indexOf(MARK, end);
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** npm integrity(sha512-<base64>)校验 */
95
+ export function checkIntegrity(buf, integrity) {
96
+ const m = /^sha512-(.+)$/.exec(String(integrity || '').split(/\s+/)[0]);
97
+ if (!m) return false;
98
+ return crypto.createHash('sha512').update(buf).digest('base64') === m[1];
99
+ }
100
+
101
+ // ── 安装 ──
102
+
103
+ async function fetchBuf(url, timeoutMs = FETCH_TIMEOUT_MS) {
104
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
105
+ if (!res.ok) throw new Error(`HTTP ${res.status}:${url}`);
106
+ return Buffer.from(await res.arrayBuffer());
107
+ }
108
+
109
+ /**
110
+ * 从 npmjs 拉权威 integrity。镜像(npmmirror)的元数据与 tarball 同源,
111
+ * 镜像被污染时它自己的 integrity 校验形同虚设,所以校验值以 npmjs 为准;
112
+ * npmjs 不可达时返回 null,退回镜像自身 integrity(与旧行为一致)。
113
+ */
114
+ async function authoritativeIntegrity() {
115
+ try {
116
+ const meta = JSON.parse((await fetchBuf(`${AUTH_REGISTRY}/${PKG.replace('/', '%2f')}/latest`, META_TIMEOUT_MS)).toString('utf8'));
117
+ if (meta?.dist?.integrity && meta?.version) return { integrity: meta.dist.integrity, version: meta.version };
118
+ } catch {}
119
+ return null;
120
+ }
121
+
122
+ function testRun(file) {
123
+ return new Promise((resolve, reject) => {
124
+ execFile(file, [String(CLI_RISK_ENV.qoder)], { timeout: 30_000, maxBuffer: 1 << 20 }, (err, stdout) => {
125
+ try {
126
+ const j = JSON.parse(String(stdout).split('\n')[0]);
127
+ if (typeof j.machineToken === 'string' && j.machineToken && j.machineCode && j.machineType) return resolve(j);
128
+ } catch {}
129
+ reject(new Error('组件试运行失败' + (err ? ':' + err.message : '(输出里没有设备身份)')));
130
+ });
131
+ });
132
+ }
133
+
134
+ let installing = null;
135
+
136
+ /** 下载官方 qodercli 包并提取本机架构的 UMID;并发调用共用同一次安装 */
137
+ export function installUmid() {
138
+ if (!installing) installing = doInstall().finally(() => { installing = null; });
139
+ return installing;
140
+ }
141
+ export const umidInstalling = () => Boolean(installing);
142
+
143
+ async function doInstall() {
144
+ if (!umidSupported()) throw new Error(`当前平台(${process.platform}/${process.arch})不需要或不支持该组件`);
145
+ const authority = await authoritativeIntegrity();
146
+ const errors = [];
147
+ for (const registry of REGISTRIES) {
148
+ try {
149
+ const meta = JSON.parse((await fetchBuf(`${registry}/${PKG.replace('/', '%2f')}/latest`, META_TIMEOUT_MS)).toString('utf8'));
150
+ const { tarball, integrity } = meta.dist || {};
151
+ if (!tarball || !integrity) throw new Error('包元数据缺少 tarball / integrity');
152
+ // 镜像版本与 npmjs 不一致时没有权威校验值可比,跳过镜像直接用官方源
153
+ if (authority && registry !== AUTH_REGISTRY && meta.version !== authority.version) {
154
+ throw new Error(`镜像版本(${meta.version})与 npmjs(${authority.version})不一致`);
155
+ }
156
+ const expected = authority && authority.version === meta.version ? authority.integrity : integrity;
157
+ logger.info('UMID', `下载 ${PKG}@${meta.version}(${registry})`);
158
+ const tgz = await fetchBuf(tarball);
159
+ if (!checkIntegrity(tgz, expected)) throw new Error('下载内容与 npm integrity 不一致');
160
+ const bundle = tarEntry(zlib.gunzipSync(tgz), BUNDLE_ENTRY);
161
+ if (!bundle) throw new Error(`包里没有 ${BUNDLE_ENTRY}(qodercli 结构可能变化)`);
162
+ const elf = extractElf(bundle.toString('latin1'), ELF_MACHINE[process.arch]);
163
+ if (!elf) throw new Error(`qodercli ${meta.version} 里没有 ${process.arch} 的 UMID 程序(结构可能变化)`);
164
+
165
+ fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
166
+ const tmp = binPath() + '.' + process.pid + '.tmp';
167
+ fs.writeFileSync(tmp, elf, { mode: 0o755 });
168
+ try { await testRun(tmp); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
169
+ fs.renameSync(tmp, binPath());
170
+ const manifest = {
171
+ version: meta.version, arch: process.arch, registry,
172
+ sha256: crypto.createHash('sha256').update(elf).digest('hex'),
173
+ installedAt: new Date().toISOString(),
174
+ };
175
+ fs.writeFileSync(manifestPath(), JSON.stringify(manifest, null, 2), { mode: 0o600 });
176
+ logger.info('UMID', `设备身份组件已安装:qodercli ${meta.version} / ${process.arch}`);
177
+ return { ...manifest, path: binPath() };
178
+ } catch (e) {
179
+ errors.push(`${registry}:${e.message}`);
180
+ logger.warn('UMID', `从 ${registry} 安装失败:${e.message}`);
181
+ }
182
+ }
183
+ throw new Error('设备身份组件安装失败 —— ' + errors.join(';'));
184
+ }
185
+
186
+ export function removeUmid() {
187
+ fs.rmSync(dir(), { recursive: true, force: true });
188
+ }
189
+
190
+ /** 面板 / 状态接口用的摘要 */
191
+ export function umidInfo() {
192
+ const m = installedUmid();
193
+ return {
194
+ supported: umidSupported(),
195
+ installing: umidInstalling(),
196
+ installed: m ? { version: m.version, arch: m.arch, installedAt: m.installedAt } : null,
197
+ };
198
+ }
package/src/store.js CHANGED
@@ -98,7 +98,16 @@ export async function loadAccounts() {
98
98
 
99
99
  export async function saveAccounts(accounts) {
100
100
  await ensureDir();
101
- await atomicWrite(ACCOUNTS_FILE(), JSON.stringify(accounts, null, 2));
101
+ const file = ACCOUNTS_FILE();
102
+ const bak = file + '.bak';
103
+ try {
104
+ // 只有当已有账号文件存在且非空时才建立/更新 .bak 备份
105
+ const existing = await fs.readFile(file, 'utf8').catch(() => null);
106
+ if (existing && existing.trim().length > 2) {
107
+ await atomicWrite(bak, existing);
108
+ }
109
+ } catch {}
110
+ await atomicWrite(file, JSON.stringify(accounts, null, 2));
102
111
  }
103
112
 
104
113
  // 进程内串行队列:所有「读-改-写」都经由 withAccounts,避免并发请求互相覆盖
@@ -200,6 +209,12 @@ export function publicAccount(a) {
200
209
  phone: maskPhone(meta.phone),
201
210
  domain: meta.domain || null,
202
211
  flavor: a.provider === 'zcode' ? zcodeFlavor(meta) : null,
212
+ // Qoder 网页会话新鲜度(10Router issue #44):只回时间戳、绝不回 Cookie。
213
+ // 10Router 的「套餐内 Credits」明细依赖导出的这份会话;面板据此在
214
+ // 导出/同步缺会话或明显过期的 Qoder 账号时给出提示。
215
+ qoderWebCapturedAt: String(a.provider).startsWith('qoder')
216
+ ? (meta.qoderWebSession?.capturedAt || null)
217
+ : null,
203
218
  canSwitch: Boolean(meta.session?.account || meta.credentials || meta.qoderAuth || a.provider === 'catpaw' || a.provider === 'trae'),
204
219
  verified: a.verified ?? null,
205
220
  createdAt: a.createdAt,
@@ -215,12 +230,37 @@ export async function loadSettings() {
215
230
  return s && typeof s === 'object' && !Array.isArray(s) ? s : {};
216
231
  }
217
232
 
218
- /** 合并写入设置并返回合并后的完整对象(原子写) */
219
- export async function saveSettings(patch) {
220
- await ensureDir();
221
- const next = Object.assign(await loadSettings(), patch);
222
- await atomicWrite(SETTINGS_FILE(), JSON.stringify(next, null, 2));
223
- return next;
233
+ // 与 accountsQueue 同样的串行队列:saveSettings 内部是「读 → 合并 → 写」三步,
234
+ // 不串行的话两个并发写(面板改密码 + 网关切局域网)会各自基于旧快照落盘,互相覆盖。
235
+ let settingsQueue = Promise.resolve();
236
+
237
+ /** 合并写入设置并返回合并后的完整对象(原子写,串行执行) */
238
+ export function saveSettings(patch) {
239
+ const run = settingsQueue.then(async () => {
240
+ await ensureDir();
241
+ const next = Object.assign(await loadSettings(), patch);
242
+ await atomicWrite(SETTINGS_FILE(), JSON.stringify(next, null, 2));
243
+ return next;
244
+ });
245
+ settingsQueue = run.catch(() => {});
246
+ return run;
247
+ }
248
+
249
+ /**
250
+ * 串行地读取 → 修改 settings.json。fn 直接改传入对象即可,返回 fn 的结果。
251
+ * 只用于「读出来算一算再写回去」的调用方(单次 saveSettings 已经是原子的)。
252
+ * 锁顺序:先 accountsQueue 再 settingsQueue,禁止反向嵌套,否则会死锁。
253
+ */
254
+ export function withSettings(fn) {
255
+ const run = settingsQueue.then(async () => {
256
+ await ensureDir();
257
+ const settings = await loadSettings();
258
+ const result = await fn(settings);
259
+ await atomicWrite(SETTINGS_FILE(), JSON.stringify(settings, null, 2));
260
+ return result;
261
+ });
262
+ settingsQueue = run.catch(() => {});
263
+ return run;
224
264
  }
225
265
 
226
266
  // ─── 运行状态(签到日历) ───
@@ -235,4 +275,22 @@ export async function saveState(state) {
235
275
  await atomicWrite(STATE_FILE(), JSON.stringify(state, null, 2));
236
276
  }
237
277
 
278
+ // state.json 有多个互不相干的写入方(签到轮写 qoderDailyDone / deviceClaim,
279
+ // 网关写 zcodeGatewayExhausted),各自「读 → 改几个键 → 写回」会丢更新:
280
+ // 签到轮拿旧快照落盘,就把网关刚写的耗尽打标抹掉了。与 settings 共用一个队列。
281
+ let stateQueue = Promise.resolve();
282
+
283
+ /** 串行地读取 → 修改 → 保存运行状态;fn 直接改传入对象即可,返回 fn 的结果。 */
284
+ export function withState(fn) {
285
+ const run = stateQueue.then(async () => {
286
+ await ensureDir();
287
+ const state = await loadState();
288
+ const result = await fn(state);
289
+ await atomicWrite(STATE_FILE(), JSON.stringify(state, null, 2));
290
+ return result;
291
+ });
292
+ stateQueue = run.catch(() => {});
293
+ return run;
294
+ }
295
+
238
296
  // 导入 / 导出(含 10router 互通)见 transfer.js
package/src/tenrouter.js CHANGED
@@ -123,8 +123,16 @@ async function call(c, pathname, { method = 'GET', body, timeout = QUOTA_TIMEOUT
123
123
  headers: { Authorization: `Bearer ${c.key}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
124
124
  body: body ? JSON.stringify(body) : undefined,
125
125
  signal: AbortSignal.timeout(timeout),
126
+ // 请求体里是本机各账号的 token/用量,Authorization 是 10Router 的 key。
127
+ // fetch 默认跟随重定向,而 307/308 会把请求体原样重放到新地址(Authorization 会被
128
+ // 剥掉,但 body 不会)——一个被 307 的 10Router 就足以把凭据送到别处。直接拒绝重定向。
129
+ redirect: 'error',
126
130
  });
127
131
  } catch (e) {
132
+ // redirect:'error' 抛的是 cause.message === 'unexpected redirect'(顶层 message 只是 "fetch failed")
133
+ if (e?.cause?.message === 'unexpected redirect') {
134
+ throw new Error(`10Router(${c.endpoint})返回了重定向,已拒绝跟随:凭据只发往你填写的地址,请把地址改成最终地址`);
135
+ }
128
136
  throw new Error(`无法连接 10Router(${c.endpoint}):${e.cause?.code || e.message}`);
129
137
  }
130
138
  const text = await res.text();
@@ -41,8 +41,15 @@ async function trFetch(url, { key, cookie, method = 'GET', body } = {}) {
41
41
  },
42
42
  body: body ? JSON.stringify(body) : undefined,
43
43
  signal: AbortSignal.timeout(TIMEOUT_MS),
44
+ // 这里的 body 是账号明文 token(/api/oauth/transfer/import)与 apiKey(/api/providers)。
45
+ // 307/308 重定向会把 body 原样重放到新地址,Authorization 反而会被剥掉——所以必须拒绝。
46
+ redirect: 'error',
44
47
  });
45
48
  } catch (e) {
49
+ // redirect:'error' 抛的是 cause.message === 'unexpected redirect'(顶层 message 只是 "fetch failed")
50
+ if (e?.cause?.message === 'unexpected redirect') {
51
+ throw new Error(`10Router 返回了重定向,已拒绝跟随:账号凭据只发往你填写的地址,请把地址改成最终地址`);
52
+ }
46
53
  throw new Error(`无法连接 10Router:${e.cause?.code || e.message}`);
47
54
  }
48
55
  const text = await res.text();
@@ -85,6 +92,9 @@ async function syncViaTransfer({ endpoint, cookie, groups }) {
85
92
  results.push({
86
93
  provider: target, count: list.length, status: (d.failed || 0) ? 'partial' : 'ok',
87
94
  imported: d.imported || 0, updated: d.updated || 0, skipped: d.skipped || 0, failed: d.failed || 0,
95
+ // 10Router issue #44:导入端会探测刚同步过去的网页会话是否仍可用,
96
+ // 失败清单直接回显到本端摘要(见 syncAccountsTo10r 的 sessionWarns)。
97
+ webSessions: d.webSessions || null,
88
98
  });
89
99
  }
90
100
  return results;
@@ -96,9 +106,15 @@ async function syncViaProviders({ endpoint, key, groups }) {
96
106
  for (const [target, list] of groups) {
97
107
  const row = { provider: target, count: list.length, status: 'ok', imported: 0, skipped: 0, failed: 0 };
98
108
  for (const a of list) {
99
- const r = await trFetch(`${endpoint}/api/providers`, {
100
- key, method: 'POST', body: { provider: target, apiKey: a.token, name: connName(a) },
101
- });
109
+ // issue #44:qoder 的 apikey 连接同样按 providerSpecificData 读网页会话
110
+ // (usage 侧 getQoderUsage 不区分 authType),所以新建连接时把会话一并
111
+ // 挂上;已存在的同名连接此接口不更新(409),缺失由同步摘要点名提醒。
112
+ const web = a.meta?.qoderWebSession?.cookie && String(a.provider).startsWith('qoder')
113
+ ? { creditDaddyWebSession: { cookie: a.meta.qoderWebSession.cookie, capturedAt: a.meta.qoderWebSession.capturedAt || null, userId: a.uid || null } }
114
+ : null;
115
+ const body = { provider: target, apiKey: a.token, name: connName(a) };
116
+ if (web) body.providerSpecificData = { userId: a.uid || null, ...web };
117
+ const r = await trFetch(`${endpoint}/api/providers`, { key, method: 'POST', body });
102
118
  if (r.status === 201 || r.status === 200) row.imported++;
103
119
  else if (r.status === 409) row.skipped++;
104
120
  else { row.failed++; row.error = r.data?.error || `HTTP ${r.status}`; }
@@ -156,6 +172,25 @@ export async function syncAccountsTo10r({ endpoint, key, adminPassword, accounts
156
172
  if (sum('skipped')) parts.push(`已存在 ${sum('skipped')}`);
157
173
  if (sum('failed')) parts.push(`失败 ${sum('failed')}`);
158
174
  if (unmapped.size) parts.push('跳过 ' + [...unmapped].map(([p, n]) => `${p} ×${n}`).join('、'));
175
+ // issue #44:Qoder 网页会话决定 10Router 端「套餐内 Credits」能不能显示。
176
+ // OAuth 通道:10Router 导入时已逐账号探测,失效清单原样带回(deadNames);
177
+ // 两条通道都要点名本地就没有会话的账号(apikey 通道 webSessions 恒为
178
+ // null,条件自动适用;OAuth 通道里已被探测过的组不重复报)。
179
+ const deadNames = [];
180
+ for (const r of results) {
181
+ for (const f of r.webSessions?.failures || []) deadNames.push(f.name);
182
+ }
183
+ const sessionless = [];
184
+ for (const [target, list] of groups) {
185
+ if (!String(target).startsWith('qoder')) continue;
186
+ for (const a of list) {
187
+ if (results.find((r) => r.provider === target)?.webSessions == null && !a.meta?.qoderWebSession?.cookie) {
188
+ sessionless.push(connName(a));
189
+ }
190
+ }
191
+ }
192
+ if (sessionless.length) parts.push(`未带网页会话 ${sessionless.length}(${sessionless.join('、')},在 CreditDaddy 重新浏览器登录 Qoder 后再同步可补齐逐资源包明细)`);
193
+ if (deadNames.length) parts.push(`网页会话已失效 ${deadNames.length}(${deadNames.join('、')},重新登录 Qoder 网页后再次同步)`);
159
194
  const summary = (channel === 'oauth' ? '账号同步完成(OAuth 通道):' : '账号同步完成(apikey 通道,token 过期后需重新同步):')
160
195
  + (parts.join(',') || '无变化')
161
196
  + (failed.length ? `;${failed.map((r) => r.provider + ':' + (r.error || r.status)).join(';')}` : '');
package/src/traeClient.js CHANGED
@@ -223,6 +223,11 @@ export async function checkinTrae(account, ctx = {}) {
223
223
  return { ...base, status: 'already', message: '今天已签到', uid };
224
224
  }
225
225
 
226
+ // 签到应得额度:status 接口里通常预告 credits / extra_credits(如 100 积分),
227
+ // claim 接口有时只返回 { code: 0, message: "success" } 而不包含 credits 字段,作为 fallback 避免 +0
228
+ const TRAE_DEFAULT_DAILY_CREDITS = 100;
229
+ const expectedCredits = Number(findField(r.body, 'credits') ?? findField(r.body, 'extra_credits')) || TRAE_DEFAULT_DAILY_CREDITS;
230
+
226
231
  const c = await post(CLAIM_URL, account);
227
232
  if (c.code !== 0) {
228
233
  ctx.log?.(`Trae 领取失败:${c.message || `code ${c.code}`}`);
@@ -233,7 +238,7 @@ export async function checkinTrae(account, ctx = {}) {
233
238
  uid,
234
239
  };
235
240
  }
236
- const claimed = Number(findField(c.body, 'credits')) || 0;
241
+ const claimed = Number(findField(c.body, 'credits')) || expectedCredits;
237
242
  return { ...base, status: 'checked-in', claimedAmount: claimed, message: claimed ? `签到成功,+${claimed} 积分` : '签到成功', uid };
238
243
  }
239
244