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,840 @@
1
+ // The single source of truth for what the CLI can do.
2
+ //
3
+ // Everything that describes a command lives here and nowhere else: the
4
+ // parseArgs option spec, the help text, the flag table on /cli, the reminder
5
+ // list in Settings → Developers, and the generated blocks in README.md and the
6
+ // docs site are all derived from this array. Add a command by adding an entry
7
+ // and its handler; nothing else needs to learn about it.
8
+ //
9
+ // Plain data + pure functions on purpose — the web app imports this file too,
10
+ // so it must not touch process, fs, or fetch.
11
+
12
+ /**
13
+ * Declared here rather than read from package.json at import time.
14
+ *
15
+ * This module is imported by the Next app (the /cli page and the developers
16
+ * panel render their tables from it). Next traces the files a route needs into
17
+ * its serverless bundle, and apps/cli/package.json sits outside apps/web, so a
18
+ * `createRequire(import.meta.url)("../package.json")` compiles fine and then
19
+ * throws at runtime — a 500 on /cli that no build step catches.
20
+ *
21
+ * Kept honest by a test that asserts this matches package.json.
22
+ */
23
+ export const VERSION = "0.2.0";
24
+
25
+ /** A flag definition. `arg` is the placeholder shown in help ("N", "ISO", "ID"). */
26
+ const flag = (name, type, meaning, extra = {}) => ({ name, type, meaning, ...extra });
27
+
28
+ export const GLOBAL_FLAGS = [
29
+ flag("json", "boolean", "print the API's JSON instead of a table"),
30
+ flag("profile", "string", "use a saved login other than the current one", { arg: "NAME" }),
31
+ flag("yes", "boolean", "skip the confirmation on destructive commands"),
32
+ flag("version", "boolean", "print the version"),
33
+ flag("help", "boolean", "show help (also: trackrev <command> --help)"),
34
+ ];
35
+
36
+ const WINDOW_FLAGS = [
37
+ flag("days", "string", "last N days (default 30, max 365)", { arg: "N", default: "30" }),
38
+ flag("from", "string", "explicit start, e.g. 2026-01-01; wins over --days", { arg: "ISO" }),
39
+ flag("to", "string", "explicit end (defaults to now)", { arg: "ISO" }),
40
+ ];
41
+
42
+ const LIMIT = (meaning) => flag("limit", "string", meaning, { arg: "N" });
43
+
44
+ // Flags shared by `links create` and `links update`.
45
+ const LINK_SETTING_FLAGS = [
46
+ flag("slug", "string", "custom slug (2–64 chars; lowercase, digits, hyphens)", { arg: "SLUG" }),
47
+ flag("campaign", "string", "utm_campaign (defaults to the name)", { arg: "TEXT" }),
48
+ flag("expires", "string", "expire at this time", { arg: "ISO" }),
49
+ flag("max-clicks", "string", "expire after this many clicks", { arg: "N" }),
50
+ flag("expired-url", "string", "where to send visitors after expiry", { arg: "URL" }),
51
+ flag("password", "string", "require this password before redirecting", { arg: "TEXT" }),
52
+ flag("mobile-url", "string", "device targeting: send mobile here", { arg: "URL" }),
53
+ flag("desktop-url", "string", "device targeting: send desktop here", { arg: "URL" }),
54
+ flag("retarget", "string", "fire the workspace's ad pixels on click", { arg: "on|off" }),
55
+ ];
56
+
57
+ /**
58
+ * Every command. `verb: null` means the noun alone is the command (kept for
59
+ * the original analytics commands so existing scripts don't break).
60
+ * args positional placeholders, in order
61
+ * module file under src/commands/ exporting `handler`
62
+ * paid needs a paid plan (the API 402s otherwise)
63
+ * destructive asks for confirmation unless --yes
64
+ * aliases extra top-level words that resolve to this command
65
+ * bareNoun the noun alone also runs this command (`links` → `links perf`)
66
+ */
67
+ export const COMMANDS = [
68
+ /* ── Analytics (paid) ─────────────────────────────────────────────────── */
69
+ {
70
+ noun: "channels", verb: null, group: "Analytics",
71
+ summary: "performance per traffic source",
72
+ example: "trackrev channels --days 7 --ltv",
73
+ args: [],
74
+ flags: [...WINDOW_FLAGS, flag("ltv", "boolean", "add all-time lifetime value per channel")],
75
+ module: "analytics", handler: "channels", paid: true,
76
+ note: "conversions is a decimal on purpose — attribution splits credit, so one sale touched by two channels counts 0.5 on each.",
77
+ },
78
+ {
79
+ noun: "links", verb: "perf", group: "Analytics",
80
+ summary: "performance per short link (bare `trackrev links` still works)",
81
+ example: "trackrev links perf --limit 20",
82
+ args: [],
83
+ flags: [
84
+ LIMIT("rows to return (max 500)"),
85
+ ...WINDOW_FLAGS,
86
+ flag("settings", "boolean", "attach each link's URL, expiry and password flag"),
87
+ ],
88
+ module: "analytics", handler: "linksPerf", paid: true, bareNoun: true,
89
+ },
90
+ {
91
+ noun: "clicks", verb: null, group: "Analytics",
92
+ summary: "the raw click stream — newest first, bots excluded",
93
+ example: "trackrev clicks --all --json",
94
+ args: [],
95
+ flags: [
96
+ LIMIT("page size (default 100, max 500)"),
97
+ flag("all", "boolean", "follow the cursor to the end of the stream"),
98
+ flag("link", "string", "one link only", { arg: "ID" }),
99
+ flag("bots", "boolean", "include bot traffic"),
100
+ ],
101
+ module: "analytics", handler: "clicks", paid: true,
102
+ note: "--all stops after 200 pages and says so on stderr, so a runaway cursor can never loop forever.",
103
+ },
104
+ {
105
+ noun: "visitors", verb: "journey", group: "Analytics",
106
+ summary: "one visitor's timeline — every click, identify event and order, in order",
107
+ example: "trackrev journey <visitor-id>",
108
+ args: ["visitor-id"],
109
+ flags: [],
110
+ module: "analytics", handler: "journey", paid: true, aliases: ["journey"],
111
+ note: "The visitor caption goes to stderr, so the rows stay pipe-clean.",
112
+ },
113
+
114
+ /* ── Links (any plan) ─────────────────────────────────────────────────── */
115
+ {
116
+ noun: "links", verb: "list", group: "Links",
117
+ summary: "the links themselves — newest first, no window",
118
+ example: "trackrev links list --channel youtube",
119
+ args: [],
120
+ flags: [
121
+ LIMIT("page size (default 100, max 500)"),
122
+ flag("all", "boolean", "follow the cursor to the end"),
123
+ flag("channel", "string", "one channel only", { arg: "KEY" }),
124
+ flag("q", "string", "slug contains this text", { arg: "TEXT" }),
125
+ ],
126
+ module: "links", handler: "list", paid: false,
127
+ },
128
+ {
129
+ noun: "links", verb: "create", group: "Links",
130
+ summary: "create a campaign: one link per channel, or one Smart Link",
131
+ example: "trackrev links create --url https://acme.com/launch --name Launch --channel youtube --channel newsletter",
132
+ args: [],
133
+ flags: [
134
+ flag("url", "string", "destination (required)", { arg: "URL" }),
135
+ flag("name", "string", "campaign name (required)", { arg: "TEXT" }),
136
+ flag("channel", "string", "a channel; repeat for several", { arg: "KEY", multiple: true }),
137
+ flag("smart", "boolean", "one link that infers its channel per click"),
138
+ flag("tag", "string", "a tag; repeat for several", { arg: "TEXT", multiple: true }),
139
+ flag("folder", "string", "put the campaign in this folder", { arg: "ID" }),
140
+ flag("external", "boolean", "destination is a site you can't put the pixel on"),
141
+ ...LINK_SETTING_FLAGS,
142
+ ],
143
+ module: "links", handler: "create", paid: false,
144
+ note: "Prints one row per link created. Channels: facebook instagram youtube linkedin twitter tiktok newsletter website other.",
145
+ },
146
+ {
147
+ noun: "links", verb: "get", group: "Links",
148
+ summary: "one link by id, slug or short code",
149
+ example: "trackrev links get black-friday",
150
+ args: ["id-or-slug"],
151
+ flags: [],
152
+ module: "links", handler: "get", paid: false,
153
+ },
154
+ {
155
+ noun: "links", verb: "update", group: "Links",
156
+ summary: "change a link's slug, UTMs, expiry, password or targeting",
157
+ example: "trackrev links update <id> --expires 2026-12-31T23:59:59Z --max-clicks 1000",
158
+ args: ["id"],
159
+ flags: [
160
+ flag("term", "string", "utm_term", { arg: "TEXT" }),
161
+ flag("content", "string", "utm_content", { arg: "TEXT" }),
162
+ flag("clear-password", "boolean", "remove the password"),
163
+ flag("clear-expiry", "boolean", "remove date and click-cap expiry"),
164
+ ...LINK_SETTING_FLAGS,
165
+ ],
166
+ module: "links", handler: "update", paid: false,
167
+ },
168
+ {
169
+ noun: "links", verb: "delete", group: "Links",
170
+ summary: "delete one link (its campaign and other channels stay)",
171
+ example: "trackrev links delete <id> --yes",
172
+ args: ["id"],
173
+ flags: [],
174
+ module: "links", handler: "remove", paid: false, destructive: true,
175
+ },
176
+ {
177
+ noun: "links", verb: "bulk", group: "Links",
178
+ summary: "create up to 500 links from a CSV",
179
+ example: "trackrev links bulk --file links.csv",
180
+ args: [],
181
+ flags: [flag("file", "string", "CSV with url, name, channel columns (- for stdin)", { arg: "PATH" })],
182
+ module: "links", handler: "bulk", paid: false,
183
+ note: "Optional columns: utm_campaign, utm_term, utm_content, tags, campaign_id. Rows that fail are listed with the reason; the rest are created.",
184
+ },
185
+ {
186
+ noun: "links", verb: "qr", group: "Links",
187
+ summary: "the link's QR code as SVG",
188
+ example: "trackrev links qr black-friday --out black-friday.svg",
189
+ args: ["id-or-slug"],
190
+ flags: [
191
+ flag("out", "string", "write here instead of stdout", { arg: "PATH" }),
192
+ flag("size", "string", "size in px (default 512, max 2048)", { arg: "N" }),
193
+ ],
194
+ module: "links", handler: "qr", paid: false,
195
+ },
196
+
197
+ /* ── Developers (any plan) ────────────────────────────────────────────── */
198
+ {
199
+ noun: "keys", verb: "list", group: "Developers",
200
+ summary: "the workspace's API keys (prefixes only)",
201
+ example: "trackrev keys list --revoked",
202
+ args: [],
203
+ flags: [flag("revoked", "boolean", "include revoked keys")],
204
+ module: "keys", handler: "list", paid: false, bareNoun: true,
205
+ },
206
+ {
207
+ noun: "keys", verb: "create", group: "Developers",
208
+ summary: "mint a key — the plaintext is shown once, never again",
209
+ example: "trackrev keys create --label 'CI deploy'",
210
+ args: [],
211
+ flags: [
212
+ flag("scope", "string", "secret (servers, CLI) or public (browser)", { arg: "SCOPE" }),
213
+ flag("label", "string", "what this key is for", { arg: "TEXT" }),
214
+ ],
215
+ module: "keys", handler: "create", paid: false,
216
+ note: "The key goes to stdout and everything else to stderr, so `trackrev keys create > key.txt` captures only the key.",
217
+ },
218
+ {
219
+ noun: "keys", verb: "revoke", group: "Developers",
220
+ summary: "revoke a key immediately",
221
+ example: "trackrev keys revoke <id> --yes",
222
+ args: ["id"],
223
+ flags: [],
224
+ module: "keys", handler: "revoke", paid: false, destructive: true,
225
+ },
226
+ {
227
+ noun: "webhooks", verb: "list", group: "Developers",
228
+ summary: "outbound endpoints, with their last delivery status",
229
+ example: "trackrev webhooks list",
230
+ args: [],
231
+ flags: [],
232
+ module: "webhooks", handler: "list", paid: false, bareNoun: true,
233
+ },
234
+ {
235
+ noun: "webhooks", verb: "events", group: "Developers",
236
+ summary: "every event an endpoint can subscribe to",
237
+ example: "trackrev webhooks events",
238
+ args: [],
239
+ flags: [],
240
+ module: "webhooks", handler: "events", paid: false,
241
+ },
242
+ {
243
+ noun: "webhooks", verb: "create", group: "Developers",
244
+ summary: "add an endpoint — the signing secret is shown once",
245
+ example: "trackrev webhooks create --url https://acme.com/hook --event sale.created",
246
+ args: [],
247
+ flags: [
248
+ flag("url", "string", "https endpoint (required)", { arg: "URL" }),
249
+ flag("event", "string", "an event; repeat for several", { arg: "NAME", multiple: true }),
250
+ ],
251
+ module: "webhooks", handler: "create", paid: false,
252
+ note: "https only. Run `trackrev webhooks events` for the valid names.",
253
+ },
254
+ {
255
+ noun: "webhooks", verb: "update", group: "Developers",
256
+ summary: "change the URL or events, or pause and resume delivery",
257
+ example: "trackrev webhooks update <id> --pause",
258
+ args: ["id"],
259
+ flags: [
260
+ flag("url", "string", "new endpoint URL", { arg: "URL" }),
261
+ flag("event", "string", "replace the event list; repeat", { arg: "NAME", multiple: true }),
262
+ flag("pause", "boolean", "stop delivering"),
263
+ flag("resume", "boolean", "start delivering again"),
264
+ ],
265
+ module: "webhooks", handler: "update", paid: false,
266
+ },
267
+ {
268
+ noun: "webhooks", verb: "delete", group: "Developers",
269
+ summary: "remove an endpoint",
270
+ example: "trackrev webhooks delete <id> --yes",
271
+ args: ["id"],
272
+ flags: [],
273
+ module: "webhooks", handler: "remove", paid: false, destructive: true,
274
+ },
275
+
276
+ /* ── Setup (any plan) ─────────────────────────────────────────────────── */
277
+ {
278
+ noun: "attribution", verb: "get", group: "Setup",
279
+ summary: "the model and lookback window this workspace uses",
280
+ example: "trackrev attribution get --models",
281
+ args: [],
282
+ flags: [flag("models", "boolean", "list the three models and what each credits")],
283
+ module: "attribution", handler: "get", paid: false, bareNoun: true,
284
+ },
285
+ {
286
+ noun: "attribution", verb: "set", group: "Setup",
287
+ summary: "change the model or the window",
288
+ example: "trackrev attribution set --model linear --window 60",
289
+ args: [],
290
+ flags: [
291
+ flag("model", "string", "last_touch, first_touch or linear", { arg: "NAME" }),
292
+ flag("window", "string", "lookback in days (1-365)", { arg: "N" }),
293
+ ],
294
+ module: "attribution", handler: "set", paid: false,
295
+ note: "Both settings apply retroactively — every past order is re-credited against them.",
296
+ },
297
+ {
298
+ noun: "folders", verb: "list", group: "Setup",
299
+ summary: "campaign folders, with how many campaigns each holds",
300
+ example: "trackrev folders list",
301
+ args: [],
302
+ flags: [],
303
+ module: "folders", handler: "list", paid: false, bareNoun: true,
304
+ },
305
+ {
306
+ noun: "folders", verb: "create", group: "Setup",
307
+ summary: "create a folder to group campaigns under",
308
+ example: "trackrev folders create --name 'Q4 launch' --start 2026-10-01",
309
+ args: [],
310
+ flags: [
311
+ flag("name", "string", "folder name (required)", { arg: "TEXT" }),
312
+ flag("description", "string", "what it covers", { arg: "TEXT" }),
313
+ flag("start", "string", "start date", { arg: "YYYY-MM-DD" }),
314
+ flag("end", "string", "end date", { arg: "YYYY-MM-DD" }),
315
+ ],
316
+ module: "folders", handler: "create", paid: false,
317
+ },
318
+ {
319
+ noun: "folders", verb: "update", group: "Setup",
320
+ summary: "rename a folder or change its dates",
321
+ example: "trackrev folders update <id> --name 'Q1 launch'",
322
+ args: ["id"],
323
+ flags: [
324
+ flag("name", "string", "new name", { arg: "TEXT" }),
325
+ flag("description", "string", "new description", { arg: "TEXT" }),
326
+ flag("start", "string", "start date", { arg: "YYYY-MM-DD" }),
327
+ flag("end", "string", "end date", { arg: "YYYY-MM-DD" }),
328
+ ],
329
+ module: "folders", handler: "update", paid: false,
330
+ },
331
+ {
332
+ noun: "folders", verb: "delete", group: "Setup",
333
+ summary: "delete a folder — its campaigns become ungrouped, not deleted",
334
+ example: "trackrev folders delete <id> --yes",
335
+ args: ["id"],
336
+ flags: [],
337
+ module: "folders", handler: "remove", paid: false, destructive: true,
338
+ },
339
+ {
340
+ noun: "folders", verb: "assign", group: "Setup",
341
+ summary: "file a campaign under a folder, or un-file it",
342
+ example: "trackrev folders assign <destination-id> --folder <folder-id>",
343
+ args: ["destination-id"],
344
+ flags: [flag("folder", "string", "folder to file under; omit to un-file", { arg: "ID" })],
345
+ module: "folders", handler: "assign", paid: false,
346
+ note: "The id is a CAMPAIGN (the destination behind a set of links), not a single link.",
347
+ },
348
+
349
+ /* ── Revenue ──────────────────────────────────────────────────────────── */
350
+ {
351
+ noun: "revenue", verb: "list", group: "Revenue",
352
+ summary: "connected payment providers and their last sync",
353
+ example: "trackrev revenue list",
354
+ args: [],
355
+ flags: [],
356
+ module: "revenue", handler: "list", paid: false, bareNoun: true,
357
+ },
358
+ {
359
+ noun: "revenue", verb: "providers", group: "Revenue",
360
+ summary: "what can be connected, and the credentials each needs",
361
+ example: "trackrev revenue providers",
362
+ args: [],
363
+ flags: [],
364
+ module: "revenue", handler: "providers", paid: false,
365
+ note: "Stripe is absent by design — its restricted key lives on the workspace, not here.",
366
+ },
367
+ {
368
+ noun: "revenue", verb: "connect", group: "Revenue",
369
+ summary: "connect a provider — credentials are verified before saving",
370
+ example: "trackrev revenue connect --provider polar --field api_key=polar_oat_…",
371
+ args: [],
372
+ flags: [
373
+ flag("provider", "string", "polar, lemonsqueezy, paddle, creem or dodo", { arg: "NAME" }),
374
+ flag("field", "string", "credential as key=value; repeat per field", { arg: "K=V", multiple: true }),
375
+ flag("sandbox", "boolean", "use the provider's sandbox host, where it has one"),
376
+ ],
377
+ module: "revenue", handler: "connect", paid: false,
378
+ },
379
+ {
380
+ noun: "revenue", verb: "sync", group: "Revenue",
381
+ summary: "pull charges now and attribute them",
382
+ example: "trackrev revenue sync",
383
+ args: [],
384
+ flags: [flag("connection", "string", "one connection only; omit for all + Stripe", { arg: "ID" })],
385
+ module: "revenue", handler: "sync", paid: true,
386
+ note: "Reports imported and attributed per provider. A provider that fails does not stop the others.",
387
+ },
388
+ {
389
+ noun: "revenue", verb: "disconnect", group: "Revenue",
390
+ summary: "disconnect a provider; imported orders are kept",
391
+ example: "trackrev revenue disconnect <id> --yes",
392
+ args: ["id"],
393
+ flags: [],
394
+ module: "revenue", handler: "disconnect", paid: false, destructive: true,
395
+ },
396
+
397
+ /* ── Audience ─────────────────────────────────────────────────────────── */
398
+ {
399
+ noun: "visitors", verb: "list", group: "Audience",
400
+ summary: "visitors, most recently seen first",
401
+ example: "trackrev visitors list --email @acme.com",
402
+ args: [],
403
+ flags: [
404
+ LIMIT("page size (default 100, max 500)"),
405
+ flag("all", "boolean", "follow the cursor to the end"),
406
+ flag("email", "string", "email contains this text", { arg: "TEXT" }),
407
+ ],
408
+ module: "people", handler: "visitors", paid: true, bareNoun: true,
409
+ },
410
+ {
411
+ noun: "visitors", verb: "get", group: "Audience",
412
+ summary: "one visitor",
413
+ example: "trackrev visitors get <id>",
414
+ args: ["id"],
415
+ flags: [],
416
+ module: "people", handler: "visitor", paid: true,
417
+ },
418
+ {
419
+ noun: "orders", verb: "list", group: "Audience",
420
+ summary: "synced purchases, newest first",
421
+ example: "trackrev orders list --status refunded",
422
+ args: [],
423
+ flags: [
424
+ LIMIT("page size (default 100, max 500)"),
425
+ flag("all", "boolean", "follow the cursor to the end"),
426
+ flag("status", "string", "paid or refunded", { arg: "NAME" }),
427
+ flag("email", "string", "email contains this text", { arg: "TEXT" }),
428
+ ],
429
+ module: "people", handler: "orders", paid: false, bareNoun: true,
430
+ note: "amount is blank on the free plan, where revenue figures are hidden.",
431
+ },
432
+ {
433
+ noun: "export", verb: null, group: "Audience",
434
+ summary: "any dataset as CSV",
435
+ example: "trackrev export --kind orders --days 90 --out orders.csv",
436
+ args: [],
437
+ flags: [
438
+ flag("kind", "string", "channels, links, orders or visitors", { arg: "NAME" }),
439
+ flag("out", "string", "write here instead of stdout", { arg: "PATH" }),
440
+ ...WINDOW_FLAGS,
441
+ ],
442
+ module: "people", handler: "exportCsv", paid: true,
443
+ },
444
+
445
+ /* ── Domains & pixels ─────────────────────────────────────────────────── */
446
+ {
447
+ noun: "domains", verb: "list", group: "Domains",
448
+ summary: "branded short-link domains and their DNS status",
449
+ example: "trackrev domains list",
450
+ args: [],
451
+ flags: [],
452
+ module: "domains", handler: "list", paid: false, bareNoun: true,
453
+ },
454
+ {
455
+ noun: "domains", verb: "add", group: "Domains",
456
+ summary: "attach a domain — prints the DNS records to add",
457
+ example: "trackrev domains add go.acme.com",
458
+ args: ["domain"],
459
+ flags: [],
460
+ module: "domains", handler: "add", paid: false,
461
+ },
462
+ {
463
+ noun: "domains", verb: "verify", group: "Domains",
464
+ summary: "re-check DNS now; exits non-zero until it is active",
465
+ example: "trackrev domains verify go.acme.com",
466
+ args: ["domain-or-id"],
467
+ flags: [],
468
+ module: "domains", handler: "verify", paid: false,
469
+ note: "Exits 1 while still pending, so a deploy script can poll until it passes.",
470
+ },
471
+ {
472
+ noun: "domains", verb: "remove", group: "Domains",
473
+ summary: "detach a domain; links keep working on the default host",
474
+ example: "trackrev domains remove go.acme.com --yes",
475
+ args: ["domain-or-id"],
476
+ flags: [],
477
+ module: "domains", handler: "remove", paid: false, destructive: true,
478
+ },
479
+ {
480
+ noun: "retargeting", verb: "list", group: "Domains",
481
+ summary: "the ad pixels fired on opted-in link clicks",
482
+ example: "trackrev retargeting list --providers",
483
+ args: [],
484
+ flags: [flag("providers", "boolean", "show what can be configured instead")],
485
+ module: "retargeting", handler: "list", paid: false, bareNoun: true,
486
+ },
487
+ {
488
+ noun: "retargeting", verb: "set", group: "Domains",
489
+ summary: "set a provider's pixel id",
490
+ example: "trackrev retargeting set meta --id 1234567890123456",
491
+ args: ["provider"],
492
+ flags: [flag("id", "string", "the pixel/tag id (required)", { arg: "ID" })],
493
+ module: "retargeting", handler: "set", paid: false,
494
+ note: "The id must match that provider's shape — only validated ids are ever put into a loader snippet.",
495
+ },
496
+ {
497
+ noun: "retargeting", verb: "remove", group: "Domains",
498
+ summary: "remove a provider's pixel",
499
+ example: "trackrev retargeting remove meta --yes",
500
+ args: ["provider"],
501
+ flags: [],
502
+ module: "retargeting", handler: "remove", paid: false, destructive: true,
503
+ },
504
+
505
+ /* ── Affiliate program ────────────────────────────────────────────────── */
506
+ {
507
+ noun: "programs", verb: "list", group: "Affiliate",
508
+ summary: "the workspace's affiliate programs and their terms",
509
+ example: "trackrev programs list --archived",
510
+ args: [],
511
+ flags: [flag("archived", "boolean", "include archived programs")],
512
+ module: "affiliates", handler: "programs", paid: false, bareNoun: true,
513
+ },
514
+ {
515
+ noun: "programs", verb: "get", group: "Affiliate",
516
+ summary: "one program in full",
517
+ example: "trackrev programs get <id>",
518
+ args: ["id"],
519
+ flags: [],
520
+ module: "affiliates", handler: "program", paid: false,
521
+ },
522
+ {
523
+ noun: "programs", verb: "update", group: "Affiliate",
524
+ summary: "change commission terms, or pause and archive",
525
+ example: "trackrev programs update <id> --rate 0.25 --status paused",
526
+ args: ["id"],
527
+ flags: [
528
+ flag("name", "string", "program name", { arg: "TEXT" }),
529
+ flag("landing-url", "string", "where partner links point", { arg: "URL" }),
530
+ flag("type", "string", "percent or flat", { arg: "TYPE" }),
531
+ flag("rate", "string", "0-1 fraction for percent (0.25 = 25%), dollars for flat", { arg: "N" }),
532
+ flag("recurring", "string", "months a commission keeps paying", { arg: "N" }),
533
+ flag("cookie", "string", "attribution window in days", { arg: "N" }),
534
+ flag("min-payout", "string", "minimum balance before a payout", { arg: "N" }),
535
+ flag("auto-approve", "string", "approve signups instantly", { arg: "on|off" }),
536
+ flag("status", "string", "active, paused or archived", { arg: "NAME" }),
537
+ ],
538
+ module: "affiliates", handler: "updateProgram", paid: true,
539
+ note: "Changes apply to NEW conversions; commissions already earned are untouched.",
540
+ },
541
+ {
542
+ noun: "partners", verb: "list", group: "Affiliate",
543
+ summary: "affiliates with their clicks, sales and earnings",
544
+ example: "trackrev partners list --status pending",
545
+ args: [],
546
+ flags: [
547
+ flag("program", "string", "one program only", { arg: "ID" }),
548
+ flag("status", "string", "pending, approved, rejected, banned or archived", { arg: "NAME" }),
549
+ ],
550
+ module: "affiliates", handler: "partners", paid: true, bareNoun: true,
551
+ },
552
+ {
553
+ noun: "partners", verb: "approve", group: "Affiliate",
554
+ summary: "approve a pending affiliate",
555
+ example: "trackrev partners approve <partner-id> --program <program-id>",
556
+ args: ["partner-id"],
557
+ flags: [flag("program", "string", "the program id (required)", { arg: "ID" })],
558
+ module: "affiliates", handler: "approve", paid: true,
559
+ note: "Does NOT send the approval email the dashboard sends — a re-run would mail them again.",
560
+ },
561
+ {
562
+ noun: "partners", verb: "reject", group: "Affiliate",
563
+ summary: "reject an application",
564
+ example: "trackrev partners reject <partner-id> --program <program-id> --yes",
565
+ args: ["partner-id"],
566
+ flags: [flag("program", "string", "the program id (required)", { arg: "ID" })],
567
+ module: "affiliates", handler: "reject", paid: true, destructive: true,
568
+ },
569
+ {
570
+ noun: "partners", verb: "ban", group: "Affiliate",
571
+ summary: "ban an affiliate",
572
+ example: "trackrev partners ban <partner-id> --program <program-id> --yes",
573
+ args: ["partner-id"],
574
+ flags: [flag("program", "string", "the program id (required)", { arg: "ID" })],
575
+ module: "affiliates", handler: "ban", paid: true, destructive: true,
576
+ },
577
+ {
578
+ noun: "partners", verb: "group", group: "Affiliate",
579
+ summary: "move an affiliate into a group, or back to program terms",
580
+ example: "trackrev partners group <partner-id> --program <program-id> --group <group-id>",
581
+ args: ["partner-id"],
582
+ flags: [
583
+ flag("program", "string", "the program id (required)", { arg: "ID" }),
584
+ flag("group", "string", "group to move them to; omit to clear", { arg: "ID" }),
585
+ ],
586
+ module: "affiliates", handler: "group", paid: true,
587
+ },
588
+ {
589
+ noun: "groups", verb: "list", group: "Affiliate",
590
+ summary: "a program's tiers, showing the terms each one resolves to",
591
+ example: "trackrev groups list <program-id>",
592
+ args: ["program-id"],
593
+ flags: [],
594
+ module: "affiliates", handler: "groups", paid: true, bareNoun: true,
595
+ },
596
+
597
+ /* ── Money ────────────────────────────────────────────────────────────── */
598
+ {
599
+ noun: "commissions", verb: "list", group: "Money",
600
+ summary: "the commission ledger, newest first",
601
+ example: "trackrev commissions list --status pending",
602
+ args: [],
603
+ flags: [
604
+ LIMIT("rows to return (max 500)"),
605
+ flag("status", "string", "pending, eligible, paid, refunded, void or fraud", { arg: "NAME" }),
606
+ flag("partner", "string", "one affiliate only", { arg: "ID" }),
607
+ ],
608
+ module: "money", handler: "commissions", paid: true, bareNoun: true,
609
+ note: "level 1 is the affiliate who sold; 2+ is an upline earning from their network.",
610
+ },
611
+ {
612
+ noun: "commissions", verb: "add", group: "Money",
613
+ summary: "record an off-platform deal by hand",
614
+ example: "trackrev commissions add --program <id> --partner <id> --amount 500 --earnings 100",
615
+ args: [],
616
+ flags: [
617
+ flag("program", "string", "program id (required)", { arg: "ID" }),
618
+ flag("partner", "string", "affiliate id (required)", { arg: "ID" }),
619
+ flag("amount", "string", "gross sale value (required)", { arg: "N" }),
620
+ flag("earnings", "string", "the affiliate's cut (required)", { arg: "N" }),
621
+ flag("currency", "string", "defaults to usd", { arg: "CODE" }),
622
+ flag("notes", "string", "why this was entered by hand", { arg: "TEXT" }),
623
+ ],
624
+ module: "money", handler: "addCommission", paid: true,
625
+ note: "Earnings is not derived — a manual commission exists because the normal rate did not apply. It counts toward your monthly commission cap.",
626
+ },
627
+ {
628
+ noun: "commissions", verb: "void", group: "Money",
629
+ summary: "void a commission entered in error",
630
+ example: "trackrev commissions void <id> --yes",
631
+ args: ["id"],
632
+ flags: [flag("status", "string", "set another status instead of void", { arg: "NAME" })],
633
+ module: "money", handler: "voidCommission", paid: true, destructive: true,
634
+ note: "Refused if it is already on a payout batch — cancel the payout first.",
635
+ },
636
+ {
637
+ noun: "payouts", verb: "list", group: "Money",
638
+ summary: "payout batches, with open and all-time totals",
639
+ example: "trackrev payouts list --status pending",
640
+ args: [],
641
+ flags: [
642
+ flag("status", "string", "pending, processing, paid, failed or canceled", { arg: "NAME" }),
643
+ LIMIT("rows to return (max 500)"),
644
+ ],
645
+ module: "money", handler: "payouts", paid: true, bareNoun: true,
646
+ note: "Creating a batch stays in the dashboard: it applies per-group payout floors and platform fees, and a second implementation would eventually pay someone wrong.",
647
+ },
648
+ {
649
+ noun: "payouts", verb: "mark-paid", group: "Money",
650
+ summary: "settle a payout sent off-platform",
651
+ example: "trackrev payouts mark-paid <id> --reference PAYPAL-BATCH-123 --yes",
652
+ args: ["id"],
653
+ flags: [flag("reference", "string", "the rail's own id (PayPal batch, Wise transfer)", { arg: "TEXT" })],
654
+ module: "money", handler: "markPaid", paid: true, destructive: true,
655
+ note: "Does NOT email the affiliate — the dashboard sends that, and a re-run would send it twice.",
656
+ },
657
+
658
+ /* ── Settings ─────────────────────────────────────────────────────────── */
659
+ {
660
+ noun: "settings", verb: "notifications", group: "Settings",
661
+ summary: "every transactional email, and whether it is on",
662
+ example: "trackrev settings notifications",
663
+ args: [],
664
+ flags: [],
665
+ module: "settings", handler: "notifications", paid: false, bareNoun: true,
666
+ note: "default=yes means no override is stored and the catalogue default applies.",
667
+ },
668
+ {
669
+ noun: "settings", verb: "notify", group: "Settings",
670
+ summary: "turn one transactional email on or off",
671
+ example: "trackrev settings notify affiliate.approved --off",
672
+ args: ["key"],
673
+ flags: [
674
+ flag("on", "boolean", "enable it"),
675
+ flag("off", "boolean", "disable it"),
676
+ ],
677
+ module: "settings", handler: "setNotification", paid: false,
678
+ },
679
+ {
680
+ noun: "settings", verb: "branding", group: "Settings",
681
+ summary: "the white-label settings affiliates see",
682
+ example: "trackrev settings branding",
683
+ args: [],
684
+ flags: [],
685
+ module: "settings", handler: "branding", paid: false,
686
+ },
687
+ {
688
+ noun: "settings", verb: "set-branding", group: "Settings",
689
+ summary: "set the partner-facing logo and accent colour",
690
+ example: "trackrev settings set-branding --color '#e63e2e'",
691
+ args: [],
692
+ flags: [
693
+ flag("logo", "string", "https logo URL", { arg: "URL" }),
694
+ flag("color", "string", "hex accent, e.g. #e63e2e", { arg: "HEX" }),
695
+ flag("clear-logo", "boolean", "back to the TrackRev logo"),
696
+ flag("clear-color", "boolean", "back to the TrackRev colour"),
697
+ ],
698
+ module: "settings", handler: "setBranding", paid: false,
699
+ note: "The affiliate subdomain is read-only here — claiming one is a namespace reservation and belongs in one place.",
700
+ },
701
+
702
+ /* ── Account ──────────────────────────────────────────────────────────── */
703
+ {
704
+ noun: "me", verb: null, group: "Account",
705
+ summary: "which workspace, plan, limits and key you're using",
706
+ example: "trackrev me",
707
+ args: [],
708
+ flags: [],
709
+ module: "me", handler: "me", paid: false,
710
+ },
711
+ {
712
+ noun: "login", verb: null, group: "Account",
713
+ summary: "save a secret key so you don't need TRACKREV_KEY",
714
+ example: "trackrev login --profile staging --api-url https://staging.example.com/api/v1",
715
+ args: [],
716
+ flags: [
717
+ flag("key", "string", "the key (prompted, hidden, when omitted)", { arg: "lk_…" }),
718
+ flag("api-url", "string", "API base for this profile", { arg: "URL" }),
719
+ ],
720
+ module: "auth", handler: "login", paid: false,
721
+ note: "Stored at ~/.config/trackrev/config.json with mode 0600. TRACKREV_KEY in the environment always wins, for CI.",
722
+ },
723
+ {
724
+ noun: "logout", verb: null, group: "Account",
725
+ summary: "forget a saved key",
726
+ example: "trackrev logout",
727
+ args: [],
728
+ flags: [],
729
+ module: "auth", handler: "logout", paid: false,
730
+ },
731
+ ];
732
+
733
+ /* ── Derivations ─────────────────────────────────────────────────────────── */
734
+
735
+ /** The words after `trackrev` that invoke a command. */
736
+ export function usageOf(cmd) {
737
+ return [cmd.noun, cmd.verb].filter(Boolean).join(" ");
738
+ }
739
+
740
+ /** Help-table label for a flag: "--days N". */
741
+ export function flagLabel(f) {
742
+ return f.arg ? `--${f.name} ${f.arg}` : `--${f.name}`;
743
+ }
744
+
745
+ /**
746
+ * Resolve the typed words to a command. Tries noun+verb, then a bare noun
747
+ * (`channels`; `links` → `links perf`), then top-level aliases (`journey`).
748
+ * Returns the command and how many positionals it consumed.
749
+ */
750
+ export function resolve(noun, verb) {
751
+ if (!noun) return null;
752
+ if (verb) {
753
+ const exact = COMMANDS.find((c) => c.noun === noun && c.verb === verb);
754
+ if (exact) return { command: exact, consumed: 2 };
755
+ }
756
+ const bare = COMMANDS.find((c) => c.noun === noun && (c.verb === null || c.bareNoun));
757
+ if (bare) return { command: bare, consumed: 1 };
758
+ const alias = COMMANDS.find((c) => (c.aliases ?? []).includes(noun));
759
+ if (alias) return { command: alias, consumed: 1 };
760
+ return null;
761
+ }
762
+
763
+ /** A parseArgs `options` spec: the command's flags plus the globals. */
764
+ export function optionsFor(cmd) {
765
+ const spec = {};
766
+ for (const f of [...(cmd ? cmd.flags : allFlags()), ...GLOBAL_FLAGS]) {
767
+ spec[f.name] = {
768
+ type: f.type,
769
+ ...(f.multiple && { multiple: true }),
770
+ ...(f.default !== undefined && { default: f.default }),
771
+ };
772
+ }
773
+ return spec;
774
+ }
775
+
776
+ /** Every flag across every command, one per name — for the first, lenient parse. */
777
+ function allFlags() {
778
+ const seen = new Map();
779
+ for (const c of COMMANDS) for (const f of c.flags) if (!seen.has(f.name)) seen.set(f.name, f);
780
+ return [...seen.values()];
781
+ }
782
+
783
+ const pad = (s, n) => s.padEnd(n);
784
+ const words = (c) => usageOf(c) + c.args.map((a) => ` <${a}>`).join("");
785
+
786
+ export function helpText() {
787
+ const groups = [...new Set(COMMANDS.map((c) => c.group))];
788
+ const width = Math.max(...COMMANDS.map((c) => words(c).length));
789
+ const lines = [
790
+ `trackrev ${VERSION} — TrackRev in the terminal`,
791
+ "",
792
+ "Usage",
793
+ " trackrev <command> [flags]",
794
+ " trackrev <command> --help flags for one command",
795
+ "",
796
+ ];
797
+ for (const g of groups) {
798
+ lines.push(g);
799
+ for (const c of COMMANDS.filter((c) => c.group === g)) {
800
+ lines.push(` ${pad(words(c), width)} ${c.summary}`);
801
+ }
802
+ lines.push("");
803
+ }
804
+ const gw = Math.max(...GLOBAL_FLAGS.map((f) => flagLabel(f).length));
805
+ lines.push("Global flags");
806
+ for (const f of GLOBAL_FLAGS) lines.push(` ${pad(flagLabel(f), gw)} ${f.meaning}`);
807
+ lines.push(
808
+ "",
809
+ "Output",
810
+ " A table when you are watching, tab-separated when piped or redirected,",
811
+ " JSON with --json. Warnings and errors always go to stderr.",
812
+ "",
813
+ "Exit codes",
814
+ " 0 success · 1 failure · 2 usage error · 3 plan required (upgrade)",
815
+ "",
816
+ "Environment",
817
+ " TRACKREV_KEY secret key; overrides any saved login",
818
+ " TRACKREV_API_URL API base; overrides the profile's",
819
+ "",
820
+ "Analytics commands need a paid plan. Link and account commands work on every plan.",
821
+ );
822
+ return lines.join("\n");
823
+ }
824
+
825
+ export function helpFor(cmd) {
826
+ const lines = [`trackrev ${words(cmd)} — ${cmd.summary}`, "", ` ${cmd.example}`, ""];
827
+ const all = [...cmd.flags, ...GLOBAL_FLAGS];
828
+ const w = Math.max(...all.map((f) => flagLabel(f).length));
829
+ if (cmd.flags.length) {
830
+ lines.push("Flags");
831
+ for (const f of cmd.flags) lines.push(` ${pad(flagLabel(f), w)} ${f.meaning}`);
832
+ lines.push("");
833
+ }
834
+ lines.push("Global");
835
+ for (const f of GLOBAL_FLAGS) lines.push(` ${pad(flagLabel(f), w)} ${f.meaning}`);
836
+ if (cmd.note) lines.push("", cmd.note);
837
+ if (cmd.paid) lines.push("", "Needs a paid plan.");
838
+ if (cmd.destructive) lines.push("", "Asks for confirmation. Pass --yes in scripts.");
839
+ return lines.join("\n");
840
+ }