@supalytics/cli 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/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # Supalytics CLI
2
+
3
+ Command-line interface for [Supalytics](https://supalytics.co) web analytics.
4
+
5
+ ```
6
+ ░█▀▀░█░█░█▀█░█▀█░█░░░█░█░▀█▀░▀█▀░█▀▀░█▀▀
7
+ ░▀▀█░█░█░█▀▀░█▀█░█░░░░█░░░█░░░█░░█░░░▀▀█
8
+ ░▀▀▀░▀▀▀░▀░░░▀░▀░▀▀▀░░▀░░░▀░░▀▀▀░▀▀▀░▀▀▀
9
+ ```
10
+
11
+ ## Requirements
12
+
13
+ - [Bun](https://bun.sh) runtime
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ bun add -g @supalytics/cli
19
+ ```
20
+
21
+ ## Setup
22
+
23
+ Get your API key from [supalytics.co/settings/api](https://supalytics.co/settings/api), then:
24
+
25
+ ```bash
26
+ supalytics login <your-api-key>
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### Quick Stats
32
+
33
+ ```bash
34
+ supalytics stats # Last 30 days (default)
35
+ supalytics stats today
36
+ supalytics stats yesterday
37
+ supalytics stats week
38
+ supalytics stats month
39
+ supalytics stats 7d
40
+ ```
41
+
42
+ ### Realtime
43
+
44
+ ```bash
45
+ supalytics realtime # Current visitors
46
+ supalytics realtime --watch # Auto-refresh every 30s
47
+ ```
48
+
49
+ ### Breakdowns
50
+
51
+ ```bash
52
+ supalytics pages # Top pages
53
+ supalytics referrers # Top referrers
54
+ supalytics countries # Traffic by country
55
+ ```
56
+
57
+ ### Custom Queries
58
+
59
+ ```bash
60
+ # Top pages with revenue
61
+ supalytics query -d page -m visitors,revenue
62
+
63
+ # Traffic by country and device
64
+ supalytics query -d country,device -m visitors
65
+
66
+ # Filter by country
67
+ supalytics query -d page -f "country:is:US"
68
+
69
+ # UTM campaign performance
70
+ supalytics query -d utm_source,utm_campaign -m visitors,revenue
71
+ ```
72
+
73
+ ### Events
74
+
75
+ ```bash
76
+ supalytics events # List all events
77
+ supalytics events signup # Show properties for event
78
+ supalytics events signup --property plan # Breakdown by property
79
+ ```
80
+
81
+ ## Options
82
+
83
+ All commands support:
84
+
85
+ - `-s, --site <domain>` - Query a specific site
86
+ - `--json` - Output raw JSON
87
+ - `--no-revenue` - Exclude revenue metrics
88
+ - `-f, --filter <filter>` - Filter data (format: `field:operator:value`)
89
+
90
+ ### Filters
91
+
92
+ ```bash
93
+ -f "country:is:US"
94
+ -f "page:contains:/blog"
95
+ -f "device:is:mobile"
96
+ -f "referrer:is:twitter.com"
97
+ ```
98
+
99
+ ## Multi-site Support
100
+
101
+ ```bash
102
+ supalytics login <api-key> # Add a site
103
+ supalytics sites # List all sites
104
+ supalytics default <domain> # Set default site
105
+ supalytics stats -s other.com # Query specific site
106
+ ```
107
+
108
+ ## License
109
+
110
+ Apache-2.0
package/bin/supalytics ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/index.ts";
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@supalytics/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Supalytics web analytics",
5
+ "type": "module",
6
+ "bin": {
7
+ "supalytics": "./bin/supalytics"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "bin"
12
+ ],
13
+ "scripts": {
14
+ "dev": "bun run src/index.ts"
15
+ },
16
+ "keywords": [
17
+ "analytics",
18
+ "web-analytics",
19
+ "cli",
20
+ "supalytics",
21
+ "privacy"
22
+ ],
23
+ "author": "Supalytics",
24
+ "license": "Apache-2.0",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/yogesharc/supalytics-cli"
28
+ },
29
+ "homepage": "https://supalytics.co",
30
+ "engines": {
31
+ "bun": ">=1.0.0"
32
+ },
33
+ "dependencies": {
34
+ "chalk": "^5.6.2",
35
+ "commander": "^14.0.2"
36
+ }
37
+ }
package/src/api.ts ADDED
@@ -0,0 +1,311 @@
1
+ import { getApiKeyForSite, getSites } from "./config";
2
+
3
+ const API_BASE = process.env.SUPALYTICS_API_URL || "https://api.supalytics.co";
4
+
5
+ export interface QueryRequest {
6
+ metrics: string[];
7
+ date_range?: string | [string, string];
8
+ dimensions?: string[];
9
+ filters?: [string, string, string][]; // [field, operator, value]
10
+ sort?: { field: string; order: "asc" | "desc" };
11
+ timezone?: string;
12
+ limit?: number;
13
+ offset?: number;
14
+ include_revenue?: boolean;
15
+ }
16
+
17
+ export interface QueryResult {
18
+ dimensions: Record<string, string>;
19
+ metrics: Record<string, number>;
20
+ }
21
+
22
+ export interface QueryResponse {
23
+ data: QueryResult[];
24
+ meta: {
25
+ domain: string;
26
+ date_range: [string, string];
27
+ timezone: string;
28
+ query_ms: number;
29
+ };
30
+ pagination: {
31
+ limit: number;
32
+ offset: number;
33
+ has_more: boolean;
34
+ };
35
+ query: {
36
+ metrics: string[];
37
+ dimensions?: string[];
38
+ date_range: string | [string, string];
39
+ filters?: [string, string, string][];
40
+ sort?: { field: string; order: "asc" | "desc" };
41
+ timezone: string;
42
+ };
43
+ }
44
+
45
+ export interface ApiError {
46
+ error: string;
47
+ message?: string;
48
+ }
49
+
50
+ /**
51
+ * Query analytics data
52
+ */
53
+ export async function query(
54
+ site: string,
55
+ request: QueryRequest
56
+ ): Promise<QueryResponse> {
57
+ const apiKey = await getApiKeyForSite(site);
58
+
59
+ if (!apiKey) {
60
+ const sites = await getSites();
61
+ if (sites.length === 0) {
62
+ throw new Error(
63
+ "No sites configured. Run `supalytics login <api-key>` to add a site."
64
+ );
65
+ }
66
+ throw new Error(
67
+ `No API key found for '${site}'. Available sites: ${sites.join(", ")}`
68
+ );
69
+ }
70
+
71
+ const response = await fetch(`${API_BASE}/v1/query`, {
72
+ method: "POST",
73
+ headers: {
74
+ Authorization: `Bearer ${apiKey}`,
75
+ "Content-Type": "application/json",
76
+ },
77
+ body: JSON.stringify(request),
78
+ });
79
+
80
+ if (!response.ok) {
81
+ const error = (await response.json()) as ApiError;
82
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
83
+ }
84
+
85
+ return response.json();
86
+ }
87
+
88
+ export function formatNumber(num: number): string {
89
+ if (num >= 1_000_000) {
90
+ return (num / 1_000_000).toFixed(1) + "M";
91
+ }
92
+ if (num >= 1_000) {
93
+ return (num / 1_000).toFixed(1) + "K";
94
+ }
95
+ return num.toString();
96
+ }
97
+
98
+ export function formatDuration(seconds: number): string {
99
+ if (seconds < 60) {
100
+ return `${Math.round(seconds)}s`;
101
+ }
102
+ const mins = Math.floor(seconds / 60);
103
+ const secs = Math.round(seconds % 60);
104
+ return `${mins}m ${secs}s`;
105
+ }
106
+
107
+ export function formatPercent(value: number): string {
108
+ return `${value.toFixed(1)}%`;
109
+ }
110
+
111
+ // Events API types
112
+ export interface EventItem {
113
+ name: string;
114
+ count: number;
115
+ visitors: number;
116
+ has_properties: boolean;
117
+ }
118
+
119
+ export interface EventsResponse {
120
+ data: EventItem[];
121
+ meta: {
122
+ domain: string;
123
+ date_range: [string, string];
124
+ query_ms: number;
125
+ };
126
+ }
127
+
128
+ export interface PropertyKeysResponse {
129
+ data: string[];
130
+ meta: {
131
+ domain: string;
132
+ event: string;
133
+ date_range: [string, string];
134
+ query_ms: number;
135
+ };
136
+ }
137
+
138
+ export interface PropertyValue {
139
+ value: string;
140
+ count: number;
141
+ visitors: number;
142
+ revenue: number | null;
143
+ }
144
+
145
+ export interface PropertyBreakdownResponse {
146
+ data: PropertyValue[];
147
+ meta: {
148
+ domain: string;
149
+ event: string;
150
+ property: string;
151
+ date_range: [string, string];
152
+ query_ms: number;
153
+ };
154
+ }
155
+
156
+ /**
157
+ * List all events
158
+ */
159
+ export async function listEvents(
160
+ site: string,
161
+ period: string = "30d",
162
+ limit: number = 100
163
+ ): Promise<EventsResponse> {
164
+ const apiKey = await getApiKeyForSite(site);
165
+
166
+ if (!apiKey) {
167
+ const sites = await getSites();
168
+ if (sites.length === 0) {
169
+ throw new Error(
170
+ "No sites configured. Run `supalytics login <api-key>` to add a site."
171
+ );
172
+ }
173
+ throw new Error(
174
+ `No API key found for '${site}'. Available sites: ${sites.join(", ")}`
175
+ );
176
+ }
177
+
178
+ const params = new URLSearchParams({ period, limit: String(limit) });
179
+ const response = await fetch(`${API_BASE}/v1/events?${params}`, {
180
+ headers: { Authorization: `Bearer ${apiKey}` },
181
+ });
182
+
183
+ if (!response.ok) {
184
+ const error = (await response.json()) as ApiError;
185
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
186
+ }
187
+
188
+ return response.json();
189
+ }
190
+
191
+ /**
192
+ * Get property keys for an event
193
+ */
194
+ export async function getEventProperties(
195
+ site: string,
196
+ eventName: string,
197
+ period: string = "30d"
198
+ ): Promise<PropertyKeysResponse> {
199
+ const apiKey = await getApiKeyForSite(site);
200
+
201
+ if (!apiKey) {
202
+ throw new Error(`No API key found for '${site}'.`);
203
+ }
204
+
205
+ const params = new URLSearchParams({ period });
206
+ const response = await fetch(
207
+ `${API_BASE}/v1/events/${encodeURIComponent(eventName)}/properties?${params}`,
208
+ { headers: { Authorization: `Bearer ${apiKey}` } }
209
+ );
210
+
211
+ if (!response.ok) {
212
+ const error = (await response.json()) as ApiError;
213
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
214
+ }
215
+
216
+ return response.json();
217
+ }
218
+
219
+ /**
220
+ * Get property value breakdown
221
+ */
222
+ export async function getPropertyBreakdown(
223
+ site: string,
224
+ eventName: string,
225
+ propertyKey: string,
226
+ period: string = "30d",
227
+ limit: number = 100,
228
+ includeRevenue: boolean = false
229
+ ): Promise<PropertyBreakdownResponse> {
230
+ const apiKey = await getApiKeyForSite(site);
231
+
232
+ if (!apiKey) {
233
+ throw new Error(`No API key found for '${site}'.`);
234
+ }
235
+
236
+ const params = new URLSearchParams({
237
+ period,
238
+ limit: String(limit),
239
+ include_revenue: String(includeRevenue),
240
+ });
241
+ const response = await fetch(
242
+ `${API_BASE}/v1/events/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propertyKey)}?${params}`,
243
+ { headers: { Authorization: `Bearer ${apiKey}` } }
244
+ );
245
+
246
+ if (!response.ok) {
247
+ const error = (await response.json()) as ApiError;
248
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
249
+ }
250
+
251
+ return response.json();
252
+ }
253
+
254
+ // Realtime API types
255
+ export interface RealtimePageItem {
256
+ page: string;
257
+ visitors: number;
258
+ }
259
+
260
+ export interface RealtimeCountryItem {
261
+ country: string;
262
+ visitors: number;
263
+ }
264
+
265
+ export interface RealtimeReferrerItem {
266
+ referrer: string;
267
+ visitors: number;
268
+ }
269
+
270
+ export interface RealtimeResponse {
271
+ data: {
272
+ active_visitors: number;
273
+ pages: RealtimePageItem[];
274
+ countries: RealtimeCountryItem[];
275
+ referrers: RealtimeReferrerItem[];
276
+ };
277
+ meta: {
278
+ domain: string;
279
+ timestamp: string;
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Get realtime visitors
285
+ */
286
+ export async function getRealtime(site: string): Promise<RealtimeResponse> {
287
+ const apiKey = await getApiKeyForSite(site);
288
+
289
+ if (!apiKey) {
290
+ const sites = await getSites();
291
+ if (sites.length === 0) {
292
+ throw new Error(
293
+ "No sites configured. Run `supalytics login <api-key>` to add a site."
294
+ );
295
+ }
296
+ throw new Error(
297
+ `No API key found for '${site}'. Available sites: ${sites.join(", ")}`
298
+ );
299
+ }
300
+
301
+ const response = await fetch(`${API_BASE}/v1/realtime`, {
302
+ headers: { Authorization: `Bearer ${apiKey}` },
303
+ });
304
+
305
+ if (!response.ok) {
306
+ const error = (await response.json()) as ApiError;
307
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
308
+ }
309
+
310
+ return response.json();
311
+ }
@@ -0,0 +1,93 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { query, formatNumber } from "../api";
4
+ import { getDefaultSite } from "../config";
5
+ import { sparkBar, formatRevenue } from "../ui";
6
+
7
+ export const countriesCommand = new Command("countries")
8
+ .description("Traffic by country")
9
+ .option("-s, --site <site>", "Site to query")
10
+ .option("-p, --period <period>", "Time period (7d, 30d, 90d, 12mo, all)", "30d")
11
+ .option("--start <date>", "Start date (YYYY-MM-DD)")
12
+ .option("--end <date>", "End date (YYYY-MM-DD)")
13
+ .option("-l, --limit <number>", "Number of results", "10")
14
+ .option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
15
+ .option("--no-revenue", "Exclude revenue metrics")
16
+ .option("--json", "Output as JSON")
17
+ .action(async (options) => {
18
+ const site = options.site || (await getDefaultSite());
19
+
20
+ if (!site) {
21
+ console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
22
+ process.exit(1);
23
+ }
24
+
25
+ const dateRange = options.start && options.end
26
+ ? [options.start, options.end] as [string, string]
27
+ : options.period;
28
+
29
+ const filters = options.filter
30
+ ? options.filter.map((f: string) => {
31
+ const parts = f.split(":");
32
+ if (parts.length < 3) {
33
+ console.error(chalk.red(`Invalid filter format: ${f}. Use 'field:operator:value'`));
34
+ process.exit(1);
35
+ }
36
+ const [field, operator, ...valueParts] = parts;
37
+ return [field, operator, valueParts.join(":")] as [string, string, string];
38
+ })
39
+ : undefined;
40
+
41
+ try {
42
+ const metrics = ["visitors"];
43
+ if (options.revenue !== false) {
44
+ metrics.push("revenue");
45
+ }
46
+
47
+ const response = await query(site, {
48
+ metrics,
49
+ dimensions: ["country"],
50
+ filters,
51
+ date_range: dateRange,
52
+ limit: parseInt(options.limit),
53
+ include_revenue: options.revenue !== false,
54
+ });
55
+
56
+ if (options.json) {
57
+ console.log(JSON.stringify(response, null, 2));
58
+ return;
59
+ }
60
+
61
+ const [startDate, endDate] = response.meta.date_range;
62
+
63
+ console.log();
64
+ console.log(chalk.bold(" 🌍 Countries"), chalk.dim(`${startDate} → ${endDate}`));
65
+ console.log();
66
+
67
+ if (response.data.length === 0) {
68
+ console.log(chalk.dim(" No data"));
69
+ console.log();
70
+ return;
71
+ }
72
+
73
+ const maxVisitors = Math.max(...response.data.map(r => r.metrics.visitors || 0), 1);
74
+
75
+ for (const result of response.data) {
76
+ const country = result.dimensions?.country || "Unknown";
77
+ const visitors = result.metrics.visitors || 0;
78
+ const bar = sparkBar(visitors, maxVisitors);
79
+
80
+ let line = ` ${country.padEnd(34)} ${bar} ${formatNumber(visitors).padStart(6)}`;
81
+
82
+ if (options.revenue !== false && result.metrics.revenue != null) {
83
+ line += ` ${formatRevenue(result.metrics.revenue)}`;
84
+ }
85
+
86
+ console.log(line);
87
+ }
88
+ console.log();
89
+ } catch (error) {
90
+ console.error(chalk.red(`Error: ${(error as Error).message}`));
91
+ process.exit(1);
92
+ }
93
+ });
@@ -0,0 +1,133 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { listEvents, getEventProperties, getPropertyBreakdown, formatNumber, PropertyKeysResponse } from "../api";
4
+ import { getDefaultSite } from "../config";
5
+
6
+ const eventsDescription = `List and explore custom events.
7
+
8
+ Examples:
9
+ # List all events
10
+ supalytics events
11
+
12
+ # List properties for an event
13
+ supalytics events signup
14
+
15
+ # Get breakdown of a property
16
+ supalytics events signup --property plan
17
+
18
+ # With revenue
19
+ supalytics events signup --property plan --revenue`;
20
+
21
+ function displayPropertyKeys(response: PropertyKeysResponse, event: string, json: boolean) {
22
+ if (json) {
23
+ console.log(JSON.stringify(response, null, 2));
24
+ return;
25
+ }
26
+
27
+ console.log();
28
+ console.log(chalk.bold(`Properties for "${event}"`));
29
+ console.log();
30
+
31
+ if (response.data.length === 0) {
32
+ console.log(chalk.dim(" No properties found"));
33
+ return;
34
+ }
35
+
36
+ for (const key of response.data) {
37
+ console.log(` ${chalk.cyan(key)}`);
38
+ }
39
+ console.log();
40
+ console.log(chalk.dim(` Use: supalytics events ${event} --property <key>`));
41
+ console.log();
42
+ }
43
+
44
+ export const eventsCommand = new Command("events")
45
+ .description(eventsDescription)
46
+ .argument("[event]", "Event name to explore")
47
+ .option("-s, --site <site>", "Site to query")
48
+ .option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
49
+ .option("--property <key>", "Get breakdown for a specific property")
50
+ .option("-l, --limit <number>", "Number of results", "20")
51
+ .option("--revenue", "Include revenue in property breakdown")
52
+ .option("--json", "Output as JSON")
53
+ .action(async (event, options) => {
54
+ const site = options.site || (await getDefaultSite());
55
+
56
+ if (!site) {
57
+ console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
58
+ process.exit(1);
59
+ }
60
+
61
+ try {
62
+ // If no event specified, list all events
63
+ if (!event) {
64
+ const response = await listEvents(site, options.period, parseInt(options.limit));
65
+
66
+ if (options.json) {
67
+ console.log(JSON.stringify(response, null, 2));
68
+ return;
69
+ }
70
+
71
+ const [startDate, endDate] = response.meta.date_range;
72
+ console.log();
73
+ console.log(chalk.bold("Events"), chalk.dim(`${startDate} → ${endDate}`));
74
+ console.log();
75
+
76
+ if (response.data.length === 0) {
77
+ console.log(chalk.dim(" No events found"));
78
+ return;
79
+ }
80
+
81
+ for (const e of response.data) {
82
+ const props = e.has_properties ? chalk.dim(" [has properties]") : "";
83
+ console.log(` ${chalk.cyan(e.name)} ${formatNumber(e.visitors)} visitors ${formatNumber(e.count)} events${props}`);
84
+ }
85
+ console.log();
86
+ return;
87
+ }
88
+
89
+ // If --property flag, get breakdown
90
+ if (options.property) {
91
+ const response = await getPropertyBreakdown(
92
+ site,
93
+ event,
94
+ options.property,
95
+ options.period,
96
+ parseInt(options.limit),
97
+ options.revenue
98
+ );
99
+
100
+ if (options.json) {
101
+ console.log(JSON.stringify(response, null, 2));
102
+ return;
103
+ }
104
+
105
+ console.log();
106
+ console.log(chalk.bold(`${event}.${options.property}`), chalk.dim(`${response.meta.date_range[0]} → ${response.meta.date_range[1]}`));
107
+ console.log();
108
+
109
+ if (response.data.length === 0) {
110
+ console.log(chalk.dim(" No values found"));
111
+ return;
112
+ }
113
+
114
+ for (const v of response.data) {
115
+ let line = ` ${chalk.cyan(v.value)} ${formatNumber(v.visitors)} visitors ${formatNumber(v.count)} events`;
116
+ if (options.revenue && v.revenue !== null) {
117
+ line += ` ${chalk.green("$" + (v.revenue / 100).toFixed(2))}`;
118
+ }
119
+ console.log(line);
120
+ }
121
+ console.log();
122
+ return;
123
+ }
124
+
125
+ // If just event name, show properties
126
+ const response = await getEventProperties(site, event, options.period);
127
+ displayPropertyKeys(response, event, options.json);
128
+
129
+ } catch (error) {
130
+ console.error(chalk.red(`Error: ${(error as Error).message}`));
131
+ process.exit(1);
132
+ }
133
+ });