@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.
@@ -0,0 +1,104 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { query, formatNumber, formatPercent, formatDuration } from "../api";
4
+ import { getDefaultSite } from "../config";
5
+ import { logo, parsePeriod } from "../ui";
6
+
7
+ export const statsCommand = new Command("stats")
8
+ .description("Overview stats: pageviews, visitors, bounce rate, revenue")
9
+ .argument("[period]", "Time period: today, yesterday, week, month, year, 7d, 30d, 90d, 12mo, all", "30d")
10
+ .option("-s, --site <site>", "Site to query")
11
+ .option("--start <date>", "Start date (YYYY-MM-DD)")
12
+ .option("--end <date>", "End date (YYYY-MM-DD)")
13
+ .option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
14
+ .option("--no-revenue", "Exclude revenue metrics")
15
+ .option("--json", "Output as JSON")
16
+ .action(async (period, options) => {
17
+ const site = options.site || (await getDefaultSite());
18
+
19
+ if (!site) {
20
+ console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
21
+ process.exit(1);
22
+ }
23
+
24
+ const dateRange = options.start && options.end
25
+ ? [options.start, options.end] as [string, string]
26
+ : parsePeriod(period);
27
+
28
+ const filters = options.filter
29
+ ? options.filter.map((f: string) => {
30
+ const parts = f.split(":");
31
+ if (parts.length < 3) {
32
+ console.error(chalk.red(`Invalid filter format: ${f}. Use 'field:operator:value'`));
33
+ process.exit(1);
34
+ }
35
+ const [field, operator, ...valueParts] = parts;
36
+ return [field, operator, valueParts.join(":")] as [string, string, string];
37
+ })
38
+ : undefined;
39
+
40
+ try {
41
+ const metrics = ["pageviews", "visitors", "bounce_rate", "avg_session_duration"];
42
+ if (options.revenue !== false) {
43
+ metrics.push("revenue", "conversions", "conversion_rate");
44
+ }
45
+
46
+ const response = await query(site, {
47
+ metrics,
48
+ filters,
49
+ date_range: dateRange,
50
+ include_revenue: options.revenue !== false,
51
+ });
52
+
53
+ if (options.json) {
54
+ console.log(JSON.stringify(response, null, 2));
55
+ return;
56
+ }
57
+
58
+ const m = response.data[0]?.metrics || {};
59
+ const [startDate, endDate] = response.meta.date_range;
60
+
61
+ // Logo
62
+ console.log(logo());
63
+
64
+ // Site and date range
65
+ console.log(chalk.bold(` ${site}`));
66
+ console.log(chalk.dim(` ${startDate} → ${endDate}`));
67
+ console.log();
68
+
69
+ // Main metrics - clean 2x2 grid
70
+ const col = 20;
71
+
72
+ const visitors = formatNumber(m.visitors || 0);
73
+ const pageviews = formatNumber(m.pageviews || 0);
74
+ const bounceRate = formatPercent(m.bounce_rate || 0);
75
+ const duration = formatDuration(m.avg_session_duration || 0);
76
+
77
+ console.log(` ${"VISITORS".padEnd(col)}${"PAGEVIEWS"}`);
78
+ console.log(` ${chalk.bold(visitors.padEnd(col))}${chalk.bold(pageviews)}`);
79
+ console.log();
80
+ console.log(` ${"BOUNCE RATE".padEnd(col)}${"AVG DURATION"}`);
81
+ console.log(` ${chalk.bold(bounceRate.padEnd(col))}${chalk.bold(duration)}`);
82
+
83
+ // Revenue section
84
+ if (options.revenue !== false && m.revenue !== undefined) {
85
+ const revenue = "$" + (m.revenue / 100).toFixed(2);
86
+ const conversions = String(m.conversions || 0);
87
+ const convRate = formatPercent(m.conversion_rate || 0);
88
+
89
+ console.log();
90
+ console.log(chalk.dim(" ────────────────────────────────────────"));
91
+ console.log();
92
+ console.log(` ${"REVENUE".padEnd(col)}${"CONVERSIONS"}`);
93
+ console.log(` ${chalk.green.bold(revenue.padEnd(col))}${chalk.bold(conversions)}`);
94
+ console.log();
95
+ console.log(` CONVERSION RATE`);
96
+ console.log(` ${chalk.bold(convRate)}`);
97
+ }
98
+
99
+ console.log();
100
+ } catch (error) {
101
+ console.error(chalk.red(`Error: ${(error as Error).message}`));
102
+ process.exit(1);
103
+ }
104
+ });
@@ -0,0 +1,95 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { query, formatNumber } from "../api";
4
+ import { getDefaultSite } from "../config";
5
+
6
+ export const trendCommand = new Command("trend")
7
+ .description("Daily visitor trend")
8
+ .option("-s, --site <site>", "Site to query")
9
+ .option("-p, --period <period>", "Time period (7d, 30d, 90d, 12mo, all)", "30d")
10
+ .option("--start <date>", "Start date (YYYY-MM-DD)")
11
+ .option("--end <date>", "End date (YYYY-MM-DD)")
12
+ .option("-f, --filter <filters...>", "Filters in format 'field:operator:value' (e.g., 'is:country:US')")
13
+ .option("--no-revenue", "Exclude revenue metrics")
14
+ .option("--json", "Output as JSON")
15
+ .action(async (options) => {
16
+ const site = options.site || (await getDefaultSite());
17
+
18
+ if (!site) {
19
+ console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
20
+ process.exit(1);
21
+ }
22
+
23
+ const dateRange = options.start && options.end
24
+ ? [options.start, options.end] as [string, string]
25
+ : options.period;
26
+
27
+ // Parse filters
28
+ const filters = options.filter
29
+ ? options.filter.map((f: string) => {
30
+ const parts = f.split(":");
31
+ if (parts.length < 3) {
32
+ console.error(chalk.red(`Invalid filter format: ${f}. Use 'field:operator:value'`));
33
+ process.exit(1);
34
+ }
35
+ const [field, operator, ...valueParts] = parts;
36
+ return [field, operator, valueParts.join(":")] as [string, string, string];
37
+ })
38
+ : undefined;
39
+
40
+ try {
41
+ const metrics = ["visitors"];
42
+ if (options.revenue !== false) {
43
+ metrics.push("revenue");
44
+ }
45
+
46
+ const response = await query(site, {
47
+ metrics,
48
+ dimensions: ["date"],
49
+ filters,
50
+ date_range: dateRange,
51
+ limit: 1000, // Get all days
52
+ include_revenue: options.revenue !== false,
53
+ });
54
+
55
+ if (options.json) {
56
+ console.log(JSON.stringify(response, null, 2));
57
+ return;
58
+ }
59
+
60
+ const [startDate, endDate] = response.meta.date_range;
61
+
62
+ console.log();
63
+ console.log(chalk.bold("Daily Trend"), chalk.dim(`${startDate} → ${endDate}`));
64
+ console.log();
65
+
66
+ if (response.data.length === 0) {
67
+ console.log(chalk.dim(" No data"));
68
+ return;
69
+ }
70
+
71
+ // Find max for sparkline scaling
72
+ const maxVisitors = Math.max(...response.data.map((r) => r.metrics.visitors || 0));
73
+
74
+ for (const result of response.data) {
75
+ const date = result.dimensions?.date || "";
76
+ const visitors = result.metrics.visitors || 0;
77
+
78
+ // Simple bar chart
79
+ const barLength = maxVisitors > 0 ? Math.round((visitors / maxVisitors) * 20) : 0;
80
+ const bar = "█".repeat(barLength) + chalk.dim("░".repeat(20 - barLength));
81
+
82
+ let line = ` ${date} ${bar} ${formatNumber(visitors).padStart(5)}`;
83
+
84
+ if (options.revenue !== false && result.metrics.revenue != null) {
85
+ line += ` ${chalk.green("$" + (result.metrics.revenue / 100).toFixed(0))}`;
86
+ }
87
+
88
+ console.log(line);
89
+ }
90
+ console.log();
91
+ } catch (error) {
92
+ console.error(chalk.red(`Error: ${(error as Error).message}`));
93
+ process.exit(1);
94
+ }
95
+ });
package/src/config.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+
4
+ const CONFIG_DIR = join(homedir(), ".supalytics");
5
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
6
+
7
+ interface SiteConfig {
8
+ apiKey: string;
9
+ }
10
+
11
+ interface Config {
12
+ // Map of domain -> API key
13
+ sites: Record<string, SiteConfig>;
14
+ // Default site domain
15
+ defaultSite?: string;
16
+ }
17
+
18
+ export async function getConfig(): Promise<Config> {
19
+ try {
20
+ const file = Bun.file(CONFIG_FILE);
21
+ if (await file.exists()) {
22
+ const config = await file.json();
23
+ // Migrate old config format if needed
24
+ if (config.apiKey && !config.sites) {
25
+ return {
26
+ sites: {},
27
+ defaultSite: config.defaultSite,
28
+ };
29
+ }
30
+ return { sites: {}, ...config };
31
+ }
32
+ } catch {
33
+ // Config doesn't exist or is invalid
34
+ }
35
+ return { sites: {} };
36
+ }
37
+
38
+ export async function saveConfig(config: Config): Promise<void> {
39
+ await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
40
+ }
41
+
42
+ export async function addSite(domain: string, apiKey: string): Promise<void> {
43
+ const config = await getConfig();
44
+ config.sites[domain] = { apiKey };
45
+ // Set as default if it's the first site
46
+ if (!config.defaultSite || Object.keys(config.sites).length === 1) {
47
+ config.defaultSite = domain;
48
+ }
49
+ await saveConfig(config);
50
+ }
51
+
52
+ export async function removeSite(domain: string): Promise<boolean> {
53
+ const config = await getConfig();
54
+ if (!config.sites[domain]) {
55
+ return false;
56
+ }
57
+ delete config.sites[domain];
58
+ // Update default if we removed the default site
59
+ if (config.defaultSite === domain) {
60
+ const remaining = Object.keys(config.sites);
61
+ config.defaultSite = remaining.length > 0 ? remaining[0] : undefined;
62
+ }
63
+ await saveConfig(config);
64
+ return true;
65
+ }
66
+
67
+ export async function setDefaultSite(domain: string): Promise<boolean> {
68
+ const config = await getConfig();
69
+ if (!config.sites[domain]) {
70
+ return false;
71
+ }
72
+ config.defaultSite = domain;
73
+ await saveConfig(config);
74
+ return true;
75
+ }
76
+
77
+ export async function getApiKeyForSite(domain: string): Promise<string | undefined> {
78
+ // Check env var first
79
+ if (process.env.SUPALYTICS_API_KEY) {
80
+ return process.env.SUPALYTICS_API_KEY;
81
+ }
82
+ const config = await getConfig();
83
+ return config.sites[domain]?.apiKey;
84
+ }
85
+
86
+ export async function getDefaultSite(): Promise<string | undefined> {
87
+ const config = await getConfig();
88
+ return config.defaultSite;
89
+ }
90
+
91
+ export async function getSites(): Promise<string[]> {
92
+ const config = await getConfig();
93
+ return Object.keys(config.sites);
94
+ }
95
+
96
+ export { CONFIG_DIR, CONFIG_FILE };
package/src/index.ts ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bun
2
+ import { program } from "commander";
3
+ import { loginCommand } from "./commands/login";
4
+ import { logoutCommand } from "./commands/logout";
5
+ import { sitesCommand, defaultCommand, removeCommand } from "./commands/sites";
6
+ import { statsCommand } from "./commands/stats";
7
+ import { pagesCommand } from "./commands/pages";
8
+ import { referrersCommand } from "./commands/referrers";
9
+ import { countriesCommand } from "./commands/countries";
10
+ import { trendCommand } from "./commands/trend";
11
+ import { queryCommand } from "./commands/query";
12
+ import { eventsCommand } from "./commands/events";
13
+ import { realtimeCommand } from "./commands/realtime";
14
+
15
+ const description = `CLI for Supalytics web analytics.
16
+
17
+ Multi-site Support:
18
+ Add sites: supalytics login <api-key> (auto-detects domain from API key)
19
+ List sites: supalytics sites
20
+ Set default: supalytics default <domain>
21
+ Remove site: supalytics remove <domain>
22
+ Query other: supalytics stats --site <domain>
23
+
24
+ API keys start with 'sly_' and can be created at https://supalytics.co/settings/api
25
+
26
+ Date Ranges:
27
+ --period: 7d, 14d, 30d, 90d, 12mo, all (default: 30d)
28
+ --start/--end: Custom range in YYYY-MM-DD format
29
+
30
+ Filters (-f, --filter):
31
+ Format: field:operator:value
32
+
33
+ Fields:
34
+ page, country, region, city, browser, os, device,
35
+ referrer, utm_source, utm_medium, utm_campaign, utm_content, utm_term,
36
+ event, event_property, exit_link
37
+
38
+ Operators:
39
+ is Exact match (supports comma-separated: "country:is:US,UK")
40
+ is_not Exclude exact match
41
+ contains Substring match
42
+ not_contains Exclude substring
43
+ starts_with Prefix match
44
+
45
+ Examples:
46
+ -f "country:is:US"
47
+ -f "page:contains:/blog"
48
+ -f "device:is:mobile"
49
+ -f "referrer:is:twitter.com"
50
+ -f "event:is:signup"
51
+ -f "event_property:is:plan:premium"
52
+
53
+ Output:
54
+ --json Raw JSON output (useful for piping to other tools or AI)
55
+ --no-revenue Exclude revenue metrics from output`;
56
+
57
+ program
58
+ .name("supalytics")
59
+ .description(description)
60
+ .version("0.1.0");
61
+
62
+ // Auth & site management commands
63
+ program.addCommand(loginCommand);
64
+ program.addCommand(logoutCommand);
65
+ program.addCommand(sitesCommand);
66
+ program.addCommand(defaultCommand);
67
+ program.addCommand(removeCommand);
68
+
69
+ // Analytics commands
70
+ program.addCommand(statsCommand);
71
+ program.addCommand(pagesCommand);
72
+ program.addCommand(referrersCommand);
73
+ program.addCommand(countriesCommand);
74
+ program.addCommand(trendCommand);
75
+ program.addCommand(queryCommand);
76
+ program.addCommand(eventsCommand);
77
+ program.addCommand(realtimeCommand);
78
+
79
+ program.parse();
package/src/ui.ts ADDED
@@ -0,0 +1,79 @@
1
+ import chalk from "chalk";
2
+
3
+ // Brand color
4
+ const brand = chalk.hex("#FF2500");
5
+
6
+ // Parse friendly period names into date ranges
7
+ export function parsePeriod(period: string): string | [string, string] {
8
+ const today = new Date();
9
+ const yyyy = (d: Date) => d.toISOString().split("T")[0];
10
+
11
+ switch (period.toLowerCase()) {
12
+ case "today":
13
+ return [yyyy(today), yyyy(today)];
14
+ case "yesterday": {
15
+ const yesterday = new Date(today);
16
+ yesterday.setDate(yesterday.getDate() - 1);
17
+ return [yyyy(yesterday), yyyy(yesterday)];
18
+ }
19
+ case "week":
20
+ case "this week": {
21
+ const startOfWeek = new Date(today);
22
+ startOfWeek.setDate(today.getDate() - today.getDay());
23
+ return [yyyy(startOfWeek), yyyy(today)];
24
+ }
25
+ case "month":
26
+ case "this month": {
27
+ const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
28
+ return [yyyy(startOfMonth), yyyy(today)];
29
+ }
30
+ case "year":
31
+ case "this year": {
32
+ const startOfYear = new Date(today.getFullYear(), 0, 1);
33
+ return [yyyy(startOfYear), yyyy(today)];
34
+ }
35
+ case "last week": {
36
+ const endOfLastWeek = new Date(today);
37
+ endOfLastWeek.setDate(today.getDate() - today.getDay() - 1);
38
+ const startOfLastWeek = new Date(endOfLastWeek);
39
+ startOfLastWeek.setDate(endOfLastWeek.getDate() - 6);
40
+ return [yyyy(startOfLastWeek), yyyy(endOfLastWeek)];
41
+ }
42
+ case "last month": {
43
+ const startOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
44
+ const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
45
+ return [yyyy(startOfLastMonth), yyyy(endOfLastMonth)];
46
+ }
47
+ default:
48
+ // Return as-is for 7d, 30d, etc.
49
+ return period;
50
+ }
51
+ }
52
+
53
+ // ASCII logo
54
+ export function logo(): string {
55
+ return `
56
+ ${brand("░█▀▀░█░█░█▀█░█▀█░█░░░█░█░▀█▀░▀█▀░█▀▀░█▀▀")}
57
+ ${brand("░▀▀█░█░█░█▀▀░█▀█░█░░░░█░░░█░░░█░░█░░░▀▀█")}
58
+ ${brand("░▀▀▀░▀▀▀░▀░░░▀░▀░▀▀▀░░▀░░░▀░░▀▀▀░▀▀▀░▀▀▀")}
59
+ `;
60
+ }
61
+
62
+ export function sparkBar(value: number, max: number, width: number = 15): string {
63
+ if (max === 0) return chalk.dim("░".repeat(width));
64
+ const filled = Math.round((value / max) * width);
65
+ const empty = width - filled;
66
+ return chalk.cyan("█".repeat(filled)) + chalk.dim("░".repeat(empty));
67
+ }
68
+
69
+ export function truncate(str: string, max: number): string {
70
+ if (str.length <= max) return str;
71
+ return str.slice(0, max - 3) + "...";
72
+ }
73
+
74
+ export function formatRevenue(cents: number): string {
75
+ if (cents >= 100000) {
76
+ return chalk.green("$" + (cents / 100000).toFixed(1) + "K");
77
+ }
78
+ return chalk.green("$" + (cents / 100).toFixed(0));
79
+ }