@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.
@@ -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) {
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { query, formatNumber, formatPercent, formatDuration } from "../api";
4
4
  import { getDefaultSite } from "../config";
5
+ import { table, type TableColumn } from "../ui";
5
6
 
6
7
  const queryDescription = `Flexible query with custom metrics and dimensions.
7
8
 
@@ -36,7 +37,7 @@ Examples:
36
37
  export const queryCommand = new Command("query")
37
38
  .description(queryDescription)
38
39
  .option("-s, --site <site>", "Site to query")
39
- .option("-m, --metrics <metrics>", "Metrics: visitors, bounce_rate, avg_session_duration, revenue, conversions", "visitors")
40
+ .option("-m, --metrics <metrics>", "Metrics: visitors, bounce_rate, avg_session_duration, revenue, conversions", "visitors,revenue")
40
41
  .option("-d, --dimensions <dimensions>", "Dimensions: page, referrer, country, region, city, browser, os, device, date, hour, event, utm_* (max 2, event cannot combine)")
41
42
  .option("-f, --filter <filters...>", "Filters: field:operator:value (e.g., 'page:contains:/blog', 'event:is:signup', 'event_property:is:plan:premium')")
42
43
  .option("--sort <sort>", "Sort by field:order (e.g., 'revenue:desc', 'visitors:asc')")
@@ -48,6 +49,7 @@ export const queryCommand = new Command("query")
48
49
  .option("--offset <number>", "Skip results for pagination", "0")
49
50
  .option("--no-revenue", "Exclude revenue metrics")
50
51
  .option("--json", "Output as JSON")
52
+ .option("-t, --test", "Test mode: query localhost data instead of production")
51
53
  .action(async (options) => {
52
54
  const site = options.site || (await getDefaultSite());
53
55
 
@@ -104,6 +106,7 @@ export const queryCommand = new Command("query")
104
106
  limit: parseInt(options.limit),
105
107
  offset: parseInt(options.offset),
106
108
  include_revenue: options.revenue !== false,
109
+ is_dev: options.test || false,
107
110
  });
108
111
 
109
112
  if (options.json) {
@@ -126,34 +129,56 @@ export const queryCommand = new Command("query")
126
129
  return;
127
130
  }
128
131
 
129
- // Display results
130
- for (const result of response.data) {
131
- const parts: string[] = [];
132
-
133
- // Dimensions
134
- if (result.dimensions) {
135
- for (const [key, value] of Object.entries(result.dimensions)) {
136
- parts.push(`${chalk.dim(key)}=${value}`);
137
- }
132
+ // Build columns from data
133
+ const columns: TableColumn[] = [];
134
+
135
+ // Add dimension columns
136
+ if (response.data[0]?.dimensions) {
137
+ for (const key of Object.keys(response.data[0].dimensions)) {
138
+ columns.push({
139
+ key: `dim_${key}`,
140
+ label: key.toUpperCase(),
141
+ align: "left",
142
+ });
138
143
  }
144
+ }
145
+
146
+ // Add metric columns
147
+ for (const key of Object.keys(response.data[0]?.metrics || {})) {
148
+ columns.push({
149
+ key: `met_${key}`,
150
+ label: key.toUpperCase().replaceAll("_", " "),
151
+ align: "right",
152
+ format: (val) => {
153
+ if (val === null || val === undefined) return "-";
154
+ const v = val as number;
155
+ if (key === "bounce_rate" || key === "conversion_rate") {
156
+ return formatPercent(v);
157
+ } else if (key === "avg_session_duration") {
158
+ return formatDuration(v);
159
+ } else if (key === "revenue") {
160
+ return chalk.green("$" + (v / 100).toFixed(2));
161
+ }
162
+ return formatNumber(v);
163
+ },
164
+ });
165
+ }
139
166
 
140
- // Metrics
141
- for (const [key, value] of Object.entries(result.metrics)) {
142
- let formatted: string;
143
- if (key === "bounce_rate" || key === "conversion_rate") {
144
- formatted = formatPercent(value);
145
- } else if (key === "avg_session_duration") {
146
- formatted = formatDuration(value);
147
- } else if (key === "revenue") {
148
- formatted = chalk.green("$" + (value / 100).toFixed(2));
149
- } else {
150
- formatted = formatNumber(value);
167
+ // Transform data for table
168
+ const tableData = response.data.map((r) => {
169
+ const row: Record<string, unknown> = {};
170
+ if (r.dimensions) {
171
+ for (const [k, v] of Object.entries(r.dimensions)) {
172
+ row[`dim_${k}`] = v || "(none)";
151
173
  }
152
- parts.push(`${chalk.dim(key)}=${formatted}`);
153
174
  }
175
+ for (const [k, v] of Object.entries(r.metrics)) {
176
+ row[`met_${k}`] = v;
177
+ }
178
+ return row;
179
+ });
154
180
 
155
- console.log(` ${parts.join(" ")}`);
156
- }
181
+ console.log(table(tableData, columns));
157
182
  console.log();
158
183
  } catch (error) {
159
184
  console.error(chalk.red(`Error: ${(error as Error).message}`));
@@ -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) {