@supalytics/cli 0.2.0 → 0.3.1
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 +19 -7
- package/package.json +3 -2
- package/src/api.ts +23 -5
- package/src/commands/completions.ts +36 -13
- package/src/commands/countries.ts +2 -0
- package/src/commands/events.ts +6 -4
- package/src/commands/init.ts +97 -0
- package/src/commands/login.ts +198 -54
- package/src/commands/logout.ts +2 -2
- package/src/commands/pages.ts +2 -0
- package/src/commands/query.ts +2 -0
- 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 +4 -2
- package/src/commands/trend.ts +2 -0
- package/src/config.ts +62 -0
- package/src/index.ts +16 -7
package/README.md
CHANGED
|
@@ -20,10 +20,19 @@ bun add -g @supalytics/cli
|
|
|
20
20
|
|
|
21
21
|
## Setup
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
### Quick Start
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
supalytics
|
|
26
|
+
supalytics init
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Opens browser to log in, creates your site, and gives you the tracking snippet.
|
|
30
|
+
|
|
31
|
+
### Manual Setup
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
supalytics login # Opens browser for authentication
|
|
35
|
+
supalytics sites add # Create a new site
|
|
27
36
|
```
|
|
28
37
|
|
|
29
38
|
## Usage
|
|
@@ -91,6 +100,7 @@ supalytics events signup --property plan # Breakdown by property
|
|
|
91
100
|
All commands support:
|
|
92
101
|
|
|
93
102
|
- `-s, --site <domain>` - Query a specific site
|
|
103
|
+
- `-t, --test` - Query localhost/test data instead of production
|
|
94
104
|
- `--json` - Output raw JSON
|
|
95
105
|
- `--no-revenue` - Exclude revenue metrics
|
|
96
106
|
- `-f, --filter <filter>` - Filter data (format: `field:operator:value`)
|
|
@@ -104,13 +114,15 @@ All commands support:
|
|
|
104
114
|
-f "referrer:is:twitter.com"
|
|
105
115
|
```
|
|
106
116
|
|
|
107
|
-
##
|
|
117
|
+
## Site Management
|
|
108
118
|
|
|
109
119
|
```bash
|
|
110
|
-
supalytics
|
|
111
|
-
supalytics sites
|
|
112
|
-
supalytics
|
|
113
|
-
supalytics
|
|
120
|
+
supalytics sites # List all sites
|
|
121
|
+
supalytics sites add example.com # Create site with domain
|
|
122
|
+
supalytics sites add my-project # Or use any name, update later
|
|
123
|
+
supalytics sites update my-project -d example.com # Set the real domain
|
|
124
|
+
supalytics default example.com # Set default site
|
|
125
|
+
supalytics stats -s other.com # Query specific site
|
|
114
126
|
```
|
|
115
127
|
|
|
116
128
|
## Shell Completions
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supalytics/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "CLI for Supalytics web analytics",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"chalk": "^5.6.2",
|
|
36
|
-
"commander": "^14.0.2"
|
|
36
|
+
"commander": "^14.0.2",
|
|
37
|
+
"open": "^10.1.0"
|
|
37
38
|
}
|
|
38
39
|
}
|
package/src/api.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface QueryRequest {
|
|
|
12
12
|
limit?: number;
|
|
13
13
|
offset?: number;
|
|
14
14
|
include_revenue?: boolean;
|
|
15
|
+
is_dev?: boolean; // Test mode: query localhost data instead of production
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface QueryResult {
|
|
@@ -159,7 +160,8 @@ export interface PropertyBreakdownResponse {
|
|
|
159
160
|
export async function listEvents(
|
|
160
161
|
site: string,
|
|
161
162
|
period: string = "30d",
|
|
162
|
-
limit: number = 100
|
|
163
|
+
limit: number = 100,
|
|
164
|
+
isDev: boolean = false
|
|
163
165
|
): Promise<EventsResponse> {
|
|
164
166
|
const apiKey = await getApiKeyForSite(site);
|
|
165
167
|
|
|
@@ -176,6 +178,9 @@ export async function listEvents(
|
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
const params = new URLSearchParams({ period, limit: String(limit) });
|
|
181
|
+
if (isDev) {
|
|
182
|
+
params.set("is_dev", "true");
|
|
183
|
+
}
|
|
179
184
|
const response = await fetch(`${API_BASE}/v1/events?${params}`, {
|
|
180
185
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
181
186
|
});
|
|
@@ -194,7 +199,8 @@ export async function listEvents(
|
|
|
194
199
|
export async function getEventProperties(
|
|
195
200
|
site: string,
|
|
196
201
|
eventName: string,
|
|
197
|
-
period: string = "30d"
|
|
202
|
+
period: string = "30d",
|
|
203
|
+
isDev: boolean = false
|
|
198
204
|
): Promise<PropertyKeysResponse> {
|
|
199
205
|
const apiKey = await getApiKeyForSite(site);
|
|
200
206
|
|
|
@@ -203,6 +209,9 @@ export async function getEventProperties(
|
|
|
203
209
|
}
|
|
204
210
|
|
|
205
211
|
const params = new URLSearchParams({ period });
|
|
212
|
+
if (isDev) {
|
|
213
|
+
params.set("is_dev", "true");
|
|
214
|
+
}
|
|
206
215
|
const response = await fetch(
|
|
207
216
|
`${API_BASE}/v1/events/${encodeURIComponent(eventName)}/properties?${params}`,
|
|
208
217
|
{ headers: { Authorization: `Bearer ${apiKey}` } }
|
|
@@ -225,7 +234,8 @@ export async function getPropertyBreakdown(
|
|
|
225
234
|
propertyKey: string,
|
|
226
235
|
period: string = "30d",
|
|
227
236
|
limit: number = 100,
|
|
228
|
-
includeRevenue: boolean = false
|
|
237
|
+
includeRevenue: boolean = false,
|
|
238
|
+
isDev: boolean = false
|
|
229
239
|
): Promise<PropertyBreakdownResponse> {
|
|
230
240
|
const apiKey = await getApiKeyForSite(site);
|
|
231
241
|
|
|
@@ -238,6 +248,9 @@ export async function getPropertyBreakdown(
|
|
|
238
248
|
limit: String(limit),
|
|
239
249
|
include_revenue: String(includeRevenue),
|
|
240
250
|
});
|
|
251
|
+
if (isDev) {
|
|
252
|
+
params.set("is_dev", "true");
|
|
253
|
+
}
|
|
241
254
|
const response = await fetch(
|
|
242
255
|
`${API_BASE}/v1/events/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propertyKey)}?${params}`,
|
|
243
256
|
{ headers: { Authorization: `Bearer ${apiKey}` } }
|
|
@@ -283,7 +296,7 @@ export interface RealtimeResponse {
|
|
|
283
296
|
/**
|
|
284
297
|
* Get realtime visitors
|
|
285
298
|
*/
|
|
286
|
-
export async function getRealtime(site: string): Promise<RealtimeResponse> {
|
|
299
|
+
export async function getRealtime(site: string, isDev: boolean = false): Promise<RealtimeResponse> {
|
|
287
300
|
const apiKey = await getApiKeyForSite(site);
|
|
288
301
|
|
|
289
302
|
if (!apiKey) {
|
|
@@ -298,7 +311,12 @@ export async function getRealtime(site: string): Promise<RealtimeResponse> {
|
|
|
298
311
|
);
|
|
299
312
|
}
|
|
300
313
|
|
|
301
|
-
const
|
|
314
|
+
const params = new URLSearchParams();
|
|
315
|
+
if (isDev) {
|
|
316
|
+
params.set("is_dev", "true");
|
|
317
|
+
}
|
|
318
|
+
const url = params.toString() ? `${API_BASE}/v1/realtime?${params}` : `${API_BASE}/v1/realtime`;
|
|
319
|
+
const response = await fetch(url, {
|
|
302
320
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
303
321
|
});
|
|
304
322
|
|
|
@@ -10,7 +10,7 @@ _supalytics_completions() {
|
|
|
10
10
|
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
11
11
|
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
12
12
|
|
|
13
|
-
commands="login logout sites default remove stats pages referrers countries trend query events realtime completions help"
|
|
13
|
+
commands="init login logout sites default remove stats pages referrers countries trend query events realtime completions help"
|
|
14
14
|
|
|
15
15
|
# Main command completion
|
|
16
16
|
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
@@ -38,8 +38,17 @@ _supalytics_completions() {
|
|
|
38
38
|
realtime)
|
|
39
39
|
opts="--site --json --watch"
|
|
40
40
|
;;
|
|
41
|
+
init)
|
|
42
|
+
opts=""
|
|
43
|
+
;;
|
|
41
44
|
login)
|
|
42
|
-
opts="
|
|
45
|
+
opts=""
|
|
46
|
+
;;
|
|
47
|
+
logout)
|
|
48
|
+
opts=""
|
|
49
|
+
;;
|
|
50
|
+
sites)
|
|
51
|
+
opts="add update"
|
|
43
52
|
;;
|
|
44
53
|
completions)
|
|
45
54
|
opts="bash zsh fish"
|
|
@@ -64,9 +73,10 @@ const ZSH_COMPLETION = `#compdef supalytics
|
|
|
64
73
|
_supalytics() {
|
|
65
74
|
local -a commands
|
|
66
75
|
commands=(
|
|
67
|
-
'
|
|
68
|
-
'
|
|
69
|
-
'
|
|
76
|
+
'init:Quick setup - login, create site, get snippet'
|
|
77
|
+
'login:Authenticate with Supalytics'
|
|
78
|
+
'logout:Log out (keeps site API keys)'
|
|
79
|
+
'sites:List and manage configured sites'
|
|
70
80
|
'default:Set the default site'
|
|
71
81
|
'remove:Remove a site'
|
|
72
82
|
'stats:Overview stats (pageviews, visitors, bounce rate, revenue)'
|
|
@@ -176,10 +186,20 @@ _supalytics() {
|
|
|
176
186
|
'--json[Output as JSON]' \\
|
|
177
187
|
'--watch[Auto-refresh every 30 seconds]'
|
|
178
188
|
;;
|
|
189
|
+
init)
|
|
190
|
+
_arguments '1:identifier:'
|
|
191
|
+
;;
|
|
179
192
|
login)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
193
|
+
;;
|
|
194
|
+
logout)
|
|
195
|
+
;;
|
|
196
|
+
sites)
|
|
197
|
+
local -a sites_commands
|
|
198
|
+
sites_commands=(
|
|
199
|
+
'add:Create a new site'
|
|
200
|
+
'update:Update a site domain'
|
|
201
|
+
)
|
|
202
|
+
_describe 'sites command' sites_commands
|
|
183
203
|
;;
|
|
184
204
|
default|remove)
|
|
185
205
|
_arguments '1:domain:'
|
|
@@ -203,9 +223,10 @@ const FISH_COMPLETION = `# Supalytics CLI Fish Completion
|
|
|
203
223
|
complete -c supalytics -f
|
|
204
224
|
|
|
205
225
|
# Commands
|
|
206
|
-
complete -c supalytics -n "__fish_use_subcommand" -a "
|
|
207
|
-
complete -c supalytics -n "__fish_use_subcommand" -a "
|
|
208
|
-
complete -c supalytics -n "__fish_use_subcommand" -a "
|
|
226
|
+
complete -c supalytics -n "__fish_use_subcommand" -a "init" -d "Quick setup - login, create site, get snippet"
|
|
227
|
+
complete -c supalytics -n "__fish_use_subcommand" -a "login" -d "Authenticate with Supalytics"
|
|
228
|
+
complete -c supalytics -n "__fish_use_subcommand" -a "logout" -d "Log out (keeps site API keys)"
|
|
229
|
+
complete -c supalytics -n "__fish_use_subcommand" -a "sites" -d "List and manage configured sites"
|
|
209
230
|
complete -c supalytics -n "__fish_use_subcommand" -a "default" -d "Set the default site"
|
|
210
231
|
complete -c supalytics -n "__fish_use_subcommand" -a "remove" -d "Remove a site"
|
|
211
232
|
complete -c supalytics -n "__fish_use_subcommand" -a "stats" -d "Overview stats"
|
|
@@ -277,8 +298,10 @@ complete -c supalytics -n "__fish_seen_subcommand_from realtime" -l site -s s -d
|
|
|
277
298
|
complete -c supalytics -n "__fish_seen_subcommand_from realtime" -l json -d "Output as JSON"
|
|
278
299
|
complete -c supalytics -n "__fish_seen_subcommand_from realtime" -l watch -s w -d "Auto-refresh"
|
|
279
300
|
|
|
280
|
-
#
|
|
281
|
-
complete -c supalytics -n "__fish_seen_subcommand_from
|
|
301
|
+
# Sites subcommands
|
|
302
|
+
complete -c supalytics -n "__fish_seen_subcommand_from sites; and not __fish_seen_subcommand_from add update" -a "add" -d "Create a new site"
|
|
303
|
+
complete -c supalytics -n "__fish_seen_subcommand_from sites; and not __fish_seen_subcommand_from add update" -a "update" -d "Update a site domain"
|
|
304
|
+
complete -c supalytics -n "__fish_seen_subcommand_from sites; and __fish_seen_subcommand_from update" -l domain -s d -d "New domain name"
|
|
282
305
|
|
|
283
306
|
# Completions command
|
|
284
307
|
complete -c supalytics -n "__fish_seen_subcommand_from completions" -a "bash zsh fish"
|
|
@@ -14,6 +14,7 @@ export const countriesCommand = new Command("countries")
|
|
|
14
14
|
.option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
|
|
15
15
|
.option("--no-revenue", "Exclude revenue metrics")
|
|
16
16
|
.option("--json", "Output as JSON")
|
|
17
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
17
18
|
.action(async (options) => {
|
|
18
19
|
const site = options.site || (await getDefaultSite());
|
|
19
20
|
|
|
@@ -51,6 +52,7 @@ export const countriesCommand = new Command("countries")
|
|
|
51
52
|
date_range: dateRange,
|
|
52
53
|
limit: parseInt(options.limit),
|
|
53
54
|
include_revenue: options.revenue !== false,
|
|
55
|
+
is_dev: options.test || false,
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
if (options.json) {
|
package/src/commands/events.ts
CHANGED
|
@@ -50,6 +50,7 @@ export const eventsCommand = new Command("events")
|
|
|
50
50
|
.option("-l, --limit <number>", "Number of results", "20")
|
|
51
51
|
.option("--no-revenue", "Exclude revenue metrics")
|
|
52
52
|
.option("--json", "Output as JSON")
|
|
53
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
53
54
|
.action(async (event, options) => {
|
|
54
55
|
const site = options.site || (await getDefaultSite());
|
|
55
56
|
|
|
@@ -61,7 +62,7 @@ export const eventsCommand = new Command("events")
|
|
|
61
62
|
try {
|
|
62
63
|
// If no event specified, list all events
|
|
63
64
|
if (!event) {
|
|
64
|
-
const response = await listEvents(site, options.period, parseInt(options.limit));
|
|
65
|
+
const response = await listEvents(site, options.period, parseInt(options.limit), options.test || false);
|
|
65
66
|
|
|
66
67
|
if (options.json) {
|
|
67
68
|
console.log(JSON.stringify(response, null, 2));
|
|
@@ -94,7 +95,8 @@ export const eventsCommand = new Command("events")
|
|
|
94
95
|
options.property,
|
|
95
96
|
options.period,
|
|
96
97
|
parseInt(options.limit),
|
|
97
|
-
options.revenue !== false
|
|
98
|
+
options.revenue !== false,
|
|
99
|
+
options.test || false
|
|
98
100
|
);
|
|
99
101
|
|
|
100
102
|
if (options.json) {
|
|
@@ -124,8 +126,8 @@ export const eventsCommand = new Command("events")
|
|
|
124
126
|
|
|
125
127
|
// If just event name, show event stats + properties
|
|
126
128
|
const [eventsResponse, propsResponse] = await Promise.all([
|
|
127
|
-
listEvents(site, options.period, 100), // Get all events to find this one
|
|
128
|
-
getEventProperties(site, event, options.period),
|
|
129
|
+
listEvents(site, options.period, 100, options.test || false), // Get all events to find this one
|
|
130
|
+
getEventProperties(site, event, options.period, options.test || false),
|
|
129
131
|
]);
|
|
130
132
|
|
|
131
133
|
// Find the specific event stats
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { basename } from "path";
|
|
4
|
+
import { getAuth, addSiteWithId } from "../config";
|
|
5
|
+
import { loginWithDeviceFlow } from "./login";
|
|
6
|
+
import { createSiteViaApi } from "./sites";
|
|
7
|
+
|
|
8
|
+
async function detectProjectIdentifier(): Promise<string> {
|
|
9
|
+
// Try package.json name
|
|
10
|
+
try {
|
|
11
|
+
const pkg = await Bun.file("package.json").json();
|
|
12
|
+
if (pkg.name) {
|
|
13
|
+
// Strip npm scope (@org/name -> name)
|
|
14
|
+
return pkg.name.replace(/^@[^/]+\//, "");
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// No package.json or invalid
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Try git remote origin
|
|
21
|
+
try {
|
|
22
|
+
const result = await Bun.$`git remote get-url origin`.text();
|
|
23
|
+
// Match both Unix and Windows path separators, and handle .git suffix
|
|
24
|
+
const match = result.trim().match(/[/\\]([^/\\]+?)(?:\.git)?$/);
|
|
25
|
+
if (match) return match[1];
|
|
26
|
+
} catch {
|
|
27
|
+
// Not a git repo or no remote
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fall back to directory name (cross-platform)
|
|
31
|
+
return basename(process.cwd()) || "my-project";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const initCommand = new Command("init")
|
|
35
|
+
.description("Quick setup: login, create site, get tracking snippet")
|
|
36
|
+
.argument("[identifier]", "Domain or project name (auto-detects if not provided)")
|
|
37
|
+
.action(async (identifier?: string) => {
|
|
38
|
+
try {
|
|
39
|
+
console.log();
|
|
40
|
+
|
|
41
|
+
// 1. Check if already logged in, if not do device flow
|
|
42
|
+
let auth = await getAuth();
|
|
43
|
+
if (!auth) {
|
|
44
|
+
console.log(chalk.bold("Step 1: Login"));
|
|
45
|
+
await loginWithDeviceFlow();
|
|
46
|
+
auth = await getAuth();
|
|
47
|
+
if (!auth) {
|
|
48
|
+
console.error(chalk.red("Login failed"));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
console.log(chalk.dim(`Logged in as ${auth.email}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Detect or use provided identifier
|
|
56
|
+
if (!identifier) {
|
|
57
|
+
identifier = await detectProjectIdentifier();
|
|
58
|
+
console.log(chalk.dim(`Detected project: ${identifier}`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. Create site
|
|
62
|
+
console.log();
|
|
63
|
+
console.log(chalk.bold("Step 2: Create site"));
|
|
64
|
+
console.log(chalk.dim("Creating site..."));
|
|
65
|
+
|
|
66
|
+
const result = await createSiteViaApi(auth.accessToken, identifier);
|
|
67
|
+
await addSiteWithId(result.site.domain, result.apiKey.key, result.site.site_id, result.site.id);
|
|
68
|
+
|
|
69
|
+
console.log(chalk.green(`✓ Created ${result.site.domain}`));
|
|
70
|
+
|
|
71
|
+
// 4. Show tracking snippet
|
|
72
|
+
console.log();
|
|
73
|
+
console.log(chalk.bold("Step 3: Add tracking snippet"));
|
|
74
|
+
console.log();
|
|
75
|
+
console.log(chalk.dim("Add this to your HTML <head>:"));
|
|
76
|
+
console.log();
|
|
77
|
+
console.log(
|
|
78
|
+
chalk.cyan(
|
|
79
|
+
` <script src="https://cdn.supalytics.co/script.js" data-site="${result.site.site_id}" defer></script>`
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
console.log();
|
|
83
|
+
|
|
84
|
+
// 5. Next steps
|
|
85
|
+
console.log(chalk.bold("Next steps:"));
|
|
86
|
+
console.log(chalk.dim(` • Run ${chalk.cyan("supalytics stats")} to see your analytics`));
|
|
87
|
+
console.log(
|
|
88
|
+
chalk.dim(
|
|
89
|
+
` • Update domain: ${chalk.cyan(`supalytics sites update ${identifier} --domain yourdomain.com`)}`
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
console.log();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
});
|
package/src/commands/login.ts
CHANGED
|
@@ -1,83 +1,227 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
-
import
|
|
3
|
+
import open from "open";
|
|
4
|
+
import { saveAuth, getAuth, CONFIG_FILE, addSiteWithId } from "../config";
|
|
4
5
|
import { mkdir } from "fs/promises";
|
|
5
6
|
import { dirname } from "path";
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const WEB_BASE = process.env.SUPALYTICS_WEB_URL || "https://www.supalytics.co";
|
|
8
9
|
|
|
9
|
-
interface
|
|
10
|
-
|
|
10
|
+
interface DeviceCodeResponse {
|
|
11
|
+
device_code: string;
|
|
12
|
+
user_code: string;
|
|
13
|
+
verification_uri: string;
|
|
14
|
+
verification_uri_complete: string;
|
|
15
|
+
expires_in: number;
|
|
16
|
+
interval: number;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
interface TokenResponse {
|
|
20
|
+
access_token: string;
|
|
21
|
+
token_type: string;
|
|
22
|
+
expires_in: number;
|
|
23
|
+
user: {
|
|
24
|
+
id: string;
|
|
25
|
+
email: string;
|
|
26
|
+
name: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TokenErrorResponse {
|
|
31
|
+
error: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function requestDeviceCode(): Promise<DeviceCodeResponse> {
|
|
35
|
+
let response: Response;
|
|
14
36
|
try {
|
|
15
|
-
|
|
16
|
-
const response = await fetch(`${API_BASE}/v1/analytics`, {
|
|
37
|
+
response = await fetch(`${WEB_BASE}/api/cli/auth/device`, {
|
|
17
38
|
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
|
-
}),
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
27
40
|
});
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new Error(`Network error: Could not connect to ${WEB_BASE}. Check your internet connection.`);
|
|
43
|
+
}
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error("Failed to start device authorization");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return response.json();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function pollForToken(
|
|
53
|
+
deviceCode: string,
|
|
54
|
+
interval: number,
|
|
55
|
+
expiresIn: number
|
|
56
|
+
): Promise<TokenResponse> {
|
|
57
|
+
const startTime = Date.now();
|
|
58
|
+
const timeoutMs = expiresIn * 1000;
|
|
59
|
+
|
|
60
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
62
|
+
|
|
63
|
+
const response = await fetch(`${WEB_BASE}/api/cli/auth/token`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const data = (await response.json()) as TokenResponse | TokenErrorResponse;
|
|
70
|
+
|
|
71
|
+
if (response.ok) {
|
|
72
|
+
return data as TokenResponse;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const errorData = data as TokenErrorResponse;
|
|
76
|
+
|
|
77
|
+
if (errorData.error === "authorization_pending") {
|
|
78
|
+
// Still waiting for user to approve - keep polling
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (errorData.error === "expired_token") {
|
|
83
|
+
throw new Error("Authorization timed out. Please try again.");
|
|
31
84
|
}
|
|
32
85
|
|
|
33
|
-
|
|
34
|
-
|
|
86
|
+
if (errorData.error === "access_denied") {
|
|
87
|
+
throw new Error("Access was denied.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
throw new Error(`Authorization failed: ${errorData.error}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error("Authorization timed out. Please try again.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface SyncSite {
|
|
97
|
+
site: {
|
|
98
|
+
id: string; // Website UUID for API operations
|
|
99
|
+
site_id: string; // Site ID for tracking snippet
|
|
100
|
+
domain: string;
|
|
101
|
+
name: string | null;
|
|
102
|
+
};
|
|
103
|
+
apiKey: {
|
|
104
|
+
key: string;
|
|
105
|
+
prefix: string;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function syncSites(accessToken: string): Promise<SyncSite[]> {
|
|
110
|
+
let response: Response;
|
|
111
|
+
try {
|
|
112
|
+
response = await fetch(`${WEB_BASE}/api/cli/sites/sync`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: {
|
|
115
|
+
Authorization: `Bearer ${accessToken}`,
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
},
|
|
118
|
+
});
|
|
35
119
|
} catch {
|
|
36
|
-
|
|
120
|
+
throw new Error("Network error while syncing sites");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error("Failed to sync sites");
|
|
37
125
|
}
|
|
126
|
+
|
|
127
|
+
const data = await response.json();
|
|
128
|
+
return data.sites;
|
|
38
129
|
}
|
|
39
130
|
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
131
|
+
export async function loginWithDeviceFlow(forceResync = false): Promise<void> {
|
|
132
|
+
// Check if already logged in
|
|
133
|
+
const existingAuth = await getAuth();
|
|
134
|
+
if (existingAuth && !forceResync) {
|
|
135
|
+
console.log(chalk.dim(`Already logged in as ${existingAuth.email}`));
|
|
136
|
+
console.log(chalk.dim(`Run 'supalytics login --resync' to refresh API keys, or 'supalytics logout' to log out.`));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// If resyncing, just sync sites with existing auth
|
|
141
|
+
if (existingAuth && forceResync) {
|
|
142
|
+
console.log(chalk.dim("Resyncing sites..."));
|
|
143
|
+
try {
|
|
144
|
+
const sites = await syncSites(existingAuth.accessToken);
|
|
145
|
+
if (sites.length > 0) {
|
|
146
|
+
for (const { site, apiKey } of sites) {
|
|
147
|
+
await addSiteWithId(site.domain, apiKey.key, site.site_id, site.id);
|
|
148
|
+
}
|
|
149
|
+
console.log(chalk.green(`✓ Synced ${sites.length} site${sites.length > 1 ? "s" : ""}`));
|
|
150
|
+
} else {
|
|
151
|
+
console.log(chalk.dim("No sites found."));
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
console.log(chalk.yellow("Could not sync. Your session may have expired. Run 'supalytics logout' then 'supalytics login'."));
|
|
51
155
|
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
52
158
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const result = await verifyApiKey(apiKey);
|
|
159
|
+
console.log();
|
|
160
|
+
console.log(chalk.dim("Starting device authorization..."));
|
|
56
161
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
162
|
+
// 1. Request device code
|
|
163
|
+
const { device_code, user_code, verification_uri_complete, interval, expires_in } =
|
|
164
|
+
await requestDeviceCode();
|
|
61
165
|
|
|
62
|
-
|
|
63
|
-
|
|
166
|
+
// 2. Display instructions
|
|
167
|
+
console.log();
|
|
168
|
+
console.log(` Visit: ${chalk.cyan(verification_uri_complete)}`);
|
|
169
|
+
console.log(` Code: ${chalk.bold(user_code)}`);
|
|
170
|
+
console.log();
|
|
64
171
|
|
|
65
|
-
|
|
66
|
-
|
|
172
|
+
// 3. Open browser (don't fail if it can't open)
|
|
173
|
+
try {
|
|
174
|
+
await open(verification_uri_complete);
|
|
175
|
+
} catch {
|
|
176
|
+
console.log(chalk.yellow("Could not open browser automatically."));
|
|
177
|
+
console.log(chalk.dim("Please open the URL above manually."));
|
|
178
|
+
}
|
|
67
179
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
180
|
+
// 4. Poll for token
|
|
181
|
+
console.log(chalk.dim("Waiting for authorization..."));
|
|
182
|
+
console.log(chalk.dim("(Press Ctrl+C to cancel)"));
|
|
183
|
+
console.log();
|
|
72
184
|
|
|
73
|
-
|
|
74
|
-
const isDefault = config.defaultSite === result.domain;
|
|
75
|
-
const siteCount = Object.keys(config.sites).length;
|
|
185
|
+
const tokenResponse = await pollForToken(device_code, interval, expires_in);
|
|
76
186
|
|
|
77
|
-
|
|
78
|
-
|
|
187
|
+
// 5. Ensure config directory exists
|
|
188
|
+
await mkdir(dirname(CONFIG_FILE), { recursive: true });
|
|
79
189
|
|
|
80
|
-
|
|
81
|
-
|
|
190
|
+
// 6. Save auth
|
|
191
|
+
await saveAuth({
|
|
192
|
+
accessToken: tokenResponse.access_token,
|
|
193
|
+
email: tokenResponse.user.email,
|
|
194
|
+
name: tokenResponse.user.name,
|
|
195
|
+
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
console.log(chalk.green(`✓ Logged in as ${tokenResponse.user.email}`));
|
|
199
|
+
|
|
200
|
+
// 7. Sync existing sites
|
|
201
|
+
console.log(chalk.dim("Syncing sites..."));
|
|
202
|
+
try {
|
|
203
|
+
const sites = await syncSites(tokenResponse.access_token);
|
|
204
|
+
if (sites.length > 0) {
|
|
205
|
+
for (const { site, apiKey } of sites) {
|
|
206
|
+
await addSiteWithId(site.domain, apiKey.key, site.site_id, site.id);
|
|
207
|
+
}
|
|
208
|
+
console.log(chalk.green(`✓ Synced ${sites.length} site${sites.length > 1 ? "s" : ""}`));
|
|
209
|
+
} else {
|
|
210
|
+
console.log(chalk.dim("No sites found. Create one with `supalytics sites add <name>`"));
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
console.log(chalk.dim("Could not sync sites. You can add sites manually."));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const loginCommand = new Command("login")
|
|
218
|
+
.description("Authenticate with Supalytics")
|
|
219
|
+
.option("--resync", "Refresh API keys for all sites")
|
|
220
|
+
.action(async (options: { resync?: boolean }) => {
|
|
221
|
+
try {
|
|
222
|
+
await loginWithDeviceFlow(options.resync || false);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
225
|
+
process.exit(1);
|
|
82
226
|
}
|
|
83
227
|
});
|
package/src/commands/logout.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { CONFIG_FILE } from "../config";
|
|
|
4
4
|
import { unlink } from "fs/promises";
|
|
5
5
|
|
|
6
6
|
export const logoutCommand = new Command("logout")
|
|
7
|
-
.description("
|
|
7
|
+
.description("Log out and remove all stored credentials")
|
|
8
8
|
.action(async () => {
|
|
9
9
|
try {
|
|
10
10
|
await unlink(CONFIG_FILE);
|
|
11
|
-
console.log(chalk.green("✓ Logged out
|
|
11
|
+
console.log(chalk.green("✓ Logged out"));
|
|
12
12
|
} catch {
|
|
13
13
|
console.log(chalk.dim("Already logged out"));
|
|
14
14
|
}
|
package/src/commands/pages.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const pagesCommand = new Command("pages")
|
|
|
14
14
|
.option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
|
|
15
15
|
.option("--no-revenue", "Exclude revenue metrics")
|
|
16
16
|
.option("--json", "Output as JSON")
|
|
17
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
17
18
|
.action(async (options) => {
|
|
18
19
|
const site = options.site || (await getDefaultSite());
|
|
19
20
|
|
|
@@ -51,6 +52,7 @@ export const pagesCommand = new Command("pages")
|
|
|
51
52
|
date_range: dateRange,
|
|
52
53
|
limit: parseInt(options.limit),
|
|
53
54
|
include_revenue: options.revenue !== false,
|
|
55
|
+
is_dev: options.test || false,
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
if (options.json) {
|
package/src/commands/query.ts
CHANGED
|
@@ -49,6 +49,7 @@ export const queryCommand = new Command("query")
|
|
|
49
49
|
.option("--offset <number>", "Skip results for pagination", "0")
|
|
50
50
|
.option("--no-revenue", "Exclude revenue metrics")
|
|
51
51
|
.option("--json", "Output as JSON")
|
|
52
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
52
53
|
.action(async (options) => {
|
|
53
54
|
const site = options.site || (await getDefaultSite());
|
|
54
55
|
|
|
@@ -105,6 +106,7 @@ export const queryCommand = new Command("query")
|
|
|
105
106
|
limit: parseInt(options.limit),
|
|
106
107
|
offset: parseInt(options.offset),
|
|
107
108
|
include_revenue: options.revenue !== false,
|
|
109
|
+
is_dev: options.test || false,
|
|
108
110
|
});
|
|
109
111
|
|
|
110
112
|
if (options.json) {
|
package/src/commands/realtime.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const realtimeCommand = new Command("realtime")
|
|
|
9
9
|
.option("-s, --site <site>", "Site to query")
|
|
10
10
|
.option("--json", "Output as JSON")
|
|
11
11
|
.option("-w, --watch", "Auto-refresh every 30 seconds")
|
|
12
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
12
13
|
.action(async (options) => {
|
|
13
14
|
const site = options.site || (await getDefaultSite());
|
|
14
15
|
|
|
@@ -19,7 +20,7 @@ export const realtimeCommand = new Command("realtime")
|
|
|
19
20
|
|
|
20
21
|
const display = async () => {
|
|
21
22
|
try {
|
|
22
|
-
const response = await getRealtime(site);
|
|
23
|
+
const response = await getRealtime(site, options.test || false);
|
|
23
24
|
|
|
24
25
|
if (options.json) {
|
|
25
26
|
console.log(JSON.stringify(response, null, 2));
|
|
@@ -14,6 +14,7 @@ export const referrersCommand = new Command("referrers")
|
|
|
14
14
|
.option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
|
|
15
15
|
.option("--no-revenue", "Exclude revenue metrics")
|
|
16
16
|
.option("--json", "Output as JSON")
|
|
17
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
17
18
|
.action(async (options) => {
|
|
18
19
|
const site = options.site || (await getDefaultSite());
|
|
19
20
|
|
|
@@ -51,6 +52,7 @@ export const referrersCommand = new Command("referrers")
|
|
|
51
52
|
date_range: dateRange,
|
|
52
53
|
limit: parseInt(options.limit),
|
|
53
54
|
include_revenue: options.revenue !== false,
|
|
55
|
+
is_dev: options.test || false,
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
if (options.json) {
|
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
|
@@ -14,6 +14,7 @@ export const statsCommand = new Command("stats")
|
|
|
14
14
|
.option("-a, --all", "Show detailed breakdown (pages, referrers, countries, etc.)")
|
|
15
15
|
.option("--no-revenue", "Exclude revenue metrics")
|
|
16
16
|
.option("--json", "Output as JSON")
|
|
17
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
17
18
|
.action(async (period, options) => {
|
|
18
19
|
const site = options.site || (await getDefaultSite());
|
|
19
20
|
|
|
@@ -51,19 +52,20 @@ export const statsCommand = new Command("stats")
|
|
|
51
52
|
filters,
|
|
52
53
|
date_range: dateRange,
|
|
53
54
|
include_revenue: options.revenue !== false,
|
|
55
|
+
is_dev: options.test || false,
|
|
54
56
|
}),
|
|
55
57
|
];
|
|
56
58
|
|
|
57
59
|
// Add breakdown calls if --all flag
|
|
58
60
|
if (options.all) {
|
|
59
|
-
const breakdownOpts = { filters, date_range: dateRange, limit: 10 };
|
|
61
|
+
const breakdownOpts = { filters, date_range: dateRange, limit: 10, is_dev: options.test || false };
|
|
60
62
|
apiCalls.push(
|
|
61
63
|
query(site, { metrics: ["visitors"], dimensions: ["page"], ...breakdownOpts }),
|
|
62
64
|
query(site, { metrics: ["visitors"], dimensions: ["referrer"], ...breakdownOpts }),
|
|
63
65
|
query(site, { metrics: ["visitors"], dimensions: ["country"], ...breakdownOpts }),
|
|
64
66
|
query(site, { metrics: ["visitors"], dimensions: ["browser"], ...breakdownOpts }),
|
|
65
67
|
query(site, { metrics: ["visitors"], dimensions: ["utm_source"], ...breakdownOpts }),
|
|
66
|
-
listEvents(site, typeof dateRange === "string" ? dateRange : "30d", 10)
|
|
68
|
+
listEvents(site, typeof dateRange === "string" ? dateRange : "30d", 10, options.test || false)
|
|
67
69
|
);
|
|
68
70
|
}
|
|
69
71
|
|
package/src/commands/trend.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const trendCommand = new Command("trend")
|
|
|
14
14
|
.option("--no-revenue", "Exclude revenue metrics")
|
|
15
15
|
.option("--compact", "Show compact sparkline only")
|
|
16
16
|
.option("--json", "Output as JSON")
|
|
17
|
+
.option("-t, --test", "Test mode: query localhost data instead of production")
|
|
17
18
|
.action(async (options) => {
|
|
18
19
|
const site = options.site || (await getDefaultSite());
|
|
19
20
|
|
|
@@ -52,6 +53,7 @@ export const trendCommand = new Command("trend")
|
|
|
52
53
|
date_range: dateRange,
|
|
53
54
|
limit: 1000, // Get all days
|
|
54
55
|
include_revenue: options.revenue !== false,
|
|
56
|
+
is_dev: options.test || false,
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
if (options.json) {
|
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
|
@@ -12,17 +12,23 @@ import { queryCommand } from "./commands/query";
|
|
|
12
12
|
import { eventsCommand } from "./commands/events";
|
|
13
13
|
import { realtimeCommand } from "./commands/realtime";
|
|
14
14
|
import { completionsCommand } from "./commands/completions";
|
|
15
|
+
import { initCommand } from "./commands/init";
|
|
15
16
|
|
|
16
17
|
const description = `CLI for Supalytics web analytics.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
List sites: supalytics sites
|
|
21
|
-
Set default: supalytics default <domain>
|
|
22
|
-
Remove site: supalytics remove <domain>
|
|
23
|
-
Query other: supalytics stats --site <domain>
|
|
19
|
+
Quick Start:
|
|
20
|
+
supalytics init Auto-setup: login → create site → get snippet
|
|
24
21
|
|
|
25
|
-
|
|
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
|
|
26
32
|
|
|
27
33
|
Date Ranges:
|
|
28
34
|
--period: 7d, 14d, 30d, 90d, 12mo, all (default: 30d)
|
|
@@ -63,6 +69,9 @@ program
|
|
|
63
69
|
.description(description)
|
|
64
70
|
.version(pkg.version);
|
|
65
71
|
|
|
72
|
+
// Quick start
|
|
73
|
+
program.addCommand(initCommand);
|
|
74
|
+
|
|
66
75
|
// Auth & site management commands
|
|
67
76
|
program.addCommand(loginCommand);
|
|
68
77
|
program.addCommand(logoutCommand);
|