@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.
@@ -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")
@@ -1,8 +1,8 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
- import { query, formatNumber, formatPercent, formatDuration } from "../api";
3
+ import { query, formatNumber, formatPercent, formatDuration, listEvents } from "../api";
4
4
  import { getDefaultSite } from "../config";
5
- import { logo, parsePeriod } from "../ui";
5
+ import { logo, parsePeriod, truncate } from "../ui";
6
6
 
7
7
  export const statsCommand = new Command("stats")
8
8
  .description("Overview stats: pageviews, visitors, bounce rate, revenue")
@@ -11,8 +11,10 @@ export const statsCommand = new Command("stats")
11
11
  .option("--start <date>", "Start date (YYYY-MM-DD)")
12
12
  .option("--end <date>", "End date (YYYY-MM-DD)")
13
13
  .option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
14
+ .option("-a, --all", "Show detailed breakdown (pages, referrers, countries, etc.)")
14
15
  .option("--no-revenue", "Exclude revenue metrics")
15
16
  .option("--json", "Output as JSON")
17
+ .option("-t, --test", "Test mode: query localhost data instead of production")
16
18
  .action(async (period, options) => {
17
19
  const site = options.site || (await getDefaultSite());
18
20
 
@@ -43,15 +45,47 @@ export const statsCommand = new Command("stats")
43
45
  metrics.push("revenue", "conversions", "conversion_rate");
44
46
  }
45
47
 
46
- const response = await query(site, {
47
- metrics,
48
- filters,
49
- date_range: dateRange,
50
- include_revenue: options.revenue !== false,
51
- });
48
+ // Build API calls
49
+ const apiCalls: Promise<unknown>[] = [
50
+ query(site, {
51
+ metrics,
52
+ filters,
53
+ date_range: dateRange,
54
+ include_revenue: options.revenue !== false,
55
+ is_dev: options.test || false,
56
+ }),
57
+ ];
58
+
59
+ // Add breakdown calls if --all flag
60
+ if (options.all) {
61
+ const breakdownOpts = { filters, date_range: dateRange, limit: 10, is_dev: options.test || false };
62
+ apiCalls.push(
63
+ query(site, { metrics: ["visitors"], dimensions: ["page"], ...breakdownOpts }),
64
+ query(site, { metrics: ["visitors"], dimensions: ["referrer"], ...breakdownOpts }),
65
+ query(site, { metrics: ["visitors"], dimensions: ["country"], ...breakdownOpts }),
66
+ query(site, { metrics: ["visitors"], dimensions: ["browser"], ...breakdownOpts }),
67
+ query(site, { metrics: ["visitors"], dimensions: ["utm_source"], ...breakdownOpts }),
68
+ listEvents(site, typeof dateRange === "string" ? dateRange : "30d", 10, options.test || false)
69
+ );
70
+ }
71
+
72
+ const results = await Promise.all(apiCalls);
73
+ const response = results[0] as Awaited<ReturnType<typeof query>>;
52
74
 
53
75
  if (options.json) {
54
- console.log(JSON.stringify(response, null, 2));
76
+ if (options.all) {
77
+ console.log(JSON.stringify({
78
+ stats: results[0],
79
+ pages: results[1],
80
+ referrers: results[2],
81
+ countries: results[3],
82
+ browsers: results[4],
83
+ utm_sources: results[5],
84
+ events: results[6],
85
+ }, null, 2));
86
+ } else {
87
+ console.log(JSON.stringify(response, null, 2));
88
+ }
55
89
  return;
56
90
  }
57
91
 
@@ -96,6 +130,59 @@ export const statsCommand = new Command("stats")
96
130
  console.log(` ${chalk.bold(convRate)}`);
97
131
  }
98
132
 
133
+ // Detailed breakdowns if --all
134
+ if (options.all) {
135
+ const pagesRes = results[1] as Awaited<ReturnType<typeof query>>;
136
+ const referrersRes = results[2] as Awaited<ReturnType<typeof query>>;
137
+ const countriesRes = results[3] as Awaited<ReturnType<typeof query>>;
138
+ const browsersRes = results[4] as Awaited<ReturnType<typeof query>>;
139
+ const utmRes = results[5] as Awaited<ReturnType<typeof query>>;
140
+ const eventsRes = results[6] as Awaited<ReturnType<typeof listEvents>>;
141
+
142
+ const renderBreakdown = (
143
+ title: string,
144
+ data: { dimensions?: Record<string, string>; metrics: Record<string, number> }[],
145
+ dimKey: string,
146
+ metricKey: string = "visitors",
147
+ metricLabel: string = "VISITORS"
148
+ ) => {
149
+ console.log();
150
+ console.log(chalk.dim(" ────────────────────────────────────────"));
151
+ console.log();
152
+ console.log(` ${chalk.dim(title.padEnd(36))}${chalk.dim(metricLabel)}`);
153
+ if (data.length === 0) {
154
+ console.log(chalk.dim(" (no data)"));
155
+ } else {
156
+ for (const row of data) {
157
+ const dim = truncate(row.dimensions?.[dimKey] || "(direct)", 34);
158
+ const val = formatNumber(row.metrics[metricKey] || 0);
159
+ console.log(` ${dim.padEnd(36)}${val.padStart(8)}`);
160
+ }
161
+ }
162
+ };
163
+
164
+ renderBreakdown("PAGES", pagesRes.data, "page");
165
+ renderBreakdown("REFERRERS", referrersRes.data, "referrer");
166
+ renderBreakdown("COUNTRIES", countriesRes.data, "country");
167
+ renderBreakdown("BROWSERS", browsersRes.data, "browser");
168
+ renderBreakdown("UTM SOURCES", utmRes.data, "utm_source");
169
+
170
+ // Events have different structure
171
+ console.log();
172
+ console.log(chalk.dim(" ────────────────────────────────────────"));
173
+ console.log();
174
+ console.log(` ${chalk.dim("EVENTS".padEnd(36))}${chalk.dim("COUNT")}`);
175
+ if (eventsRes.data.length === 0) {
176
+ console.log(chalk.dim(" (no events)"));
177
+ } else {
178
+ for (const event of eventsRes.data) {
179
+ const name = truncate(event.name, 34);
180
+ const count = formatNumber(event.count);
181
+ console.log(` ${name.padEnd(36)}${count.padStart(8)}`);
182
+ }
183
+ }
184
+ }
185
+
99
186
  console.log();
100
187
  } catch (error) {
101
188
  console.error(chalk.red(`Error: ${(error as Error).message}`));
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { query, formatNumber } from "../api";
4
4
  import { getDefaultSite } from "../config";
5
+ import { coloredSparkline } from "../ui";
5
6
 
6
7
  export const trendCommand = new Command("trend")
7
8
  .description("Daily visitor trend")
@@ -11,7 +12,9 @@ export const trendCommand = new Command("trend")
11
12
  .option("--end <date>", "End date (YYYY-MM-DD)")
12
13
  .option("-f, --filter <filters...>", "Filters in format 'field:operator:value' (e.g., 'is:country:US')")
13
14
  .option("--no-revenue", "Exclude revenue metrics")
15
+ .option("--compact", "Show compact sparkline only")
14
16
  .option("--json", "Output as JSON")
17
+ .option("-t, --test", "Test mode: query localhost data instead of production")
15
18
  .action(async (options) => {
16
19
  const site = options.site || (await getDefaultSite());
17
20
 
@@ -50,6 +53,7 @@ export const trendCommand = new Command("trend")
50
53
  date_range: dateRange,
51
54
  limit: 1000, // Get all days
52
55
  include_revenue: options.revenue !== false,
56
+ is_dev: options.test || false,
53
57
  });
54
58
 
55
59
  if (options.json) {
@@ -68,8 +72,18 @@ export const trendCommand = new Command("trend")
68
72
  return;
69
73
  }
70
74
 
75
+ const visitors = response.data.map((r) => r.metrics.visitors || 0);
76
+ const total = visitors.reduce((a, b) => a + b, 0);
77
+
78
+ // Compact mode - just sparkline
79
+ if (options.compact) {
80
+ console.log(` ${coloredSparkline(visitors)} ${formatNumber(total)} visitors`);
81
+ console.log();
82
+ return;
83
+ }
84
+
71
85
  // Find max for sparkline scaling
72
- const maxVisitors = Math.max(...response.data.map((r) => r.metrics.visitors || 0));
86
+ const maxVisitors = Math.max(...visitors);
73
87
 
74
88
  for (const result of response.data) {
75
89
  const date = result.dimensions?.date || "";
package/src/config.ts CHANGED
@@ -6,9 +6,20 @@ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
6
6
 
7
7
  interface SiteConfig {
8
8
  apiKey: string;
9
+ siteId?: string; // The site_id for tracking snippet
10
+ id?: string; // The website UUID for API operations
11
+ }
12
+
13
+ interface AuthConfig {
14
+ accessToken: string;
15
+ email: string;
16
+ name: string;
17
+ expiresAt?: string;
9
18
  }
10
19
 
11
20
  interface Config {
21
+ // User authentication
22
+ auth?: AuthConfig;
12
23
  // Map of domain -> API key
13
24
  sites: Record<string, SiteConfig>;
14
25
  // Default site domain
@@ -93,4 +104,55 @@ export async function getSites(): Promise<string[]> {
93
104
  return Object.keys(config.sites);
94
105
  }
95
106
 
107
+ // Auth functions
108
+ export async function getAuth(): Promise<AuthConfig | undefined> {
109
+ const config = await getConfig();
110
+ return config.auth;
111
+ }
112
+
113
+ export async function saveAuth(auth: AuthConfig): Promise<void> {
114
+ const config = await getConfig();
115
+ config.auth = auth;
116
+ await saveConfig(config);
117
+ }
118
+
119
+ export async function clearAuth(): Promise<void> {
120
+ const config = await getConfig();
121
+ delete config.auth;
122
+ await saveConfig(config);
123
+ }
124
+
125
+ export async function isAuthenticated(): Promise<boolean> {
126
+ const auth = await getAuth();
127
+ if (!auth?.accessToken) return false;
128
+ // Check expiration if set
129
+ if (auth.expiresAt && new Date(auth.expiresAt) < new Date()) {
130
+ return false;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ // Add site with siteId and optional website id
136
+ export async function addSiteWithId(
137
+ domain: string,
138
+ apiKey: string,
139
+ siteId: string,
140
+ id?: string
141
+ ): Promise<void> {
142
+ const config = await getConfig();
143
+ config.sites[domain] = { apiKey, siteId, id };
144
+ // Set as default if it's the first site
145
+ if (!config.defaultSite || Object.keys(config.sites).length === 1) {
146
+ config.defaultSite = domain;
147
+ }
148
+ await saveConfig(config);
149
+ }
150
+
151
+ // Get site ID for a domain
152
+ export async function getSiteId(domain: string): Promise<string | undefined> {
153
+ const config = await getConfig();
154
+ return config.sites[domain]?.siteId;
155
+ }
156
+
96
157
  export { CONFIG_DIR, CONFIG_FILE };
158
+ export type { AuthConfig, SiteConfig, Config };
package/src/index.ts CHANGED
@@ -11,17 +11,24 @@ import { trendCommand } from "./commands/trend";
11
11
  import { queryCommand } from "./commands/query";
12
12
  import { eventsCommand } from "./commands/events";
13
13
  import { realtimeCommand } from "./commands/realtime";
14
+ import { completionsCommand } from "./commands/completions";
15
+ import { initCommand } from "./commands/init";
14
16
 
15
17
  const description = `CLI for Supalytics web analytics.
16
18
 
17
- Multi-site Support:
18
- Add sites: supalytics login <api-key> (auto-detects domain from API key)
19
- List sites: supalytics sites
20
- Set default: supalytics default <domain>
21
- Remove site: supalytics remove <domain>
22
- Query other: supalytics stats --site <domain>
19
+ Quick Start:
20
+ supalytics init Auto-setup: login create site get snippet
23
21
 
24
- 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
25
32
 
26
33
  Date Ranges:
27
34
  --period: 7d, 14d, 30d, 90d, 12mo, all (default: 30d)
@@ -54,10 +61,16 @@ Output:
54
61
  --json Raw JSON output (useful for piping to other tools or AI)
55
62
  --no-revenue Exclude revenue metrics from output`;
56
63
 
64
+ // Read version from package.json
65
+ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
66
+
57
67
  program
58
68
  .name("supalytics")
59
69
  .description(description)
60
- .version("0.1.0");
70
+ .version(pkg.version);
71
+
72
+ // Quick start
73
+ program.addCommand(initCommand);
61
74
 
62
75
  // Auth & site management commands
63
76
  program.addCommand(loginCommand);
@@ -75,5 +88,6 @@ program.addCommand(trendCommand);
75
88
  program.addCommand(queryCommand);
76
89
  program.addCommand(eventsCommand);
77
90
  program.addCommand(realtimeCommand);
91
+ program.addCommand(completionsCommand);
78
92
 
79
93
  program.parse();
package/src/ui.ts CHANGED
@@ -77,3 +77,114 @@ export function formatRevenue(cents: number): string {
77
77
  }
78
78
  return chalk.green("$" + (cents / 100).toFixed(0));
79
79
  }
80
+
81
+ // Sparkline characters from lowest to highest
82
+ const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
83
+
84
+ /**
85
+ * Generate a sparkline from an array of numbers
86
+ */
87
+ export function sparkline(values: number[]): string {
88
+ if (values.length === 0) return "";
89
+
90
+ const min = Math.min(...values);
91
+ const max = Math.max(...values);
92
+ const range = max - min || 1;
93
+
94
+ return values
95
+ .map((v) => {
96
+ const normalized = (v - min) / range;
97
+ const index = Math.min(Math.floor(normalized * SPARK_CHARS.length), SPARK_CHARS.length - 1);
98
+ return SPARK_CHARS[index];
99
+ })
100
+ .join("");
101
+ }
102
+
103
+ /**
104
+ * Generate a colored sparkline (green for up, red for down trend)
105
+ */
106
+ export function coloredSparkline(values: number[]): string {
107
+ if (values.length === 0) return "";
108
+
109
+ const spark = sparkline(values);
110
+ const first = values[0];
111
+ const last = values[values.length - 1];
112
+
113
+ if (last > first) {
114
+ return chalk.green(spark);
115
+ } else if (last < first) {
116
+ return chalk.red(spark);
117
+ }
118
+ return chalk.dim(spark);
119
+ }
120
+
121
+ export interface TableColumn {
122
+ key: string;
123
+ label?: string;
124
+ align?: "left" | "right";
125
+ width?: number;
126
+ format?: (value: unknown) => string;
127
+ }
128
+
129
+ export interface TableOptions {
130
+ indent?: number;
131
+ showHeader?: boolean;
132
+ headerColor?: (s: string) => string;
133
+ }
134
+
135
+ /**
136
+ * Render a table with proper column alignment
137
+ */
138
+ export function table(
139
+ data: Record<string, unknown>[],
140
+ columns: TableColumn[],
141
+ options: TableOptions = {}
142
+ ): string {
143
+ const { indent = 2, showHeader = true, headerColor = chalk.dim } = options;
144
+ const prefix = " ".repeat(indent);
145
+ const lines: string[] = [];
146
+
147
+ // Calculate column widths
148
+ const widths = columns.map((col) => {
149
+ const label = col.label || col.key;
150
+ const dataWidth = Math.max(
151
+ ...data.map((row) => {
152
+ const val = row[col.key];
153
+ const formatted = col.format ? col.format(val) : String(val ?? "");
154
+ // Strip ANSI codes for width calculation
155
+ return formatted.replace(/\x1b\[[0-9;]*m/g, "").length;
156
+ })
157
+ );
158
+ return col.width || Math.max(label.length, dataWidth);
159
+ });
160
+
161
+ // Header row
162
+ if (showHeader) {
163
+ const headerParts = columns.map((col, i) => {
164
+ const label = col.label || col.key.toUpperCase();
165
+ const width = widths[i];
166
+ return col.align === "right" ? label.padStart(width) : label.padEnd(width);
167
+ });
168
+ lines.push(prefix + headerColor(headerParts.join(" ")));
169
+ }
170
+
171
+ // Data rows
172
+ for (const row of data) {
173
+ const parts = columns.map((col, i) => {
174
+ const val = row[col.key];
175
+ const formatted = col.format ? col.format(val) : String(val ?? "");
176
+ const width = widths[i];
177
+ // Get display length (without ANSI codes)
178
+ const displayLen = formatted.replace(/\x1b\[[0-9;]*m/g, "").length;
179
+ const padding = Math.max(0, width - displayLen);
180
+
181
+ if (col.align === "right") {
182
+ return " ".repeat(padding) + formatted;
183
+ }
184
+ return formatted + " ".repeat(padding);
185
+ });
186
+ lines.push(prefix + parts.join(" "));
187
+ }
188
+
189
+ return lines.join("\n");
190
+ }