@juejin-opensource/jusage 0.1.1-beta.8

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 (36) hide show
  1. package/README.md +72 -0
  2. package/bin/jusage.js +2 -0
  3. package/dist/daemon.d.ts +12 -0
  4. package/dist/daemon.js +68 -0
  5. package/dist/dashboard/assets/antigravity-color-DgAQAdn3.svg +1 -0
  6. package/dist/dashboard/assets/click-idle-6pQvdt8N.png +0 -0
  7. package/dist/dashboard/assets/dashboard-CUazuH2T.js +55 -0
  8. package/dist/dashboard/assets/dashboard-QA2DxQpC.css +1 -0
  9. package/dist/dashboard/assets/download-BzFSswoC.js +1 -0
  10. package/dist/dashboard/assets/env-BKC5UKtr.js +1 -0
  11. package/dist/dashboard/assets/hawking-idle-C6M3ZvDm.png +0 -0
  12. package/dist/dashboard/assets/hermesagent-DUfTwIy7.svg +1 -0
  13. package/dist/dashboard/assets/index-1sV0Fo6m.js +143 -0
  14. package/dist/dashboard/assets/index-C1eSlCqF.css +1 -0
  15. package/dist/dashboard/assets/index-DcW6PDH-.js +1 -0
  16. package/dist/dashboard/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  17. package/dist/dashboard/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  18. package/dist/dashboard/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  19. package/dist/dashboard/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  20. package/dist/dashboard/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  21. package/dist/dashboard/assets/qoder-color-CCjsVbDP.svg +1 -0
  22. package/dist/dashboard/assets/rank-DedRCNMw.js +16 -0
  23. package/dist/dashboard/assets/settings-jDmOMdkE.js +1 -0
  24. package/dist/dashboard/assets/yoyo-idle-DmXh5tLT.png +0 -0
  25. package/dist/dashboard/brand-logos/claude-code.svg +1 -0
  26. package/dist/dashboard/brand-logos/codex.svg +15 -0
  27. package/dist/dashboard/index.html +13 -0
  28. package/dist/index.d.ts +10 -0
  29. package/dist/index.js +486 -0
  30. package/dist/service-macos.d.ts +7 -0
  31. package/dist/service-macos.js +97 -0
  32. package/dist/service-windows.d.ts +6 -0
  33. package/dist/service-windows.js +50 -0
  34. package/dist/service.d.ts +3 -0
  35. package/dist/service.js +109 -0
  36. package/package.json +51 -0
package/dist/index.js ADDED
@@ -0,0 +1,486 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { appendJsonLog, BucketStore, createAggregateCache, createApplyAfterSync, createHttpServer, createLocalApiApp, createPollBackoff, DEFAULT_PORT, getHookStatus, getRunningOwner, listenServer, loadConfig, POLL_INTERVAL_MS, pollIntervalLabel, releaseRuntimeOwner, resolveLocalCollectSince, runtimeKindLabel, saveConfig, setupClaudeHook, setupCodexHook, syncAll, collectWrittenBuckets, syncLogPath, touchStatsSince, watchRuntimeSignals, watchSyncSignals, writeSyncDone, maybeUploadAfterSync, uploadToServer, resolvePricingRefreshConfig, startPricingRefresh, } from '@juejin-opensource/jusage-core';
4
+ import { writePid } from './daemon.js';
5
+ import { cmdServiceStart, cmdServiceStatus, cmdServiceStop } from './service.js';
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ let server = null;
8
+ let pollTimer = null;
9
+ let syncWatcherStop = null;
10
+ let pricingRefreshStop = null;
11
+ let runSyncFn = null;
12
+ let runtime = null;
13
+ let runtimeDataDir = null;
14
+ /** True only when this process wrote `tud.pid` as sync/upload owner. */
15
+ let ownedPid = false;
16
+ let runtimeRole = 'owner';
17
+ function startPricingOverlayRefresh(config) {
18
+ pricingRefreshStop?.();
19
+ pricingRefreshStop = null;
20
+ const { url, ttlMs } = resolvePricingRefreshConfig({
21
+ url: config.pricing?.url,
22
+ ttlMs: config.pricing?.ttlMs,
23
+ });
24
+ if (!url)
25
+ return;
26
+ pricingRefreshStop = startPricingRefresh({
27
+ url,
28
+ ttlMs,
29
+ onError: (err) => {
30
+ console.warn('定价表远程刷新失败(继续用内置/上次覆盖):', err instanceof Error ? err.message : err);
31
+ },
32
+ });
33
+ console.log(`定价覆盖层: ${url}(TTL ${ttlMs}ms)`);
34
+ }
35
+ function resolveDashboardDir() {
36
+ return join(__dirname, 'dashboard');
37
+ }
38
+ function resolveCliBinPath() {
39
+ return fileURLToPath(new URL('../bin/jusage.js', import.meta.url));
40
+ }
41
+ export function parseArgs(argv) {
42
+ const args = argv.slice(2);
43
+ let command = args[0] ?? 'start';
44
+ let serviceAction;
45
+ let port = DEFAULT_PORT;
46
+ let source;
47
+ let force = false;
48
+ let reconcile = false;
49
+ let days;
50
+ if (command === 'help' || command === '--help' || command === '-h' || args.includes('--help') || args.includes('-h')) {
51
+ return { command: 'help', port, source, force, reconcile };
52
+ }
53
+ if (command === 'service') {
54
+ serviceAction = args[1];
55
+ }
56
+ for (let i = 0; i < args.length; i++) {
57
+ if (args[i] === '--port' && args[i + 1]) {
58
+ port = Number(args[i + 1]) || DEFAULT_PORT;
59
+ i += 1;
60
+ }
61
+ else if (args[i] === '--source' && args[i + 1]) {
62
+ source = args[i + 1];
63
+ i += 1;
64
+ }
65
+ else if (args[i]?.startsWith('--source=')) {
66
+ source = args[i].slice('--source='.length);
67
+ }
68
+ else if (args[i] === '--force') {
69
+ force = true;
70
+ }
71
+ else if (args[i] === '--reconcile') {
72
+ reconcile = true;
73
+ }
74
+ else if (args[i] === '--days' && args[i + 1]) {
75
+ days = Number(args[i + 1]);
76
+ i += 1;
77
+ }
78
+ else if (args[i]?.startsWith('--days=')) {
79
+ days = Number(args[i].slice('--days='.length));
80
+ }
81
+ }
82
+ if (command.startsWith('-'))
83
+ command = 'start';
84
+ return { command, serviceAction, port, source, force, reconcile, days };
85
+ }
86
+ function resolveDaysAgo(days) {
87
+ if (days === undefined)
88
+ return undefined;
89
+ if (!Number.isInteger(days) || days <= 0) {
90
+ throw new Error(`--days 须为正整数,收到: ${days}`);
91
+ }
92
+ return days;
93
+ }
94
+ function printHelp() {
95
+ console.log(`Usage: jusage <command> [options]
96
+
97
+ Commands:
98
+ start 前台启动本地面板与同步(默认;桌面已运行时为观察模式)
99
+ stop 停止当前进程内的服务
100
+ status 查看 CLI / 面板当前启动状态
101
+ sync 手动同步本地用量数据
102
+ upload 上报数据到云端
103
+ service <action> 后台服务与开机自启(macOS / Windows)
104
+ action: start | stop | status
105
+ help 显示帮助
106
+
107
+ Options:
108
+ --port <number> 面板端口(默认 ${DEFAULT_PORT})
109
+ --source <name> sync 数据源:claude | codex | cursor | qoder | trae | gemini | opencode | copilot | antigravity | openclaw | hermes | zcode | pi | kimi | roocode | droid | kiro | cline | amp | qwen | codebuddy | workbuddy | grok | mimo | every-code | omp | kilo-cli | kilocode | goose | zed | warp | all
110
+ --force upload 时忽略云端同步开关,强制上报
111
+ --reconcile upload 时做全量对账
112
+ -h, --help 显示帮助
113
+
114
+ Examples:
115
+ jusage
116
+ jusage start --port 8452
117
+ jusage status
118
+ jusage sync --source=claude
119
+ jusage upload --force
120
+ jusage service start`);
121
+ }
122
+ async function refreshRuntimeFromDisk(results) {
123
+ if (!runtime)
124
+ return;
125
+ try {
126
+ const { config } = await loadConfig(runtime.dir);
127
+ runtime.config = config;
128
+ if (results) {
129
+ const written = collectWrittenBuckets(results);
130
+ if (written.length > 0) {
131
+ runtime.bucketStore.apply(written);
132
+ }
133
+ await runtime.aggregateCache.onBucketsChanged(runtime.bucketStore.getRows(), written);
134
+ await appendJsonLog(syncLogPath(runtime.dir), {
135
+ event: 'bucket_apply',
136
+ written: written.length,
137
+ statsSince: config.statsSince,
138
+ localCollectSince: resolveLocalCollectSince(config),
139
+ lastSyncAt: config.lastSyncAt,
140
+ });
141
+ return;
142
+ }
143
+ await runtime.bucketStore.refresh(runtime.dir, resolveLocalCollectSince(config));
144
+ await runtime.aggregateCache.rebuildFromRows(runtime.bucketStore.getRows());
145
+ await appendJsonLog(syncLogPath(runtime.dir), {
146
+ event: 'bucket_refresh',
147
+ statsSince: config.statsSince,
148
+ localCollectSince: resolveLocalCollectSince(config),
149
+ lastSyncAt: config.lastSyncAt,
150
+ });
151
+ }
152
+ catch (err) {
153
+ await appendJsonLog(syncLogPath(runtime.dir), {
154
+ event: 'bucket_refresh_error',
155
+ error: err instanceof Error ? err.message : String(err),
156
+ });
157
+ }
158
+ }
159
+ async function cmdStart(port, daysAgo) {
160
+ const { dir, config } = await loadConfig();
161
+ const existing = await getRunningOwner(dir);
162
+ if (existing != null && existing.pid !== process.pid && existing.kind === 'cli') {
163
+ throw new Error(`服务已在运行(CLI pid ${existing.pid}),请先 jusage service stop 或结束该进程`);
164
+ }
165
+ // Desktop already owns sync/upload → serve panel only (observer).
166
+ const isObserver = existing != null && existing.pid !== process.pid && existing.kind === 'desktop';
167
+ runtimeRole = isObserver ? 'observer' : 'owner';
168
+ ownedPid = false;
169
+ await touchStatsSince(dir, config, daysAgo != null ? { daysAgo } : undefined);
170
+ config.serverPort = port;
171
+ await saveConfig(dir, config);
172
+ console.log(`设备 UUID: ${config.deviceId}`);
173
+ console.log(`上报 Token: ${config.juejin.token ?? '(未配置)'}`);
174
+ console.log(`云端地址: ${config.juejin.apiUrl}`);
175
+ console.log(`云端同步: ${config.juejin.enabled ? '已开启' : '未开启'}`);
176
+ if (isObserver && existing) {
177
+ console.log(`\n◎ 观察模式:${runtimeKindLabel(existing.kind)} (pid ${existing.pid}) 负责同步/上报`);
178
+ console.log(' 本进程只提供面板读数,不抢占 runtime、不轮询、不上报\n');
179
+ }
180
+ const { hookOk: claudeHookOk } = await setupClaudeHook(dir);
181
+ const { hookOk: codexHookOk } = await setupCodexHook(dir);
182
+ if (!isObserver) {
183
+ if (!claudeHookOk) {
184
+ console.warn(`⚠ Claude Hook 未注册成功,将使用定时轮询(${pollIntervalLabel()})同步`);
185
+ }
186
+ if (!codexHookOk) {
187
+ console.warn(`⚠ Codex Hook 未注册成功,将使用定时轮询(${pollIntervalLabel()})同步`);
188
+ }
189
+ }
190
+ const { config: refreshed } = await loadConfig(dir);
191
+ startPricingOverlayRefresh(refreshed);
192
+ const bucketStore = new BucketStore();
193
+ await bucketStore.reload(dir, resolveLocalCollectSince(refreshed));
194
+ const aggregateCache = await createAggregateCache(dir, bucketStore.getRows());
195
+ runtime = { dir, config: refreshed, bucketStore, aggregateCache };
196
+ runtimeDataDir = dir;
197
+ // Idle rounds back off 1min → 2min → 5min; hook/manual activity re-arms at 1min.
198
+ const pollBackoff = createPollBackoff();
199
+ let reArmPoll = null;
200
+ const resetPollBackoffToFast = () => {
201
+ const wasSlow = pollBackoff.currentDelayMs() > POLL_INTERVAL_MS;
202
+ pollBackoff.reset();
203
+ if (wasSlow)
204
+ reArmPoll?.();
205
+ };
206
+ if (isObserver) {
207
+ syncWatcherStop = watchSyncSignals(dir, async (filename) => {
208
+ if (filename === 'sync.done') {
209
+ await refreshRuntimeFromDisk();
210
+ }
211
+ });
212
+ runSyncFn = null;
213
+ }
214
+ else {
215
+ const applyAfterSync = createApplyAfterSync({
216
+ getBucketStore: () => runtime.bucketStore,
217
+ getAggregateCache: () => runtime?.aggregateCache,
218
+ });
219
+ const { stop, runSync } = watchRuntimeSignals({
220
+ dataDir: dir,
221
+ getConfig: () => runtime.config,
222
+ setConfig: (next) => {
223
+ if (runtime)
224
+ runtime.config = next;
225
+ },
226
+ refreshFromDisk: async (results, opts) => {
227
+ // Non-quiet result delivery means a hook/manual sync ran: user active.
228
+ if (results && results.length > 0 && !opts?.quiet) {
229
+ resetPollBackoffToFast();
230
+ }
231
+ if (!results) {
232
+ await refreshRuntimeFromDisk();
233
+ return;
234
+ }
235
+ await applyAfterSync(results, opts);
236
+ },
237
+ isOwner: () => runtimeRole === 'owner',
238
+ loadConfig,
239
+ });
240
+ syncWatcherStop = stop;
241
+ runSyncFn = runSync;
242
+ }
243
+ const getConfig = () => runtime.config;
244
+ const app = createLocalApiApp({
245
+ dataDir: dir,
246
+ getConfig,
247
+ bucketStore,
248
+ aggregateCache,
249
+ runSyncViaRunner: runSyncFn
250
+ ? (reason, source) => runSyncFn(reason, source)
251
+ : undefined,
252
+ getHookStatus: () => getHookStatus(dir),
253
+ onConfigChange: (next) => {
254
+ if (runtime)
255
+ runtime.config = next;
256
+ },
257
+ });
258
+ const httpServer = createHttpServer({
259
+ honoApp: app,
260
+ staticDir: resolveDashboardDir(),
261
+ port,
262
+ });
263
+ const { port: actualPort } = await listenServer(httpServer, '127.0.0.1', port);
264
+ server = httpServer;
265
+ if (!isObserver) {
266
+ await writePid(process.pid, dir, 'cli');
267
+ ownedPid = true;
268
+ // Schedule next poll only after the previous one settles (avoid overlap).
269
+ const scheduleNextPoll = (delayMs = pollBackoff.currentDelayMs()) => {
270
+ if (pollTimer)
271
+ clearTimeout(pollTimer);
272
+ pollTimer = null;
273
+ if (!runtime || runtimeRole !== 'owner' || !runSyncFn)
274
+ return;
275
+ pollTimer = setTimeout(() => {
276
+ void (async () => {
277
+ let nextDelayMs = pollBackoff.currentDelayMs();
278
+ try {
279
+ if (!runtime || runtimeRole !== 'owner' || !runSyncFn)
280
+ return;
281
+ const results = await runSyncFn('poll');
282
+ const wroteAny = results.some((r) => r.writtenBuckets.length > 0);
283
+ nextDelayMs = pollBackoff.noteRound(wroteAny);
284
+ }
285
+ catch (err) {
286
+ console.error('轮询同步失败:', err instanceof Error ? err.message : err);
287
+ }
288
+ finally {
289
+ scheduleNextPoll(nextDelayMs);
290
+ }
291
+ })();
292
+ }, delayMs);
293
+ };
294
+ reArmPoll = () => scheduleNextPoll();
295
+ console.log('后台同步 Claude / Codex / Cursor 数据…');
296
+ void runSyncFn?.('startup')
297
+ .catch((err) => {
298
+ console.error('启动同步失败:', err instanceof Error ? err.message : err);
299
+ })
300
+ .finally(scheduleNextPoll);
301
+ }
302
+ const url = `http://127.0.0.1:${actualPort}`;
303
+ console.log(`\n✓ Juejin Usage 已启动${isObserver ? '(观察模式)' : ''}`);
304
+ console.log(` 面板: ${url}`);
305
+ console.log(` 数据: ${dir}`);
306
+ console.log(` 调试日志: ${join(dir, 'logs')}`);
307
+ if (isObserver) {
308
+ console.log(' 同步/上报: 由桌面端负责');
309
+ }
310
+ else {
311
+ console.log(` Claude Hook: ${claudeHookOk ? '已注册' : '轮询模式'}`);
312
+ console.log(` Codex Hook: ${codexHookOk ? '已注册' : '轮询模式'}`);
313
+ }
314
+ console.log(`\n按 Ctrl+C 停止\n`);
315
+ }
316
+ async function cmdSync(source) {
317
+ const { dir, config } = await loadConfig();
318
+ await touchStatsSince(dir, config);
319
+ const scope = source ?? 'all';
320
+ const started = Date.now();
321
+ const logPath = syncLogPath(dir);
322
+ await appendJsonLog(logPath, { event: 'start', source: scope });
323
+ try {
324
+ const results = await syncAll(dir, config, source);
325
+ await writeSyncDone(dir);
326
+ await maybeUploadAfterSync(dir, config, collectWrittenBuckets(results));
327
+ await appendJsonLog(logPath, {
328
+ event: 'done',
329
+ source: scope,
330
+ durationMs: Date.now() - started,
331
+ results: results.map((r) => ({
332
+ source: r.source,
333
+ eventsParsed: r.eventsParsed,
334
+ bucketsWritten: r.bucketsWritten,
335
+ filesProcessed: r.filesProcessed,
336
+ })),
337
+ });
338
+ for (const r of results) {
339
+ console.log(`${r.source}: ${r.eventsParsed} 条消息, ${r.bucketsWritten} 个桶写入, ${r.filesProcessed} 个文件`);
340
+ }
341
+ }
342
+ catch (err) {
343
+ await appendJsonLog(logPath, {
344
+ event: 'error',
345
+ source: scope,
346
+ durationMs: Date.now() - started,
347
+ error: err instanceof Error ? err.message : String(err),
348
+ });
349
+ throw err;
350
+ }
351
+ }
352
+ async function cmdStatus() {
353
+ const { dir, config } = await loadConfig();
354
+ const hooks = await getHookStatus(dir);
355
+ const owner = await getRunningOwner(dir);
356
+ const panelUp = server != null;
357
+ const port = config.serverPort || DEFAULT_PORT;
358
+ if (owner != null) {
359
+ console.log(`Runtime: ${runtimeKindLabel(owner.kind)} pid ${owner.pid}(同步/上报)`);
360
+ }
361
+ else {
362
+ console.log('Runtime: 未运行');
363
+ }
364
+ if (panelUp) {
365
+ console.log(`本进程面板: 运行中${runtimeRole === 'observer' ? '(观察模式,只读)' : ''}`);
366
+ }
367
+ console.log(`面板: http://127.0.0.1:${port}`);
368
+ console.log(`数据目录: ${dir}`);
369
+ console.log(`设备 UUID: ${config.deviceId}`);
370
+ console.log(`上报 Token: ${config.juejin.token ?? '(未配置)'}`);
371
+ console.log(`statsSince: ${config.statsSince}`);
372
+ console.log(`上次同步: ${config.lastSyncAt ?? '从未'}`);
373
+ console.log(`Claude Hook: ${hooks.claude ? 'active' : 'poll 模式'}`);
374
+ console.log(`Codex Hook: ${hooks.codex ? 'active' : 'poll 模式'}`);
375
+ console.log(`Cursor: 轮询模式(无 Hook)`);
376
+ console.log(`云端同步: ${config.juejin.enabled ? '已开启' : '未开启'} → ${config.juejin.apiUrl}`);
377
+ console.log(`上次上报: ${config.lastUploadAt ?? '从未'}`);
378
+ console.log(`调试日志: ${join(dir, 'logs')}`);
379
+ }
380
+ async function cmdUpload(force = false, reconcile = false) {
381
+ const { dir, config } = await loadConfig();
382
+ await touchStatsSince(dir, config);
383
+ const result = await uploadToServer(dir, config, { force, fullScan: true, reconcile });
384
+ if (result === null && !force && !config.juejin.enabled) {
385
+ console.log('云端同步未开启(config.juejin.enabled = false)');
386
+ console.log('使用 --force 可强制上报');
387
+ return;
388
+ }
389
+ if (!result || result.uploaded === 0) {
390
+ console.log('无新数据需上报');
391
+ return;
392
+ }
393
+ console.log(`上报完成: ${result.uploaded} 条事件, ${result.requestCount} 个请求, accepted=${result.accepted}, duplicate=${result.duplicate}`);
394
+ }
395
+ async function cmdStop() {
396
+ if (pricingRefreshStop) {
397
+ pricingRefreshStop();
398
+ pricingRefreshStop = null;
399
+ }
400
+ if (syncWatcherStop) {
401
+ syncWatcherStop();
402
+ syncWatcherStop = null;
403
+ }
404
+ runSyncFn = null;
405
+ if (pollTimer)
406
+ clearTimeout(pollTimer);
407
+ pollTimer = null;
408
+ if (server) {
409
+ await new Promise((resolve) => server.close(() => resolve()));
410
+ server = null;
411
+ }
412
+ const wasObserver = runtimeRole === 'observer';
413
+ if (ownedPid && runtimeDataDir) {
414
+ await releaseRuntimeOwner(runtimeDataDir);
415
+ }
416
+ ownedPid = false;
417
+ runtimeDataDir = null;
418
+ runtime = null;
419
+ runtimeRole = 'owner';
420
+ console.log(wasObserver ? '观察面板已停止' : '服务已停止');
421
+ }
422
+ async function cmdService(action, daysAgo) {
423
+ const cliBinPath = resolveCliBinPath();
424
+ switch (action) {
425
+ case 'start':
426
+ await cmdServiceStart(cliBinPath, daysAgo);
427
+ break;
428
+ case 'stop':
429
+ await cmdServiceStop();
430
+ break;
431
+ case 'status':
432
+ await cmdServiceStatus();
433
+ break;
434
+ default:
435
+ console.error('用法: jusage service <start|stop|status>');
436
+ process.exit(1);
437
+ }
438
+ }
439
+ async function main() {
440
+ const { command, serviceAction, port, source, force, reconcile, days } = parseArgs(process.argv);
441
+ try {
442
+ const daysAgo = resolveDaysAgo(days);
443
+ switch (command) {
444
+ case 'help':
445
+ printHelp();
446
+ break;
447
+ case 'start':
448
+ await cmdStart(port, daysAgo);
449
+ break;
450
+ case 'sync':
451
+ await cmdSync(source);
452
+ break;
453
+ case 'status':
454
+ await cmdStatus();
455
+ break;
456
+ case 'upload':
457
+ await cmdUpload(force, reconcile);
458
+ break;
459
+ case 'stop':
460
+ await cmdStop();
461
+ break;
462
+ case 'service':
463
+ await cmdService(serviceAction, daysAgo);
464
+ break;
465
+ default:
466
+ console.error(`未知命令: ${command}`);
467
+ console.error('使用 jusage --help 查看可用命令');
468
+ process.exit(1);
469
+ }
470
+ }
471
+ catch (err) {
472
+ console.error(err instanceof Error ? err.message : err);
473
+ process.exit(1);
474
+ }
475
+ }
476
+ async function shutdownFromSignal() {
477
+ await cmdStop();
478
+ process.exit(0);
479
+ }
480
+ process.on('SIGINT', () => {
481
+ void shutdownFromSignal();
482
+ });
483
+ process.on('SIGTERM', () => {
484
+ void shutdownFromSignal();
485
+ });
486
+ main();
@@ -0,0 +1,7 @@
1
+ export declare const MACOS_LABEL = "com.juejin.jusage";
2
+ /** Previous launchd label; unload on start/stop so upgrades do not leave two daemons. */
3
+ export declare const LEGACY_MACOS_LABEL = "com.ai-usage.tud";
4
+ export declare function macosPlistPath(): string;
5
+ export declare function isMacosAutostartRegistered(): Promise<boolean>;
6
+ export declare function registerMacosAutostart(cliBinPath: string, dataDir: string): Promise<void>;
7
+ export declare function unregisterMacosAutostart(): Promise<void>;
@@ -0,0 +1,97 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { mkdir, unlink, writeFile } from 'node:fs/promises';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { daemonLogPath, ensureDaemonLogDir, resolveServiceCommand } from './daemon.js';
7
+ export const MACOS_LABEL = 'com.juejin.jusage';
8
+ /** Previous launchd label; unload on start/stop so upgrades do not leave two daemons. */
9
+ export const LEGACY_MACOS_LABEL = 'com.ai-usage.tud';
10
+ function macosPlistPathFor(label) {
11
+ return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
12
+ }
13
+ export function macosPlistPath() {
14
+ return macosPlistPathFor(MACOS_LABEL);
15
+ }
16
+ function escapeXml(value) {
17
+ return value
18
+ .replace(/&/g, '&amp;')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;');
22
+ }
23
+ function buildPlist(cliBinPath, dataDir) {
24
+ const { nodePath, args } = resolveServiceCommand(cliBinPath);
25
+ const logPath = daemonLogPath(dataDir);
26
+ const programArgs = [nodePath, ...args]
27
+ .map((a) => ` <string>${escapeXml(a)}</string>`)
28
+ .join('\n');
29
+ return `<?xml version="1.0" encoding="UTF-8"?>
30
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
31
+ <plist version="1.0">
32
+ <dict>
33
+ <key>Label</key>
34
+ <string>${MACOS_LABEL}</string>
35
+ <key>ProgramArguments</key>
36
+ <array>
37
+ ${programArgs}
38
+ </array>
39
+ <key>RunAtLoad</key>
40
+ <true/>
41
+ <key>KeepAlive</key>
42
+ <true/>
43
+ <key>StandardOutPath</key>
44
+ <string>${escapeXml(logPath)}</string>
45
+ <key>StandardErrorPath</key>
46
+ <string>${escapeXml(logPath)}</string>
47
+ </dict>
48
+ </plist>
49
+ `;
50
+ }
51
+ function uid() {
52
+ return String(process.getuid?.() ?? 501);
53
+ }
54
+ function runLaunchctl(args) {
55
+ const result = spawnSync('launchctl', args, { encoding: 'utf8' });
56
+ const stderr = `${result.stderr ?? ''}${result.stdout ?? ''}`.trim();
57
+ return { ok: result.status === 0, stderr };
58
+ }
59
+ export async function isMacosAutostartRegistered() {
60
+ return existsSync(macosPlistPath());
61
+ }
62
+ export async function registerMacosAutostart(cliBinPath, dataDir) {
63
+ await ensureDaemonLogDir(dataDir);
64
+ const plistPath = macosPlistPath();
65
+ await mkdir(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
66
+ // Unload existing before rewrite so KeepAlive does not fight us.
67
+ await unregisterMacosAutostart();
68
+ await writeFile(plistPath, buildPlist(cliBinPath, dataDir), 'utf8');
69
+ const domain = `gui/${uid()}`;
70
+ let loaded = runLaunchctl(['bootstrap', domain, plistPath]);
71
+ if (!loaded.ok) {
72
+ loaded = runLaunchctl(['load', '-w', plistPath]);
73
+ }
74
+ if (!loaded.ok) {
75
+ throw new Error(`注册 macOS 自启失败: ${loaded.stderr || 'launchctl 返回非零'}`);
76
+ }
77
+ // RunAtLoad should start it; kickstart if the job exists but is not running.
78
+ runLaunchctl(['kickstart', `${domain}/${MACOS_LABEL}`]);
79
+ }
80
+ async function unloadLaunchAgent(label) {
81
+ const plistPath = macosPlistPathFor(label);
82
+ const domain = `gui/${uid()}`;
83
+ runLaunchctl(['bootout', `${domain}/${label}`]);
84
+ runLaunchctl(['unload', plistPath]);
85
+ if (existsSync(plistPath)) {
86
+ try {
87
+ await unlink(plistPath);
88
+ }
89
+ catch {
90
+ // ignore
91
+ }
92
+ }
93
+ }
94
+ export async function unregisterMacosAutostart() {
95
+ await unloadLaunchAgent(LEGACY_MACOS_LABEL);
96
+ await unloadLaunchAgent(MACOS_LABEL);
97
+ }
@@ -0,0 +1,6 @@
1
+ export declare const WINDOWS_TASK_NAME = "jusage";
2
+ /** Previous scheduled-task name; remove on start/stop so upgrades do not leave two daemons. */
3
+ export declare const LEGACY_WINDOWS_TASK_NAME = "ai-usage-tud";
4
+ export declare function isWindowsAutostartRegistered(): Promise<boolean>;
5
+ export declare function registerWindowsAutostart(cliBinPath: string, dataDir: string): Promise<void>;
6
+ export declare function unregisterWindowsAutostart(): Promise<void>;
@@ -0,0 +1,50 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { ensureDaemonLogDir, resolveServiceCommand } from './daemon.js';
3
+ export const WINDOWS_TASK_NAME = 'jusage';
4
+ /** Previous scheduled-task name; remove on start/stop so upgrades do not leave two daemons. */
5
+ export const LEGACY_WINDOWS_TASK_NAME = 'ai-usage-tud';
6
+ function runPowerShell(script) {
7
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { encoding: 'utf8', windowsHide: true });
8
+ return {
9
+ ok: result.status === 0,
10
+ stdout: (result.stdout ?? '').trim(),
11
+ stderr: (result.stderr ?? '').trim(),
12
+ };
13
+ }
14
+ function psQuote(value) {
15
+ return `'${value.replace(/'/g, "''")}'`;
16
+ }
17
+ export async function isWindowsAutostartRegistered() {
18
+ const result = runPowerShell(`Get-ScheduledTask -TaskName ${psQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty TaskName`);
19
+ return result.ok && result.stdout.includes(WINDOWS_TASK_NAME);
20
+ }
21
+ export async function registerWindowsAutostart(cliBinPath, dataDir) {
22
+ await ensureDaemonLogDir(dataDir);
23
+ await unregisterWindowsAutostart();
24
+ const { nodePath, args } = resolveServiceCommand(cliBinPath);
25
+ const argument = args.map((a) => `"${a}"`).join(' ');
26
+ const script = `
27
+ $ErrorActionPreference = 'Stop'
28
+ $taskName = ${psQuote(WINDOWS_TASK_NAME)}
29
+ $action = New-ScheduledTaskAction -Execute ${psQuote(nodePath)} -Argument ${psQuote(argument)}
30
+ $trigger = New-ScheduledTaskTrigger -AtLogOn
31
+ $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
32
+ $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited
33
+ Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
34
+ Start-ScheduledTask -TaskName $taskName
35
+ `;
36
+ const result = runPowerShell(script);
37
+ if (!result.ok) {
38
+ throw new Error(`注册 Windows 自启失败: ${result.stderr || result.stdout || 'PowerShell 返回非零'}`);
39
+ }
40
+ }
41
+ export async function unregisterWindowsAutostart() {
42
+ const script = `
43
+ $ErrorActionPreference = 'SilentlyContinue'
44
+ foreach ($taskName in @(${psQuote(WINDOWS_TASK_NAME)}, ${psQuote(LEGACY_WINDOWS_TASK_NAME)})) {
45
+ Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
46
+ Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
47
+ }
48
+ `;
49
+ runPowerShell(script);
50
+ }
@@ -0,0 +1,3 @@
1
+ export declare function cmdServiceStart(cliBinPath: string, daysAgo?: number): Promise<void>;
2
+ export declare function cmdServiceStop(): Promise<void>;
3
+ export declare function cmdServiceStatus(): Promise<void>;