creditdaddy 1.3.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/README.md +24 -2
- package/bin/creditdaddy.js +131 -2
- package/package.json +29 -29
- package/src/bgdaemon.js +332 -0
- package/src/checkin.js +3 -2
- package/src/daemon.js +65 -3
- package/src/logger.js +57 -12
- package/src/minimaxClient.js +10 -1
- package/src/minimaxGateway.js +14 -8
- package/src/panel.html +252 -14
- package/src/store.js +16 -1
- package/src/tenrouterAccounts.js +31 -3
- package/src/traeClient.js +6 -1
- package/src/traeGateway.js +679 -0
- package/src/zcodeClient.js +69 -11
- package/src/zcodeGateway.js +25 -13
- package/src/zcodePlanShape.json +3 -3
package/README.md
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
[下载](#下载) · [架构](#%EF%B8%8F-架构) · [功能](#功能) · [10Router 集成](#10router-集成) · [产品线说明](#产品线说明) · [快速开始](#快速开始) · [HTTP API](#本地-http-api) · [相关项目](#-相关项目) · [贡献者致谢](#-贡献者致谢) · [许可证](#-许可证)
|
|
18
18
|
|
|
19
|
-
<
|
|
19
|
+
<picture>
|
|
20
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshot-dashboard-dark.png">
|
|
21
|
+
<img src="docs/screenshot-dashboard-light.png" width="860" alt="CreditDaddy 仪表盘">
|
|
22
|
+
</picture>
|
|
20
23
|
|
|
21
24
|
</div>
|
|
22
25
|
|
|
@@ -146,10 +149,29 @@
|
|
|
146
149
|
# 全局安装(npm)
|
|
147
150
|
npm i -g creditdaddy
|
|
148
151
|
|
|
149
|
-
#
|
|
152
|
+
# 后台启动(关掉终端也继续跑,默认 http://127.0.0.1:47860)
|
|
153
|
+
creditdaddy start
|
|
154
|
+
|
|
155
|
+
# 或者前台跑(Ctrl+C 停止,调试用)
|
|
150
156
|
creditdaddy daemon
|
|
151
157
|
```
|
|
152
158
|
|
|
159
|
+
**后台运行**(CLI 用户用这个,不需要一直开着终端):
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
creditdaddy start # 后台启动;已在运行则直接报出地址,不会起第二个
|
|
163
|
+
creditdaddy status # 查看运行状态(未运行时退出码 1,便于脚本判断)
|
|
164
|
+
creditdaddy stop # 停止(没在跑也算成功)
|
|
165
|
+
creditdaddy restart # 重启
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`start` 会把实例拉到独立进程组,终端窗口关掉、SSH 断开都不影响每日自动签到。运行信息记在
|
|
169
|
+
`<数据目录>/daemon.json`,日志在 `<数据目录>/logs/background.log`(按日归档的运行日志另在
|
|
170
|
+
`daemon-<日期>.log`)。`start` / `stop` 都是幂等的,脚本里可以放心串着写。
|
|
171
|
+
|
|
172
|
+
> 想要**托盘图标**和**开机自启**请用方式一的桌面版;CLI 这条路只做后台守护,不装任何原生依赖。
|
|
173
|
+
> 也可以把 `creditdaddy start` 挂到系统服务 / 任务计划程序上实现开机自启。
|
|
174
|
+
|
|
153
175
|
**CLI 常用命令**:
|
|
154
176
|
|
|
155
177
|
```bash
|
package/bin/creditdaddy.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* creditdaddy remove <id> 删除账号
|
|
9
9
|
* creditdaddy export/import 导出/导入账号
|
|
10
10
|
* creditdaddy logs 查看领取状态(state.json)
|
|
11
|
+
* creditdaddy start/stop 后台运行(关掉终端不影响每日签到)
|
|
12
|
+
* creditdaddy status 查看后台运行状态
|
|
11
13
|
* creditdaddy help 显示帮助
|
|
12
14
|
*/
|
|
13
15
|
|
|
@@ -16,6 +18,7 @@ import { logger } from '../src/logger.js';
|
|
|
16
18
|
|
|
17
19
|
const args = process.argv.slice(2);
|
|
18
20
|
const cmd = args[0] || 'daemon';
|
|
21
|
+
const DEFAULT_BG_PORT = 47860; // 与 src/daemon.js 的 DEFAULT_PORT 一致
|
|
19
22
|
|
|
20
23
|
const HELP = `CreditDaddy — Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae 多账号管理 + 每日积分自动领取
|
|
21
24
|
|
|
@@ -31,6 +34,10 @@ const HELP = `CreditDaddy — Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Tra
|
|
|
31
34
|
creditdaddy import <file.json> [--password 口令] 导入 CreditDaddy / 10router 导出文件
|
|
32
35
|
creditdaddy scan 导入本机 Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae 客户端已登录的账号
|
|
33
36
|
creditdaddy umid [install|remove] Qoder 设备身份组件(Linux / fnOS 国际版领取用)
|
|
37
|
+
creditdaddy start [--port 47860] [--host 127.0.0.1] 后台启动(关掉终端也继续跑)
|
|
38
|
+
creditdaddy stop 停止后台实例
|
|
39
|
+
creditdaddy status 查看后台运行状态
|
|
40
|
+
creditdaddy restart 重启后台实例
|
|
34
41
|
creditdaddy logs 查看领取状态
|
|
35
42
|
creditdaddy help 显示本帮助`;
|
|
36
43
|
|
|
@@ -39,15 +46,137 @@ function flag(name) {
|
|
|
39
46
|
return i >= 0 ? args[i + 1] : undefined;
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
/** 面板访问地址(只监听回环时用 127.0.0.1,0.0.0.0 不能当访问地址用) */
|
|
50
|
+
function panelUrl(host, port) {
|
|
51
|
+
const h = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
52
|
+
return `http://${h}:${port}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 把运行时长说成人话(status 用) */
|
|
56
|
+
function humanUptime(ms) {
|
|
57
|
+
if (!Number.isFinite(ms) || ms < 0) return '未知';
|
|
58
|
+
const min = Math.floor(ms / 60000);
|
|
59
|
+
if (min < 1) return '不到 1 分钟';
|
|
60
|
+
if (min < 60) return `${min} 分钟`;
|
|
61
|
+
const h = Math.floor(min / 60);
|
|
62
|
+
if (h < 24) return `${h} 小时 ${min % 60} 分`;
|
|
63
|
+
return `${Math.floor(h / 24)} 天 ${h % 24} 小时`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 优雅退出。之前 src/ 里一个 process.on 都没有,Ctrl+C 是 Node 默认的硬杀:
|
|
68
|
+
* 状态文件留在原地(下次 start 会当成「已在运行」)、当天的日志既不落盘也不归档。
|
|
69
|
+
*/
|
|
70
|
+
function installShutdown({ server, background }) {
|
|
71
|
+
let closing = false;
|
|
72
|
+
const shutdown = async (signal) => {
|
|
73
|
+
if (closing) return;
|
|
74
|
+
closing = true;
|
|
75
|
+
try { logger.info('CLI', `收到 ${signal},正在退出…`); } catch {}
|
|
76
|
+
try { (await import('../src/checkin.js')).stopScheduler(); } catch {}
|
|
77
|
+
try { await server?.close(); } catch {}
|
|
78
|
+
if (background) {
|
|
79
|
+
// 只删自己写的状态文件:并发跑着第二个实例时别把它的状态抹掉
|
|
80
|
+
try { const { clearRuntime } = await import('../src/bgdaemon.js'); await clearRuntime(process.pid); } catch {}
|
|
81
|
+
}
|
|
82
|
+
// 等归档流真的 close(gzip 完成)再退,否则今天的日志丢了
|
|
83
|
+
try { await (await import('../src/logger.js')).closeArchiveStream(); } catch {}
|
|
84
|
+
process.exit(0);
|
|
85
|
+
};
|
|
86
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
87
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
88
|
+
// 关掉终端会发 SIGHUP。后台实例(stdout 不是 TTY)本来就该在终端消失后活着,
|
|
89
|
+
// 照着这个忽略;前台则当作用户要收摊。
|
|
90
|
+
process.on('SIGHUP', () => { if (process.stdout.isTTY) shutdown('SIGHUP'); });
|
|
91
|
+
}
|
|
92
|
+
|
|
42
93
|
async function main() {
|
|
43
94
|
switch (cmd) {
|
|
44
95
|
case 'daemon': {
|
|
45
96
|
const port = Number(flag('--port')) || Number(process.env.PORT) || undefined;
|
|
46
97
|
const host = flag('--host') || process.env.HOST || undefined;
|
|
47
|
-
await startDaemon(port, host);
|
|
98
|
+
const { server, port: boundPort } = await startDaemon(port, host);
|
|
99
|
+
// 端口被占时 daemon 会自动 +1 重试,真正在听的是 boundPort;
|
|
100
|
+
// 后台模式要把它记进状态文件,start/status 打印的必须是这个值
|
|
101
|
+
const background = process.env.CREDITDADDY_BG === '1';
|
|
102
|
+
if (background) {
|
|
103
|
+
const { APP_VERSION } = await import('../src/constants.js');
|
|
104
|
+
const { writeRuntime } = await import('../src/bgdaemon.js');
|
|
105
|
+
await writeRuntime({
|
|
106
|
+
pid: process.pid,
|
|
107
|
+
port: boundPort,
|
|
108
|
+
host: host || '127.0.0.1',
|
|
109
|
+
startedAt: new Date().toISOString(),
|
|
110
|
+
version: APP_VERSION,
|
|
111
|
+
});
|
|
112
|
+
logger.info('CLI', '已在后台运行');
|
|
113
|
+
}
|
|
48
114
|
const { startScheduler } = await import('../src/checkin.js');
|
|
49
115
|
startScheduler();
|
|
50
|
-
|
|
116
|
+
installShutdown({ server, background });
|
|
117
|
+
logger.info('CLI', background ? '按 creditdaddy stop 停止' : '按 Ctrl+C 停止');
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'start': {
|
|
121
|
+
const { startBackground, backgroundLogFile } = await import('../src/bgdaemon.js');
|
|
122
|
+
const { APP_VERSION } = await import('../src/constants.js');
|
|
123
|
+
const port = Number(flag('--port')) || Number(process.env.PORT) || DEFAULT_BG_PORT;
|
|
124
|
+
const host = flag('--host') || '127.0.0.1';
|
|
125
|
+
console.log('正在后台启动 CreditDaddy…');
|
|
126
|
+
const r = await startBackground({ port, host });
|
|
127
|
+
if (r.error) { console.error('✗', r.error); process.exit(1); }
|
|
128
|
+
const rec = r.rec;
|
|
129
|
+
console.log(r.already ? '✓ 已在运行' : '✓ 已启动');
|
|
130
|
+
console.log(' 面板:', panelUrl(rec.host, rec.port));
|
|
131
|
+
console.log(' 进程:pid', rec.pid, '· 端口', rec.port, '· 版本', rec.version || APP_VERSION);
|
|
132
|
+
console.log(' 日志:', backgroundLogFile());
|
|
133
|
+
console.log(' 停止:creditdaddy stop');
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'stop': {
|
|
137
|
+
const { stopDaemon } = await import('../src/bgdaemon.js');
|
|
138
|
+
const r = await stopDaemon();
|
|
139
|
+
// 没在跑也算成功:脚本里 stop 之后紧接 start 不该因为「本来就没跑」而中断
|
|
140
|
+
if (!r.stopped) {
|
|
141
|
+
console.log(r.stale ? '· 清理了残留状态(进程已不在)' : '· 未在运行');
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
console.log('✓ 已停止(pid ' + r.rec.pid + ')');
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'status': {
|
|
148
|
+
const { statusRuntime } = await import('../src/bgdaemon.js');
|
|
149
|
+
const s = await statusRuntime();
|
|
150
|
+
if (!s.running) {
|
|
151
|
+
console.log('· 未在运行');
|
|
152
|
+
if (s.reason === 'no-state') {
|
|
153
|
+
console.log(' 启动:creditdaddy start');
|
|
154
|
+
} else if (s.pidAlive) {
|
|
155
|
+
// 进程在但端口不应答:多半卡在 listen 之前,或状态文件指向了旧实例
|
|
156
|
+
console.log(' 进程 pid ' + s.pid + ' 还在,但面板没有应答;建议 creditdaddy stop 后重启');
|
|
157
|
+
}
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
const startedMs = s.startedAt ? Date.now() - Date.parse(s.startedAt) : NaN;
|
|
161
|
+
console.log('✓ 运行中');
|
|
162
|
+
console.log(' 面板:', panelUrl(s.host, s.port));
|
|
163
|
+
console.log(' 进程:pid', s.pid, '· 端口', s.port, '· 版本', s.probe?.version || s.version || '未知');
|
|
164
|
+
console.log(' 已运行:', humanUptime(startedMs));
|
|
165
|
+
if (s.probe?.needsKey) console.log(' 账号数:需面板密码(creditdaddy 设过访问密码)');
|
|
166
|
+
else if (s.probe?.accountsCount != null) console.log(' 账号数:', s.probe.accountsCount);
|
|
167
|
+
if (s.probe?.nextTickAt) console.log(' 下次签到:', new Date(s.probe.nextTickAt).toLocaleString());
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'restart': {
|
|
171
|
+
const { stopDaemon, startBackground, backgroundLogFile } = await import('../src/bgdaemon.js');
|
|
172
|
+
await stopDaemon();
|
|
173
|
+
const port = Number(flag('--port')) || Number(process.env.PORT) || DEFAULT_BG_PORT;
|
|
174
|
+
const host = flag('--host') || '127.0.0.1';
|
|
175
|
+
const r = await startBackground({ port, host });
|
|
176
|
+
if (r.error) { console.error('✗', r.error); process.exit(1); }
|
|
177
|
+
console.log('✓ 已重启');
|
|
178
|
+
console.log(' 面板:', panelUrl(r.rec.host, r.rec.port));
|
|
179
|
+
console.log(' 日志:', backgroundLogFile());
|
|
51
180
|
break;
|
|
52
181
|
}
|
|
53
182
|
case 'add': {
|
package/package.json
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "creditdaddy",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "CreditDaddy — Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae / MiniMax 多账号本地管理与每日积分自动签到助手",
|
|
5
|
-
"homepage": "https://github.com/techysy/CreditDaddy",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/techysy/CreditDaddy.git"
|
|
9
|
-
},
|
|
10
|
-
"type": "module",
|
|
11
|
-
"bin": {
|
|
12
|
-
"creditdaddy": "bin/creditdaddy.js"
|
|
13
|
-
},
|
|
14
|
-
"files": [
|
|
15
|
-
"bin",
|
|
16
|
-
"src",
|
|
17
|
-
"README.md",
|
|
18
|
-
"LICENSE"
|
|
19
|
-
],
|
|
20
|
-
"engines": {
|
|
21
|
-
"node": ">=20"
|
|
22
|
-
},
|
|
23
|
-
"scripts": {
|
|
24
|
-
"start": "node bin/creditdaddy.js daemon",
|
|
25
|
-
"test": "node --test",
|
|
26
|
-
"release-check": "bash scripts/release-check.sh"
|
|
27
|
-
},
|
|
28
|
-
"license": "MIT"
|
|
29
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "creditdaddy",
|
|
3
|
+
"version": "1.3.2",
|
|
4
|
+
"description": "CreditDaddy — Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae / MiniMax 多账号本地管理与每日积分自动签到助手",
|
|
5
|
+
"homepage": "https://github.com/techysy/CreditDaddy",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/techysy/CreditDaddy.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"creditdaddy": "bin/creditdaddy.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"start": "node bin/creditdaddy.js daemon",
|
|
25
|
+
"test": "node --test",
|
|
26
|
+
"release-check": "bash scripts/release-check.sh"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|
package/src/bgdaemon.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CreditDaddy 后台守护 —— 让 `creditdaddy start` 把 daemon 丢到后台跑,终端可以随手关掉。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不直接把日志接到 daemon-YYYY-MM-DD.log:
|
|
5
|
+
* 同一个数据目录可能同时有多个实例(后台实例 + 用户手动跑的前台 daemon + 桌面版),
|
|
6
|
+
* 它们以 O_APPEND 共享当天文件。logger.js 关闭流时会 gzip 并清空原文件;子进程那侧
|
|
7
|
+
* 单独接 background.log,两个实例互不干扰,也避开 cleanupArchive 的文件名前缀约定。
|
|
8
|
+
*
|
|
9
|
+
* 状态落在 <数据目录>/daemon.json(不是 .pid),因为要记的不止 pid —— daemon 端口被占
|
|
10
|
+
* 时会自动 +1 重试最多 10 次(见 daemon.js 的 EADDRINUSE 分支),真正在听的端口只有
|
|
11
|
+
* startDaemon() resolve 后的 r.port 知道,所以由 daemon 进程自己写这个文件。
|
|
12
|
+
*
|
|
13
|
+
* 「在运行」= pid 存活 **且** 端口应答:pid 会被系统回收、文件会被手删,只查 pid 会误报;
|
|
14
|
+
* 只查端口又会把同端口的桌面版误认成 CLI 实例。状态查询走带鉴权的 /api/status(不新增
|
|
15
|
+
* 任何未鉴权端点),401 同样算「活着」—— 说明服务在,只是要面板密码。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
19
|
+
import fsSync from 'node:fs';
|
|
20
|
+
import fs from 'node:fs/promises';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import net from 'node:net';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { dataDir } from './store.js';
|
|
26
|
+
import { logger } from './logger.js';
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const CLI_ENTRY = path.join(__dirname, '..', 'bin', 'creditdaddy.js');
|
|
30
|
+
const DEFAULT_PORT = 47860;
|
|
31
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
32
|
+
|
|
33
|
+
// 后台启动最多等这么久:daemon 要读账号库、建 HTTP server、起签到定时器,冷启动慢一点正常
|
|
34
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
35
|
+
const READY_INTERVAL_MS = 250;
|
|
36
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
37
|
+
const STOP_TIMEOUT_MS = 8000;
|
|
38
|
+
|
|
39
|
+
/** 状态文件路径:跟随 store.js 的 dataDir(),尊重 CREDITDADDY_HOME */
|
|
40
|
+
export function runtimeFile() {
|
|
41
|
+
return path.join(dataDir(), 'daemon.json');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 后台实例的 stdout/stderr 落点(与按日归档的 daemon-*.log 分开,见文件头) */
|
|
45
|
+
export function backgroundLogFile() {
|
|
46
|
+
return path.join(dataDir(), 'logs', 'background.log');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 读状态文件。文件不存在 / 内容损坏都返回 null(调用方按「没有后台实例」处理) */
|
|
50
|
+
export function readRuntime() {
|
|
51
|
+
try {
|
|
52
|
+
const raw = fsSync.readFileSync(runtimeFile(), 'utf8');
|
|
53
|
+
const r = JSON.parse(raw);
|
|
54
|
+
if (!r || typeof r !== 'object' || !Number.isInteger(r.pid) || r.pid <= 0) return null;
|
|
55
|
+
return r;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function writeRuntime(rec) {
|
|
62
|
+
const file = runtimeFile();
|
|
63
|
+
try {
|
|
64
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
65
|
+
await fs.writeFile(file, JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
66
|
+
return true;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
logger.warn('BG', `写状态文件失败:${e?.message || e}`);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 删状态文件。仅当 pid 与自己一致时才删——避免把后一个实例的状态误删 */
|
|
74
|
+
export async function clearRuntime(expectedPid = null) {
|
|
75
|
+
try {
|
|
76
|
+
const cur = readRuntime();
|
|
77
|
+
if (expectedPid != null && cur && cur.pid !== expectedPid) return false;
|
|
78
|
+
await fs.unlink(runtimeFile());
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 进程是否还活着。
|
|
87
|
+
* ESRCH = 确实没有;EPERM = 在、只是当前用户无权发信号(不能当成已死);
|
|
88
|
+
* 抛到调用方的其他异常同样按「活着」处理——宁可多报一次运行中,也不要误杀。
|
|
89
|
+
*/
|
|
90
|
+
export function isAlive(pid) {
|
|
91
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return e?.code !== 'ESRCH';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 面板访问密码,取值顺序与 daemon.js 的 initPanelKey 保持一致:
|
|
102
|
+
* 面板设置里存的 panelKey 优先(用户可能已改过),环境变量兜底。
|
|
103
|
+
*/
|
|
104
|
+
export async function readPanelKey() {
|
|
105
|
+
try {
|
|
106
|
+
const { loadSettings } = await import('./store.js');
|
|
107
|
+
const s = await loadSettings();
|
|
108
|
+
if (typeof s.panelKey === 'string' && s.panelKey) return s.panelKey;
|
|
109
|
+
} catch { /* 读不到就退到环境变量 */ }
|
|
110
|
+
return process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 探一次 /api/status。200 与 401 都算 reachable(详见文件头)。
|
|
115
|
+
* 不带 Origin,能过 daemon 的 rejectForeignRequest(非浏览器客户端不受该校验约束)。
|
|
116
|
+
*/
|
|
117
|
+
export function probeStatus(rec, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
118
|
+
const port = Number(rec?.port);
|
|
119
|
+
const host = rec?.host || DEFAULT_HOST;
|
|
120
|
+
const probeHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
121
|
+
if (!Number.isInteger(port) || port <= 0) return Promise.resolve({ reachable: false });
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const req = http.request(
|
|
125
|
+
{ host: probeHost, port, path: '/api/status', method: 'GET', headers: { Host: `${probeHost}:${port}` } },
|
|
126
|
+
(res) => {
|
|
127
|
+
const chunks = [];
|
|
128
|
+
res.on('data', (c) => chunks.push(c));
|
|
129
|
+
res.on('end', () => {
|
|
130
|
+
const reachable = res.statusCode === 200 || res.statusCode === 401;
|
|
131
|
+
let body = null;
|
|
132
|
+
if (res.statusCode === 200) {
|
|
133
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { body = null; }
|
|
134
|
+
}
|
|
135
|
+
resolve({
|
|
136
|
+
reachable,
|
|
137
|
+
status: res.statusCode,
|
|
138
|
+
needsKey: res.statusCode === 401,
|
|
139
|
+
version: body?.version || null,
|
|
140
|
+
accountsCount: Number.isInteger(body?.accountsCount) ? body.accountsCount : null,
|
|
141
|
+
nextTickAt: body?.scheduler?.nextTickAt || null,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
// 401 也要把面板密码带上再试一次:daemon 可能在跑但设了访问密码,
|
|
147
|
+
// 拿不到 version 不影响「在运行」的判定,但要账号数就得认证。
|
|
148
|
+
req.on('error', () => resolve({ reachable: false }));
|
|
149
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ reachable: false }); });
|
|
150
|
+
readPanelKey().then((key) => {
|
|
151
|
+
if (key) req.setHeader('x-qd-key', key);
|
|
152
|
+
req.end();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 后台实例的完整状态:文件 + 进程 + 端口三处都查 */
|
|
158
|
+
export async function statusRuntime() {
|
|
159
|
+
const rec = readRuntime();
|
|
160
|
+
if (!rec) return { running: false, reason: 'no-state' };
|
|
161
|
+
const pidAlive = isAlive(rec.pid);
|
|
162
|
+
const probe = pidAlive ? await probeStatus(rec) : { reachable: false };
|
|
163
|
+
return { running: pidAlive && probe.reachable, pidAlive, probe, ...rec };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 端口已被别的东西占了?(和「本 daemon 已在上一个端口跑着」是两回事)
|
|
168
|
+
* 后台启动前先探,避免子进程悄悄漂到 47861 让用户摸不着面板在哪。
|
|
169
|
+
*/
|
|
170
|
+
/**
|
|
171
|
+
* 端口是否已被别的东西占了?(和「本 daemon 已在上一个端口跑着」是两回事)
|
|
172
|
+
*
|
|
173
|
+
* 用「自己试着 bind 一下」判断,而不是发 HTTP 请求探活:非 HTTP 的程序(数据库、
|
|
174
|
+
* 别的服务)占着端口时不会回话,HTTP 探测会一直等到超时,然后误报「端口空闲」,
|
|
175
|
+
* 子进程就这么悄悄漂到了下一个端口,用户摸不着面板到底在哪。绑一下立刻 close,
|
|
176
|
+
* 唯一目的就是拿到 EADDRINUSE 这个答案。
|
|
177
|
+
*/
|
|
178
|
+
export function portTaken(port, host = DEFAULT_HOST) {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
const probe = net.createServer();
|
|
181
|
+
probe.once('error', (e) => resolve(e?.code === 'EADDRINUSE'));
|
|
182
|
+
probe.once('listening', () => probe.close(() => resolve(false)));
|
|
183
|
+
try {
|
|
184
|
+
probe.listen(port, host);
|
|
185
|
+
} catch {
|
|
186
|
+
resolve(true);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 等后台实例真正开始应答(子进程要先写状态文件,再能被探到) */
|
|
192
|
+
async function waitReady(timeoutMs = READY_TIMEOUT_MS) {
|
|
193
|
+
const deadline = Date.now() + timeoutMs;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
const rec = readRuntime();
|
|
196
|
+
if (rec && isAlive(rec.pid)) {
|
|
197
|
+
const probe = await probeStatus(rec);
|
|
198
|
+
if (probe.reachable) return { ok: true, rec, probe };
|
|
199
|
+
}
|
|
200
|
+
await new Promise((r) => setTimeout(r, READY_INTERVAL_MS));
|
|
201
|
+
}
|
|
202
|
+
return { ok: false };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 后台拉起 daemon。
|
|
207
|
+
* 已有一个活着的实例就直接返回(幂等)——脚本里 `start` 连跑两次不该起两个服务。
|
|
208
|
+
*/
|
|
209
|
+
export async function startBackground({ port = DEFAULT_PORT, host = DEFAULT_HOST } = {}) {
|
|
210
|
+
const cur = await statusRuntime();
|
|
211
|
+
if (cur.running) return { already: true, rec: cur };
|
|
212
|
+
|
|
213
|
+
const stale = readRuntime();
|
|
214
|
+
if (stale) await clearRuntime(); // 残留状态(进程已死 / 端口失联),先清掉再起
|
|
215
|
+
|
|
216
|
+
if (await portTaken(port, host)) {
|
|
217
|
+
return { already: false, error: `端口 ${port} 已被其他程序占用` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const logFile = backgroundLogFile();
|
|
221
|
+
try {
|
|
222
|
+
await fs.mkdir(path.dirname(logFile), { recursive: true });
|
|
223
|
+
} catch { /* 目录建不了就退回继承父进程 stdio,daemon 本身不受影响 */ }
|
|
224
|
+
|
|
225
|
+
let out = 'ignore';
|
|
226
|
+
let logFd = null;
|
|
227
|
+
try {
|
|
228
|
+
logFd = fsSync.openSync(logFile, 'a');
|
|
229
|
+
out = logFd;
|
|
230
|
+
} catch { /* 落不了盘就让 daemon 继承 stdio */ }
|
|
231
|
+
|
|
232
|
+
let child;
|
|
233
|
+
try {
|
|
234
|
+
child = spawn(process.execPath, [CLI_ENTRY, 'daemon', '--port', String(port), '--host', host], {
|
|
235
|
+
detached: true, // 独立进程组:关掉终端不影响它
|
|
236
|
+
windowsHide: true, // Windows 上不要弹控制台窗口
|
|
237
|
+
stdio: ['ignore', out, out],
|
|
238
|
+
env: { ...process.env, CREDITDADDY_BG: '1' }, // 让 daemon 知道自己跑在后台
|
|
239
|
+
});
|
|
240
|
+
} catch (e) {
|
|
241
|
+
if (logFd != null) try { fsSync.closeSync(logFd); } catch {}
|
|
242
|
+
return { already: false, error: `启动失败:${e?.message || e}` };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (logFd != null) try { fsSync.closeSync(logFd); } catch {}
|
|
246
|
+
child.unref(); // 父进程立刻可以退出,子进程继续活着
|
|
247
|
+
|
|
248
|
+
const ready = await waitReady();
|
|
249
|
+
if (!ready.ok) {
|
|
250
|
+
// 没等到应答:把刚起的进程收掉,否则会留一个查不到状态的孤儿
|
|
251
|
+
await killPid(child.pid);
|
|
252
|
+
return { already: false, error: `启动超时(${Math.round(READY_TIMEOUT_MS / 1000)}s),日志见 ${logFile}` };
|
|
253
|
+
}
|
|
254
|
+
return { already: false, rec: ready.rec };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 杀掉一个 pid。
|
|
259
|
+
* Windows 没有进程组概念,process.kill(-pid) 会直接抛 EINVAL,所以用 taskkill /T 杀进程树;
|
|
260
|
+
* taskkill 不可用(精简系统 / 容器)时退回单杀。
|
|
261
|
+
*/
|
|
262
|
+
export async function killPid(pid) {
|
|
263
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
264
|
+
if (process.platform === 'win32') {
|
|
265
|
+
try {
|
|
266
|
+
execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
|
|
267
|
+
windowsHide: true, stdio: 'ignore', timeout: 10_000,
|
|
268
|
+
});
|
|
269
|
+
return true;
|
|
270
|
+
} catch { /* 进程可能已经没了,或 taskkill 不可用 */ }
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
// POSIX 下 detached 起的子进程自成进程组,-pid 才能连它自己拉起的子进程一起收掉
|
|
274
|
+
process.kill(-pid, 'SIGTERM');
|
|
275
|
+
return true;
|
|
276
|
+
} catch { /* 不是组长或已退出 */ }
|
|
277
|
+
try {
|
|
278
|
+
process.kill(pid, 'SIGTERM');
|
|
279
|
+
return true;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 端口是否已从监听状态退下 */
|
|
286
|
+
async function waitPortFree(port, host = DEFAULT_HOST, timeoutMs = STOP_TIMEOUT_MS) {
|
|
287
|
+
const deadline = Date.now() + timeoutMs;
|
|
288
|
+
while (Date.now() < deadline) {
|
|
289
|
+
if (!(await portTaken(port, host))) return true;
|
|
290
|
+
await new Promise((r) => setTimeout(r, READY_INTERVAL_MS));
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 等进程真正从进程表里消失。
|
|
297
|
+
* SIGTERM 只是投递信号,handler 跑完 + 内核回收之间还有几十毫秒到几秒的窗口——
|
|
298
|
+
* 端口先释放、pid 后回收是常态。不等它就报 stopped:true,调用方紧接着的
|
|
299
|
+
* isAlive(pid) 仍会读到 true,误判「停不干净」。
|
|
300
|
+
*/
|
|
301
|
+
async function waitPidDead(pid, timeoutMs = STOP_TIMEOUT_MS) {
|
|
302
|
+
const deadline = Date.now() + timeoutMs;
|
|
303
|
+
while (Date.now() < deadline) {
|
|
304
|
+
if (!isAlive(pid)) return true;
|
|
305
|
+
await new Promise((r) => setTimeout(r, READY_INTERVAL_MS));
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 停掉后台实例。
|
|
312
|
+
* 没在跑也正常返回(幂等)——脚本里 `stop` 后紧接 `start` 不该因为「本来就没跑」而中断。
|
|
313
|
+
*/
|
|
314
|
+
export async function stopDaemon() {
|
|
315
|
+
const rec = readRuntime();
|
|
316
|
+
if (!rec) return { stopped: false, reason: 'no-state' };
|
|
317
|
+
|
|
318
|
+
if (!isAlive(rec.pid)) {
|
|
319
|
+
await clearRuntime();
|
|
320
|
+
return { stopped: false, reason: 'not-running', stale: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await killPid(rec.pid);
|
|
324
|
+
const free = await waitPortFree(rec.port, rec.host);
|
|
325
|
+
// 端口释放不等于进程退出:SIGTERM 只是投递,handler 收尾 + 内核回收还有窗口。
|
|
326
|
+
// 必须等到 pid 真的没了再报 stopped,否则调用方紧接着 isAlive(pid) 仍读到 true。
|
|
327
|
+
const dead = await waitPidDead(rec.pid);
|
|
328
|
+
await clearRuntime();
|
|
329
|
+
if (!free) logger.warn('BG', `端口 ${rec.port} 迟迟没有释放,进程可能仍在收尾`);
|
|
330
|
+
if (!dead) logger.warn('BG', `进程 ${rec.pid} 在 ${Math.round(STOP_TIMEOUT_MS / 1000)}s 内没有退出干净`);
|
|
331
|
+
return { stopped: true, rec, portFree: free, pidDead: dead };
|
|
332
|
+
}
|
package/src/checkin.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { loadAccounts, loadState, withAccounts, withState } from './store.js';
|
|
6
6
|
import { productImpl } from './providers.js';
|
|
7
|
-
import { productOf } from './constants.js';
|
|
7
|
+
import { productOf, PROVIDER_LABEL } from './constants.js';
|
|
8
8
|
import { startUsageSyncScheduler } from './tenrouter.js';
|
|
9
9
|
import { refreshContext } from './accounts.js';
|
|
10
10
|
import { zcodeAutoClaim } from './zcodeAutoClaim.js';
|
|
@@ -237,7 +237,8 @@ async function runTickInner(opts) {
|
|
|
237
237
|
let deviceClaim = state?.deviceClaim?.day === today ? state.deviceClaim : null;
|
|
238
238
|
|
|
239
239
|
for (const account of accounts) {
|
|
240
|
-
const
|
|
240
|
+
const pTag = `[${PROVIDER_LABEL[account.provider] || account.provider}]`;
|
|
241
|
+
const label = `${pTag} ${account.name || account.id}`;
|
|
241
242
|
// 「今日已领」按各产品自己的业务日界比对:Qoder 10 点翻日,其余 0 点翻日
|
|
242
243
|
const todayFor = dayKey(Date.now(), account.provider);
|
|
243
244
|
try {
|