@supalytics/cli 0.2.0 → 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 CHANGED
@@ -20,10 +20,19 @@ bun add -g @supalytics/cli
20
20
 
21
21
  ## Setup
22
22
 
23
- Get your API key from the Settings page at [www.supalytics.co](https://www.supalytics.co), then:
23
+ ### Quick Start
24
24
 
25
25
  ```bash
26
- supalytics login <your-api-key>
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,11 +114,12 @@ All commands support:
104
114
  -f "referrer:is:twitter.com"
105
115
  ```
106
116
 
107
- ## Multi-site Support
117
+ ## Site Management
108
118
 
109
119
  ```bash
110
- supalytics login <api-key> # Add a site
111
120
  supalytics sites # List all sites
121
+ supalytics sites add # Create a new site
122
+ supalytics sites update # Update site settings
112
123
  supalytics default <domain> # Set default site
113
124
  supalytics stats -s other.com # Query specific site
114
125
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supalytics/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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 response = await fetch(`${API_BASE}/v1/realtime`, {
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="--domain"
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
- 'login:Add a site by verifying its API key'
68
- 'logout:Remove stored credentials'
69
- 'sites:List all configured sites'
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
- _arguments \\
181
- '1:API key:' \\
182
- '--domain[Override detected domain]:domain:'
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 "login" -d "Add a site by verifying its API key"
207
- complete -c supalytics -n "__fish_use_subcommand" -a "logout" -d "Remove stored credentials"
208
- complete -c supalytics -n "__fish_use_subcommand" -a "sites" -d "List all configured sites"
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
- # Login options
281
- complete -c supalytics -n "__fish_seen_subcommand_from login" -l domain -d "Override detected domain"
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) {
@@ -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
+ });
@@ -1,83 +1,207 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
- import { addSite, CONFIG_FILE, getConfig, setDefaultSite } from "../config";
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 API_BASE = process.env.SUPALYTICS_API_URL || "http://localhost:3000";
8
+ const WEB_BASE = process.env.SUPALYTICS_WEB_URL || "https://www.supalytics.co";
8
9
 
9
- interface VerifyResponse {
10
- domain: string;
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
- async function verifyApiKey(apiKey: string): Promise<VerifyResponse | null> {
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
- // Use a minimal analytics query to verify the key and get the domain
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
- if (!response.ok) {
30
- return null;
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
- const data = await response.json();
34
- return { domain: data.meta.domain };
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
- return null;
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 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
- }
131
+ export async function loginWithDeviceFlow(): Promise<void> {
132
+ // Check if already logged in
133
+ const existingAuth = await getAuth();
134
+ if (existingAuth) {
135
+ console.log(chalk.dim(`Already logged in as ${existingAuth.email}`));
136
+ console.log(chalk.dim(`Run 'supalytics logout' to log out first.`));
137
+ return;
138
+ }
52
139
 
53
- // Verify API key with server
54
- console.log(chalk.dim("Verifying API key..."));
55
- const result = await verifyApiKey(apiKey);
140
+ console.log();
141
+ console.log(chalk.dim("Starting device authorization..."));
56
142
 
57
- if (!result) {
58
- console.error(chalk.red("Error: Invalid API key or unable to verify"));
59
- process.exit(1);
60
- }
143
+ // 1. Request device code
144
+ const { device_code, user_code, verification_uri_complete, interval, expires_in } =
145
+ await requestDeviceCode();
61
146
 
62
- // Ensure config directory exists
63
- await mkdir(dirname(CONFIG_FILE), { recursive: true });
147
+ // 2. Display instructions
148
+ console.log();
149
+ console.log(` Visit: ${chalk.cyan(verification_uri_complete)}`);
150
+ console.log(` Code: ${chalk.bold(user_code)}`);
151
+ console.log();
64
152
 
65
- // Add site to config
66
- await addSite(result.domain, apiKey);
153
+ // 3. Open browser (don't fail if it can't open)
154
+ try {
155
+ await open(verification_uri_complete);
156
+ } catch {
157
+ console.log(chalk.yellow("Could not open browser automatically."));
158
+ console.log(chalk.dim("Please open the URL above manually."));
159
+ }
67
160
 
68
- // Set as default if requested
69
- if (options.default) {
70
- await setDefaultSite(result.domain);
71
- }
161
+ // 4. Poll for token
162
+ console.log(chalk.dim("Waiting for authorization..."));
163
+ console.log(chalk.dim("(Press Ctrl+C to cancel)"));
164
+ console.log();
72
165
 
73
- const config = await getConfig();
74
- const isDefault = config.defaultSite === result.domain;
75
- const siteCount = Object.keys(config.sites).length;
166
+ const tokenResponse = await pollForToken(device_code, interval, expires_in);
76
167
 
77
- console.log(chalk.green(`✓ Added ${result.domain}`) + (isDefault ? chalk.dim(" (default)") : ""));
78
- console.log(chalk.dim(` ${siteCount} site${siteCount > 1 ? "s" : ""} configured`));
168
+ // 5. Ensure config directory exists
169
+ await mkdir(dirname(CONFIG_FILE), { recursive: true });
79
170
 
80
- if (siteCount > 1 && !isDefault) {
81
- console.log(chalk.dim(` Use --default flag or 'supalytics default ${result.domain}' to set as default`));
171
+ // 6. Save auth
172
+ await saveAuth({
173
+ accessToken: tokenResponse.access_token,
174
+ email: tokenResponse.user.email,
175
+ name: tokenResponse.user.name,
176
+ expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
177
+ });
178
+
179
+ console.log(chalk.green(`✓ Logged in as ${tokenResponse.user.email}`));
180
+
181
+ // 7. Sync existing sites
182
+ console.log(chalk.dim("Syncing sites..."));
183
+ try {
184
+ const sites = await syncSites(tokenResponse.access_token);
185
+ if (sites.length > 0) {
186
+ for (const { site, apiKey } of sites) {
187
+ await addSiteWithId(site.domain, apiKey.key, site.site_id, site.id);
188
+ }
189
+ console.log(chalk.green(`✓ Synced ${sites.length} site${sites.length > 1 ? "s" : ""}`));
190
+ } else {
191
+ console.log(chalk.dim("No sites found. Create one with `supalytics sites add <name>`"));
192
+ }
193
+ } catch {
194
+ console.log(chalk.dim("Could not sync sites. You can add sites manually."));
195
+ }
196
+ }
197
+
198
+ export const loginCommand = new Command("login")
199
+ .description("Authenticate with Supalytics")
200
+ .action(async () => {
201
+ try {
202
+ await loginWithDeviceFlow();
203
+ } catch (error) {
204
+ console.error(chalk.red(`Error: ${(error as Error).message}`));
205
+ process.exit(1);
82
206
  }
83
207
  });
@@ -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("Remove stored credentials")
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 successfully"));
11
+ console.log(chalk.green("✓ Logged out"));
12
12
  } catch {
13
13
  console.log(chalk.dim("Already logged out"));
14
14
  }
@@ -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) {
@@ -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) {
@@ -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) {
@@ -1,6 +1,67 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
- import { getConfig, removeSite, setDefaultSite } from "../config";
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. Run `supalytics login <api-key>` to add a site."));
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")
@@ -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
 
@@ -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
- Multi-site Support:
19
- Add sites: supalytics login <api-key> (auto-detects domain from API key)
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
- API keys start with 'sly_' and can be created at https://supalytics.co/settings/api
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);