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.
- package/README.md +874 -42
- package/package.json +14 -2
- package/src/commands/affiliates.js +162 -0
- package/src/commands/analytics.js +128 -0
- package/src/commands/attribution.js +55 -0
- package/src/commands/auth.js +41 -0
- package/src/commands/domains.js +60 -0
- package/src/commands/folders.js +69 -0
- package/src/commands/keys.js +54 -0
- package/src/commands/links.js +203 -0
- package/src/commands/me.js +25 -0
- package/src/commands/money.js +96 -0
- package/src/commands/people.js +94 -0
- package/src/commands/retargeting.js +49 -0
- package/src/commands/revenue.js +86 -0
- package/src/commands/settings.js +64 -0
- package/src/commands/webhooks.js +79 -0
- package/src/index.js +43 -294
- package/src/lib/api.js +87 -0
- package/src/lib/config.js +52 -0
- package/src/lib/output.js +72 -0
- package/src/lib/prompt.js +55 -0
- package/src/registry.d.ts +39 -0
- package/src/registry.js +840 -0
|
@@ -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 —
|
|
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 {
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
const
|
|
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
|
|
67
|
+
await handler({ args, flags, api, auth, command });
|
|
319
68
|
} catch (err) {
|
|
320
|
-
die(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
|
+
}
|