trackrev 0.1.0 → 0.2.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,79 @@
1
+ // Outbound webhook endpoints. The signing secret is shown once at creation.
2
+ import { die, warn, EXIT } from "../lib/api.js";
3
+ import { emit } from "../lib/output.js";
4
+ import { confirm } from "../lib/prompt.js";
5
+
6
+ const COLUMNS = [
7
+ { header: "url", value: (r) => r.url },
8
+ { header: "events", value: (r) => (r.events ?? []).join(",") },
9
+ { header: "active", value: (r) => r.active },
10
+ { header: "status", value: (r) => r.last_status },
11
+ { header: "lastfire",value: (r) => (r.last_at ?? "").slice(0, 16).replace("T", " ") || null },
12
+ { header: "id", value: (r) => r.id },
13
+ ];
14
+
15
+ export async function list({ flags, api }) {
16
+ const body = await api.get("/webhooks");
17
+ emit(COLUMNS, body.webhooks, body, {
18
+ json: flags.json,
19
+ empty: "No endpoints. Create one with: trackrev webhooks create",
20
+ });
21
+ }
22
+
23
+ export async function events({ flags, api }) {
24
+ const body = await api.get("/webhooks/events");
25
+ emit(
26
+ [
27
+ { header: "event", value: (r) => r.value },
28
+ { header: "fires when", value: (r) => r.label },
29
+ ],
30
+ body.events,
31
+ body,
32
+ { json: flags.json },
33
+ );
34
+ }
35
+
36
+ export async function create({ flags, api }) {
37
+ if (!flags.url) die("--url is required (https only).", EXIT.USAGE);
38
+ if (!flags.event || flags.event.length === 0) {
39
+ die("Pass at least one --event. See: trackrev webhooks events", EXIT.USAGE);
40
+ }
41
+ const body = await api.post("/webhooks", { url: flags.url, events: flags.event });
42
+
43
+ if (flags.json) {
44
+ console.log(JSON.stringify(body, null, 2));
45
+ return;
46
+ }
47
+ warn(`Created ${body.webhook.url} for ${body.webhook.events.join(", ")}.`);
48
+ warn("Signing secret (shown once):");
49
+ console.log(body.secret);
50
+ }
51
+
52
+ export async function update({ args, flags, api }) {
53
+ const [id] = args;
54
+ const patch = {};
55
+ if (flags.url !== undefined) patch.url = flags.url;
56
+ if (flags.event && flags.event.length) patch.events = flags.event;
57
+ if (flags.pause) patch.active = false;
58
+ if (flags.resume) patch.active = true;
59
+ if (flags.pause && flags.resume) die("Pass --pause or --resume, not both.", EXIT.USAGE);
60
+ if (Object.keys(patch).length === 0) {
61
+ die("Nothing to change. Run: trackrev webhooks update --help", EXIT.USAGE);
62
+ }
63
+
64
+ const hook = await api.patch(`/webhooks/${encodeURIComponent(id)}`, patch);
65
+ emit(COLUMNS, [hook], hook, { json: flags.json });
66
+ }
67
+
68
+ export async function remove({ args, flags, api }) {
69
+ const [id] = args;
70
+ const hook = await api.get(`/webhooks/${encodeURIComponent(id)}`);
71
+ const ok = await confirm(`Delete the endpoint ${hook.url}? Deliveries stop immediately.`, {
72
+ yes: flags.yes,
73
+ });
74
+ if (!ok) die("Cancelled.", EXIT.USAGE);
75
+
76
+ const res = await api.del(`/webhooks/${encodeURIComponent(hook.id)}`);
77
+ if (flags.json) console.log(JSON.stringify(res, null, 2));
78
+ else warn(`Deleted ${hook.url}.`);
79
+ }
package/src/index.js CHANGED
@@ -1,321 +1,70 @@
1
1
  #!/usr/bin/env node
2
- // trackrev — your link analytics, in the terminal.
2
+ // trackrev — TrackRev in the terminal.
3
3
  //
4
4
  // trackrev channels --days 7
5
+ // trackrev links create --url https://acme.com/launch --name Launch --channel youtube
5
6
  // trackrev clicks --all --json > clicks.json
6
7
  import { parseArgs } from "node:util";
7
- import { createRequire } from "node:module";
8
+ import { VERSION, resolve, optionsFor, helpText, helpFor, usageOf } from "./registry.js";
9
+ import { resolveAuth } from "./lib/config.js";
10
+ import { createApi, die, EXIT } from "./lib/api.js";
8
11
 
9
- const { version } = createRequire(import.meta.url)("../package.json");
10
- const BASE = process.env.TRACKREV_API_URL ?? "https://app.trackrev.io/api/v1";
11
- const KEY = process.env.TRACKREV_KEY;
12
+ const argv = process.argv.slice(2);
12
13
 
13
- const EXIT_FAIL = 1;
14
- const EXIT_USAGE = 2;
15
- const MAX_PAGES = 200;
16
-
17
- const HELP = `trackrev — your link analytics, in the terminal
18
-
19
- Usage
20
- trackrev channels [--ltv] performance per channel
21
- trackrev links [--limit N] performance per link
22
- trackrev clicks [--all] [--link ID] the raw click stream
23
- trackrev journey <visitor-id> one visitor's full timeline
24
-
25
- Window (channels, links)
26
- --days N last N days (default 30)
27
- --from ISO explicit start, e.g. 2026-01-01
28
- --to ISO explicit end (defaults to now)
29
-
30
- Options
31
- --ltv channels: add all-time lifetime value per channel
32
- --limit N links: rows to return; clicks: page size (max 500)
33
- --all clicks: follow the cursor to the end of the stream
34
- --link ID clicks: restrict to one link
35
- --bots clicks: include bot traffic (excluded by default)
36
- --json print raw JSON instead of a table
37
- --version print the version
38
- --help show this message
39
-
40
- Output
41
- A table when you are watching, tab-separated when piped or redirected,
42
- JSON with --json. Warnings and errors always go to stderr.
43
-
44
- Environment
45
- TRACKREV_KEY secret API key, from Settings → Developers
46
- TRACKREV_API_URL override the API base URL
47
- `;
48
-
49
- /* ── plumbing ───────────────────────────────────────────────────────────── */
50
-
51
- /** Anything that is not data goes to stderr, so it never lands in a pipe. */
52
- function warn(message) {
53
- console.error(message);
54
- }
55
-
56
- /** Print to stderr and quit with a failure code. */
57
- function die(message, code = EXIT_FAIL) {
58
- console.error(message);
59
- process.exit(code);
60
- }
61
-
62
- /** GET a path under the API base, returning the parsed JSON body. */
63
- async function api(path) {
64
- if (!KEY) die("No API key. Set TRACKREV_KEY to a secret key from Settings → Developers.");
65
-
66
- let res;
67
- try {
68
- res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${KEY}` } });
69
- } catch {
70
- return die(`Could not reach ${BASE} — is the server running?`);
71
- }
72
-
73
- if (!res.ok) {
74
- const body = await res.json().catch(() => null);
75
- const message = body?.error?.message ?? res.statusText;
76
- if (res.status === 401) die(`${message}. Check TRACKREV_KEY.`);
77
- if (res.status === 402) die(`${message}\nUpgrade at https://www.trackrev.io/pricing`);
78
- if (res.status === 404) die(message);
79
- die(`API error ${res.status}: ${message}`);
80
- }
81
- return res.json();
82
- }
83
-
84
- /* ── arguments ──────────────────────────────────────────────────────────── */
85
-
86
- // parseArgs throws on an unknown flag. Left uncaught that is a stack trace in
87
- // the user's face; caught, it is a one-line usage error.
88
- let parsed;
14
+ // Pass 1 — lenient. We don't know the command yet, so parse with the union of
15
+ // every flag (so `--days 7` consumes its value instead of leaking "7" into the
16
+ // positionals) and tolerate anything unknown.
17
+ let first;
89
18
  try {
90
- parsed = parseArgs({
91
- allowPositionals: true,
92
- options: {
93
- days: { type: "string", default: "30" },
94
- from: { type: "string" },
95
- to: { type: "string" },
96
- limit: { type: "string" },
97
- link: { type: "string" },
98
- ltv: { type: "boolean", default: false },
99
- all: { type: "boolean", default: false },
100
- bots: { type: "boolean", default: false },
101
- json: { type: "boolean", default: false },
102
- version: { type: "boolean", default: false },
103
- help: { type: "boolean", default: false },
104
- },
105
- });
19
+ first = parseArgs({ args: argv, allowPositionals: true, strict: false, options: optionsFor(null) });
106
20
  } catch (err) {
107
- console.error(`${err.message}\n\nRun: trackrev --help`);
108
- process.exit(EXIT_USAGE);
21
+ die(`${err.message}\n\nRun: trackrev --help`, EXIT.USAGE);
109
22
  }
110
- const { positionals, values } = parsed;
111
- const command = positionals[0];
112
23
 
113
- if (values.version) {
114
- console.log(version);
115
- process.exit(0);
116
- }
117
- if (values.help || !command) {
118
- console.log(HELP);
24
+ if (first.values.version) {
25
+ console.log(VERSION);
119
26
  process.exit(0);
120
27
  }
121
28
 
122
- /**
123
- * The reporting window as query params. `--from`/`--to` win over `--days`:
124
- * the server ignores `days` once either bound is present, so sending all three
125
- * would only be noise in the request log.
126
- */
127
- function windowParams() {
128
- const params = new URLSearchParams();
129
- if (values.from) params.set("from", values.from);
130
- if (values.to) params.set("to", values.to);
131
- if (!values.from && !values.to) params.set("days", values.days);
132
- return params;
133
- }
134
-
135
- /** Parse a numeric flag, failing with a usage error rather than sending NaN. */
136
- function intFlag(name, rawValue, { min = 1, max }) {
137
- const n = Number(rawValue);
138
- if (!Number.isInteger(n) || n < min || n > max) {
139
- die(`--${name} must be a whole number between ${min} and ${max}.`, EXIT_USAGE);
140
- }
141
- return n;
142
- }
143
-
144
- /* ── output ─────────────────────────────────────────────────────────────── */
145
-
146
- const TTY = process.stdout.isTTY;
147
-
148
- /** Raw value for a pipe: no padding, no separators, empty cell for null. */
149
- function rawCell(value) {
150
- return value === null || value === undefined ? "" : String(value);
151
- }
152
-
153
- /**
154
- * Human value for a table. `fixed` is the column's decimal places, so a money
155
- * column stays uniform (9 → 9.00) instead of following each individual value.
156
- */
157
- function pretty(value, fixed = 0) {
158
- if (value === null || value === undefined || value === "") return "—";
159
- if (typeof value === "number") {
160
- if (!Number.isFinite(value)) return "—";
161
- return value.toLocaleString("en-US", {
162
- minimumFractionDigits: fixed,
163
- maximumFractionDigits: fixed,
164
- });
165
- }
166
- return String(value);
167
- }
168
-
169
- /**
170
- * Render rows three ways:
171
- * --json the API's own body, untouched, for jq
172
- * a terminal an aligned table
173
- * a pipe tab-separated raw values, ready for cut/awk
174
- */
175
- function emit(columns, rows, body) {
176
- if (values.json) {
177
- console.log(JSON.stringify(body, null, 2));
178
- return;
179
- }
180
- if (rows.length === 0) {
181
- warn("No rows for this window.");
182
- return;
183
- }
184
-
185
- const header = columns.map((c) => c.header);
186
- const cells = rows.map((row) => columns.map((c) => c.value(row)));
187
-
188
- if (!TTY) {
189
- console.log(header.join("\t"));
190
- for (const line of cells) console.log(line.map(rawCell).join("\t"));
191
- return;
192
- }
193
-
194
- const table = [header, ...cells.map((line) => line.map((v, i) => pretty(v, columns[i].fixed)))];
195
- const widths = header.map((_, i) => Math.max(...table.map((line) => line[i].length)));
196
- for (const line of table) {
197
- console.log(
198
- line
199
- .map((v, i) => (columns[i].align === "right" ? v.padStart(widths[i]) : v.padEnd(widths[i])))
200
- .join(" ")
201
- .trimEnd(),
202
- );
203
- }
29
+ const [noun, maybeVerb] = first.positionals;
30
+ if (!noun) {
31
+ console.log(helpText());
32
+ process.exit(0);
204
33
  }
205
34
 
206
- /* ── commands ───────────────────────────────────────────────────────────── */
207
-
208
- async function channels() {
209
- const params = windowParams();
210
- if (values.ltv) params.set("ltv", "1");
211
- const body = await api(`/channels?${params}`);
212
-
213
- const columns = [
214
- { header: "channel", value: (r) => r.channel },
215
- { header: "clicks", value: (r) => Number(r.clicks), align: "right" },
216
- { header: "visitors", value: (r) => Number(r.visitors), align: "right" },
217
- { header: "conversions", value: (r) => Number(r.conversions), align: "right", fixed: 2 },
218
- { header: "revenue", value: (r) => Number(r.revenue), align: "right", fixed: 2 },
219
- ];
220
- if (values.ltv) {
221
- columns.push({ header: "ltv", value: (r) => Number(r.ltv), align: "right", fixed: 2 });
222
- }
223
-
224
- emit(columns, body.channels, body);
35
+ const match = resolve(noun, maybeVerb);
36
+ if (!match) {
37
+ die(`Unknown command "${[noun, maybeVerb].filter(Boolean).join(" ")}".\n\nRun: trackrev --help`, EXIT.USAGE);
225
38
  }
39
+ const { command, consumed } = match;
226
40
 
227
- async function links() {
228
- const params = windowParams();
229
- if (values.limit !== undefined) {
230
- params.set("limit", String(intFlag("limit", values.limit, { max: 500 })));
231
- }
232
- const body = await api(`/links?${params}`);
233
-
234
- emit(
235
- [
236
- { header: "slug", value: (r) => r.slug },
237
- { header: "channel", value: (r) => r.channel },
238
- { header: "clicks", value: (r) => Number(r.clicks), align: "right" },
239
- { header: "visitors", value: (r) => Number(r.visitors), align: "right" },
240
- { header: "conversions", value: (r) => Number(r.conversions), align: "right", fixed: 2 },
241
- { header: "revenue", value: (r) => Number(r.revenue), align: "right", fixed: 2 },
242
- { header: "destination", value: (r) => r.destination },
243
- ],
244
- body.links,
245
- body,
246
- );
41
+ if (first.values.help) {
42
+ console.log(helpFor(command));
43
+ process.exit(0);
247
44
  }
248
45
 
249
- async function clicks() {
250
- const pageSize = values.limit === undefined ? 100 : intFlag("limit", values.limit, { max: 500 });
251
- const stream = [];
252
- let before = null;
253
- let pages = 0;
254
-
255
- do {
256
- const params = new URLSearchParams({ limit: String(pageSize) });
257
- if (before) params.set("before", before);
258
- if (values.link) params.set("link_id", values.link);
259
- if (values.bots) params.set("bots", "include");
260
-
261
- const page = await api(`/clicks?${params}`);
262
- stream.push(...page.clicks);
263
- before = page.next_cursor;
264
-
265
- if (++pages >= MAX_PAGES && before) {
266
- warn(`Stopped after ${MAX_PAGES} pages (${stream.length} clicks). Narrow it with --link.`);
267
- break;
268
- }
269
- } while (values.all && before);
270
-
271
- const columns = [
272
- { header: "ts", value: (r) => r.ts },
273
- { header: "channel", value: (r) => r.channel },
274
- { header: "country", value: (r) => r.country },
275
- { header: "device", value: (r) => r.device },
276
- { header: "browser", value: (r) => r.browser },
277
- { header: "visitor", value: (r) => r.visitor_id },
278
- ];
279
- if (values.bots) columns.push({ header: "bot", value: (r) => (r.is_bot ? "yes" : "") });
280
-
281
- emit(columns, stream, { clicks: stream });
46
+ // Pass 2 — strict, with exactly this command's flags. An unknown flag is a
47
+ // one-line usage error, not a stack trace.
48
+ let parsed;
49
+ try {
50
+ parsed = parseArgs({ args: argv, allowPositionals: true, strict: true, options: optionsFor(command) });
51
+ } catch (err) {
52
+ die(`${err.message}\n\nRun: trackrev ${usageOf(command)} --help`, EXIT.USAGE);
282
53
  }
54
+ const flags = parsed.values;
55
+ const args = parsed.positionals.slice(consumed);
283
56
 
284
- async function journey() {
285
- const id = positionals[1];
286
- if (!id) die("Usage: trackrev journey <visitor-id>", EXIT_USAGE);
287
-
288
- const body = await api(`/visitors/${encodeURIComponent(id)}/journey`);
289
- const who = body.visitor;
290
- // Identity goes to stderr: it is a caption, not a row, so a pipe stays clean.
291
- if (!values.json) warn(`visitor ${who.id}${who.email ? ` · ${who.email}` : ""}`);
292
-
293
- emit(
294
- [
295
- { header: "ts", value: (r) => r.ts },
296
- { header: "kind", value: (r) => r.kind },
297
- { header: "label", value: (r) => r.label },
298
- { header: "channel", value: (r) => r.channel },
299
- { header: "link", value: (r) => r.link_slug },
300
- { header: "amount", value: (r) => (r.amount == null ? null : Number(r.amount)), align: "right", fixed: 2 },
301
- { header: "detail", value: (r) => r.detail },
302
- ],
303
- body.journey,
304
- body,
305
- );
57
+ if (args.length < command.args.length) {
58
+ die(`Usage: trackrev ${usageOf(command)} ${command.args.map((a) => `<${a}>`).join(" ")}`, EXIT.USAGE);
306
59
  }
307
60
 
308
- /* ── dispatch ───────────────────────────────────────────────────────────── */
309
-
310
- const COMMANDS = { channels, links, clicks, journey };
311
- const run = COMMANDS[command];
312
- if (!run) {
313
- console.error(`Unknown command "${command}".\n\nRun: trackrev --help`);
314
- process.exit(EXIT_USAGE);
315
- }
61
+ const auth = resolveAuth(flags.profile);
62
+ const api = createApi(auth);
63
+ const mod = await import(`./commands/${command.module}.js`);
64
+ const handler = mod[command.handler];
316
65
 
317
66
  try {
318
- await run();
67
+ await handler({ args, flags, api, auth, command });
319
68
  } catch (err) {
320
- die(err?.message ?? String(err));
321
- }
69
+ die(err && err.message ? err.message : String(err));
70
+ }
package/src/lib/api.js ADDED
@@ -0,0 +1,87 @@
1
+ import { VERSION } from "../registry.js";
2
+
3
+ export const EXIT = { OK: 0, FAIL: 1, USAGE: 2, PLAN: 3 };
4
+
5
+ /** Anything that is not data goes to stderr, so it never lands in a pipe. */
6
+ export function warn(message) {
7
+ console.error(message);
8
+ }
9
+
10
+ /** Print to stderr and quit with a failure code. */
11
+ export function die(message, code = EXIT.FAIL) {
12
+ console.error(message);
13
+ process.exit(code);
14
+ }
15
+
16
+ /**
17
+ * A tiny client over /api/v1. Every non-2xx becomes a one-line error and an
18
+ * exit code a script can branch on: 401 → 1, 402 → 3, 400 → 2, the rest → 1.
19
+ */
20
+ /**
21
+ * The secret key rides in every request, so refuse to send it in cleartext.
22
+ * http is allowed only for localhost, where there is no network to sniff.
23
+ * Carried over from the September 2026 hardening of the single-file CLI.
24
+ */
25
+ function assertSecureBase(apiUrl) {
26
+ let u = null;
27
+ try {
28
+ u = new URL(apiUrl);
29
+ } catch {
30
+ /* handled below */
31
+ }
32
+ const local = u && (u.hostname === "localhost" || u.hostname === "127.0.0.1");
33
+ if (!u || (u.protocol !== "https:" && !local)) {
34
+ die(
35
+ "TRACKREV_API_URL must be an https:// URL (http is only allowed for localhost).",
36
+ EXIT.USAGE,
37
+ );
38
+ }
39
+ }
40
+
41
+ export function createApi({ key, apiUrl, source }) {
42
+ assertSecureBase(apiUrl);
43
+
44
+ async function request(method, path, body) {
45
+ if (!key) {
46
+ die("No API key. Run `trackrev login`, or set TRACKREV_KEY to a secret key from Settings → Developers.");
47
+ }
48
+ const headers = {
49
+ Authorization: `Bearer ${key}`,
50
+ "User-Agent": `trackrev-cli/${VERSION}`,
51
+ };
52
+ let payload;
53
+ if (body !== undefined) {
54
+ const isText = typeof body === "string";
55
+ headers["Content-Type"] = isText ? "text/csv" : "application/json";
56
+ payload = isText ? body : JSON.stringify(body);
57
+ }
58
+
59
+ let res;
60
+ try {
61
+ res = await fetch(apiUrl + path, { method, headers, body: payload });
62
+ } catch {
63
+ return die(`Could not reach ${apiUrl} — is the server running?`);
64
+ }
65
+
66
+ const type = res.headers.get("content-type") || "";
67
+ const parsed = type.includes("json") ? await res.json().catch(() => null) : await res.text();
68
+
69
+ if (!res.ok) {
70
+ const message = (parsed && parsed.error && parsed.error.message) || res.statusText;
71
+ if (res.status === 401) die(`${message}. The key came from ${source}.`);
72
+ if (res.status === 402) die(`${message}\nUpgrade at https://www.trackrev.io/pricing`, EXIT.PLAN);
73
+ if (res.status === 400) die(message, EXIT.USAGE);
74
+ if (res.status === 404 || res.status === 409) die(message);
75
+ die(`API error ${res.status}: ${message}`);
76
+ }
77
+ return parsed;
78
+ }
79
+
80
+ return {
81
+ get: (path) => request("GET", path),
82
+ post: (path, body) => request("POST", path, body),
83
+ patch: (path, body) => request("PATCH", path, body),
84
+ put: (path, body) => request("PUT", path, body),
85
+ del: (path) => request("DELETE", path),
86
+ };
87
+ }
@@ -0,0 +1,52 @@
1
+ // Saved logins. One file, several named profiles, one marked current:
2
+ // { "current": "default", "profiles": { "default": { "key": "lk_…", "apiUrl": "…" } } }
3
+ // Written 0600 — it holds a secret key.
4
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+
8
+ export const DEFAULT_API = "https://app.trackrev.io/api/v1";
9
+
10
+ export function configPath() {
11
+ const root = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
12
+ return join(root, "trackrev", "config.json");
13
+ }
14
+
15
+ export function readConfig() {
16
+ try {
17
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
18
+ return { current: parsed.current || "default", profiles: parsed.profiles || {} };
19
+ } catch {
20
+ return { current: "default", profiles: {} };
21
+ }
22
+ }
23
+
24
+ export function writeConfig(cfg) {
25
+ const p = configPath();
26
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
27
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
28
+ chmodSync(p, 0o600);
29
+ }
30
+
31
+ export function deleteConfig() {
32
+ rmSync(configPath(), { force: true });
33
+ }
34
+
35
+ /**
36
+ * Resolve credentials. Highest precedence first:
37
+ * 1. TRACKREV_KEY / TRACKREV_API_URL in the environment (CI, one-offs)
38
+ * 2. --profile NAME
39
+ * 3. the profile `trackrev login` marked current
40
+ */
41
+ export function resolveAuth(profileFlag) {
42
+ const cfg = readConfig();
43
+ const name = profileFlag || cfg.current || "default";
44
+ const profile = cfg.profiles[name] || {};
45
+ const envKey = process.env.TRACKREV_KEY;
46
+ return {
47
+ profile: name,
48
+ key: envKey || profile.key || null,
49
+ apiUrl: process.env.TRACKREV_API_URL || profile.apiUrl || DEFAULT_API,
50
+ source: envKey ? "the environment" : profile.key ? `profile "${name}"` : "nowhere",
51
+ };
52
+ }
@@ -0,0 +1,72 @@
1
+ // Three renderings of the same rows:
2
+ // --json the API's own body, untouched, for jq
3
+ // a terminal an aligned table
4
+ // a pipe tab-separated raw values, ready for cut/awk
5
+ import { warn } from "./api.js";
6
+
7
+ const TTY = process.stdout.isTTY;
8
+
9
+ /** Raw value for a pipe: no padding, no separators, empty cell for null. */
10
+ function rawCell(value) {
11
+ return value === null || value === undefined ? "" : String(value);
12
+ }
13
+
14
+ /**
15
+ * Human value for a table. `fixed` is the column's decimal places, so a money
16
+ * column stays uniform (9 → 9.00) instead of following each individual value.
17
+ */
18
+ function pretty(value, fixed = 0) {
19
+ if (value === null || value === undefined || value === "") return "—";
20
+ if (typeof value === "boolean") return value ? "yes" : "";
21
+ if (typeof value === "number") {
22
+ if (!Number.isFinite(value)) return "—";
23
+ return value.toLocaleString("en-US", {
24
+ minimumFractionDigits: fixed,
25
+ maximumFractionDigits: fixed,
26
+ });
27
+ }
28
+ return String(value);
29
+ }
30
+
31
+ export function emit(columns, rows, body, { json, empty = "No rows." } = {}) {
32
+ if (json) {
33
+ console.log(JSON.stringify(body, null, 2));
34
+ return;
35
+ }
36
+ if (rows.length === 0) {
37
+ warn(empty);
38
+ return;
39
+ }
40
+
41
+ const header = columns.map((c) => c.header);
42
+ const cells = rows.map((row) => columns.map((c) => c.value(row)));
43
+
44
+ if (!TTY) {
45
+ console.log(header.join("\t"));
46
+ for (const line of cells) console.log(line.map(rawCell).join("\t"));
47
+ return;
48
+ }
49
+
50
+ const table = [header, ...cells.map((line) => line.map((v, i) => pretty(v, columns[i].fixed)))];
51
+ const widths = header.map((_, i) => Math.max(...table.map((line) => line[i].length)));
52
+ for (const line of table) {
53
+ console.log(
54
+ line
55
+ .map((v, i) => (columns[i].align === "right" ? v.padStart(widths[i]) : v.padEnd(widths[i])))
56
+ .join(" ")
57
+ .trimEnd(),
58
+ );
59
+ }
60
+ }
61
+
62
+ /** A single record as aligned `key value` lines (TTY) or key/value TSV (pipe). */
63
+ export function emitRecord(pairs, body, { json } = {}) {
64
+ if (json) {
65
+ console.log(JSON.stringify(body, null, 2));
66
+ return;
67
+ }
68
+ const w = TTY ? Math.max(...pairs.map(([k]) => k.length)) : 0;
69
+ for (const [k, v] of pairs) {
70
+ console.log(TTY ? `${k.padEnd(w)} ${pretty(v)}` : [k, rawCell(v)].join("\t"));
71
+ }
72
+ }