trackrev 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,18 +1,27 @@
1
1
  # trackrev
2
2
 
3
- [TrackRev](https://trackrev.io) in your terminal — create and manage tracking links, pull
4
- performance per channel and per link, walk the raw click stream, and replay any visitor's
5
- journey from first click to paid conversion.
3
+ [TrackRev](https://trackrev.io) for Node and the terminal — create and manage tracking links,
4
+ pull performance per channel and per link, walk the raw click stream, replay any visitor's
5
+ journey from first click to paid conversion, and run a referral program end to end.
6
6
 
7
- Zero dependencies. Node 20 or newer.
7
+ One package, two ways in: `import { TrackRev } from "trackrev"` in your app, or the `trackrev`
8
+ command in your shell. Zero dependencies. Node 20 or newer.
8
9
 
9
10
  ## Install
10
11
 
12
+ In your project, for the SDK:
13
+
14
+ ```bash
15
+ npm install trackrev
16
+ ```
17
+
18
+ Globally, for the CLI:
19
+
11
20
  ```bash
12
21
  npm install -g trackrev
13
22
  ```
14
23
 
15
- Or run it once without installing:
24
+ Or run the CLI once without installing:
16
25
 
17
26
  ```bash
18
27
  npx trackrev channels
@@ -45,6 +54,111 @@ dashboard does (50 links on the free tier). Analytics commands — `channels`, `
45
54
  `clicks`, `journey` — need a **paid plan**; on a free workspace they exit `3` with an upgrade
46
55
  message.
47
56
 
57
+ ## Node SDK
58
+
59
+ Everything below, as a client for your own code. Twenty resources, 71 methods, and a
60
+ hand-written `index.d.ts`, so an editor completes `trackrev.links.` and catches a misspelled
61
+ option before the code runs.
62
+
63
+ ```js
64
+ import { TrackRev } from "trackrev";
65
+
66
+ const trackrev = new TrackRev(process.env.TRACKREV_KEY);
67
+
68
+ const { channels } = await trackrev.channels.list({ days: 7 });
69
+
70
+ const launch = await trackrev.links.create("https://acme.com/launch", "Launch", {
71
+ channels: ["youtube", "newsletter"],
72
+ maxClicks: 1000,
73
+ });
74
+ ```
75
+
76
+ Required arguments are positional; everything optional goes in a trailing object, named in
77
+ camelCase and sent as the snake_case the API expects.
78
+
79
+ ```
80
+ trackrev.attribution .get .update
81
+ trackrev.channels .list
82
+ trackrev.clicks .list
83
+ trackrev.commissions .list .create .setStatus
84
+ trackrev.credits .list .markDelivered
85
+ trackrev.domains .list .get .add .verify .remove
86
+ trackrev.export .csv
87
+ trackrev.folders .list .get .create .update .remove .assign
88
+ trackrev.keys .list .create .revoke
89
+ trackrev.links .list .records .get .create .update .remove .createMany .qr
90
+ trackrev.orders .list
91
+ trackrev.partners .list .setStatus .setGroup
92
+ trackrev.payouts .list .settle
93
+ trackrev.programs .list .get .update .groups
94
+ trackrev.referrals .enroll .reportSignup .reportPurchase .stats
95
+ .setPayoutMethod .setRewardMode
96
+ trackrev.retargeting .list .set .remove
97
+ trackrev.revenue .connections .connection .providers .connect
98
+ .setWebhookSecret .disconnect .sync
99
+ trackrev.settings .notifications .setNotification .branding .updateBranding
100
+ trackrev.visitors .list .get .journey
101
+ trackrev.webhooks .list .events .get .create .update .remove
102
+ trackrev.me()
103
+ ```
104
+
105
+ ### A referral program, end to end
106
+
107
+ ```js
108
+ // Someone opts in. You get back their referral link.
109
+ const { referral_link } = await trackrev.referrals.enroll(user.id, user.email, "paid");
110
+
111
+ // Someone they invited signs up.
112
+ await trackrev.referrals.reportSignup(newUser.id, { refCode: "abc123" });
113
+
114
+ // That person pays. Idempotent on your own order id, so a replay never double-credits.
115
+ await trackrev.referrals.reportPurchase(newUser.id, order.id, 49.99, { currency: "usd" });
116
+ ```
117
+
118
+ ### Errors
119
+
120
+ Anything that is not a success rejects with a `TrackRevError` carrying the HTTP `status` and
121
+ the API's own `code`. A request that never got an answer — server unreachable, or timed out —
122
+ rejects with a `TrackRevConnectionError`, which is a `TrackRevError` with `status` 0.
123
+
124
+ ```js
125
+ import { TrackRev, TrackRevError, TrackRevConnectionError } from "trackrev";
126
+
127
+ try {
128
+ await trackrev.keys.create({ scope: "secret", label: "CI" });
129
+ } catch (e) {
130
+ if (e instanceof TrackRevConnectionError) retryLater();
131
+ else if (e instanceof TrackRevError) console.error(e.status, e.code, e.message);
132
+ else throw e;
133
+ }
134
+ ```
135
+
136
+ ### Retries
137
+
138
+ A request that is safe to repeat is retried three times on a 429, a 5xx or a connection
139
+ failure, backing off 400ms, 800ms, 1600ms with a little randomness so throttled clients do not
140
+ all return in the same instant. A `Retry-After` header wins over that schedule.
141
+
142
+ Safe to repeat means every `GET`, plus the three referral writes the API ignores a repeat of:
143
+ `enroll`, `reportSignup` and `reportPurchase`. Every other write — creating a link, minting a
144
+ key, settling a payout — is sent exactly once, because a retry there could do the work twice.
145
+
146
+ ### Options
147
+
148
+ ```js
149
+ new TrackRev(key, {
150
+ apiUrl: "https://app.trackrev.io/api/v1", // point at staging or self-hosted
151
+ timeoutMs: 15000, // per attempt; 0 means no limit
152
+ maxRetries: 3, // 0 turns retries off
153
+ });
154
+ ```
155
+
156
+ An endpoint the SDK has no method for yet is one call away:
157
+
158
+ ```js
159
+ await trackrev.request("GET", "/some/new/endpoint");
160
+ ```
161
+
48
162
  ## Commands
49
163
 
50
164
  <!-- cli:commands:start -->
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "trackrev",
3
- "version": "0.2.0",
4
- "description": "TrackRev in the terminal — create and manage tracking links, pull channel analytics, the raw click stream and any visitor's journey.",
3
+ "version": "0.3.0",
4
+ "description": "The TrackRev SDK and CLI — tracking links, channel analytics, referrals and affiliate payouts, from Node or the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "trackrev": "src/index.js"
8
8
  },
9
9
  "exports": {
10
- ".": "./src/index.js",
10
+ ".": {
11
+ "types": "./src/sdk/index.d.ts",
12
+ "default": "./src/sdk/index.js"
13
+ },
11
14
  "./registry": {
12
15
  "types": "./src/registry.d.ts",
13
16
  "default": "./src/registry.js"
@@ -27,7 +30,10 @@
27
30
  "attribution",
28
31
  "link-tracking",
29
32
  "short-links",
30
- "cli"
33
+ "cli",
34
+ "sdk",
35
+ "affiliate",
36
+ "referral"
31
37
  ],
32
38
  "homepage": "https://trackrev.io",
33
39
  "license": "MIT",
@@ -7,7 +7,8 @@ const COLUMNS = [
7
7
  { header: "provider", value: (r) => r.provider },
8
8
  { header: "lastsync", value: (r) => (r.last_sync_at ?? "").slice(0, 16).replace("T", " ") || null },
9
9
  { header: "webhook", value: (r) => r.webhook_configured },
10
- { header: "error", value: (r) => r.last_error },
10
+ // Provider errors run long; the full text is in --json and in a pipe.
11
+ { header: "error", value: (r) => r.last_error, max: 60 },
11
12
  { header: "id", value: (r) => r.id },
12
13
  ];
13
14
 
package/src/lib/output.js CHANGED
@@ -6,6 +6,18 @@ import { warn } from "./api.js";
6
6
 
7
7
  const TTY = process.stdout.isTTY;
8
8
 
9
+ /** Widest a table cell renders before it is truncated with an ellipsis. */
10
+ const DEFAULT_MAX_WIDTH = 48;
11
+
12
+ /**
13
+ * Shorten a value for TABLE display only. Exported so the rule can be tested
14
+ * without a terminal: the test harness spawns the CLI, so stdout is a pipe,
15
+ * and pipes deliberately receive the untruncated value.
16
+ */
17
+ export function truncate(value, max = DEFAULT_MAX_WIDTH) {
18
+ return value.length > max ? value.slice(0, max - 1) + "…" : value;
19
+ }
20
+
9
21
  /** Raw value for a pipe: no padding, no separators, empty cell for null. */
10
22
  function rawCell(value) {
11
23
  return value === null || value === undefined ? "" : String(value);
@@ -48,6 +60,17 @@ export function emit(columns, rows, body, { json, empty = "No rows." } = {}) {
48
60
  }
49
61
 
50
62
  const table = [header, ...cells.map((line) => line.map((v, i) => pretty(v, columns[i].fixed)))];
63
+
64
+ // Cap each column so one long value cannot destroy the layout. A provider
65
+ // error can run to several hundred characters; left unbounded it pushes every
66
+ // other column off the screen and the table stops being a table. Truncation
67
+ // is display-only — the pipe and --json still carry the full text, which is
68
+ // where anyone reading a long message will actually want it.
69
+ for (const line of table) {
70
+ for (let i = 0; i < line.length; i++) {
71
+ line[i] = truncate(line[i], columns[i].max ?? DEFAULT_MAX_WIDTH);
72
+ }
73
+ }
51
74
  const widths = header.map((_, i) => Math.max(...table.map((line) => line[i].length)));
52
75
  for (const line of table) {
53
76
  console.log(
package/src/registry.d.ts CHANGED
@@ -8,10 +8,29 @@ export interface CliFlag {
8
8
  default?: string;
9
9
  }
10
10
 
11
+ /**
12
+ * The heading a command is filed under in `trackrev --help`, on /cli, on
13
+ * /agents and in the generated README tables. Kept in lockstep with the
14
+ * `group:` values in registry.js — a group that exists there but not here is
15
+ * invisible to every TypeScript consumer that filters by group.
16
+ */
17
+ export type CliGroup =
18
+ | "Analytics"
19
+ | "Links"
20
+ | "Developers"
21
+ | "Setup"
22
+ | "Revenue"
23
+ | "Audience"
24
+ | "Domains"
25
+ | "Affiliate"
26
+ | "Money"
27
+ | "Settings"
28
+ | "Account";
29
+
11
30
  export interface CliCommand {
12
31
  noun: string;
13
32
  verb: string | null;
14
- group: "Analytics" | "Links" | "Account";
33
+ group: CliGroup;
15
34
  summary: string;
16
35
  example: string;
17
36
  args: string[];
package/src/registry.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Kept honest by a test that asserts this matches package.json.
22
22
  */
23
- export const VERSION = "0.2.0";
23
+ export const VERSION = "0.3.0";
24
24
 
25
25
  /** A flag definition. `arg` is the placeholder shown in help ("N", "ISO", "ID"). */
26
26
  const flag = (name, type, meaning, extra = {}) => ({ name, type, meaning, ...extra });
@@ -0,0 +1,177 @@
1
+ import { attribution } from "./resources/attribution.js";
2
+ import { channels } from "./resources/channels.js";
3
+ import { clicks } from "./resources/clicks.js";
4
+ import { commissions } from "./resources/commissions.js";
5
+ import { credits } from "./resources/credits.js";
6
+ import { domains } from "./resources/domains.js";
7
+ import { exportCsv } from "./resources/export.js";
8
+ import { folders } from "./resources/folders.js";
9
+ import { keys } from "./resources/keys.js";
10
+ import { links } from "./resources/links.js";
11
+ import { orders } from "./resources/orders.js";
12
+ import { partners } from "./resources/partners.js";
13
+ import { payouts } from "./resources/payouts.js";
14
+ import { programs } from "./resources/programs.js";
15
+ import { referrals } from "./resources/referrals.js";
16
+ import { retargeting } from "./resources/retargeting.js";
17
+ import { revenue } from "./resources/revenue.js";
18
+ import { settings } from "./resources/settings.js";
19
+ import { visitors } from "./resources/visitors.js";
20
+ import { webhooks } from "./resources/webhooks.js";
21
+ export const VERSION = "0.3.0";
22
+ const DEFAULT_API_URL = "https://app.trackrev.io/api/v1";
23
+
24
+ const RETRYABLE_POSTS = new Set([
25
+ "/referrals/enroll",
26
+ "/referrals/report-signup",
27
+ "/referrals/report-purchase"
28
+ ])
29
+
30
+ const sleep = (ms) => new Promise ((resolve)=> setTimeout(resolve, ms))
31
+
32
+ export class TrackRevError extends Error {
33
+ constructor({ status, code, message }) {
34
+ super(message);
35
+
36
+ this.name = "TrackRevError";
37
+ this.status = status;
38
+ this.code = code;
39
+ }
40
+ }
41
+ export class TrackRevConnectionError extends TrackRevError {
42
+ constructor(message) {
43
+ super({
44
+ status: 0,
45
+ code: "connection_error",
46
+ message
47
+ });
48
+
49
+ this.name = "TrackRevConnectionError";
50
+ }
51
+ }
52
+
53
+ export class TrackRev {
54
+ constructor(key, {
55
+ apiUrl = DEFAULT_API_URL,
56
+ timeoutMs=15000,
57
+ maxRetries=3
58
+ } = {}) {
59
+ this.key = key;
60
+ this.apiUrl = apiUrl;
61
+ this.timeoutMs = timeoutMs;
62
+ this.maxRetries = maxRetries;
63
+ // One way to reach the API, handed to every resource, so a resource
64
+ // file never touches fetch or the key itself.
65
+ const call = (method, path, options) => this.request(method, path, options);
66
+
67
+ this.attribution = attribution(call);
68
+ this.channels = channels(call);
69
+ this.clicks = clicks(call);
70
+ this.commissions = commissions(call);
71
+ this.credits = credits(call);
72
+ this.domains = domains(call);
73
+ this.export = exportCsv(call);
74
+ this.folders = folders(call);
75
+ this.keys = keys(call);
76
+ this.links = links(call);
77
+ this.orders = orders(call);
78
+ this.partners = partners(call);
79
+ this.payouts = payouts(call);
80
+ this.programs = programs(call);
81
+ this.referrals = referrals(call);
82
+ this.retargeting = retargeting(call);
83
+ this.revenue = revenue(call);
84
+ this.settings = settings(call);
85
+ this.visitors = visitors(call);
86
+ this.webhooks = webhooks(call);
87
+ }
88
+
89
+ // Who am I, and what can this key do.
90
+ me() {
91
+ return this.request("GET", "/me");
92
+ }
93
+
94
+
95
+ async request(method, path, options) {
96
+ const bare = path.split("?")[0];
97
+
98
+ const safeToRepeat =
99
+ method === "GET" || RETRYABLE_POSTS.has(bare);
100
+
101
+ for (let attempt = 0; ; attempt++) {
102
+ try {
103
+ return await this.#send(method, path, options);
104
+ } catch (e) {
105
+ const mightWorkLater =
106
+ e instanceof TrackRevConnectionError ||
107
+ e.status === 429 ||
108
+ e.status >= 500;
109
+
110
+ if (
111
+ !safeToRepeat ||
112
+ !mightWorkLater ||
113
+ attempt >= this.maxRetries
114
+ ) {
115
+ throw e;
116
+ }
117
+
118
+ const waitMs =
119
+ e.retryAfterMs ??
120
+ 400 * 2 ** attempt + Math.random() * 200;
121
+
122
+ await sleep(waitMs);
123
+ }
124
+ }
125
+ }
126
+ async #send(method, path, { body } = {}) {
127
+ const url = this.apiUrl + path;
128
+ const payload = body ? JSON.stringify(body) : undefined;
129
+ let response;
130
+ let text;
131
+ try {
132
+ response = await fetch(url, {
133
+ method,
134
+ headers: {
135
+ Authorization: `Bearer ${this.key}`,
136
+ "Content-Type": "application/json"
137
+ },
138
+ body: payload,
139
+ signal: this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs): undefined
140
+ });
141
+ text= await response.text();
142
+ } catch (e) {
143
+ // fetch itself failed — server unreachable, DNS error. No response exists, timeout
144
+ if(e.name === 'TimeoutError'){
145
+ throw new TrackRevConnectionError(`Request timed out after ${this.timeoutMs}ms`);
146
+ }
147
+ throw new TrackRevConnectionError(e.message || "Network error");
148
+ }
149
+
150
+ // The API sends { error: { code, message } }, but never assume the
151
+ // body parses — a proxy in front of it can send HTML or nothing.
152
+ let data = text;
153
+ if(response.headers.get("Content-Type")?.includes("application/json")){
154
+ try {
155
+ data = JSON.parse(text);
156
+ } catch {
157
+ data = null;
158
+ }
159
+ }
160
+
161
+
162
+ if (!response.ok) {
163
+ const err = new TrackRevError({
164
+ status: response.status,
165
+ code: data?.error?.code ?? "unknown_error",
166
+ message: data?.error?.message ?? `HTTP ${response.status}`
167
+ });
168
+ const retryAfter = Number(response.headers.get('Retry-After'));
169
+ if(retryAfter>0){
170
+ err.retryAfterMs = retryAfter * 1000;
171
+ }
172
+ throw err;
173
+ }
174
+
175
+ return data;
176
+ }
177
+ }