atm-droid 1.0.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.
- package/README.md +85 -0
- package/bin/atm.js +62 -0
- package/package.json +24 -0
- package/src/api.js +104 -0
- package/src/commands.js +373 -0
- package/src/config.js +77 -0
- package/src/daemon-runner.js +7 -0
- package/src/daemon.js +257 -0
- package/src/index.js +2 -0
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# ATM Client CLI
|
|
2
|
+
|
|
3
|
+
跨平台 Factory Token 管理工具,支持 Windows / macOS / Linux。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 全局安装
|
|
9
|
+
npm install -g atm-client
|
|
10
|
+
|
|
11
|
+
# 或本地安装后链接
|
|
12
|
+
cd atm-cli
|
|
13
|
+
npm install
|
|
14
|
+
npm link
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 使用
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 查看帮助
|
|
21
|
+
atm --help
|
|
22
|
+
|
|
23
|
+
# 登录(使用激活码)
|
|
24
|
+
atm login ABC-DEF-123
|
|
25
|
+
|
|
26
|
+
# 查看账号列表
|
|
27
|
+
atm list
|
|
28
|
+
|
|
29
|
+
# 切换账号(交互式选择)
|
|
30
|
+
atm switch
|
|
31
|
+
|
|
32
|
+
# 切换到指定账号
|
|
33
|
+
atm switch 1
|
|
34
|
+
|
|
35
|
+
# 查看当前状态
|
|
36
|
+
atm status
|
|
37
|
+
|
|
38
|
+
# 检查余额并自动切换
|
|
39
|
+
atm check
|
|
40
|
+
|
|
41
|
+
# 启动后台服务(实时同步)
|
|
42
|
+
atm start
|
|
43
|
+
|
|
44
|
+
# 停止后台服务
|
|
45
|
+
atm stop
|
|
46
|
+
|
|
47
|
+
# 退出登录
|
|
48
|
+
atm logout
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 功能
|
|
52
|
+
|
|
53
|
+
- ✅ 激活码登录
|
|
54
|
+
- ✅ 多账号管理
|
|
55
|
+
- ✅ 手动切换账号
|
|
56
|
+
- ✅ 自动切换(余额用完时)
|
|
57
|
+
- ✅ 后台服务(WebSocket 实时同步)
|
|
58
|
+
- ✅ 自动写入 ~/.factory/auth.json
|
|
59
|
+
|
|
60
|
+
## 后台服务
|
|
61
|
+
|
|
62
|
+
启动后台服务后,会保持与服务器的 WebSocket 连接:
|
|
63
|
+
- 实时接收 Token 更新
|
|
64
|
+
- 自动刷新 auth.json
|
|
65
|
+
- 余额用完自动切换
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
atm start # 启动
|
|
69
|
+
atm stop # 停止
|
|
70
|
+
atm status # 查看状态
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 配合 crontab 使用(Linux/macOS)
|
|
74
|
+
|
|
75
|
+
如果不想运行后台服务,可以用 crontab 定时检查:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# 每分钟检查一次
|
|
79
|
+
* * * * * /usr/local/bin/atm check
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 配置文件位置
|
|
83
|
+
|
|
84
|
+
- 配置: `~/.config/atm-client/config.json`
|
|
85
|
+
- Factory 认证: `~/.factory/auth.json`
|
package/bin/atm.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const pkg = require('../package.json');
|
|
6
|
+
const { login, logout, list, switchToken, status, start, stop, check } = require('../src/commands');
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name('atm')
|
|
10
|
+
.description('ATM Token Manager - Factory Token 管理工具')
|
|
11
|
+
.version(pkg.version);
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.command('login <code>')
|
|
15
|
+
.description('使用激活码登录')
|
|
16
|
+
.action(login);
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('logout')
|
|
20
|
+
.description('退出登录并清除数据')
|
|
21
|
+
.action(logout);
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command('list')
|
|
25
|
+
.alias('ls')
|
|
26
|
+
.description('列出所有可用账号')
|
|
27
|
+
.action(list);
|
|
28
|
+
|
|
29
|
+
program
|
|
30
|
+
.command('switch [index]')
|
|
31
|
+
.alias('sw')
|
|
32
|
+
.description('切换到指定账号')
|
|
33
|
+
.action(switchToken);
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.command('status')
|
|
37
|
+
.alias('st')
|
|
38
|
+
.description('查看当前状态')
|
|
39
|
+
.action(status);
|
|
40
|
+
|
|
41
|
+
program
|
|
42
|
+
.command('start')
|
|
43
|
+
.description('启动后台服务(实时同步)')
|
|
44
|
+
.action(start);
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command('stop')
|
|
48
|
+
.description('停止后台服务')
|
|
49
|
+
.action(stop);
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.command('check')
|
|
53
|
+
.description('检查并自动切换(余额不足时)')
|
|
54
|
+
.action(check);
|
|
55
|
+
|
|
56
|
+
program.parse();
|
|
57
|
+
|
|
58
|
+
if (!process.argv.slice(2).length) {
|
|
59
|
+
console.log(chalk.cyan('\n ATM Token Manager v' + pkg.version));
|
|
60
|
+
console.log(chalk.gray(' 跨平台 Factory Token 管理工具\n'));
|
|
61
|
+
program.outputHelp();
|
|
62
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "atm-droid",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ATM Token Manager CLI - 跨平台 Factory Token 管理工具",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"atm": "./bin/atm.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/atm.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["factory", "token", "manager", "cli"],
|
|
13
|
+
"author": "ATM Team",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"commander": "^11.1.0",
|
|
17
|
+
"chalk": "^4.1.2",
|
|
18
|
+
"inquirer": "^8.2.6",
|
|
19
|
+
"ws": "^8.14.2",
|
|
20
|
+
"node-fetch": "^2.7.0",
|
|
21
|
+
"ora": "^5.4.1",
|
|
22
|
+
"conf": "^10.2.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const fetch = require('node-fetch');
|
|
2
|
+
const { config, getDeviceId } = require('./config');
|
|
3
|
+
|
|
4
|
+
const getServerUrl = () => config.get('serverUrl');
|
|
5
|
+
|
|
6
|
+
// 激活码登录
|
|
7
|
+
async function activate(code) {
|
|
8
|
+
const deviceId = getDeviceId();
|
|
9
|
+
const response = await fetch(`${getServerUrl()}/api/activate`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({ code, device_id: deviceId })
|
|
13
|
+
});
|
|
14
|
+
return response.json();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 获取 Token 列表
|
|
18
|
+
async function getTokens() {
|
|
19
|
+
const sessionToken = config.get('sessionToken');
|
|
20
|
+
const deviceId = getDeviceId();
|
|
21
|
+
|
|
22
|
+
if (!sessionToken) {
|
|
23
|
+
throw new Error('未登录');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const response = await fetch(`${getServerUrl()}/api/tokens`, {
|
|
27
|
+
headers: {
|
|
28
|
+
'Authorization': `Bearer ${sessionToken}`,
|
|
29
|
+
'X-Device-ID': deviceId
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const result = await response.json();
|
|
34
|
+
|
|
35
|
+
// 如果返回加密数据,需要解密
|
|
36
|
+
if (result.success && result.data && result.iv) {
|
|
37
|
+
// 简化版:假设服务器返回明文(或添加解密逻辑)
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 激活指定 Token
|
|
45
|
+
async function activateToken(tokenId) {
|
|
46
|
+
const sessionToken = config.get('sessionToken');
|
|
47
|
+
const deviceId = getDeviceId();
|
|
48
|
+
|
|
49
|
+
const response = await fetch(`${getServerUrl()}/api/tokens/activate`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
'Authorization': `Bearer ${sessionToken}`,
|
|
54
|
+
'X-Device-ID': deviceId
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({ token_id: tokenId })
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return response.json();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 心跳
|
|
63
|
+
async function heartbeat() {
|
|
64
|
+
const sessionToken = config.get('sessionToken');
|
|
65
|
+
const deviceId = getDeviceId();
|
|
66
|
+
|
|
67
|
+
if (!sessionToken) return { valid: false };
|
|
68
|
+
|
|
69
|
+
const response = await fetch(`${getServerUrl()}/api/heartbeat`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'Authorization': `Bearer ${sessionToken}`,
|
|
74
|
+
'X-Device-ID': deviceId
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return response.json();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 解绑设备
|
|
82
|
+
async function unbind() {
|
|
83
|
+
const sessionToken = config.get('sessionToken');
|
|
84
|
+
const deviceId = getDeviceId();
|
|
85
|
+
|
|
86
|
+
const response = await fetch(`${getServerUrl()}/api/unbind`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
'Content-Type': 'application/json',
|
|
90
|
+
'Authorization': `Bearer ${sessionToken}`,
|
|
91
|
+
'X-Device-ID': deviceId
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return response.json();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
activate,
|
|
100
|
+
getTokens,
|
|
101
|
+
activateToken,
|
|
102
|
+
heartbeat,
|
|
103
|
+
unbind
|
|
104
|
+
};
|
package/src/commands.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const ora = require('ora');
|
|
3
|
+
const inquirer = require('inquirer');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { config, writeFactoryAuth, readFactoryAuth, getFactoryAuthPath } = require('./config');
|
|
7
|
+
const api = require('./api');
|
|
8
|
+
const daemon = require('./daemon');
|
|
9
|
+
|
|
10
|
+
// 格式化数字
|
|
11
|
+
function formatNumber(num) {
|
|
12
|
+
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
|
13
|
+
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
|
14
|
+
return num.toString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 登录
|
|
18
|
+
async function login(code) {
|
|
19
|
+
const spinner = ora('正在激活...').start();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const result = await api.activate(code);
|
|
23
|
+
|
|
24
|
+
if (result.success) {
|
|
25
|
+
config.set('sessionToken', result.session_token);
|
|
26
|
+
config.set('autoSwitch', result.auto_switch || false);
|
|
27
|
+
|
|
28
|
+
spinner.succeed(chalk.green('激活成功!'));
|
|
29
|
+
console.log(chalk.gray(` 会话有效期: ${new Date(result.expires_at * 1000).toLocaleString()}`));
|
|
30
|
+
console.log(chalk.gray(` 可用账号数: ${result.quota || 0}`));
|
|
31
|
+
|
|
32
|
+
if (result.auto_switch) {
|
|
33
|
+
console.log(chalk.cyan(' 自动切换: 已启用'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(chalk.yellow('\n 提示: 使用 atm list 查看账号列表'));
|
|
37
|
+
} else {
|
|
38
|
+
spinner.fail(chalk.red('激活失败: ' + (result.error || '未知错误')));
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
spinner.fail(chalk.red('激活失败: ' + e.message));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 退出
|
|
46
|
+
async function logout() {
|
|
47
|
+
const { confirm } = await inquirer.prompt([{
|
|
48
|
+
type: 'confirm',
|
|
49
|
+
name: 'confirm',
|
|
50
|
+
message: '确定要退出登录吗?',
|
|
51
|
+
default: false
|
|
52
|
+
}]);
|
|
53
|
+
|
|
54
|
+
if (!confirm) return;
|
|
55
|
+
|
|
56
|
+
const spinner = ora('正在退出...').start();
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await api.unbind();
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// 忽略错误
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
config.clear();
|
|
65
|
+
spinner.succeed(chalk.green('已退出登录'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 列出账号
|
|
69
|
+
async function list() {
|
|
70
|
+
const spinner = ora('获取账号列表...').start();
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const result = await api.getTokens();
|
|
74
|
+
|
|
75
|
+
if (!result.success) {
|
|
76
|
+
spinner.fail(chalk.red('获取失败: ' + (result.error || '未知错误')));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tokens = result.data || [];
|
|
81
|
+
const currentId = config.get('currentTokenId');
|
|
82
|
+
|
|
83
|
+
spinner.stop();
|
|
84
|
+
|
|
85
|
+
if (tokens.length === 0) {
|
|
86
|
+
console.log(chalk.yellow('\n 暂无可用账号'));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(chalk.cyan('\n 可用账号列表:\n'));
|
|
91
|
+
|
|
92
|
+
tokens.forEach((token, index) => {
|
|
93
|
+
const total = token.quota_total || 0;
|
|
94
|
+
const used = token.quota_used || 0;
|
|
95
|
+
const remaining = Math.max(0, total - used);
|
|
96
|
+
const isCurrent = token.id === currentId;
|
|
97
|
+
|
|
98
|
+
const prefix = isCurrent ? chalk.green('► ') : ' ';
|
|
99
|
+
const indexStr = chalk.gray(`${index + 1}.`);
|
|
100
|
+
const email = isCurrent ? chalk.green(token.email) : token.email;
|
|
101
|
+
const quota = remaining > 0
|
|
102
|
+
? chalk.cyan(`(${formatNumber(remaining)})`)
|
|
103
|
+
: chalk.red('(已用完)');
|
|
104
|
+
const current = isCurrent ? chalk.green(' [当前]') : '';
|
|
105
|
+
|
|
106
|
+
console.log(` ${prefix}${indexStr} ${email} ${quota}${current}`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
console.log(chalk.gray('\n 使用 atm switch <序号> 切换账号'));
|
|
110
|
+
|
|
111
|
+
} catch (e) {
|
|
112
|
+
spinner.fail(chalk.red('获取失败: ' + e.message));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 切换账号
|
|
117
|
+
async function switchToken(index) {
|
|
118
|
+
const spinner = ora('获取账号列表...').start();
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const result = await api.getTokens();
|
|
122
|
+
|
|
123
|
+
if (!result.success) {
|
|
124
|
+
spinner.fail(chalk.red('获取失败: ' + (result.error || '未知错误')));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tokens = result.data || [];
|
|
129
|
+
|
|
130
|
+
if (tokens.length === 0) {
|
|
131
|
+
spinner.fail(chalk.yellow('暂无可用账号'));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
spinner.stop();
|
|
136
|
+
|
|
137
|
+
let targetToken;
|
|
138
|
+
|
|
139
|
+
if (index) {
|
|
140
|
+
// 指定序号
|
|
141
|
+
const idx = parseInt(index) - 1;
|
|
142
|
+
if (idx < 0 || idx >= tokens.length) {
|
|
143
|
+
console.log(chalk.red('无效的序号'));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
targetToken = tokens[idx];
|
|
147
|
+
} else {
|
|
148
|
+
// 交互式选择
|
|
149
|
+
const choices = tokens.map((t, i) => {
|
|
150
|
+
const remaining = (t.quota_total || 0) - (t.quota_used || 0);
|
|
151
|
+
return {
|
|
152
|
+
name: `${t.email} (${formatNumber(remaining)})`,
|
|
153
|
+
value: t,
|
|
154
|
+
short: t.email
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const { selected } = await inquirer.prompt([{
|
|
159
|
+
type: 'list',
|
|
160
|
+
name: 'selected',
|
|
161
|
+
message: '选择要切换的账号:',
|
|
162
|
+
choices
|
|
163
|
+
}]);
|
|
164
|
+
|
|
165
|
+
targetToken = selected;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 执行切换
|
|
169
|
+
const switchSpinner = ora(`正在切换到 ${targetToken.email}...`).start();
|
|
170
|
+
|
|
171
|
+
const activateResult = await api.activateToken(targetToken.id);
|
|
172
|
+
|
|
173
|
+
if (activateResult.success) {
|
|
174
|
+
// 写入 auth.json
|
|
175
|
+
if (activateResult.access_token && activateResult.refresh_token) {
|
|
176
|
+
writeFactoryAuth(activateResult.access_token, activateResult.refresh_token);
|
|
177
|
+
config.set('currentTokenId', targetToken.id);
|
|
178
|
+
|
|
179
|
+
switchSpinner.succeed(chalk.green(`已切换到 ${targetToken.email}`));
|
|
180
|
+
console.log(chalk.gray(` auth.json 已更新: ${getFactoryAuthPath()}`));
|
|
181
|
+
} else {
|
|
182
|
+
switchSpinner.fail(chalk.red('切换失败: 未获取到 Token'));
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
switchSpinner.fail(chalk.red('切换失败: ' + (activateResult.error || '未知错误')));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
} catch (e) {
|
|
189
|
+
spinner.fail(chalk.red('切换失败: ' + e.message));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 查看状态
|
|
194
|
+
async function status() {
|
|
195
|
+
const sessionToken = config.get('sessionToken');
|
|
196
|
+
|
|
197
|
+
console.log(chalk.cyan('\n ATM Token Manager 状态\n'));
|
|
198
|
+
|
|
199
|
+
// 登录状态
|
|
200
|
+
if (sessionToken) {
|
|
201
|
+
console.log(chalk.green(' ✓ 已登录'));
|
|
202
|
+
} else {
|
|
203
|
+
console.log(chalk.red(' ✗ 未登录'));
|
|
204
|
+
console.log(chalk.gray(' 使用 atm login <激活码> 登录'));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 当前账号
|
|
209
|
+
const currentId = config.get('currentTokenId');
|
|
210
|
+
if (currentId) {
|
|
211
|
+
try {
|
|
212
|
+
const result = await api.getTokens();
|
|
213
|
+
if (result.success && result.data) {
|
|
214
|
+
const current = result.data.find(t => t.id === currentId);
|
|
215
|
+
if (current) {
|
|
216
|
+
const remaining = (current.quota_total || 0) - (current.quota_used || 0);
|
|
217
|
+
console.log(chalk.white(` 当前账号: ${current.email}`));
|
|
218
|
+
console.log(chalk.white(` 剩余配额: ${formatNumber(remaining)}`));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {
|
|
222
|
+
// 忽略
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
console.log(chalk.yellow(' 当前账号: 未选择'));
|
|
226
|
+
console.log(chalk.gray(' 使用 atm switch 选择账号'));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 自动切换
|
|
230
|
+
const autoSwitch = config.get('autoSwitch');
|
|
231
|
+
console.log(chalk.white(` 自动切换: ${autoSwitch ? '已启用' : '未启用'}`));
|
|
232
|
+
|
|
233
|
+
// 后台服务
|
|
234
|
+
const daemonStatus = daemon.getDaemonStatus();
|
|
235
|
+
if (daemonStatus.running) {
|
|
236
|
+
console.log(chalk.green(` 后台服务: 运行中 (PID: ${daemonStatus.pid})`));
|
|
237
|
+
} else {
|
|
238
|
+
console.log(chalk.gray(' 后台服务: 未运行'));
|
|
239
|
+
console.log(chalk.gray(' 使用 atm start 启动后台服务'));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// auth.json 状态
|
|
243
|
+
const auth = readFactoryAuth();
|
|
244
|
+
if (auth && auth.accessToken) {
|
|
245
|
+
console.log(chalk.green(' auth.json: 已配置'));
|
|
246
|
+
} else {
|
|
247
|
+
console.log(chalk.yellow(' auth.json: 未配置'));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 启动后台服务
|
|
254
|
+
async function start() {
|
|
255
|
+
const daemonStatus = daemon.getDaemonStatus();
|
|
256
|
+
|
|
257
|
+
if (daemonStatus.running) {
|
|
258
|
+
console.log(chalk.yellow('后台服务已在运行 (PID: ' + daemonStatus.pid + ')'));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const sessionToken = config.get('sessionToken');
|
|
263
|
+
if (!sessionToken) {
|
|
264
|
+
console.log(chalk.red('请先登录: atm login <激活码>'));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
console.log(chalk.cyan('正在启动后台服务...'));
|
|
269
|
+
|
|
270
|
+
// 使用 spawn 启动独立进程
|
|
271
|
+
const child = spawn(process.execPath, [
|
|
272
|
+
path.join(__dirname, 'daemon-runner.js')
|
|
273
|
+
], {
|
|
274
|
+
detached: true,
|
|
275
|
+
stdio: 'ignore'
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
child.unref();
|
|
279
|
+
|
|
280
|
+
// 等待一下确认启动
|
|
281
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
282
|
+
|
|
283
|
+
const newStatus = daemon.getDaemonStatus();
|
|
284
|
+
if (newStatus.running) {
|
|
285
|
+
console.log(chalk.green('✓ 后台服务已启动 (PID: ' + newStatus.pid + ')'));
|
|
286
|
+
console.log(chalk.gray(' 日志文件: ' + daemon.LOG_FILE));
|
|
287
|
+
} else {
|
|
288
|
+
console.log(chalk.red('启动失败,请检查日志: ' + daemon.LOG_FILE));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 停止后台服务
|
|
293
|
+
async function stop() {
|
|
294
|
+
const result = daemon.stopDaemon();
|
|
295
|
+
|
|
296
|
+
if (result) {
|
|
297
|
+
console.log(chalk.green('✓ 后台服务已停止'));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 检查并自动切换
|
|
302
|
+
async function check() {
|
|
303
|
+
const spinner = ora('检查账号状态...').start();
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const result = await api.getTokens();
|
|
307
|
+
|
|
308
|
+
if (!result.success) {
|
|
309
|
+
spinner.fail(chalk.red('检查失败: ' + (result.error || '未知错误')));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const tokens = result.data || [];
|
|
314
|
+
const currentId = config.get('currentTokenId');
|
|
315
|
+
|
|
316
|
+
// 检查当前账号余额
|
|
317
|
+
const current = tokens.find(t => t.id === currentId);
|
|
318
|
+
|
|
319
|
+
if (current) {
|
|
320
|
+
const remaining = (current.quota_total || 0) - (current.quota_used || 0);
|
|
321
|
+
|
|
322
|
+
if (remaining > 0) {
|
|
323
|
+
spinner.succeed(chalk.green(`当前账号 ${current.email} 余额充足 (${formatNumber(remaining)})`));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
spinner.text = '当前账号余额不足,正在切换...';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 找到有余额的账号
|
|
331
|
+
const available = tokens.filter(t => {
|
|
332
|
+
const r = (t.quota_total || 0) - (t.quota_used || 0);
|
|
333
|
+
return r > 0 && t.id !== currentId;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (available.length === 0) {
|
|
337
|
+
spinner.fail(chalk.red('所有账号余额已用完'));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 切换到余额最少的
|
|
342
|
+
available.sort((a, b) => {
|
|
343
|
+
const ra = (a.quota_total || 0) - (a.quota_used || 0);
|
|
344
|
+
const rb = (b.quota_total || 0) - (b.quota_used || 0);
|
|
345
|
+
return ra - rb;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const target = available[0];
|
|
349
|
+
const activateResult = await api.activateToken(target.id);
|
|
350
|
+
|
|
351
|
+
if (activateResult.success && activateResult.access_token) {
|
|
352
|
+
writeFactoryAuth(activateResult.access_token, activateResult.refresh_token);
|
|
353
|
+
config.set('currentTokenId', target.id);
|
|
354
|
+
spinner.succeed(chalk.green(`已自动切换到 ${target.email}`));
|
|
355
|
+
} else {
|
|
356
|
+
spinner.fail(chalk.red('切换失败'));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
} catch (e) {
|
|
360
|
+
spinner.fail(chalk.red('检查失败: ' + e.message));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
module.exports = {
|
|
365
|
+
login,
|
|
366
|
+
logout,
|
|
367
|
+
list,
|
|
368
|
+
switchToken,
|
|
369
|
+
status,
|
|
370
|
+
start,
|
|
371
|
+
stop,
|
|
372
|
+
check
|
|
373
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const Conf = require('conf');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
// 配置存储
|
|
7
|
+
const config = new Conf({
|
|
8
|
+
projectName: 'atm-client',
|
|
9
|
+
defaults: {
|
|
10
|
+
serverUrl: 'https://dd.776523718.xyz',
|
|
11
|
+
sessionToken: null,
|
|
12
|
+
deviceId: null,
|
|
13
|
+
currentTokenId: null,
|
|
14
|
+
autoSwitch: false
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Factory auth.json 路径
|
|
19
|
+
function getFactoryAuthPath() {
|
|
20
|
+
const home = os.homedir();
|
|
21
|
+
return path.join(home, '.factory', 'auth.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 确保 .factory 目录存在
|
|
25
|
+
function ensureFactoryDir() {
|
|
26
|
+
const dir = path.join(os.homedir(), '.factory');
|
|
27
|
+
if (!fs.existsSync(dir)) {
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 读取 Factory auth.json
|
|
33
|
+
function readFactoryAuth() {
|
|
34
|
+
const authPath = getFactoryAuthPath();
|
|
35
|
+
if (fs.existsSync(authPath)) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(fs.readFileSync(authPath, 'utf8'));
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 写入 Factory auth.json
|
|
46
|
+
function writeFactoryAuth(accessToken, refreshToken) {
|
|
47
|
+
ensureFactoryDir();
|
|
48
|
+
const authPath = getFactoryAuthPath();
|
|
49
|
+
const data = {
|
|
50
|
+
accessToken,
|
|
51
|
+
refreshToken,
|
|
52
|
+
expiresAt: Date.now() + 3600000 // 1小时后过期
|
|
53
|
+
};
|
|
54
|
+
fs.writeFileSync(authPath, JSON.stringify(data, null, 2));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 生成设备ID
|
|
59
|
+
function getDeviceId() {
|
|
60
|
+
let deviceId = config.get('deviceId');
|
|
61
|
+
if (!deviceId) {
|
|
62
|
+
const crypto = require('crypto');
|
|
63
|
+
const machineInfo = `${os.hostname()}-${os.platform()}-${os.arch()}-${os.userInfo().username}`;
|
|
64
|
+
deviceId = crypto.createHash('sha256').update(machineInfo).digest('hex').substring(0, 32);
|
|
65
|
+
config.set('deviceId', deviceId);
|
|
66
|
+
}
|
|
67
|
+
return deviceId;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
config,
|
|
72
|
+
getFactoryAuthPath,
|
|
73
|
+
ensureFactoryDir,
|
|
74
|
+
readFactoryAuth,
|
|
75
|
+
writeFactoryAuth,
|
|
76
|
+
getDeviceId
|
|
77
|
+
};
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { config, writeFactoryAuth, getDeviceId } = require('./config');
|
|
6
|
+
const api = require('./api');
|
|
7
|
+
|
|
8
|
+
const PID_FILE = path.join(os.tmpdir(), 'atm-daemon.pid');
|
|
9
|
+
const LOG_FILE = path.join(os.tmpdir(), 'atm-daemon.log');
|
|
10
|
+
|
|
11
|
+
let ws = null;
|
|
12
|
+
let reconnectTimer = null;
|
|
13
|
+
let heartbeatTimer = null;
|
|
14
|
+
|
|
15
|
+
function log(message) {
|
|
16
|
+
const timestamp = new Date().toISOString();
|
|
17
|
+
const line = `[${timestamp}] ${message}\n`;
|
|
18
|
+
fs.appendFileSync(LOG_FILE, line);
|
|
19
|
+
if (process.env.ATM_DEBUG) {
|
|
20
|
+
console.log(line.trim());
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function connectWebSocket() {
|
|
25
|
+
const serverUrl = config.get('serverUrl').replace('https://', 'wss://').replace('http://', 'ws://');
|
|
26
|
+
const sessionToken = config.get('sessionToken');
|
|
27
|
+
const deviceId = getDeviceId();
|
|
28
|
+
|
|
29
|
+
if (!sessionToken) {
|
|
30
|
+
log('未登录,无法连接 WebSocket');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const wsUrl = `${serverUrl}/ws?token=${sessionToken}&device=${deviceId}`;
|
|
35
|
+
|
|
36
|
+
log(`连接 WebSocket: ${serverUrl}/ws`);
|
|
37
|
+
|
|
38
|
+
ws = new WebSocket(wsUrl);
|
|
39
|
+
|
|
40
|
+
ws.on('open', () => {
|
|
41
|
+
log('WebSocket 已连接');
|
|
42
|
+
// 订阅 Token 更新
|
|
43
|
+
ws.send(JSON.stringify({ type: 'subscribe', channel: 'tokens' }));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
ws.on('message', async (data) => {
|
|
47
|
+
try {
|
|
48
|
+
const message = JSON.parse(data.toString());
|
|
49
|
+
log(`收到消息: ${message.type}`);
|
|
50
|
+
|
|
51
|
+
if (message.type === 'token_updated') {
|
|
52
|
+
// Token 已更新,重新获取并写入 auth.json
|
|
53
|
+
await refreshCurrentToken();
|
|
54
|
+
} else if (message.type === 'quota_depleted') {
|
|
55
|
+
// 余额用完,自动切换
|
|
56
|
+
if (config.get('autoSwitch')) {
|
|
57
|
+
await autoSwitch();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
log(`解析消息失败: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
ws.on('close', () => {
|
|
66
|
+
log('WebSocket 断开,5秒后重连...');
|
|
67
|
+
scheduleReconnect();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
ws.on('error', (err) => {
|
|
71
|
+
log(`WebSocket 错误: ${err.message}`);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function scheduleReconnect() {
|
|
76
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
77
|
+
reconnectTimer = setTimeout(() => {
|
|
78
|
+
connectWebSocket();
|
|
79
|
+
}, 5000);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function refreshCurrentToken() {
|
|
83
|
+
try {
|
|
84
|
+
const currentTokenId = config.get('currentTokenId');
|
|
85
|
+
if (!currentTokenId) return;
|
|
86
|
+
|
|
87
|
+
const result = await api.activateToken(currentTokenId);
|
|
88
|
+
if (result.success && result.access_token && result.refresh_token) {
|
|
89
|
+
writeFactoryAuth(result.access_token, result.refresh_token);
|
|
90
|
+
log(`Token 已更新: ${currentTokenId}`);
|
|
91
|
+
}
|
|
92
|
+
} catch (e) {
|
|
93
|
+
log(`刷新 Token 失败: ${e.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function autoSwitch() {
|
|
98
|
+
try {
|
|
99
|
+
const result = await api.getTokens();
|
|
100
|
+
if (!result.success || !result.data) return;
|
|
101
|
+
|
|
102
|
+
const tokens = result.data;
|
|
103
|
+
const currentId = config.get('currentTokenId');
|
|
104
|
+
|
|
105
|
+
// 找到有余额的 Token
|
|
106
|
+
const available = tokens.filter(t => {
|
|
107
|
+
const remaining = (t.quota_total || 0) - (t.quota_used || 0);
|
|
108
|
+
return remaining > 0 && t.id !== currentId;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (available.length > 0) {
|
|
112
|
+
// 切换到余额最少的(用完一个再用下一个)
|
|
113
|
+
available.sort((a, b) => {
|
|
114
|
+
const ra = (a.quota_total || 0) - (a.quota_used || 0);
|
|
115
|
+
const rb = (b.quota_total || 0) - (b.quota_used || 0);
|
|
116
|
+
return ra - rb;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const target = available[0];
|
|
120
|
+
const activateResult = await api.activateToken(target.id);
|
|
121
|
+
|
|
122
|
+
if (activateResult.success) {
|
|
123
|
+
config.set('currentTokenId', target.id);
|
|
124
|
+
writeFactoryAuth(activateResult.access_token, activateResult.refresh_token);
|
|
125
|
+
log(`自动切换到: ${target.email}`);
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
log('所有账号余额已用完');
|
|
129
|
+
}
|
|
130
|
+
} catch (e) {
|
|
131
|
+
log(`自动切换失败: ${e.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function startHeartbeat() {
|
|
136
|
+
heartbeatTimer = setInterval(async () => {
|
|
137
|
+
try {
|
|
138
|
+
const result = await api.heartbeat();
|
|
139
|
+
if (!result.valid) {
|
|
140
|
+
log('会话已失效');
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
log(`心跳失败: ${e.message}`);
|
|
144
|
+
}
|
|
145
|
+
}, 30000); // 每30秒心跳一次
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function writePidFile() {
|
|
149
|
+
fs.writeFileSync(PID_FILE, process.pid.toString());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function removePidFile() {
|
|
153
|
+
if (fs.existsSync(PID_FILE)) {
|
|
154
|
+
fs.unlinkSync(PID_FILE);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getPid() {
|
|
159
|
+
if (fs.existsSync(PID_FILE)) {
|
|
160
|
+
return parseInt(fs.readFileSync(PID_FILE, 'utf8'));
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isRunning(pid) {
|
|
166
|
+
try {
|
|
167
|
+
process.kill(pid, 0);
|
|
168
|
+
return true;
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 启动守护进程
|
|
175
|
+
function startDaemon() {
|
|
176
|
+
const existingPid = getPid();
|
|
177
|
+
if (existingPid && isRunning(existingPid)) {
|
|
178
|
+
console.log('后台服务已在运行 (PID: ' + existingPid + ')');
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 清空日志
|
|
183
|
+
fs.writeFileSync(LOG_FILE, '');
|
|
184
|
+
|
|
185
|
+
writePidFile();
|
|
186
|
+
log('守护进程启动');
|
|
187
|
+
|
|
188
|
+
connectWebSocket();
|
|
189
|
+
startHeartbeat();
|
|
190
|
+
|
|
191
|
+
// 优雅退出
|
|
192
|
+
process.on('SIGTERM', () => {
|
|
193
|
+
log('收到 SIGTERM,正在退出...');
|
|
194
|
+
cleanup();
|
|
195
|
+
process.exit(0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
process.on('SIGINT', () => {
|
|
199
|
+
log('收到 SIGINT,正在退出...');
|
|
200
|
+
cleanup();
|
|
201
|
+
process.exit(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function cleanup() {
|
|
208
|
+
if (ws) ws.close();
|
|
209
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
210
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
211
|
+
removePidFile();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 停止守护进程
|
|
215
|
+
function stopDaemon() {
|
|
216
|
+
const pid = getPid();
|
|
217
|
+
if (!pid) {
|
|
218
|
+
console.log('后台服务未运行');
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!isRunning(pid)) {
|
|
223
|
+
removePidFile();
|
|
224
|
+
console.log('后台服务未运行');
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
process.kill(pid, 'SIGTERM');
|
|
230
|
+
removePidFile();
|
|
231
|
+
return true;
|
|
232
|
+
} catch (e) {
|
|
233
|
+
console.log('停止失败:', e.message);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 获取守护进程状态
|
|
239
|
+
function getDaemonStatus() {
|
|
240
|
+
const pid = getPid();
|
|
241
|
+
if (!pid) return { running: false };
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
running: isRunning(pid),
|
|
245
|
+
pid,
|
|
246
|
+
logFile: LOG_FILE
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
startDaemon,
|
|
252
|
+
stopDaemon,
|
|
253
|
+
getDaemonStatus,
|
|
254
|
+
refreshCurrentToken,
|
|
255
|
+
autoSwitch,
|
|
256
|
+
LOG_FILE
|
|
257
|
+
};
|
package/src/index.js
ADDED