tokenforbes-cli 0.1.3 → 0.1.5
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/bin.js +51 -31
- package/daemon.js +146 -24
- package/package.json +1 -1
package/bin.js
CHANGED
|
@@ -7,7 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import { exec } from 'node:child_process';
|
|
8
8
|
import { readUsage } from './ccusage.js';
|
|
9
9
|
import { detectDevice } from './device.js';
|
|
10
|
-
import { daemon } from './daemon.js';
|
|
10
|
+
import { daemon, ensureDaemonInstalled } from './daemon.js';
|
|
11
11
|
|
|
12
12
|
const CONFIG = path.join(os.homedir(), '.tokenboard.json');
|
|
13
13
|
const DEFAULT_SERVER = process.env.TB_SERVER || 'https://ceorank.cn';
|
|
@@ -36,35 +36,46 @@ if (cmd === 'login') {
|
|
|
36
36
|
console.log('命令: link | register <昵称> | login <密钥> | report | daemon <install|status|uninstall>');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
//
|
|
40
|
-
|
|
39
|
+
// 设备授权登录:先弹浏览器让你批准 → 趁这工夫后台抓用量 → 批准一到立刻上传。
|
|
40
|
+
// preDays: 若上层已抓好用量(比如 report 探到密钥失效),直接传进来,不再重抓。
|
|
41
|
+
async function link(preDays) {
|
|
41
42
|
const c = loadConfig();
|
|
42
43
|
const server = c.server || DEFAULT_SERVER;
|
|
43
44
|
const device = detectDevice();
|
|
44
45
|
const s = await post(server + '/api/device/start', { device });
|
|
45
46
|
const url = `${server}/link.html?code=${s.user_code}`;
|
|
46
|
-
|
|
47
|
-
console.log('
|
|
48
|
-
console.log('
|
|
47
|
+
// 第一步就把浏览器弹出去,别让用户对着黑终端干等。
|
|
48
|
+
console.log('\n① 浏览器这就打开,填个昵称、点「注册并批准」就行。没弹出来就手动复制这条链接:');
|
|
49
|
+
console.log(' → ' + url);
|
|
50
|
+
console.log(' 验证码: ' + s.user_code + '(跟网页上那串核对一致再批准)');
|
|
49
51
|
openBrowser(url);
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
// 趁你在浏览器填昵称、点批准的工夫,后台把本地用量先抓好,批准一到就能立刻上传。
|
|
54
|
+
let days = preDays;
|
|
55
|
+
if (!days) {
|
|
56
|
+
process.stdout.write('\n② 正在后台读取本地用量(ccusage)…');
|
|
57
|
+
days = readUsage();
|
|
58
|
+
console.log(days.length ? ` 已抓好 ${days.length} 天,就等你在浏览器批准。` : ' 没读到用量。');
|
|
59
|
+
} else {
|
|
60
|
+
console.log('\n② 本地用量已经抓好,就等你在浏览器批准。');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
process.stdout.write(' 等你点确认…');
|
|
52
64
|
const until = Date.now() + s.expires_in * 1000;
|
|
53
65
|
while (Date.now() < until) {
|
|
54
66
|
await sleep(s.interval * 1000);
|
|
55
67
|
const p = await post(server + '/api/device/poll', { device_code: s.device_code });
|
|
56
68
|
if (p.status === 'approved') {
|
|
57
69
|
c.key = p.key; c.server = server; saveConfig(c);
|
|
58
|
-
console.log('\n✓
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
console.log('\n想让它每半小时自动后台上报? npm i -g tokenforbes-cli 后跑 tokenforbes daemon install');
|
|
70
|
+
console.log('\n✓ 已批准,密钥已存到 ' + CONFIG);
|
|
71
|
+
await uploadDays(days, { server, key: p.key, device });
|
|
72
|
+
ensureDaemonInstalled();
|
|
62
73
|
return;
|
|
63
74
|
}
|
|
64
75
|
if (p.status === 'expired') break;
|
|
65
76
|
process.stdout.write('.');
|
|
66
77
|
}
|
|
67
|
-
die('\n登录超时或验证码已过期,重新跑一次 npx tokenforbes-cli
|
|
78
|
+
die('\n登录超时或验证码已过期,重新跑一次 npx -y tokenforbes-cli');
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
@@ -84,37 +95,46 @@ async function register(nickname) {
|
|
|
84
95
|
const j = await post(server + '/api/register', { nickname, device });
|
|
85
96
|
c.key = j.key; c.server = server; saveConfig(c);
|
|
86
97
|
console.log(`注册成功,昵称「${j.nickname}」。密钥已存到 ${CONFIG}`);
|
|
87
|
-
console.log('
|
|
98
|
+
console.log('正在读取本地用量(ccusage)…');
|
|
99
|
+
await uploadDays(readUsage(), { server, key: j.key, device }); // 注册完顺手上报,别让用户再跑一条
|
|
100
|
+
ensureDaemonInstalled();
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
async function report({ allowRelink = true } = {}) {
|
|
91
104
|
const c = loadConfig();
|
|
92
|
-
// 没密钥:直接带去登录(
|
|
105
|
+
// 没密钥:直接带去登录(link 会先弹浏览器,再后台抓用量),不让用户对着报错发愣。
|
|
93
106
|
if (!c.key) {
|
|
94
107
|
if (allowRelink) return link();
|
|
95
|
-
die('还没登录。先跑 npx tokenforbes-cli (浏览器一键登录),或 register / login');
|
|
108
|
+
die('还没登录。先跑 npx -y tokenforbes-cli (浏览器一键登录),或 register / login');
|
|
96
109
|
}
|
|
97
110
|
const server = c.server || DEFAULT_SERVER;
|
|
98
|
-
console.log('正在读取本地用量(ccusage)…');
|
|
99
|
-
const days = readUsage();
|
|
100
|
-
if (!days.length) return console.log('没读到用量数据(这台电脑还没用过 Claude Code / Codex?)。');
|
|
101
111
|
const device = detectDevice();
|
|
112
|
+
// 先用一个很快的空请求验密钥,别等抓完几十天用量才发现要重登——那样浏览器弹得太迟。
|
|
113
|
+
const probe = await postRaw(server + '/api/report', { key: c.key, device, days: [] });
|
|
114
|
+
if (!probe.ok) {
|
|
115
|
+
const authFailed = probe.status === 401 || /密钥/.test(probe.json?.error || '');
|
|
116
|
+
if (authFailed && allowRelink) {
|
|
117
|
+
console.log('本地密钥已失效,这就打开浏览器带你重新登录…');
|
|
118
|
+
delete c.key; saveConfig(c);
|
|
119
|
+
return link(); // 密钥无效,还没抓用量;交给 link 先弹浏览器再后台抓
|
|
120
|
+
}
|
|
121
|
+
die('上报失败:' + (probe.json?.error || probe.netError || probe.status));
|
|
122
|
+
}
|
|
123
|
+
// 密钥有效:抓用量并上传。
|
|
124
|
+
console.log('正在读取本地用量(ccusage)…');
|
|
125
|
+
await uploadDays(readUsage(), { server, key: c.key, device });
|
|
126
|
+
ensureDaemonInstalled();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 把抓好的用量传上去。抽出来给 report / link / register 共用,避免各写一遍、避免重复抓。
|
|
130
|
+
async function uploadDays(days, { server, key, device }) {
|
|
131
|
+
if (!days || !days.length) return console.log('没读到用量数据(这台电脑还没用过 Claude Code / Codex?)。');
|
|
102
132
|
const total = days.reduce((s, d) => s + d.input + d.output + (d.cacheCreation || 0) + (d.cacheRead || 0), 0);
|
|
103
133
|
console.log(`设备:${device}`);
|
|
104
134
|
console.log(`共 ${days.length} 天,合计约 ${Math.round(total / 1e4).toLocaleString()} 万 token,正在上传…`);
|
|
105
|
-
const res = await postRaw(server + '/api/report', { key
|
|
106
|
-
if (res.ok) {
|
|
107
|
-
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
// 密钥失效(最常见:服务器重置过用户库,旧密钥查无此人):清掉旧密钥,自动带去重新登录再上报。
|
|
111
|
-
const authFailed = res.status === 401 || /密钥/.test(res.json?.error || '');
|
|
112
|
-
if (authFailed && allowRelink) {
|
|
113
|
-
console.log('\n本地密钥已失效(服务器可能重置过数据),带你重新登录一次…\n');
|
|
114
|
-
delete c.key; saveConfig(c);
|
|
115
|
-
return link();
|
|
116
|
-
}
|
|
117
|
-
die('上报失败:' + (res.json?.error || res.netError || res.status));
|
|
135
|
+
const res = await postRaw(server + '/api/report', { key, device, days });
|
|
136
|
+
if (res.ok) console.log(`上报成功 ✓ 已记录 ${res.json.days} 天。去 ${server} 看你的排名。`);
|
|
137
|
+
else die('上报失败:' + (res.json?.error || res.netError || res.status));
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
// 会抛错就 die 的版本:给登录/注册这类「失败就该停」的调用用。
|
package/daemon.js
CHANGED
|
@@ -1,38 +1,72 @@
|
|
|
1
|
-
//
|
|
2
|
-
// ponytail: 只做 macOS(launchd)—— 负责人在 Mac,对标的竞品也是这套。
|
|
3
|
-
// Linux(systemd/cron)、Windows(计划任务)等真有人要再加,先不抽象。
|
|
1
|
+
// 后台自动同步:macOS 用 launchd、Windows 用计划任务,每 30 分钟自动跑一次 report。
|
|
4
2
|
import fs from 'node:fs';
|
|
5
3
|
import os from 'node:os';
|
|
6
4
|
import path from 'node:path';
|
|
7
5
|
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
6
|
|
|
10
7
|
const LABEL = 'ai.tokenboard.report';
|
|
11
8
|
const PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', LABEL + '.plist');
|
|
12
9
|
const LOG = path.join(os.homedir(), '.tokenboard.log');
|
|
13
10
|
const CONFIG = path.join(os.homedir(), '.tokenboard.json');
|
|
14
|
-
const
|
|
11
|
+
const VBS = path.join(os.homedir(), '.tokenboard.vbs'); // Windows 隐藏窗口启动器
|
|
15
12
|
const INTERVAL = 30 * 60; // 秒
|
|
13
|
+
// launchd 不读用户 shell 配置,PATH 得自己给全。nvm/fnm 装的 node 不在这些标准目录,
|
|
14
|
+
// 所以安装时把「当前 node 的真实目录」排最前(realpath 解掉 fnm 每个终端的临时软链)。
|
|
15
|
+
const FALLBACK_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
|
|
16
|
+
// 上线前旧采集脚本(opentoken)的 launchd 任务名,新版安装/卸载时顺手关掉,免得双份任务并存。
|
|
17
|
+
export const LEGACY_LABELS = ['com.opentoken.daemon'];
|
|
16
18
|
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
export function launchdPath() {
|
|
20
|
+
let nodeBin = '';
|
|
21
|
+
try { nodeBin = path.dirname(fs.realpathSync(process.execPath)); } catch {}
|
|
22
|
+
if (!nodeBin || FALLBACK_PATH.split(':').includes(nodeBin)) return FALLBACK_PATH;
|
|
23
|
+
return nodeBin + ':' + FALLBACK_PATH;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function reportCommand() {
|
|
27
|
+
return ['npx', '-y', 'tokenforbes-cli@latest', 'report'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Windows 上直接让计划任务跑 cmd 会每半小时闪一个黑窗,所以包一层 VBS:
|
|
31
|
+
// wscript 是无窗口宿主,由它再以隐藏方式(第二参 0)拉起 cmd。VBS 字符串里的双引号写成两个。
|
|
32
|
+
export function buildVbs() {
|
|
33
|
+
const cmd = 'cmd /d /c ' + reportCommand().join(' ') + ' >> ""' + LOG + '"" 2>&1';
|
|
34
|
+
return 'CreateObject("WScript.Shell").Run "' + cmd + '", 0, False\r\n';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function windowsTaskArgs() {
|
|
38
|
+
return ['/create', '/tn', LABEL, '/sc', 'minute', '/mo', '30', '/tr', 'wscript.exe //B //Nologo "' + VBS + '"', '/f'];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function supported() {
|
|
42
|
+
return process.platform === 'darwin' || process.platform === 'win32';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ensureSupported() {
|
|
46
|
+
if (!supported()) {
|
|
47
|
+
console.error('后台自动同步目前支持 macOS / Windows。其它系统可自己挂定时任务跑 npx -y tokenforbes-cli@latest report');
|
|
20
48
|
process.exit(1);
|
|
21
49
|
}
|
|
22
50
|
}
|
|
23
51
|
|
|
24
|
-
// launchd 任务说明书(plist)
|
|
52
|
+
// launchd 任务说明书(plist)。日志重定向到文件,失败不静默。
|
|
53
|
+
// ponytail: 命令必须用 /bin/sh -c 包一层 —— launchd 找程序不认 EnvironmentVariables 里的 PATH,
|
|
54
|
+
// 裸写 npx 会以退出码 78(找不到程序)静默失败,真机实测过。PATH 是给 sh 找 npx 用的。
|
|
25
55
|
export function buildPlist() {
|
|
26
56
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
57
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
58
|
<plist version="1.0">
|
|
29
59
|
<dict>
|
|
30
60
|
<key>Label</key><string>${LABEL}</string>
|
|
61
|
+
<key>EnvironmentVariables</key>
|
|
62
|
+
<dict>
|
|
63
|
+
<key>PATH</key><string>${launchdPath()}</string>
|
|
64
|
+
</dict>
|
|
31
65
|
<key>ProgramArguments</key>
|
|
32
66
|
<array>
|
|
33
|
-
<string
|
|
34
|
-
<string
|
|
35
|
-
<string
|
|
67
|
+
<string>/bin/sh</string>
|
|
68
|
+
<string>-c</string>
|
|
69
|
+
<string>${reportCommand().join(' ')}</string>
|
|
36
70
|
</array>
|
|
37
71
|
<key>StartInterval</key><integer>${INTERVAL}</integer>
|
|
38
72
|
<key>RunAtLoad</key><true/>
|
|
@@ -43,46 +77,134 @@ export function buildPlist() {
|
|
|
43
77
|
`;
|
|
44
78
|
}
|
|
45
79
|
|
|
46
|
-
function
|
|
47
|
-
ensureMac();
|
|
80
|
+
function requireKey() {
|
|
48
81
|
// 没登录就别装,否则只会每半小时报一次「未登录」错。
|
|
49
82
|
let key;
|
|
50
83
|
try { key = JSON.parse(fs.readFileSync(CONFIG, 'utf8')).key; } catch {}
|
|
51
|
-
|
|
84
|
+
return Boolean(key);
|
|
85
|
+
}
|
|
52
86
|
|
|
53
|
-
|
|
54
|
-
|
|
87
|
+
function cleanupLegacyMac() {
|
|
88
|
+
for (const label of LEGACY_LABELS) {
|
|
89
|
+
const p = path.join(os.homedir(), 'Library', 'LaunchAgents', label + '.plist');
|
|
90
|
+
if (!fs.existsSync(p)) continue;
|
|
91
|
+
try { execFileSync('launchctl', ['unload', p], { stdio: 'ignore' }); } catch {}
|
|
92
|
+
try { fs.rmSync(p, { force: true }); } catch {}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function installMac() {
|
|
97
|
+
cleanupLegacyMac();
|
|
55
98
|
fs.mkdirSync(path.dirname(PLIST), { recursive: true });
|
|
56
99
|
fs.writeFileSync(PLIST, buildPlist());
|
|
57
100
|
// ponytail: 用 load/unload(老接口,现 macOS 仍可用);若哪天失效,换 bootstrap/bootout gui/$UID。
|
|
58
101
|
// 先 unload 再 load = 幂等,重复装不报错。
|
|
59
102
|
try { execFileSync('launchctl', ['unload', PLIST], { stdio: 'ignore' }); } catch {}
|
|
60
103
|
execFileSync('launchctl', ['load', PLIST]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function installWindows() {
|
|
107
|
+
fs.writeFileSync(VBS, buildVbs());
|
|
108
|
+
execFileSync('schtasks', windowsTaskArgs(), { stdio: 'ignore' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function install() {
|
|
112
|
+
ensureSupported();
|
|
113
|
+
if (!requireKey()) {
|
|
114
|
+
console.error('还没登录,先跑 npx tokenforbes-cli 登录后再装。');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
if (process.platform === 'darwin') installMac();
|
|
118
|
+
else installWindows();
|
|
61
119
|
console.log('✓ 已开启后台自动同步,每 30 分钟自动上报一次。');
|
|
62
|
-
|
|
63
|
-
|
|
120
|
+
if (process.platform === 'darwin') {
|
|
121
|
+
console.log(' 任务文件: ' + PLIST);
|
|
122
|
+
console.log(' 日志: ' + LOG);
|
|
123
|
+
}
|
|
64
124
|
console.log(' 查看状态: tokenforbes daemon status');
|
|
65
125
|
console.log(' 关掉它: tokenforbes daemon uninstall');
|
|
66
126
|
}
|
|
67
127
|
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
if (!fs.existsSync(PLIST)) return console.log('未安装。装它: tokenforbes daemon install');
|
|
128
|
+
function statusMac() {
|
|
129
|
+
if (!fs.existsSync(PLIST)) return false;
|
|
71
130
|
let loaded = false;
|
|
72
131
|
try { loaded = execFileSync('launchctl', ['list'], { encoding: 'utf8' }).includes(LABEL); } catch {}
|
|
73
132
|
console.log(loaded ? '✓ 已安装并在运行,每 30 分钟自动上报。' : '已安装但未加载(重新 install 一下)。');
|
|
74
133
|
console.log(' 任务文件: ' + PLIST);
|
|
75
134
|
console.log(' 日志: ' + LOG + '(看最近几次上报有没有出错)');
|
|
135
|
+
return true;
|
|
76
136
|
}
|
|
77
137
|
|
|
78
|
-
function
|
|
79
|
-
|
|
138
|
+
function statusWindows() {
|
|
139
|
+
try {
|
|
140
|
+
execFileSync('schtasks', ['/query', '/tn', LABEL], { stdio: 'ignore' });
|
|
141
|
+
console.log('✓ 已安装,Windows 会每 30 分钟自动上报。');
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function needsInstall() {
|
|
149
|
+
if (process.platform === 'darwin') {
|
|
150
|
+
if (!fs.existsSync(PLIST)) return true;
|
|
151
|
+
// 内容不含新版命令 = 旧格式;不含当前 PATH = 用户换过 node 安装位置,都重装自愈。
|
|
152
|
+
try {
|
|
153
|
+
const content = fs.readFileSync(PLIST, 'utf8');
|
|
154
|
+
return !content.includes('tokenforbes-cli@latest') || !content.includes(launchdPath());
|
|
155
|
+
} catch { return true; }
|
|
156
|
+
}
|
|
157
|
+
if (process.platform === 'win32') {
|
|
158
|
+
try { execFileSync('schtasks', ['/query', '/tn', LABEL], { stdio: 'ignore' }); return false; } catch { return true; }
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function status() {
|
|
164
|
+
ensureSupported();
|
|
165
|
+
const ok = process.platform === 'darwin' ? statusMac() : statusWindows();
|
|
166
|
+
if (!ok) console.log('未安装。装它: tokenforbes daemon install');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function uninstallMac() {
|
|
170
|
+
cleanupLegacyMac();
|
|
80
171
|
if (!fs.existsSync(PLIST)) return console.log('本来就没装。');
|
|
81
172
|
try { execFileSync('launchctl', ['unload', PLIST], { stdio: 'ignore' }); } catch {}
|
|
82
173
|
fs.rmSync(PLIST, { force: true });
|
|
83
174
|
console.log('✓ 已关闭后台自动同步,删除了任务文件。');
|
|
84
175
|
}
|
|
85
176
|
|
|
177
|
+
function uninstallWindows() {
|
|
178
|
+
try {
|
|
179
|
+
execFileSync('schtasks', ['/delete', '/tn', LABEL, '/f'], { stdio: 'ignore' });
|
|
180
|
+
console.log('✓ 已关闭后台自动同步。');
|
|
181
|
+
} catch {
|
|
182
|
+
console.log('本来就没装。');
|
|
183
|
+
}
|
|
184
|
+
fs.rmSync(VBS, { force: true });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function uninstall() {
|
|
188
|
+
ensureSupported();
|
|
189
|
+
if (process.platform === 'darwin') return uninstallMac();
|
|
190
|
+
return uninstallWindows();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function ensureDaemonInstalled() {
|
|
194
|
+
if (!supported()) return;
|
|
195
|
+
// 老脚本清理独立于「要不要装」:就算新任务已装好,发现旧任务也顺手关掉。
|
|
196
|
+
if (process.platform === 'darwin') { try { cleanupLegacyMac(); } catch {} }
|
|
197
|
+
if (!needsInstall()) return;
|
|
198
|
+
try {
|
|
199
|
+
if (!requireKey()) return;
|
|
200
|
+
if (process.platform === 'darwin') installMac();
|
|
201
|
+
else installWindows();
|
|
202
|
+
console.log('后台自动同步已开启:之后每 30 分钟自动上报一次。');
|
|
203
|
+
} catch (e) {
|
|
204
|
+
console.log('后台自动同步没自动开启,可稍后手动跑: tokenforbes daemon install');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
86
208
|
export function daemon(sub) {
|
|
87
209
|
if (sub === 'install') return install();
|
|
88
210
|
if (sub === 'status') return status();
|