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.
Files changed (2) hide show
  1. package/dist/databull.mjs +831 -52
  2. 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 COMMANDS = ["install", "update", "reset-password", "domain", "status", "license", "uninstall", "help"];
176
- var COMMAND_ALIASES = { license: "license", "reset": "reset-password", upgrade: "update", remove: "uninstall" };
177
- var VALUE_FLAGS = ["key", "account", "name", "domain", "api", "bundle", "home"];
178
- var BOOL_FLAGS = ["yes", "open", "help", "version", "deactivate", "delete-database", "debug"];
179
- var SHORT = { y: "yes", h: "help", v: "version" };
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 out2 = { command: null, flags: { open: true } };
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
- out2.flags[name.slice(3)] = false;
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
- out2.flags[camel(name)] = true;
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
- out2.flags[name] = value;
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
- for (const ch of arg.slice(1)) {
210
- if (!SHORT[ch]) throw new Fail(`Unknown option -${ch}.`, { fix: "Run `npx databull@latest help` to see every option." });
211
- out2.flags[SHORT[ch]] = true;
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 (out2.command) throw new Fail(`Unexpected "${arg}".`, { fix: "Run `npx databull@latest help` to see every command." });
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
- out2.command = cmd;
236
+ out3.command = cmd;
219
237
  }
220
- if (out2.flags.help) out2.command = "help";
221
- else if (out2.flags.version) out2.command = "version";
222
- else if (!out2.command) out2.command = "install";
223
- return out2;
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 { key: "XXXX-XXXX", account: "<account id>", name: "databull", domain: "analytics.example.com", api: "https://databull.app", bundle: "./databull-1.0.0.tgz", home: "~/.databull" }[name];
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 strip = (s) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
1160
- const width = Math.max(strip(title).length + 2, ...lines.map((l2) => strip(l2).length)) + 2;
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 - strip(title).length - 3)))}${color("\u256E")}
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 - strip(l2).length - 1))}${color("\u2502")}
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 out2 = [];
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
- out2.push(...json2.result || []);
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 out2;
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(header) {
1562
- const stored = octal(header, 148, 8);
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 : header[i];
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 out2 = {};
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) out2[record.slice(0, eq)] = record.slice(eq + 1);
1639
+ if (eq > 0) out3[record.slice(0, eq)] = record.slice(eq + 1);
1590
1640
  i += len;
1591
1641
  }
1592
- return out2;
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 header = buf.subarray(off, off + BLOCK);
1602
- if (header.every((b3) => b3 === 0)) break;
1603
- if (!checksumOk(header)) throw new TarError("corrupt tar header (bad checksum)");
1604
- const size = octal(header, 124, 12);
1605
- const type = String.fromCharCode(header[156] || 48);
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(header, 0, 100);
1620
- if (str(header, 257, 5) === "ustar") {
1621
- const prefix = str(header, 345, 155);
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 out2 = [];
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
- out2.push({
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 out2;
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.0.0" : "0.0.0-dev";
2787
- var RUN = { install: runInstall, update: runUpdate, "reset-password": runReset, domain: runDomain, status: runStatus, license: runLicense, uninstall: runUninstall };
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 SUBTITLE = { install: "installer", update: "update", "reset-password": "reset password", domain: "custom domain", status: "status", license: "license", uninstall: "uninstall" };
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(16))}${text}`;
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
- ctx.wrangler = NEEDS_WRANGLER.has(args.command) ? startTools(paths, log) : () => Promise.reject(new Error("wrangler not available for this command"));
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.0.0",
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": {