rosession 0.0.0-stage → 1.1.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 +16 -2
- package/bin/rosession.mjs +417 -0
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# rosession
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[RoSession](https://rosession.com) analytics for your Roblox games, in the terminal and for AI agents that run commands (Claude Code, Codex, Cursor).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx rosession login # sign in with Roblox in your browser
|
|
7
|
+
npx rosession games # your games
|
|
8
|
+
npx rosession overview "My Game" --range 30d
|
|
9
|
+
npx rosession leaving "My Game" # why players leave
|
|
10
|
+
npx rosession watch "My Game" # live players, refreshes every 10 s
|
|
11
|
+
npx rosession tools # every command and its options
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Pretty in a terminal, JSON when a program runs it (or with `--json`). No browser? Make a token at
|
|
15
|
+
rosession.com/dashboard/agents and run `npx rosession login --token rsa_…`, or set `ROSESSION_TOKEN`.
|
|
16
|
+
|
|
17
|
+
Every reply has `status`, `data`, a `link` to the dashboard page, and `next`: commands worth running after it.
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rosession: RoSession analytics in the terminal and for AI agents that run commands.
|
|
3
|
+
// rosession login [--token rsa_…] sign in (browser, or paste a token)
|
|
4
|
+
// rosession <tool> [game] [--option value …] [--json]
|
|
5
|
+
// rosession watch [game] live players, refreshed every 10 s
|
|
6
|
+
// Pretty when a person runs it, JSON when a program does (stdout isn't a terminal) or with --json.
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const VERSION = "1.1.0";
|
|
15
|
+
const DEFAULT_URL = "https://rosession.com";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------- args
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const flags = {};
|
|
20
|
+
const words = [];
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a.startsWith("--")) {
|
|
24
|
+
const [k, v] = a.slice(2).split("=", 2);
|
|
25
|
+
if (v !== undefined) flags[k] = v;
|
|
26
|
+
else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) flags[k] = argv[++i];
|
|
27
|
+
else flags[k] = true;
|
|
28
|
+
} else if (a === "-h") flags.help = true;
|
|
29
|
+
else words.push(a);
|
|
30
|
+
}
|
|
31
|
+
const asJson = !!flags.json || !process.stdout.isTTY;
|
|
32
|
+
const tty = process.stdout.isTTY && !flags.json && !process.env.NO_COLOR;
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------- config
|
|
35
|
+
const configFile = join(process.env.XDG_CONFIG_HOME || (process.platform === "win32" ? process.env.APPDATA || homedir() : join(homedir(), ".config")), "rosession", "config.json");
|
|
36
|
+
const readConfig = () => {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(configFile, "utf8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const saveConfig = (c) => {
|
|
44
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
45
|
+
writeFileSync(configFile, JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
46
|
+
};
|
|
47
|
+
const config = readConfig();
|
|
48
|
+
const base = String(flags.url || process.env.ROSESSION_URL || config.url || DEFAULT_URL).replace(/\/$/, "");
|
|
49
|
+
let token = process.env.ROSESSION_TOKEN || config.token || "";
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------- output
|
|
52
|
+
const c = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
53
|
+
const bold = c(1);
|
|
54
|
+
const dim = c(2);
|
|
55
|
+
const green = c(32);
|
|
56
|
+
const red = c(31);
|
|
57
|
+
const cyan = c(36);
|
|
58
|
+
const violet = c("38;5;141");
|
|
59
|
+
const nf = new Intl.NumberFormat("en-US");
|
|
60
|
+
const num = (v, d = 0) => (v == null ? "–" : nf.format(Math.round(v * 10 ** d) / 10 ** d));
|
|
61
|
+
const dur = (s) => (s == null ? "–" : s < 60 ? `${Math.round(s)}s` : s < 3600 ? `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, "0")}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`);
|
|
62
|
+
const pct = (v) => (v == null ? "–" : `${num(v, 1)}%`);
|
|
63
|
+
const delta = (v, invert) => (v == null ? "" : (v >= 0) !== !!invert ? green(` ▲ ${num(Math.abs(v), 1)}%`) : red(` ▼ ${num(Math.abs(v), 1)}%`));
|
|
64
|
+
const ago = (iso) => {
|
|
65
|
+
if (!iso) return "never";
|
|
66
|
+
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
|
|
67
|
+
return s < 120 ? "just now" : s < 3600 ? `${Math.floor(s / 60)}m ago` : s < 86400 ? `${Math.floor(s / 3600)}h ago` : `${Math.floor(s / 86400)}d ago`;
|
|
68
|
+
};
|
|
69
|
+
const SPARK = "▁▂▃▄▅▆▇█";
|
|
70
|
+
const spark = (vals) => {
|
|
71
|
+
const v = vals.map(Number).filter(Number.isFinite);
|
|
72
|
+
if (!v.length) return "";
|
|
73
|
+
const lo = Math.min(...v);
|
|
74
|
+
const hi = Math.max(...v);
|
|
75
|
+
return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join("");
|
|
76
|
+
};
|
|
77
|
+
const bar = (share, width = 20) => {
|
|
78
|
+
const fill = Math.min(width, Math.max(share > 0 ? 1 : 0, Math.round((share / 100) * width)));
|
|
79
|
+
return violet("█".repeat(fill)) + dim("·".repeat(width - fill));
|
|
80
|
+
};
|
|
81
|
+
const pad = (s, n) => {
|
|
82
|
+
const len = String(s).replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
83
|
+
return String(s) + " ".repeat(Math.max(0, n - len));
|
|
84
|
+
};
|
|
85
|
+
function table(rows, cols) {
|
|
86
|
+
const widths = cols.map((col) => Math.max(col.label.length, ...rows.map((r) => String(col.get(r)).replace(/\x1b\[[0-9;]*m/g, "").length)));
|
|
87
|
+
const line = (cells) => cells.map((x, i) => (cols[i].right ? " ".repeat(Math.max(0, widths[i] - String(x).replace(/\x1b\[[0-9;]*m/g, "").length)) + x : pad(x, widths[i]))).join(" ");
|
|
88
|
+
return [dim(line(cols.map((x) => x.label))), ...rows.map((r) => line(cols.map((x) => x.get(r))))].join("\n");
|
|
89
|
+
}
|
|
90
|
+
const out = (s = "") => process.stdout.write(s + "\n");
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------- api
|
|
93
|
+
async function request(tool, input) {
|
|
94
|
+
if (!token) return { status: "error", tool, reason: "login_required", message: "Not signed in. Run: npx rosession login" };
|
|
95
|
+
let res;
|
|
96
|
+
try {
|
|
97
|
+
res = await fetch(`${base}/api/agent/${tool}`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": `rosession-cli/${VERSION}` },
|
|
100
|
+
body: JSON.stringify(input),
|
|
101
|
+
signal: AbortSignal.timeout(30000),
|
|
102
|
+
});
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { status: "error", tool, reason: "busy", message: `Couldn't reach ${base}: ${e.message}` };
|
|
105
|
+
}
|
|
106
|
+
return res.json().catch(() => ({ status: "error", tool, reason: "busy", message: `${base} answered ${res.status}` }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------- pretty printers
|
|
110
|
+
const P = {};
|
|
111
|
+
P.games = (d) => {
|
|
112
|
+
if (!d.games.length) return out("No games yet. Add one at rosession.com/dashboard/new");
|
|
113
|
+
out(table(d.games, [
|
|
114
|
+
{ label: "GAME", get: (g) => bold(g.name) },
|
|
115
|
+
{ label: "ID", get: (g) => dim(g.id) },
|
|
116
|
+
{ label: "ONLINE", right: true, get: (g) => (g.online ? green(num(g.online)) : "0") },
|
|
117
|
+
{ label: "PLAYERS 24H", right: true, get: (g) => num(g.players24h) },
|
|
118
|
+
{ label: "SESSIONS 24H", right: true, get: (g) => num(g.sessions24h) },
|
|
119
|
+
{ label: "LAST DATA", get: (g) => ago(g.lastData) },
|
|
120
|
+
]));
|
|
121
|
+
};
|
|
122
|
+
P.overview = (d) => {
|
|
123
|
+
const k = d.kpis;
|
|
124
|
+
const ch = d.change || {};
|
|
125
|
+
const rows = [
|
|
126
|
+
["Players", num(k.users), ch.users],
|
|
127
|
+
["New players", num(k.newUsers), ch.newUsers],
|
|
128
|
+
["Sessions", num(k.sessions), ch.sessions],
|
|
129
|
+
["Avg session", dur(k.avgSession), ch.avgSession],
|
|
130
|
+
["Playtime", `${num(k.playtimeHours, 1)} h`, ch.playtimeHours],
|
|
131
|
+
["Robux", `R$ ${num(k.revenue)}`, ch.revenue],
|
|
132
|
+
["Paying players", pct(k.conversion), ch.conversion],
|
|
133
|
+
["Peak online", num(k.peakCcu), ch.peakCcu],
|
|
134
|
+
["Day 1 return", pct(k.d1), ch.d1],
|
|
135
|
+
];
|
|
136
|
+
for (const [l, v, x] of rows) out(` ${pad(dim(l), 16)} ${pad(bold(v), 12)}${delta(x)}`);
|
|
137
|
+
if (d.series?.length > 1) out(`\n ${dim("Players")} ${cyan(spark(d.series.map((s) => s.users)))}`);
|
|
138
|
+
if (d.leaveReasons?.length) {
|
|
139
|
+
out(`\n ${bold("Why they left")}`);
|
|
140
|
+
const total = d.leaveReasons.reduce((a, r) => a + r.v, 0) || 1;
|
|
141
|
+
for (const r of d.leaveReasons.slice(0, 5)) out(` ${pad(r.name, 30)} ${bar((r.v / total) * 100)} ${pct((r.v / total) * 100)}`);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
P.live = (d) => {
|
|
145
|
+
out(` ${green("●")} ${bold(num(d.online))} playing now on ${num(d.servers.length)} server${d.servers.length === 1 ? "" : "s"} ${dim("last hour")} ${cyan(spark(d.lastHour.map((x) => x.v)))}`);
|
|
146
|
+
out(` ${dim("Today:")} ${num(d.today?.users)} players · ${num(d.today?.sessions)} sessions · R$ ${num(d.today?.revenue)} · peak ${num(d.today?.peakCcu)}\n`);
|
|
147
|
+
if (d.players.length)
|
|
148
|
+
out(table(d.players.slice(0, 25), [
|
|
149
|
+
{ label: "PLAYER", get: (p) => bold(p.display || p.name) + (p.is_new ? green(" new") : "") },
|
|
150
|
+
{ label: "DOING", get: (p) => p.current_activity || dim("–") },
|
|
151
|
+
{ label: "FOR", right: true, get: (p) => dur(p.seconds) },
|
|
152
|
+
{ label: "DEVICE", get: (p) => p.platform || "–" },
|
|
153
|
+
{ label: "FPS", right: true, get: (p) => (p.fps == null ? "–" : p.fps < 25 ? red(num(p.fps)) : num(p.fps)) },
|
|
154
|
+
{ label: "IDLE", right: true, get: (p) => (p.idle_s > 60 ? dur(p.idle_s) : "") },
|
|
155
|
+
]));
|
|
156
|
+
};
|
|
157
|
+
P.leaving = (d) => {
|
|
158
|
+
out(` ${num(d.totalSessions)} sessions ended · ${pct(d.newPlayerShortSessions)} of first sessions were under a minute\n`);
|
|
159
|
+
for (const r of d.reasons) out(` ${pad(r.name, 32)} ${bar(r.share)} ${pad(pct(r.share), 7)} ${dim(`avg ${dur(r.avg * 60)}`)}`);
|
|
160
|
+
if (d.lastActions?.length) out(`\n ${dim("Last thing they did:")} ${d.lastActions.slice(0, 5).map((a) => `${a.name} (${num(a.v)})`).join(", ")}`);
|
|
161
|
+
};
|
|
162
|
+
P.sessions = (d) => {
|
|
163
|
+
out(dim(` ${num(d.total)} sessions\n`));
|
|
164
|
+
out(table(d.sessions, [
|
|
165
|
+
{ label: "WHEN", get: (s) => dim(ago(s.joined_at)) },
|
|
166
|
+
{ label: "PLAYER", get: (s) => bold(s.name) + (s.is_new ? green(" new") : "") },
|
|
167
|
+
{ label: "LENGTH", right: true, get: (s) => dur(s.duration_s) },
|
|
168
|
+
{ label: "DEVICE", get: (s) => s.platform || "–" },
|
|
169
|
+
{ label: "R$", right: true, get: (s) => (s.robux ? num(s.robux) : "") },
|
|
170
|
+
{ label: "LEFT BECAUSE", get: (s) => s.leave_reason || green("still playing") },
|
|
171
|
+
{ label: "ID", get: (s) => dim(s.id) },
|
|
172
|
+
]));
|
|
173
|
+
};
|
|
174
|
+
P.updates = (d) => {
|
|
175
|
+
if (!d.current) return out("No place versions yet.");
|
|
176
|
+
const c = d.current;
|
|
177
|
+
const b = d.previous;
|
|
178
|
+
out(` ${bold(`v${c.version}`)}${b ? dim(` vs v${b.version}`) : ""} ${dim(`${num(c.sessions)} sessions`)}\n`);
|
|
179
|
+
const ch = d.change || {};
|
|
180
|
+
const rows = [
|
|
181
|
+
["Players / day", num(c.playersPerDay, 1), ch.playersPerDay],
|
|
182
|
+
["Avg session", dur(c.avgSession), ch.avgSession],
|
|
183
|
+
["Sessions / player", num(c.sessionsPerPlayer, 2), ch.sessionsPerPlayer],
|
|
184
|
+
["Bounce rate", pct(c.bounce), ch.bounce, true],
|
|
185
|
+
["New player D1", pct(c.d1), ch.d1],
|
|
186
|
+
["Paying players", pct(c.conversion), ch.conversion],
|
|
187
|
+
["Robux / player", num(c.robuxPerPlayer, 2), ch.robuxPerPlayer],
|
|
188
|
+
["Errors / 100", num(c.errorsPer100, 1), ch.errorsPer100, true],
|
|
189
|
+
];
|
|
190
|
+
for (const [l, v, x, inv] of rows) out(` ${pad(dim(l), 20)} ${pad(bold(v), 10)}${delta(x, inv)}`);
|
|
191
|
+
const fresh = (c.errors || []).filter((e) => e.isNew);
|
|
192
|
+
if (fresh.length) {
|
|
193
|
+
out(`\n ${red("New errors")}`);
|
|
194
|
+
for (const e of fresh.slice(0, 5)) out(` ${dim(`×${e.n}`)} ${e.message}`);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
P.experiments = (d) => {
|
|
198
|
+
if (!d.tests.length) return out("No A/B tests in this period. Start one with RoSession.variant(player, name, variants).");
|
|
199
|
+
for (const t of d.tests) {
|
|
200
|
+
out(` ${bold(t.name)} ${dim(`${num(t.players)} players · control ${t.control}`)}`);
|
|
201
|
+
out(table(t.variants, [
|
|
202
|
+
{ label: " VARIANT", get: (v) => ` ${v.name}` },
|
|
203
|
+
{ label: "PLAYERS", right: true, get: (v) => num(v.players) },
|
|
204
|
+
{ label: "PLAYTIME", right: true, get: (v) => `${num(v.playtime, 1)}m` + sig(v, "playtime") },
|
|
205
|
+
{ label: "PAYING", right: true, get: (v) => pct(v.conversion) + sig(v, "conversion") },
|
|
206
|
+
{ label: "R$/PLAYER", right: true, get: (v) => num(v.arpu, 2) + sig(v, "arpu") },
|
|
207
|
+
{ label: "D1", right: true, get: (v) => pct(v.d1) + sig(v, "d1") },
|
|
208
|
+
]));
|
|
209
|
+
out("");
|
|
210
|
+
}
|
|
211
|
+
out(dim(" * = significant (p < 0.05)"));
|
|
212
|
+
};
|
|
213
|
+
P.explain = (d) => {
|
|
214
|
+
out(` ${bold(d.summary)}\n`);
|
|
215
|
+
// arrow = which way it moved; colour = good or bad for the game
|
|
216
|
+
const good = (x) => {
|
|
217
|
+
if (x.direction === "neutral") return cyan("•");
|
|
218
|
+
const arrow = x.direction === "up" ? "↑" : "↓";
|
|
219
|
+
const bad = ["leave_reason", "new_error", "errors", "crashes"].includes(x.kind) === (x.direction === "up");
|
|
220
|
+
return bad ? red(arrow) : green(arrow);
|
|
221
|
+
};
|
|
222
|
+
for (const x of d.drivers || []) out(` ${good(x)} ${x.title}\n ${dim(x.detail)}`);
|
|
223
|
+
};
|
|
224
|
+
P.timeline = (d) => {
|
|
225
|
+
if (d.summary) return P.explain(d);
|
|
226
|
+
if (!d.markers.length) return out("Nothing on the timeline in this period. Add notes on the Overview chart.");
|
|
227
|
+
for (const m of d.markers) out(` ${dim(m.ts.slice(0, 16).replace("T", " "))} ${pad(cyan(m.label), 14)} ${m.text}${m.by ? dim(` @${m.by}`) : ""}`);
|
|
228
|
+
};
|
|
229
|
+
const sig = (v, k) => (v.p?.[k] != null && v.p[k] < 0.05 ? green("*") : v.p ? " " : " ");
|
|
230
|
+
|
|
231
|
+
function pretty(reply) {
|
|
232
|
+
if (reply.status !== "ok") {
|
|
233
|
+
out(red(`✗ ${reply.message || reply.reason}`));
|
|
234
|
+
if (reply.data?.games?.length) out(dim(` Your games: ${reply.data.games.map((g) => `${g.name} (${g.id})`).join(", ")}`));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const title = reply.game ? `${bold(reply.game.name)} ${dim("·")} ${reply.tool}` : bold(reply.tool);
|
|
238
|
+
const span = reply.period ? dim(` · ${reply.period.from.slice(0, 10)} → ${reply.period.to.slice(0, 10)}`) : "";
|
|
239
|
+
out(`\n${title}${span}\n`);
|
|
240
|
+
const fn = P[reply.tool];
|
|
241
|
+
if (fn) fn(reply.data);
|
|
242
|
+
else out(yaml(reply.data, 1));
|
|
243
|
+
out(`\n${dim("→")} ${cyan(reply.link)}`);
|
|
244
|
+
if (reply.next?.length) out(dim(` next: ${reply.next.map((n) => n.command).join(" | ")}`));
|
|
245
|
+
out("");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// readable dump for tools without their own printer
|
|
249
|
+
function yaml(v, depth = 0) {
|
|
250
|
+
const ind = " ".repeat(depth);
|
|
251
|
+
if (Array.isArray(v)) {
|
|
252
|
+
if (!v.length) return `${ind}${dim("(none)")}`;
|
|
253
|
+
if (v.every((x) => x && typeof x === "object" && !Array.isArray(x))) {
|
|
254
|
+
const keys = [...new Set(v.flatMap((x) => Object.keys(x).filter((k) => typeof x[k] !== "object" || x[k] === null)))].slice(0, 7);
|
|
255
|
+
return table(v.slice(0, 30), keys.map((k) => ({ label: k.toUpperCase(), get: (x) => (x[k] == null ? "–" : typeof x[k] === "number" ? num(x[k], 2) : String(x[k]).slice(0, 60)) })))
|
|
256
|
+
.split("\n")
|
|
257
|
+
.map((l) => ind + l)
|
|
258
|
+
.join("\n");
|
|
259
|
+
}
|
|
260
|
+
return ind + v.map((x) => (typeof x === "object" ? JSON.stringify(x) : x)).join(", ");
|
|
261
|
+
}
|
|
262
|
+
if (v && typeof v === "object")
|
|
263
|
+
return Object.entries(v)
|
|
264
|
+
.filter(([k]) => k !== "units")
|
|
265
|
+
.map(([k, x]) => (x && typeof x === "object" ? `${ind}${bold(k)}\n${yaml(x, depth + 1)}` : `${ind}${pad(dim(k), 22)} ${typeof x === "number" ? num(x, 2) : x}`))
|
|
266
|
+
.join("\n");
|
|
267
|
+
return ind + String(v);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------- commands
|
|
271
|
+
const REQUIRED = { session: "id", player: "player" }; // the extra word after the game
|
|
272
|
+
function inputFor(tool) {
|
|
273
|
+
const input = {};
|
|
274
|
+
for (const [k, v] of Object.entries(flags)) if (!["json", "url", "token", "help", "every"].includes(k)) input[k] = v === true ? true : /^\d+$/.test(v) ? Number(v) : v;
|
|
275
|
+
const [game, extra] = words.slice(1);
|
|
276
|
+
// public Roblox stats: the first word is that tool's main input, not one of your games
|
|
277
|
+
const FIRST = { roblox_search: "query", roblox_game: "universe_id", roblox_compare: "universe_ids", roblox_rankings: "list" };
|
|
278
|
+
if (FIRST[tool]) {
|
|
279
|
+
if (game !== undefined) input[FIRST[tool]] = game;
|
|
280
|
+
} else if (game !== undefined && tool !== "games") input.game = game;
|
|
281
|
+
if (extra !== undefined && REQUIRED[tool]) input[REQUIRED[tool]] = extra;
|
|
282
|
+
input.tz = -new Date().getTimezoneOffset();
|
|
283
|
+
return input;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function login() {
|
|
287
|
+
if (typeof flags.token === "string") {
|
|
288
|
+
if (!/^rsa_[a-z0-9]{32}$/i.test(flags.token)) return fail("That doesn't look like a RoSession token (rsa_…).");
|
|
289
|
+
saveConfig({ ...config, url: base, token: flags.token });
|
|
290
|
+
token = flags.token;
|
|
291
|
+
return check();
|
|
292
|
+
}
|
|
293
|
+
// OAuth with PKCE, back to a one-time server on this computer
|
|
294
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
295
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
296
|
+
const state = randomBytes(16).toString("base64url");
|
|
297
|
+
const server = createServer();
|
|
298
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
299
|
+
const redirect = `http://127.0.0.1:${server.address().port}/callback`;
|
|
300
|
+
const reg = await fetch(`${base}/api/oauth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_name: "RoSession CLI", redirect_uris: [redirect] }) })
|
|
301
|
+
.then((r) => r.json())
|
|
302
|
+
.catch((e) => ({ error_description: e.message }));
|
|
303
|
+
if (!reg.client_id) return server.close(), fail(`Couldn't start sign-in: ${reg.error_description || "no client id"}`);
|
|
304
|
+
const url = `${base}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id: reg.client_id, redirect_uri: redirect, code_challenge: challenge, code_challenge_method: "S256", state, scope: "read" })}`;
|
|
305
|
+
const code = new Promise((resolve, reject) => {
|
|
306
|
+
const timer = setTimeout(() => reject(new Error("Timed out waiting for the browser (5 minutes).")), 300000);
|
|
307
|
+
server.on("request", (req, res) => {
|
|
308
|
+
const q = new URL(req.url, redirect).searchParams;
|
|
309
|
+
if (!req.url.startsWith("/callback")) return res.writeHead(404).end();
|
|
310
|
+
const ok = q.get("code") && q.get("state") === state;
|
|
311
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
312
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>RoSession</title><body style="font:16px system-ui;background:#0b0c10;color:#eef0f5;display:grid;place-items:center;height:100vh;margin:0"><div style="text-align:center"><h2>${ok ? "Signed in ✓" : "Sign-in didn't finish"}</h2><p style="color:#9aa0ad">${ok ? "You can close this tab and go back to the terminal." : "Go back to the terminal and try again."}</p></div>`);
|
|
313
|
+
clearTimeout(timer);
|
|
314
|
+
ok ? resolve(q.get("code")) : reject(new Error(q.get("error_description") || q.get("error") || "Sign-in was cancelled."));
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
process.stderr.write(`Opening your browser to sign in with Roblox…\nIf it doesn't open, go to:\n\n ${url}\n\n`);
|
|
318
|
+
openBrowser(url);
|
|
319
|
+
try {
|
|
320
|
+
const got = await code;
|
|
321
|
+
const t = await fetch(`${base}/api/oauth/token`, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
324
|
+
body: new URLSearchParams({ grant_type: "authorization_code", code: got, code_verifier: verifier, client_id: reg.client_id, redirect_uri: redirect }),
|
|
325
|
+
}).then((r) => r.json());
|
|
326
|
+
if (!t.access_token) throw new Error(t.error_description || t.error || "No token");
|
|
327
|
+
saveConfig({ ...config, url: base, token: t.access_token });
|
|
328
|
+
token = t.access_token;
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return fail(e.message);
|
|
331
|
+
} finally {
|
|
332
|
+
server.close();
|
|
333
|
+
}
|
|
334
|
+
return check();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function check() {
|
|
338
|
+
const r = await request("games", {});
|
|
339
|
+
if (r.status !== "ok") return fail(r.message);
|
|
340
|
+
if (asJson) return out(JSON.stringify({ status: "ok", tool: "login", data: { games: r.data.games.length, config: configFile } }));
|
|
341
|
+
out(green("✓ Signed in.") + dim(` ${r.data.games.length} game${r.data.games.length === 1 ? "" : "s"}. Try: npx rosession overview`));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function openBrowser(url) {
|
|
345
|
+
const cmd = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
346
|
+
try {
|
|
347
|
+
spawn(cmd[0], cmd[1], { stdio: "ignore", detached: true }).on("error", () => {}).unref();
|
|
348
|
+
} catch {}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function watch() {
|
|
352
|
+
const every = Math.max(5, Number(flags.every) || 10) * 1000;
|
|
353
|
+
const input = inputFor("live");
|
|
354
|
+
for (;;) {
|
|
355
|
+
const r = await request("live", input);
|
|
356
|
+
if (asJson) out(JSON.stringify(r));
|
|
357
|
+
else {
|
|
358
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
359
|
+
pretty(r);
|
|
360
|
+
out(dim(` refreshing every ${every / 1000}s · ctrl+c to stop`));
|
|
361
|
+
}
|
|
362
|
+
if (r.status !== "ok" && ["login_required", "not_found", "missing_arguments"].includes(r.reason)) process.exit(1);
|
|
363
|
+
await new Promise((res) => setTimeout(res, every));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function tools() {
|
|
368
|
+
const r = await fetch(`${base}/api/agent/tools`).then((x) => x.json()).catch(() => null);
|
|
369
|
+
if (!r) return fail(`Couldn't reach ${base}`);
|
|
370
|
+
if (asJson) return out(JSON.stringify(r));
|
|
371
|
+
out(`\n${bold("rosession <tool> [game] [--option value]")}\n`);
|
|
372
|
+
for (const t of r.tools) {
|
|
373
|
+
const opts = Object.keys(t.input.properties).filter((k) => k !== "game");
|
|
374
|
+
out(` ${bold(pad(t.name, 13))} ${t.title}${t.light ? dim(" (free)") : ""}`);
|
|
375
|
+
if (opts.length) out(dim(` ${" ".repeat(13)} --${opts.join(" --")}`));
|
|
376
|
+
}
|
|
377
|
+
out(`\n ${bold(pad("watch", 13))} Live players, refreshed every 10 s (--every 15)`);
|
|
378
|
+
out(` ${bold(pad("login", 13))} Sign in with Roblox (or --token rsa_…)`);
|
|
379
|
+
out(` ${bold(pad("logout", 13))} Forget the saved token\n`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function help() {
|
|
383
|
+
out(`${bold("rosession")} ${dim(VERSION)}: ask your Roblox game, from the terminal
|
|
384
|
+
|
|
385
|
+
${cyan("npx rosession login")} sign in with Roblox
|
|
386
|
+
${cyan("npx rosession games")} your games
|
|
387
|
+
${cyan('npx rosession overview "My Game"')} this week vs last (--range 24h|7d|30d)
|
|
388
|
+
${cyan('npx rosession leaving "My Game"')} why players leave
|
|
389
|
+
${cyan('npx rosession watch "My Game"')} live players
|
|
390
|
+
${cyan("npx rosession tools")} every command and option
|
|
391
|
+
|
|
392
|
+
--json for JSON (automatic when a program runs it) · ROSESSION_TOKEN to skip login
|
|
393
|
+
Docs: ${base}/docs#agents`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function fail(message, code = 1) {
|
|
397
|
+
if (asJson) out(JSON.stringify({ status: "error", reason: "login_failed", message }));
|
|
398
|
+
else process.stderr.write(red(`✗ ${message}\n`));
|
|
399
|
+
process.exit(code);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------- main
|
|
403
|
+
const cmd = words[0];
|
|
404
|
+
if (!cmd || flags.help || cmd === "help") help();
|
|
405
|
+
else if (cmd === "--version" || cmd === "version" || flags.version) out(VERSION);
|
|
406
|
+
else if (cmd === "login") await login();
|
|
407
|
+
else if (cmd === "logout") {
|
|
408
|
+
if (existsSync(configFile)) rmSync(configFile);
|
|
409
|
+
out(asJson ? JSON.stringify({ status: "ok", tool: "logout" }) : "Signed out.");
|
|
410
|
+
} else if (cmd === "tools") await tools();
|
|
411
|
+
else if (cmd === "watch") await watch();
|
|
412
|
+
else {
|
|
413
|
+
const reply = await request(cmd, inputFor(cmd));
|
|
414
|
+
if (asJson) out(JSON.stringify(reply, null, flags.json === "pretty" ? 2 : 0));
|
|
415
|
+
else pretty(reply);
|
|
416
|
+
process.exitCode = reply.status === "ok" ? 0 : reply.reason === "login_required" ? 2 : 1;
|
|
417
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rosession",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Ask your Roblox game: RoSession analytics in your terminal and for AI agents (Claude Code, Codex, Cursor).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "rosession": "bin/rosession.mjs" },
|
|
7
|
+
"files": ["bin", "README.md"],
|
|
8
|
+
"engines": { "node": ">=18" },
|
|
9
|
+
"keywords": ["roblox", "analytics", "rosession", "cli", "mcp", "ai-agents"],
|
|
10
|
+
"homepage": "https://rosession.com/docs#agents",
|
|
11
|
+
"license": "MIT"
|
|
12
|
+
}
|