tokenforbes-cli 0.1.4 → 0.1.6

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.
Files changed (4) hide show
  1. package/bin.js +148 -45
  2. package/ccusage.js +12 -4
  3. package/daemon.js +160 -32
  4. package/package.json +1 -1
package/bin.js CHANGED
@@ -1,39 +1,98 @@
1
1
  #!/usr/bin/env node
2
- // TokenBoard 上报命令行。只上报数字和设备型号,不读、不传你的任何项目内容。
3
- // 命令:register <昵称> | login <密钥> | report(默认)
2
+ // CEO Rank(tokenforbes)上报命令行。只上报用量数字和设备型号,不读、不传你的任何项目内容。
3
+ // 命令:link | register <昵称> | login <密钥> | report(默认) | daemon <install|status|uninstall>
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  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';
14
+ const CMD_PLAIN = 'npx -y tokenforbes-cli@latest'; // 万能命令:没登录带你登录,登录过直接上报
15
+ const CMD_LINK = CMD_PLAIN + ' link'; // 中断后接着来,统一推荐这条
14
16
 
15
17
  const loadConfig = () => { try { return JSON.parse(fs.readFileSync(CONFIG, 'utf8')); } catch { return {}; } };
16
18
  const saveConfig = (c) => fs.writeFileSync(CONFIG, JSON.stringify(c, null, 2));
17
19
 
20
+ // ---- Ctrl+C 善后 ----
21
+ // 任何阶段按 Ctrl+C:说清进行到哪一步、怎么继续,然后干净退出(130),绝不甩堆栈。
22
+ // stage 跟着流程走:start(刚起步) → usage(统计用量) → approve(等浏览器批准) → upload(上传) → done。
23
+ let stage = 'start';
24
+ const STAGE_BYE = {
25
+ start: '刚起步,还什么都没动,一切照旧。',
26
+ usage: '刚才正在统计本地用量,还没统计完;没有上传任何数据,也没改动任何东西。',
27
+ approve: '刚才正在等你去浏览器点批准,还没上传任何数据。',
28
+ upload: '刚才正在上传,可能只传了一部分——不要紧,重跑一次会自动补齐,不会重复计数。',
29
+ done: '该办的都已经办完了,用量传上去了。',
30
+ };
31
+ function bye() {
32
+ console.log('\n\n好,先停在这儿。' + (STAGE_BYE[stage] || ''));
33
+ console.log('想继续的时候,随时跑这一条,接着帮你办完:\n');
34
+ console.log(' ' + CMD_LINK + '\n');
35
+ process.exit(130);
36
+ }
37
+ process.on('SIGINT', bye);
38
+
18
39
  const [cmd, arg] = process.argv.slice(2);
19
40
 
20
- if (cmd === 'login') {
21
- if (!arg) die('用法: tokenforbes login <密钥>');
22
- const c = loadConfig(); c.key = arg; c.server = c.server || DEFAULT_SERVER; saveConfig(c);
23
- console.log('密钥已保存 ', CONFIG);
24
- } else if (cmd === 'link') {
25
- await link();
26
- } else if (cmd === 'daemon') {
27
- daemon(arg);
28
- } else if (cmd === 'register') {
29
- await register(arg);
30
- } else if (cmd === 'report') {
31
- await report();
32
- } else if (!cmd) {
33
- // 一条命令走完:report 内部会兜底——没密钥或密钥失效(服务器重置过)都自动带去登录再上报。
34
- await report();
35
- } else {
36
- console.log('命令: link | register <昵称> | login <密钥> | report | daemon <install|status|uninstall>');
41
+ try {
42
+ if (cmd === 'login') {
43
+ login(arg);
44
+ } else if (cmd === 'link') {
45
+ await link();
46
+ } else if (cmd === 'daemon') {
47
+ daemon(arg);
48
+ } else if (cmd === 'register') {
49
+ await register(arg);
50
+ } else if (cmd === 'report') {
51
+ await report();
52
+ } else if (!cmd) {
53
+ // 一条命令走完:report 内部会兜底——没密钥或密钥失效(服务器重置过)都自动带去登录再上报。
54
+ await report();
55
+ } else {
56
+ help();
57
+ }
58
+ } catch (e) {
59
+ // 统计用量时按 Ctrl+C,子进程先被打断,错误会带 interrupted 标记辗转到这里——按告别处理,不算出错。
60
+ if (e?.interrupted) bye();
61
+ if (e?.friendly) die(e.message); // 自家抛的错,文案已经写好了「出了什么事 + 下一步」
62
+ die('✗ 出了点意外:' + (e?.message || e) + '\n 重新跑一次通常就能好: ' + CMD_PLAIN + '\n 要是反复不行,去 https://ceorank.cn 找我们说一声,我们来修。');
63
+ }
64
+
65
+ function help() {
66
+ console.log(`
67
+ CEO Rank 用量上报工具(tokenforbes-cli)
68
+ 只上报 token 用量数字和设备型号,绝不读取、不上传你的代码和对话内容。
69
+
70
+ 最省事的用法(一条命令全搞定):
71
+
72
+ ${CMD_PLAIN}
73
+
74
+ 没登录会自动打开浏览器带你登录;登录过就直接上报。
75
+
76
+ 分步命令(想单独控制时用):
77
+ link 浏览器一键登录 + 上报(推荐)
78
+ report 上报一次本地用量
79
+ register <昵称> 不走浏览器,直接用昵称注册
80
+ login <密钥> 已有密钥,保存到本机
81
+ daemon install 开启后台自动同步(每 30 分钟自动上报)
82
+ daemon status 看后台同步是否在跑
83
+ daemon uninstall 关闭后台自动同步
84
+
85
+ 上报成功后,去 https://ceorank.cn 看排名;登录后打开「同步数据」页,能看到每台设备的上报明细。
86
+ `);
87
+ }
88
+
89
+ function login(key) {
90
+ if (!key) {
91
+ die('差一个密钥,没法登录。这样用:\n ' + CMD_PLAIN + ' login <你的密钥>\n\n 手头没有密钥?直接跑 ' + CMD_PLAIN + ' 走浏览器登录,更省事。');
92
+ }
93
+ const c = loadConfig(); c.key = key; c.server = c.server || DEFAULT_SERVER; saveConfig(c);
94
+ console.log('✓ 密钥已保存(' + CONFIG + '),这台电脑之后免登录。');
95
+ console.log('接下来跑 ' + CMD_PLAIN + ' 就能把用量传上去了。');
37
96
  }
38
97
 
39
98
  // 设备授权登录:先弹浏览器让你批准 → 趁这工夫后台抓用量 → 批准一到立刻上传。
@@ -45,37 +104,48 @@ async function link(preDays) {
45
104
  const s = await post(server + '/api/device/start', { device });
46
105
  const url = `${server}/link.html?code=${s.user_code}`;
47
106
  // 第一步就把浏览器弹出去,别让用户对着黑终端干等。
48
- console.log('\n① 浏览器这就打开,填个昵称、点「注册并批准」就行。没弹出来就手动复制这条链接:');
107
+ console.log('\n① 去浏览器批准登录');
108
+ console.log(' 浏览器马上会自己弹出来,填个昵称、点「注册并批准」就行。');
109
+ console.log(' 没弹出来?手动打开这条链接:');
49
110
  console.log(' → ' + url);
50
- console.log(' 验证码: ' + s.user_code + '(跟网页上那串核对一致再批准)');
111
+ console.log(' 验证码:' + s.user_code + '(和网页上显示的对上号,再点批准)');
51
112
  openBrowser(url);
52
113
 
53
114
  // 趁你在浏览器填昵称、点批准的工夫,后台把本地用量先抓好,批准一到就能立刻上传。
54
115
  let days = preDays;
55
116
  if (!days) {
56
- process.stdout.write('\n② 正在后台读取本地用量(ccusage)…');
117
+ stage = 'usage';
118
+ console.log('\n② 趁这工夫,统计这台电脑的 AI 用量');
119
+ console.log(' 正在读取 Claude Code / Codex 的 token 用量,第一次跑要先下载统计工具,可能要几十秒…');
57
120
  days = readUsage();
58
- console.log(days.length ? ` 已抓好 ${days.length} 天,就等你在浏览器批准。` : ' 没读到用量。');
121
+ if (days.length) {
122
+ console.log(` ✓ 统计好了,共 ${days.length} 天的用量,你在浏览器一批准就立刻上传。`);
123
+ } else {
124
+ console.log(' 这台电脑暂时没读到用量——可能还没用 Claude Code / Codex 干过活,不碍事。');
125
+ console.log(' 先把账号批准了;之后有了用量,重跑一次这条命令就会自动传上来。');
126
+ }
59
127
  } else {
60
- console.log('\n② 本地用量已经抓好,就等你在浏览器批准。');
128
+ console.log('\n② 本地用量刚才已经统计好,你一批准就立刻上传。');
61
129
  }
62
130
 
63
- process.stdout.write(' 等你点确认…');
131
+ stage = 'approve';
132
+ console.log('\n③ 就差你在浏览器里点批准了');
133
+ process.stdout.write(' 等你确认中,点完这边马上继续(不想等就按 Ctrl+C,随时能回来)');
64
134
  const until = Date.now() + s.expires_in * 1000;
65
135
  while (Date.now() < until) {
66
136
  await sleep(s.interval * 1000);
67
137
  const p = await post(server + '/api/device/poll', { device_code: s.device_code });
68
138
  if (p.status === 'approved') {
69
139
  c.key = p.key; c.server = server; saveConfig(c);
70
- console.log('\n✓ 已批准,密钥已存到 ' + CONFIG);
140
+ console.log('\n 批准成功!密钥已存好(' + CONFIG + '),这台电脑之后免登录。');
71
141
  await uploadDays(days, { server, key: p.key, device });
72
- console.log('\n想让它每半小时自动后台上报? npm i -g tokenforbes-cli 后跑 tokenforbes daemon install');
142
+ ensureDaemonInstalled();
73
143
  return;
74
144
  }
75
145
  if (p.status === 'expired') break;
76
146
  process.stdout.write('.');
77
147
  }
78
- die('\n登录超时或验证码已过期,重新跑一次 npx -y tokenforbes-cli');
148
+ die('✗ 这次的验证码过期了——等得有点久,安全起见每个码只有几分钟有效。\n 没关系,重跑一遍就有新码,一分钟搞定:\n ' + CMD_LINK);
79
149
  }
80
150
 
81
151
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
@@ -88,23 +158,30 @@ function openBrowser(url) {
88
158
  }
89
159
 
90
160
  async function register(nickname) {
91
- if (!nickname) die('用法: tokenforbes register <昵称>');
161
+ if (!nickname) {
162
+ die('差一个昵称,没法注册。这样用:\n ' + CMD_PLAIN + ' register <你的昵称>\n\n 更省事的办法:直接跑 ' + CMD_PLAIN + ' ,在浏览器里填昵称就行。');
163
+ }
92
164
  const c = loadConfig();
93
165
  const server = c.server || DEFAULT_SERVER;
94
166
  const device = detectDevice();
95
167
  const j = await post(server + '/api/register', { nickname, device });
96
168
  c.key = j.key; c.server = server; saveConfig(c);
97
- console.log(`注册成功,昵称「${j.nickname}」。密钥已存到 ${CONFIG}`);
98
- console.log('正在读取本地用量(ccusage)…');
169
+ console.log(`✓ 注册成功,欢迎入榜,「${j.nickname}」!密钥已存好(${CONFIG}),这台电脑之后免登录。`);
170
+ stage = 'usage';
171
+ console.log('接着统计这台电脑的 token 用量,统计完马上传上去,第一次要下载统计工具,可能要几十秒…');
99
172
  await uploadDays(readUsage(), { server, key: j.key, device }); // 注册完顺手上报,别让用户再跑一条
173
+ ensureDaemonInstalled();
100
174
  }
101
175
 
102
176
  async function report({ allowRelink = true } = {}) {
103
177
  const c = loadConfig();
104
178
  // 没密钥:直接带去登录(link 会先弹浏览器,再后台抓用量),不让用户对着报错发愣。
105
179
  if (!c.key) {
106
- if (allowRelink) return link();
107
- die('还没登录。先跑 npx -y tokenforbes-cli (浏览器一键登录),或 register / login');
180
+ if (allowRelink) {
181
+ console.log('这台电脑还没登录过,先带你走一遍浏览器登录,一分钟搞定。');
182
+ return link();
183
+ }
184
+ die('这台电脑还没登录,没法上报。先跑 ' + CMD_PLAIN + ' 用浏览器一键登录(也可以 register <昵称> 或 login <密钥>)。');
108
185
  }
109
186
  const server = c.server || DEFAULT_SERVER;
110
187
  const device = detectDevice();
@@ -113,32 +190,58 @@ async function report({ allowRelink = true } = {}) {
113
190
  if (!probe.ok) {
114
191
  const authFailed = probe.status === 401 || /密钥/.test(probe.json?.error || '');
115
192
  if (authFailed && allowRelink) {
116
- console.log('本地密钥已失效,这就打开浏览器带你重新登录…');
193
+ console.log('本机存的密钥不管用了(可能在网站上重置过密钥)。');
194
+ console.log('不用慌,你的历史数据都在。这就打开浏览器带你重新登录…');
117
195
  delete c.key; saveConfig(c);
118
196
  return link(); // 密钥无效,还没抓用量;交给 link 先弹浏览器再后台抓
119
197
  }
120
- die('上报失败:' + (probe.json?.error || probe.netError || probe.status));
198
+ die(sendFailText(probe));
121
199
  }
122
200
  // 密钥有效:抓用量并上传。
123
- console.log('正在读取本地用量(ccusage)…');
201
+ stage = 'usage';
202
+ console.log('正在统计这台电脑的 token 用量(Claude Code / Codex),可能要几十秒…');
124
203
  await uploadDays(readUsage(), { server, key: c.key, device });
204
+ ensureDaemonInstalled();
125
205
  }
126
206
 
127
207
  // 把抓好的用量传上去。抽出来给 report / link / register 共用,避免各写一遍、避免重复抓。
128
208
  async function uploadDays(days, { server, key, device }) {
129
- if (!days || !days.length) return console.log('没读到用量数据(这台电脑还没用过 Claude Code / Codex?)。');
209
+ if (!days || !days.length) {
210
+ console.log('\n这台电脑上没读到 token 用量,这次就先不上传了。');
211
+ console.log('多半是这两种情况,都不用担心:');
212
+ console.log(' · 这台电脑还没用 Claude Code / Codex 干过活 —— 用起来之后重跑一次就有了');
213
+ console.log(' · 你平时在另一台电脑上干活 —— 去那台电脑跑同样这条命令');
214
+ console.log('账号本身没任何问题。之后随时跑 ' + CMD_PLAIN + ' 都能补传。');
215
+ return;
216
+ }
217
+ stage = 'upload';
130
218
  const total = days.reduce((s, d) => s + d.input + d.output + (d.cacheCreation || 0) + (d.cacheRead || 0), 0);
131
- console.log(`设备:${device}`);
132
- console.log(`共 ${days.length} 天,合计约 ${Math.round(total / 1e4).toLocaleString()} 万 token,正在上传…`);
219
+ console.log(`\n设备:${device}`);
220
+ console.log(`共 ${days.length} 天、约 ${Math.round(total / 1e4).toLocaleString()} 万 token,正在上传(只传数字,不传内容)…`);
133
221
  const res = await postRaw(server + '/api/report', { key, device, days });
134
- if (res.ok) console.log(`上报成功 ✓ 已记录 ${res.json.days} 天。去 ${server} 看你的排名。`);
135
- else die('上报失败:' + (res.json?.error || res.netError || res.status));
222
+ if (!res.ok) die(sendFailText(res));
223
+ stage = 'done';
224
+ console.log(`✓ 上报成功!服务器已记下 ${res.json.days} 天的用量。\n`);
225
+ console.log('去看看成果:');
226
+ console.log(` · 打开 ${server} ,在排行榜上找你的名次`);
227
+ console.log(' · 登录后进「同步数据」页,能看到这台设备每天的上报明细');
228
+ }
229
+
230
+ // 统一的「没传上去」文案:说清原因 + 数据没丢 + 下一步。
231
+ function sendFailText(res) {
232
+ const why = res.netError || res.json?.error || ('服务器回了个 ' + res.status + ',没说原因');
233
+ return '✗ 这次没传上去:' + why + '\n' +
234
+ ' 你的用量都存在本机,一条都不会丢。\n' +
235
+ ' 等网络稳定了重跑一次就行: ' + CMD_PLAIN;
136
236
  }
137
237
 
138
238
  // 会抛错就 die 的版本:给登录/注册这类「失败就该停」的调用用。
139
239
  async function post(url, body) {
140
240
  const res = await postRaw(url, body);
141
- if (!res.ok) die('失败:' + (res.json?.error || res.netError || res.status));
241
+ if (!res.ok) {
242
+ die('✗ 没连上服务器,这一步没办成:' + (res.netError || res.json?.error || ('服务器回了个 ' + res.status)) + '\n' +
243
+ ' 你这边什么都没丢。检查下网络,稍后重跑: ' + CMD_PLAIN);
244
+ }
142
245
  return res.json;
143
246
  }
144
247
 
@@ -148,10 +251,10 @@ async function postRaw(url, body) {
148
251
  try {
149
252
  res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
150
253
  } catch (e) {
151
- return { ok: false, netError: `连不上服务器(${url}):${e.message}` };
254
+ return { ok: false, netError: `连不上服务器(${e.message}),可能是断网、开了代理,或服务器暂时没响应` };
152
255
  }
153
256
  const json = await res.json().catch(() => ({}));
154
257
  return { ok: res.ok, status: res.status, json };
155
258
  }
156
259
 
157
- function die(msg) { console.error(msg); process.exit(1); }
260
+ function die(msg) { console.error('\n' + msg); process.exit(1); }
package/ccusage.js CHANGED
@@ -4,6 +4,8 @@ import { execSync } from 'node:child_process';
4
4
 
5
5
  const num = (x) => Math.max(0, Math.round(Number(x) || 0));
6
6
  const money = (x) => Math.max(0, Number(x) || 0); // 成本是美元小数,别取整
7
+ // friendly 标记 = 文案已经按「出了什么事 + 下一步」写好,上层直接展示,别再包一层「出了点意外」。
8
+ const friendly = (msg) => { const e = new Error(msg); e.friendly = true; return e; };
7
9
 
8
10
  export function readUsage() {
9
11
  let out;
@@ -11,17 +13,23 @@ export function readUsage() {
11
13
  out = execSync('npx ccusage@latest daily --breakdown --json', {
12
14
  encoding: 'utf8', timeout: 120000, maxBuffer: 64 * 1024 * 1024,
13
15
  });
14
- } catch {
15
- throw new Error('读取用量失败:ccusage 跑不起来。先确认能联网、能跑 npx。');
16
+ } catch (e) {
17
+ // 用户按 Ctrl+C 时,这个子进程会先被打断——这不是故障,标记出来让上层好聚好散。
18
+ if (e && (e.signal === 'SIGINT' || e.signal === 'SIGTERM' || e.status === 130)) {
19
+ const stop = new Error('interrupted');
20
+ stop.interrupted = true;
21
+ throw stop;
22
+ }
23
+ throw friendly('✗ 统计工具(ccusage)没跑起来,这次没读到用量。\n 多半是网络不稳——它第一次要联网下载。检查下网络,重跑一次就行;\n 要是反复失败,单独跑一下 npx ccusage@latest daily 看看它报什么。');
16
24
  }
17
25
  let data;
18
26
  try {
19
27
  data = JSON.parse(out);
20
28
  } catch {
21
- throw new Error('读取用量失败:ccusage 输出看不懂(可能版本变了)。');
29
+ throw friendly('✗ 统计工具(ccusage)的输出这版看不懂了,可能它刚改版。\n 过几个小时再跑一次试试;一直不行就是我们这边要修,去 https://ceorank.cn 说一声。');
22
30
  }
23
31
  if (!data || !Array.isArray(data.daily)) {
24
- throw new Error('读取用量失败:没找到每日数据(ccusage 输出结构变了)。');
32
+ throw friendly('✗ 统计工具(ccusage)的输出里没找到每日数据,格式可能变了。\n 过几个小时再跑一次试试;一直不行就是我们这边要修,去 https://ceorank.cn 说一声。');
25
33
  }
26
34
  return data.daily
27
35
  .map((d) => {
package/daemon.js CHANGED
@@ -1,38 +1,72 @@
1
- // 后台自动同步:在 macOS 上装一个 launchd 定时任务,每 30 分钟自动跑一次 report。
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 BIN = fileURLToPath(new URL('./bin.js', import.meta.url)); // 这个 CLI 的绝对路径
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 ensureMac() {
18
- if (process.platform !== 'darwin') {
19
- console.error('后台自动同步目前只支持 macOS。其它系统可自己挂个定时任务跑 tokenforbes report');
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,你这个系统暂时开不了。\n不过可以自己挂个定时任务,每半小时跑一次: 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>${process.execPath}</string>
34
- <string>${BIN}</string>
35
- <string>report</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,49 +77,143 @@ export function buildPlist() {
43
77
  `;
44
78
  }
45
79
 
46
- function install() {
47
- ensureMac();
80
+ function requireKey() {
48
81
  // 没登录就别装,否则只会每半小时报一次「未登录」错。
49
82
  let key;
50
83
  try { key = JSON.parse(fs.readFileSync(CONFIG, 'utf8')).key; } catch {}
51
- if (!key) { console.error('还没登录,先跑 npx tokenforbes-cli 登录后再装。'); process.exit(1); }
84
+ return Boolean(key);
85
+ }
52
86
 
53
- // ponytail: plist 里写死了 BIN 的绝对路径。装 daemon 请用全局安装(npm i -g),
54
- // 别用 npx —— npx 缓存目录会被清,清掉后 launchd 每半小时报一次「文件不存在」。
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]);
61
- console.log('✓ 已开启后台自动同步,每 30 分钟自动上报一次。');
62
- console.log(' 任务文件: ' + PLIST);
63
- console.log(' 日志: ' + LOG);
64
- console.log(' 查看状态: tokenforbes daemon status');
65
- console.log(' 关掉它: tokenforbes daemon uninstall');
66
104
  }
67
105
 
68
- function status() {
69
- ensureMac();
70
- if (!fs.existsSync(PLIST)) return console.log('未安装。装它: tokenforbes daemon install');
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('这台电脑还没登录,先不开后台同步——开了也只会每半小时报一次「未登录」。\n先跑 npx -y tokenforbes-cli@latest 登录,登录成功会自动帮你开启。');
115
+ process.exit(1);
116
+ }
117
+ if (process.platform === 'darwin') installMac();
118
+ else installWindows();
119
+ console.log('✓ 后台自动同步已开启:每 30 分钟自动上报一次,之后不用你管。');
120
+ if (process.platform === 'darwin') {
121
+ console.log(' 任务文件: ' + PLIST);
122
+ console.log(' 日志: ' + LOG);
123
+ }
124
+ console.log(' 看状态: npx -y tokenforbes-cli@latest daemon status');
125
+ console.log(' 想关掉: npx -y tokenforbes-cli@latest daemon uninstall');
126
+ }
127
+
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
- console.log(loaded ? '✓ 已安装并在运行,每 30 分钟自动上报。' : '已安装但未加载(重新 install 一下)。');
132
+ console.log(loaded
133
+ ? '✓ 后台自动同步在运行,每 30 分钟自动上报一次,不用你管。'
134
+ : '装过,但目前没在运行。重跑一次 npx -y tokenforbes-cli@latest daemon install 就能拉起来。');
74
135
  console.log(' 任务文件: ' + PLIST);
75
- console.log(' 日志: ' + LOG + '(看最近几次上报有没有出错)');
136
+ console.log(' 日志: ' + LOG + '(想看最近几次上报有没有出错就翻它)');
137
+ return true;
76
138
  }
77
139
 
78
- function uninstall() {
79
- ensureMac();
80
- if (!fs.existsSync(PLIST)) return console.log('本来就没装。');
140
+ function statusWindows() {
141
+ try {
142
+ execFileSync('schtasks', ['/query', '/tn', LABEL], { stdio: 'ignore' });
143
+ console.log('✓ 后台自动同步在运行,Windows 每 30 分钟自动上报一次,不用你管。');
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function needsInstall() {
151
+ if (process.platform === 'darwin') {
152
+ if (!fs.existsSync(PLIST)) return true;
153
+ // 内容不含新版命令 = 旧格式;不含当前 PATH = 用户换过 node 安装位置,都重装自愈。
154
+ try {
155
+ const content = fs.readFileSync(PLIST, 'utf8');
156
+ return !content.includes('tokenforbes-cli@latest') || !content.includes(launchdPath());
157
+ } catch { return true; }
158
+ }
159
+ if (process.platform === 'win32') {
160
+ try { execFileSync('schtasks', ['/query', '/tn', LABEL], { stdio: 'ignore' }); return false; } catch { return true; }
161
+ }
162
+ return true;
163
+ }
164
+
165
+ function status() {
166
+ ensureSupported();
167
+ const ok = process.platform === 'darwin' ? statusMac() : statusWindows();
168
+ if (!ok) console.log('后台自动同步还没开启。想开就跑: npx -y tokenforbes-cli@latest daemon install');
169
+ }
170
+
171
+ function uninstallMac() {
172
+ cleanupLegacyMac();
173
+ if (!fs.existsSync(PLIST)) return console.log('后台自动同步本来就没开,不用关。');
81
174
  try { execFileSync('launchctl', ['unload', PLIST], { stdio: 'ignore' }); } catch {}
82
175
  fs.rmSync(PLIST, { force: true });
83
- console.log('✓ 已关闭后台自动同步,删除了任务文件。');
176
+ console.log('✓ 已关闭后台自动同步,任务文件也删干净了。想再开: npx -y tokenforbes-cli@latest daemon install');
177
+ }
178
+
179
+ function uninstallWindows() {
180
+ try {
181
+ execFileSync('schtasks', ['/delete', '/tn', LABEL, '/f'], { stdio: 'ignore' });
182
+ console.log('✓ 已关闭后台自动同步。想再开: npx -y tokenforbes-cli@latest daemon install');
183
+ } catch {
184
+ console.log('后台自动同步本来就没开,不用关。');
185
+ }
186
+ fs.rmSync(VBS, { force: true });
187
+ }
188
+
189
+ function uninstall() {
190
+ ensureSupported();
191
+ if (process.platform === 'darwin') return uninstallMac();
192
+ return uninstallWindows();
193
+ }
194
+
195
+ export function ensureDaemonInstalled() {
196
+ if (!supported()) return;
197
+ // 老脚本清理独立于「要不要装」:就算新任务已装好,发现旧任务也顺手关掉。
198
+ if (process.platform === 'darwin') { try { cleanupLegacyMac(); } catch {} }
199
+ if (!needsInstall()) return;
200
+ try {
201
+ if (!requireKey()) return;
202
+ if (process.platform === 'darwin') installMac();
203
+ else installWindows();
204
+ console.log('顺手帮你开好了后台自动同步:之后每 30 分钟自动上报一次,不用再手动跑命令。');
205
+ console.log('(想关掉随时跑: npx -y tokenforbes-cli@latest daemon uninstall )');
206
+ } catch (e) {
207
+ console.log('后台自动同步这次没开成(不影响刚才的上报)。想开的话稍后手动跑: npx -y tokenforbes-cli@latest daemon install');
208
+ }
84
209
  }
85
210
 
86
211
  export function daemon(sub) {
87
212
  if (sub === 'install') return install();
88
213
  if (sub === 'status') return status();
89
214
  if (sub === 'uninstall' || sub === 'remove') return uninstall();
90
- console.log('用法: tokenforbes daemon install | status | uninstall');
215
+ console.log('后台自动同步的开关,这样用:');
216
+ console.log(' npx -y tokenforbes-cli@latest daemon install 开启(每 30 分钟自动上报)');
217
+ console.log(' npx -y tokenforbes-cli@latest daemon status 看状态');
218
+ console.log(' npx -y tokenforbes-cli@latest daemon uninstall 关闭');
91
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenforbes-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "上报本地 AI 编程工具(Claude Code / Codex 等)的 token 用量到 Token 福布斯排行榜(ceorank.cn)。只读用量数字和设备型号,绝不碰你的代码内容。",
5
5
  "type": "module",
6
6
  "bin": {