@supalytics/cli 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -4
- package/package.json +3 -2
- package/src/api.ts +23 -5
- package/src/commands/completions.ts +328 -0
- package/src/commands/countries.ts +2 -0
- package/src/commands/events.ts +50 -10
- package/src/commands/init.ts +97 -0
- package/src/commands/login.ts +179 -55
- package/src/commands/logout.ts +2 -2
- package/src/commands/pages.ts +2 -0
- package/src/commands/query.ts +49 -24
- package/src/commands/realtime.ts +2 -1
- package/src/commands/referrers.ts +2 -0
- package/src/commands/sites.ts +194 -2
- package/src/commands/stats.ts +96 -9
- package/src/commands/trend.ts +15 -1
- package/src/config.ts +62 -0
- package/src/index.ts +22 -8
- package/src/ui.ts +111 -0
|
@@ -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,207 @@
|
|
|
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
|
-
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
|
-
|
|
54
|
-
|
|
55
|
-
const result = await verifyApiKey(apiKey);
|
|
140
|
+
console.log();
|
|
141
|
+
console.log(chalk.dim("Starting device authorization..."));
|
|
56
142
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
143
|
+
// 1. Request device code
|
|
144
|
+
const { device_code, user_code, verification_uri_complete, interval, expires_in } =
|
|
145
|
+
await requestDeviceCode();
|
|
61
146
|
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
168
|
+
// 5. Ensure config directory exists
|
|
169
|
+
await mkdir(dirname(CONFIG_FILE), { recursive: true });
|
|
79
170
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
});
|
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
|
@@ -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
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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}`));
|
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) {
|