@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 +110 -0
- package/bin/supalytics +2 -0
- package/package.json +37 -0
- package/src/api.ts +311 -0
- package/src/commands/countries.ts +93 -0
- package/src/commands/events.ts +133 -0
- package/src/commands/login.ts +83 -0
- package/src/commands/logout.ts +15 -0
- package/src/commands/pages.ts +93 -0
- package/src/commands/query.ts +162 -0
- package/src/commands/realtime.ts +110 -0
- package/src/commands/referrers.ts +95 -0
- package/src/commands/sites.ts +65 -0
- package/src/commands/stats.ts +104 -0
- package/src/commands/trend.ts +95 -0
- package/src/config.ts +96 -0
- package/src/index.ts +79 -0
- package/src/ui.ts +79 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { addSite, CONFIG_FILE, getConfig, setDefaultSite } from "../config";
|
|
4
|
+
import { mkdir } from "fs/promises";
|
|
5
|
+
import { dirname } from "path";
|
|
6
|
+
|
|
7
|
+
const API_BASE = process.env.SUPALYTICS_API_URL || "http://localhost:3000";
|
|
8
|
+
|
|
9
|
+
interface VerifyResponse {
|
|
10
|
+
domain: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function verifyApiKey(apiKey: string): Promise<VerifyResponse | null> {
|
|
14
|
+
try {
|
|
15
|
+
// Use a minimal analytics query to verify the key and get the domain
|
|
16
|
+
const response = await fetch(`${API_BASE}/v1/analytics`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: `Bearer ${apiKey}`,
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
metrics: ["pageviews"],
|
|
24
|
+
date_range: "7d",
|
|
25
|
+
limit: 1,
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const data = await response.json();
|
|
34
|
+
return { domain: data.meta.domain };
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const loginCommand = new Command("login")
|
|
41
|
+
.description("Add a site by verifying its API key. Can be called multiple times to add multiple sites.")
|
|
42
|
+
.argument("<api-key>", "Your API key (starts with sly_)")
|
|
43
|
+
.option("--default", "Set this site as the default")
|
|
44
|
+
.action(async (apiKey: string, options: { default?: boolean }) => {
|
|
45
|
+
apiKey = apiKey.trim();
|
|
46
|
+
|
|
47
|
+
// Validate API key format
|
|
48
|
+
if (!apiKey.startsWith("sly_")) {
|
|
49
|
+
console.error(chalk.red("Error: Invalid API key format. Keys start with 'sly_'"));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Verify API key with server
|
|
54
|
+
console.log(chalk.dim("Verifying API key..."));
|
|
55
|
+
const result = await verifyApiKey(apiKey);
|
|
56
|
+
|
|
57
|
+
if (!result) {
|
|
58
|
+
console.error(chalk.red("Error: Invalid API key or unable to verify"));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Ensure config directory exists
|
|
63
|
+
await mkdir(dirname(CONFIG_FILE), { recursive: true });
|
|
64
|
+
|
|
65
|
+
// Add site to config
|
|
66
|
+
await addSite(result.domain, apiKey);
|
|
67
|
+
|
|
68
|
+
// Set as default if requested
|
|
69
|
+
if (options.default) {
|
|
70
|
+
await setDefaultSite(result.domain);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const config = await getConfig();
|
|
74
|
+
const isDefault = config.defaultSite === result.domain;
|
|
75
|
+
const siteCount = Object.keys(config.sites).length;
|
|
76
|
+
|
|
77
|
+
console.log(chalk.green(`✓ Added ${result.domain}`) + (isDefault ? chalk.dim(" (default)") : ""));
|
|
78
|
+
console.log(chalk.dim(` ${siteCount} site${siteCount > 1 ? "s" : ""} configured`));
|
|
79
|
+
|
|
80
|
+
if (siteCount > 1 && !isDefault) {
|
|
81
|
+
console.log(chalk.dim(` Use --default flag or 'supalytics default ${result.domain}' to set as default`));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { CONFIG_FILE } from "../config";
|
|
4
|
+
import { unlink } from "fs/promises";
|
|
5
|
+
|
|
6
|
+
export const logoutCommand = new Command("logout")
|
|
7
|
+
.description("Remove stored credentials")
|
|
8
|
+
.action(async () => {
|
|
9
|
+
try {
|
|
10
|
+
await unlink(CONFIG_FILE);
|
|
11
|
+
console.log(chalk.green("✓ Logged out successfully"));
|
|
12
|
+
} catch {
|
|
13
|
+
console.log(chalk.dim("Already logged out"));
|
|
14
|
+
}
|
|
15
|
+
});
|
|
@@ -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, truncate, formatRevenue } from "../ui";
|
|
6
|
+
|
|
7
|
+
export const pagesCommand = new Command("pages")
|
|
8
|
+
.description("Top pages by visitors")
|
|
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: ["page"],
|
|
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(" 📄 Top Pages"), 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 page = truncate(result.dimensions?.page || "/", 32);
|
|
77
|
+
const visitors = result.metrics.visitors || 0;
|
|
78
|
+
const bar = sparkBar(visitors, maxVisitors);
|
|
79
|
+
|
|
80
|
+
let line = ` ${page.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,162 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { query, formatNumber, formatPercent, formatDuration } from "../api";
|
|
4
|
+
import { getDefaultSite } from "../config";
|
|
5
|
+
|
|
6
|
+
const queryDescription = `Flexible query with custom metrics and dimensions.
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
# Top pages with revenue
|
|
10
|
+
supalytics query -d page -m visitors,revenue
|
|
11
|
+
|
|
12
|
+
# Traffic by country and device
|
|
13
|
+
supalytics query -d country,device -m visitors
|
|
14
|
+
|
|
15
|
+
# Blog traffic from US
|
|
16
|
+
supalytics query -d page -f "page:contains:/blog" -f "country:is:US"
|
|
17
|
+
|
|
18
|
+
# Hourly breakdown for last 7 days
|
|
19
|
+
supalytics query -d hour -m visitors -p 7d
|
|
20
|
+
|
|
21
|
+
# UTM campaign performance
|
|
22
|
+
supalytics query -d utm_source,utm_campaign -m visitors,revenue
|
|
23
|
+
|
|
24
|
+
# Sort by revenue
|
|
25
|
+
supalytics query -d page --sort revenue:desc
|
|
26
|
+
|
|
27
|
+
# List all custom events
|
|
28
|
+
supalytics query -d event
|
|
29
|
+
|
|
30
|
+
# Top countries for visitors who signed up
|
|
31
|
+
supalytics query -d country -f "event:is:signup"
|
|
32
|
+
|
|
33
|
+
# Pages visited by premium users
|
|
34
|
+
supalytics query -d page -f "event_property:is:plan:premium"`;
|
|
35
|
+
|
|
36
|
+
export const queryCommand = new Command("query")
|
|
37
|
+
.description(queryDescription)
|
|
38
|
+
.option("-s, --site <site>", "Site to query")
|
|
39
|
+
.option("-m, --metrics <metrics>", "Metrics: visitors, bounce_rate, avg_session_duration, revenue, conversions", "visitors")
|
|
40
|
+
.option("-d, --dimensions <dimensions>", "Dimensions: page, referrer, country, region, city, browser, os, device, date, hour, event, utm_* (max 2, event cannot combine)")
|
|
41
|
+
.option("-f, --filter <filters...>", "Filters: field:operator:value (e.g., 'page:contains:/blog', 'event:is:signup', 'event_property:is:plan:premium')")
|
|
42
|
+
.option("--sort <sort>", "Sort by field:order (e.g., 'revenue:desc', 'visitors:asc')")
|
|
43
|
+
.option("--timezone <tz>", "Timezone for date grouping (e.g., 'America/New_York')", "UTC")
|
|
44
|
+
.option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
|
|
45
|
+
.option("--start <date>", "Start date (YYYY-MM-DD)")
|
|
46
|
+
.option("--end <date>", "End date (YYYY-MM-DD)")
|
|
47
|
+
.option("-l, --limit <number>", "Number of results (1-1000)", "10")
|
|
48
|
+
.option("--offset <number>", "Skip results for pagination", "0")
|
|
49
|
+
.option("--no-revenue", "Exclude revenue metrics")
|
|
50
|
+
.option("--json", "Output as JSON")
|
|
51
|
+
.action(async (options) => {
|
|
52
|
+
const site = options.site || (await getDefaultSite());
|
|
53
|
+
|
|
54
|
+
if (!site) {
|
|
55
|
+
console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const dateRange = options.start && options.end
|
|
60
|
+
? [options.start, options.end] as [string, string]
|
|
61
|
+
: options.period;
|
|
62
|
+
|
|
63
|
+
// Parse metrics
|
|
64
|
+
const metrics = options.metrics.split(",").map((m: string) => m.trim());
|
|
65
|
+
|
|
66
|
+
// Parse dimensions
|
|
67
|
+
const dimensions = options.dimensions
|
|
68
|
+
? options.dimensions.split(",").map((d: string) => d.trim())
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
// Parse filters - NEW FORMAT: field:operator:value
|
|
72
|
+
const filters = options.filter
|
|
73
|
+
? options.filter.map((f: string) => {
|
|
74
|
+
const parts = f.split(":");
|
|
75
|
+
if (parts.length < 3) {
|
|
76
|
+
console.error(chalk.red(`Invalid filter format: ${f}. Use 'field:operator:value'`));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const [field, operator, ...valueParts] = parts;
|
|
80
|
+
return [field, operator, valueParts.join(":")] as [string, string, string];
|
|
81
|
+
})
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
// Parse sort
|
|
85
|
+
const sort = options.sort
|
|
86
|
+
? (() => {
|
|
87
|
+
const [field, order] = options.sort.split(":");
|
|
88
|
+
if (!field || !order || (order !== "asc" && order !== "desc")) {
|
|
89
|
+
console.error(chalk.red(`Invalid sort format: ${options.sort}. Use 'field:order' where order is 'asc' or 'desc'`));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
return { field, order: order as "asc" | "desc" };
|
|
93
|
+
})()
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const response = await query(site, {
|
|
98
|
+
metrics,
|
|
99
|
+
dimensions,
|
|
100
|
+
filters,
|
|
101
|
+
sort,
|
|
102
|
+
timezone: options.timezone,
|
|
103
|
+
date_range: dateRange,
|
|
104
|
+
limit: parseInt(options.limit),
|
|
105
|
+
offset: parseInt(options.offset),
|
|
106
|
+
include_revenue: options.revenue !== false,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (options.json) {
|
|
110
|
+
console.log(JSON.stringify(response, null, 2));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [startDate, endDate] = response.meta.date_range;
|
|
115
|
+
|
|
116
|
+
console.log();
|
|
117
|
+
console.log(chalk.bold("Query Results"), chalk.dim(`${startDate} → ${endDate}`));
|
|
118
|
+
console.log(chalk.dim(` ${response.data.length} rows in ${response.meta.query_ms}ms`));
|
|
119
|
+
if (response.pagination.has_more) {
|
|
120
|
+
console.log(chalk.dim(` More results available (offset: ${response.pagination.offset + response.pagination.limit})`));
|
|
121
|
+
}
|
|
122
|
+
console.log();
|
|
123
|
+
|
|
124
|
+
if (response.data.length === 0) {
|
|
125
|
+
console.log(chalk.dim(" No data"));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Display results
|
|
130
|
+
for (const result of response.data) {
|
|
131
|
+
const parts: string[] = [];
|
|
132
|
+
|
|
133
|
+
// Dimensions
|
|
134
|
+
if (result.dimensions) {
|
|
135
|
+
for (const [key, value] of Object.entries(result.dimensions)) {
|
|
136
|
+
parts.push(`${chalk.dim(key)}=${value}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Metrics
|
|
141
|
+
for (const [key, value] of Object.entries(result.metrics)) {
|
|
142
|
+
let formatted: string;
|
|
143
|
+
if (key === "bounce_rate" || key === "conversion_rate") {
|
|
144
|
+
formatted = formatPercent(value);
|
|
145
|
+
} else if (key === "avg_session_duration") {
|
|
146
|
+
formatted = formatDuration(value);
|
|
147
|
+
} else if (key === "revenue") {
|
|
148
|
+
formatted = chalk.green("$" + (value / 100).toFixed(2));
|
|
149
|
+
} else {
|
|
150
|
+
formatted = formatNumber(value);
|
|
151
|
+
}
|
|
152
|
+
parts.push(`${chalk.dim(key)}=${formatted}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(` ${parts.join(" ")}`);
|
|
156
|
+
}
|
|
157
|
+
console.log();
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { getRealtime } from "../api";
|
|
4
|
+
import { getDefaultSite } from "../config";
|
|
5
|
+
import { sparkBar, truncate, logo } from "../ui";
|
|
6
|
+
|
|
7
|
+
export const realtimeCommand = new Command("realtime")
|
|
8
|
+
.description("Live visitors on your site right now")
|
|
9
|
+
.option("-s, --site <site>", "Site to query")
|
|
10
|
+
.option("--json", "Output as JSON")
|
|
11
|
+
.option("-w, --watch", "Auto-refresh every 30 seconds")
|
|
12
|
+
.action(async (options) => {
|
|
13
|
+
const site = options.site || (await getDefaultSite());
|
|
14
|
+
|
|
15
|
+
if (!site) {
|
|
16
|
+
console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const display = async () => {
|
|
21
|
+
try {
|
|
22
|
+
const response = await getRealtime(site);
|
|
23
|
+
|
|
24
|
+
if (options.json) {
|
|
25
|
+
console.log(JSON.stringify(response, null, 2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Clear screen in watch mode
|
|
30
|
+
if (options.watch) {
|
|
31
|
+
console.clear();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { data, meta } = response;
|
|
35
|
+
const time = new Date(meta.timestamp).toLocaleTimeString();
|
|
36
|
+
const maxVisitors = Math.max(
|
|
37
|
+
...data.pages.map(p => p.visitors),
|
|
38
|
+
...data.countries.map(c => c.visitors),
|
|
39
|
+
...data.referrers.map(r => r.visitors),
|
|
40
|
+
1
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Logo
|
|
44
|
+
console.log(logo());
|
|
45
|
+
|
|
46
|
+
// Site info
|
|
47
|
+
console.log(chalk.bold(` ${site}`));
|
|
48
|
+
console.log(chalk.dim(` Updated ${time}`));
|
|
49
|
+
console.log();
|
|
50
|
+
|
|
51
|
+
// Big visitor count
|
|
52
|
+
const visitorCount = String(data.active_visitors);
|
|
53
|
+
console.log(` ${chalk.green.bold(visitorCount)} ${chalk.dim("visitors right now")}`);
|
|
54
|
+
console.log();
|
|
55
|
+
|
|
56
|
+
// Top pages
|
|
57
|
+
if (data.pages.length > 0) {
|
|
58
|
+
console.log(chalk.dim(" PAGES"));
|
|
59
|
+
for (const p of data.pages.slice(0, 5)) {
|
|
60
|
+
const page = truncate(p.page, 36);
|
|
61
|
+
const bar = sparkBar(p.visitors, maxVisitors, 12);
|
|
62
|
+
console.log(` ${page.padEnd(38)} ${bar} ${chalk.bold(String(p.visitors).padStart(3))}`);
|
|
63
|
+
}
|
|
64
|
+
console.log();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Countries
|
|
68
|
+
if (data.countries.length > 0) {
|
|
69
|
+
console.log(chalk.dim(" COUNTRIES"));
|
|
70
|
+
for (const c of data.countries.slice(0, 5)) {
|
|
71
|
+
const bar = sparkBar(c.visitors, maxVisitors, 12);
|
|
72
|
+
console.log(` ${c.country.padEnd(38)} ${bar} ${chalk.bold(String(c.visitors).padStart(3))}`);
|
|
73
|
+
}
|
|
74
|
+
console.log();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Referrers
|
|
78
|
+
if (data.referrers.length > 0) {
|
|
79
|
+
console.log(chalk.dim(" REFERRERS"));
|
|
80
|
+
for (const r of data.referrers.slice(0, 5)) {
|
|
81
|
+
const ref = truncate(r.referrer || "Direct", 36);
|
|
82
|
+
const bar = sparkBar(r.visitors, maxVisitors, 12);
|
|
83
|
+
console.log(` ${ref.padEnd(38)} ${bar} ${chalk.bold(String(r.visitors).padStart(3))}`);
|
|
84
|
+
}
|
|
85
|
+
console.log();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (data.pages.length === 0 && data.countries.length === 0 && data.referrers.length === 0) {
|
|
89
|
+
console.log(chalk.dim(" No active visitors right now"));
|
|
90
|
+
console.log();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options.watch) {
|
|
94
|
+
console.log(chalk.dim(" ──────────────────────────────────────────────────────"));
|
|
95
|
+
console.log(chalk.dim(" Refreshing every 30s • Press Ctrl+C to exit"));
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
99
|
+
if (!options.watch) {
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
await display();
|
|
106
|
+
|
|
107
|
+
if (options.watch) {
|
|
108
|
+
setInterval(display, 30000);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
@@ -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
|
+
import { sparkBar, truncate, formatRevenue } from "../ui";
|
|
6
|
+
|
|
7
|
+
export const referrersCommand = new Command("referrers")
|
|
8
|
+
.description("Top referrers")
|
|
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: ["referrer"],
|
|
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(" 🔗 Referrers"), 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 referrer = result.dimensions?.referrer || "";
|
|
77
|
+
const displayRef = referrer === "" ? "Direct / None" : referrer;
|
|
78
|
+
const source = truncate(displayRef, 32);
|
|
79
|
+
const visitors = result.metrics.visitors || 0;
|
|
80
|
+
const bar = sparkBar(visitors, maxVisitors);
|
|
81
|
+
|
|
82
|
+
let line = ` ${source.padEnd(34)} ${bar} ${formatNumber(visitors).padStart(6)}`;
|
|
83
|
+
|
|
84
|
+
if (options.revenue !== false && result.metrics.revenue != null) {
|
|
85
|
+
line += ` ${formatRevenue(result.metrics.revenue)}`;
|
|
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
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { getConfig, removeSite, setDefaultSite } from "../config";
|
|
4
|
+
|
|
5
|
+
export const sitesCommand = new Command("sites")
|
|
6
|
+
.description("List all configured sites")
|
|
7
|
+
.action(async () => {
|
|
8
|
+
const config = await getConfig();
|
|
9
|
+
const sites = Object.keys(config.sites);
|
|
10
|
+
|
|
11
|
+
if (sites.length === 0) {
|
|
12
|
+
console.log(chalk.dim("No sites configured. Run `supalytics login <api-key>` to add a site."));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
console.log();
|
|
17
|
+
console.log(chalk.bold("Configured Sites"));
|
|
18
|
+
console.log();
|
|
19
|
+
|
|
20
|
+
for (const site of sites) {
|
|
21
|
+
const isDefault = config.defaultSite === site;
|
|
22
|
+
const apiKey = config.sites[site].apiKey;
|
|
23
|
+
const maskedKey = apiKey.slice(0, 8) + "..." + apiKey.slice(-4);
|
|
24
|
+
|
|
25
|
+
if (isDefault) {
|
|
26
|
+
console.log(` ${chalk.green("●")} ${site} ${chalk.dim("(default)")} ${chalk.dim(maskedKey)}`);
|
|
27
|
+
} else {
|
|
28
|
+
console.log(` ${chalk.dim("○")} ${site} ${chalk.dim(maskedKey)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
console.log();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const defaultCommand = new Command("default")
|
|
35
|
+
.description("Set the default site")
|
|
36
|
+
.argument("<domain>", "Domain to set as default")
|
|
37
|
+
.action(async (domain: string) => {
|
|
38
|
+
const success = await setDefaultSite(domain);
|
|
39
|
+
|
|
40
|
+
if (!success) {
|
|
41
|
+
const config = await getConfig();
|
|
42
|
+
const sites = Object.keys(config.sites);
|
|
43
|
+
console.error(chalk.red(`Error: Site '${domain}' not found.`));
|
|
44
|
+
if (sites.length > 0) {
|
|
45
|
+
console.log(chalk.dim(`Available sites: ${sites.join(", ")}`));
|
|
46
|
+
}
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(chalk.green(`✓ Default site set to ${domain}`));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const removeCommand = new Command("remove")
|
|
54
|
+
.description("Remove a site")
|
|
55
|
+
.argument("<domain>", "Domain to remove")
|
|
56
|
+
.action(async (domain: string) => {
|
|
57
|
+
const success = await removeSite(domain);
|
|
58
|
+
|
|
59
|
+
if (!success) {
|
|
60
|
+
console.error(chalk.red(`Error: Site '${domain}' not found.`));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(chalk.green(`✓ Removed ${domain}`));
|
|
65
|
+
});
|