databull 1.0.0 → 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/dist/databull.mjs +831 -52
- package/package.json +2 -2
package/dist/databull.mjs
CHANGED
|
@@ -172,13 +172,17 @@ var Cancelled = class extends Error {
|
|
|
172
172
|
var SUPPORT = "adgan.business@gmail.com";
|
|
173
173
|
|
|
174
174
|
// src/args.mjs
|
|
175
|
-
var
|
|
176
|
-
var
|
|
177
|
-
var
|
|
178
|
-
var
|
|
179
|
-
var
|
|
175
|
+
var DATA_COMMANDS = ["sites", "stats", "sources", "breakdown", "compare", "sales", "visitors", "visitor", "live", "goals", "funnels", "ltv", "query"];
|
|
176
|
+
var COMMANDS = ["install", "update", "reset-password", "domain", "status", "license", "uninstall", "mcp", "api-key", ...DATA_COMMANDS, "help"];
|
|
177
|
+
var TAKES_ARGS = /* @__PURE__ */ new Set(["api-key", ...DATA_COMMANDS]);
|
|
178
|
+
var COMMAND_ALIASES = { license: "license", "reset": "reset-password", upgrade: "update", remove: "uninstall", "api-keys": "api-key", keys: "api-key", key: "api-key", overview: "stats", referrers: "sources", realtime: "live", payments: "sales" };
|
|
179
|
+
var VALUE_FLAGS = ["key", "account", "name", "domain", "api", "bundle", "home", "period", "from", "to", "site", "limit", "by", "dim", "sort", "url", "api-key", "client", "funnel", "page", "label"];
|
|
180
|
+
var MULTI_FLAGS = ["filter"];
|
|
181
|
+
var BOOL_FLAGS = ["yes", "open", "help", "version", "deactivate", "delete-database", "debug", "json", "pii", "customers", "series"];
|
|
182
|
+
var SHORT = { y: "yes", h: "help", v: "version", p: "period", s: "site", n: "limit", f: "filter" };
|
|
183
|
+
var SHORT_VALUE = /* @__PURE__ */ new Set(["p", "s", "n", "f"]);
|
|
180
184
|
function parseArgs(argv) {
|
|
181
|
-
const
|
|
185
|
+
const out3 = { command: null, flags: { open: true }, args: [] };
|
|
182
186
|
for (let i = 0; i < argv.length; i++) {
|
|
183
187
|
const arg = argv[i];
|
|
184
188
|
if (arg === "--") continue;
|
|
@@ -186,41 +190,55 @@ function parseArgs(argv) {
|
|
|
186
190
|
let [name, value] = splitOnce(arg.slice(2), "=");
|
|
187
191
|
if (name.startsWith("no-") && BOOL_FLAGS.includes(name.slice(3))) {
|
|
188
192
|
if (value !== void 0) throw new Fail(`--${name} doesn't take a value.`);
|
|
189
|
-
|
|
193
|
+
out3.flags[name.slice(3)] = false;
|
|
190
194
|
continue;
|
|
191
195
|
}
|
|
192
196
|
if (BOOL_FLAGS.includes(name)) {
|
|
193
197
|
if (value !== void 0) throw new Fail(`--${name} doesn't take a value.`);
|
|
194
|
-
|
|
198
|
+
out3.flags[camel(name)] = true;
|
|
195
199
|
continue;
|
|
196
200
|
}
|
|
197
|
-
if (VALUE_FLAGS.includes(name)) {
|
|
201
|
+
if (VALUE_FLAGS.includes(name) || MULTI_FLAGS.includes(name)) {
|
|
198
202
|
if (value === void 0) {
|
|
199
203
|
value = argv[i + 1];
|
|
200
204
|
if (value === void 0 || value.startsWith("--")) throw new Fail(`--${name} needs a value.`, { fix: `Example: --${name} ${example(name)}` });
|
|
201
205
|
i++;
|
|
202
206
|
}
|
|
203
|
-
|
|
207
|
+
if (MULTI_FLAGS.includes(name)) (out3.flags[name] ||= []).push(value);
|
|
208
|
+
else out3.flags[name === "api-key" ? "apiKey" : name] = value;
|
|
204
209
|
continue;
|
|
205
210
|
}
|
|
206
211
|
throw new Fail(`Unknown option --${name}.`, { fix: "Run `npx databull@latest help` to see every option." });
|
|
207
212
|
}
|
|
208
|
-
if (arg.startsWith("-") && arg.length > 1) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
if (arg.startsWith("-") && arg.length > 1 && !/^-\d/.test(arg)) {
|
|
214
|
+
const letters = arg.slice(1);
|
|
215
|
+
if (letters.length === 1 && SHORT_VALUE.has(letters)) {
|
|
216
|
+
const v2 = argv[i + 1];
|
|
217
|
+
if (v2 === void 0 || v2.startsWith("-")) throw new Fail(`-${letters} needs a value.`, { fix: `Example: -${letters} ${example(SHORT[letters])}` });
|
|
218
|
+
i++;
|
|
219
|
+
if (MULTI_FLAGS.includes(SHORT[letters])) (out3.flags[SHORT[letters]] ||= []).push(v2);
|
|
220
|
+
else out3.flags[SHORT[letters]] = v2;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
for (const ch of letters) {
|
|
224
|
+
if (!SHORT[ch] || SHORT_VALUE.has(ch)) throw new Fail(`Unknown option -${ch}.`, { fix: "Run `npx databull@latest help` to see every option." });
|
|
225
|
+
out3.flags[SHORT[ch]] = true;
|
|
212
226
|
}
|
|
213
227
|
continue;
|
|
214
228
|
}
|
|
215
|
-
if (
|
|
229
|
+
if (out3.command && TAKES_ARGS.has(out3.command)) {
|
|
230
|
+
out3.args.push(arg);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (out3.command) throw new Fail(`Unexpected "${arg}".`, { fix: "Run `npx databull@latest help` to see every command." });
|
|
216
234
|
const cmd = COMMAND_ALIASES[arg] || arg;
|
|
217
235
|
if (!COMMANDS.includes(cmd)) throw new Fail(`Unknown command "${arg}".`, { fix: `Commands: ${COMMANDS.join(", ")}.` });
|
|
218
|
-
|
|
236
|
+
out3.command = cmd;
|
|
219
237
|
}
|
|
220
|
-
if (
|
|
221
|
-
else if (
|
|
222
|
-
else if (!
|
|
223
|
-
return
|
|
238
|
+
if (out3.flags.help) out3.command = "help";
|
|
239
|
+
else if (out3.flags.version) out3.command = "version";
|
|
240
|
+
else if (!out3.command) out3.command = "install";
|
|
241
|
+
return out3;
|
|
224
242
|
}
|
|
225
243
|
function splitOnce(s, sep) {
|
|
226
244
|
const i = s.indexOf(sep);
|
|
@@ -230,7 +248,30 @@ function camel(s) {
|
|
|
230
248
|
return s.replace(/-([a-z])/g, (_3, c) => c.toUpperCase());
|
|
231
249
|
}
|
|
232
250
|
function example(name) {
|
|
233
|
-
return {
|
|
251
|
+
return {
|
|
252
|
+
key: "XXXX-XXXX",
|
|
253
|
+
account: "<account id>",
|
|
254
|
+
name: "databull",
|
|
255
|
+
domain: "analytics.example.com",
|
|
256
|
+
api: "https://databull.app",
|
|
257
|
+
bundle: "./databull-1.0.0.tgz",
|
|
258
|
+
home: "~/.databull",
|
|
259
|
+
period: "7d",
|
|
260
|
+
from: "2026-10-01",
|
|
261
|
+
to: "2026-10-31",
|
|
262
|
+
site: "example.com",
|
|
263
|
+
limit: "20",
|
|
264
|
+
by: "revenue",
|
|
265
|
+
dim: "referrer",
|
|
266
|
+
sort: "revenue",
|
|
267
|
+
url: "https://analytics.example.com",
|
|
268
|
+
"api-key": "dbk_\u2026",
|
|
269
|
+
client: "claude",
|
|
270
|
+
funnel: "Checkout",
|
|
271
|
+
page: "1",
|
|
272
|
+
label: '"CI"',
|
|
273
|
+
filter: "country=US"
|
|
274
|
+
}[name];
|
|
234
275
|
}
|
|
235
276
|
|
|
236
277
|
// src/config.mjs
|
|
@@ -289,6 +330,11 @@ function upsertInstall(cfg, install) {
|
|
|
289
330
|
else cfg.installs.push(install);
|
|
290
331
|
return cfg;
|
|
291
332
|
}
|
|
333
|
+
function forgetInstallFields(cfg, install, fields) {
|
|
334
|
+
const saved = cfg.installs.find((i) => i.name === install.name && i.account_id === install.account_id);
|
|
335
|
+
if (saved) for (const f of fields) delete saved[f];
|
|
336
|
+
return cfg;
|
|
337
|
+
}
|
|
292
338
|
function removeInstall(cfg, install) {
|
|
293
339
|
cfg.installs = cfg.installs.filter((i) => !(i.name === install.name && i.account_id === install.account_id));
|
|
294
340
|
return cfg;
|
|
@@ -1069,6 +1115,10 @@ function info(text) {
|
|
|
1069
1115
|
out.write(`${import_picocolors3.default.blue("\u25CF")} ${text}
|
|
1070
1116
|
`);
|
|
1071
1117
|
}
|
|
1118
|
+
function success(text) {
|
|
1119
|
+
out.write(`${import_picocolors3.default.green("\u25C6")} ${text}
|
|
1120
|
+
`);
|
|
1121
|
+
}
|
|
1072
1122
|
function warn(text, fix) {
|
|
1073
1123
|
out.write(`${import_picocolors3.default.yellow("\u25B2")} ${text}
|
|
1074
1124
|
`);
|
|
@@ -1156,8 +1206,8 @@ function stopSpinner() {
|
|
|
1156
1206
|
if (activeSpinner) activeSpinner.fail(import_picocolors3.default.dim("Stopped"));
|
|
1157
1207
|
}
|
|
1158
1208
|
function box(title, lines, { color = import_picocolors3.default.blue } = {}) {
|
|
1159
|
-
const
|
|
1160
|
-
const width = Math.max(
|
|
1209
|
+
const strip2 = (s) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
|
1210
|
+
const width = Math.max(strip2(title).length + 2, ...lines.map((l2) => strip2(l2).length)) + 2;
|
|
1161
1211
|
if (isTTY() && out.columns && width + 2 > out.columns) {
|
|
1162
1212
|
out.write(`${BAR}
|
|
1163
1213
|
${color("\u256D\u2500")} ${import_picocolors3.default.bold(title)}
|
|
@@ -1169,9 +1219,9 @@ ${color("\u256D\u2500")} ${import_picocolors3.default.bold(title)}
|
|
|
1169
1219
|
return;
|
|
1170
1220
|
}
|
|
1171
1221
|
out.write(`${BAR}
|
|
1172
|
-
${color("\u256D")}${color("\u2500")} ${import_picocolors3.default.bold(title)} ${color("\u2500".repeat(Math.max(0, width -
|
|
1222
|
+
${color("\u256D")}${color("\u2500")} ${import_picocolors3.default.bold(title)} ${color("\u2500".repeat(Math.max(0, width - strip2(title).length - 3)))}${color("\u256E")}
|
|
1173
1223
|
`);
|
|
1174
|
-
for (const l2 of lines) out.write(`${color("\u2502")} ${l2}${" ".repeat(Math.max(0, width -
|
|
1224
|
+
for (const l2 of lines) out.write(`${color("\u2502")} ${l2}${" ".repeat(Math.max(0, width - strip2(l2).length - 1))}${color("\u2502")}
|
|
1175
1225
|
`);
|
|
1176
1226
|
out.write(`${color("\u2570")}${color("\u2500".repeat(width))}${color("\u256F")}
|
|
1177
1227
|
`);
|
|
@@ -1285,16 +1335,16 @@ async function cf(ctx, method, path7, body) {
|
|
|
1285
1335
|
});
|
|
1286
1336
|
}
|
|
1287
1337
|
async function cfList(ctx, path7, perPage = 50) {
|
|
1288
|
-
const
|
|
1338
|
+
const out3 = [];
|
|
1289
1339
|
for (let page = 1; page < 50; page++) {
|
|
1290
1340
|
const sep = path7.includes("?") ? "&" : "?";
|
|
1291
1341
|
const json2 = await cf(ctx, "GET", `${path7}${sep}page=${page}&per_page=${perPage}`);
|
|
1292
|
-
|
|
1342
|
+
out3.push(...json2.result || []);
|
|
1293
1343
|
const info2 = json2.result_info;
|
|
1294
1344
|
if (!info2 || !Array.isArray(json2.result) || json2.result.length < perPage) break;
|
|
1295
1345
|
if (info2.total_pages && page >= info2.total_pages) break;
|
|
1296
1346
|
}
|
|
1297
|
-
return
|
|
1347
|
+
return out3;
|
|
1298
1348
|
}
|
|
1299
1349
|
async function whoami(ctx) {
|
|
1300
1350
|
const r2 = await runWrangler(ctx, ["whoami", "--json"], { title: "whoami --json" });
|
|
@@ -1558,10 +1608,10 @@ function octal(buf, start, len) {
|
|
|
1558
1608
|
if (!/^[0-7]+$/.test(s)) throw new TarError("corrupt tar header");
|
|
1559
1609
|
return parseInt(s, 8);
|
|
1560
1610
|
}
|
|
1561
|
-
function checksumOk(
|
|
1562
|
-
const stored = octal(
|
|
1611
|
+
function checksumOk(header2) {
|
|
1612
|
+
const stored = octal(header2, 148, 8);
|
|
1563
1613
|
let sum = 0;
|
|
1564
|
-
for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 32 :
|
|
1614
|
+
for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 32 : header2[i];
|
|
1565
1615
|
return sum === stored;
|
|
1566
1616
|
}
|
|
1567
1617
|
function safePath(name) {
|
|
@@ -1576,7 +1626,7 @@ function safePath(name) {
|
|
|
1576
1626
|
return parts.join("/");
|
|
1577
1627
|
}
|
|
1578
1628
|
function parsePax(data) {
|
|
1579
|
-
const
|
|
1629
|
+
const out3 = {};
|
|
1580
1630
|
let i = 0;
|
|
1581
1631
|
const text = data.toString("utf8");
|
|
1582
1632
|
while (i < text.length) {
|
|
@@ -1586,10 +1636,10 @@ function parsePax(data) {
|
|
|
1586
1636
|
if (!len) break;
|
|
1587
1637
|
const record = text.slice(sp + 1, i + len - 1);
|
|
1588
1638
|
const eq = record.indexOf("=");
|
|
1589
|
-
if (eq > 0)
|
|
1639
|
+
if (eq > 0) out3[record.slice(0, eq)] = record.slice(eq + 1);
|
|
1590
1640
|
i += len;
|
|
1591
1641
|
}
|
|
1592
|
-
return
|
|
1642
|
+
return out3;
|
|
1593
1643
|
}
|
|
1594
1644
|
function readTar(input) {
|
|
1595
1645
|
const buf = input[0] === 31 && input[1] === 139 ? gunzipSync(input) : input;
|
|
@@ -1598,11 +1648,11 @@ function readTar(input) {
|
|
|
1598
1648
|
let longName = null;
|
|
1599
1649
|
let pax = null;
|
|
1600
1650
|
while (off + BLOCK <= buf.length) {
|
|
1601
|
-
const
|
|
1602
|
-
if (
|
|
1603
|
-
if (!checksumOk(
|
|
1604
|
-
const size = octal(
|
|
1605
|
-
const type = String.fromCharCode(
|
|
1651
|
+
const header2 = buf.subarray(off, off + BLOCK);
|
|
1652
|
+
if (header2.every((b3) => b3 === 0)) break;
|
|
1653
|
+
if (!checksumOk(header2)) throw new TarError("corrupt tar header (bad checksum)");
|
|
1654
|
+
const size = octal(header2, 124, 12);
|
|
1655
|
+
const type = String.fromCharCode(header2[156] || 48);
|
|
1606
1656
|
const dataStart = off + BLOCK;
|
|
1607
1657
|
if (dataStart + size > buf.length) throw new TarError("truncated tar archive");
|
|
1608
1658
|
const data = buf.subarray(dataStart, dataStart + size);
|
|
@@ -1616,9 +1666,9 @@ function readTar(input) {
|
|
|
1616
1666
|
continue;
|
|
1617
1667
|
}
|
|
1618
1668
|
if (type === "g") continue;
|
|
1619
|
-
let name = str(
|
|
1620
|
-
if (str(
|
|
1621
|
-
const prefix = str(
|
|
1669
|
+
let name = str(header2, 0, 100);
|
|
1670
|
+
if (str(header2, 257, 5) === "ustar") {
|
|
1671
|
+
const prefix = str(header2, 345, 155);
|
|
1622
1672
|
if (prefix) name = `${prefix}/${name}`;
|
|
1623
1673
|
}
|
|
1624
1674
|
if (pax?.path) name = pax.path;
|
|
@@ -2127,7 +2177,7 @@ async function discoverInstalls(ctx, account, onlyName) {
|
|
|
2127
2177
|
}
|
|
2128
2178
|
};
|
|
2129
2179
|
await Promise.all(Array.from({ length: 8 }, worker));
|
|
2130
|
-
const
|
|
2180
|
+
const out3 = [];
|
|
2131
2181
|
for (const f of found.sort((a, b3) => a.name.localeCompare(b3.name))) {
|
|
2132
2182
|
const settings = await workerSettings(ctx, account.id, f.name).catch(() => ({}));
|
|
2133
2183
|
const bindings = settings.bindings || [];
|
|
@@ -2146,7 +2196,7 @@ async function discoverInstalls(ctx, account, onlyName) {
|
|
|
2146
2196
|
}
|
|
2147
2197
|
const domains = await customDomainsOf(ctx, account.id, f.name);
|
|
2148
2198
|
const custom = domains[0] || null;
|
|
2149
|
-
|
|
2199
|
+
out3.push({
|
|
2150
2200
|
name: f.name,
|
|
2151
2201
|
account_id: account.id,
|
|
2152
2202
|
account_name: account.name,
|
|
@@ -2162,7 +2212,7 @@ async function discoverInstalls(ctx, account, onlyName) {
|
|
|
2162
2212
|
updated_at: null
|
|
2163
2213
|
});
|
|
2164
2214
|
}
|
|
2165
|
-
return
|
|
2215
|
+
return out3;
|
|
2166
2216
|
}
|
|
2167
2217
|
async function pickDiscovered(ctx, found, verb) {
|
|
2168
2218
|
if (ctx.flags.name) return found.find((f2) => f2.name === ctx.flags.name) || null;
|
|
@@ -2782,20 +2832,686 @@ async function runUninstall(ctx) {
|
|
|
2782
2832
|
end("Done.");
|
|
2783
2833
|
}
|
|
2784
2834
|
|
|
2835
|
+
// src/agent.mjs
|
|
2836
|
+
import os4 from "node:os";
|
|
2837
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2838
|
+
var MIN_AGENT_VERSION = "1.1.0";
|
|
2839
|
+
var KEY_RE = /^dbk_[a-z0-9]{40}$/;
|
|
2840
|
+
var ENV_FIX = "Or set DATABULL_URL and DATABULL_API_KEY (create a key in your dashboard under Settings \u2192 API keys).";
|
|
2841
|
+
var cleanUrl = (u2) => String(u2).trim().replace(/\/+$/, "").replace(/\/mcp$/, "");
|
|
2842
|
+
function endpoint(url) {
|
|
2843
|
+
const mock = process.env.DATABULL_TEST_HEALTH_URL;
|
|
2844
|
+
return mock ? `${mock}/mcp?host=${encodeURIComponent(new URL(url).host)}` : `${url}/mcp`;
|
|
2845
|
+
}
|
|
2846
|
+
async function rpc(conn, method, params = {}, { timeoutMs = 45e3 } = {}) {
|
|
2847
|
+
let res;
|
|
2848
|
+
try {
|
|
2849
|
+
res = await fetchWithTimeout(endpoint(conn.url), {
|
|
2850
|
+
method: "POST",
|
|
2851
|
+
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", Authorization: `Bearer ${conn.key}`, "User-Agent": "databull-cli" },
|
|
2852
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params })
|
|
2853
|
+
}, timeoutMs);
|
|
2854
|
+
} catch {
|
|
2855
|
+
throw new Fail(`Couldn't reach ${conn.url}.`, { fix: "Check the address and your internet connection, then try again." });
|
|
2856
|
+
}
|
|
2857
|
+
const type = res.headers.get("content-type") || "";
|
|
2858
|
+
if (res.status === 401) {
|
|
2859
|
+
const body2 = await res.json().catch(() => null);
|
|
2860
|
+
throw new Fail(body2?.error?.message || "The API key was refused.", {
|
|
2861
|
+
fix: conn.install ? "Run the command again to create a new key for this computer." : "Check that DATABULL_API_KEY belongs to this address, or create a new key under Settings \u2192 API keys.",
|
|
2862
|
+
code: "unauthorized"
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
if (!type.includes("application/json")) {
|
|
2866
|
+
const health = await fetchHealth(conn.url);
|
|
2867
|
+
if (!health) throw new Fail(`${conn.url} isn't a DataBull install.`, { fix: "Check the address: it's the one you open your dashboard at." });
|
|
2868
|
+
throw new Fail(`${conn.url} runs DataBull ${health.version}; the API needs ${MIN_AGENT_VERSION} or newer.`, { fix: "Update it with `npx databull@latest update`, then try again.", code: "too_old" });
|
|
2869
|
+
}
|
|
2870
|
+
const body = await res.json().catch(() => null);
|
|
2871
|
+
if (!body) throw new Fail(`${conn.url} sent an answer the CLI couldn't read.`, { fix: "Try again in a moment." });
|
|
2872
|
+
if (body.error) throw new Fail(body.error.message || "The request failed.");
|
|
2873
|
+
return body.result;
|
|
2874
|
+
}
|
|
2875
|
+
async function callTool(conn, name, args = {}) {
|
|
2876
|
+
const r2 = await rpc(conn, "tools/call", { name, arguments: args });
|
|
2877
|
+
if (r2?.isError) throw new Fail(r2.content?.[0]?.text || `${name} failed.`);
|
|
2878
|
+
if (r2?.structuredContent) return r2.structuredContent;
|
|
2879
|
+
try {
|
|
2880
|
+
return JSON.parse(r2.content[0].text);
|
|
2881
|
+
} catch {
|
|
2882
|
+
throw new Fail(`${name} answered in a shape the CLI doesn't understand.`, { fix: "Update the CLI: `npx databull@latest`." });
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
function newApiKey() {
|
|
2886
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
2887
|
+
let s = "";
|
|
2888
|
+
while (s.length < 40) for (const b3 of randomBytes2(64)) if (b3 < 252 && s.length < 40) s += alphabet[b3 % 36];
|
|
2889
|
+
return `dbk_${s}`;
|
|
2890
|
+
}
|
|
2891
|
+
var sqlText = (s) => `'${String(s).replace(/[^\w .@()+#:/-]/g, "").slice(0, 60).replace(/'/g, "''")}'`;
|
|
2892
|
+
var hostName = () => os4.hostname().replace(/\.local$/, "").slice(0, 30) || "this computer";
|
|
2893
|
+
async function ensureAgentVersion(ctx, install, runUpdate2) {
|
|
2894
|
+
const health = await fetchHealth(install.url);
|
|
2895
|
+
if (!health) throw new Fail(`${install.url} isn't answering.`, { fix: "Check it with `npx databull@latest status`." });
|
|
2896
|
+
if (compareVersions(health.version, MIN_AGENT_VERSION) >= 0) return health.version;
|
|
2897
|
+
const why = `${install.name} runs DataBull ${health.version}; API keys and AI agents need ${MIN_AGENT_VERSION} or newer.`;
|
|
2898
|
+
if (!ctx.flags.yes) {
|
|
2899
|
+
if (!canPrompt()) throw new Fail(why, { fix: "Update it with `npx databull@latest update` (or pass --yes to update now)." });
|
|
2900
|
+
warn(why);
|
|
2901
|
+
if (!await askConfirm({ message: `Update ${install.name} now? Your data stays as it is.` })) throw new Cancelled();
|
|
2902
|
+
} else {
|
|
2903
|
+
info(`${why} Updating it first.`);
|
|
2904
|
+
}
|
|
2905
|
+
const before = ctx.flags.name;
|
|
2906
|
+
ctx.flags.name = install.name;
|
|
2907
|
+
try {
|
|
2908
|
+
await runUpdate2(ctx);
|
|
2909
|
+
} finally {
|
|
2910
|
+
ctx.flags.name = before;
|
|
2911
|
+
}
|
|
2912
|
+
const after = await fetchHealth(install.url);
|
|
2913
|
+
if (!after || compareVersions(after.version, MIN_AGENT_VERSION) < 0) throw new Fail(`${install.name} still isn't on ${MIN_AGENT_VERSION}.`, { fix: "Run `npx databull@latest update` and try again." });
|
|
2914
|
+
return after.version;
|
|
2915
|
+
}
|
|
2916
|
+
async function createKeyOnInstall(ctx, install, { label, pii = false, connect: connect2 }) {
|
|
2917
|
+
await connect2(ctx, null, null, { accountId: install.account_id });
|
|
2918
|
+
const key = newApiKey();
|
|
2919
|
+
const sql = `INSERT INTO api_keys (user_id, name, prefix, hash, pii, created_at) SELECT id, ${sqlText(label)}, '${key.slice(0, 12)}', '${sha256hex(key)}', ${pii ? 1 : 0}, ${Date.now()} FROM users ORDER BY id LIMIT 1;`;
|
|
2920
|
+
const configFile = ensureD1Config(ctx.paths.installDir(install.name), install);
|
|
2921
|
+
const r2 = await withStep(null, null, "Creating an API key", async () => {
|
|
2922
|
+
const res = await d1Execute(ctx, install, configFile, sql, "create API key");
|
|
2923
|
+
return { value: res, label: res.changes === 0 ? "No login yet" : "API key created", detail: res.changes === 0 ? "" : label };
|
|
2924
|
+
});
|
|
2925
|
+
if (r2.value.changes === 0) throw new Fail(`${install.name} has no login yet, so there's no account to give a key to.`, { fix: "Create your login first (`npx databull@latest status` shows the setup link), then try again." });
|
|
2926
|
+
return { key, prefix: key.slice(0, 12), label };
|
|
2927
|
+
}
|
|
2928
|
+
async function resolveConnection(ctx, { pickInstall: pickInstall2, connect: connect2, runUpdate: runUpdate2, save: save2 }) {
|
|
2929
|
+
const url = ctx.flags.url || process.env.DATABULL_URL;
|
|
2930
|
+
const key = ctx.flags.apiKey || process.env.DATABULL_API_KEY;
|
|
2931
|
+
if (url || key) {
|
|
2932
|
+
if (!url || !key) throw new Fail("Pass both the address and the API key.", { fix: "Set DATABULL_URL=https://\u2026 and DATABULL_API_KEY=dbk_\u2026 (or use --url and --api-key)." });
|
|
2933
|
+
if (!KEY_RE.test(key)) throw new Fail("That isn't a DataBull API key (they start with dbk_ and have 44 characters).", { fix: "Copy it again from Settings \u2192 API keys." });
|
|
2934
|
+
if (!/^https?:\/\//.test(url)) throw new Fail(`"${url}" isn't an address.`, { fix: "Use the full address, e.g. https://analytics.example.com." });
|
|
2935
|
+
return { url: cleanUrl(url), key };
|
|
2936
|
+
}
|
|
2937
|
+
const install = await pickInstall2(ctx, { verb: "read" });
|
|
2938
|
+
if (!install) throw new Fail("No DataBull install on this computer.", { fix: `Install it with \`npx databull@latest\`. ${ENV_FIX}` });
|
|
2939
|
+
if (install.api_key && KEY_RE.test(install.api_key)) return { url: install.url, key: install.api_key, install };
|
|
2940
|
+
await ensureAgentVersion(ctx, install, runUpdate2);
|
|
2941
|
+
if (!ctx.flags.yes) {
|
|
2942
|
+
if (!canPrompt()) throw new Fail(`This computer has no API key for ${install.name} yet.`, { fix: "Run the command in a terminal to create one, pass --yes, or create it with `npx databull@latest api-key create`." });
|
|
2943
|
+
info(`To read your stats, this computer needs an API key for ${install.name} (read-only).`);
|
|
2944
|
+
if (!await askConfirm({ message: "Create one now? It uses your Cloudflare login." })) throw new Cancelled();
|
|
2945
|
+
}
|
|
2946
|
+
const made = await createKeyOnInstall(ctx, install, { label: `CLI on ${hostName()}`, connect: connect2 });
|
|
2947
|
+
upsertInstall(ctx.cfg, { ...install, api_key: made.key });
|
|
2948
|
+
save2(ctx);
|
|
2949
|
+
return { url: install.url, key: made.key, install: { ...install, api_key: made.key } };
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// src/commands/data.mjs
|
|
2953
|
+
var out2 = (s = "") => process.stdout.write(`${s}
|
|
2954
|
+
`);
|
|
2955
|
+
var strip = (s) => String(s).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
|
2956
|
+
function money(n, currency) {
|
|
2957
|
+
if (n === null || n === void 0) return import_picocolors3.default.dim("\u2013");
|
|
2958
|
+
const big = Math.abs(n) >= 1e3;
|
|
2959
|
+
try {
|
|
2960
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: big ? 0 : 2, minimumFractionDigits: big || Number.isInteger(n) ? 0 : 2 }).format(n);
|
|
2961
|
+
} catch {
|
|
2962
|
+
return `${n} ${currency}`;
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
var num = (n) => n === null || n === void 0 ? import_picocolors3.default.dim("\u2013") : Number(n).toLocaleString("en-US");
|
|
2966
|
+
var pctText = (n) => n === null || n === void 0 ? "" : `${n}%`;
|
|
2967
|
+
function change(n) {
|
|
2968
|
+
if (n === null || n === void 0) return import_picocolors3.default.dim("new");
|
|
2969
|
+
if (n === 0) return import_picocolors3.default.dim("\xB10%");
|
|
2970
|
+
return n > 0 ? import_picocolors3.default.green(`+${n}%`) : import_picocolors3.default.red(`${n}%`);
|
|
2971
|
+
}
|
|
2972
|
+
function duration(s) {
|
|
2973
|
+
if (!s) return "0s";
|
|
2974
|
+
const m2 = Math.floor(s / 60);
|
|
2975
|
+
return m2 ? `${m2}m ${Math.round(s % 60)}s` : `${Math.round(s)}s`;
|
|
2976
|
+
}
|
|
2977
|
+
function ago(iso) {
|
|
2978
|
+
if (!iso) return "";
|
|
2979
|
+
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1e3);
|
|
2980
|
+
if (s < 60) return "just now";
|
|
2981
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
2982
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
2983
|
+
if (s < 30 * 86400) return `${Math.floor(s / 86400)}d ago`;
|
|
2984
|
+
return iso.slice(0, 10);
|
|
2985
|
+
}
|
|
2986
|
+
function bar(value, max, width = 14) {
|
|
2987
|
+
if (!max || !value) return " ".repeat(width);
|
|
2988
|
+
const eighths = Math.max(1, Math.round(value / max * width * 8));
|
|
2989
|
+
const full = Math.floor(eighths / 8);
|
|
2990
|
+
const part = ["", "\u258F", "\u258E", "\u258D", "\u258C", "\u258B", "\u258A", "\u2589"][eighths % 8];
|
|
2991
|
+
return ("\u2588".repeat(full) + part).padEnd(width, " ").slice(0, width);
|
|
2992
|
+
}
|
|
2993
|
+
function spark(values) {
|
|
2994
|
+
const max = Math.max(...values, 0);
|
|
2995
|
+
if (!max) return import_picocolors3.default.dim("\u2581".repeat(values.length));
|
|
2996
|
+
return values.map((v2) => "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"[Math.min(7, Math.round(v2 / max * 7))]).join("");
|
|
2997
|
+
}
|
|
2998
|
+
var short = (s, n = 34) => s && s.length > n ? `${s.slice(0, n - 1)}\u2026` : s || "";
|
|
2999
|
+
var regions = (() => {
|
|
3000
|
+
try {
|
|
3001
|
+
return new Intl.DisplayNames(["en"], { type: "region" });
|
|
3002
|
+
} catch {
|
|
3003
|
+
return null;
|
|
3004
|
+
}
|
|
3005
|
+
})();
|
|
3006
|
+
var countryName = (code) => {
|
|
3007
|
+
if (!code || !/^[A-Z]{2}$/.test(code)) return code;
|
|
3008
|
+
try {
|
|
3009
|
+
return `${regions.of(code)} (${code})`;
|
|
3010
|
+
} catch {
|
|
3011
|
+
return code;
|
|
3012
|
+
}
|
|
3013
|
+
};
|
|
3014
|
+
function rowName(x2, dim, n = 44) {
|
|
3015
|
+
if (x2.label) return import_picocolors3.default.italic(short(x2.label[0].toUpperCase() + x2.label.slice(1), n));
|
|
3016
|
+
return short(dim === "country" ? countryName(x2.name) : x2.name, n);
|
|
3017
|
+
}
|
|
3018
|
+
function table(rows, cols) {
|
|
3019
|
+
const cells = rows.map((r2) => cols.map((c) => String(c.get(r2) ?? "")));
|
|
3020
|
+
const widths = cols.map((c, i) => Math.max(strip(c.label).length, ...cells.map((row) => strip(row[i]).length)));
|
|
3021
|
+
const pad = (s, i) => cols[i].right ? " ".repeat(widths[i] - strip(s).length) + s : s + " ".repeat(widths[i] - strip(s).length);
|
|
3022
|
+
out2(` ${cols.map((c, i) => import_picocolors3.default.dim(pad(c.label, i))).join(" ")}`);
|
|
3023
|
+
for (const row of cells) out2(` ${row.map((s, i) => pad(s, i)).join(" ")}`);
|
|
3024
|
+
}
|
|
3025
|
+
function header(r2, title) {
|
|
3026
|
+
const range2 = r2.range ? import_picocolors3.default.dim(` ${r2.range.from === r2.range.to ? r2.range.from : `${r2.range.from} \u2192 ${r2.range.to}`}${r2.range.period && r2.range.period !== "custom" ? ` (${r2.range.period})` : ""}`) : "";
|
|
3027
|
+
out2("");
|
|
3028
|
+
out2(`${import_picocolors3.default.bold(title || r2.site?.domain || "")}${range2}`);
|
|
3029
|
+
out2("");
|
|
3030
|
+
}
|
|
3031
|
+
function filters(ctx) {
|
|
3032
|
+
const f = {};
|
|
3033
|
+
for (const item of ctx.flags.filter || []) {
|
|
3034
|
+
const i = item.indexOf("=");
|
|
3035
|
+
if (i < 1) throw new Fail(`--filter needs key=value (got "${item}").`, { fix: "Example: --filter referrer=news.ycombinator.com --filter country=US" });
|
|
3036
|
+
f[item.slice(0, i).trim()] = item.slice(i + 1).trim();
|
|
3037
|
+
}
|
|
3038
|
+
return Object.keys(f).length ? f : void 0;
|
|
3039
|
+
}
|
|
3040
|
+
function range(ctx) {
|
|
3041
|
+
const r2 = {};
|
|
3042
|
+
if (ctx.flags.period) r2.period = ctx.flags.period;
|
|
3043
|
+
if (ctx.flags.from) r2.from = ctx.flags.from;
|
|
3044
|
+
if (ctx.flags.to) r2.to = ctx.flags.to;
|
|
3045
|
+
const f = filters(ctx);
|
|
3046
|
+
if (f) r2.filters = f;
|
|
3047
|
+
return r2;
|
|
3048
|
+
}
|
|
3049
|
+
function limit(ctx, def) {
|
|
3050
|
+
if (ctx.flags.limit === void 0) return def;
|
|
3051
|
+
const n = Number(ctx.flags.limit);
|
|
3052
|
+
if (!Number.isInteger(n) || n < 1) throw new Fail(`--limit must be a whole number (got "${ctx.flags.limit}").`);
|
|
3053
|
+
return n;
|
|
3054
|
+
}
|
|
3055
|
+
var siteArg = (ctx, i = 0) => ctx.flags.site || ctx.args[i];
|
|
3056
|
+
function renderSites(r2) {
|
|
3057
|
+
out2("");
|
|
3058
|
+
if (!r2.sites.length) return out2(" No sites yet. Add one in your dashboard.");
|
|
3059
|
+
table(r2.sites, [
|
|
3060
|
+
{ label: "Site", get: (s) => import_picocolors3.default.bold(s.domain) },
|
|
3061
|
+
{ label: "Today", get: (s) => num(s.visitors_today), right: true },
|
|
3062
|
+
{ label: "Online", get: (s) => s.online_now ? import_picocolors3.default.green(`\u25CF ${s.online_now}`) : import_picocolors3.default.dim("0"), right: true },
|
|
3063
|
+
{ label: "7d visitors", get: (s) => `${num(s.last_7_days.visitors)} ${change(pctChange(s.last_7_days.visitors, s.last_7_days.previous.visitors))}`, right: true },
|
|
3064
|
+
{ label: "7d revenue", get: (s) => `${money(s.last_7_days.revenue, r2.currency)} ${change(pctChange(s.last_7_days.revenue, s.last_7_days.previous.revenue))}`, right: true },
|
|
3065
|
+
{ label: "30d revenue", get: (s) => money(s.last_30_days.revenue, r2.currency), right: true },
|
|
3066
|
+
{ label: "Id", get: (s) => import_picocolors3.default.dim(s.id) }
|
|
3067
|
+
]);
|
|
3068
|
+
out2("");
|
|
3069
|
+
}
|
|
3070
|
+
var pctChange = (cur, prev) => prev ? Math.round((cur - prev) / prev * 1e3) / 10 : cur ? null : 0;
|
|
3071
|
+
function renderOverview(r2) {
|
|
3072
|
+
header(r2);
|
|
3073
|
+
const t = r2.totals;
|
|
3074
|
+
const c = r2.change_pct;
|
|
3075
|
+
const rows = [
|
|
3076
|
+
["Visitors", num(t.visitors), change(c.visitors)],
|
|
3077
|
+
["Revenue", money(t.revenue, r2.currency), change(c.revenue)],
|
|
3078
|
+
["Sales", num(t.sales), change(c.sales)],
|
|
3079
|
+
["Conversion", pctText(t.conversion_rate_pct), change(c.conversion_rate_pct)],
|
|
3080
|
+
["Revenue/visitor", money(t.revenue_per_visitor, r2.currency), change(c.revenue_per_visitor)],
|
|
3081
|
+
["Pageviews", num(t.pageviews), change(c.pageviews)],
|
|
3082
|
+
["Bounce rate", pctText(t.bounce_rate_pct), ""],
|
|
3083
|
+
["Visit length", duration(t.avg_visit_seconds), ""]
|
|
3084
|
+
];
|
|
3085
|
+
if (t.refunds) rows.push(["Refunds", money(t.refunds, r2.currency), ""]);
|
|
3086
|
+
for (const [k3, v2, d3] of rows) out2(` ${import_picocolors3.default.dim(k3.padEnd(16))}${v2.padStart(strip(v2).length > 12 ? strip(v2).length : 12 + (v2.length - strip(v2).length))} ${d3}`);
|
|
3087
|
+
out2(` ${import_picocolors3.default.dim("Online now".padEnd(16))}${String(r2.online_now).padStart(12)} ${r2.online_now ? import_picocolors3.default.green("\u25CF") : ""}`);
|
|
3088
|
+
if (r2.series?.length > 1) {
|
|
3089
|
+
out2("");
|
|
3090
|
+
out2(` ${import_picocolors3.default.dim("Visitors".padEnd(16))}${import_picocolors3.default.blue(spark(r2.series.map((b3) => b3.visitors)))}`);
|
|
3091
|
+
out2(` ${import_picocolors3.default.dim("Revenue".padEnd(16))}${import_picocolors3.default.magenta(spark(r2.series.map((b3) => b3.revenue || 0)))}`);
|
|
3092
|
+
out2(` ${import_picocolors3.default.dim("".padEnd(16))}${import_picocolors3.default.dim(`${r2.series[0].date} \u2026 ${r2.series[r2.series.length - 1].date} (${r2.granularity})`)}`);
|
|
3093
|
+
}
|
|
3094
|
+
if (r2.spikes?.length) {
|
|
3095
|
+
out2("");
|
|
3096
|
+
for (const s of r2.spikes) out2(` ${import_picocolors3.default.yellow("\u25B2")} ${s.date}: ${import_picocolors3.default.bold(s.source)} sent ${s.times_usual}\xD7 its usual traffic (${num(s.visitors)} visitors)`);
|
|
3097
|
+
}
|
|
3098
|
+
out2("");
|
|
3099
|
+
}
|
|
3100
|
+
function renderBreakdown(r2) {
|
|
3101
|
+
header(r2, `${r2.site.domain} \xB7 ${r2.dimension.replace(/_/g, " ")}`);
|
|
3102
|
+
if (!r2.rows.length) return out2(` Nothing for this period.
|
|
3103
|
+
`);
|
|
3104
|
+
const byRevenue = r2.sorted_by === "revenue";
|
|
3105
|
+
const max = Math.max(...r2.rows.map((x2) => byRevenue ? x2.revenue || 0 : x2.visitors || x2.completions || 0));
|
|
3106
|
+
const hasMoney = r2.rows.some((x2) => x2.revenue !== void 0);
|
|
3107
|
+
const cols = [
|
|
3108
|
+
{ label: "#", get: (x2) => import_picocolors3.default.dim(String(r2.rows.indexOf(x2) + 1)), right: true },
|
|
3109
|
+
{ label: r2.dimension.replace(/_/g, " "), get: (x2) => rowName(x2, r2.dimension) + (x2.country ? import_picocolors3.default.dim(` ${x2.country}`) : "") },
|
|
3110
|
+
{ label: "", get: (x2) => (byRevenue ? import_picocolors3.default.magenta : import_picocolors3.default.blue)(bar(byRevenue ? x2.revenue || 0 : x2.visitors || x2.completions || 0, max)) },
|
|
3111
|
+
{ label: "Visitors", get: (x2) => num(x2.visitors), right: true }
|
|
3112
|
+
];
|
|
3113
|
+
if (r2.rows.some((x2) => x2.pageviews !== void 0)) cols.push({ label: "Pageviews", get: (x2) => num(x2.pageviews), right: true });
|
|
3114
|
+
if (r2.rows.some((x2) => x2.completions !== void 0)) cols.push({ label: "Completions", get: (x2) => num(x2.completions), right: true });
|
|
3115
|
+
if (hasMoney) {
|
|
3116
|
+
cols.push({ label: "Revenue", get: (x2) => x2.revenue ? money(x2.revenue, r2.currency) : import_picocolors3.default.dim("\u2013"), right: true });
|
|
3117
|
+
if (r2.rows.some((x2) => x2.sales !== void 0)) cols.push({ label: "Sales", get: (x2) => x2.sales ? num(x2.sales) : import_picocolors3.default.dim("\u2013"), right: true });
|
|
3118
|
+
}
|
|
3119
|
+
if (r2.rows.some((x2) => x2.conversion_rate_pct !== void 0)) cols.push({ label: "Conv.", get: (x2) => pctText(x2.conversion_rate_pct), right: true });
|
|
3120
|
+
table(r2.rows, cols);
|
|
3121
|
+
out2("");
|
|
3122
|
+
out2(import_picocolors3.default.dim(` ${num(r2.total_visitors)} visitors in total \xB7 showing ${r2.rows.length} of ${r2.rows_total} \xB7 sorted by ${r2.sorted_by}`));
|
|
3123
|
+
out2("");
|
|
3124
|
+
}
|
|
3125
|
+
function renderCompare(r2) {
|
|
3126
|
+
out2("");
|
|
3127
|
+
out2(`${import_picocolors3.default.bold(r2.site.domain)} ${r2.range.from} \u2192 ${r2.range.to} ${import_picocolors3.default.dim(`vs ${r2.previous_range.from} \u2192 ${r2.previous_range.to}`)}`);
|
|
3128
|
+
out2("");
|
|
3129
|
+
const t = r2.totals;
|
|
3130
|
+
const p2 = r2.previous_period;
|
|
3131
|
+
const row = (k3, a, b3, d3) => out2(` ${import_picocolors3.default.dim(k3.padEnd(16))}${a.padStart(12 + (a.length - strip(a).length))} ${import_picocolors3.default.dim("vs")} ${b3.padEnd(10 + (b3.length - strip(b3).length))} ${d3}`);
|
|
3132
|
+
row("Visitors", num(t.visitors), num(p2.visitors), change(r2.change_pct.visitors));
|
|
3133
|
+
row("Revenue", money(t.revenue, r2.currency), money(p2.revenue, r2.currency), change(r2.change_pct.revenue));
|
|
3134
|
+
row("Sales", num(t.sales), num(p2.sales), change(r2.change_pct.sales));
|
|
3135
|
+
row("Conversion", pctText(t.conversion_rate_pct), pctText(p2.conversion_rate_pct), change(r2.change_pct.conversion_rate_pct));
|
|
3136
|
+
const list = (title, items, field) => {
|
|
3137
|
+
if (!items.length) return;
|
|
3138
|
+
out2("");
|
|
3139
|
+
out2(` ${import_picocolors3.default.bold(title)}`);
|
|
3140
|
+
for (const m2 of items) {
|
|
3141
|
+
const delta = field === "revenue" ? money(Math.abs(m2.change), r2.currency) : num(Math.abs(m2.change));
|
|
3142
|
+
out2(` ${m2.change > 0 ? import_picocolors3.default.green(`\u25B2 +${delta}`) : import_picocolors3.default.red(`\u25BC -${delta}`)} ${rowName(m2, r2.dimension, 40)}${m2.country ? import_picocolors3.default.dim(` ${m2.country}`) : ""} ${import_picocolors3.default.dim(`(${field === "revenue" ? `${money(m2.previous_revenue, r2.currency)} \u2192 ${money(m2.revenue, r2.currency)}` : `${num(m2.previous_visitors)} \u2192 ${num(m2.visitors)}`})`)}`);
|
|
3143
|
+
}
|
|
3144
|
+
};
|
|
3145
|
+
const dim = r2.dimension.replace(/_/g, " ");
|
|
3146
|
+
list(`Revenue up, by ${dim}`, r2.gainers_by_revenue, "revenue");
|
|
3147
|
+
list(`Revenue down, by ${dim}`, r2.losers_by_revenue, "revenue");
|
|
3148
|
+
list(`Visitors up, by ${dim}`, r2.gainers_by_visitors, "visitors");
|
|
3149
|
+
list(`Visitors down, by ${dim}`, r2.losers_by_visitors, "visitors");
|
|
3150
|
+
out2("");
|
|
3151
|
+
}
|
|
3152
|
+
function sourceText(s) {
|
|
3153
|
+
if (!s) return import_picocolors3.default.dim("not matched to a visit");
|
|
3154
|
+
return [s.referrer || s.channel, s.utm_campaign ? import_picocolors3.default.dim(`campaign ${s.utm_campaign}`) : "", s.country ? import_picocolors3.default.dim(s.country) : ""].filter(Boolean).join(" \xB7 ");
|
|
3155
|
+
}
|
|
3156
|
+
function renderSales(r2) {
|
|
3157
|
+
header(r2);
|
|
3158
|
+
if (!r2.sales.length) return out2(" No sales in this period.\n");
|
|
3159
|
+
table(r2.sales, [
|
|
3160
|
+
{ label: "When", get: (s) => import_picocolors3.default.dim(ago(s.at)) },
|
|
3161
|
+
{ label: "Amount", get: (s) => s.refunded ? `${money(s.amount, r2.currency)} ${import_picocolors3.default.red(`-${money(s.refunded, r2.currency)}`)}` : money(s.amount, r2.currency), right: true },
|
|
3162
|
+
{ label: "Product", get: (s) => short(s.product, 26) },
|
|
3163
|
+
{ label: "Type", get: (s) => import_picocolors3.default.dim((s.charge_type || "").replace("_", "-")) },
|
|
3164
|
+
{ label: "Source", get: (s) => sourceText(s.source) },
|
|
3165
|
+
...r2.sales.some((s) => s.customer) ? [{ label: "Customer", get: (s) => s.customer?.email || "" }] : [],
|
|
3166
|
+
{ label: "Visitor", get: (s) => import_picocolors3.default.dim((s.visitor_id || "").slice(0, 10)) }
|
|
3167
|
+
]);
|
|
3168
|
+
out2("");
|
|
3169
|
+
const total = r2.by_product.reduce((a, p2) => a + p2.revenue, 0);
|
|
3170
|
+
out2(import_picocolors3.default.dim(` ${money(total, r2.currency)} from ${r2.by_product.reduce((a, p2) => a + p2.sales, 0)} sales${r2.by_product.length > 1 ? ` across ${r2.by_product.length} products` : ""}${r2.has_more ? " \xB7 more with --limit" : ""} \xB7 see a buyer's path: databull visitor <visitor id>`));
|
|
3171
|
+
out2("");
|
|
3172
|
+
}
|
|
3173
|
+
function renderVisitors(r2) {
|
|
3174
|
+
header(r2);
|
|
3175
|
+
if (!r2.visitors.length) return out2(" No visitors match.\n");
|
|
3176
|
+
table(r2.visitors, [
|
|
3177
|
+
{ label: "Last seen", get: (v2) => import_picocolors3.default.dim(ago(v2.last_seen)) },
|
|
3178
|
+
{ label: "Visitor", get: (v2) => v2.email || v2.name ? `${v2.name || v2.email}` : import_picocolors3.default.dim(v2.visitor_id.slice(0, 10)) },
|
|
3179
|
+
{ label: "From", get: (v2) => short(v2.source?.referrer || v2.source?.channel || "", 24) },
|
|
3180
|
+
{ label: "Path", get: (v2) => short((v2.path || []).join(" \u2192 "), 40) },
|
|
3181
|
+
{ label: "Visits", get: (v2) => num(v2.visits_in_range), right: true },
|
|
3182
|
+
{ label: "Time", get: (v2) => duration(v2.time_on_site_seconds), right: true },
|
|
3183
|
+
{ label: "Revenue", get: (v2) => v2.revenue ? import_picocolors3.default.magenta(money(v2.revenue, r2.currency)) : import_picocolors3.default.dim("\u2013"), right: true },
|
|
3184
|
+
{ label: "Where", get: (v2) => import_picocolors3.default.dim(short(v2.location || "", 24)) }
|
|
3185
|
+
]);
|
|
3186
|
+
out2("");
|
|
3187
|
+
out2(import_picocolors3.default.dim(` ${num(r2.total_visitors)} visitors \xB7 ${num(r2.paying_visitors)} paid${r2.has_more ? " \xB7 more with --page 1" : ""}`));
|
|
3188
|
+
out2("");
|
|
3189
|
+
}
|
|
3190
|
+
function renderVisitor(r2) {
|
|
3191
|
+
const v2 = r2.visitor;
|
|
3192
|
+
out2("");
|
|
3193
|
+
out2(`${import_picocolors3.default.bold(v2.name || v2.email || v2.visitor_id)} ${import_picocolors3.default.dim([r2.site.domain, `first seen ${ago(v2.first_seen)}`, `${v2.visits} visit${v2.visits === 1 ? "" : "s"}`, v2.location, v2.device].filter(Boolean).join(" \xB7 "))}`);
|
|
3194
|
+
out2("");
|
|
3195
|
+
if (r2.payments.length) {
|
|
3196
|
+
for (const p2 of r2.payments) out2(` ${import_picocolors3.default.magenta("$")} ${import_picocolors3.default.bold(money(p2.amount, r2.currency))} ${p2.product || ""} ${import_picocolors3.default.dim(`${ago(p2.at)} \xB7 credited to ${sourceText(p2.source)}`)}`);
|
|
3197
|
+
out2("");
|
|
3198
|
+
}
|
|
3199
|
+
const events = [...r2.events].reverse();
|
|
3200
|
+
for (const s of [...r2.visits].reverse()) {
|
|
3201
|
+
out2(` ${import_picocolors3.default.blue("\u25CF")} ${import_picocolors3.default.dim(s.started?.slice(0, 16).replace("T", " "))} ${import_picocolors3.default.bold(s.referrer || s.channel)}${s.campaign ? import_picocolors3.default.dim(` \xB7 ${s.campaign}`) : ""} ${import_picocolors3.default.dim(`${s.pageviews} page${s.pageviews === 1 ? "" : "s"} \xB7 ${duration(s.seconds)}`)}`);
|
|
3202
|
+
const inVisit = events.filter((e2) => e2.visit_id === s.visit_id);
|
|
3203
|
+
for (const e2 of inVisit.slice(0, 15)) out2(` ${e2.type === "pageview" ? import_picocolors3.default.dim("\u2192") : import_picocolors3.default.yellow("\u2605")} ${e2.type === "pageview" ? e2.page : `${e2.type}: ${e2.name || ""}`}`);
|
|
3204
|
+
}
|
|
3205
|
+
out2("");
|
|
3206
|
+
}
|
|
3207
|
+
function renderLive(r2) {
|
|
3208
|
+
out2("");
|
|
3209
|
+
out2(`${import_picocolors3.default.bold(r2.site.domain)} ${r2.online_now ? import_picocolors3.default.green(`\u25CF ${r2.online_now} online now`) : import_picocolors3.default.dim("nobody online right now")}`);
|
|
3210
|
+
out2("");
|
|
3211
|
+
if (r2.visitors.length) {
|
|
3212
|
+
table(r2.visitors.slice(0, 20), [
|
|
3213
|
+
{ label: "Page", get: (v2) => short(v2.page, 36) },
|
|
3214
|
+
{ label: "From", get: (v2) => short(v2.referrer || v2.channel || "", 26) },
|
|
3215
|
+
{ label: "Where", get: (v2) => import_picocolors3.default.dim(short(v2.location || "", 22)) },
|
|
3216
|
+
{ label: "Device", get: (v2) => import_picocolors3.default.dim(short(v2.device || "", 26)) }
|
|
3217
|
+
]);
|
|
3218
|
+
out2("");
|
|
3219
|
+
}
|
|
3220
|
+
if (r2.recent_sales.length) {
|
|
3221
|
+
for (const s of r2.recent_sales) out2(` ${import_picocolors3.default.magenta("$")} ${money(s.amount, s.currency)} ${s.product || ""} ${import_picocolors3.default.dim(`${ago(s.at)} \xB7 ${s.referrer || "direct"}${s.country ? ` \xB7 ${s.country}` : ""}`)}`);
|
|
3222
|
+
out2("");
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
function renderGoals(r2) {
|
|
3226
|
+
header(r2, `${r2.site.domain} \xB7 goals`);
|
|
3227
|
+
if (!r2.goals.length) return out2(" No goals completed in this period. Track one with data-fast-goal or datacheap('goal').\n");
|
|
3228
|
+
const max = Math.max(...r2.goals.map((g) => g.completions));
|
|
3229
|
+
table(r2.goals, [
|
|
3230
|
+
{ label: "Goal", get: (g) => import_picocolors3.default.bold(g.goal) },
|
|
3231
|
+
{ label: "", get: (g) => import_picocolors3.default.yellow(bar(g.completions, max)) },
|
|
3232
|
+
{ label: "Completions", get: (g) => num(g.completions), right: true },
|
|
3233
|
+
{ label: "Visitors", get: (g) => num(g.visitors), right: true },
|
|
3234
|
+
{ label: "Conv.", get: (g) => pctText(g.conversion_rate_pct), right: true },
|
|
3235
|
+
{ label: "Revenue", get: (g) => g.revenue ? money(g.revenue, r2.currency) : import_picocolors3.default.dim("\u2013"), right: true }
|
|
3236
|
+
]);
|
|
3237
|
+
out2("");
|
|
3238
|
+
}
|
|
3239
|
+
function renderFunnels(r2) {
|
|
3240
|
+
header(r2, `${r2.site.domain} \xB7 funnels`);
|
|
3241
|
+
if (!r2.funnels.length) return out2(" No funnels yet. Create one in the dashboard under Funnels.\n");
|
|
3242
|
+
for (const f of r2.funnels) {
|
|
3243
|
+
out2(` ${import_picocolors3.default.bold(f.name)} ${import_picocolors3.default.dim(`${f.overall_conversion_pct}% make it to the end`)}`);
|
|
3244
|
+
const max = Math.max(...f.steps.map((s) => s.visitors), 1);
|
|
3245
|
+
f.steps.forEach((s, i) => {
|
|
3246
|
+
out2(` ${import_picocolors3.default.dim(`${i + 1}.`)} ${short(s.step, 30).padEnd(30)} ${import_picocolors3.default.blue(bar(s.visitors, max, 20))} ${num(s.visitors).padStart(7)} ${i ? import_picocolors3.default.dim(`${s.from_previous_pct}% of previous`) : ""}${s.revenue ? ` ${money(s.revenue, r2.currency)}` : ""}`);
|
|
3247
|
+
});
|
|
3248
|
+
out2("");
|
|
3249
|
+
}
|
|
3250
|
+
if (r2.note) out2(import_picocolors3.default.dim(` ${r2.note}
|
|
3251
|
+
`));
|
|
3252
|
+
}
|
|
3253
|
+
function renderLtv(r2) {
|
|
3254
|
+
header(r2, `${r2.site.domain} \xB7 customer value by ${r2.dimension.replace(/_/g, " ")}`);
|
|
3255
|
+
if (!r2.by_source.length) return out2(" No customers whose first payment falls in this period.\n");
|
|
3256
|
+
table(r2.by_source, [
|
|
3257
|
+
{ label: "Source", get: (g) => rowName({ name: g.source, label: g.label }, r2.dimension, 34) },
|
|
3258
|
+
{ label: "Customers", get: (g) => num(g.customers), right: true },
|
|
3259
|
+
{ label: "Revenue", get: (g) => money(g.revenue, r2.currency), right: true },
|
|
3260
|
+
{ label: "Avg value", get: (g) => money(g.average_value, r2.currency), right: true },
|
|
3261
|
+
{ label: "Recurring", get: (g) => pctText(g.recurring_share_pct), right: true },
|
|
3262
|
+
{ label: "Repeat", get: (g) => pctText(g.repeat_rate_pct), right: true }
|
|
3263
|
+
]);
|
|
3264
|
+
out2("");
|
|
3265
|
+
out2(import_picocolors3.default.dim(` ${num(r2.totals.customers)} customers \xB7 ${money(r2.totals.revenue, r2.currency)} \xB7 ${money(r2.totals.average_value, r2.currency)} each on average`));
|
|
3266
|
+
out2("");
|
|
3267
|
+
}
|
|
3268
|
+
var COMMANDS2 = {
|
|
3269
|
+
sites: { tool: "list_sites", input: () => ({}), render: renderSites },
|
|
3270
|
+
stats: { tool: "get_overview", input: (ctx) => ({ site: siteArg(ctx), ...range(ctx) }), render: renderOverview },
|
|
3271
|
+
sources: {
|
|
3272
|
+
tool: "get_breakdown",
|
|
3273
|
+
input: (ctx) => ({ site: siteArg(ctx), dimension: ctx.flags.dim || "referrer", sort: ctx.flags.by || "revenue", limit: limit(ctx, 15), ...range(ctx) }),
|
|
3274
|
+
render: renderBreakdown
|
|
3275
|
+
},
|
|
3276
|
+
breakdown: {
|
|
3277
|
+
tool: "get_breakdown",
|
|
3278
|
+
input: (ctx) => {
|
|
3279
|
+
const dimension = ctx.flags.dim || ctx.args[0];
|
|
3280
|
+
if (!dimension) throw new Fail("Which dimension? e.g. `databull breakdown country`.", { fix: "Dimensions: channel, referrer, utm_source, utm_medium, utm_campaign, utm_term, utm_content, country, region, city, browser, os, device, entry_page, exit_page, hostname, page, goal, outbound." });
|
|
3281
|
+
return { site: ctx.flags.site || ctx.args[ctx.flags.dim ? 0 : 1], dimension, sort: ctx.flags.by || ctx.flags.sort || "visitors", limit: limit(ctx, 15), ...range(ctx) };
|
|
3282
|
+
},
|
|
3283
|
+
render: renderBreakdown
|
|
3284
|
+
},
|
|
3285
|
+
compare: { tool: "compare_periods", input: (ctx) => ({ site: siteArg(ctx), ...ctx.flags.dim || ctx.flags.by ? { dimension: ctx.flags.dim || ctx.flags.by } : {}, ...ctx.flags.limit ? { limit: limit(ctx, 5) } : {}, ...range(ctx), period: ctx.flags.period || (ctx.flags.from ? void 0 : "7d") }), render: renderCompare },
|
|
3286
|
+
sales: { tool: "list_sales", input: (ctx) => ({ site: siteArg(ctx), limit: limit(ctx, 20), ...ctx.flags.page ? { page: Number(ctx.flags.page) } : {}, ...range(ctx) }), render: renderSales },
|
|
3287
|
+
visitors: {
|
|
3288
|
+
tool: "list_visitors",
|
|
3289
|
+
input: (ctx) => ({ site: siteArg(ctx), sort: ctx.flags.sort || ctx.flags.by || "recent", ...ctx.flags.customers ? { customers_only: true } : {}, limit: limit(ctx, 20), ...ctx.flags.page ? { page: Number(ctx.flags.page) } : {}, ...range(ctx) }),
|
|
3290
|
+
render: renderVisitors
|
|
3291
|
+
},
|
|
3292
|
+
visitor: {
|
|
3293
|
+
tool: "get_visitor",
|
|
3294
|
+
input: (ctx) => {
|
|
3295
|
+
if (!ctx.args[0]) throw new Fail("Which visitor? e.g. `databull visitor 3f2a\u2026`", { fix: "Visitor ids are in `databull sales` and `databull visitors`." });
|
|
3296
|
+
return { site: ctx.flags.site || ctx.args[1], visitor_id: ctx.args[0] };
|
|
3297
|
+
},
|
|
3298
|
+
render: renderVisitor
|
|
3299
|
+
},
|
|
3300
|
+
live: { tool: "get_realtime", input: (ctx) => ({ site: siteArg(ctx) }), render: renderLive },
|
|
3301
|
+
goals: { tool: "get_goals", input: (ctx) => ({ site: siteArg(ctx), ...range(ctx) }), render: renderGoals },
|
|
3302
|
+
funnels: { tool: "get_funnels", input: (ctx) => ({ site: siteArg(ctx), ...ctx.flags.funnel ? { funnel: ctx.flags.funnel } : {}, ...range(ctx) }), render: renderFunnels },
|
|
3303
|
+
ltv: { tool: "get_customer_value", input: (ctx) => ({ site: siteArg(ctx), ...ctx.flags.dim || ctx.flags.by ? { dimension: ctx.flags.dim || ctx.flags.by } : {}, ...range(ctx) }), render: renderLtv }
|
|
3304
|
+
};
|
|
3305
|
+
async function withKey(ctx, deps, tool, input) {
|
|
3306
|
+
const conn = await resolveConnection(ctx, deps);
|
|
3307
|
+
try {
|
|
3308
|
+
return await callTool(conn, tool, input);
|
|
3309
|
+
} catch (err) {
|
|
3310
|
+
if (err.code !== "unauthorized" || !conn.install?.api_key) throw err;
|
|
3311
|
+
forgetInstallFields(ctx.cfg, conn.install, ["api_key"]);
|
|
3312
|
+
deps.save(ctx);
|
|
3313
|
+
process.stderr.write(`${import_picocolors3.default.yellow("\u25B2")} The API key saved on this computer no longer works (revoked?). Getting a new one.
|
|
3314
|
+
`);
|
|
3315
|
+
return callTool(await resolveConnection(ctx, deps), tool, input);
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
async function runData(ctx, deps) {
|
|
3319
|
+
if (ctx.command === "query") {
|
|
3320
|
+
const [tool, json2] = ctx.args;
|
|
3321
|
+
if (!tool) throw new Fail('Which tool? e.g. `databull query get_overview \'{"period":"7d"}\'`.', { fix: "Tools: list_sites, get_overview, get_breakdown, compare_periods, list_sales, list_visitors, get_visitor, get_realtime, get_goals, get_funnels, get_customer_value." });
|
|
3322
|
+
let args = {};
|
|
3323
|
+
if (json2) {
|
|
3324
|
+
try {
|
|
3325
|
+
args = JSON.parse(json2);
|
|
3326
|
+
} catch {
|
|
3327
|
+
throw new Fail("The tool's arguments must be JSON.", { fix: `Example: '{"site":"example.com","period":"7d"}'` });
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
process.stdout.write(`${JSON.stringify(await withKey(ctx, deps, tool, args), null, 2)}
|
|
3331
|
+
`);
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
const cmd = COMMANDS2[ctx.command];
|
|
3335
|
+
const input = cmd.input(ctx);
|
|
3336
|
+
for (const k3 of Object.keys(input)) if (input[k3] === void 0) delete input[k3];
|
|
3337
|
+
const result = await withKey(ctx, deps, cmd.tool, input);
|
|
3338
|
+
if (ctx.flags.json) process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3339
|
+
`);
|
|
3340
|
+
else cmd.render(result);
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
// src/commands/keys.mjs
|
|
3344
|
+
var fmtDate = (ms) => ms ? new Date(Number(ms)).toISOString().slice(0, 16).replace("T", " ") : "\u2013";
|
|
3345
|
+
async function runApiKey(ctx, { pickInstall: pickInstall2, connect: connect2, runUpdate: runUpdate2, save: save2 }) {
|
|
3346
|
+
const sub = ctx.args[0] || "list";
|
|
3347
|
+
if (!["create", "list", "revoke"].includes(sub)) throw new Fail(`Unknown api-key command "${sub}".`, { fix: `Use \`${CMD} api-key create\`, \`list\` or \`revoke <prefix>\`.` });
|
|
3348
|
+
const install = await pickInstall2(ctx, { verb: "manage API keys for" });
|
|
3349
|
+
if (!install) throw new Fail("No DataBull install on this computer.", { fix: `Manage keys in your dashboard under Settings \u2192 API keys, or install with \`${CMD}\`.` });
|
|
3350
|
+
await ensureAgentVersion(ctx, install, runUpdate2);
|
|
3351
|
+
if (sub === "create") {
|
|
3352
|
+
const label = (ctx.flags.label || `API key ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`).trim();
|
|
3353
|
+
const made = await createKeyOnInstall(ctx, install, { label, pii: !!ctx.flags.pii, connect: connect2 });
|
|
3354
|
+
box("Your API key (shown once)", [
|
|
3355
|
+
import_picocolors3.default.bold(made.key),
|
|
3356
|
+
"",
|
|
3357
|
+
`${import_picocolors3.default.dim("Address")} ${install.url}`,
|
|
3358
|
+
`${import_picocolors3.default.dim("Access ")} read-only${ctx.flags.pii ? ", including customer names and emails" : ", customer details hidden"}`,
|
|
3359
|
+
"",
|
|
3360
|
+
import_picocolors3.default.dim("Use it anywhere:"),
|
|
3361
|
+
` DATABULL_URL=${install.url} DATABULL_API_KEY=${made.key.slice(0, 8)}\u2026 ${CMD} stats`,
|
|
3362
|
+
import_picocolors3.default.dim(`AI agents: \`${CMD} mcp\` sets them up for you.`)
|
|
3363
|
+
], { color: import_picocolors3.default.green });
|
|
3364
|
+
end(`Store it somewhere safe. Revoke it any time with \`${CMD} api-key revoke ${made.prefix}\`.`);
|
|
3365
|
+
return;
|
|
3366
|
+
}
|
|
3367
|
+
await connect2(ctx, null, null, { accountId: install.account_id });
|
|
3368
|
+
const configFile = ensureD1Config(ctx.paths.installDir(install.name), install);
|
|
3369
|
+
if (sub === "list") {
|
|
3370
|
+
const r3 = await d1Execute(ctx, install, configFile, "SELECT id, name, prefix, pii, created_at, last_used_at FROM api_keys WHERE revoked_at IS NULL ORDER BY created_at DESC;", "list API keys");
|
|
3371
|
+
if (!r3.results.length) {
|
|
3372
|
+
end(`No API keys yet. Create one with \`${CMD} api-key create\` (or \`${CMD} mcp\` for AI agents).`);
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
box(`API keys on ${install.name}`, r3.results.map((k3) => `${import_picocolors3.default.bold(k3.prefix)}\u2026 ${String(k3.name).padEnd(28)} ${import_picocolors3.default.dim(`created ${fmtDate(k3.created_at)} \xB7 last used ${fmtDate(k3.last_used_at)}${k3.pii ? " \xB7 customer details" : ""}`)}`));
|
|
3376
|
+
end(`Revoke one with \`${CMD} api-key revoke <prefix>\`.`);
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
const which = ctx.args[1];
|
|
3380
|
+
if (!which) throw new Fail("Which key? Pass its prefix, e.g. `api-key revoke dbk_ab12cd34`.", { fix: `\`${CMD} api-key list\` shows them.` });
|
|
3381
|
+
const prefix = which.replace(/…$/, "").slice(0, 12);
|
|
3382
|
+
if (!/^dbk_[a-z0-9]{8}$/.test(prefix) && !/^\d+$/.test(which)) throw new Fail(`"${which}" isn't a key prefix (dbk_ + 8 characters) or id.`, { fix: `\`${CMD} api-key list\` shows them.` });
|
|
3383
|
+
const where = /^\d+$/.test(which) ? `id = ${Number(which)}` : `prefix = '${prefix}'`;
|
|
3384
|
+
const r2 = await d1Execute(ctx, install, configFile, `UPDATE api_keys SET revoked_at = ${Date.now()} WHERE revoked_at IS NULL AND ${where};`, "revoke API key");
|
|
3385
|
+
if (!r2.changes) throw new Fail(`No active key ${which} on ${install.name}.`, { fix: `\`${CMD} api-key list\` shows the active ones.` });
|
|
3386
|
+
const mine = ["api_key", "mcp_key"].filter((f) => install[f] && (install[f].startsWith(prefix) || /^\d+$/.test(which)));
|
|
3387
|
+
if (mine.length && !/^\d+$/.test(which)) {
|
|
3388
|
+
forgetInstallFields(ctx.cfg, install, mine);
|
|
3389
|
+
save2(ctx);
|
|
3390
|
+
}
|
|
3391
|
+
end(`Revoked. Anything using that key stops working right away.`);
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
// src/commands/mcp.mjs
|
|
3395
|
+
import { spawnSync } from "node:child_process";
|
|
3396
|
+
var CLIENTS = ["claude", "cursor", "vscode", "codex", "windsurf", "other"];
|
|
3397
|
+
var claudeBin = () => process.env.DATABULL_CLAUDE_BIN || "claude";
|
|
3398
|
+
function hasClaude() {
|
|
3399
|
+
if (process.env.DATABULL_CLAUDE_BIN === "") return false;
|
|
3400
|
+
const r2 = spawnSync(claudeBin(), ["--version"], { stdio: "ignore", timeout: 1e4 });
|
|
3401
|
+
return r2.status === 0;
|
|
3402
|
+
}
|
|
3403
|
+
function snippets(url, key) {
|
|
3404
|
+
const header2 = `Authorization: Bearer ${key}`;
|
|
3405
|
+
const json2 = (o2) => JSON.stringify(o2, null, 2);
|
|
3406
|
+
return {
|
|
3407
|
+
claude: { title: "Claude Code", where: "Run in a terminal", text: `claude mcp add --transport http --scope user databull ${url} --header "${header2}"` },
|
|
3408
|
+
cursor: { title: "Cursor", where: "~/.cursor/mcp.json (or .cursor/mcp.json in a project)", text: json2({ mcpServers: { databull: { url, headers: { Authorization: `Bearer ${key}` } } } }) },
|
|
3409
|
+
vscode: { title: "VS Code (Copilot agent mode)", where: '.vscode/mcp.json, or "MCP: Open User Configuration"', text: json2({ servers: { databull: { type: "http", url, headers: { Authorization: `Bearer ${key}` } } } }) },
|
|
3410
|
+
codex: { title: "Codex", where: "~/.codex/config.toml", text: `[mcp_servers.databull]
|
|
3411
|
+
url = "${url}"
|
|
3412
|
+
http_headers = { "Authorization" = "Bearer ${key}" }` },
|
|
3413
|
+
windsurf: { title: "Windsurf", where: "~/.codeium/windsurf/mcp_config.json", text: json2({ mcpServers: { databull: { serverUrl: url, headers: { Authorization: `Bearer ${key}` } } } }) },
|
|
3414
|
+
other: { title: "Any other MCP client", where: "Streamable HTTP", text: `URL ${url}
|
|
3415
|
+
Header ${header2}` }
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3418
|
+
function addToClaude(url, key) {
|
|
3419
|
+
const args = ["mcp", "add", "--transport", "http", "--scope", "user", "databull", url, "--header", `Authorization: Bearer ${key}`];
|
|
3420
|
+
let r2 = spawnSync(claudeBin(), args, { encoding: "utf8", timeout: 3e4 });
|
|
3421
|
+
if (r2.status !== 0 && /already exists/i.test(`${r2.stdout}${r2.stderr}`)) {
|
|
3422
|
+
spawnSync(claudeBin(), ["mcp", "remove", "databull", "--scope", "user"], { encoding: "utf8", timeout: 3e4 });
|
|
3423
|
+
r2 = spawnSync(claudeBin(), args, { encoding: "utf8", timeout: 3e4 });
|
|
3424
|
+
}
|
|
3425
|
+
return r2.status === 0 ? null : (r2.stderr || r2.stdout || "claude mcp add failed").trim().split("\n").pop();
|
|
3426
|
+
}
|
|
3427
|
+
async function runMcp(ctx, { pickInstall: pickInstall2, connect: connect2, runUpdate: runUpdate2, save: save2 }) {
|
|
3428
|
+
const client = ctx.flags.client?.toLowerCase();
|
|
3429
|
+
if (client && !CLIENTS.includes(client)) throw new Fail(`Unknown client "${ctx.flags.client}".`, { fix: `Use one of: ${CLIENTS.join(", ")}.` });
|
|
3430
|
+
let url;
|
|
3431
|
+
let key;
|
|
3432
|
+
let install = null;
|
|
3433
|
+
if (ctx.flags.url || process.env.DATABULL_URL) {
|
|
3434
|
+
url = String(ctx.flags.url || process.env.DATABULL_URL).replace(/\/+$/, "").replace(/\/mcp$/, "");
|
|
3435
|
+
key = ctx.flags.apiKey || process.env.DATABULL_API_KEY;
|
|
3436
|
+
if (!key) throw new Fail("With --url, pass the key too (--api-key or DATABULL_API_KEY).", { fix: "Create one in your dashboard under Settings \u2192 API keys." });
|
|
3437
|
+
} else {
|
|
3438
|
+
install = await pickInstall2(ctx, { verb: "connect AI agents to" });
|
|
3439
|
+
if (!install) throw new Fail("No DataBull install on this computer.", { fix: `Install it with \`${CMD}\`, or pass --url and --api-key (Settings \u2192 API keys in your dashboard).` });
|
|
3440
|
+
await ensureAgentVersion(ctx, install, runUpdate2);
|
|
3441
|
+
url = install.url;
|
|
3442
|
+
key = install.mcp_key;
|
|
3443
|
+
if (key) {
|
|
3444
|
+
try {
|
|
3445
|
+
await rpc({ url, key, install }, "ping");
|
|
3446
|
+
} catch {
|
|
3447
|
+
key = null;
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
if (!key) {
|
|
3451
|
+
const made = await createKeyOnInstall(ctx, install, { label: `AI agents on ${hostName()}`, pii: !!ctx.flags.pii, connect: connect2 });
|
|
3452
|
+
key = made.key;
|
|
3453
|
+
install = { ...install, mcp_key: key };
|
|
3454
|
+
upsertInstall(ctx.cfg, install);
|
|
3455
|
+
save2(ctx);
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
const tools = await withStep(null, null, "Checking the connection", async () => {
|
|
3459
|
+
const r2 = await rpc({ url, key, install }, "tools/list");
|
|
3460
|
+
return { value: r2.tools, label: `${r2.tools.length} tools ready`, detail: url.replace(/^https:\/\//, "") };
|
|
3461
|
+
}).then((r2) => r2.value);
|
|
3462
|
+
const mcpUrl = `${url}/mcp`;
|
|
3463
|
+
const all = snippets(mcpUrl, key);
|
|
3464
|
+
let addedToClaude = false;
|
|
3465
|
+
if ((!client || client === "claude") && hasClaude()) {
|
|
3466
|
+
const go = ctx.flags.yes || canPrompt() && await askConfirm({ message: "Claude Code is installed. Add DataBull to it now (for all your projects)?" });
|
|
3467
|
+
if (go) {
|
|
3468
|
+
const err = addToClaude(mcpUrl, key);
|
|
3469
|
+
if (err) warn(`Couldn't add it to Claude Code automatically: ${err}`, "Use the command below instead.");
|
|
3470
|
+
else {
|
|
3471
|
+
addedToClaude = true;
|
|
3472
|
+
success(`Added to Claude Code. Open Claude Code and run ${import_picocolors3.default.bold("/mcp")} to see "databull" connected.`);
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
const show = client ? [client] : CLIENTS.filter((c) => !(c === "claude" && addedToClaude));
|
|
3477
|
+
for (const c of show) {
|
|
3478
|
+
const s = all[c];
|
|
3479
|
+
box(`${s.title} ${import_picocolors3.default.dim(s.where)}`, s.text.split("\n"));
|
|
3480
|
+
}
|
|
3481
|
+
line();
|
|
3482
|
+
line(import_picocolors3.default.bold("Try asking your agent:"));
|
|
3483
|
+
for (const q2 of ["Which referrer made me the most money this month?", "Compare this week with last week. What changed?", "Show my last 5 sales and where each buyer came from.", "Walk me through the journey of my latest customer."]) line(` ${import_picocolors3.default.cyan(`\u201C${q2}\u201D`)}`);
|
|
3484
|
+
line();
|
|
3485
|
+
line(import_picocolors3.default.dim(`${tools.length} read-only tools: ${tools.map((t) => t.name).join(", ")}.`));
|
|
3486
|
+
end(`The key is read-only${install ? `; revoke it any time with \`${CMD} api-key revoke ${key.slice(0, 12)}\`` : ""}. Docs: https://databull.app/docs/mcp`);
|
|
3487
|
+
}
|
|
3488
|
+
|
|
2785
3489
|
// src/index.mjs
|
|
2786
|
-
var VERSION = true ? "1.
|
|
2787
|
-
var
|
|
3490
|
+
var VERSION = true ? "1.1.0" : "0.0.0-dev";
|
|
3491
|
+
var DEPS = { pickInstall, connect, runUpdate, save };
|
|
3492
|
+
var RUN = {
|
|
3493
|
+
install: runInstall,
|
|
3494
|
+
update: runUpdate,
|
|
3495
|
+
"reset-password": runReset,
|
|
3496
|
+
domain: runDomain,
|
|
3497
|
+
status: runStatus,
|
|
3498
|
+
license: runLicense,
|
|
3499
|
+
uninstall: runUninstall,
|
|
3500
|
+
mcp: (ctx) => runMcp(ctx, DEPS),
|
|
3501
|
+
"api-key": (ctx) => runApiKey(ctx, DEPS)
|
|
3502
|
+
};
|
|
2788
3503
|
var NEEDS_WRANGLER = /* @__PURE__ */ new Set(["install", "update", "reset-password", "domain", "uninstall"]);
|
|
2789
|
-
var
|
|
3504
|
+
var LAZY_WRANGLER = /* @__PURE__ */ new Set(["mcp", "api-key", ...DATA_COMMANDS]);
|
|
3505
|
+
var SUBTITLE = { install: "installer", update: "update", "reset-password": "reset password", domain: "custom domain", status: "status", license: "license", uninstall: "uninstall", mcp: "AI agents", "api-key": "API keys" };
|
|
2790
3506
|
function help() {
|
|
2791
3507
|
const c = import_picocolors3.default;
|
|
2792
|
-
const cmd = (name, text) => ` ${c.cyan(name.padEnd(
|
|
3508
|
+
const cmd = (name, text) => ` ${c.cyan(name.padEnd(22))}${text}`;
|
|
2793
3509
|
const flag = (name, text) => ` ${c.yellow(name.padEnd(28))}${text}`;
|
|
2794
3510
|
return [
|
|
2795
3511
|
"",
|
|
2796
3512
|
`${c.bgBlue(c.white(c.bold(" DataBull ")))} ${c.dim(`v${VERSION}`)}`,
|
|
2797
3513
|
"",
|
|
2798
|
-
"Install and update DataBull on your own Cloudflare account.",
|
|
3514
|
+
"Install and update DataBull on your own Cloudflare account, and read your stats from the terminal or an AI agent.",
|
|
2799
3515
|
"",
|
|
2800
3516
|
`${c.bold("Usage")} npx databull@latest ${c.dim("[command] [options]")}`,
|
|
2801
3517
|
"",
|
|
@@ -2809,6 +3525,23 @@ function help() {
|
|
|
2809
3525
|
cmd("uninstall", "Delete an install from Cloudflare"),
|
|
2810
3526
|
cmd("help", "Show this help"),
|
|
2811
3527
|
"",
|
|
3528
|
+
c.bold("AI agents & API"),
|
|
3529
|
+
cmd("mcp", "Connect Claude Code, Cursor, VS Code, Codex\u2026 to your stats"),
|
|
3530
|
+
cmd("api-key", "create | list | revoke read-only API keys"),
|
|
3531
|
+
"",
|
|
3532
|
+
c.bold("Your stats") + c.dim(" (add --json for scripts and agents)"),
|
|
3533
|
+
cmd("sites", "Every site: today, online now, 7 and 30 days"),
|
|
3534
|
+
cmd("stats [site]", "Visitors, revenue, conversion\u2026 vs the previous period"),
|
|
3535
|
+
cmd("sources [site]", "Referrers ranked by revenue (--dim channel|utm_campaign\u2026)"),
|
|
3536
|
+
cmd("breakdown <dim>", "Rank any dimension: country, page, device, entry_page\u2026"),
|
|
3537
|
+
cmd("compare [site]", "What changed vs the previous period, and why"),
|
|
3538
|
+
cmd("sales [site]", "Recent sales and the source each one came from"),
|
|
3539
|
+
cmd("visitors [site]", "Recent visitors (--customers for buyers only)"),
|
|
3540
|
+
cmd("visitor <id>", "One visitor's journey: visits, pages, payments"),
|
|
3541
|
+
cmd("live [site]", "Who's on the site right now"),
|
|
3542
|
+
cmd("goals | funnels | ltv", "Goals, funnel steps, lifetime value by source"),
|
|
3543
|
+
cmd("query <tool> [json]", "Call any agent tool and print its JSON"),
|
|
3544
|
+
"",
|
|
2812
3545
|
c.bold("Options"),
|
|
2813
3546
|
flag("--key <key>", "License key (otherwise you are asked)"),
|
|
2814
3547
|
flag("--account <id|name>", "Cloudflare account to use"),
|
|
@@ -2818,6 +3551,15 @@ function help() {
|
|
|
2818
3551
|
flag("--no-open", "Don't open the browser; print links instead"),
|
|
2819
3552
|
flag("--deactivate", "With license: free this activation"),
|
|
2820
3553
|
flag("--delete-database", "With uninstall: also delete the database"),
|
|
3554
|
+
flag("--period, -p <range>", "today, yesterday, 24h, 7d, 30d, mtd, last_month, ytd, 12m, all"),
|
|
3555
|
+
flag("--from / --to <YYYY-MM-DD>", "Custom date range (in the site's time zone)"),
|
|
3556
|
+
flag("--filter, -f <key=value>", "Repeatable: referrer=x.com, country=US, page=/pricing\u2026"),
|
|
3557
|
+
flag("--by <metric>", "Sort: revenue, visitors, conversion (sources, breakdown, visitors)"),
|
|
3558
|
+
flag("--limit, -n <n>", "How many rows"),
|
|
3559
|
+
flag("--json", "Print the raw JSON (for scripts and agents)"),
|
|
3560
|
+
flag("--url, --api-key", "Read another install (or set DATABULL_URL, DATABULL_API_KEY)"),
|
|
3561
|
+
flag("--client <name>", "With mcp: claude, cursor, vscode, codex, windsurf, other"),
|
|
3562
|
+
flag("--label <text>, --pii", "With api-key create: a name; allow customer details"),
|
|
2821
3563
|
flag("--bundle <file.tgz>", "Use a local release bundle instead of downloading"),
|
|
2822
3564
|
flag("--api <url>", "License server (default https://databull.app, env DATABULL_API)"),
|
|
2823
3565
|
flag("--home <dir>", "Where DataBull keeps its files (default ~/.databull, env DATABULL_HOME)"),
|
|
@@ -2853,9 +3595,15 @@ Install the LTS version from https://nodejs.org (or \`brew install node\` on a M
|
|
|
2853
3595
|
}
|
|
2854
3596
|
const paths = resolvePaths(args.flags.home);
|
|
2855
3597
|
const log = createLog(paths.logs);
|
|
2856
|
-
const ctx = { version: VERSION, flags: args.flags, paths, log, api: apiBase(args.flags), cfg: null };
|
|
2857
|
-
|
|
3598
|
+
const ctx = { version: VERSION, command: args.command, args: args.args, flags: args.flags, paths, log, api: apiBase(args.flags), cfg: null };
|
|
3599
|
+
if (NEEDS_WRANGLER.has(args.command)) ctx.wrangler = startTools(paths, log);
|
|
3600
|
+
else if (LAZY_WRANGLER.has(args.command)) {
|
|
3601
|
+
let tools = null;
|
|
3602
|
+
ctx.wrangler = () => (tools ||= startTools(paths, log))();
|
|
3603
|
+
Object.defineProperty(ctx.wrangler, "ready", { get: () => !!tools?.ready });
|
|
3604
|
+
} else ctx.wrangler = () => Promise.reject(new Error("wrangler not available for this command"));
|
|
2858
3605
|
ctx.cfToken = tokenGetter(ctx);
|
|
3606
|
+
if (DATA_COMMANDS.includes(args.command)) return runDataCommand(ctx);
|
|
2859
3607
|
const onSigint = () => {
|
|
2860
3608
|
killChildren();
|
|
2861
3609
|
cleanupTemp();
|
|
@@ -2884,6 +3632,37 @@ Install the LTS version from https://nodejs.org (or \`brew install node\` on a M
|
|
|
2884
3632
|
process.off("SIGTERM", onSigint);
|
|
2885
3633
|
}
|
|
2886
3634
|
}
|
|
3635
|
+
async function runDataCommand(ctx) {
|
|
3636
|
+
process.stdout.on("error", (err) => {
|
|
3637
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
3638
|
+
throw err;
|
|
3639
|
+
});
|
|
3640
|
+
const onSigint = () => {
|
|
3641
|
+
killChildren();
|
|
3642
|
+
process.exit(130);
|
|
3643
|
+
};
|
|
3644
|
+
process.on("SIGINT", onSigint);
|
|
3645
|
+
try {
|
|
3646
|
+
ctx.cfg = readConfig(ctx.paths);
|
|
3647
|
+
await runData(ctx, DEPS);
|
|
3648
|
+
return 0;
|
|
3649
|
+
} catch (err) {
|
|
3650
|
+
stopSpinner();
|
|
3651
|
+
if (err instanceof Cancelled) return 130;
|
|
3652
|
+
ctx.log.section("failure");
|
|
3653
|
+
ctx.log.write(err?.stack || String(err));
|
|
3654
|
+
const message = err instanceof Fail ? err.message : `Something unexpected went wrong: ${err?.message || err}`;
|
|
3655
|
+
const fix = err instanceof Fail ? err.fix : `Run it again; if it keeps happening, email adgan.business@gmail.com with ${ctx.log.path}.`;
|
|
3656
|
+
if (ctx.flags.json) process.stdout.write(`${JSON.stringify({ error: message, ...fix ? { fix } : {} })}
|
|
3657
|
+
`);
|
|
3658
|
+
process.stderr.write(`${import_picocolors3.default.red("\u2716")} ${message}
|
|
3659
|
+
${fix ? ` ${import_picocolors3.default.dim(fix)}
|
|
3660
|
+
` : ""}`);
|
|
3661
|
+
return 1;
|
|
3662
|
+
} finally {
|
|
3663
|
+
process.off("SIGINT", onSigint);
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
2887
3666
|
|
|
2888
3667
|
// src/cli.mjs
|
|
2889
3668
|
main(process.argv.slice(2)).then(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "databull",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Install and update DataBull, revenue-first web analytics, on your own Cloudflare account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "databull": "dist/databull.mjs" },
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"author": "DataBull <adgan.business@gmail.com>",
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "node scripts/build.mjs",
|
|
15
|
-
"test": "node scripts/build.mjs && node --test test/unit.test.mjs test/install.test.mjs test/safety.test.mjs",
|
|
15
|
+
"test": "node scripts/build.mjs && node --test test/unit.test.mjs test/install.test.mjs test/safety.test.mjs test/agent.test.mjs",
|
|
16
16
|
"prepublishOnly": "npm test"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|