@supalytics/cli 0.1.2 → 0.3.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 +38 -4
- package/package.json +3 -2
- package/src/api.ts +23 -5
- package/src/commands/completions.ts +328 -0
- package/src/commands/countries.ts +2 -0
- package/src/commands/events.ts +50 -10
- package/src/commands/init.ts +97 -0
- package/src/commands/login.ts +179 -55
- package/src/commands/logout.ts +2 -2
- package/src/commands/pages.ts +2 -0
- package/src/commands/query.ts +49 -24
- package/src/commands/realtime.ts +2 -1
- package/src/commands/referrers.ts +2 -0
- package/src/commands/sites.ts +194 -2
- package/src/commands/stats.ts +96 -9
- package/src/commands/trend.ts +15 -1
- package/src/config.ts +62 -0
- package/src/index.ts +22 -8
- package/src/ui.ts +111 -0
package/src/commands/sites.ts
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getConfig,
|
|
5
|
+
removeSite,
|
|
6
|
+
setDefaultSite,
|
|
7
|
+
getAuth,
|
|
8
|
+
addSiteWithId,
|
|
9
|
+
saveConfig,
|
|
10
|
+
} from "../config";
|
|
11
|
+
|
|
12
|
+
const WEB_BASE = process.env.SUPALYTICS_WEB_URL || "https://www.supalytics.co";
|
|
13
|
+
|
|
14
|
+
interface CreateSiteResponse {
|
|
15
|
+
site: {
|
|
16
|
+
id: string;
|
|
17
|
+
site_id: string;
|
|
18
|
+
domain: string;
|
|
19
|
+
};
|
|
20
|
+
apiKey: {
|
|
21
|
+
key: string;
|
|
22
|
+
prefix: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UpdateSiteResponse {
|
|
27
|
+
site: {
|
|
28
|
+
id: string;
|
|
29
|
+
site_id: string;
|
|
30
|
+
domain: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function createSiteViaApi(
|
|
35
|
+
accessToken: string,
|
|
36
|
+
identifier: string
|
|
37
|
+
): Promise<CreateSiteResponse> {
|
|
38
|
+
let response: Response;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetch(`${WEB_BASE}/api/cli/sites`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${accessToken}`,
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({ identifier }),
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(`Network error: Could not connect to ${WEB_BASE}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
let errorMessage = "Failed to create site";
|
|
54
|
+
try {
|
|
55
|
+
const error = await response.json();
|
|
56
|
+
errorMessage = error.error || errorMessage;
|
|
57
|
+
} catch {
|
|
58
|
+
// Response wasn't JSON
|
|
59
|
+
}
|
|
60
|
+
throw new Error(errorMessage);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return response.json();
|
|
64
|
+
}
|
|
4
65
|
|
|
5
66
|
export const sitesCommand = new Command("sites")
|
|
6
67
|
.description("List all configured sites")
|
|
@@ -9,7 +70,8 @@ export const sitesCommand = new Command("sites")
|
|
|
9
70
|
const sites = Object.keys(config.sites);
|
|
10
71
|
|
|
11
72
|
if (sites.length === 0) {
|
|
12
|
-
console.log(chalk.dim("No sites configured.
|
|
73
|
+
console.log(chalk.dim("No sites configured."));
|
|
74
|
+
console.log(chalk.dim("Run `supalytics login` then `supalytics sites add <name>` to add a site."));
|
|
13
75
|
return;
|
|
14
76
|
}
|
|
15
77
|
|
|
@@ -31,6 +93,136 @@ export const sitesCommand = new Command("sites")
|
|
|
31
93
|
console.log();
|
|
32
94
|
});
|
|
33
95
|
|
|
96
|
+
// sites add <identifier>
|
|
97
|
+
const addCommand = new Command("add")
|
|
98
|
+
.description("Create a new site")
|
|
99
|
+
.argument("<identifier>", "Domain or project name")
|
|
100
|
+
.action(async (identifier: string) => {
|
|
101
|
+
const auth = await getAuth();
|
|
102
|
+
if (!auth) {
|
|
103
|
+
console.error(chalk.red("Not logged in. Run `supalytics login` first."));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(chalk.dim("Creating site..."));
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const result = await createSiteViaApi(auth.accessToken, identifier);
|
|
111
|
+
|
|
112
|
+
// Store locally (including website UUID for updates)
|
|
113
|
+
await addSiteWithId(result.site.domain, result.apiKey.key, result.site.site_id, result.site.id);
|
|
114
|
+
|
|
115
|
+
console.log(chalk.green(`✓ Created ${result.site.domain}`));
|
|
116
|
+
console.log(chalk.dim(` Site ID: ${result.site.site_id}`));
|
|
117
|
+
console.log(chalk.dim(` API key stored locally`));
|
|
118
|
+
console.log();
|
|
119
|
+
console.log(chalk.dim("Add this to your HTML <head>:"));
|
|
120
|
+
console.log();
|
|
121
|
+
console.log(
|
|
122
|
+
chalk.cyan(
|
|
123
|
+
` <script src="https://cdn.supalytics.co/script.js" data-site="${result.site.site_id}" defer></script>`
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
console.log();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// sites update <identifier> --domain <domain>
|
|
134
|
+
const updateCommand = new Command("update")
|
|
135
|
+
.description("Update a site's domain")
|
|
136
|
+
.argument("<identifier>", "Current domain/identifier")
|
|
137
|
+
.option("-d, --domain <domain>", "New domain name")
|
|
138
|
+
.action(async (identifier: string, options: { domain?: string }) => {
|
|
139
|
+
if (!options.domain) {
|
|
140
|
+
console.error(chalk.red("Error: --domain is required"));
|
|
141
|
+
console.error(chalk.dim("Usage: supalytics sites update <identifier> --domain <new-domain>"));
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const auth = await getAuth();
|
|
146
|
+
if (!auth) {
|
|
147
|
+
console.error(chalk.red("Not logged in. Run `supalytics login` first."));
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Get site from local config
|
|
152
|
+
const config = await getConfig();
|
|
153
|
+
const siteConfig = config.sites[identifier];
|
|
154
|
+
if (!siteConfig) {
|
|
155
|
+
console.error(chalk.red(`Site '${identifier}' not found locally.`));
|
|
156
|
+
const sites = Object.keys(config.sites);
|
|
157
|
+
if (sites.length > 0) {
|
|
158
|
+
console.log(chalk.dim(`Available sites: ${sites.join(", ")}`));
|
|
159
|
+
}
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log(chalk.dim("Updating site..."));
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// Try to use locally stored website ID first
|
|
167
|
+
let websiteId = siteConfig.id;
|
|
168
|
+
|
|
169
|
+
// If no local ID, fetch from server
|
|
170
|
+
if (!websiteId) {
|
|
171
|
+
const listResponse = await fetch(`${WEB_BASE}/api/cli/sites`, {
|
|
172
|
+
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!listResponse.ok) {
|
|
176
|
+
throw new Error("Failed to fetch sites");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { sites } = await listResponse.json();
|
|
180
|
+
const site = sites.find((s: { domain: string }) => s.domain === identifier);
|
|
181
|
+
|
|
182
|
+
if (!site) {
|
|
183
|
+
throw new Error(`Site '${identifier}' not found on server`);
|
|
184
|
+
}
|
|
185
|
+
websiteId = site.id;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Update via API
|
|
189
|
+
const updateResponse = await fetch(`${WEB_BASE}/api/cli/sites/${websiteId}`, {
|
|
190
|
+
method: "PATCH",
|
|
191
|
+
headers: {
|
|
192
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
},
|
|
195
|
+
body: JSON.stringify({ domain: options.domain }),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (!updateResponse.ok) {
|
|
199
|
+
const error = await updateResponse.json();
|
|
200
|
+
throw new Error(error.error || "Failed to update site");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const result: UpdateSiteResponse = await updateResponse.json();
|
|
204
|
+
|
|
205
|
+
// Update local config (preserve all fields)
|
|
206
|
+
const { apiKey, siteId: existingSiteId, id: existingId } = siteConfig;
|
|
207
|
+
const siteId = existingSiteId || result.site.site_id;
|
|
208
|
+
const id = existingId || websiteId;
|
|
209
|
+
delete config.sites[identifier];
|
|
210
|
+
config.sites[options.domain] = { apiKey, siteId, id };
|
|
211
|
+
if (config.defaultSite === identifier) {
|
|
212
|
+
config.defaultSite = options.domain;
|
|
213
|
+
}
|
|
214
|
+
await saveConfig(config);
|
|
215
|
+
|
|
216
|
+
console.log(chalk.green(`✓ Updated ${identifier} → ${options.domain}`));
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
sitesCommand.addCommand(addCommand);
|
|
224
|
+
sitesCommand.addCommand(updateCommand);
|
|
225
|
+
|
|
34
226
|
export const defaultCommand = new Command("default")
|
|
35
227
|
.description("Set the default site")
|
|
36
228
|
.argument("<domain>", "Domain to set as default")
|
package/src/commands/stats.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
-
import { query, formatNumber, formatPercent, formatDuration } from "../api";
|
|
3
|
+
import { query, formatNumber, formatPercent, formatDuration, listEvents } from "../api";
|
|
4
4
|
import { getDefaultSite } from "../config";
|
|
5
|
-
import { logo, parsePeriod } from "../ui";
|
|
5
|
+
import { logo, parsePeriod, truncate } from "../ui";
|
|
6
6
|
|
|
7
7
|
export const statsCommand = new Command("stats")
|
|
8
8
|
.description("Overview stats: pageviews, visitors, bounce rate, revenue")
|
|
@@ -11,8 +11,10 @@ export const statsCommand = new Command("stats")
|
|
|
11
11
|
.option("--start <date>", "Start date (YYYY-MM-DD)")
|
|
12
12
|
.option("--end <date>", "End date (YYYY-MM-DD)")
|
|
13
13
|
.option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
|
|
14
|
+
.option("-a, --all", "Show detailed breakdown (pages, referrers, countries, etc.)")
|
|
14
15
|
.option("--no-revenue", "Exclude revenue metrics")
|
|
15
16
|
.option("--json", "Output as JSON")
|
|
17
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
16
18
|
.action(async (period, options) => {
|
|
17
19
|
const site = options.site || (await getDefaultSite());
|
|
18
20
|
|
|
@@ -43,15 +45,47 @@ export const statsCommand = new Command("stats")
|
|
|
43
45
|
metrics.push("revenue", "conversions", "conversion_rate");
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
// Build API calls
|
|
49
|
+
const apiCalls: Promise<unknown>[] = [
|
|
50
|
+
query(site, {
|
|
51
|
+
metrics,
|
|
52
|
+
filters,
|
|
53
|
+
date_range: dateRange,
|
|
54
|
+
include_revenue: options.revenue !== false,
|
|
55
|
+
is_dev: options.test || false,
|
|
56
|
+
}),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Add breakdown calls if --all flag
|
|
60
|
+
if (options.all) {
|
|
61
|
+
const breakdownOpts = { filters, date_range: dateRange, limit: 10, is_dev: options.test || false };
|
|
62
|
+
apiCalls.push(
|
|
63
|
+
query(site, { metrics: ["visitors"], dimensions: ["page"], ...breakdownOpts }),
|
|
64
|
+
query(site, { metrics: ["visitors"], dimensions: ["referrer"], ...breakdownOpts }),
|
|
65
|
+
query(site, { metrics: ["visitors"], dimensions: ["country"], ...breakdownOpts }),
|
|
66
|
+
query(site, { metrics: ["visitors"], dimensions: ["browser"], ...breakdownOpts }),
|
|
67
|
+
query(site, { metrics: ["visitors"], dimensions: ["utm_source"], ...breakdownOpts }),
|
|
68
|
+
listEvents(site, typeof dateRange === "string" ? dateRange : "30d", 10, options.test || false)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const results = await Promise.all(apiCalls);
|
|
73
|
+
const response = results[0] as Awaited<ReturnType<typeof query>>;
|
|
52
74
|
|
|
53
75
|
if (options.json) {
|
|
54
|
-
|
|
76
|
+
if (options.all) {
|
|
77
|
+
console.log(JSON.stringify({
|
|
78
|
+
stats: results[0],
|
|
79
|
+
pages: results[1],
|
|
80
|
+
referrers: results[2],
|
|
81
|
+
countries: results[3],
|
|
82
|
+
browsers: results[4],
|
|
83
|
+
utm_sources: results[5],
|
|
84
|
+
events: results[6],
|
|
85
|
+
}, null, 2));
|
|
86
|
+
} else {
|
|
87
|
+
console.log(JSON.stringify(response, null, 2));
|
|
88
|
+
}
|
|
55
89
|
return;
|
|
56
90
|
}
|
|
57
91
|
|
|
@@ -96,6 +130,59 @@ export const statsCommand = new Command("stats")
|
|
|
96
130
|
console.log(` ${chalk.bold(convRate)}`);
|
|
97
131
|
}
|
|
98
132
|
|
|
133
|
+
// Detailed breakdowns if --all
|
|
134
|
+
if (options.all) {
|
|
135
|
+
const pagesRes = results[1] as Awaited<ReturnType<typeof query>>;
|
|
136
|
+
const referrersRes = results[2] as Awaited<ReturnType<typeof query>>;
|
|
137
|
+
const countriesRes = results[3] as Awaited<ReturnType<typeof query>>;
|
|
138
|
+
const browsersRes = results[4] as Awaited<ReturnType<typeof query>>;
|
|
139
|
+
const utmRes = results[5] as Awaited<ReturnType<typeof query>>;
|
|
140
|
+
const eventsRes = results[6] as Awaited<ReturnType<typeof listEvents>>;
|
|
141
|
+
|
|
142
|
+
const renderBreakdown = (
|
|
143
|
+
title: string,
|
|
144
|
+
data: { dimensions?: Record<string, string>; metrics: Record<string, number> }[],
|
|
145
|
+
dimKey: string,
|
|
146
|
+
metricKey: string = "visitors",
|
|
147
|
+
metricLabel: string = "VISITORS"
|
|
148
|
+
) => {
|
|
149
|
+
console.log();
|
|
150
|
+
console.log(chalk.dim(" ────────────────────────────────────────"));
|
|
151
|
+
console.log();
|
|
152
|
+
console.log(` ${chalk.dim(title.padEnd(36))}${chalk.dim(metricLabel)}`);
|
|
153
|
+
if (data.length === 0) {
|
|
154
|
+
console.log(chalk.dim(" (no data)"));
|
|
155
|
+
} else {
|
|
156
|
+
for (const row of data) {
|
|
157
|
+
const dim = truncate(row.dimensions?.[dimKey] || "(direct)", 34);
|
|
158
|
+
const val = formatNumber(row.metrics[metricKey] || 0);
|
|
159
|
+
console.log(` ${dim.padEnd(36)}${val.padStart(8)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
renderBreakdown("PAGES", pagesRes.data, "page");
|
|
165
|
+
renderBreakdown("REFERRERS", referrersRes.data, "referrer");
|
|
166
|
+
renderBreakdown("COUNTRIES", countriesRes.data, "country");
|
|
167
|
+
renderBreakdown("BROWSERS", browsersRes.data, "browser");
|
|
168
|
+
renderBreakdown("UTM SOURCES", utmRes.data, "utm_source");
|
|
169
|
+
|
|
170
|
+
// Events have different structure
|
|
171
|
+
console.log();
|
|
172
|
+
console.log(chalk.dim(" ────────────────────────────────────────"));
|
|
173
|
+
console.log();
|
|
174
|
+
console.log(` ${chalk.dim("EVENTS".padEnd(36))}${chalk.dim("COUNT")}`);
|
|
175
|
+
if (eventsRes.data.length === 0) {
|
|
176
|
+
console.log(chalk.dim(" (no events)"));
|
|
177
|
+
} else {
|
|
178
|
+
for (const event of eventsRes.data) {
|
|
179
|
+
const name = truncate(event.name, 34);
|
|
180
|
+
const count = formatNumber(event.count);
|
|
181
|
+
console.log(` ${name.padEnd(36)}${count.padStart(8)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
99
186
|
console.log();
|
|
100
187
|
} catch (error) {
|
|
101
188
|
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
package/src/commands/trend.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Command } from "commander";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { query, formatNumber } from "../api";
|
|
4
4
|
import { getDefaultSite } from "../config";
|
|
5
|
+
import { coloredSparkline } from "../ui";
|
|
5
6
|
|
|
6
7
|
export const trendCommand = new Command("trend")
|
|
7
8
|
.description("Daily visitor trend")
|
|
@@ -11,7 +12,9 @@ export const trendCommand = new Command("trend")
|
|
|
11
12
|
.option("--end <date>", "End date (YYYY-MM-DD)")
|
|
12
13
|
.option("-f, --filter <filters...>", "Filters in format 'field:operator:value' (e.g., 'is:country:US')")
|
|
13
14
|
.option("--no-revenue", "Exclude revenue metrics")
|
|
15
|
+
.option("--compact", "Show compact sparkline only")
|
|
14
16
|
.option("--json", "Output as JSON")
|
|
17
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
15
18
|
.action(async (options) => {
|
|
16
19
|
const site = options.site || (await getDefaultSite());
|
|
17
20
|
|
|
@@ -50,6 +53,7 @@ export const trendCommand = new Command("trend")
|
|
|
50
53
|
date_range: dateRange,
|
|
51
54
|
limit: 1000, // Get all days
|
|
52
55
|
include_revenue: options.revenue !== false,
|
|
56
|
+
is_dev: options.test || false,
|
|
53
57
|
});
|
|
54
58
|
|
|
55
59
|
if (options.json) {
|
|
@@ -68,8 +72,18 @@ export const trendCommand = new Command("trend")
|
|
|
68
72
|
return;
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
const visitors = response.data.map((r) => r.metrics.visitors || 0);
|
|
76
|
+
const total = visitors.reduce((a, b) => a + b, 0);
|
|
77
|
+
|
|
78
|
+
// Compact mode - just sparkline
|
|
79
|
+
if (options.compact) {
|
|
80
|
+
console.log(` ${coloredSparkline(visitors)} ${formatNumber(total)} visitors`);
|
|
81
|
+
console.log();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
71
85
|
// Find max for sparkline scaling
|
|
72
|
-
const maxVisitors = Math.max(...
|
|
86
|
+
const maxVisitors = Math.max(...visitors);
|
|
73
87
|
|
|
74
88
|
for (const result of response.data) {
|
|
75
89
|
const date = result.dimensions?.date || "";
|
package/src/config.ts
CHANGED
|
@@ -6,9 +6,20 @@ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
|
6
6
|
|
|
7
7
|
interface SiteConfig {
|
|
8
8
|
apiKey: string;
|
|
9
|
+
siteId?: string; // The site_id for tracking snippet
|
|
10
|
+
id?: string; // The website UUID for API operations
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AuthConfig {
|
|
14
|
+
accessToken: string;
|
|
15
|
+
email: string;
|
|
16
|
+
name: string;
|
|
17
|
+
expiresAt?: string;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
interface Config {
|
|
21
|
+
// User authentication
|
|
22
|
+
auth?: AuthConfig;
|
|
12
23
|
// Map of domain -> API key
|
|
13
24
|
sites: Record<string, SiteConfig>;
|
|
14
25
|
// Default site domain
|
|
@@ -93,4 +104,55 @@ export async function getSites(): Promise<string[]> {
|
|
|
93
104
|
return Object.keys(config.sites);
|
|
94
105
|
}
|
|
95
106
|
|
|
107
|
+
// Auth functions
|
|
108
|
+
export async function getAuth(): Promise<AuthConfig | undefined> {
|
|
109
|
+
const config = await getConfig();
|
|
110
|
+
return config.auth;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function saveAuth(auth: AuthConfig): Promise<void> {
|
|
114
|
+
const config = await getConfig();
|
|
115
|
+
config.auth = auth;
|
|
116
|
+
await saveConfig(config);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function clearAuth(): Promise<void> {
|
|
120
|
+
const config = await getConfig();
|
|
121
|
+
delete config.auth;
|
|
122
|
+
await saveConfig(config);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function isAuthenticated(): Promise<boolean> {
|
|
126
|
+
const auth = await getAuth();
|
|
127
|
+
if (!auth?.accessToken) return false;
|
|
128
|
+
// Check expiration if set
|
|
129
|
+
if (auth.expiresAt && new Date(auth.expiresAt) < new Date()) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Add site with siteId and optional website id
|
|
136
|
+
export async function addSiteWithId(
|
|
137
|
+
domain: string,
|
|
138
|
+
apiKey: string,
|
|
139
|
+
siteId: string,
|
|
140
|
+
id?: string
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const config = await getConfig();
|
|
143
|
+
config.sites[domain] = { apiKey, siteId, id };
|
|
144
|
+
// Set as default if it's the first site
|
|
145
|
+
if (!config.defaultSite || Object.keys(config.sites).length === 1) {
|
|
146
|
+
config.defaultSite = domain;
|
|
147
|
+
}
|
|
148
|
+
await saveConfig(config);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Get site ID for a domain
|
|
152
|
+
export async function getSiteId(domain: string): Promise<string | undefined> {
|
|
153
|
+
const config = await getConfig();
|
|
154
|
+
return config.sites[domain]?.siteId;
|
|
155
|
+
}
|
|
156
|
+
|
|
96
157
|
export { CONFIG_DIR, CONFIG_FILE };
|
|
158
|
+
export type { AuthConfig, SiteConfig, Config };
|
package/src/index.ts
CHANGED
|
@@ -11,17 +11,24 @@ import { trendCommand } from "./commands/trend";
|
|
|
11
11
|
import { queryCommand } from "./commands/query";
|
|
12
12
|
import { eventsCommand } from "./commands/events";
|
|
13
13
|
import { realtimeCommand } from "./commands/realtime";
|
|
14
|
+
import { completionsCommand } from "./commands/completions";
|
|
15
|
+
import { initCommand } from "./commands/init";
|
|
14
16
|
|
|
15
17
|
const description = `CLI for Supalytics web analytics.
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
List sites: supalytics sites
|
|
20
|
-
Set default: supalytics default <domain>
|
|
21
|
-
Remove site: supalytics remove <domain>
|
|
22
|
-
Query other: supalytics stats --site <domain>
|
|
19
|
+
Quick Start:
|
|
20
|
+
supalytics init Auto-setup: login → create site → get snippet
|
|
23
21
|
|
|
24
|
-
|
|
22
|
+
Authentication:
|
|
23
|
+
supalytics login Open browser to authenticate
|
|
24
|
+
supalytics logout Log out and remove all credentials
|
|
25
|
+
|
|
26
|
+
Site Management:
|
|
27
|
+
supalytics sites List configured sites
|
|
28
|
+
supalytics sites add <name> Create a new site
|
|
29
|
+
supalytics sites update <name> --domain <domain> Update domain
|
|
30
|
+
supalytics default <domain> Set default site
|
|
31
|
+
supalytics remove <domain> Remove a site
|
|
25
32
|
|
|
26
33
|
Date Ranges:
|
|
27
34
|
--period: 7d, 14d, 30d, 90d, 12mo, all (default: 30d)
|
|
@@ -54,10 +61,16 @@ Output:
|
|
|
54
61
|
--json Raw JSON output (useful for piping to other tools or AI)
|
|
55
62
|
--no-revenue Exclude revenue metrics from output`;
|
|
56
63
|
|
|
64
|
+
// Read version from package.json
|
|
65
|
+
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
66
|
+
|
|
57
67
|
program
|
|
58
68
|
.name("supalytics")
|
|
59
69
|
.description(description)
|
|
60
|
-
.version(
|
|
70
|
+
.version(pkg.version);
|
|
71
|
+
|
|
72
|
+
// Quick start
|
|
73
|
+
program.addCommand(initCommand);
|
|
61
74
|
|
|
62
75
|
// Auth & site management commands
|
|
63
76
|
program.addCommand(loginCommand);
|
|
@@ -75,5 +88,6 @@ program.addCommand(trendCommand);
|
|
|
75
88
|
program.addCommand(queryCommand);
|
|
76
89
|
program.addCommand(eventsCommand);
|
|
77
90
|
program.addCommand(realtimeCommand);
|
|
91
|
+
program.addCommand(completionsCommand);
|
|
78
92
|
|
|
79
93
|
program.parse();
|
package/src/ui.ts
CHANGED
|
@@ -77,3 +77,114 @@ export function formatRevenue(cents: number): string {
|
|
|
77
77
|
}
|
|
78
78
|
return chalk.green("$" + (cents / 100).toFixed(0));
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
// Sparkline characters from lowest to highest
|
|
82
|
+
const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Generate a sparkline from an array of numbers
|
|
86
|
+
*/
|
|
87
|
+
export function sparkline(values: number[]): string {
|
|
88
|
+
if (values.length === 0) return "";
|
|
89
|
+
|
|
90
|
+
const min = Math.min(...values);
|
|
91
|
+
const max = Math.max(...values);
|
|
92
|
+
const range = max - min || 1;
|
|
93
|
+
|
|
94
|
+
return values
|
|
95
|
+
.map((v) => {
|
|
96
|
+
const normalized = (v - min) / range;
|
|
97
|
+
const index = Math.min(Math.floor(normalized * SPARK_CHARS.length), SPARK_CHARS.length - 1);
|
|
98
|
+
return SPARK_CHARS[index];
|
|
99
|
+
})
|
|
100
|
+
.join("");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generate a colored sparkline (green for up, red for down trend)
|
|
105
|
+
*/
|
|
106
|
+
export function coloredSparkline(values: number[]): string {
|
|
107
|
+
if (values.length === 0) return "";
|
|
108
|
+
|
|
109
|
+
const spark = sparkline(values);
|
|
110
|
+
const first = values[0];
|
|
111
|
+
const last = values[values.length - 1];
|
|
112
|
+
|
|
113
|
+
if (last > first) {
|
|
114
|
+
return chalk.green(spark);
|
|
115
|
+
} else if (last < first) {
|
|
116
|
+
return chalk.red(spark);
|
|
117
|
+
}
|
|
118
|
+
return chalk.dim(spark);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface TableColumn {
|
|
122
|
+
key: string;
|
|
123
|
+
label?: string;
|
|
124
|
+
align?: "left" | "right";
|
|
125
|
+
width?: number;
|
|
126
|
+
format?: (value: unknown) => string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface TableOptions {
|
|
130
|
+
indent?: number;
|
|
131
|
+
showHeader?: boolean;
|
|
132
|
+
headerColor?: (s: string) => string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Render a table with proper column alignment
|
|
137
|
+
*/
|
|
138
|
+
export function table(
|
|
139
|
+
data: Record<string, unknown>[],
|
|
140
|
+
columns: TableColumn[],
|
|
141
|
+
options: TableOptions = {}
|
|
142
|
+
): string {
|
|
143
|
+
const { indent = 2, showHeader = true, headerColor = chalk.dim } = options;
|
|
144
|
+
const prefix = " ".repeat(indent);
|
|
145
|
+
const lines: string[] = [];
|
|
146
|
+
|
|
147
|
+
// Calculate column widths
|
|
148
|
+
const widths = columns.map((col) => {
|
|
149
|
+
const label = col.label || col.key;
|
|
150
|
+
const dataWidth = Math.max(
|
|
151
|
+
...data.map((row) => {
|
|
152
|
+
const val = row[col.key];
|
|
153
|
+
const formatted = col.format ? col.format(val) : String(val ?? "");
|
|
154
|
+
// Strip ANSI codes for width calculation
|
|
155
|
+
return formatted.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
return col.width || Math.max(label.length, dataWidth);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Header row
|
|
162
|
+
if (showHeader) {
|
|
163
|
+
const headerParts = columns.map((col, i) => {
|
|
164
|
+
const label = col.label || col.key.toUpperCase();
|
|
165
|
+
const width = widths[i];
|
|
166
|
+
return col.align === "right" ? label.padStart(width) : label.padEnd(width);
|
|
167
|
+
});
|
|
168
|
+
lines.push(prefix + headerColor(headerParts.join(" ")));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Data rows
|
|
172
|
+
for (const row of data) {
|
|
173
|
+
const parts = columns.map((col, i) => {
|
|
174
|
+
const val = row[col.key];
|
|
175
|
+
const formatted = col.format ? col.format(val) : String(val ?? "");
|
|
176
|
+
const width = widths[i];
|
|
177
|
+
// Get display length (without ANSI codes)
|
|
178
|
+
const displayLen = formatted.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
179
|
+
const padding = Math.max(0, width - displayLen);
|
|
180
|
+
|
|
181
|
+
if (col.align === "right") {
|
|
182
|
+
return " ".repeat(padding) + formatted;
|
|
183
|
+
}
|
|
184
|
+
return formatted + " ".repeat(padding);
|
|
185
|
+
});
|
|
186
|
+
lines.push(prefix + parts.join(" "));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return lines.join("\n");
|
|
190
|
+
}
|