visitrack 1.0.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.
Files changed (3) hide show
  1. package/README.md +51 -0
  2. package/package.json +10 -0
  3. package/visitrack.mjs +189 -0
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # visitrack
2
+
3
+ Read your [VisitTrack](https://visitrack.app) analytics from the terminal.
4
+
5
+ A thin wrapper over the public REST API — every command maps to one
6
+ `GET /api/v1/<resource>`, so it can never report something the API
7
+ wouldn't. No dependencies, single file.
8
+
9
+ ## Use it
10
+
11
+ From a checkout, with nothing to install:
12
+
13
+ ```bash
14
+ export VISITRACK_API_KEY=vt_... # Settings → API / MCP → New API key
15
+
16
+ node cli/visitrack.mjs stats
17
+ node cli/visitrack.mjs pages --days 7
18
+ node cli/visitrack.mjs referrers --csv > referrers.csv
19
+ ```
20
+
21
+ For a plain `visitrack` on your PATH, install this folder globally:
22
+
23
+ ```bash
24
+ npm install -g ./cli
25
+
26
+ visitrack stats
27
+ ```
28
+
29
+ It is not published to the npm registry yet, so `npx visitrack` does not
30
+ work — publishing it is planned, and the docs switch to that form when it
31
+ happens.
32
+
33
+ ## Commands
34
+
35
+ `site` · `stats` · `timeseries` · `pages` · `referrers` · `countries` ·
36
+ `devices` · `revenue` · `goals` · `live`
37
+
38
+ ## Options
39
+
40
+ | Flag | What it does |
41
+ |---|---|
42
+ | `--days N` | How far back to report on (1–365, default 30) |
43
+ | `--from ISO --to ISO` | An explicit range instead of `--days` |
44
+ | `--granularity G` | `day` \| `hour` \| `minute` (timeseries only) |
45
+ | `--json` | Raw JSON, for piping into `jq` |
46
+ | `--csv` | CSV, for row-shaped resources |
47
+ | `--key KEY` | API key, if you'd rather not use the env var |
48
+ | `--host URL` | API origin (default `https://visitrack.app`) |
49
+
50
+ A key is scoped to one site, and is read-only — nothing the CLI can do
51
+ changes or deletes anything.
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "visitrack",
3
+ "version": "1.0.0",
4
+ "description": "Read your VisitTrack analytics from the terminal.",
5
+ "type": "module",
6
+ "bin": { "visitrack": "./visitrack.mjs" },
7
+ "files": ["visitrack.mjs", "README.md"],
8
+ "engines": { "node": ">=18" },
9
+ "license": "MIT"
10
+ }
package/visitrack.mjs ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ // VisitTrack CLI — the third piece of the "API, CLI & exports" the pricing
3
+ // page has always promised. Deliberately a thin wrapper over the public
4
+ // REST API rather than its own data path: every command below maps to one
5
+ // GET /api/v1/<resource>, so the CLI can never report something the API
6
+ // wouldn't, and a new API resource shows up here by adding one line.
7
+ //
8
+ // Zero dependencies, single file, Node built-ins only — it runs straight
9
+ // from a checkout (`node cli/visitrack.mjs stats`) with no build step.
10
+
11
+ const DEFAULT_HOST = "https://visitrack.app";
12
+
13
+ const RESOURCES = {
14
+ site: "Details of the site this key belongs to",
15
+ stats: "Visitors, pageviews, bounce rate, session time, online now",
16
+ timeseries: "Visitors and pageviews per bucket",
17
+ pages: "Most-viewed pages",
18
+ referrers: "Traffic sources with their channel",
19
+ countries: "Visitors by country",
20
+ devices: "Visitors by device type",
21
+ revenue: "Attributed revenue plus breakdowns",
22
+ goals: "Goals with completions and conversion rates",
23
+ live: "Visitors active in the last 5 minutes",
24
+ funnels: "Funnels with per-step visitor counts and drop-off",
25
+ visitor: "One visitor's full event timeline (needs --visitor-id or --external-id)",
26
+ };
27
+
28
+ function parseArgs(argv) {
29
+ const [command, ...rest] = argv;
30
+ const flags = {};
31
+ for (let i = 0; i < rest.length; i++) {
32
+ const arg = rest[i];
33
+ if (!arg.startsWith("--")) continue;
34
+ const [name, inline] = arg.slice(2).split("=");
35
+ if (inline !== undefined) {
36
+ flags[name] = inline;
37
+ } else if (rest[i + 1] && !rest[i + 1].startsWith("--")) {
38
+ flags[name] = rest[++i];
39
+ } else {
40
+ flags[name] = true;
41
+ }
42
+ }
43
+ return { command, flags };
44
+ }
45
+
46
+ function usage() {
47
+ const width = Math.max(...Object.keys(RESOURCES).map((r) => r.length));
48
+ return `visitrack — read your analytics from the terminal
49
+
50
+ Usage
51
+ node cli/visitrack.mjs <command> [options]
52
+ visitrack <command> [options] (after npm install -g ./cli)
53
+
54
+ Commands
55
+ ${Object.entries(RESOURCES)
56
+ .map(([name, summary]) => ` ${name.padEnd(width)} ${summary}`)
57
+ .join("\n")}
58
+
59
+ Options
60
+ --days N How far back to report on (1-365, default 30)
61
+ --from ISO --to ISO An explicit range instead of --days
62
+ --granularity G day | hour | minute (timeseries only)
63
+ --visitor-id ID Look up one visitor by our id (visitor only)
64
+ --external-id ID Look up one visitor by your own user id (visitor only)
65
+ --json Print the raw JSON response
66
+ --csv Print CSV (row-shaped resources only)
67
+ --key KEY API key; defaults to $VISITRACK_API_KEY
68
+ --host URL API origin (default ${DEFAULT_HOST})
69
+
70
+ Getting a key
71
+ Dashboard -> Settings -> API / MCP -> New API key, then:
72
+ export VISITRACK_API_KEY=vt_...
73
+
74
+ Examples
75
+ node cli/visitrack.mjs stats
76
+ node cli/visitrack.mjs pages --days 7
77
+ node cli/visitrack.mjs referrers --csv > referrers.csv
78
+ node cli/visitrack.mjs timeseries --granularity hour --days 2
79
+ node cli/visitrack.mjs funnels
80
+ node cli/visitrack.mjs visitor --external-id user_482`;
81
+ }
82
+
83
+ function fail(message) {
84
+ process.stderr.write(`${message}\n`);
85
+ process.exit(1);
86
+ }
87
+
88
+ /** Aligned columns, so output stays readable without a table dependency. */
89
+ function renderTable(rows) {
90
+ if (rows.length === 0) return "(nothing to show)";
91
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
92
+ const cell = (row, column) => {
93
+ const value = row[column];
94
+ if (value === null || value === undefined) return "";
95
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
96
+ };
97
+ const widths = columns.map((column) =>
98
+ Math.max(column.length, ...rows.map((row) => cell(row, column).length)),
99
+ );
100
+ const line = (cells) => cells.map((text, i) => text.padEnd(widths[i])).join(" ").trimEnd();
101
+
102
+ return [
103
+ line(columns),
104
+ line(widths.map((width) => "-".repeat(width))),
105
+ ...rows.map((row) => line(columns.map((column) => cell(row, column)))),
106
+ ].join("\n");
107
+ }
108
+
109
+ /** Scalar resources (stats, site) read better as label/value pairs. */
110
+ function renderObject(data) {
111
+ const entries = Object.entries(data).filter(([, value]) => typeof value !== "object" || value === null);
112
+ const width = Math.max(...entries.map(([key]) => key.length));
113
+ return entries.map(([key, value]) => `${key.padEnd(width)} ${value ?? ""}`).join("\n");
114
+ }
115
+
116
+ function firstArray(data) {
117
+ if (Array.isArray(data)) return data;
118
+ return Object.values(data ?? {}).find(Array.isArray) ?? null;
119
+ }
120
+
121
+ async function main() {
122
+ const { command, flags } = parseArgs(process.argv.slice(2));
123
+
124
+ if (!command || command === "help" || flags.help) {
125
+ process.stdout.write(`${usage()}\n`);
126
+ return;
127
+ }
128
+ if (!(command in RESOURCES)) {
129
+ fail(`Unknown command '${command}'.\n\n${usage()}`);
130
+ }
131
+
132
+ const key = flags.key ?? process.env.VISITRACK_API_KEY;
133
+ if (!key || key === true) {
134
+ fail("No API key. Pass --key, or set VISITRACK_API_KEY.\nCreate one in Settings -> API / MCP.");
135
+ }
136
+
137
+ const host = (flags.host === true ? null : flags.host) ?? process.env.VISITRACK_HOST ?? DEFAULT_HOST;
138
+ const url = new URL(`/api/v1/${command}`, host);
139
+ for (const name of ["days", "from", "to", "granularity"]) {
140
+ if (flags[name] !== undefined && flags[name] !== true) url.searchParams.set(name, String(flags[name]));
141
+ }
142
+ // Flag names read like CLI options (kebab-case); the API's query params
143
+ // match the resource's own field names (camelCase) — translate here so
144
+ // the REST endpoint never has to know about the CLI's spelling.
145
+ if (flags["visitor-id"] !== undefined && flags["visitor-id"] !== true) {
146
+ url.searchParams.set("visitorId", String(flags["visitor-id"]));
147
+ }
148
+ if (flags["external-id"] !== undefined && flags["external-id"] !== true) {
149
+ url.searchParams.set("externalId", String(flags["external-id"]));
150
+ }
151
+ if (flags.csv) url.searchParams.set("format", "csv");
152
+
153
+ let response;
154
+ try {
155
+ response = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
156
+ } catch (error) {
157
+ fail(`Couldn't reach ${host}: ${error.message}`);
158
+ }
159
+
160
+ if (response.status === 401) fail("That API key was rejected. Check it, or create a new one in Settings -> API / MCP.");
161
+ if (response.status === 429) {
162
+ fail(`Rate limited — 120 requests a minute per key. Retry in ${response.headers.get("retry-after") ?? "a moment"}s.`);
163
+ }
164
+ if (!response.ok) {
165
+ let detail = "";
166
+ try {
167
+ detail = (await response.json()).error ?? "";
168
+ } catch {
169
+ /* non-JSON error body */
170
+ }
171
+ fail(`Request failed (${response.status})${detail ? `: ${detail}` : ""}`);
172
+ }
173
+
174
+ if (flags.csv) {
175
+ process.stdout.write(await response.text());
176
+ return;
177
+ }
178
+
179
+ const body = await response.json();
180
+ if (flags.json) {
181
+ process.stdout.write(`${JSON.stringify(body, null, 2)}\n`);
182
+ return;
183
+ }
184
+
185
+ const rows = firstArray(body.data);
186
+ process.stdout.write(`${rows ? renderTable(rows) : renderObject(body.data)}\n`);
187
+ }
188
+
189
+ main().catch((error) => fail(error.message));