ccus-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/README.md +200 -174
- package/dist/cli.js +85 -15
- package/dist/lib/aggregate-dashboard.js +407 -407
- package/dist/lib/dashboard.js +176 -42
- package/dist/lib/paths.js +5 -0
- package/dist/lib/update-check.js +180 -0
- package/dist/lib/version.js +62 -0
- package/package.json +35 -35
package/dist/lib/dashboard.js
CHANGED
|
@@ -46,12 +46,13 @@ function summarizeEvents(events) {
|
|
|
46
46
|
*/
|
|
47
47
|
function bucketizeEvents(events, start, end, bucketMinutes = 5) {
|
|
48
48
|
const bucketMs = bucketMinutes * 60 * 1000;
|
|
49
|
+
// 5h 与 7d 在同一时间桶里各自独立收集:某条采样可能只带其中一项,不应互相牵连。
|
|
49
50
|
const buckets = new Map();
|
|
50
51
|
for (let cursor = start.getTime(); cursor <= end.getTime(); cursor += bucketMs) {
|
|
51
|
-
buckets.set(cursor, []);
|
|
52
|
+
buckets.set(cursor, { fiveHour: [], sevenDay: [] });
|
|
52
53
|
}
|
|
53
54
|
for (const event of events) {
|
|
54
|
-
if (event.usagePct === null) {
|
|
55
|
+
if (event.usagePct === null && event.sevenDayUsagePct === null) {
|
|
55
56
|
continue;
|
|
56
57
|
}
|
|
57
58
|
const ts = new Date(event.timestamp).getTime();
|
|
@@ -59,17 +60,24 @@ function bucketizeEvents(events, start, end, bucketMinutes = 5) {
|
|
|
59
60
|
continue;
|
|
60
61
|
}
|
|
61
62
|
const bucketStart = start.getTime() + Math.floor((ts - start.getTime()) / bucketMs) * bucketMs;
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
|
|
63
|
+
const slot = buckets.get(bucketStart);
|
|
64
|
+
if (!slot) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (event.usagePct !== null) {
|
|
68
|
+
slot.fiveHour.push(event.usagePct);
|
|
69
|
+
}
|
|
70
|
+
if (event.sevenDayUsagePct !== null) {
|
|
71
|
+
slot.sevenDay.push(event.sevenDayUsagePct);
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
|
-
return [...buckets.entries()].map(([bucketStart,
|
|
74
|
+
return [...buckets.entries()].map(([bucketStart, slot]) => ({
|
|
68
75
|
bucketStart: new Date(bucketStart).toISOString(),
|
|
69
|
-
avgUsagePct:
|
|
70
|
-
maxUsagePct:
|
|
71
|
-
minUsagePct:
|
|
72
|
-
|
|
76
|
+
avgUsagePct: slot.fiveHour.length > 0 ? (0, time_1.roundNumber)(average(slot.fiveHour), 1) : null,
|
|
77
|
+
maxUsagePct: slot.fiveHour.length > 0 ? (0, time_1.roundNumber)(Math.max(...slot.fiveHour), 1) : null,
|
|
78
|
+
minUsagePct: slot.fiveHour.length > 0 ? (0, time_1.roundNumber)(Math.min(...slot.fiveHour), 1) : null,
|
|
79
|
+
avgSevenDayUsagePct: slot.sevenDay.length > 0 ? (0, time_1.roundNumber)(average(slot.sevenDay), 1) : null,
|
|
80
|
+
sampleCount: slot.fiveHour.length,
|
|
73
81
|
}));
|
|
74
82
|
}
|
|
75
83
|
/** 直接输出内联 SVG 曲线图,避免额外引入前端框架或图表依赖。 */
|
|
@@ -80,46 +88,154 @@ function renderChart(buckets) {
|
|
|
80
88
|
const paddingY = 28;
|
|
81
89
|
const innerWidth = width - paddingX * 2;
|
|
82
90
|
const innerHeight = height - paddingY * 2;
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
const xOf = (index) => paddingX + (index / Math.max(buckets.length - 1, 1)) * innerWidth;
|
|
92
|
+
const yOf = (usage) => paddingY + ((100 - usage) / 100) * innerHeight;
|
|
93
|
+
// 每条曲线只连有数据的桶,跨空桶直接相连,避免周视图里大量空桶把线拉回 0 变成锯齿。
|
|
94
|
+
const collectPoints = (accessor) => buckets
|
|
95
|
+
.map((bucket, index) => ({ index, value: accessor(bucket), bucket }))
|
|
96
|
+
.filter((entry) => entry.value !== null)
|
|
97
|
+
.map((entry) => ({
|
|
98
|
+
x: xOf(entry.index),
|
|
99
|
+
y: yOf(entry.value),
|
|
100
|
+
usage: entry.value,
|
|
101
|
+
label: (0, time_1.formatLocalTimestamp)(new Date(entry.bucket.bucketStart)),
|
|
102
|
+
}));
|
|
103
|
+
const fiveHourPoints = collectPoints((bucket) => bucket.avgUsagePct);
|
|
104
|
+
const sevenDayPoints = collectPoints((bucket) => bucket.avgSevenDayUsagePct);
|
|
105
|
+
const linePathOf = (points) => points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`).join(" ");
|
|
106
|
+
const fiveHourLine = linePathOf(fiveHourPoints);
|
|
107
|
+
const fiveHourArea = fiveHourPoints.length > 0
|
|
108
|
+
? `${fiveHourLine} L${fiveHourPoints.at(-1).x.toFixed(2)} ${(height - paddingY).toFixed(2)} L${fiveHourPoints[0].x.toFixed(2)} ${(height - paddingY).toFixed(2)} Z`
|
|
109
|
+
: "";
|
|
110
|
+
const sevenDayLine = linePathOf(sevenDayPoints);
|
|
92
111
|
const ticks = [0, 25, 50, 75, 100].map((tick) => {
|
|
93
|
-
const y =
|
|
112
|
+
const y = yOf(tick);
|
|
94
113
|
return `<g><line x1="${paddingX}" x2="${width - paddingX}" y1="${y}" y2="${y}" class="chart-grid" /><text x="8" y="${y + 4}" class="chart-axis">${tick}%</text></g>`;
|
|
95
114
|
}).join("");
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
// 跨多天的窗口(this-week / last-week)x 轴按自然日打刻度,每天一格、标签只显示月-日,
|
|
116
|
+
// 更像“周视图”;当天/短窗口仍按时间桶等距取约 6 个时:分刻度。
|
|
117
|
+
const firstTs = buckets.length > 0 ? new Date(buckets[0].bucketStart).getTime() : 0;
|
|
118
|
+
const lastTs = buckets.length > 0 ? new Date(buckets.at(-1).bucketStart).getTime() : 0;
|
|
119
|
+
const multiDay = lastTs - firstTs > 2 * 24 * 60 * 60 * 1000;
|
|
120
|
+
let markerEntries;
|
|
121
|
+
if (multiDay) {
|
|
122
|
+
const seenDays = new Set();
|
|
123
|
+
markerEntries = buckets
|
|
124
|
+
.map((bucket, index) => ({ index, day: (0, time_1.localDateKey)(new Date(bucket.bucketStart)) }))
|
|
125
|
+
.filter((entry) => {
|
|
126
|
+
if (seenDays.has(entry.day)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
seenDays.add(entry.day);
|
|
130
|
+
return true;
|
|
131
|
+
})
|
|
132
|
+
.map((entry) => ({ index: entry.index, label: entry.day.slice(5) }));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
markerEntries = buckets
|
|
136
|
+
.map((bucket, index) => ({ index, label: (0, time_1.formatLocalTimestamp)(new Date(bucket.bucketStart)) }))
|
|
137
|
+
.filter((_, index) => index === buckets.length - 1 || index % Math.max(Math.floor(buckets.length / 6), 1) === 0);
|
|
138
|
+
}
|
|
139
|
+
const markers = markerEntries
|
|
140
|
+
.map((entry) => {
|
|
141
|
+
const x = xOf(entry.index);
|
|
142
|
+
return `<g><line x1="${x}" x2="${x}" y1="${height - paddingY}" y2="${height - paddingY + 6}" class="chart-axis-line" /><text x="${x}" y="${height - 2}" text-anchor="middle" class="chart-axis">${escapeHtml(entry.label)}</text></g>`;
|
|
143
|
+
})
|
|
99
144
|
.join("");
|
|
100
|
-
const
|
|
101
|
-
.
|
|
102
|
-
.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="3.5" class="chart-point"><title>${escapeHtml(point.label)} · ${point.usage.toFixed(1)}%</title></circle>`)
|
|
145
|
+
const dotsOf = (points, pointClass, seriesLabel) => points
|
|
146
|
+
.map((point) => `<circle cx="${point.x.toFixed(2)}" cy="${point.y.toFixed(2)}" r="3.5" class="${pointClass}"><title>${escapeHtml(point.label)} · ${seriesLabel} ${point.usage.toFixed(1)}%</title></circle>`)
|
|
103
147
|
.join("");
|
|
104
|
-
const noData =
|
|
105
|
-
? `<div class="empty-state">当前时间窗口里还没有可绘制的 Claude
|
|
148
|
+
const noData = fiveHourPoints.length === 0 && sevenDayPoints.length === 0
|
|
149
|
+
? `<div class="empty-state">当前时间窗口里还没有可绘制的 Claude 使用率样本。</div>`
|
|
106
150
|
: "";
|
|
107
151
|
return `
|
|
108
152
|
<section class="panel chart-panel">
|
|
109
153
|
<div class="panel-header">
|
|
110
154
|
<div>
|
|
111
155
|
<p class="eyebrow">Recent Trend</p>
|
|
112
|
-
<h2>Claude
|
|
156
|
+
<h2>Claude 使用率趋势</h2>
|
|
113
157
|
</div>
|
|
114
|
-
<p class="muted">按采样时间聚合,
|
|
158
|
+
<p class="muted">按采样时间聚合,5h 来自 rate_limits.five_hour,7d 来自 rate_limits.seven_day</p>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="chart-legend">
|
|
161
|
+
<span class="legend-item"><span class="legend-swatch legend-5h"></span>5 小时使用率</span>
|
|
162
|
+
<span class="legend-item"><span class="legend-swatch legend-7d"></span>7 天使用率</span>
|
|
115
163
|
</div>
|
|
116
164
|
${noData}
|
|
117
|
-
<svg viewBox="0 0 ${width} ${height}" class="chart" role="img" aria-label="Claude
|
|
165
|
+
<svg viewBox="0 0 ${width} ${height}" class="chart" role="img" aria-label="Claude 使用率趋势(5h 与 7d)">
|
|
118
166
|
${ticks}
|
|
119
|
-
|
|
120
|
-
|
|
167
|
+
${fiveHourArea ? `<path d="${fiveHourArea}" class="chart-area"></path>` : ""}
|
|
168
|
+
${sevenDayLine ? `<path d="${sevenDayLine}" class="chart-line chart-line-7d"></path>` : ""}
|
|
169
|
+
${fiveHourLine ? `<path d="${fiveHourLine}" class="chart-line"></path>` : ""}
|
|
121
170
|
${markers}
|
|
122
|
-
${
|
|
171
|
+
${dotsOf(sevenDayPoints, "chart-point chart-point-7d", "7d")}
|
|
172
|
+
${dotsOf(fiveHourPoints, "chart-point", "5h")}
|
|
173
|
+
</svg>
|
|
174
|
+
</section>
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 根据时间窗口跨度自适应选择曲线分桶粒度。
|
|
179
|
+
*
|
|
180
|
+
* today/5h 这类短窗口保持 5 分钟桶;this-week/last-week 这类跨多天窗口改用小时桶,
|
|
181
|
+
* 否则一周会产生上千个点,曲线既慢又糊。
|
|
182
|
+
*/
|
|
183
|
+
function pickBucketMinutes(start, end) {
|
|
184
|
+
const spanMs = end.getTime() - start.getTime();
|
|
185
|
+
const twoDaysMs = 2 * 24 * 60 * 60 * 1000;
|
|
186
|
+
return spanMs > twoDaysMs ? 60 : 5;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* 每日用户消息数柱状图。
|
|
190
|
+
*
|
|
191
|
+
* 口径与导出契约的 `userMessageCount` 一致:来自 Claude 本地 transcript 的真实用户请求数,
|
|
192
|
+
* 不是 statusline 采样数,也不是 API 请求数。
|
|
193
|
+
*/
|
|
194
|
+
function renderDailyMessages(points) {
|
|
195
|
+
if (points.length === 0) {
|
|
196
|
+
return "";
|
|
197
|
+
}
|
|
198
|
+
const total = points.reduce((sum, point) => sum + point.userMessageCount, 0);
|
|
199
|
+
const maxCount = Math.max(...points.map((point) => point.userMessageCount), 1);
|
|
200
|
+
const width = 920;
|
|
201
|
+
const height = 280;
|
|
202
|
+
const paddingX = 36;
|
|
203
|
+
const paddingY = 28;
|
|
204
|
+
const innerWidth = width - paddingX * 2;
|
|
205
|
+
const innerHeight = height - paddingY * 2;
|
|
206
|
+
const slot = innerWidth / points.length;
|
|
207
|
+
const barWidth = Math.max(Math.min(slot * 0.62, 48), 2);
|
|
208
|
+
const bars = points
|
|
209
|
+
.map((point, index) => {
|
|
210
|
+
const cx = paddingX + slot * (index + 0.5);
|
|
211
|
+
const barHeight = (point.userMessageCount / maxCount) * innerHeight;
|
|
212
|
+
const y = paddingY + (innerHeight - barHeight);
|
|
213
|
+
const dayLabel = point.date.slice(5);
|
|
214
|
+
return `<g>
|
|
215
|
+
<rect x="${(cx - barWidth / 2).toFixed(2)}" y="${y.toFixed(2)}" width="${barWidth.toFixed(2)}" height="${Math.max(barHeight, 0).toFixed(2)}" rx="4" class="bar"><title>${escapeHtml(dayLabel)} · ${point.userMessageCount} 条</title></rect>
|
|
216
|
+
<text x="${cx.toFixed(2)}" y="${(y - 6).toFixed(2)}" text-anchor="middle" class="bar-value">${point.userMessageCount > 0 ? point.userMessageCount : ""}</text>
|
|
217
|
+
<text x="${cx.toFixed(2)}" y="${(height - 8).toFixed(2)}" text-anchor="middle" class="chart-axis">${escapeHtml(dayLabel)}</text>
|
|
218
|
+
</g>`;
|
|
219
|
+
})
|
|
220
|
+
.join("");
|
|
221
|
+
const baselineY = paddingY + innerHeight;
|
|
222
|
+
const baseline = `<line x1="${paddingX}" x2="${width - paddingX}" y1="${baselineY}" y2="${baselineY}" class="chart-axis-line" />`;
|
|
223
|
+
const noData = total === 0
|
|
224
|
+
? `<div class="empty-state">当前时间窗口里还没有统计到 Claude 用户消息。</div>`
|
|
225
|
+
: "";
|
|
226
|
+
return `
|
|
227
|
+
<section class="panel chart-panel">
|
|
228
|
+
<div class="panel-header">
|
|
229
|
+
<div>
|
|
230
|
+
<p class="eyebrow">Daily Messages</p>
|
|
231
|
+
<h2>每日用户消息数</h2>
|
|
232
|
+
</div>
|
|
233
|
+
<p class="muted">按自然日统计的真实用户请求数(口径同导出 userMessageCount),共 ${total} 条</p>
|
|
234
|
+
</div>
|
|
235
|
+
${noData}
|
|
236
|
+
<svg viewBox="0 0 ${width} ${height}" class="chart" role="img" aria-label="每日用户消息数">
|
|
237
|
+
${baseline}
|
|
238
|
+
${bars}
|
|
123
239
|
</svg>
|
|
124
240
|
</section>
|
|
125
241
|
`;
|
|
@@ -175,9 +291,10 @@ function renderRecentEvents(events) {
|
|
|
175
291
|
*
|
|
176
292
|
* 整个 dashboard 保持“单文件可打开”,方便导出和直接分享本地结果。
|
|
177
293
|
*/
|
|
178
|
-
function buildDashboardHtml(events, rangeLabel, start, end) {
|
|
294
|
+
function buildDashboardHtml(events, rangeLabel, start, end, dailyUserMessages = []) {
|
|
179
295
|
const summary = summarizeEvents(events);
|
|
180
|
-
const buckets = bucketizeEvents(events, start, end,
|
|
296
|
+
const buckets = bucketizeEvents(events, start, end, pickBucketMinutes(start, end));
|
|
297
|
+
const totalUserMessages = dailyUserMessages.reduce((sum, point) => sum + point.userMessageCount, 0);
|
|
181
298
|
return `<!doctype html>
|
|
182
299
|
<html lang="zh-CN">
|
|
183
300
|
<head>
|
|
@@ -310,6 +427,22 @@ function buildDashboardHtml(events, rangeLabel, start, end) {
|
|
|
310
427
|
.chart-area { fill: rgba(94, 234, 212, 0.14); }
|
|
311
428
|
.chart-line { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
|
312
429
|
.chart-point { fill: var(--accent); }
|
|
430
|
+
.chart-line-7d { stroke: var(--warning); stroke-dasharray: 6 4; }
|
|
431
|
+
.chart-point-7d { fill: var(--warning); }
|
|
432
|
+
.chart-legend {
|
|
433
|
+
display: flex;
|
|
434
|
+
gap: 18px;
|
|
435
|
+
padding: 10px 24px 0;
|
|
436
|
+
color: var(--muted);
|
|
437
|
+
font-size: 13px;
|
|
438
|
+
}
|
|
439
|
+
.legend-item { display: inline-flex; align-items: center; gap: 8px; }
|
|
440
|
+
.legend-swatch { width: 18px; height: 0; border-top-width: 3px; border-top-style: solid; }
|
|
441
|
+
.legend-5h { border-top-color: var(--accent); }
|
|
442
|
+
.legend-7d { border-top-color: var(--warning); border-top-style: dashed; }
|
|
443
|
+
.bar { fill: rgba(94, 234, 212, 0.55); }
|
|
444
|
+
.bar:hover { fill: var(--accent); }
|
|
445
|
+
.bar-value { fill: var(--muted); font-size: 11px; }
|
|
313
446
|
.empty-state {
|
|
314
447
|
margin: 18px 24px 0;
|
|
315
448
|
padding: 16px 18px;
|
|
@@ -375,17 +508,18 @@ function buildDashboardHtml(events, rangeLabel, start, end) {
|
|
|
375
508
|
<p class="stat-note">窗口内观测到的 5 小时使用率峰值</p>
|
|
376
509
|
</article>
|
|
377
510
|
<article class="panel stat-card">
|
|
378
|
-
<h2>
|
|
379
|
-
<p class="stat-value">${summary.
|
|
380
|
-
<p class="stat-note"
|
|
511
|
+
<h2>Latest 7d usage</h2>
|
|
512
|
+
<p class="stat-value">${escapeHtml(statValue(summary.sevenDayLatestUsagePct))}</p>
|
|
513
|
+
<p class="stat-note">最新 7 天 usage 样本,峰值 ${escapeHtml(statValue(summary.sevenDayPeakUsagePct))}</p>
|
|
381
514
|
</article>
|
|
382
515
|
<article class="panel stat-card">
|
|
383
|
-
<h2
|
|
384
|
-
<p class="stat-value">${
|
|
385
|
-
<p class="stat-note"
|
|
516
|
+
<h2>用户消息数</h2>
|
|
517
|
+
<p class="stat-value">${totalUserMessages}</p>
|
|
518
|
+
<p class="stat-note">窗口内每日真实用户请求数合计</p>
|
|
386
519
|
</article>
|
|
387
520
|
</section>
|
|
388
521
|
${renderChart(buckets)}
|
|
522
|
+
${renderDailyMessages(dailyUserMessages)}
|
|
389
523
|
${renderRecentEvents(events)}
|
|
390
524
|
</main>
|
|
391
525
|
</body>
|
package/dist/lib/paths.js
CHANGED
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.getDefaultDataDir = getDefaultDataDir;
|
|
7
7
|
exports.getEventsDir = getEventsDir;
|
|
8
8
|
exports.getDashboardDir = getDashboardDir;
|
|
9
|
+
exports.getUpdateCachePath = getUpdateCachePath;
|
|
9
10
|
exports.getClaudeDataDir = getClaudeDataDir;
|
|
10
11
|
exports.getClaudeSettingsPath = getClaudeSettingsPath;
|
|
11
12
|
const node_os_1 = __importDefault(require("node:os"));
|
|
@@ -41,6 +42,10 @@ function getEventsDir(dataDir) {
|
|
|
41
42
|
function getDashboardDir(dataDir) {
|
|
42
43
|
return node_path_1.default.join(dataDir, "dashboard");
|
|
43
44
|
}
|
|
45
|
+
/** 版本更新检查的本地缓存文件,记录上次检查时间与拿到的最新版本。 */
|
|
46
|
+
function getUpdateCachePath(dataDir) {
|
|
47
|
+
return node_path_1.default.join(dataDir, "update-check.json");
|
|
48
|
+
}
|
|
44
49
|
/** Claude Code 默认本地数据目录。 */
|
|
45
50
|
function getClaudeDataDir() {
|
|
46
51
|
const configured = process.env.CCUS_CLAUDE_DATA_DIR;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readUpdateCacheSync = readUpdateCacheSync;
|
|
7
|
+
exports.fetchLatestVersion = fetchLatestVersion;
|
|
8
|
+
exports.performUpdateCheck = performUpdateCheck;
|
|
9
|
+
exports.maybeSpawnBackgroundCheck = maybeSpawnBackgroundCheck;
|
|
10
|
+
exports.computeUpdateNotice = computeUpdateNotice;
|
|
11
|
+
const node_child_process_1 = require("node:child_process");
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
14
|
+
const node_https_1 = __importDefault(require("node:https"));
|
|
15
|
+
const debug_1 = require("./debug");
|
|
16
|
+
const paths_1 = require("./paths");
|
|
17
|
+
const version_1 = require("./version");
|
|
18
|
+
/** npm 包名,更新检查向 registry 查询它的最新版本。 */
|
|
19
|
+
const PACKAGE_NAME = "ccus-cli";
|
|
20
|
+
/** 后台检查节流窗口:同一数据目录每天最多向 registry 查一次。 */
|
|
21
|
+
const CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
/** 网络请求超时,避免后台进程长时间挂着;statusline 主进程本身不等它。 */
|
|
23
|
+
const FETCH_TIMEOUT_MS = 4000;
|
|
24
|
+
/**
|
|
25
|
+
* 同步读取更新缓存。
|
|
26
|
+
*
|
|
27
|
+
* statusline 是高频短命进程,这里只读一个很小的 JSON,用同步 IO 换取最简单、最快的路径;
|
|
28
|
+
* 任何异常都静默返回 null,绝不影响状态行输出。
|
|
29
|
+
*/
|
|
30
|
+
function readUpdateCacheSync(dataDir) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = node_fs_1.default.readFileSync((0, paths_1.getUpdateCachePath)(dataDir), "utf8");
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (typeof parsed.lastCheckedAt !== "string") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
lastCheckedAt: parsed.lastCheckedAt,
|
|
39
|
+
latestVersion: typeof parsed.latestVersion === "string" ? parsed.latestVersion : null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** 写入更新缓存;目录可能尚未创建,所以先 mkdir。失败静默。 */
|
|
47
|
+
async function writeUpdateCache(dataDir, cache) {
|
|
48
|
+
try {
|
|
49
|
+
await promises_1.default.mkdir(dataDir, { recursive: true });
|
|
50
|
+
await promises_1.default.writeFile((0, paths_1.getUpdateCachePath)(dataDir), `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
(0, debug_1.debugLog)("update", "failed to write cache", error instanceof Error ? error.message : String(error));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** registry 基地址,允许用 CCUS_REGISTRY 指向私服或镜像(如 npmmirror)。 */
|
|
57
|
+
function getRegistryBase(env = process.env) {
|
|
58
|
+
const configured = (env.CCUS_REGISTRY ?? "").trim();
|
|
59
|
+
const base = configured || "https://registry.npmjs.org";
|
|
60
|
+
return base.replace(/\/+$/, "");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 向 npm registry 查询最新发布版本。
|
|
64
|
+
*
|
|
65
|
+
* 走 `/<pkg>/latest` 这个轻量端点,只取 `version` 字段;超时或任何错误都 reject,
|
|
66
|
+
* 由调用方决定是否静默。
|
|
67
|
+
*/
|
|
68
|
+
function fetchLatestVersion(env = process.env) {
|
|
69
|
+
const url = `${getRegistryBase(env)}/${PACKAGE_NAME}/latest`;
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const request = node_https_1.default.get(url, { headers: { accept: "application/json" } }, (response) => {
|
|
72
|
+
const status = response.statusCode ?? 0;
|
|
73
|
+
if (status < 200 || status >= 300) {
|
|
74
|
+
response.resume();
|
|
75
|
+
reject(new Error(`registry responded ${status}`));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
let body = "";
|
|
79
|
+
response.setEncoding("utf8");
|
|
80
|
+
response.on("data", (chunk) => {
|
|
81
|
+
body += chunk;
|
|
82
|
+
});
|
|
83
|
+
response.on("end", () => {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(body);
|
|
86
|
+
if (typeof parsed.version === "string") {
|
|
87
|
+
resolve(parsed.version);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
reject(new Error("registry payload missing version"));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
request.setTimeout(FETCH_TIMEOUT_MS, () => {
|
|
99
|
+
request.destroy(new Error("registry request timed out"));
|
|
100
|
+
});
|
|
101
|
+
request.on("error", reject);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 真正执行一次检查并把结果写进缓存。
|
|
106
|
+
*
|
|
107
|
+
* 供隐藏命令 `__check-update`(statusline 后台触发)和 `ccus update`(用户主动)复用。
|
|
108
|
+
* 即使 registry 失败也会更新 lastCheckedAt,避免反复重试打爆 registry。
|
|
109
|
+
*/
|
|
110
|
+
async function performUpdateCheck(dataDir) {
|
|
111
|
+
let latestVersion = null;
|
|
112
|
+
try {
|
|
113
|
+
latestVersion = await fetchLatestVersion();
|
|
114
|
+
(0, debug_1.debugLog)("update", "fetched latest version", latestVersion);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
(0, debug_1.debugLog)("update", "fetch failed", error instanceof Error ? error.message : String(error));
|
|
118
|
+
}
|
|
119
|
+
const cache = {
|
|
120
|
+
lastCheckedAt: new Date().toISOString(),
|
|
121
|
+
latestVersion,
|
|
122
|
+
};
|
|
123
|
+
await writeUpdateCache(dataDir, cache);
|
|
124
|
+
return cache;
|
|
125
|
+
}
|
|
126
|
+
/** 缓存是否已过 TTL(或根本不存在),需要重新向 registry 查询。 */
|
|
127
|
+
function isCacheStale(cache, now) {
|
|
128
|
+
if (!cache) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
const last = Date.parse(cache.lastCheckedAt);
|
|
132
|
+
if (!Number.isFinite(last)) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return now.getTime() - last >= CHECK_TTL_MS;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 如缓存过期,spawn 一个 detached 后台进程刷新它,主进程不等待。
|
|
139
|
+
*
|
|
140
|
+
* 这是 statusline 路径能用的唯一安全方式:主进程同步读旧缓存决定是否提示,
|
|
141
|
+
* 把网络请求甩给后台子进程,自己立刻输出单行状态文本并退出,绝不阻塞。
|
|
142
|
+
*/
|
|
143
|
+
function maybeSpawnBackgroundCheck(dataDir, now = new Date()) {
|
|
144
|
+
try {
|
|
145
|
+
if (!isCacheStale(readUpdateCacheSync(dataDir), now)) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const scriptPath = process.argv[1];
|
|
149
|
+
if (!scriptPath) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [scriptPath, "__check-update", "--data-dir", dataDir], {
|
|
153
|
+
detached: true,
|
|
154
|
+
stdio: "ignore",
|
|
155
|
+
windowsHide: true,
|
|
156
|
+
});
|
|
157
|
+
child.on("error", () => { });
|
|
158
|
+
child.unref();
|
|
159
|
+
(0, debug_1.debugLog)("update", "spawned background check");
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
(0, debug_1.debugLog)("update", "failed to spawn background check", error instanceof Error ? error.message : String(error));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 根据缓存计算 statusline 末尾要追加的更新标记。
|
|
167
|
+
*
|
|
168
|
+
* 有严格新于当前版本的发布时返回形如 `⬆ v0.1.5` 的短标记,否则返回 null。
|
|
169
|
+
*/
|
|
170
|
+
function computeUpdateNotice(dataDir, currentVersion = (0, version_1.getCurrentVersion)()) {
|
|
171
|
+
const cache = readUpdateCacheSync(dataDir);
|
|
172
|
+
if (!cache || !cache.latestVersion) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (!(0, version_1.isNewerVersion)(cache.latestVersion, currentVersion)) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
return `⬆ v${cache.latestVersion}`;
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=update-check.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getCurrentVersion = getCurrentVersion;
|
|
7
|
+
exports.isNewerVersion = isNewerVersion;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
/**
|
|
11
|
+
* 读取 ccus 自身的版本号(package.json 的 version)。
|
|
12
|
+
*
|
|
13
|
+
* dist/lib/version.js 与 src/lib/version.ts 距离包根都是 `../..`,
|
|
14
|
+
* 所以编译产物和 tsx 直跑源码时都能定位到同一个 package.json。
|
|
15
|
+
* 读不到时回退 "0.0.0",让上层逻辑(更新检查)安全降级而不是抛错。
|
|
16
|
+
*/
|
|
17
|
+
let cachedVersion = null;
|
|
18
|
+
function getCurrentVersion() {
|
|
19
|
+
if (cachedVersion !== null) {
|
|
20
|
+
return cachedVersion;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const packageJsonPath = node_path_1.default.join(__dirname, "..", "..", "package.json");
|
|
24
|
+
const raw = node_fs_1.default.readFileSync(packageJsonPath, "utf8");
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
cachedVersion = typeof parsed.version === "string" ? parsed.version : "0.0.0";
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
cachedVersion = "0.0.0";
|
|
30
|
+
}
|
|
31
|
+
return cachedVersion;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 语义化版本比较:判断 latest 是否严格新于 current。
|
|
35
|
+
*
|
|
36
|
+
* 只比较 `major.minor.patch` 三段数字,忽略预发布/构建元数据,
|
|
37
|
+
* 对当前发布节奏足够;任意一段解析失败按 0 处理,避免误报更新。
|
|
38
|
+
*/
|
|
39
|
+
function isNewerVersion(latest, current) {
|
|
40
|
+
const parse = (value) => value
|
|
41
|
+
.trim()
|
|
42
|
+
.replace(/^v/, "")
|
|
43
|
+
.split("-", 1)[0]
|
|
44
|
+
.split(".")
|
|
45
|
+
.map((part) => Number.parseInt(part, 10))
|
|
46
|
+
.map((part) => (Number.isFinite(part) ? part : 0));
|
|
47
|
+
const a = parse(latest);
|
|
48
|
+
const b = parse(current);
|
|
49
|
+
const length = Math.max(a.length, b.length);
|
|
50
|
+
for (let index = 0; index < length; index += 1) {
|
|
51
|
+
const left = a[index] ?? 0;
|
|
52
|
+
const right = b[index] ?? 0;
|
|
53
|
+
if (left > right) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (left < right) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=version.js.map
|
package/package.json
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ccus-cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Claude Code statusline usage logger and dashboard CLI",
|
|
5
|
-
"type": "commonjs",
|
|
6
|
-
"bin": {
|
|
7
|
-
"ccus": "dist/cli.js"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"dist/cli.js",
|
|
11
|
-
"dist/lib/**/*.js",
|
|
12
|
-
"dist/types.js"
|
|
13
|
-
],
|
|
14
|
-
"scripts": {
|
|
15
|
-
"build": "tsc -p tsconfig.json",
|
|
16
|
-
"prepublishOnly": "npm run build",
|
|
17
|
-
"test": "node --test dist/test",
|
|
18
|
-
"test:src": "node --import tsx --test src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.test.ts src/test/claude.test.ts src/test/aggregate.test.ts src/test/aggregate-dashboard.test.ts src/test/install.test.ts src/test/debug.test.ts src/test/time.test.ts"
|
|
19
|
-
},
|
|
20
|
-
"keywords": [
|
|
21
|
-
"claude-code",
|
|
22
|
-
"statusline",
|
|
23
|
-
"cli",
|
|
24
|
-
"dashboard"
|
|
25
|
-
],
|
|
26
|
-
"license": "MIT",
|
|
27
|
-
"engines": {
|
|
28
|
-
"node": ">=20"
|
|
29
|
-
},
|
|
30
|
-
"devDependencies": {
|
|
31
|
-
"@types/node": "^24.0.0",
|
|
32
|
-
"tsx": "^4.20.0",
|
|
33
|
-
"typescript": "^5.8.3"
|
|
34
|
-
}
|
|
35
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "ccus-cli",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Claude Code statusline usage logger and dashboard CLI",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ccus": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/cli.js",
|
|
11
|
+
"dist/lib/**/*.js",
|
|
12
|
+
"dist/types.js"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"test": "node --test dist/test",
|
|
18
|
+
"test:src": "node --import tsx --test src/test/payload.test.ts src/test/dashboard.test.ts src/test/export.test.ts src/test/storage.test.ts src/test/claude.test.ts src/test/aggregate.test.ts src/test/aggregate-dashboard.test.ts src/test/install.test.ts src/test/debug.test.ts src/test/time.test.ts src/test/update-check.test.ts"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"claude-code",
|
|
22
|
+
"statusline",
|
|
23
|
+
"cli",
|
|
24
|
+
"dashboard"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^24.0.0",
|
|
32
|
+
"tsx": "^4.20.0",
|
|
33
|
+
"typescript": "^5.8.3"
|
|
34
|
+
}
|
|
35
|
+
}
|