ccc-notifier 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -122
- package/dist/{budget-EHWPEYXY.js → budget-V7CCMJHF.js} +1 -1
- package/dist/{chunk-J6Y3RMMC.js → chunk-27SJELD2.js} +107 -22
- package/dist/{chunk-2VPBBIDW.js → chunk-4JSRGJCZ.js} +61 -22
- package/dist/chunk-6HTETN26.js +226 -0
- package/dist/{chunk-5LHZOZZO.js → chunk-CH34OJ7Q.js} +241 -110
- package/dist/chunk-D4D76RGQ.js +547 -0
- package/dist/chunk-HLJAJS2W.js +55 -0
- package/dist/chunk-OOAC5ULQ.js +1101 -0
- package/dist/chunk-OYD6H3ZZ.js +124 -0
- package/dist/{chunk-TUZVISLD.js → chunk-T6Z2ZAN4.js} +1 -1
- package/dist/cli.js +388 -77
- package/dist/{dashboard-GYKABTTN.js → dashboard-NORNHUPT.js} +3 -4
- package/dist/{history-6OUJLXJT.js → history-DL4SG3PG.js} +3 -5
- package/dist/{mute-GBDNN5PB.js → mute-UIR5P5N5.js} +2 -2
- package/dist/{setup-W3CSTHJC.js → setup-3SRNQ5PG.js} +4 -3
- package/dist/subagent-store-US4TJJQR.js +31 -0
- package/dist/sweep-RUPPGAWJ.js +795 -0
- package/dist/{track-QT2U3E2R.js → track-H4AIT575.js} +53 -31
- package/package.json +1 -1
- package/dist/chunk-26CISNOE.js +0 -324
- package/dist/chunk-CKVK3UCA.js +0 -357
- package/dist/chunk-LHKBGA5K.js +0 -462
- package/dist/chunk-ZHIZZ6V5.js +0 -92
- package/dist/sweep-IBSV4LRD.js +0 -604
package/README.md
CHANGED
|
@@ -1,179 +1,186 @@
|
|
|
1
1
|
# ccc-notifier
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Claude CodeとCodex CLIの利用コストをターンごとに通知し、履歴とダッシュボードで見えるようにするツールです。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
> [!IMPORTANT]
|
|
6
|
+
> **表示される金額は概算です。** 実際の請求額と一致する保証はありません。「使いすぎに気づく」ための目安として使ってください。詳しくは[金額の意味](docs/cost.md)を参照してください。
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
💰 API換算 $0.267(¥40) | Fable 5
|
|
10
|
+
in 1.2k(cache 40%) / out 480 · 📁 my-app · 今日: $1.85
|
|
8
11
|
バグを直してテストを通してください
|
|
9
12
|
```
|
|
10
13
|
|
|
11
|
-
上が通知の例です(1行目がタイトル、2〜3行目が本文)。応答が完了するたびに OS 通知(任意で Slack 通知も)としてこれが届きます。
|
|
12
|
-
|
|
13
|
-
> **⚠️ 表示される金額はあくまで概算値です。** transcript のトークン数 × 公開単価表からローカルで計算した参考値であり、Anthropic の請求額と一致することを保証するものではありません。「使いすぎに気づく」ための目安としてご利用ください(詳細は [金額の意味](docs/cost.md))。
|
|
14
|
-
|
|
15
14
|

|
|
16
15
|
|
|
17
16
|

|
|
18
17
|
|
|
19
|
-
##
|
|
18
|
+
## 主な機能
|
|
20
19
|
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
- **OS 標準の通知機構のみ使用・追加依存ゼロ** — 通知は macOS では `osascript`、Windows では PowerShell 標準のトースト通知機能のみで送信します(node-notifier 等の外部通知ライブラリには一切依存しません)
|
|
28
|
-
- **全処理ローカル・フェイルセーフ設計** — 通知や集計の処理が失敗しても、Claude Code / Codex CLI 本体の応答は絶対にブロックしません
|
|
20
|
+
- Claude Codeの応答完了ごとに、USD・JPYの概算をOS通知またはSlackへ送ります
|
|
21
|
+
- プロンプトと概算をローカルへ保存し、あとから検索・集計できます
|
|
22
|
+
- 日・週・月の推移やモデル別・プロジェクト別の内訳をHTMLダッシュボードで確認できます
|
|
23
|
+
- 月予算を設定し、今月の使用率を確認できます
|
|
24
|
+
- 通知を一時停止しても、履歴の記録は続けられます
|
|
25
|
+
- Codex CLIも任意で追加し、Claude Codeと同じダッシュボードで確認できます
|
|
29
26
|
|
|
30
|
-
##
|
|
27
|
+
## 必要なもの
|
|
31
28
|
|
|
32
|
-
- Node.js 20
|
|
33
|
-
- Claude Code
|
|
34
|
-
-
|
|
29
|
+
- Node.js 20以上(未導入の場合は[Node.jsの用意](docs/installing-node.md))
|
|
30
|
+
- Claude Code(必須)
|
|
31
|
+
- Codex CLI(任意)
|
|
35
32
|
|
|
36
|
-
Windows / WSL2
|
|
33
|
+
Windows / WSL2は[専用の導入手順](docs/windows-wsl2.md)も参照してください。
|
|
37
34
|
|
|
38
|
-
##
|
|
35
|
+
## まず試す
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
グローバルインストールは不要です。最新版を一度試します。
|
|
41
38
|
|
|
42
39
|
```bash
|
|
43
|
-
|
|
44
|
-
ccc-notifier init
|
|
40
|
+
npx ccc-notifier@latest init
|
|
45
41
|
```
|
|
46
42
|
|
|
47
|
-
|
|
43
|
+
画面の質問に沿って、通知方法・金額ラベル・月予算・Codex連携を選びます。
|
|
48
44
|
|
|
49
|
-
|
|
45
|
+
`init`がClaude Codeの`~/.claude/settings.json`を書き換える場合は、**書き換える前にタイムスタンプ付きのバックアップ**を作ります。既存の設定を解析できない場合は自動編集せず、手動設定方法を表示します。
|
|
46
|
+
|
|
47
|
+
セットアップ後にClaude Codeで何か実行してください。通知が届かない場合は次で診断できます。
|
|
50
48
|
|
|
51
49
|
```bash
|
|
52
|
-
|
|
53
|
-
cd ccc-notifier
|
|
54
|
-
mise install # mise 利用時(Node.js 20 が自動で入ります)。mise が無ければ Node.js 20 以上を用意してください
|
|
55
|
-
npm ci
|
|
56
|
-
npm run build
|
|
57
|
-
node dist/cli.js init
|
|
50
|
+
npx ccc-notifier@latest doctor
|
|
58
51
|
```
|
|
59
52
|
|
|
60
|
-
|
|
53
|
+
Codex連携を選んだ場合は、Codexを再起動し、表示されるhookの確認画面で承認してください。詳しくは[Codex CLI対応](docs/codex.md)を参照してください。
|
|
61
54
|
|
|
62
|
-
|
|
55
|
+
<details>
|
|
56
|
+
<summary><strong>まず直近1週間の履歴で試す(全履歴・完全削除まで)</strong></summary>
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
上のセットアップが終わったら、まず直近7日分の件数と概算を事前確認します。この操作では何も変更しません。
|
|
65
59
|
|
|
66
|
-
|
|
60
|
+
```bash
|
|
61
|
+
npx ccc-notifier@latest sweep --dry-run --days 7
|
|
62
|
+
```
|
|
67
63
|
|
|
68
|
-
|
|
64
|
+
次の操作は、保存済み履歴をいったん消し、直近7日分だけを作り直します。履歴のバックアップは作りません。
|
|
69
65
|
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
```bash
|
|
67
|
+
npx ccc-notifier@latest sweep --days 7
|
|
68
|
+
```
|
|
72
69
|
|
|
73
|
-
|
|
70
|
+
作成した7日分の履歴をダッシュボードで確認します。
|
|
74
71
|
|
|
75
|
-
|
|
72
|
+
```bash
|
|
73
|
+
npx ccc-notifier@latest dashboard --days 7
|
|
74
|
+
```
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
- コスト表示ラベル(API換算 / 実額)
|
|
79
|
-
- USD/JPY のフォールバック為替レート(既定 150円)
|
|
80
|
-
- 月の予算(USD、既定 $400。`0` で無効。ダッシュボードに当月の使用率を表示。詳細は [月予算](docs/monthly-budget.md))
|
|
81
|
-
- (Codex CLI 検出時)Codex にもコスト通知を入れるか(既定 Yes。詳細は [Codex CLI 対応](docs/codex.md))
|
|
76
|
+
### 続けて使う場合
|
|
82
77
|
|
|
83
|
-
|
|
78
|
+
まず、全履歴を作り直した場合の件数と概算を事前確認します。
|
|
84
79
|
|
|
85
|
-
|
|
80
|
+
```bash
|
|
81
|
+
npx ccc-notifier@latest sweep --dry-run
|
|
82
|
+
```
|
|
86
83
|
|
|
87
|
-
|
|
84
|
+
問題なければ、7日版の履歴をいったん消し、残っているデータから全履歴を作り直します。
|
|
88
85
|
|
|
89
|
-
|
|
86
|
+
```bash
|
|
87
|
+
npx ccc-notifier@latest sweep
|
|
88
|
+
```
|
|
90
89
|
|
|
91
|
-
|
|
90
|
+
継続利用しやすいようにグローバルインストールし、連携先をグローバル版へ更新します。
|
|
92
91
|
|
|
93
|
-
|
|
92
|
+
```bash
|
|
93
|
+
npm install -g ccc-notifier
|
|
94
|
+
ccc-notifier init
|
|
95
|
+
```
|
|
94
96
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
| `--no-notify` | 通知なし(記録・ダッシュボードのみ)。`--os-only` / `--slack-only` / `--slack-webhook` とは併用不可(例: `npx ccc-notifier init --yes --no-notify`。詳細は [設定](docs/configuration.md#通知なしモード記録ダッシュボードのみ--dashboard-only-mode)) |
|
|
102
|
-
| `--label <api_equivalent\|actual>` | コスト表示ラベルを指定 |
|
|
103
|
-
| `--rate <number>` | USD/JPY フォールバックレートを指定 |
|
|
104
|
-
| `--budget <USD>` | 月予算(USD)を指定(0 で無効)。未指定なら既定 **$400**(既存設定があれば維持) |
|
|
105
|
-
| `--codex` | Codex CLI にも Stop hook を導入する(`~/.codex` 未検出でも強制導入。詳細は [Codex CLI 対応](docs/codex.md)) |
|
|
106
|
-
| `--no-codex` | Codex hook を導入しない(検出しても触らない)。`--codex` とは併用不可 |
|
|
107
|
-
|
|
108
|
-
## コマンド一覧 / Commands
|
|
109
|
-
|
|
110
|
-
| コマンド | 説明 |
|
|
111
|
-
|---|---|
|
|
112
|
-
| `init` | Stop hook を対話形式でセットアップ(前述のフラグで非対話実行も可) |
|
|
113
|
-
| `doctor` | hook登録・設定・単価表・為替・通知・直近セッション合計を診断 |
|
|
114
|
-
| `report [--days N] [--json]` | 蓄積した履歴を集計してターミナルに表示(`--days` の既定は30、不正な値も30扱い)。`--json` で機械可読な出力 |
|
|
115
|
-
| `dashboard [--all\|--days N] [--no-open] [--out <path>] [--refresh <sec>\|--no-refresh]` | 履歴を可視化した HTML ダッシュボードを生成してブラウザで開く(引数なしは設定期間の直近版 `report.html`、`--all` は全履歴版 `report-all.html`。詳細は [ダッシュボード](docs/dashboard.md)) |
|
|
116
|
-
| `sweep [--dry-run] [--days N] [--include-active]` | 過去の未計上分(hook 導入前や後から完了したサブエージェント分)を一括で履歴に取り込む。ローカル走査のみで **Claude API を呼ばず料金ゼロ**・二重計上なし(詳細は [過去分の取り込み](docs/sweep.md)) |
|
|
117
|
-
| `history <clear\|redact> [--days N] [--yes]` | 履歴(`history.jsonl`)を削除。`clear` はレコードごと、`redact` はプロンプト全文だけ消去。`--days N` で「N 日より前」だけ対象(詳細は [履歴の削除](docs/dashboard.md#履歴の削除--deleting-history)) |
|
|
118
|
-
| `budget [<USD>]` | 月予算(USD)の表示/設定。金額省略で現在の予算と当月の使用率を表示、`budget 400` で設定、`budget 0` で解除(詳細は [月予算](docs/monthly-budget.md)) |
|
|
119
|
-
| `mute [30m\|2h\|1d]` | 通知(OS/Slack)を一時停止する。期間省略で無期限、`30m`/`2h`/`1d` で期限付き。**停止中もコスト記録とダッシュボード更新は続きます**(詳細は [設定 / mute](docs/configuration.md#通知の一時停止と再開--pausing--resuming-notifications)) |
|
|
120
|
-
| `unmute` | 停止した通知を再開する |
|
|
121
|
-
| `uninstall [--purge] [--yes]` | Stop hook を削除。`--purge` を付けると `~/.ccc-notifier` のデータ(設定・履歴・キャッシュ)も削除 |
|
|
122
|
-
| `track` | Stop hook から自動的に呼ばれる**内部コマンド**。stdin 経由で JSON を受け取ります。手動実行は不要です |
|
|
123
|
-
| `--version`, `-v` | バージョン表示 |
|
|
124
|
-
| `--help`, `-h` | ヘルプ表示 |
|
|
125
|
-
|
|
126
|
-
`report --json` は次のような形の JSON を出力します(スクリプト等への取り込み用)。
|
|
127
|
-
|
|
128
|
-
```json
|
|
129
|
-
{
|
|
130
|
-
"days": 30,
|
|
131
|
-
"daily": [{ "date": "2026-07-06", "turns": 3, "inputTokens": 12345, "outputTokens": 678, "costUSD": 0.42, "costJPY": 63 }],
|
|
132
|
-
"byModel": { "claude-fable-5": { "turns": 2, "costUSD": 0.3, "costJPY": 45 }, "claude-sonnet-5": { "turns": 1, "costUSD": 0.03, "costJPY": 4.5 } },
|
|
133
|
-
"total": { "turns": 3, "inputTokens": 12345, "outputTokens": 678, "costUSD": 0.42, "costJPY": 63, "subagentsUSD": 0.03 }
|
|
134
|
-
}
|
|
97
|
+
### 使わない場合
|
|
98
|
+
|
|
99
|
+
この時点ではグローバル版を入れていないため、npxでccc-notifierのhook・設定・履歴・キャッシュを削除します。
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx ccc-notifier@latest uninstall --yes --purge
|
|
135
103
|
```
|
|
136
104
|
|
|
137
|
-
|
|
105
|
+
安全のため作成した`settings.json.bak-*`などのバックアップは残ります。不要なら内容を確認してから手動で削除してください。
|
|
138
106
|
|
|
139
|
-
|
|
107
|
+
</details>
|
|
140
108
|
|
|
141
|
-
|
|
109
|
+
## 気に入ったらグローバルインストール
|
|
142
110
|
|
|
143
111
|
```bash
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
npx ccc-notifier dashboard --days 7 # 直近7日の report.html を生成して開く
|
|
112
|
+
npm install -g ccc-notifier
|
|
113
|
+
ccc-notifier init
|
|
147
114
|
```
|
|
148
115
|
|
|
149
|
-
|
|
116
|
+
以降は`ccc-notifier doctor`のように短く実行できます。`cccn`も同じコマンドとして使えます。
|
|
117
|
+
|
|
118
|
+
## よく使うコマンド
|
|
119
|
+
|
|
120
|
+
以下はグローバルインストール後の表記です。npxで使う場合は`ccc-notifier`を`npx ccc-notifier@latest`に置き換えてください。
|
|
121
|
+
|
|
122
|
+
| コマンド | できること |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `ccc-notifier init` | 通知や連携を設定する |
|
|
125
|
+
| `ccc-notifier doctor` | 設定と通知を診断する |
|
|
126
|
+
| `ccc-notifier report [--days N]` | コスト集計をターミナルに表示する |
|
|
127
|
+
| `ccc-notifier dashboard` | 直近のHTMLダッシュボードを開く |
|
|
128
|
+
| `ccc-notifier dashboard --all` | 保存済みの全履歴版を開く |
|
|
129
|
+
| `ccc-notifier budget [<USD>]` | 月予算を確認・設定する(`0`で解除) |
|
|
130
|
+
| `ccc-notifier mute [30m\|2h\|1d]` | 通知を一時停止する(期間省略で無期限) |
|
|
131
|
+
| `ccc-notifier unmute` | 通知を再開する |
|
|
132
|
+
| `ccc-notifier sweep --dry-run [--days N]` | 履歴を作り直した場合の件数と概算を確認する |
|
|
133
|
+
| `ccc-notifier sweep [--days N]` | 残っている利用データから履歴を作り直す |
|
|
134
|
+
|
|
135
|
+
> [!CAUTION]
|
|
136
|
+
> **`sweep`は履歴を作り直すコマンドです。** 保存済みの履歴をいったん消し、Claude Code / Codex CLIに残っているデータから再作成します。設定や通知は消えませんが、履歴のバックアップは作りません。以前に削除・伏せ字にした履歴も、Claude Code / Codex CLI側の元データに残っていれば再び入ります。また、再作成した時点の単価と為替を使うため、以前の金額から変わることがあります。先に`--dry-run`で確認してください。詳しくは[履歴の再生成](docs/sweep.md)を参照してください。
|
|
137
|
+
|
|
138
|
+
ダッシュボード、履歴の削除、通知なしモードなど、その他の操作は[ドキュメント](#ドキュメント)または`ccc-notifier --help`で確認できます。
|
|
139
|
+
|
|
140
|
+
## アップデート
|
|
141
|
+
|
|
142
|
+
npxで使う場合は、各コマンドに`@latest`を付ければ常に最新版が使われるため、別の更新作業は不要です。
|
|
143
|
+
|
|
144
|
+
グローバルインストールの場合は、次のコマンドだけで更新できます。
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npm update -g ccc-notifier
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
hook設定の更新が必要なリリースだけ、リリース案内に従って`init`を実行してください。Codexの更新手順は[Codex CLI対応](docs/codex.md)にまとめています。
|
|
151
|
+
|
|
152
|
+
## 完全に削除する
|
|
153
|
+
|
|
154
|
+
グローバルインストールした場合は、ccc-notifierのhookと保存データを削除してからパッケージを削除します。
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
ccc-notifier uninstall --yes --purge
|
|
158
|
+
npm uninstall -g ccc-notifier
|
|
159
|
+
```
|
|
150
160
|
|
|
151
|
-
|
|
161
|
+
npxだけで使っていた場合は、最初のコマンドを`npx ccc-notifier@latest uninstall --yes --purge`に置き換え、`npm uninstall -g`は不要です。安全のため作成した`settings.json.bak-*`などのバックアップは自動削除しません。
|
|
152
162
|
|
|
153
|
-
|
|
163
|
+
## プライバシー
|
|
154
164
|
|
|
155
|
-
|
|
165
|
+
- 履歴とプロンプトは`~/.ccc-notifier`にローカル保存します
|
|
166
|
+
- Slackを設定した場合だけ、概算・トークン情報とプロンプトを指定したSlack Webhookへ送ります。既定はプロンプト冒頭100字で、設定により文字数の変更や全文送信もできます
|
|
167
|
+
- 単価表と為替レートの取得時に外部通信しますが、プロンプトやコードは送りません
|
|
156
168
|
|
|
157
|
-
-
|
|
158
|
-
- OS通知に表示されるプロンプトは、ローカル上で先頭50字程度に切り詰めたものです
|
|
159
|
-
- Slack を設定した場合のみ、既定でプロンプト冒頭100字(`sendFullPrompt` で文字数変更・全文送信も可能)がその Slack Webhook 宛に送信されます
|
|
160
|
-
- それ以外に外部へ送信されるのは次の2種類の API 呼び出しだけです。いずれもプロンプトやコードの内容を一切含まない、レート・価格を取得するだけのリクエストです
|
|
161
|
-
- 為替レート取得([frankfurter.dev](https://frankfurter.dev/) → 失敗時は [open.er-api.com](https://open.er-api.com/))
|
|
162
|
-
- 単価表取得([LiteLLM の公開JSON](https://github.com/BerriAI/litellm))
|
|
169
|
+
詳しくは[Slack通知](docs/slack.md)と[仕組み](docs/how-it-works.md)を参照してください。
|
|
163
170
|
|
|
164
|
-
## ドキュメント
|
|
171
|
+
## ドキュメント
|
|
165
172
|
|
|
166
|
-
- [Node.js
|
|
167
|
-
- [Windows / WSL2
|
|
168
|
-
- [Codex CLI
|
|
169
|
-
- [
|
|
170
|
-
- [
|
|
171
|
-
- [
|
|
172
|
-
- [
|
|
173
|
-
- [
|
|
174
|
-
- [
|
|
175
|
-
- [仕組み
|
|
176
|
-
- [よくある質問
|
|
173
|
+
- [Node.jsの用意](docs/installing-node.md)
|
|
174
|
+
- [Windows / WSL2での導入](docs/windows-wsl2.md)
|
|
175
|
+
- [Codex CLI対応](docs/codex.md)
|
|
176
|
+
- [Slack通知](docs/slack.md)
|
|
177
|
+
- [設定・通知の一時停止](docs/configuration.md)
|
|
178
|
+
- [金額の意味](docs/cost.md)
|
|
179
|
+
- [ダッシュボードと履歴の削除](docs/dashboard.md)
|
|
180
|
+
- [月予算](docs/monthly-budget.md)
|
|
181
|
+
- [履歴の再生成(sweep)](docs/sweep.md)
|
|
182
|
+
- [仕組み](docs/how-it-works.md)
|
|
183
|
+
- [よくある質問](docs/faq.md)
|
|
177
184
|
|
|
178
185
|
## License
|
|
179
186
|
|
|
@@ -1,19 +1,100 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
paths
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-OOAC5ULQ.js";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
import { randomUUID } from "crypto";
|
|
8
|
-
import { hostname } from "os";
|
|
6
|
+
// src/dashboard-state.ts
|
|
9
7
|
import {
|
|
8
|
+
closeSync,
|
|
10
9
|
existsSync,
|
|
11
|
-
|
|
10
|
+
openSync,
|
|
12
11
|
readFileSync,
|
|
12
|
+
readSync,
|
|
13
13
|
renameSync,
|
|
14
14
|
rmSync,
|
|
15
15
|
writeFileSync
|
|
16
16
|
} from "fs";
|
|
17
|
+
import { randomUUID } from "crypto";
|
|
18
|
+
function localDate(now) {
|
|
19
|
+
const y = now.getFullYear();
|
|
20
|
+
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
21
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
22
|
+
return `${y}-${m}-${d}`;
|
|
23
|
+
}
|
|
24
|
+
function timeZone() {
|
|
25
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown";
|
|
26
|
+
}
|
|
27
|
+
function makeFullDashboardState(now = /* @__PURE__ */ new Date()) {
|
|
28
|
+
return { localDate: localDate(now), timeZone: timeZone(), generatedAt: now.toISOString() };
|
|
29
|
+
}
|
|
30
|
+
function readState() {
|
|
31
|
+
const file = paths().dashboardFullStateFile;
|
|
32
|
+
if (!existsSync(file)) return null;
|
|
33
|
+
try {
|
|
34
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
35
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
36
|
+
const v = value;
|
|
37
|
+
if (typeof v.localDate !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v.localDate) || typeof v.timeZone !== "string" || v.timeZone.length === 0 || typeof v.generatedAt !== "string" || !Number.isFinite(Date.parse(v.generatedAt))) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return v;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function isFullDashboardDue(now = /* @__PURE__ */ new Date()) {
|
|
46
|
+
const p = paths();
|
|
47
|
+
if (!existsSync(p.fullDashboardFile)) return true;
|
|
48
|
+
try {
|
|
49
|
+
const fd = openSync(p.fullDashboardFile, "r");
|
|
50
|
+
try {
|
|
51
|
+
const head = Buffer.alloc(512);
|
|
52
|
+
const n = readSync(fd, head, 0, head.length, 0);
|
|
53
|
+
if (head.toString("utf8", 0, n).includes('name="cccn-placeholder"')) return true;
|
|
54
|
+
} finally {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const state = readState();
|
|
61
|
+
if (state === null) return true;
|
|
62
|
+
const expected = makeFullDashboardState(now);
|
|
63
|
+
if (state.timeZone !== expected.timeZone) return true;
|
|
64
|
+
if (state.localDate !== expected.localDate) return true;
|
|
65
|
+
if (state.localDate > expected.localDate) return true;
|
|
66
|
+
if (Date.parse(state.generatedAt) > now.getTime()) return true;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
function writeFullDashboardStateAtomic(state) {
|
|
70
|
+
const file = paths().dashboardFullStateFile;
|
|
71
|
+
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(tmp, `${JSON.stringify(state)}
|
|
74
|
+
`, "utf8");
|
|
75
|
+
renameSync(tmp, file);
|
|
76
|
+
} finally {
|
|
77
|
+
rmSync(tmp, { force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function invalidateCanonicalDashboards() {
|
|
81
|
+
const p = paths();
|
|
82
|
+
for (const file of [p.recentDashboardFile, p.fullDashboardFile, p.dashboardFullStateFile]) {
|
|
83
|
+
rmSync(file, { force: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/data-lock.ts
|
|
88
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
89
|
+
import { hostname } from "os";
|
|
90
|
+
import {
|
|
91
|
+
existsSync as existsSync2,
|
|
92
|
+
mkdirSync,
|
|
93
|
+
readFileSync as readFileSync2,
|
|
94
|
+
renameSync as renameSync2,
|
|
95
|
+
rmSync as rmSync2,
|
|
96
|
+
writeFileSync as writeFileSync2
|
|
97
|
+
} from "fs";
|
|
17
98
|
import { join } from "path";
|
|
18
99
|
var DATA_LOCK_LEASE_MS = 3e4;
|
|
19
100
|
var HEARTBEAT_MS = 5e3;
|
|
@@ -22,7 +103,7 @@ function ownerFile(dir) {
|
|
|
22
103
|
}
|
|
23
104
|
function readOwner(dir) {
|
|
24
105
|
try {
|
|
25
|
-
const v = JSON.parse(
|
|
106
|
+
const v = JSON.parse(readFileSync2(ownerFile(dir), "utf8"));
|
|
26
107
|
if (typeof v.token !== "string" || typeof v.pid !== "number" || typeof v.hostname !== "string" || typeof v.acquiredAt !== "string" || typeof v.heartbeatAt !== "string") return null;
|
|
27
108
|
return v;
|
|
28
109
|
} catch {
|
|
@@ -30,21 +111,21 @@ function readOwner(dir) {
|
|
|
30
111
|
}
|
|
31
112
|
}
|
|
32
113
|
function writeOwner(dir, owner) {
|
|
33
|
-
const tmp = join(dir, `owner.${owner.token}.${
|
|
114
|
+
const tmp = join(dir, `owner.${owner.token}.${randomUUID2()}.tmp`);
|
|
34
115
|
try {
|
|
35
|
-
|
|
116
|
+
writeFileSync2(tmp, `${JSON.stringify(owner)}
|
|
36
117
|
`, "utf8");
|
|
37
|
-
|
|
118
|
+
renameSync2(tmp, ownerFile(dir));
|
|
38
119
|
} finally {
|
|
39
|
-
|
|
120
|
+
rmSync2(tmp, { force: true });
|
|
40
121
|
}
|
|
41
122
|
}
|
|
42
123
|
function quarantineOwned(dir, token, label) {
|
|
43
124
|
const before = readOwner(dir);
|
|
44
125
|
if (before?.token !== token) return false;
|
|
45
|
-
const quarantine = `${dir}.${label}-${token}-${
|
|
126
|
+
const quarantine = `${dir}.${label}-${token}-${randomUUID2()}`;
|
|
46
127
|
try {
|
|
47
|
-
|
|
128
|
+
renameSync2(dir, quarantine);
|
|
48
129
|
} catch {
|
|
49
130
|
return false;
|
|
50
131
|
}
|
|
@@ -52,21 +133,21 @@ function quarantineOwned(dir, token, label) {
|
|
|
52
133
|
if (moved?.token !== token) {
|
|
53
134
|
return false;
|
|
54
135
|
}
|
|
55
|
-
|
|
136
|
+
rmSync2(quarantine, { recursive: true, force: true });
|
|
56
137
|
return true;
|
|
57
138
|
}
|
|
58
139
|
function claimDir(fixed, label, now, metadataWriter = writeOwner) {
|
|
59
|
-
const token =
|
|
140
|
+
const token = randomUUID2();
|
|
60
141
|
const staging = `${fixed}.${label}-${token}`;
|
|
61
142
|
const iso = now.toISOString();
|
|
62
143
|
const owner = { token, pid: process.pid, hostname: hostname(), acquiredAt: iso, heartbeatAt: iso };
|
|
63
144
|
try {
|
|
64
145
|
mkdirSync(staging);
|
|
65
146
|
metadataWriter(staging, owner);
|
|
66
|
-
|
|
147
|
+
renameSync2(staging, fixed);
|
|
67
148
|
return { token, owner };
|
|
68
149
|
} catch {
|
|
69
|
-
|
|
150
|
+
rmSync2(staging, { recursive: true, force: true });
|
|
70
151
|
return null;
|
|
71
152
|
}
|
|
72
153
|
}
|
|
@@ -99,15 +180,15 @@ function tryReclaim(now, leaseMs) {
|
|
|
99
180
|
if (!processDefinitelyDead(first.pid)) return false;
|
|
100
181
|
const second = readOwner(p.dataLockDir);
|
|
101
182
|
if (second === null || second.token !== first.token || second.heartbeatAt !== first.heartbeatAt) return false;
|
|
102
|
-
const orphan = `${p.dataLockDir}.orphan-${first.token}-${
|
|
183
|
+
const orphan = `${p.dataLockDir}.orphan-${first.token}-${randomUUID2()}`;
|
|
103
184
|
try {
|
|
104
|
-
|
|
185
|
+
renameSync2(p.dataLockDir, orphan);
|
|
105
186
|
} catch {
|
|
106
187
|
return false;
|
|
107
188
|
}
|
|
108
189
|
const moved = readOwner(orphan);
|
|
109
190
|
if (moved?.token !== first.token || moved.heartbeatAt !== first.heartbeatAt) return false;
|
|
110
|
-
|
|
191
|
+
rmSync2(orphan, { recursive: true, force: true });
|
|
111
192
|
return true;
|
|
112
193
|
} finally {
|
|
113
194
|
quarantineOwned(p.dataReclaimDir, guard.token, "released");
|
|
@@ -117,15 +198,15 @@ function acquireDataLock(opts = {}) {
|
|
|
117
198
|
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
118
199
|
const leaseMs = opts.leaseMs ?? DATA_LOCK_LEASE_MS;
|
|
119
200
|
const p = paths();
|
|
120
|
-
if (
|
|
201
|
+
if (existsSync2(p.dataReclaimDir) && reclaimerGuardBlocks(now, leaseMs)) return null;
|
|
121
202
|
let claim = claimDir(p.dataLockDir, "acquire", now, opts.metadataWriter);
|
|
122
203
|
if (claim === null) {
|
|
123
204
|
if (!tryReclaim(now, leaseMs)) return null;
|
|
124
|
-
if (
|
|
205
|
+
if (existsSync2(p.dataReclaimDir) && reclaimerGuardBlocks(now, leaseMs)) return null;
|
|
125
206
|
claim = claimDir(p.dataLockDir, "acquire", now, opts.metadataWriter);
|
|
126
207
|
if (claim === null) return null;
|
|
127
208
|
}
|
|
128
|
-
if (
|
|
209
|
+
if (existsSync2(p.dataReclaimDir) && reclaimerGuardBlocks(now, leaseMs)) {
|
|
129
210
|
quarantineOwned(p.dataLockDir, claim.token, "yielded");
|
|
130
211
|
return null;
|
|
131
212
|
}
|
|
@@ -168,5 +249,9 @@ async function waitForDataLock(timeoutMs, pollMs = 25) {
|
|
|
168
249
|
}
|
|
169
250
|
|
|
170
251
|
export {
|
|
252
|
+
makeFullDashboardState,
|
|
253
|
+
isFullDashboardDue,
|
|
254
|
+
writeFullDashboardStateAtomic,
|
|
255
|
+
invalidateCanonicalDashboards,
|
|
171
256
|
waitForDataLock
|
|
172
257
|
};
|