@xyleapp/cli 0.1.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 ADDED
@@ -0,0 +1,66 @@
1
+ # xyle
2
+
3
+ CLI for the [Xyle](https://xyle.app) SEO Intelligence Engine.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g xyle
9
+ ```
10
+
11
+ Requires Node.js 18+.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # Check API connectivity
17
+ xyle status
18
+
19
+ # Authenticate with Google (opens browser)
20
+ xyle login
21
+
22
+ # List top queries for your site
23
+ xyle queries --site example.com
24
+
25
+ # Analyze competitor pages
26
+ xyle competitors --query "best seo tools"
27
+
28
+ # Find content gaps
29
+ xyle gaps --page https://example.com/blog/seo-guide
30
+
31
+ # Get AI rewrite suggestions
32
+ xyle rewrite --url https://example.com/blog/seo-guide --type title
33
+
34
+ # Crawl a page
35
+ xyle crawl --url https://example.com/blog/seo-guide
36
+
37
+ # Sync Search Console data
38
+ xyle sync --site https://example.com
39
+ ```
40
+
41
+ ## Commands
42
+
43
+ | Command | Description |
44
+ | ------------- | ---------------------------------------- |
45
+ | `status` | Check if the SEO API is reachable |
46
+ | `queries` | List top search queries for a site |
47
+ | `competitors` | Show competitor pages for a query |
48
+ | `gaps` | Show content gaps for a page |
49
+ | `analyze` | Analyze page content against competitors |
50
+ | `rewrite` | Get AI rewrite suggestions |
51
+ | `crawl` | Crawl a URL and extract SEO metadata |
52
+ | `sync` | Sync Google Search Console data |
53
+ | `login` | Authenticate with Google OAuth |
54
+ | `logout` | Remove stored credentials |
55
+ | `whoami` | Show current authentication status |
56
+
57
+ All data commands accept `--json` for machine-readable output.
58
+
59
+ ## Configuration
60
+
61
+ | Environment Variable | Default | Description |
62
+ | -------------------- | ------------------------ | -------------------- |
63
+ | `SEO_BASE` | `http://localhost:8765` | API base URL |
64
+ | `AGENT_API_KEY` | `local-agent-secret-key` | Fallback API key |
65
+
66
+ Credentials are stored in `~/.config/xyle/credentials.json` (shared with the Python CLI).
package/bin/xyle.mjs ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import { registerCommands } from "../src/commands.mjs";
5
+
6
+ const program = new Command();
7
+
8
+ program
9
+ .name("xyle")
10
+ .description("SEO Intelligence Engine CLI")
11
+ .version("0.1.0");
12
+
13
+ registerCommands(program);
14
+
15
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@xyleapp/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for the Xyle SEO Intelligence Engine",
5
+ "type": "module",
6
+ "bin": {
7
+ "xyle": "bin/xyle.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "seo",
19
+ "cli",
20
+ "search-console",
21
+ "xyle"
22
+ ],
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "commander": "^13.0.0",
26
+ "open": "^10.0.0"
27
+ }
28
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * HTTP client for the Xyle SEO API.
3
+ * Uses native fetch (Node 18+). Zero external dependencies.
4
+ */
5
+
6
+ import { getApiKey } from "./auth.mjs";
7
+
8
+ const SEO_BASE = process.env.SEO_BASE || "http://localhost:8765";
9
+
10
+ /**
11
+ * Build auth headers, preferring personal API key over shared env key.
12
+ */
13
+ function getHeaders() {
14
+ let key;
15
+ try {
16
+ key = getApiKey();
17
+ } catch {
18
+ key = null;
19
+ }
20
+ if (!key) {
21
+ key = process.env.AGENT_API_KEY || "local-agent-secret-key";
22
+ }
23
+ return {
24
+ "X-Agent-Key": key,
25
+ "Content-Type": "application/json",
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Make an API request. Throws on non-OK responses with a structured error.
31
+ */
32
+ async function request(method, path, { params, body, timeout = 30000, auth = true } = {}) {
33
+ let url = `${SEO_BASE}${path}`;
34
+ if (params) {
35
+ const qs = new URLSearchParams();
36
+ for (const [k, v] of Object.entries(params)) {
37
+ if (v != null) qs.set(k, String(v));
38
+ }
39
+ const qsStr = qs.toString();
40
+ if (qsStr) url += `?${qsStr}`;
41
+ }
42
+
43
+ const headers = auth ? getHeaders() : { "Content-Type": "application/json" };
44
+ const opts = { method, headers, signal: AbortSignal.timeout(timeout) };
45
+ if (body) opts.body = JSON.stringify(body);
46
+
47
+ const resp = await fetch(url, opts);
48
+ if (!resp.ok) {
49
+ let detail;
50
+ try {
51
+ const json = await resp.json();
52
+ detail = json.detail || resp.statusText;
53
+ } catch {
54
+ detail = resp.statusText;
55
+ }
56
+ const err = new Error(`Error ${resp.status}: ${detail}`);
57
+ err.status = resp.status;
58
+ throw err;
59
+ }
60
+ return resp.json();
61
+ }
62
+
63
+ export function checkHealth() {
64
+ return request("GET", "/health", { auth: false, timeout: 10000 });
65
+ }
66
+
67
+ export function getTopQueries(site, limit = 20) {
68
+ return request("GET", "/queries", { params: { site, limit } });
69
+ }
70
+
71
+ export function getCompetitors(query) {
72
+ return request("GET", "/competitors", { params: { query } });
73
+ }
74
+
75
+ export function getPageGaps(page, query) {
76
+ return request("GET", "/gaps", { params: { page, query } });
77
+ }
78
+
79
+ export function analyzePage(url, content, query) {
80
+ const body = { url, content };
81
+ if (query) body.query = query;
82
+ return request("POST", "/analyze", { body, timeout: 60000 });
83
+ }
84
+
85
+ export function getRewriteSuggestions(url, type = "title", query) {
86
+ const body = { url, type, context: {} };
87
+ if (query) body.query = query;
88
+ return request("POST", "/rewrite", { body, timeout: 60000 });
89
+ }
90
+
91
+ export function crawlPage(url) {
92
+ return request("POST", "/crawl", { body: { url } });
93
+ }
94
+
95
+ export function syncGsc(site) {
96
+ return request("POST", "/admin/sync", { params: { site } });
97
+ }
98
+
99
+ /**
100
+ * Fetch the OAuth auth URL from the API.
101
+ */
102
+ export function getAuthUrl(cliPort) {
103
+ return request("GET", "/admin/gsc/auth-url", {
104
+ params: { cli_port: cliPort },
105
+ auth: false,
106
+ timeout: 10000,
107
+ });
108
+ }
109
+
110
+ export { SEO_BASE };
package/src/auth.mjs ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * OAuth flow + credential storage for the xyle CLI.
3
+ * Credentials file (~/.config/xyle/credentials.json) is shared with the Python CLI.
4
+ */
5
+
6
+ import { createServer } from "node:http";
7
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+
11
+ const CREDENTIALS_DIR = join(homedir(), ".config", "xyle");
12
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json");
13
+ const CALLBACK_PORT = 9876;
14
+
15
+ /**
16
+ * Load stored credentials from disk.
17
+ * @returns {object|null}
18
+ */
19
+ export function getCredentials() {
20
+ try {
21
+ const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
22
+ return JSON.parse(raw);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Return the stored personal API key, or null.
30
+ * @returns {string|null}
31
+ */
32
+ export function getApiKey() {
33
+ const creds = getCredentials();
34
+ if (creds && creds.authenticated) return creds.api_key || null;
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Persist credentials to ~/.config/xyle/credentials.json (mode 600).
40
+ * @param {object} creds
41
+ */
42
+ export function saveCredentials(creds) {
43
+ mkdirSync(CREDENTIALS_DIR, { recursive: true });
44
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), "utf-8");
45
+ chmodSync(CREDENTIALS_FILE, 0o600);
46
+ }
47
+
48
+ /**
49
+ * Remove stored credentials.
50
+ */
51
+ export function clearCredentials() {
52
+ try {
53
+ unlinkSync(CREDENTIALS_FILE);
54
+ } catch {
55
+ // already gone
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Browser-based OAuth login flow.
61
+ *
62
+ * 1. CLI starts local HTTP server on CALLBACK_PORT
63
+ * 2. CLI asks API for the Google OAuth URL (with cli_port in state)
64
+ * 3. Browser -> Google consent -> API callback -> redirects to CLI server
65
+ * 4. CLI server receives status, stores credentials
66
+ *
67
+ * @param {string} seoBase - API base URL
68
+ * @returns {Promise<object|null>}
69
+ */
70
+ export async function runLoginFlow(seoBase) {
71
+ // Dynamic import so the rest of the module works without `open` at load time
72
+ const { default: openBrowser } = await import("open");
73
+ const { getAuthUrl } = await import("./api.mjs");
74
+
75
+ // Get auth URL from API
76
+ let authUrl;
77
+ try {
78
+ const data = await getAuthUrl(CALLBACK_PORT);
79
+ authUrl = data.auth_url;
80
+ } catch (e) {
81
+ if (e.cause && e.cause.code === "ECONNREFUSED") {
82
+ console.error("\x1b[31mAPI is not running. Start it first.\x1b[0m");
83
+ } else {
84
+ console.error(`\x1b[31mFailed to get auth URL from API: ${e.message}\x1b[0m`);
85
+ }
86
+ return null;
87
+ }
88
+
89
+ return new Promise((resolve) => {
90
+ let settled = false;
91
+
92
+ const server = createServer((req, res) => {
93
+ const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
94
+ if (url.pathname !== "/callback") {
95
+ res.writeHead(404);
96
+ res.end();
97
+ return;
98
+ }
99
+
100
+ const status = url.searchParams.get("status");
101
+ const message = url.searchParams.get("message") || "";
102
+
103
+ if (status === "authenticated") {
104
+ const creds = {
105
+ authenticated: true,
106
+ seo_base: seoBase,
107
+ message,
108
+ };
109
+ const apiKey = url.searchParams.get("api_key");
110
+ const email = url.searchParams.get("email");
111
+ const name = url.searchParams.get("name");
112
+ if (apiKey) creds.api_key = apiKey;
113
+ if (email) creds.email = email;
114
+ if (name) creds.name = name;
115
+
116
+ saveCredentials(creds);
117
+
118
+ res.writeHead(200, { "Content-Type": "text/html" });
119
+ res.end(
120
+ "<html><body style='font-family:system-ui;text-align:center;padding:60px'>" +
121
+ "<h2 style='color:#22c55e'>Login successful!</h2>" +
122
+ "<p>You can close this tab and return to the terminal.</p>" +
123
+ "</body></html>"
124
+ );
125
+
126
+ settled = true;
127
+ server.close();
128
+ resolve(creds);
129
+ } else if (status === "error") {
130
+ res.writeHead(400, { "Content-Type": "text/html" });
131
+ res.end(
132
+ "<html><body style='font-family:system-ui;text-align:center;padding:60px'>" +
133
+ "<h2 style='color:#ef4444'>Login failed</h2>" +
134
+ `<p>${message}</p>` +
135
+ "</body></html>"
136
+ );
137
+
138
+ settled = true;
139
+ server.close();
140
+ console.error(`\x1b[31mLogin failed: ${message}\x1b[0m`);
141
+ resolve(null);
142
+ } else {
143
+ res.writeHead(404);
144
+ res.end();
145
+ }
146
+ });
147
+
148
+ server.listen(CALLBACK_PORT, "localhost", () => {
149
+ console.log("Opening browser for Google login...");
150
+ console.log(`If the browser doesn't open, visit:\n${authUrl}\n`);
151
+ openBrowser(authUrl).catch(() => {});
152
+ });
153
+
154
+ // 120-second timeout
155
+ setTimeout(() => {
156
+ if (!settled) {
157
+ settled = true;
158
+ server.close();
159
+ console.error("\x1b[31mLogin timed out (2 minutes). Try again.\x1b[0m");
160
+ resolve(null);
161
+ }
162
+ }, 120_000);
163
+ });
164
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * All 11 CLI commands for the xyle CLI.
3
+ * Mirrors the Python CLI 1:1.
4
+ */
5
+
6
+ import { createRequire } from "node:module";
7
+ import { printJson, printTable } from "./formatting.mjs";
8
+ import {
9
+ checkHealth,
10
+ getTopQueries,
11
+ getCompetitors,
12
+ getPageGaps,
13
+ analyzePage,
14
+ getRewriteSuggestions,
15
+ crawlPage,
16
+ syncGsc,
17
+ SEO_BASE,
18
+ } from "./api.mjs";
19
+ import { getCredentials, clearCredentials, runLoginFlow } from "./auth.mjs";
20
+
21
+ /**
22
+ * Print error message in red to stderr and exit.
23
+ */
24
+ function handleError(e) {
25
+ const msg = e.message || String(e);
26
+ process.stderr.write(`\x1b[31m${msg}\x1b[0m\n`);
27
+ process.exit(1);
28
+ }
29
+
30
+ /**
31
+ * Register all commands on a Commander program instance.
32
+ * @param {import('commander').Command} program
33
+ */
34
+ export function registerCommands(program) {
35
+ // --- status ---
36
+ program
37
+ .command("status")
38
+ .description("Check if the SEO API is reachable")
39
+ .option("--json", "Output as JSON")
40
+ .action(async (opts) => {
41
+ try {
42
+ const data = await checkHealth();
43
+ if (opts.json) {
44
+ console.log(printJson(data));
45
+ } else {
46
+ const s = data.status || "unknown";
47
+ const color = s === "ok" ? "\x1b[32m" : "\x1b[31m";
48
+ console.log(`${color}API status: ${s}\x1b[0m`);
49
+ }
50
+ } catch (e) {
51
+ if (e.cause && e.cause.code === "ECONNREFUSED") {
52
+ process.stderr.write("\x1b[31mAPI unreachable\x1b[0m\n");
53
+ process.exit(1);
54
+ }
55
+ handleError(e);
56
+ }
57
+ });
58
+
59
+ // --- queries ---
60
+ program
61
+ .command("queries")
62
+ .description("List top search queries for a site")
63
+ .requiredOption("--site <domain>", "Site domain or URL")
64
+ .option("--limit <n>", "Max queries to return", "20")
65
+ .option("--json", "Output as JSON")
66
+ .action(async (opts) => {
67
+ try {
68
+ const data = await getTopQueries(opts.site, parseInt(opts.limit, 10));
69
+ if (opts.json) {
70
+ console.log(printJson(data));
71
+ } else {
72
+ console.log(printTable(data, ["query", "impressions", "clicks", "ctr", "position"]));
73
+ }
74
+ } catch (e) {
75
+ handleError(e);
76
+ }
77
+ });
78
+
79
+ // --- competitors ---
80
+ program
81
+ .command("competitors")
82
+ .description("Show competitor pages for a query")
83
+ .requiredOption("--query <query>", "Search query to analyze")
84
+ .option("--json", "Output as JSON")
85
+ .action(async (opts) => {
86
+ try {
87
+ const data = await getCompetitors(opts.query);
88
+ if (opts.json) {
89
+ console.log(printJson(data));
90
+ } else {
91
+ console.log(printTable(data, ["position", "url", "title", "word_count"]));
92
+ }
93
+ } catch (e) {
94
+ handleError(e);
95
+ }
96
+ });
97
+
98
+ // --- gaps ---
99
+ program
100
+ .command("gaps")
101
+ .description("Show content gaps for a page")
102
+ .requiredOption("--page <url>", "Page URL to check gaps for")
103
+ .option("--query <query>", "Optional query to filter")
104
+ .option("--json", "Output as JSON")
105
+ .action(async (opts) => {
106
+ try {
107
+ const data = await getPageGaps(opts.page, opts.query);
108
+ if (opts.json) {
109
+ console.log(printJson(data));
110
+ } else {
111
+ console.log(
112
+ printTable(data, ["query", "missing_topics", "ctr_issue", "position_bucket"])
113
+ );
114
+ }
115
+ } catch (e) {
116
+ handleError(e);
117
+ }
118
+ });
119
+
120
+ // --- analyze ---
121
+ program
122
+ .command("analyze")
123
+ .description("Analyze page content against competitors")
124
+ .requiredOption("--url <url>", "Page URL to analyze")
125
+ .requiredOption("--content <text>", "Page content text")
126
+ .option("--query <query>", "Target query for analysis")
127
+ .option("--json", "Output as JSON")
128
+ .action(async (opts) => {
129
+ try {
130
+ const data = await analyzePage(opts.url, opts.content, opts.query);
131
+ if (opts.json) {
132
+ console.log(printJson(data));
133
+ } else {
134
+ const score = data.score || 0;
135
+ const color = score >= 0.7 ? "\x1b[32m" : "\x1b[33m";
136
+ console.log(`${color}Score: ${Math.round(score * 100)}%\x1b[0m`);
137
+ console.log(`Summary: ${data.summary || ""}`);
138
+ const missing = data.missing_topics || [];
139
+ if (missing.length) {
140
+ console.log(`Missing topics: ${missing.join(", ")}`);
141
+ }
142
+ }
143
+ } catch (e) {
144
+ handleError(e);
145
+ }
146
+ });
147
+
148
+ // --- rewrite ---
149
+ program
150
+ .command("rewrite")
151
+ .description("Get AI rewrite suggestions for a page element")
152
+ .requiredOption("--url <url>", "Page URL to rewrite")
153
+ .option("--type <element>", "Element to rewrite: title, meta, heading, section", "title")
154
+ .option("--query <query>", "Target query for rewrite")
155
+ .option("--json", "Output as JSON")
156
+ .action(async (opts) => {
157
+ try {
158
+ const data = await getRewriteSuggestions(opts.url, opts.type, opts.query);
159
+ if (opts.json) {
160
+ console.log(printJson(data));
161
+ } else {
162
+ if (data.original) {
163
+ console.log(`\x1b[31mOriginal: ${data.original}\x1b[0m`);
164
+ }
165
+ console.log(`\x1b[32mSuggested: ${data.suggested}\x1b[0m`);
166
+ console.log(`Reasoning: ${data.reasoning}`);
167
+ }
168
+ } catch (e) {
169
+ handleError(e);
170
+ }
171
+ });
172
+
173
+ // --- crawl ---
174
+ program
175
+ .command("crawl")
176
+ .description("Crawl a URL and extract SEO metadata")
177
+ .requiredOption("--url <url>", "URL to crawl")
178
+ .option("--json", "Output as JSON")
179
+ .action(async (opts) => {
180
+ try {
181
+ const data = await crawlPage(opts.url);
182
+ if (opts.json) {
183
+ console.log(printJson(data));
184
+ } else {
185
+ console.log(`Title: ${data.title || "-"}`);
186
+ console.log(`Meta desc: ${data.meta_desc || "-"}`);
187
+ console.log(`Word count: ${data.word_count || 0}`);
188
+ const headings = data.headings || [];
189
+ if (headings.length) {
190
+ console.log("Headings:");
191
+ for (const h of headings) {
192
+ console.log(` ${h}`);
193
+ }
194
+ }
195
+ }
196
+ } catch (e) {
197
+ handleError(e);
198
+ }
199
+ });
200
+
201
+ // --- sync ---
202
+ program
203
+ .command("sync")
204
+ .description("Sync Google Search Console data")
205
+ .requiredOption("--site <url>", "Site URL to sync")
206
+ .option("--json", "Output as JSON")
207
+ .action(async (opts) => {
208
+ try {
209
+ const data = await syncGsc(opts.site);
210
+ if (opts.json) {
211
+ console.log(printJson(data));
212
+ } else {
213
+ console.log(
214
+ `\x1b[32mSynced ${data.synced_queries || 0} queries for ${data.site || opts.site}\x1b[0m`
215
+ );
216
+ }
217
+ } catch (e) {
218
+ handleError(e);
219
+ }
220
+ });
221
+
222
+ // --- login ---
223
+ program
224
+ .command("login")
225
+ .description("Authenticate with Google for Search Console access")
226
+ .action(async () => {
227
+ const seoBase = process.env.SEO_BASE || "http://localhost:8765";
228
+
229
+ const existing = getCredentials();
230
+ if (existing && existing.authenticated) {
231
+ const readline = await import("node:readline");
232
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
233
+ const answer = await new Promise((resolve) => {
234
+ rl.question("Already logged in. Re-authenticate? [y/N] ", resolve);
235
+ });
236
+ rl.close();
237
+ if (answer.toLowerCase() !== "y") return;
238
+ }
239
+
240
+ const result = await runLoginFlow(seoBase);
241
+ if (result) {
242
+ const email = result.email || "";
243
+ if (email) {
244
+ console.log(`\x1b[32mLogged in as ${email}\x1b[0m`);
245
+ } else {
246
+ console.log("\x1b[32mLogin successful! Credentials saved.\x1b[0m");
247
+ }
248
+ } else {
249
+ process.stderr.write("\x1b[31mLogin failed.\x1b[0m\n");
250
+ process.exit(1);
251
+ }
252
+ });
253
+
254
+ // --- logout ---
255
+ program
256
+ .command("logout")
257
+ .description("Remove stored credentials")
258
+ .action(() => {
259
+ clearCredentials();
260
+ console.log("\x1b[32mLogged out. Credentials removed.\x1b[0m");
261
+ });
262
+
263
+ // --- whoami ---
264
+ program
265
+ .command("whoami")
266
+ .description("Show current authentication status")
267
+ .action(() => {
268
+ const creds = getCredentials();
269
+ if (creds && creds.authenticated) {
270
+ const email = creds.email;
271
+ if (email) {
272
+ console.log(`\x1b[32mLogged in as ${email}\x1b[0m`);
273
+ } else {
274
+ console.log("\x1b[32mAuthenticated\x1b[0m");
275
+ }
276
+ const apiKey = creds.api_key || "";
277
+ if (apiKey) {
278
+ const prefix = apiKey.slice(0, 12);
279
+ const suffix = apiKey.length > 16 ? apiKey.slice(-4) : "";
280
+ console.log(`API key: ${prefix}...${suffix}`);
281
+ }
282
+ console.log(`API: ${creds.seo_base || "unknown"}`);
283
+ } else {
284
+ console.log("\x1b[33mNot authenticated. Run: xyle login\x1b[0m");
285
+ }
286
+ });
287
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Output formatting helpers for the xyle CLI.
3
+ */
4
+
5
+ /**
6
+ * Pretty-print data as JSON.
7
+ * @param {any} data
8
+ * @returns {string}
9
+ */
10
+ export function printJson(data) {
11
+ return JSON.stringify(data, null, 2);
12
+ }
13
+
14
+ /**
15
+ * Render rows as an ASCII table matching the Python CLI output.
16
+ * @param {Array<Object>} rows
17
+ * @param {string[]} columns
18
+ * @returns {string}
19
+ */
20
+ export function printTable(rows, columns) {
21
+ if (!rows || rows.length === 0) return "(no data)";
22
+
23
+ const widths = {};
24
+ for (const col of columns) {
25
+ widths[col] = col.length;
26
+ }
27
+
28
+ const strRows = [];
29
+ for (const row of rows) {
30
+ const strRow = {};
31
+ for (const col of columns) {
32
+ const val = row[col];
33
+ let cell;
34
+ if (typeof val === "number" && !Number.isInteger(val)) {
35
+ cell = val < 1 ? val.toFixed(3) : val.toFixed(1);
36
+ } else if (Array.isArray(val)) {
37
+ cell = val.join(", ");
38
+ } else if (val == null) {
39
+ cell = "-";
40
+ } else {
41
+ cell = String(val);
42
+ }
43
+ strRow[col] = cell;
44
+ widths[col] = Math.max(widths[col], cell.length);
45
+ }
46
+ strRows.push(strRow);
47
+ }
48
+
49
+ const header = columns.map((c) => c.toUpperCase().padEnd(widths[c])).join(" ");
50
+ const separator = columns.map((c) => "-".repeat(widths[c])).join(" ");
51
+ const lines = [header, separator];
52
+ for (const strRow of strRows) {
53
+ lines.push(columns.map((c) => strRow[c].padEnd(widths[c])).join(" "));
54
+ }
55
+ return lines.join("\n");
56
+ }