@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { fetchSites, getApiConfig } from '../lib/api.js';
|
|
4
|
+
import { WORKER_SCAFFOLD_DIR, loadEnv } from '../lib/env.js';
|
|
5
|
+
import { isValidDomain, trackingSnippet } from '../lib/prompt.js';
|
|
6
|
+
import { SCAFFOLD_WRANGLER_TOML, readAllowedSites, writeAllowedSites } from '../lib/scaffold.js';
|
|
7
|
+
import { wranglerDeploy } from '../lib/wrangler.js';
|
|
8
|
+
function requireApiConfig() {
|
|
9
|
+
const config = getApiConfig();
|
|
10
|
+
if (!config) {
|
|
11
|
+
console.error('Error: ANALYTICS_API_URL and ANALYTICS_API_TOKEN are required.');
|
|
12
|
+
console.error('Run "lazyanalytics setup" first, or set them in the environment.');
|
|
13
|
+
process.exit(3);
|
|
14
|
+
}
|
|
15
|
+
return config;
|
|
16
|
+
}
|
|
17
|
+
function requireScaffold() {
|
|
18
|
+
if (!existsSync(SCAFFOLD_WRANGLER_TOML)) {
|
|
19
|
+
console.error(`Error: worker scaffold not found at ${WORKER_SCAFFOLD_DIR}.`);
|
|
20
|
+
console.error('Run "lazyanalytics setup" first to deploy the worker.');
|
|
21
|
+
process.exit(3);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function requireWranglerEnv() {
|
|
25
|
+
loadEnv();
|
|
26
|
+
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
27
|
+
const accountId = process.env.CF_ACCOUNT_ID || process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
28
|
+
if (!apiToken) {
|
|
29
|
+
console.error('Error: CLOUDFLARE_API_TOKEN env var is required to redeploy the worker.');
|
|
30
|
+
process.exit(3);
|
|
31
|
+
}
|
|
32
|
+
if (!accountId) {
|
|
33
|
+
console.error('Error: CF_ACCOUNT_ID is required (set during "lazyanalytics setup").');
|
|
34
|
+
process.exit(3);
|
|
35
|
+
}
|
|
36
|
+
return { CLOUDFLARE_API_TOKEN: apiToken, CLOUDFLARE_ACCOUNT_ID: accountId };
|
|
37
|
+
}
|
|
38
|
+
async function redeployAndReport(verb, domain, sites) {
|
|
39
|
+
// Resolve credentials before mutating the scaffold, so a missing token
|
|
40
|
+
// can't leave wrangler.toml out of sync with the deployed worker.
|
|
41
|
+
const wranglerEnv = requireWranglerEnv();
|
|
42
|
+
writeAllowedSites(sites);
|
|
43
|
+
console.error(`Redeploying worker with updated site list...`);
|
|
44
|
+
await wranglerDeploy(WORKER_SCAFFOLD_DIR, wranglerEnv);
|
|
45
|
+
console.log(JSON.stringify({ data: sites.map((site) => ({ site })), [verb]: domain }, null, 2));
|
|
46
|
+
}
|
|
47
|
+
export function sitesCommand() {
|
|
48
|
+
const cmd = new Command('sites');
|
|
49
|
+
cmd.description('Manage the list of tracked sites (list, add, remove)');
|
|
50
|
+
cmd
|
|
51
|
+
.command('list')
|
|
52
|
+
.description('List tracked sites (queries the worker /api/sites endpoint)')
|
|
53
|
+
.option('--json', 'Output as JSON (default)', true)
|
|
54
|
+
.option('--table', 'Output as human-readable list')
|
|
55
|
+
.action(async (opts) => {
|
|
56
|
+
try {
|
|
57
|
+
const sites = await fetchSites(requireApiConfig());
|
|
58
|
+
if (opts.table) {
|
|
59
|
+
if (sites.length === 0)
|
|
60
|
+
console.log('(no sites configured)');
|
|
61
|
+
for (const site of sites)
|
|
62
|
+
console.log(site);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
console.log(JSON.stringify({ data: sites.map((site) => ({ site })) }, null, 2));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
console.error(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
cmd
|
|
74
|
+
.command('add <domain>')
|
|
75
|
+
.description('Add a site to ALLOWED_SITES and redeploy the worker')
|
|
76
|
+
.action(async (domain) => {
|
|
77
|
+
try {
|
|
78
|
+
requireScaffold();
|
|
79
|
+
const site = domain.trim().toLowerCase();
|
|
80
|
+
if (!isValidDomain(site)) {
|
|
81
|
+
console.error(`Error: invalid domain "${domain}" (expected e.g. example.com)`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
const sites = readAllowedSites();
|
|
85
|
+
if (sites.includes(site)) {
|
|
86
|
+
console.error(`Site ${site} is already tracked.`);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
sites.push(site);
|
|
90
|
+
await redeployAndReport('added', site, sites);
|
|
91
|
+
const apiUrl = process.env.ANALYTICS_API_URL;
|
|
92
|
+
if (apiUrl) {
|
|
93
|
+
console.error(`\nAdd this snippet to ${site}:`);
|
|
94
|
+
console.error(` ${trackingSnippet(apiUrl, site)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
console.error(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
cmd
|
|
103
|
+
.command('remove <domain>')
|
|
104
|
+
.description('Remove a site from ALLOWED_SITES and redeploy the worker')
|
|
105
|
+
.action(async (domain) => {
|
|
106
|
+
try {
|
|
107
|
+
requireScaffold();
|
|
108
|
+
const site = domain.trim().toLowerCase();
|
|
109
|
+
const sites = readAllowedSites();
|
|
110
|
+
if (!sites.includes(site)) {
|
|
111
|
+
console.error(`Error: site ${site} is not in the tracked list.`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
const remaining = sites.filter((s) => s !== site);
|
|
115
|
+
if (remaining.length === 0) {
|
|
116
|
+
console.error('Error: cannot remove the last tracked site.');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
await redeployAndReport('removed', site, remaining);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
console.error(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return cmd;
|
|
127
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { packagedSkill } from '../lib/paths.js';
|
|
6
|
+
export function skillCommand() {
|
|
7
|
+
const cmd = new Command('skill');
|
|
8
|
+
cmd.description('Manage the Claude Code skill for lazyanalytics');
|
|
9
|
+
cmd
|
|
10
|
+
.command('install')
|
|
11
|
+
.description('Install the lazyanalytics skill for Claude Code')
|
|
12
|
+
.option('--project', 'Install into ./.claude/skills/lazyanalytics instead of the home directory')
|
|
13
|
+
.action((opts) => {
|
|
14
|
+
const source = packagedSkill();
|
|
15
|
+
if (!existsSync(source)) {
|
|
16
|
+
console.error(`Error: packaged skill not found at ${source}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const targetDir = opts.project
|
|
20
|
+
? resolve(process.cwd(), '.claude', 'skills', 'lazyanalytics')
|
|
21
|
+
: join(homedir(), '.claude', 'skills', 'lazyanalytics');
|
|
22
|
+
const target = join(targetDir, 'SKILL.md');
|
|
23
|
+
mkdirSync(targetDir, { recursive: true });
|
|
24
|
+
copyFileSync(source, target);
|
|
25
|
+
console.log(`Skill installed at ${target}`);
|
|
26
|
+
});
|
|
27
|
+
return cmd;
|
|
28
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { fetchSites, getApiConfig } from '../lib/api.js';
|
|
3
|
+
import { loadEnv } from '../lib/env.js';
|
|
4
|
+
import { isValidDomain, trackingSnippet } from '../lib/prompt.js';
|
|
5
|
+
export function snippetCommand() {
|
|
6
|
+
const cmd = new Command('snippet');
|
|
7
|
+
cmd
|
|
8
|
+
.description('Print the tracking <script> snippet for one site, or all tracked sites')
|
|
9
|
+
.option('-s, --site <site>', 'Site to print the snippet for')
|
|
10
|
+
.action(async (opts) => {
|
|
11
|
+
loadEnv();
|
|
12
|
+
const apiUrl = process.env.ANALYTICS_API_URL;
|
|
13
|
+
if (!apiUrl) {
|
|
14
|
+
console.error('Error: ANALYTICS_API_URL is required. Run "lazyanalytics setup" first.');
|
|
15
|
+
process.exit(3);
|
|
16
|
+
}
|
|
17
|
+
if (opts.site) {
|
|
18
|
+
const site = String(opts.site).trim().toLowerCase();
|
|
19
|
+
if (!isValidDomain(site)) {
|
|
20
|
+
console.error(`Error: invalid domain "${opts.site}" (expected e.g. example.com)`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
console.log(trackingSnippet(apiUrl, site));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// No --site: list every tracked site from the worker.
|
|
27
|
+
const config = getApiConfig();
|
|
28
|
+
let sites = [];
|
|
29
|
+
if (config) {
|
|
30
|
+
try {
|
|
31
|
+
sites = await fetchSites(config);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
sites = [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (sites.length === 0) {
|
|
38
|
+
console.error('Error: could not fetch the site list from the worker.');
|
|
39
|
+
console.error('Pass --site <domain> to print a snippet for a specific site.');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
for (const site of sites) {
|
|
43
|
+
console.log(`<!-- ${site} -->`);
|
|
44
|
+
console.log(trackingSnippet(apiUrl, site));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return cmd;
|
|
48
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { loadEnv } from '../lib/env.js';
|
|
3
|
+
const FREE_PLAN_DAILY_LIMIT = 100_000;
|
|
4
|
+
const FREE_PLAN_MONTHLY_LIMIT = FREE_PLAN_DAILY_LIMIT * 30; // ~3M
|
|
5
|
+
async function queryWorkerMetrics(accountId, scriptName, startDate, endDate) {
|
|
6
|
+
const query = `query {
|
|
7
|
+
viewer {
|
|
8
|
+
accounts(filter: {accountTag: "${accountId}"}) {
|
|
9
|
+
workersInvocationsAdaptive(
|
|
10
|
+
limit: 1000,
|
|
11
|
+
filter: {
|
|
12
|
+
scriptName: "${scriptName}",
|
|
13
|
+
datetime_geq: "${startDate}",
|
|
14
|
+
datetime_leq: "${endDate}"
|
|
15
|
+
}
|
|
16
|
+
) {
|
|
17
|
+
sum { requests errors subrequests }
|
|
18
|
+
quantiles { cpuTimeP50 cpuTimeP99 }
|
|
19
|
+
dimensions { datetime }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}`;
|
|
24
|
+
// In proxy mode (HTTPS_PROXY set), the credential proxy injects the
|
|
25
|
+
// Authorization header. In direct mode, we add it ourselves.
|
|
26
|
+
const proxyMode = !!process.env.HTTPS_PROXY;
|
|
27
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
28
|
+
if (!proxyMode) {
|
|
29
|
+
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
30
|
+
if (!apiToken) {
|
|
31
|
+
throw new Error('CLOUDFLARE_API_TOKEN env var is required.');
|
|
32
|
+
}
|
|
33
|
+
headers.Authorization = `Bearer ${apiToken}`;
|
|
34
|
+
}
|
|
35
|
+
const res = await fetch('https://api.cloudflare.com/client/v4/graphql', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers,
|
|
38
|
+
body: JSON.stringify({ query }),
|
|
39
|
+
});
|
|
40
|
+
return res.json();
|
|
41
|
+
}
|
|
42
|
+
export function usageCommand() {
|
|
43
|
+
const cmd = new Command('usage');
|
|
44
|
+
cmd
|
|
45
|
+
.description('Show Worker request usage, free plan capacity, and estimated cost. ' +
|
|
46
|
+
'Requires CF_ACCOUNT_ID and CLOUDFLARE_API_TOKEN.')
|
|
47
|
+
.option('-p, --period <period>', 'Lookback period: today, 7d, 30d', 'today')
|
|
48
|
+
.option('-w, --worker <name>', 'Worker script name (defaults to ANALYTICS_WORKER_NAME env or lazyanalytics)')
|
|
49
|
+
.option('--json', 'Output as JSON (default)', true)
|
|
50
|
+
.option('--table', 'Output as human-readable table')
|
|
51
|
+
.action(async (opts) => {
|
|
52
|
+
loadEnv();
|
|
53
|
+
const accountId = process.env.CF_ACCOUNT_ID;
|
|
54
|
+
const proxyMode = !!process.env.HTTPS_PROXY;
|
|
55
|
+
if (!accountId) {
|
|
56
|
+
console.error('Error: CF_ACCOUNT_ID env var required (Cloudflare account identifier)');
|
|
57
|
+
console.error('Find it at: dash.cloudflare.com > Workers & Pages > right sidebar');
|
|
58
|
+
process.exit(3);
|
|
59
|
+
}
|
|
60
|
+
if (!proxyMode && !process.env.CLOUDFLARE_API_TOKEN) {
|
|
61
|
+
console.error('Error: CLOUDFLARE_API_TOKEN env var is required.');
|
|
62
|
+
console.error('Create one at dash.cloudflare.com with Account Analytics:Read permission.');
|
|
63
|
+
process.exit(3);
|
|
64
|
+
}
|
|
65
|
+
const now = new Date();
|
|
66
|
+
let startDate;
|
|
67
|
+
let periodLabel;
|
|
68
|
+
switch (opts.period) {
|
|
69
|
+
case '7d':
|
|
70
|
+
startDate = new Date(now.getTime() - 7 * 86400000);
|
|
71
|
+
periodLabel = '7d';
|
|
72
|
+
break;
|
|
73
|
+
case '30d':
|
|
74
|
+
startDate = new Date(now.getTime() - 30 * 86400000);
|
|
75
|
+
periodLabel = '30d';
|
|
76
|
+
break;
|
|
77
|
+
default:
|
|
78
|
+
startDate = new Date(now.toISOString().slice(0, 10) + 'T00:00:00Z');
|
|
79
|
+
periodLabel = 'today';
|
|
80
|
+
}
|
|
81
|
+
const workerName = opts.worker || process.env.ANALYTICS_WORKER_NAME || 'lazyanalytics';
|
|
82
|
+
try {
|
|
83
|
+
const result = await queryWorkerMetrics(accountId, workerName, startDate.toISOString(), now.toISOString());
|
|
84
|
+
if (result.errors?.length) {
|
|
85
|
+
console.error(`GraphQL error: ${result.errors[0].message}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const rows = result.data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive || [];
|
|
89
|
+
const totalRequests = rows.reduce((sum, r) => sum + r.sum.requests, 0);
|
|
90
|
+
const totalErrors = rows.reduce((sum, r) => sum + r.sum.errors, 0);
|
|
91
|
+
const avgCpuP50 = rows.length > 0
|
|
92
|
+
? rows.reduce((sum, r) => sum + r.quantiles.cpuTimeP50, 0) / rows.length
|
|
93
|
+
: 0;
|
|
94
|
+
const days = Math.max(1, Math.ceil((now.getTime() - startDate.getTime()) / 86400000));
|
|
95
|
+
const avgRequestsPerDay = Math.round(totalRequests / days);
|
|
96
|
+
// Cost projections
|
|
97
|
+
const projectedMonthlyRequests = avgRequestsPerDay * 30;
|
|
98
|
+
const freePlanPct = Math.round((avgRequestsPerDay / FREE_PLAN_DAILY_LIMIT) * 100 * 10) / 10;
|
|
99
|
+
const needsPaidPlan = avgRequestsPerDay > FREE_PLAN_DAILY_LIMIT;
|
|
100
|
+
// AE cost estimate (once billing starts)
|
|
101
|
+
// Roughly half of requests are /collect writes, rest are tracker.js/api/heartbeat
|
|
102
|
+
const estimatedWritesPerDay = Math.round(avgRequestsPerDay * 0.4);
|
|
103
|
+
const aeWriteCostMonthly = (estimatedWritesPerDay * 30 / 1_000_000) * 0.25;
|
|
104
|
+
const aeQueryCostMonthly = 0.001; // negligible at this scale
|
|
105
|
+
const output = {
|
|
106
|
+
period: periodLabel,
|
|
107
|
+
requests: {
|
|
108
|
+
total: totalRequests,
|
|
109
|
+
errors: totalErrors,
|
|
110
|
+
avg_per_day: avgRequestsPerDay,
|
|
111
|
+
projected_monthly: projectedMonthlyRequests,
|
|
112
|
+
},
|
|
113
|
+
cpu: {
|
|
114
|
+
p50_ms: Math.round(avgCpuP50 * 100) / 100,
|
|
115
|
+
},
|
|
116
|
+
free_plan: {
|
|
117
|
+
daily_limit: FREE_PLAN_DAILY_LIMIT,
|
|
118
|
+
daily_usage_pct: freePlanPct,
|
|
119
|
+
monthly_limit: FREE_PLAN_MONTHLY_LIMIT,
|
|
120
|
+
needs_paid_plan: needsPaidPlan,
|
|
121
|
+
headroom: `${Math.round(FREE_PLAN_DAILY_LIMIT / Math.max(avgRequestsPerDay, 1))}x before hitting limit`,
|
|
122
|
+
},
|
|
123
|
+
estimated_monthly_cost: {
|
|
124
|
+
workers: needsPaidPlan ? '$5.00 (paid plan)' : '$0.00 (free plan)',
|
|
125
|
+
analytics_engine_writes: `$${aeWriteCostMonthly.toFixed(4)} (once billing starts)`,
|
|
126
|
+
analytics_engine_queries: `$${aeQueryCostMonthly.toFixed(4)}`,
|
|
127
|
+
total: needsPaidPlan
|
|
128
|
+
? `~$${(5 + aeWriteCostMonthly + aeQueryCostMonthly).toFixed(2)}`
|
|
129
|
+
: `~$${(aeWriteCostMonthly + aeQueryCostMonthly).toFixed(4)} (currently $0, AE not billing yet)`,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
if (opts.table) {
|
|
133
|
+
console.log(`\n Usage Report (${periodLabel})`);
|
|
134
|
+
console.log(` ${'─'.repeat(50)}`);
|
|
135
|
+
console.log(` Requests: ${totalRequests.toLocaleString()} total (${totalErrors} errors)`);
|
|
136
|
+
console.log(` Avg/day: ${avgRequestsPerDay.toLocaleString()} requests`);
|
|
137
|
+
console.log(` CPU time (p50): ${output.cpu.p50_ms}ms`);
|
|
138
|
+
console.log(` ${'─'.repeat(50)}`);
|
|
139
|
+
console.log(` Free plan usage: ${freePlanPct}% of daily limit (${FREE_PLAN_DAILY_LIMIT.toLocaleString()}/day)`);
|
|
140
|
+
console.log(` Headroom: ${output.free_plan.headroom}`);
|
|
141
|
+
console.log(` Needs paid plan: ${needsPaidPlan ? 'YES' : 'No'}`);
|
|
142
|
+
console.log(` ${'─'.repeat(50)}`);
|
|
143
|
+
console.log(` Est. monthly cost: ${output.estimated_monthly_cost.total}`);
|
|
144
|
+
console.log('');
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
console.log(JSON.stringify(output, null, 2));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
console.error(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
return cmd;
|
|
156
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { makeCommand } from './commands/base.js';
|
|
4
|
+
import { usageCommand } from './commands/usage.js';
|
|
5
|
+
import { setupCommand } from './commands/setup.js';
|
|
6
|
+
import { sitesCommand } from './commands/sites.js';
|
|
7
|
+
import { snippetCommand } from './commands/snippet.js';
|
|
8
|
+
import { skillCommand } from './commands/skill.js';
|
|
9
|
+
import { configCommand } from './commands/config.js';
|
|
10
|
+
import { packageVersion } from './lib/paths.js';
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program
|
|
13
|
+
.name('lazyanalytics')
|
|
14
|
+
.description('Agent-first analytics CLI: deploy the worker (setup), manage tracked sites (sites), ' +
|
|
15
|
+
'print tracking snippets (snippet), install the Claude skill (skill), manage config ' +
|
|
16
|
+
'(config), and query web property statistics. Returns JSON by default for ' +
|
|
17
|
+
'programmatic consumption.')
|
|
18
|
+
.version(packageVersion());
|
|
19
|
+
program.addCommand(makeCommand('stats', 'Aggregate statistics (pageviews, approx visitors, avg screen width)'));
|
|
20
|
+
program.addCommand(makeCommand('pages', 'Top pages by view count'));
|
|
21
|
+
program.addCommand(makeCommand('referrers', 'Top referrer domains'));
|
|
22
|
+
program.addCommand(makeCommand('geo', 'Geographic breakdown by country'));
|
|
23
|
+
program.addCommand(makeCommand('browsers', 'Browser, OS, or device breakdown').option('--type <type>', 'Breakdown type: browser, os, device', 'browser'));
|
|
24
|
+
program.addCommand(makeCommand('timeseries', 'Pageview timeseries').option('--unit <unit>', 'Time bucket: hour, day', 'day'));
|
|
25
|
+
program.addCommand(usageCommand());
|
|
26
|
+
program.addCommand(setupCommand());
|
|
27
|
+
program.addCommand(sitesCommand());
|
|
28
|
+
program.addCommand(snippetCommand());
|
|
29
|
+
program.addCommand(skillCommand());
|
|
30
|
+
program.addCommand(configCommand());
|
|
31
|
+
program.parse();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ApiConfig {
|
|
2
|
+
apiUrl: string;
|
|
3
|
+
apiToken: string;
|
|
4
|
+
proxyMode: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** Resolve API URL + token from env/config. Returns null if not configured. */
|
|
7
|
+
export declare function getApiConfig(): ApiConfig | null;
|
|
8
|
+
/** Fetch the list of tracked sites from the worker's /api/sites endpoint. */
|
|
9
|
+
export declare function fetchSites(config: ApiConfig): Promise<string[]>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { loadEnv } from './env.js';
|
|
2
|
+
/** Resolve API URL + token from env/config. Returns null if not configured. */
|
|
3
|
+
export function getApiConfig() {
|
|
4
|
+
loadEnv();
|
|
5
|
+
const apiUrl = process.env.ANALYTICS_API_URL;
|
|
6
|
+
const apiToken = process.env.ANALYTICS_API_TOKEN || '';
|
|
7
|
+
const proxyMode = !!process.env.HTTPS_PROXY;
|
|
8
|
+
if (!apiUrl || (!apiToken && !proxyMode))
|
|
9
|
+
return null;
|
|
10
|
+
return { apiUrl: apiUrl.replace(/\/$/, ''), apiToken, proxyMode };
|
|
11
|
+
}
|
|
12
|
+
/** Fetch the list of tracked sites from the worker's /api/sites endpoint. */
|
|
13
|
+
export async function fetchSites(config) {
|
|
14
|
+
const headers = {};
|
|
15
|
+
if (!config.proxyMode && config.apiToken) {
|
|
16
|
+
headers.Authorization = `Bearer ${config.apiToken}`;
|
|
17
|
+
}
|
|
18
|
+
const res = await fetch(`${config.apiUrl}/api/sites`, { headers });
|
|
19
|
+
if (res.status === 401) {
|
|
20
|
+
throw new Error('Authentication failed. Check your ANALYTICS_API_TOKEN.');
|
|
21
|
+
}
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
throw new Error(`API returned ${res.status}: ${await res.text()}`);
|
|
24
|
+
}
|
|
25
|
+
const body = (await res.json());
|
|
26
|
+
return (body.data ?? []).map((row) => row.site ?? '').filter(Boolean);
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Config locations shared by all commands. */
|
|
2
|
+
export declare const CONFIG_DIR: string;
|
|
3
|
+
export declare const CONFIG_ENV_PATH: string;
|
|
4
|
+
export declare const WORKER_SCAFFOLD_DIR: string;
|
|
5
|
+
/** Parse .env file content into a key/value map. */
|
|
6
|
+
export declare function parseEnvContent(content: string): Record<string, string>;
|
|
7
|
+
/** Read a .env file into a key/value map (empty map if missing/unreadable). */
|
|
8
|
+
export declare function readEnvFile(path: string): Record<string, string>;
|
|
9
|
+
/** Write a key/value map to a .env file with mode 0600. */
|
|
10
|
+
export declare function writeEnvFile(path: string, vars: Record<string, string>): void;
|
|
11
|
+
/**
|
|
12
|
+
* Load vars from .env files (cwd .env, then ~/.config/lazyanalytics/.env).
|
|
13
|
+
* Existing env vars take precedence.
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadEnv(): void;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { resolve, join, dirname } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
/** Config locations shared by all commands. */
|
|
5
|
+
export const CONFIG_DIR = join(homedir(), '.config', 'lazyanalytics');
|
|
6
|
+
export const CONFIG_ENV_PATH = join(CONFIG_DIR, '.env');
|
|
7
|
+
export const WORKER_SCAFFOLD_DIR = join(CONFIG_DIR, 'worker');
|
|
8
|
+
/**
|
|
9
|
+
* Parse a single .env value: strip surrounding quotes, or strip an
|
|
10
|
+
* unquoted inline comment (everything from the first `#`).
|
|
11
|
+
*/
|
|
12
|
+
function parseEnvValue(raw) {
|
|
13
|
+
let val = raw.trim();
|
|
14
|
+
if (val.startsWith('"') || val.startsWith("'")) {
|
|
15
|
+
const quote = val[0];
|
|
16
|
+
const end = val.indexOf(quote, 1);
|
|
17
|
+
return end === -1 ? val.slice(1) : val.slice(1, end);
|
|
18
|
+
}
|
|
19
|
+
const hash = val.indexOf('#');
|
|
20
|
+
if (hash !== -1)
|
|
21
|
+
val = val.slice(0, hash);
|
|
22
|
+
return val.trim();
|
|
23
|
+
}
|
|
24
|
+
/** Parse .env file content into a key/value map. */
|
|
25
|
+
export function parseEnvContent(content) {
|
|
26
|
+
const vars = {};
|
|
27
|
+
for (const line of content.split('\n')) {
|
|
28
|
+
const trimmed = line.trim();
|
|
29
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
30
|
+
continue;
|
|
31
|
+
const eqIdx = trimmed.indexOf('=');
|
|
32
|
+
if (eqIdx === -1)
|
|
33
|
+
continue;
|
|
34
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
35
|
+
if (!key)
|
|
36
|
+
continue;
|
|
37
|
+
vars[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
|
|
38
|
+
}
|
|
39
|
+
return vars;
|
|
40
|
+
}
|
|
41
|
+
/** Read a .env file into a key/value map (empty map if missing/unreadable). */
|
|
42
|
+
export function readEnvFile(path) {
|
|
43
|
+
if (!existsSync(path))
|
|
44
|
+
return {};
|
|
45
|
+
try {
|
|
46
|
+
return parseEnvContent(readFileSync(path, 'utf-8'));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Quote a value if it would otherwise be mangled by the parser. */
|
|
53
|
+
function formatEnvValue(value) {
|
|
54
|
+
if (/[#\s]/.test(value)) {
|
|
55
|
+
return value.includes('"') ? `'${value}'` : `"${value}"`;
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
/** Write a key/value map to a .env file with mode 0600. */
|
|
60
|
+
export function writeEnvFile(path, vars) {
|
|
61
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
62
|
+
const content = Object.entries(vars)
|
|
63
|
+
.map(([k, v]) => `${k}=${formatEnvValue(v)}`)
|
|
64
|
+
.join('\n') + '\n';
|
|
65
|
+
writeFileSync(path, content, { mode: 0o600 });
|
|
66
|
+
chmodSync(path, 0o600); // in case the file already existed with looser perms
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Load vars from .env files (cwd .env, then ~/.config/lazyanalytics/.env).
|
|
70
|
+
* Existing env vars take precedence.
|
|
71
|
+
*/
|
|
72
|
+
export function loadEnv() {
|
|
73
|
+
const candidates = [resolve(process.cwd(), '.env'), CONFIG_ENV_PATH];
|
|
74
|
+
for (const path of candidates) {
|
|
75
|
+
const vars = readEnvFile(path);
|
|
76
|
+
for (const [key, val] of Object.entries(vars)) {
|
|
77
|
+
if (!process.env[key])
|
|
78
|
+
process.env[key] = val;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the lazyanalytics package root relative to this module.
|
|
3
|
+
*
|
|
4
|
+
* Installed layout: <root>/cli/dist/lib/paths.js -> root is 3 levels up.
|
|
5
|
+
* Dev layout (tsx): <root>/cli/src/lib/paths.ts -> root is 3 levels up.
|
|
6
|
+
* As a fallback, walk up until we find the package.json named "lazyanalytics".
|
|
7
|
+
*/
|
|
8
|
+
export declare function findPackageRoot(): string;
|
|
9
|
+
/** Path to the prebundled worker script shipped in the package (dist/worker.js). */
|
|
10
|
+
export declare function packagedWorkerBundle(): string;
|
|
11
|
+
/** Path to the wrangler.toml template shipped in the package. */
|
|
12
|
+
export declare function packagedWranglerTemplate(): string;
|
|
13
|
+
/** Path to the packaged Claude skill file. */
|
|
14
|
+
export declare function packagedSkill(): string;
|
|
15
|
+
/** Read the package version (falls back to 0.0.0 if unresolvable). */
|
|
16
|
+
export declare function packageVersion(): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the lazyanalytics package root relative to this module.
|
|
7
|
+
*
|
|
8
|
+
* Installed layout: <root>/cli/dist/lib/paths.js -> root is 3 levels up.
|
|
9
|
+
* Dev layout (tsx): <root>/cli/src/lib/paths.ts -> root is 3 levels up.
|
|
10
|
+
* As a fallback, walk up until we find the package.json named "lazyanalytics".
|
|
11
|
+
*/
|
|
12
|
+
export function findPackageRoot() {
|
|
13
|
+
let dir = moduleDir;
|
|
14
|
+
for (let i = 0; i < 6; i++) {
|
|
15
|
+
const pkgPath = join(dir, 'package.json');
|
|
16
|
+
if (existsSync(pkgPath)) {
|
|
17
|
+
try {
|
|
18
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
19
|
+
if (pkg.name === '@jayfarei/lazyanalytics')
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
/* ignore malformed package.json */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const parent = dirname(dir);
|
|
27
|
+
if (parent === dir)
|
|
28
|
+
break;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
return resolve(moduleDir, '..', '..', '..');
|
|
32
|
+
}
|
|
33
|
+
/** Path to the prebundled worker script shipped in the package (dist/worker.js). */
|
|
34
|
+
export function packagedWorkerBundle() {
|
|
35
|
+
return join(findPackageRoot(), 'dist', 'worker.js');
|
|
36
|
+
}
|
|
37
|
+
/** Path to the wrangler.toml template shipped in the package. */
|
|
38
|
+
export function packagedWranglerTemplate() {
|
|
39
|
+
return join(findPackageRoot(), 'templates', 'wrangler.toml');
|
|
40
|
+
}
|
|
41
|
+
/** Path to the packaged Claude skill file. */
|
|
42
|
+
export function packagedSkill() {
|
|
43
|
+
return join(findPackageRoot(), 'skill', 'SKILL.md');
|
|
44
|
+
}
|
|
45
|
+
/** Read the package version (falls back to 0.0.0 if unresolvable). */
|
|
46
|
+
export function packageVersion() {
|
|
47
|
+
try {
|
|
48
|
+
const pkg = JSON.parse(readFileSync(join(findPackageRoot(), 'package.json'), 'utf-8'));
|
|
49
|
+
return pkg.version ?? '0.0.0';
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return '0.0.0';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Ask a question on the terminal. Returns the trimmed answer. */
|
|
2
|
+
export declare function prompt(question: string): Promise<string>;
|
|
3
|
+
/** Ask for a secret without echoing the typed characters. */
|
|
4
|
+
export declare function promptHidden(question: string): Promise<string>;
|
|
5
|
+
/** Validate a bare domain name (e.g. example.com, blog.example.co.uk). */
|
|
6
|
+
export declare function isValidDomain(domain: string): boolean;
|
|
7
|
+
/** Build the tracking snippet for a site. */
|
|
8
|
+
export declare function trackingSnippet(apiUrl: string, site: string): string;
|