@jayfarei/lazyanalytics 0.1.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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/cli/dist/commands/base.d.ts +2 -0
- package/cli/dist/commands/base.js +129 -0
- package/cli/dist/commands/config.d.ts +2 -0
- package/cli/dist/commands/config.js +45 -0
- package/cli/dist/commands/setup.d.ts +2 -0
- package/cli/dist/commands/setup.js +155 -0
- package/cli/dist/commands/sites.d.ts +2 -0
- package/cli/dist/commands/sites.js +127 -0
- package/cli/dist/commands/skill.d.ts +2 -0
- package/cli/dist/commands/skill.js +28 -0
- package/cli/dist/commands/snippet.d.ts +2 -0
- package/cli/dist/commands/snippet.js +48 -0
- package/cli/dist/commands/usage.d.ts +2 -0
- package/cli/dist/commands/usage.js +156 -0
- package/cli/dist/index.d.ts +2 -0
- package/cli/dist/index.js +31 -0
- package/cli/dist/lib/api.d.ts +9 -0
- package/cli/dist/lib/api.js +27 -0
- package/cli/dist/lib/env.d.ts +15 -0
- package/cli/dist/lib/env.js +81 -0
- package/cli/dist/lib/paths.d.ts +16 -0
- package/cli/dist/lib/paths.js +54 -0
- package/cli/dist/lib/prompt.d.ts +8 -0
- package/cli/dist/lib/prompt.js +42 -0
- package/cli/dist/lib/scaffold.d.ts +5 -0
- package/cli/dist/lib/scaffold.js +21 -0
- package/cli/dist/lib/wrangler.d.ts +17 -0
- package/cli/dist/lib/wrangler.js +65 -0
- package/dist/worker.js +3221 -0
- package/package.json +58 -0
- package/skill/SKILL.md +111 -0
- package/templates/wrangler.toml +14 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
|
|
5
|
+
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-06-12
|
|
8
|
+
|
|
9
|
+
Initial public release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `lazyanalytics` CLI: `setup`, `sites list/add/remove`, `snippet`, `skill install`,
|
|
14
|
+
`config`, plus query commands `stats`, `pages`, `referrers`, `geo`, `browsers`,
|
|
15
|
+
`timeseries`, and `usage`.
|
|
16
|
+
- One-command deploy: `npx @jayfarei/lazyanalytics setup` scaffolds wrangler config into
|
|
17
|
+
`~/.config/lazyanalytics/`, deploys the prebundled worker to your Cloudflare
|
|
18
|
+
account, and generates/stores all secrets without printing them.
|
|
19
|
+
- Cloudflare Worker with `/collect` beacon ingest, `/tracker.js`, authenticated
|
|
20
|
+
`/api/*` query endpoints (sampling-aware Analytics Engine SQL), `/api/sites`,
|
|
21
|
+
a built-in `/dashboard`, and `/health`.
|
|
22
|
+
- Claude Code skill (`skill/SKILL.md`) covering the full lifecycle, installable
|
|
23
|
+
via `lazyanalytics skill install [--project]`.
|
|
24
|
+
- Privacy design: salted daily-rotating visitor hashes (`HASH_SALT` secret),
|
|
25
|
+
no cookies, no raw IPs, query strings stripped, referrer domain only.
|
|
26
|
+
See PRIVACY.md.
|
|
27
|
+
- Vitest suite for the worker and GitHub Actions CI.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jay Farei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# lazyanalytics
|
|
2
|
+
|
|
3
|
+
Agent-first, self-hosted web analytics on Cloudflare Workers + Analytics Engine.
|
|
4
|
+
|
|
5
|
+
- **Self-hosted**: deploys into *your* Cloudflare account. Your traffic data never leaves it.
|
|
6
|
+
- **Agent-first**: a CLI that returns JSON by default, with semantic exit codes and `--help` text written for AI agents. Ships a Claude Code skill.
|
|
7
|
+
- **Privacy-respecting**: no cookies, no fingerprinting, no raw IPs, no query strings. See [PRIVACY.md](PRIVACY.md).
|
|
8
|
+
- **Cheap**: a small site fits comfortably in the Cloudflare free tier (`lazyanalytics usage` shows your headroom).
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# 1. Deploy the worker into your Cloudflare account
|
|
14
|
+
CLOUDFLARE_API_TOKEN=<token> npx @jayfarei/lazyanalytics setup \
|
|
15
|
+
--sites example.com --account-id <32-hex-account-id>
|
|
16
|
+
|
|
17
|
+
# 2. Add the printed snippet to each site's <head>
|
|
18
|
+
# 3. Query
|
|
19
|
+
npx @jayfarei/lazyanalytics stats --site example.com --period 7d
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`setup` is interactive if you omit flags, and non-interactive with `--yes`. It scaffolds `~/.config/lazyanalytics/worker/`, deploys via `wrangler`, generates `API_SECRET` and `HASH_SALT` (never printed), sets the worker secrets (including `CF_ACCOUNT_ID`/`CF_API_TOKEN`, which Analytics Engine reads require — see [Secrets model](#secrets-model)), writes `~/.config/lazyanalytics/.env` (mode 0600), health-checks the deployment, and prints the tracking snippet per site. Re-running is idempotent; pass `--rotate-secrets` to regenerate credentials.
|
|
23
|
+
|
|
24
|
+
By default the token you give `setup` is also stored on the worker as `CF_API_TOKEN` for Analytics Engine reads. To keep the deploy-capable token off the worker, re-run with a token scoped to **Account Analytics: Read** only after the first deploy, or overwrite the secret manually:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd ~/.config/lazyanalytics/worker
|
|
28
|
+
echo "<read-only-token>" | CLOUDFLARE_API_TOKEN=<token> CLOUDFLARE_ACCOUNT_ID=<account-id> npx wrangler@4 secret put CF_API_TOKEN
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## The tracking snippet
|
|
32
|
+
|
|
33
|
+
```html
|
|
34
|
+
<script defer id="analytics" data-site-id="example.com"
|
|
35
|
+
src="https://lazyanalytics.YOUR-SUBDOMAIN.workers.dev/tracker.js"></script>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The script is <2KB, sets no cookies, strips query strings and fragments in the browser, sends only the referrer *domain*, and tracks SPA navigations. `data-site-id` must match a site in the worker's `ALLOWED_SITES`. Print snippets anytime with `lazyanalytics snippet [--site example.com]`.
|
|
39
|
+
|
|
40
|
+
## CLI reference
|
|
41
|
+
|
|
42
|
+
Install globally (`npm i -g @jayfarei/lazyanalytics`) or use `npx @jayfarei/lazyanalytics`.
|
|
43
|
+
|
|
44
|
+
### Lifecycle commands
|
|
45
|
+
|
|
46
|
+
| Command | What it does |
|
|
47
|
+
| ------- | ------------ |
|
|
48
|
+
| `setup` | Deploy the worker and configure the CLI. Flags: `--sites <csv>`, `--account-id <id>`, `--name <worker-name>` (default `lazyanalytics`), `--rotate-secrets`, `-y/--yes`. Needs `CLOUDFLARE_API_TOKEN` (env or hidden prompt). |
|
|
49
|
+
| `sites list` | List tracked sites via the worker's `/api/sites` endpoint. |
|
|
50
|
+
| `sites add <domain>` | Add a site to `ALLOWED_SITES` in the scaffolded `wrangler.toml` and redeploy. Needs `CLOUDFLARE_API_TOKEN`. |
|
|
51
|
+
| `sites remove <domain>` | Remove a site and redeploy (refuses to remove the last site). |
|
|
52
|
+
| `snippet [--site X]` | Print the tracking `<script>` tag for one site, or all tracked sites. |
|
|
53
|
+
| `skill install [--project]` | Install the Claude Code skill to `~/.claude/skills/lazyanalytics/` (or `./.claude/skills/lazyanalytics/` with `--project`). |
|
|
54
|
+
| `config path` / `config get <key>` / `config set <key> <value>` | Inspect/edit `~/.config/lazyanalytics/.env`. Sensitive values (`TOKEN`/`SECRET`/`SALT`/`PASSWORD`) are masked on `get`. |
|
|
55
|
+
| `usage` | Worker request usage, free-plan headroom, and cost estimate via the Cloudflare GraphQL API. Flags: `-p today\|7d\|30d`, `-w/--worker <name>`. Needs `CF_ACCOUNT_ID` + `CLOUDFLARE_API_TOKEN`. |
|
|
56
|
+
|
|
57
|
+
### Query commands
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
lazyanalytics stats --site example.com --period 7d
|
|
61
|
+
lazyanalytics pages --site example.com --period 30d --limit 5
|
|
62
|
+
lazyanalytics referrers --site example.com
|
|
63
|
+
lazyanalytics geo --site example.com --period 30d
|
|
64
|
+
lazyanalytics browsers --site example.com --type os # browser | os | device
|
|
65
|
+
lazyanalytics timeseries --site example.com --unit day # hour | day
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Flag | Short | Default | Description |
|
|
69
|
+
| ---- | ----- | ------- | ----------- |
|
|
70
|
+
| `--site` | `-s` | required | Site to query |
|
|
71
|
+
| `--period` | `-p` | `7d` | `1d` to `90d` |
|
|
72
|
+
| `--limit` | `-l` | `10` | Max results (1-100) |
|
|
73
|
+
| `--json` | | default | JSON output (for agents) |
|
|
74
|
+
| `--table` | | | Human-readable table |
|
|
75
|
+
|
|
76
|
+
Exit codes: `0` data returned, `1` error, `2` success but empty, `3` config/auth error.
|
|
77
|
+
|
|
78
|
+
## HTTP API reference
|
|
79
|
+
|
|
80
|
+
All `/api/*` endpoints require `Authorization: Bearer <API_SECRET>` (constant-time compared). Responses use the envelope `{ "data": ..., "meta": { "site", "period", "sampled" } }`; `sampled` is true only when Analytics Engine actually sampled the underlying rows.
|
|
81
|
+
|
|
82
|
+
| Endpoint | Auth | Description |
|
|
83
|
+
| -------- | ---- | ----------- |
|
|
84
|
+
| `GET /api/stats` | yes | Pageviews, approximate daily visitors, avg screen width. Params: `site` (required), `period`. |
|
|
85
|
+
| `GET /api/pages` | yes | Top pages. Params: `site`, `period`, `limit`. |
|
|
86
|
+
| `GET /api/referrers` | yes | Top external referrer domains. Params: `site`, `period`, `limit`. |
|
|
87
|
+
| `GET /api/geo` | yes | Country breakdown. Params: `site`, `period`, `limit`. |
|
|
88
|
+
| `GET /api/browsers` | yes | Browser/OS/device breakdown. Params: `site`, `period`, `limit`, `type` (`browser`\|`os`\|`device`). |
|
|
89
|
+
| `GET /api/timeseries` | yes | Pageviews over time. Params: `site`, `period`, `unit` (`hour`\|`day`). |
|
|
90
|
+
| `GET /api/sites` | yes | Tracked sites: `{ "data": [{"site": "example.com"}], "meta": {"count": 1} }`. |
|
|
91
|
+
| `POST /collect` | no | Beacon ingest. Body: `{ "sid", "url", "ref?", "sw?", "us?", "um?" }`. Returns 204. Bots get 204 but are not recorded; beacons are dropped (204) if the worker has no `ALLOWED_SITES` or `HASH_SALT` configured; unknown `sid` returns 400. CORS-enabled. |
|
|
92
|
+
| `GET /tracker.js` | no | Serves the tracking script. |
|
|
93
|
+
| `GET /dashboard` | no (page) | Built-in dashboard UI. The page is public, but data loads only after you enter the API token in-page; the token is kept in `sessionStorage` (cleared when the tab closes). You can hand off a session via `https://.../dashboard#token=<API_SECRET>`; the fragment is consumed and immediately stripped from the URL. |
|
|
94
|
+
| `GET /health` | no | `{ "status": "ok", "version": "0.1.0", "timestamp": "..." }`. |
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
curl -H "Authorization: Bearer $ANALYTICS_API_TOKEN" \
|
|
100
|
+
"https://lazyanalytics.YOUR-SUBDOMAIN.workers.dev/api/stats?site=example.com&period=7d"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Architecture
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
Your sites (example.com, blog.example.com, ...)
|
|
107
|
+
│ <script data-site-id="..." src=".../tracker.js">
|
|
108
|
+
│
|
|
109
|
+
▼ beacon POST to /collect
|
|
110
|
+
┌──────────────────────────────────────┐
|
|
111
|
+
│ Cloudflare Worker (your account) │
|
|
112
|
+
│ │
|
|
113
|
+
│ /tracker.js serves tracking JS │
|
|
114
|
+
│ /collect ingests pageviews │
|
|
115
|
+
│ /api/* query endpoints │
|
|
116
|
+
│ /dashboard built-in UI │
|
|
117
|
+
│ /health health check │
|
|
118
|
+
└──────────┬───────────────────────────┘
|
|
119
|
+
│ writeDataPoint() / SQL API
|
|
120
|
+
┌──────────▼───────────────────────────┐
|
|
121
|
+
│ Cloudflare Analytics Engine │
|
|
122
|
+
│ (ClickHouse-backed, 90-day) │
|
|
123
|
+
└──────────────────────────────────────┘
|
|
124
|
+
▲
|
|
125
|
+
│ HTTPS + bearer token
|
|
126
|
+
┌─────┴──────────────────┐
|
|
127
|
+
│ lazyanalytics CLI │ → JSON for agents, --table for humans
|
|
128
|
+
│ (or any HTTP client) │ → Claude Code skill, cron reports, alerting
|
|
129
|
+
└────────────────────────┘
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
1. **Collection**: the tracker sends a beacon on page load and SPA navigations with the page URL (already stripped), referrer domain, screen width, and UTM source/medium.
|
|
133
|
+
2. **Processing**: the worker filters bots, parses the UA, computes a salted daily visitor hash, and writes one data point to Analytics Engine.
|
|
134
|
+
3. **Querying**: `/api/*` translates HTTP params into sampling-aware SQL (`SUM(_sample_interval)`, never `COUNT(*)`).
|
|
135
|
+
|
|
136
|
+
## Data model
|
|
137
|
+
|
|
138
|
+
One Analytics Engine data point per pageview:
|
|
139
|
+
|
|
140
|
+
| Field | Contents | Example |
|
|
141
|
+
| ----- | -------- | ------- |
|
|
142
|
+
| `index1` | Visitor hash: SHA-256 of `site\|ip\|ua\|date\|HASH_SALT`, truncated to 32 hex chars | `a3f8c9...` |
|
|
143
|
+
| `blob1` | Site ID | `example.com` |
|
|
144
|
+
| `blob2` | Page path (no query string) | `/blog/my-post` |
|
|
145
|
+
| `blob3` | Referrer domain (external only) | `google.com` |
|
|
146
|
+
| `blob4` | Country code (`CF-IPCountry`) | `US` |
|
|
147
|
+
| `blob5` / `blob6` / `blob7` | Browser / OS / device | `Chrome` / `macOS` / `desktop` |
|
|
148
|
+
| `blob8` / `blob9` | UTM source / medium | `twitter` / `social` |
|
|
149
|
+
| `double1` | Count (always 1) | `1` |
|
|
150
|
+
| `double2` | Screen width | `1440` |
|
|
151
|
+
|
|
152
|
+
**Sampling note**: Analytics Engine downsamples high-volume data. All queries use `SUM(_sample_interval)` for correct estimates, and `meta.sampled` tells you when an answer is an estimate rather than an exact count. Data is retained for 90 days.
|
|
153
|
+
|
|
154
|
+
## Privacy
|
|
155
|
+
|
|
156
|
+
No cookies, no fingerprinting, no cross-site tracking. Raw IPs and user agents are only hashed transiently (with a per-deployment secret salt, rotated into the hash daily) and never stored. URLs are stripped of query strings client-side; referrers are reduced to a domain. Full details, including honest caveats about hash reversibility, in [PRIVACY.md](PRIVACY.md).
|
|
157
|
+
|
|
158
|
+
## Secrets model
|
|
159
|
+
|
|
160
|
+
| Secret | Lives where | Purpose |
|
|
161
|
+
| ------ | ----------- | ------- |
|
|
162
|
+
| `API_SECRET` | Worker secret + `~/.config/lazyanalytics/.env` (as `ANALYTICS_API_TOKEN`) | Bearer token for `/api/*` and the dashboard |
|
|
163
|
+
| `HASH_SALT` | Worker secret + CLI config (so re-runs keep hashes stable) | Salts visitor hashes; never printed |
|
|
164
|
+
| `CLOUDFLARE_API_TOKEN` | Your shell env only, at deploy time | Used by `setup` / `sites add\|remove` / `usage`. Never stored by the CLI, never sent to the worker |
|
|
165
|
+
| `CF_ACCOUNT_ID` / `CF_API_TOKEN` | Worker secrets (set by `setup`) | Used by worker-side `/api/*` queries (Analytics Engine reads go through Cloudflare's REST API) |
|
|
166
|
+
|
|
167
|
+
Guidance:
|
|
168
|
+
|
|
169
|
+
- The CLI config file is written with mode `0600`. `config get` masks sensitive values.
|
|
170
|
+
- Use a **least-privilege deploy token**: `Workers Scripts: Edit` + `Account Analytics: Read`. Treat it as setup-time only; the deploy token itself is never stored on the worker.
|
|
171
|
+
- `setup` stores the token it was given as the worker-side `CF_API_TOKEN`. For least privilege, give it (or later overwrite the secret with) a *separate* token scoped to `Account Analytics: Read` only, so a worker compromise cannot touch your Workers.
|
|
172
|
+
- The analytics bearer token (`API_SECRET`) can only read analytics; it has no power over your Cloudflare account.
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
git clone https://github.com/JayFarei/lazyanalytics.git
|
|
178
|
+
cd lazyanalytics
|
|
179
|
+
npm install # installs worker/ and cli/ workspaces
|
|
180
|
+
npm run build # esbuild-bundles dist/worker.js + compiles cli/dist
|
|
181
|
+
npm test # vitest (worker workspace)
|
|
182
|
+
|
|
183
|
+
# Run the worker locally
|
|
184
|
+
cd worker
|
|
185
|
+
cat > .dev.vars <<'EOF'
|
|
186
|
+
API_SECRET=dev-secret
|
|
187
|
+
HASH_SALT=dev-salt
|
|
188
|
+
EOF
|
|
189
|
+
npx wrangler dev # ALLOWED_SITES comes from worker/wrangler.toml [vars]
|
|
190
|
+
|
|
191
|
+
# Run the CLI from source
|
|
192
|
+
npx tsx cli/src/index.ts stats --site example.com
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The npm package ships `cli/dist/`, the prebundled `dist/worker.js`, `templates/wrangler.toml`, and `skill/SKILL.md`; the `worker/` source is only used for development.
|
|
196
|
+
|
|
197
|
+
### Advanced: credential proxies
|
|
198
|
+
|
|
199
|
+
The repo contains a `cli/bin/analytics` bash wrapper that routes requests through a OneCLI credential proxy. It is experimental, unsupported, and not part of the npm package. Direct mode (`ANALYTICS_API_URL` + `ANALYTICS_API_TOKEN`) is the supported path.
|
|
200
|
+
|
|
201
|
+
## Limitations
|
|
202
|
+
|
|
203
|
+
- 90-day retention (Analytics Engine), no per-visitor deletion.
|
|
204
|
+
- Visitor counts are approximations (NAT/VPN undercounts, shared devices overcount).
|
|
205
|
+
- No bounce rate or session duration (Analytics Engine has no JOINs).
|
|
206
|
+
- Data points can take seconds to minutes to become queryable.
|
|
207
|
+
|
|
208
|
+
## Contributing & license
|
|
209
|
+
|
|
210
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) for how to report vulnerabilities. MIT licensed, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import tls from 'node:tls';
|
|
5
|
+
import { loadEnv } from '../lib/env.js';
|
|
6
|
+
// Advanced/experimental: trust an extra proxy CA cert at runtime.
|
|
7
|
+
// Opt-in only via ANALYTICS_PROXY_CA=<path> — never loaded implicitly from
|
|
8
|
+
// the working directory. NODE_EXTRA_CA_CERTS must be set before Node boots,
|
|
9
|
+
// so we patch the secure context here instead.
|
|
10
|
+
function loadProxyCA(path) {
|
|
11
|
+
if (!existsSync(path))
|
|
12
|
+
return;
|
|
13
|
+
try {
|
|
14
|
+
const cert = readFileSync(path, 'utf-8');
|
|
15
|
+
const origCreateSecureContext = tls.createSecureContext;
|
|
16
|
+
tls.createSecureContext = function (options = {}) {
|
|
17
|
+
const ctx = origCreateSecureContext.call(this, options);
|
|
18
|
+
ctx.context.addCACert(cert);
|
|
19
|
+
return ctx;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch { /* ignore */ }
|
|
23
|
+
}
|
|
24
|
+
if (process.env.HTTPS_PROXY && process.env.ANALYTICS_PROXY_CA) {
|
|
25
|
+
loadProxyCA(resolve(process.env.ANALYTICS_PROXY_CA));
|
|
26
|
+
}
|
|
27
|
+
function getConfig() {
|
|
28
|
+
loadEnv();
|
|
29
|
+
const apiUrl = process.env.ANALYTICS_API_URL;
|
|
30
|
+
const apiToken = process.env.ANALYTICS_API_TOKEN;
|
|
31
|
+
const proxyMode = !!process.env.HTTPS_PROXY;
|
|
32
|
+
if (!apiUrl) {
|
|
33
|
+
console.error('Error: ANALYTICS_API_URL is not configured.');
|
|
34
|
+
console.error('Example: https://lazyanalytics.YOUR-SUBDOMAIN.workers.dev');
|
|
35
|
+
console.error('');
|
|
36
|
+
console.error('Run "lazyanalytics setup" to deploy and configure, or set');
|
|
37
|
+
console.error('ANALYTICS_API_URL and ANALYTICS_API_TOKEN in the environment.');
|
|
38
|
+
process.exit(3);
|
|
39
|
+
}
|
|
40
|
+
// In proxy mode (HTTPS_PROXY set), the proxy injects the Authorization header.
|
|
41
|
+
if (!apiToken && !proxyMode) {
|
|
42
|
+
console.error('Error: No authentication configured.');
|
|
43
|
+
console.error('');
|
|
44
|
+
console.error('Set ANALYTICS_API_TOKEN in the environment, or run');
|
|
45
|
+
console.error('"lazyanalytics setup" to write it to ~/.config/lazyanalytics/.env.');
|
|
46
|
+
process.exit(3);
|
|
47
|
+
}
|
|
48
|
+
return { apiUrl: apiUrl.replace(/\/$/, ''), apiToken: apiToken || '', proxyMode };
|
|
49
|
+
}
|
|
50
|
+
async function fetchApi(endpoint, params) {
|
|
51
|
+
const { apiUrl, apiToken, proxyMode } = getConfig();
|
|
52
|
+
const url = new URL(`${apiUrl}/api/${endpoint}`);
|
|
53
|
+
for (const [k, v] of Object.entries(params)) {
|
|
54
|
+
if (v)
|
|
55
|
+
url.searchParams.set(k, v);
|
|
56
|
+
}
|
|
57
|
+
// In proxy mode, the credential proxy injects the Authorization header.
|
|
58
|
+
// In direct mode, we add it ourselves.
|
|
59
|
+
const headers = {};
|
|
60
|
+
if (!proxyMode && apiToken) {
|
|
61
|
+
headers.Authorization = `Bearer ${apiToken}`;
|
|
62
|
+
}
|
|
63
|
+
const response = await fetch(url.toString(), { headers });
|
|
64
|
+
if (response.status === 401) {
|
|
65
|
+
console.error('Error: Authentication failed. Check your ANALYTICS_API_TOKEN.');
|
|
66
|
+
process.exit(3);
|
|
67
|
+
}
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const body = await response.text();
|
|
70
|
+
console.error(`Error: API returned ${response.status}: ${body}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
return response.json();
|
|
74
|
+
}
|
|
75
|
+
function formatTable(data) {
|
|
76
|
+
if (data.length === 0)
|
|
77
|
+
return '(no data)';
|
|
78
|
+
const keys = Object.keys(data[0]);
|
|
79
|
+
const widths = keys.map((k) => Math.max(k.length, ...data.map((row) => String(row[k] ?? '').length)));
|
|
80
|
+
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ');
|
|
81
|
+
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
|
82
|
+
const rows = data.map((row) => keys.map((k, i) => String(row[k] ?? '').padEnd(widths[i])).join(' '));
|
|
83
|
+
return [header, sep, ...rows].join('\n');
|
|
84
|
+
}
|
|
85
|
+
export function makeCommand(name, description) {
|
|
86
|
+
const cmd = new Command(name);
|
|
87
|
+
cmd
|
|
88
|
+
.description(description)
|
|
89
|
+
.requiredOption('-s, --site <site>', 'Site to query (e.g., example.com)')
|
|
90
|
+
.option('-p, --period <period>', 'Time period (e.g., 7d, 30d)', '7d')
|
|
91
|
+
.option('-l, --limit <limit>', 'Max results to return', '10')
|
|
92
|
+
.option('--json', 'Output as JSON (default)', true)
|
|
93
|
+
.option('--table', 'Output as human-readable table')
|
|
94
|
+
.action(async (opts) => {
|
|
95
|
+
try {
|
|
96
|
+
const params = {
|
|
97
|
+
site: opts.site,
|
|
98
|
+
period: opts.period,
|
|
99
|
+
limit: opts.limit,
|
|
100
|
+
};
|
|
101
|
+
// Add extra params from the command (type, unit)
|
|
102
|
+
if (opts.type)
|
|
103
|
+
params.type = opts.type;
|
|
104
|
+
if (opts.unit)
|
|
105
|
+
params.unit = opts.unit;
|
|
106
|
+
const result = await fetchApi(name, params);
|
|
107
|
+
if (opts.table) {
|
|
108
|
+
const arr = Array.isArray(result.data) ? result.data : [result.data];
|
|
109
|
+
console.log(formatTable(arr));
|
|
110
|
+
if (result.meta.sampled) {
|
|
111
|
+
console.log('\n(data may be sampled)');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
console.log(JSON.stringify(result, null, 2));
|
|
116
|
+
}
|
|
117
|
+
// Exit code 2 if no data
|
|
118
|
+
const dataArr = Array.isArray(result.data) ? result.data : [result.data];
|
|
119
|
+
const isEmpty = dataArr.length === 0 || (dataArr.length === 1 && Object.values(dataArr[0]).every((v) => v === 0 || v === null));
|
|
120
|
+
if (isEmpty)
|
|
121
|
+
process.exit(2);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
console.error(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return cmd;
|
|
129
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { CONFIG_ENV_PATH, readEnvFile, writeEnvFile } from '../lib/env.js';
|
|
3
|
+
/** Keys whose values must never be printed in full. */
|
|
4
|
+
const SENSITIVE_KEY = /TOKEN|SECRET|SALT|PASSWORD/i;
|
|
5
|
+
function maskValue(value) {
|
|
6
|
+
if (value.length <= 4)
|
|
7
|
+
return '****';
|
|
8
|
+
return '****' + value.slice(-4);
|
|
9
|
+
}
|
|
10
|
+
export function configCommand() {
|
|
11
|
+
const cmd = new Command('config');
|
|
12
|
+
cmd.description(`Inspect and edit the CLI config file (${CONFIG_ENV_PATH})`);
|
|
13
|
+
cmd
|
|
14
|
+
.command('path')
|
|
15
|
+
.description('Print the config file path')
|
|
16
|
+
.action(() => {
|
|
17
|
+
console.log(CONFIG_ENV_PATH);
|
|
18
|
+
});
|
|
19
|
+
cmd
|
|
20
|
+
.command('get <key>')
|
|
21
|
+
.description('Print a config value (sensitive values are masked)')
|
|
22
|
+
.action((key) => {
|
|
23
|
+
const vars = readEnvFile(CONFIG_ENV_PATH);
|
|
24
|
+
if (!(key in vars)) {
|
|
25
|
+
console.error(`Error: ${key} is not set in ${CONFIG_ENV_PATH}`);
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
const value = vars[key];
|
|
29
|
+
console.log(SENSITIVE_KEY.test(key) ? maskValue(value) : value);
|
|
30
|
+
});
|
|
31
|
+
cmd
|
|
32
|
+
.command('set <key> <value>')
|
|
33
|
+
.description('Set a config value (file is written with mode 0600)')
|
|
34
|
+
.action((key, value) => {
|
|
35
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
36
|
+
console.error(`Error: invalid key "${key}" (use letters, digits, underscores)`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const vars = readEnvFile(CONFIG_ENV_PATH);
|
|
40
|
+
vars[key] = value;
|
|
41
|
+
writeEnvFile(CONFIG_ENV_PATH, vars);
|
|
42
|
+
console.log(`Set ${key} in ${CONFIG_ENV_PATH}`);
|
|
43
|
+
});
|
|
44
|
+
return cmd;
|
|
45
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { CONFIG_ENV_PATH, WORKER_SCAFFOLD_DIR, loadEnv, readEnvFile, writeEnvFile, } from '../lib/env.js';
|
|
6
|
+
import { packagedWorkerBundle, packagedWranglerTemplate } from '../lib/paths.js';
|
|
7
|
+
import { isValidDomain, prompt, promptHidden, trackingSnippet } from '../lib/prompt.js';
|
|
8
|
+
import { readAllowedSites } from '../lib/scaffold.js';
|
|
9
|
+
import { parseWorkersDevUrl, wranglerDeploy, wranglerSecretPut } from '../lib/wrangler.js';
|
|
10
|
+
function fail(message) {
|
|
11
|
+
console.error(`Error: ${message}`);
|
|
12
|
+
// Exit 3 = missing/invalid configuration, matching the other commands.
|
|
13
|
+
process.exit(3);
|
|
14
|
+
}
|
|
15
|
+
function parseSites(raw) {
|
|
16
|
+
const sites = raw
|
|
17
|
+
.split(',')
|
|
18
|
+
.map((s) => s.trim().toLowerCase())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
if (sites.length === 0)
|
|
21
|
+
fail('At least one site is required');
|
|
22
|
+
for (const site of sites) {
|
|
23
|
+
if (!isValidDomain(site)) {
|
|
24
|
+
fail(`Invalid site domain: "${site}" (expected a bare domain like example.com)`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...new Set(sites)];
|
|
28
|
+
}
|
|
29
|
+
export function setupCommand() {
|
|
30
|
+
const cmd = new Command('setup');
|
|
31
|
+
cmd
|
|
32
|
+
.description('Deploy the analytics worker to your Cloudflare account and configure the CLI. ' +
|
|
33
|
+
'Requires CLOUDFLARE_API_TOKEN (env) and a Cloudflare account ID. ' +
|
|
34
|
+
'Idempotent: re-running reuses existing secrets unless --rotate-secrets is passed.')
|
|
35
|
+
.option('--sites <sites>', 'Comma-separated list of site domains to track')
|
|
36
|
+
.option('--account-id <id>', 'Cloudflare account ID (32-char hex)')
|
|
37
|
+
.option('--name <name>', 'Worker name', 'lazyanalytics')
|
|
38
|
+
.option('--rotate-secrets', 'Regenerate API_SECRET and HASH_SALT instead of reusing them')
|
|
39
|
+
.option('-y, --yes', 'Non-interactive mode: never prompt, fail if input is missing')
|
|
40
|
+
.action(async (opts) => {
|
|
41
|
+
loadEnv();
|
|
42
|
+
const nonInteractive = !!opts.yes;
|
|
43
|
+
// --- Cloudflare API token (env only, optionally prompted; never printed) ---
|
|
44
|
+
let apiToken = process.env.CLOUDFLARE_API_TOKEN || '';
|
|
45
|
+
if (!apiToken) {
|
|
46
|
+
if (nonInteractive) {
|
|
47
|
+
fail('CLOUDFLARE_API_TOKEN env var is required (create one at dash.cloudflare.com with Workers Scripts:Edit + Account Analytics:Read)');
|
|
48
|
+
}
|
|
49
|
+
apiToken = await promptHidden('Cloudflare API token (input hidden): ');
|
|
50
|
+
if (!apiToken)
|
|
51
|
+
fail('A Cloudflare API token is required');
|
|
52
|
+
}
|
|
53
|
+
// --- Account ID ---
|
|
54
|
+
let accountId = opts.accountId || process.env.CF_ACCOUNT_ID || process.env.CLOUDFLARE_ACCOUNT_ID || '';
|
|
55
|
+
if (!accountId) {
|
|
56
|
+
if (nonInteractive)
|
|
57
|
+
fail('Account ID required: pass --account-id or set CF_ACCOUNT_ID');
|
|
58
|
+
accountId = await prompt('Cloudflare account ID (dash.cloudflare.com > Workers & Pages sidebar): ');
|
|
59
|
+
}
|
|
60
|
+
if (!/^[a-f0-9]{32}$/i.test(accountId)) {
|
|
61
|
+
fail('Account ID must be a 32-character hex string');
|
|
62
|
+
}
|
|
63
|
+
// --- Sites (flag > existing scaffold > prompt) ---
|
|
64
|
+
const wranglerTomlPath = join(WORKER_SCAFFOLD_DIR, 'wrangler.toml');
|
|
65
|
+
let sitesRaw = opts.sites || '';
|
|
66
|
+
if (!sitesRaw) {
|
|
67
|
+
const existing = readAllowedSites(wranglerTomlPath);
|
|
68
|
+
if (existing.length > 0) {
|
|
69
|
+
sitesRaw = existing.join(',');
|
|
70
|
+
console.log(`Reusing existing site list: ${sitesRaw}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!sitesRaw) {
|
|
74
|
+
if (nonInteractive)
|
|
75
|
+
fail('Site list required: pass --sites example.com,blog.example.com');
|
|
76
|
+
sitesRaw = await prompt('Sites to track (comma-separated, e.g. mysite.com,blog.example.com): ');
|
|
77
|
+
}
|
|
78
|
+
const sites = parseSites(sitesRaw);
|
|
79
|
+
// --- Scaffold ~/.config/lazyanalytics/worker/ ---
|
|
80
|
+
mkdirSync(WORKER_SCAFFOLD_DIR, { recursive: true });
|
|
81
|
+
const bundlePath = packagedWorkerBundle();
|
|
82
|
+
if (!existsSync(bundlePath)) {
|
|
83
|
+
fail(`Worker bundle not found at ${bundlePath}. ` +
|
|
84
|
+
'If running from a repo clone, run "npm run build" at the repo root first.');
|
|
85
|
+
}
|
|
86
|
+
copyFileSync(bundlePath, join(WORKER_SCAFFOLD_DIR, 'worker.js'));
|
|
87
|
+
const templatePath = packagedWranglerTemplate();
|
|
88
|
+
if (!existsSync(templatePath))
|
|
89
|
+
fail(`wrangler.toml template not found at ${templatePath}`);
|
|
90
|
+
const toml = readFileSync(templatePath, 'utf-8')
|
|
91
|
+
.replace(/__WORKER_NAME__/g, opts.name)
|
|
92
|
+
.replace(/__ALLOWED_SITES__/g, sites.join(','));
|
|
93
|
+
writeFileSync(wranglerTomlPath, toml);
|
|
94
|
+
console.log(`Scaffolded worker in ${WORKER_SCAFFOLD_DIR}`);
|
|
95
|
+
// --- Deploy ---
|
|
96
|
+
const wranglerEnv = { CLOUDFLARE_API_TOKEN: apiToken, CLOUDFLARE_ACCOUNT_ID: accountId };
|
|
97
|
+
console.log('\nDeploying worker with wrangler...');
|
|
98
|
+
const deployOutput = await wranglerDeploy(WORKER_SCAFFOLD_DIR, wranglerEnv);
|
|
99
|
+
let workerUrl = parseWorkersDevUrl(deployOutput);
|
|
100
|
+
if (!workerUrl) {
|
|
101
|
+
console.error('\nError: could not parse the workers.dev URL from wrangler output.');
|
|
102
|
+
if (nonInteractive) {
|
|
103
|
+
fail('Re-run interactively, or check "npx wrangler deployments list" for the URL');
|
|
104
|
+
}
|
|
105
|
+
const entered = await prompt('Paste the deployed worker URL (https://...workers.dev): ');
|
|
106
|
+
if (!/^https:\/\/.+/.test(entered))
|
|
107
|
+
fail('A valid https:// worker URL is required');
|
|
108
|
+
workerUrl = entered.replace(/\/$/, '');
|
|
109
|
+
}
|
|
110
|
+
// --- Secrets (reused across runs unless --rotate-secrets) ---
|
|
111
|
+
const existingConfig = readEnvFile(CONFIG_ENV_PATH);
|
|
112
|
+
const rotate = !!opts.rotateSecrets;
|
|
113
|
+
const apiSecret = !rotate && existingConfig.ANALYTICS_API_TOKEN
|
|
114
|
+
? existingConfig.ANALYTICS_API_TOKEN
|
|
115
|
+
: randomBytes(32).toString('hex');
|
|
116
|
+
const hashSalt = !rotate && existingConfig.HASH_SALT
|
|
117
|
+
? existingConfig.HASH_SALT
|
|
118
|
+
: randomBytes(32).toString('hex');
|
|
119
|
+
console.log('\nSetting worker secrets (values are never printed)...');
|
|
120
|
+
await wranglerSecretPut(WORKER_SCAFFOLD_DIR, wranglerEnv, 'API_SECRET', apiSecret);
|
|
121
|
+
await wranglerSecretPut(WORKER_SCAFFOLD_DIR, wranglerEnv, 'HASH_SALT', hashSalt);
|
|
122
|
+
// The /api/* query endpoints read Analytics Engine through Cloudflare's
|
|
123
|
+
// SQL REST API, which needs account credentials on the worker. A token
|
|
124
|
+
// with Account Analytics:Read suffices; pass a narrower token here via
|
|
125
|
+
// setup if you don't want the deploy token stored on the worker.
|
|
126
|
+
await wranglerSecretPut(WORKER_SCAFFOLD_DIR, wranglerEnv, 'CF_ACCOUNT_ID', accountId);
|
|
127
|
+
await wranglerSecretPut(WORKER_SCAFFOLD_DIR, wranglerEnv, 'CF_API_TOKEN', apiToken);
|
|
128
|
+
// --- Persist CLI config (0600) ---
|
|
129
|
+
writeEnvFile(CONFIG_ENV_PATH, {
|
|
130
|
+
...existingConfig,
|
|
131
|
+
ANALYTICS_API_URL: workerUrl,
|
|
132
|
+
ANALYTICS_API_TOKEN: apiSecret,
|
|
133
|
+
CF_ACCOUNT_ID: accountId,
|
|
134
|
+
HASH_SALT: hashSalt,
|
|
135
|
+
});
|
|
136
|
+
console.log(`Config written to ${CONFIG_ENV_PATH} (mode 0600)`);
|
|
137
|
+
// --- Health check ---
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${workerUrl}/health`);
|
|
140
|
+
console.log(`\nHealth check: ${res.ok ? 'ok' : `failed (HTTP ${res.status})`}`);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
console.log(`\nHealth check: unreachable (${e instanceof Error ? e.message : 'unknown error'}). ` +
|
|
144
|
+
'The worker may take a few seconds to become available.');
|
|
145
|
+
}
|
|
146
|
+
// --- Tracking snippets ---
|
|
147
|
+
console.log('\nSetup complete. Add this snippet to each site:');
|
|
148
|
+
for (const site of sites) {
|
|
149
|
+
console.log(`\n ${site}:`);
|
|
150
|
+
console.log(` ${trackingSnippet(workerUrl, site)}`);
|
|
151
|
+
}
|
|
152
|
+
console.log('\nThen query with: lazyanalytics stats --site ' + sites[0]);
|
|
153
|
+
});
|
|
154
|
+
return cmd;
|
|
155
|
+
}
|