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/package.json CHANGED
@@ -1,20 +1,32 @@
1
1
  {
2
2
  "name": "trackrev",
3
- "version": "0.1.0",
4
- "description": "Your TrackRev link analytics, in the terminal — channels, links and the raw click stream.",
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.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "trackrev": "src/index.js"
8
8
  },
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./registry": {
12
+ "types": "./src/registry.d.ts",
13
+ "default": "./src/registry.js"
14
+ }
15
+ },
9
16
  "files": [
10
17
  "src",
11
18
  "README.md"
12
19
  ],
20
+ "scripts": {
21
+ "test": "node --test test/*.test.js",
22
+ "sync-docs": "node scripts/sync-docs.mjs"
23
+ },
13
24
  "keywords": [
14
25
  "trackrev",
15
26
  "analytics",
16
27
  "attribution",
17
28
  "link-tracking",
29
+ "short-links",
18
30
  "cli"
19
31
  ],
20
32
  "homepage": "https://trackrev.io",
@@ -0,0 +1,162 @@
1
+ // Programs, partners and groups.
2
+ import { die, warn, EXIT } from "../lib/api.js";
3
+ import { emit, emitRecord } from "../lib/output.js";
4
+ import { confirm } from "../lib/prompt.js";
5
+
6
+ const PROGRAM_COLUMNS = [
7
+ { header: "name", value: (r) => r.name },
8
+ { header: "status", value: (r) => r.status },
9
+ { header: "commission", value: (r) =>
10
+ r.commission_type === "percent" ? `${(r.commission_rate * 100).toFixed(1)}%` : `$${r.commission_rate}` },
11
+ { header: "recurring", value: (r) => (r.recurring_months ? `${r.recurring_months}mo` : null) },
12
+ { header: "cookie", value: (r) => `${r.cookie_window_days}d` },
13
+ { header: "autoapprove", value: (r) => r.auto_approve },
14
+ { header: "id", value: (r) => r.id },
15
+ ];
16
+
17
+ const PARTNER_COLUMNS = [
18
+ { header: "email", value: (r) => r.email },
19
+ { header: "status", value: (r) => r.status },
20
+ { header: "clicks", value: (r) => Number(r.total_clicks), align: "right" },
21
+ { header: "sales", value: (r) => Number(r.total_sales), align: "right" },
22
+ { header: "revenue", value: (r) => Number(r.total_revenue), align: "right", fixed: 2 },
23
+ { header: "earned", value: (r) => Number(r.total_earned), align: "right", fixed: 2 },
24
+ { header: "partner_id", value: (r) => r.partner_id },
25
+ ];
26
+
27
+ /* ── programs ─────────────────────────────────────────────────────────────── */
28
+
29
+ export async function programs({ flags, api }) {
30
+ const q = flags.archived ? "?include=archived" : "";
31
+ const body = await api.get(`/programs${q}`);
32
+ emit(PROGRAM_COLUMNS, body.programs, body, {
33
+ json: flags.json,
34
+ empty: "No programs yet. Create one in the dashboard.",
35
+ });
36
+ }
37
+
38
+ export async function program({ args, flags, api }) {
39
+ const [id] = args;
40
+ const p = await api.get(`/programs/${encodeURIComponent(id)}`);
41
+ emitRecord(
42
+ [
43
+ ["id", p.id],
44
+ ["name", p.name],
45
+ ["slug", p.slug],
46
+ ["short_code", p.short_code],
47
+ ["status", p.status],
48
+ ["landing_url", p.landing_url],
49
+ ["commission", p.commission_type === "percent" ? `${(p.commission_rate * 100).toFixed(1)}%` : `$${p.commission_rate}`],
50
+ ["recurring_months", p.recurring_months],
51
+ ["cookie_window_days", p.cookie_window_days],
52
+ ["min_payout", p.min_payout],
53
+ ["currency", p.payment_currency],
54
+ ["auto_approve", p.auto_approve],
55
+ ["multi_tier", p.multi_tier_enabled],
56
+ ],
57
+ p,
58
+ { json: flags.json },
59
+ );
60
+ }
61
+
62
+ export async function updateProgram({ args, flags, api }) {
63
+ const [id] = args;
64
+ const patch = {};
65
+ if (flags.name !== undefined) patch.name = flags.name;
66
+ if (flags["landing-url"] !== undefined) patch.landing_url = flags["landing-url"];
67
+ if (flags.type !== undefined) patch.commission_type = flags.type;
68
+ if (flags.rate !== undefined) {
69
+ const n = Number(flags.rate);
70
+ if (!Number.isFinite(n)) die("--rate must be a number.", EXIT.USAGE);
71
+ patch.commission_rate = n;
72
+ }
73
+ if (flags.recurring !== undefined) patch.recurring_months = Number(flags.recurring);
74
+ if (flags.cookie !== undefined) patch.cookie_window_days = Number(flags.cookie);
75
+ if (flags["min-payout"] !== undefined) patch.min_payout = Number(flags["min-payout"]);
76
+ if (flags["auto-approve"] !== undefined) {
77
+ if (flags["auto-approve"] !== "on" && flags["auto-approve"] !== "off") {
78
+ die("--auto-approve takes on or off.", EXIT.USAGE);
79
+ }
80
+ patch.auto_approve = flags["auto-approve"] === "on";
81
+ }
82
+ if (flags.status !== undefined) patch.status = flags.status;
83
+ if (Object.keys(patch).length === 0) {
84
+ die("Nothing to change. Run: trackrev programs update --help", EXIT.USAGE);
85
+ }
86
+
87
+ const p = await api.patch(`/programs/${encodeURIComponent(id)}`, patch);
88
+ emit(PROGRAM_COLUMNS, [p], p, { json: flags.json });
89
+ if (!flags.json && patch.commission_rate !== undefined) {
90
+ warn("Existing commissions are unchanged; this applies to new conversions.");
91
+ }
92
+ }
93
+
94
+ /* ── partners ─────────────────────────────────────────────────────────────── */
95
+
96
+ export async function partners({ flags, api }) {
97
+ const params = new URLSearchParams();
98
+ if (flags.program) params.set("program_id", flags.program);
99
+ if (flags.status) params.set("status", flags.status);
100
+ const body = await api.get(`/partners?${params}`);
101
+ emit(PARTNER_COLUMNS, body.partners, body, {
102
+ json: flags.json,
103
+ empty: "No affiliates yet.",
104
+ });
105
+ }
106
+
107
+ async function setStatus({ args, flags, api }, status, verb) {
108
+ const [partnerId] = args;
109
+ if (!flags.program) die("--program is required (the program id).", EXIT.USAGE);
110
+ if (status !== "approved") {
111
+ const ok = await confirm(`${verb} affiliate ${partnerId}?`, { yes: flags.yes });
112
+ if (!ok) die("Cancelled.", EXIT.USAGE);
113
+ }
114
+ const p = await api.post("/partners/status", {
115
+ program_id: flags.program,
116
+ partner_id: partnerId,
117
+ status,
118
+ });
119
+ emit(PARTNER_COLUMNS, [p], p, { json: flags.json });
120
+ if (!flags.json && status === "approved") {
121
+ warn("No approval email was sent — the dashboard sends that one.");
122
+ }
123
+ }
124
+
125
+ export const approve = (c) => setStatus(c, "approved", "Approve");
126
+ export const reject = (c) => setStatus(c, "rejected", "Reject");
127
+ export const ban = (c) => setStatus(c, "banned", "Ban");
128
+
129
+ export async function group({ args, flags, api }) {
130
+ const [partnerId] = args;
131
+ if (!flags.program) die("--program is required (the program id).", EXIT.USAGE);
132
+ const p = await api.post("/partners/group", {
133
+ program_id: flags.program,
134
+ partner_id: partnerId,
135
+ group_id: flags.group ?? null,
136
+ });
137
+ if (flags.json) console.log(JSON.stringify(p, null, 2));
138
+ else warn(p.group_id ? `Moved to group ${p.group_id}.` : "Removed from their group — back on program terms.");
139
+ }
140
+
141
+ /* ── groups ───────────────────────────────────────────────────────────────── */
142
+
143
+ export async function groups({ args, flags, api }) {
144
+ const [programId] = args;
145
+ const body = await api.get(`/programs/${encodeURIComponent(programId)}/groups`);
146
+ emit(
147
+ [
148
+ { header: "name", value: (r) => r.name },
149
+ { header: "default", value: (r) => r.is_default },
150
+ { header: "commission", value: (r) =>
151
+ r.resolved?.commission_type === "percent"
152
+ ? `${(r.resolved.commission_rate * 100).toFixed(1)}%`
153
+ : `$${r.resolved?.commission_rate ?? ""}` },
154
+ { header: "cookie", value: (r) => (r.resolved ? `${r.resolved.cookie_window_days}d` : null) },
155
+ { header: "overrides", value: (r) => (r.resolved?.overridden ?? []).join(",") || "inherits all" },
156
+ { header: "id", value: (r) => r.id },
157
+ ],
158
+ body.groups,
159
+ body,
160
+ { json: flags.json, empty: "No groups — every affiliate is on the program's own terms." },
161
+ );
162
+ }
@@ -0,0 +1,128 @@
1
+ // The four original commands, unchanged in behaviour: channels, links perf
2
+ // (bare `links`), clicks, journey. Paid plan only — the API 402s otherwise.
3
+ import { die, warn, EXIT } from "../lib/api.js";
4
+ import { emit } from "../lib/output.js";
5
+
6
+ const MAX_PAGES = 200;
7
+
8
+ /**
9
+ * The reporting window as query params. `--from`/`--to` win over `--days`:
10
+ * the server ignores `days` once either bound is present, so sending all three
11
+ * would only be noise in the request log.
12
+ */
13
+ function windowParams(flags) {
14
+ const params = new URLSearchParams();
15
+ if (flags.from) params.set("from", flags.from);
16
+ if (flags.to) params.set("to", flags.to);
17
+ if (!flags.from && !flags.to) params.set("days", flags.days ?? "30");
18
+ return params;
19
+ }
20
+
21
+ /** Parse a numeric flag, failing with a usage error rather than sending NaN. */
22
+ export function intFlag(name, rawValue, { min = 1, max }) {
23
+ const n = Number(rawValue);
24
+ if (!Number.isInteger(n) || n < min || n > max) {
25
+ die(`--${name} must be a whole number between ${min} and ${max}.`, EXIT.USAGE);
26
+ }
27
+ return n;
28
+ }
29
+
30
+ export async function channels({ flags, api }) {
31
+ const params = windowParams(flags);
32
+ if (flags.ltv) params.set("ltv", "1");
33
+ const body = await api.get(`/channels?${params}`);
34
+
35
+ const columns = [
36
+ { header: "channel", value: (r) => r.channel },
37
+ { header: "clicks", value: (r) => Number(r.clicks), align: "right" },
38
+ { header: "visitors", value: (r) => Number(r.visitors), align: "right" },
39
+ { header: "conversions", value: (r) => Number(r.conversions), align: "right", fixed: 2 },
40
+ { header: "revenue", value: (r) => Number(r.revenue), align: "right", fixed: 2 },
41
+ ];
42
+ if (flags.ltv) columns.push({ header: "ltv", value: (r) => Number(r.ltv), align: "right", fixed: 2 });
43
+
44
+ emit(columns, body.channels, body, { json: flags.json, empty: "No rows for this window." });
45
+ }
46
+
47
+ export async function linksPerf({ flags, api }) {
48
+ const params = windowParams(flags);
49
+ if (flags.limit !== undefined) params.set("limit", String(intFlag("limit", flags.limit, { max: 500 })));
50
+ if (flags.settings) params.set("include", "settings");
51
+ const body = await api.get(`/links?${params}`);
52
+
53
+ const columns = [
54
+ { header: "slug", value: (r) => r.slug },
55
+ { header: "channel", value: (r) => r.channel },
56
+ { header: "clicks", value: (r) => Number(r.clicks), align: "right" },
57
+ { header: "visitors", value: (r) => Number(r.visitors), align: "right" },
58
+ { header: "conversions", value: (r) => Number(r.conversions), align: "right", fixed: 2 },
59
+ { header: "revenue", value: (r) => Number(r.revenue), align: "right", fixed: 2 },
60
+ { header: "destination", value: (r) => r.destination },
61
+ ];
62
+ if (flags.settings) {
63
+ columns.push(
64
+ { header: "url", value: (r) => r.settings?.url ?? null },
65
+ { header: "expires", value: (r) => r.settings?.expires_at ?? null },
66
+ { header: "password", value: (r) => r.settings?.password_protected ?? false },
67
+ );
68
+ }
69
+ emit(columns, body.links, body, { json: flags.json, empty: "No rows for this window." });
70
+ }
71
+
72
+ export async function clicks({ flags, api }) {
73
+ const pageSize = flags.limit === undefined ? 100 : intFlag("limit", flags.limit, { max: 500 });
74
+ const stream = [];
75
+ let before = null;
76
+ let pages = 0;
77
+
78
+ do {
79
+ const params = new URLSearchParams({ limit: String(pageSize) });
80
+ if (before) params.set("before", before);
81
+ if (flags.link) params.set("link_id", flags.link);
82
+ if (flags.bots) params.set("bots", "include");
83
+
84
+ const page = await api.get(`/clicks?${params}`);
85
+ stream.push(...page.clicks);
86
+ before = page.next_cursor;
87
+
88
+ if (++pages >= MAX_PAGES && before) {
89
+ warn(`Stopped after ${MAX_PAGES} pages (${stream.length} clicks). Narrow it with --link.`);
90
+ break;
91
+ }
92
+ } while (flags.all && before);
93
+
94
+ const columns = [
95
+ { header: "ts", value: (r) => r.ts },
96
+ { header: "channel", value: (r) => r.channel },
97
+ { header: "country", value: (r) => r.country },
98
+ { header: "device", value: (r) => r.device },
99
+ { header: "browser", value: (r) => r.browser },
100
+ { header: "visitor", value: (r) => r.visitor_id },
101
+ ];
102
+ if (flags.bots) columns.push({ header: "bot", value: (r) => (r.is_bot ? "yes" : "") });
103
+
104
+ emit(columns, stream, { clicks: stream }, { json: flags.json, empty: "No clicks." });
105
+ }
106
+
107
+ export async function journey({ args, flags, api }) {
108
+ const [id] = args;
109
+ const body = await api.get(`/visitors/${encodeURIComponent(id)}/journey`);
110
+ const who = body.visitor;
111
+ // Identity goes to stderr: it is a caption, not a row, so a pipe stays clean.
112
+ if (!flags.json) warn(`visitor ${who.id}${who.email ? ` · ${who.email}` : ""}`);
113
+
114
+ emit(
115
+ [
116
+ { header: "ts", value: (r) => r.ts },
117
+ { header: "kind", value: (r) => r.kind },
118
+ { header: "label", value: (r) => r.label },
119
+ { header: "channel", value: (r) => r.channel },
120
+ { header: "link", value: (r) => r.link_slug },
121
+ { header: "amount", value: (r) => (r.amount == null ? null : Number(r.amount)), align: "right", fixed: 2 },
122
+ { header: "detail", value: (r) => r.detail },
123
+ ],
124
+ body.journey,
125
+ body,
126
+ { json: flags.json, empty: "No events for this visitor." },
127
+ );
128
+ }
@@ -0,0 +1,55 @@
1
+ // Attribution model and lookback window. Both retroactively change every
2
+ // revenue figure, so `set` says plainly what it just altered.
3
+ import { die, warn, EXIT } from "../lib/api.js";
4
+ import { emit, emitRecord } from "../lib/output.js";
5
+ import { intFlag } from "./analytics.js";
6
+
7
+ export async function get({ flags, api }) {
8
+ const body = await api.get("/attribution");
9
+ if (flags.models) {
10
+ emit(
11
+ [
12
+ { header: "model", value: (r) => r.value },
13
+ { header: "credit goes to", value: (r) => r.description },
14
+ ],
15
+ body.models,
16
+ body,
17
+ { json: flags.json },
18
+ );
19
+ return;
20
+ }
21
+ emitRecord(
22
+ [
23
+ ["model", body.model],
24
+ ["window_days", body.window_days],
25
+ ],
26
+ body,
27
+ { json: flags.json },
28
+ );
29
+ }
30
+
31
+ export async function set({ flags, api }) {
32
+ const patch = {};
33
+ if (flags.model !== undefined) patch.model = flags.model;
34
+ if (flags.window !== undefined) {
35
+ patch.window_days = intFlag("window", flags.window, { min: 1, max: 365 });
36
+ }
37
+ if (Object.keys(patch).length === 0) {
38
+ die("Pass --model and/or --window. Run: trackrev attribution set --help", EXIT.USAGE);
39
+ }
40
+
41
+ const body = await api.patch("/attribution", patch);
42
+ if (flags.json) {
43
+ console.log(JSON.stringify(body, null, 2));
44
+ return;
45
+ }
46
+ emitRecord(
47
+ [
48
+ ["model", body.model],
49
+ ["window_days", body.window_days],
50
+ ],
51
+ body,
52
+ {},
53
+ );
54
+ warn("Every revenue figure is recalculated against this — past orders included.");
55
+ }
@@ -0,0 +1,41 @@
1
+ import { readConfig, writeConfig, deleteConfig, configPath, DEFAULT_API } from "../lib/config.js";
2
+ import { createApi, die, warn, EXIT } from "../lib/api.js";
3
+ import { promptSecret } from "../lib/prompt.js";
4
+
5
+ /** Shape of a secret key: lk_<env>_<8 hex>_<48 hex>. See newApiKey() in the web app. */
6
+ const SECRET_KEY_RE = /^lk_[a-z]+_[0-9a-f]{8}_[0-9a-f]{48}$/;
7
+
8
+ export async function login({ flags }) {
9
+ const profile = flags.profile || "default";
10
+ const key = (flags.key || (await promptSecret("Secret key (lk_…)"))).trim();
11
+ if (!SECRET_KEY_RE.test(key)) {
12
+ die("That doesn't look like a secret key. Create one under Settings → Developers.", EXIT.USAGE);
13
+ }
14
+ const apiUrl = (flags["api-url"] || process.env.TRACKREV_API_URL || DEFAULT_API).replace(/\/$/, "");
15
+
16
+ // Prove the key before saving it — a typo should fail here, not on the next
17
+ // command, and /me works on every plan so this never trips the paid gate.
18
+ const me = await createApi({ key, apiUrl, source: "the login prompt" }).get("/me");
19
+
20
+ const cfg = readConfig();
21
+ cfg.profiles[profile] = { key, apiUrl };
22
+ cfg.current = profile;
23
+ writeConfig(cfg);
24
+
25
+ warn(`Logged in to ${me.workspace.name} (${me.plan.name}) as profile "${profile}".`);
26
+ warn(`Saved to ${configPath()}.`);
27
+ }
28
+
29
+ export async function logout({ flags }) {
30
+ const cfg = readConfig();
31
+ if (flags.profile) {
32
+ if (!cfg.profiles[flags.profile]) die(`No profile "${flags.profile}".`, EXIT.USAGE);
33
+ delete cfg.profiles[flags.profile];
34
+ if (cfg.current === flags.profile) cfg.current = Object.keys(cfg.profiles)[0] || "default";
35
+ writeConfig(cfg);
36
+ warn(`Forgot profile "${flags.profile}".`);
37
+ return;
38
+ }
39
+ deleteConfig();
40
+ warn("Forgot every saved login.");
41
+ }
@@ -0,0 +1,60 @@
1
+ // Branded short-link domains (go.brand.com).
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: "domain", value: (r) => r.domain },
8
+ { header: "status", value: (r) => r.status },
9
+ { header: "dnsok", value: (r) => !r.misconfigured },
10
+ { header: "added", value: (r) => (r.created_at ?? "").slice(0, 10) },
11
+ { header: "id", value: (r) => r.id },
12
+ ];
13
+
14
+ /** The DNS records a pending domain still needs, printed as a caption. */
15
+ function showVerification(d) {
16
+ const recs = Array.isArray(d.verification) ? d.verification : [];
17
+ if (d.status === "active" || recs.length === 0) return;
18
+ warn("\nAdd these DNS records, then run: trackrev domains verify " + d.domain);
19
+ for (const r of recs) warn(` ${r.type ?? "?"} ${r.domain ?? ""} ${r.value ?? ""}`);
20
+ }
21
+
22
+ export async function list({ flags, api }) {
23
+ const body = await api.get("/domains");
24
+ emit(COLUMNS, body.domains, body, {
25
+ json: flags.json,
26
+ empty: "No branded domains. Add one with: trackrev domains add go.yourbrand.com",
27
+ });
28
+ }
29
+
30
+ export async function add({ args, flags, api }) {
31
+ const [domain] = args;
32
+ const d = await api.post("/domains", { domain });
33
+ emit(COLUMNS, [d], d, { json: flags.json });
34
+ if (!flags.json) showVerification(d);
35
+ }
36
+
37
+ export async function verify({ args, flags, api }) {
38
+ const [idOrName] = args;
39
+ const d = await api.post(`/domains/${encodeURIComponent(idOrName)}`, {});
40
+ emit(COLUMNS, [d], d, { json: flags.json });
41
+ if (!flags.json) {
42
+ if (d.status === "active") warn("\nActive — links now resolve on this domain.");
43
+ else showVerification(d);
44
+ }
45
+ if (d.status !== "active") process.exitCode = EXIT.FAIL;
46
+ }
47
+
48
+ export async function remove({ args, flags, api }) {
49
+ const [idOrName] = args;
50
+ const d = await api.get(`/domains/${encodeURIComponent(idOrName)}`);
51
+ const ok = await confirm(
52
+ `Detach ${d.domain}? Links keep working on the default short host.`,
53
+ { yes: flags.yes },
54
+ );
55
+ if (!ok) die("Cancelled.", EXIT.USAGE);
56
+
57
+ const res = await api.del(`/domains/${encodeURIComponent(d.id)}`);
58
+ if (flags.json) console.log(JSON.stringify(res, null, 2));
59
+ else warn(`Detached ${res.domain}.`);
60
+ }
@@ -0,0 +1,69 @@
1
+ // Campaign folders — the grouping above campaigns.
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: "name", value: (r) => r.name },
8
+ { header: "campaigns", value: (r) => r.campaigns ?? null, align: "right" },
9
+ { header: "start", value: (r) => r.start_date },
10
+ { header: "end", value: (r) => r.end_date },
11
+ { header: "id", value: (r) => r.id },
12
+ ];
13
+
14
+ function body(flags) {
15
+ const out = {};
16
+ if (flags.name !== undefined) out.name = flags.name;
17
+ if (flags.description !== undefined) out.description = flags.description;
18
+ if (flags.start !== undefined) out.start_date = flags.start;
19
+ if (flags.end !== undefined) out.end_date = flags.end;
20
+ return out;
21
+ }
22
+
23
+ export async function list({ flags, api }) {
24
+ const res = await api.get("/folders");
25
+ emit(COLUMNS, res.folders, res, {
26
+ json: flags.json,
27
+ empty: "No campaign folders. Create one with: trackrev folders create --name 'Q4 launch'",
28
+ });
29
+ }
30
+
31
+ export async function create({ flags, api }) {
32
+ if (!flags.name) die("--name is required.", EXIT.USAGE);
33
+ const res = await api.post("/folders", body(flags));
34
+ emit(COLUMNS, [res], res, { json: flags.json });
35
+ }
36
+
37
+ export async function update({ args, flags, api }) {
38
+ const [id] = args;
39
+ const patch = body(flags);
40
+ if (Object.keys(patch).length === 0) {
41
+ die("Nothing to change. Run: trackrev folders update --help", EXIT.USAGE);
42
+ }
43
+ const res = await api.patch(`/folders/${encodeURIComponent(id)}`, patch);
44
+ emit(COLUMNS, [res], res, { json: flags.json });
45
+ }
46
+
47
+ export async function remove({ args, flags, api }) {
48
+ const [id] = args;
49
+ const folder = await api.get(`/folders/${encodeURIComponent(id)}`);
50
+ const ok = await confirm(
51
+ `Delete the folder "${folder.name}"? Its campaigns are kept and become ungrouped.`,
52
+ { yes: flags.yes },
53
+ );
54
+ if (!ok) die("Cancelled.", EXIT.USAGE);
55
+
56
+ const res = await api.del(`/folders/${encodeURIComponent(folder.id)}`);
57
+ if (flags.json) console.log(JSON.stringify(res, null, 2));
58
+ else warn(`Deleted "${folder.name}". ${res.released} campaign(s) are now ungrouped.`);
59
+ }
60
+
61
+ export async function assign({ args, flags, api }) {
62
+ const [destinationId] = args;
63
+ const res = await api.post("/folders/assign", {
64
+ destination_id: destinationId,
65
+ folder_id: flags.folder ?? null,
66
+ });
67
+ if (flags.json) console.log(JSON.stringify(res, null, 2));
68
+ else warn(res.folder_id ? `Filed under ${res.folder_id}.` : "Un-filed — now ungrouped.");
69
+ }
@@ -0,0 +1,54 @@
1
+ // Workspace API keys. The plaintext is shown once at creation and never again.
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: "prefix", value: (r) => r.prefix },
8
+ { header: "label", value: (r) => r.label },
9
+ { header: "scopes", value: (r) => (r.scopes ?? []).join(",") },
10
+ { header: "created", value: (r) => (r.created_at ?? "").slice(0, 10) },
11
+ { header: "lastused",value: (r) => (r.last_used_at ?? "").slice(0, 10) || null },
12
+ { header: "revoked", value: (r) => (r.revoked_at ?? "").slice(0, 10) || null },
13
+ { header: "id", value: (r) => r.id },
14
+ ];
15
+
16
+ export async function list({ flags, api }) {
17
+ const q = flags.revoked ? "?include=revoked" : "";
18
+ const body = await api.get(`/keys${q}`);
19
+ emit(COLUMNS, body.keys, body, {
20
+ json: flags.json,
21
+ empty: "No keys. Create one with: trackrev keys create",
22
+ });
23
+ }
24
+
25
+ export async function create({ flags, api }) {
26
+ const scope = flags.scope ?? "secret";
27
+ if (scope !== "secret" && scope !== "public") {
28
+ die("--scope must be secret or public.", EXIT.USAGE);
29
+ }
30
+ const body = await api.post("/keys", { scope, label: flags.label ?? null });
31
+
32
+ if (flags.json) {
33
+ console.log(JSON.stringify(body, null, 2));
34
+ return;
35
+ }
36
+ // The key itself goes to stdout so it can be captured; everything around it
37
+ // is a caption on stderr, so `trackrev keys create > key.txt` holds the key
38
+ // and nothing else.
39
+ warn(`Created ${body.key.prefix}… (${scope}). This is the only time it is shown:`);
40
+ console.log(body.secret);
41
+ warn("Store it now — it is hashed, not saved.");
42
+ }
43
+
44
+ export async function revoke({ args, flags, api }) {
45
+ const [id] = args;
46
+ const ok = await confirm(`Revoke key ${id}? Anything using it stops working immediately.`, {
47
+ yes: flags.yes,
48
+ });
49
+ if (!ok) die("Cancelled.", EXIT.USAGE);
50
+
51
+ const key = await api.del(`/keys/${encodeURIComponent(id)}`);
52
+ if (flags.json) console.log(JSON.stringify(key, null, 2));
53
+ else warn(`Revoked ${key.prefix}.`);
54
+ }