@glassanalytics/cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/dist/glass.js +1222 -0
- package/dist/worker/migrations/0001_init.sql +134 -0
- package/dist/worker/worker.js +23309 -0
- package/package.json +39 -4
package/dist/glass.js
ADDED
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// packages/cli/src/index.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { GLASS_VERSION as GLASS_VERSION3 } from "@glassanalytics/core/version";
|
|
6
|
+
|
|
7
|
+
// packages/cli/src/config.ts
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
var GLASS_DIR = ".glass";
|
|
11
|
+
function projectRoot(start = process.cwd()) {
|
|
12
|
+
let dir = resolve(start);
|
|
13
|
+
let pkg = null;
|
|
14
|
+
for (; ; ) {
|
|
15
|
+
if (existsSync(join(dir, GLASS_DIR, "config.json"))) return dir;
|
|
16
|
+
if (!pkg && existsSync(join(dir, "package.json"))) pkg = dir;
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir) return pkg ?? resolve(start);
|
|
19
|
+
dir = parent;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function glassDir(root = projectRoot()) {
|
|
23
|
+
return join(root, GLASS_DIR);
|
|
24
|
+
}
|
|
25
|
+
function readJson(path) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function writeJson(path, value) {
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
34
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
function loadConfig(root = projectRoot()) {
|
|
38
|
+
return readJson(join(glassDir(root), "config.json"));
|
|
39
|
+
}
|
|
40
|
+
function saveConfig(config, root = projectRoot()) {
|
|
41
|
+
writeJson(join(glassDir(root), "config.json"), config);
|
|
42
|
+
}
|
|
43
|
+
function loadCredentials(root = projectRoot()) {
|
|
44
|
+
return readJson(join(glassDir(root), "credentials.json"));
|
|
45
|
+
}
|
|
46
|
+
function saveCredentials(credentials, root = projectRoot()) {
|
|
47
|
+
const dir = glassDir(root);
|
|
48
|
+
writeJson(join(dir, "credentials.json"), credentials);
|
|
49
|
+
writeFileSync(join(dir, ".gitignore"), "credentials.json\n");
|
|
50
|
+
}
|
|
51
|
+
function connection() {
|
|
52
|
+
const config = loadConfig();
|
|
53
|
+
const credentials = loadCredentials();
|
|
54
|
+
const host = process.env.GLASS_HOST ?? config?.host;
|
|
55
|
+
const key2 = process.env.GLASS_API_KEY ?? credentials?.adminKey;
|
|
56
|
+
if (!host || !key2) {
|
|
57
|
+
throw new UsageError(
|
|
58
|
+
"No Glass deployment found here.",
|
|
59
|
+
"Run `npx @glassanalytics/cli init` in your project, or set GLASS_HOST and GLASS_API_KEY."
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return { host: host.replace(/\/+$/, ""), key: key2, project: process.env.GLASS_PROJECT ?? config?.project };
|
|
63
|
+
}
|
|
64
|
+
var UsageError = class extends Error {
|
|
65
|
+
hint;
|
|
66
|
+
constructor(message, hint) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "UsageError";
|
|
69
|
+
this.hint = hint;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// packages/cli/src/client.ts
|
|
74
|
+
async function call(method, path, body, options = {}) {
|
|
75
|
+
const conn = options.host && options.key ? { host: options.host, key: options.key } : connection();
|
|
76
|
+
const res = await (options.fetcher ?? fetch)(`${conn.host}${path}`, {
|
|
77
|
+
method,
|
|
78
|
+
headers: { Authorization: `Bearer ${conn.key}`, ...body === void 0 ? {} : { "Content-Type": "application/json" } },
|
|
79
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
80
|
+
}).catch((err) => {
|
|
81
|
+
throw new UsageError(`Could not reach ${conn.host}: ${err instanceof Error ? err.message : String(err)}`, "Check your network, or run `glass status`.");
|
|
82
|
+
});
|
|
83
|
+
const data = await res.json().catch(() => ({}));
|
|
84
|
+
if (!res.ok) throw new UsageError(data.error ?? `HTTP ${res.status}`, data.hint);
|
|
85
|
+
return data;
|
|
86
|
+
}
|
|
87
|
+
function withProject(value) {
|
|
88
|
+
const project = value.project ?? connection().project;
|
|
89
|
+
return project ? { ...value, project } : value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// packages/cli/src/output.ts
|
|
93
|
+
var useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
94
|
+
var wrap = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
|
|
95
|
+
var color = {
|
|
96
|
+
bold: wrap(1),
|
|
97
|
+
dim: wrap(2),
|
|
98
|
+
green: wrap(32),
|
|
99
|
+
red: wrap(31),
|
|
100
|
+
yellow: wrap(33),
|
|
101
|
+
cyan: wrap(36)
|
|
102
|
+
};
|
|
103
|
+
function line(text = "") {
|
|
104
|
+
process.stdout.write(`${text}
|
|
105
|
+
`);
|
|
106
|
+
}
|
|
107
|
+
function ok(text) {
|
|
108
|
+
line(`${color.green("\u2713")} ${text}`);
|
|
109
|
+
}
|
|
110
|
+
function skip(text) {
|
|
111
|
+
line(`${color.yellow("\u2013")} ${text}`);
|
|
112
|
+
}
|
|
113
|
+
function fail(text) {
|
|
114
|
+
process.stderr.write(`${color.red("\u2717")} ${text}
|
|
115
|
+
`);
|
|
116
|
+
}
|
|
117
|
+
function printJson(value) {
|
|
118
|
+
line(JSON.stringify(value, null, 2));
|
|
119
|
+
}
|
|
120
|
+
function table(headers, rows) {
|
|
121
|
+
const cells = rows.map((r) => r.map((c) => String(c)));
|
|
122
|
+
const widths = headers.map((h, i) => Math.min(60, Math.max(h.length, ...cells.map((r) => (r[i] ?? "").length))));
|
|
123
|
+
const numeric = headers.map((_, i) => rows.length > 0 && rows.every((r) => typeof r[i] === "number"));
|
|
124
|
+
const fmt = (values) => values.map((v, i) => {
|
|
125
|
+
const text = v.length > widths[i] ? `${v.slice(0, widths[i] - 1)}\u2026` : v;
|
|
126
|
+
return numeric[i] ? text.padStart(widths[i]) : text.padEnd(widths[i]);
|
|
127
|
+
}).join(" ").trimEnd();
|
|
128
|
+
line(color.dim(fmt(headers)));
|
|
129
|
+
for (const r of cells) line(fmt(r));
|
|
130
|
+
}
|
|
131
|
+
function pct(value) {
|
|
132
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// packages/cli/src/commands/data.ts
|
|
136
|
+
var str = (v) => typeof v === "string" ? v : void 0;
|
|
137
|
+
function showSql(sql2, flags2) {
|
|
138
|
+
if (!flags2.sql || !sql2) return;
|
|
139
|
+
line();
|
|
140
|
+
for (const q of sql2) line(color.dim(q));
|
|
141
|
+
}
|
|
142
|
+
function specFromFlags(kind, flags2) {
|
|
143
|
+
if (typeof flags2.spec === "string") {
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(flags2.spec);
|
|
146
|
+
} catch {
|
|
147
|
+
throw new UsageError("--spec is not valid JSON.");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const common = { from: str(flags2.from), to: str(flags2.to), breakdown: str(flags2.breakdown) };
|
|
151
|
+
const filters = typeof flags2.where === "string" ? flags2.where.split(",").map((w) => {
|
|
152
|
+
const [property, value] = w.split("=");
|
|
153
|
+
if (!property || value === void 0) throw new UsageError(`--where expects property=value pairs, got "${w}".`);
|
|
154
|
+
return { property: property.trim(), value: value.trim() };
|
|
155
|
+
}) : void 0;
|
|
156
|
+
switch (kind) {
|
|
157
|
+
case "web":
|
|
158
|
+
return { from: common.from, to: common.to, filters };
|
|
159
|
+
case "trends": {
|
|
160
|
+
const events2 = str(flags2.event)?.split(",") ?? [];
|
|
161
|
+
if (!events2.length) throw new UsageError("`glass query trends` needs --event <name>[,<name>\u2026]");
|
|
162
|
+
return { ...common, filters, interval: str(flags2.interval), series: events2.map((event) => ({ event, math: str(flags2.math), property: str(flags2.property) })) };
|
|
163
|
+
}
|
|
164
|
+
case "funnel": {
|
|
165
|
+
const steps = str(flags2.steps)?.split(",") ?? [];
|
|
166
|
+
if (steps.length < 2) throw new UsageError("`glass query funnel` needs --steps a,b[,c\u2026]");
|
|
167
|
+
return { ...common, filters, window: str(flags2.window), steps: steps.map((event) => ({ event: event.trim() })) };
|
|
168
|
+
}
|
|
169
|
+
case "retention": {
|
|
170
|
+
const cohort = str(flags2.cohort);
|
|
171
|
+
if (!cohort) throw new UsageError("`glass query retention` needs --cohort <event> (and optionally --return <event>)");
|
|
172
|
+
return {
|
|
173
|
+
from: common.from,
|
|
174
|
+
to: common.to,
|
|
175
|
+
filters,
|
|
176
|
+
cohortEvent: { event: cohort },
|
|
177
|
+
returnEvent: { event: str(flags2.return) ?? cohort },
|
|
178
|
+
period: str(flags2.period),
|
|
179
|
+
periods: flags2.periods ? Number(flags2.periods) : void 0
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
default:
|
|
183
|
+
throw new UsageError(`Unknown query "${kind}".`, "Use web, trends, funnel or retention (or `glass sql`).");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function query(kind, flags2) {
|
|
187
|
+
if (!kind) throw new UsageError("Which query?", "glass query web|trends|funnel|retention [options]; see `glass help query`.");
|
|
188
|
+
const result = await call("POST", "/api/query", withProject({ kind, ...specFromFlags(kind, flags2), project: str(flags2.project) }));
|
|
189
|
+
if (flags2.json) return printJson(result);
|
|
190
|
+
line(color.bold(result.summary));
|
|
191
|
+
line();
|
|
192
|
+
if (kind === "web") {
|
|
193
|
+
const r = result;
|
|
194
|
+
table(["Metric", "Value", "Previous"], [
|
|
195
|
+
["Visitors", r.totals.visitors, r.previous?.visitors ?? 0],
|
|
196
|
+
["Pageviews", r.totals.pageviews, r.previous?.pageviews ?? 0],
|
|
197
|
+
["Sessions", r.totals.sessions, r.previous?.sessions ?? 0],
|
|
198
|
+
["Bounce rate", pct(r.totals.bounceRate), r.previous ? pct(r.previous.bounceRate) : "\u2013"]
|
|
199
|
+
]);
|
|
200
|
+
line();
|
|
201
|
+
table(["Top page", "Visitors"], r.breakdowns.pages.slice(0, 8).map((p) => [p.value, p.visitors]));
|
|
202
|
+
line();
|
|
203
|
+
table(["Referrer", "Visitors"], r.breakdowns.referrers.slice(0, 8).map((p) => [p.value, p.visitors]));
|
|
204
|
+
} else if (kind === "trends") {
|
|
205
|
+
const r = result;
|
|
206
|
+
table(["Series", "Total"], r.series.map((s) => [s.label, s.total]));
|
|
207
|
+
} else if (kind === "funnel") {
|
|
208
|
+
const r = result;
|
|
209
|
+
table(["Step", "People", "From start", "From previous"], r.steps.map((s) => [s.label, s.count, pct(s.conversionFromStart), pct(s.conversionFromPrevious)]));
|
|
210
|
+
} else if (kind === "retention") {
|
|
211
|
+
const r = result;
|
|
212
|
+
table(
|
|
213
|
+
["Cohort", "People", ...r.average.map((_, k) => `${r.period[0]}${k}`)],
|
|
214
|
+
r.cohorts.map((c) => [new Date(c.start).toISOString().slice(0, 10), c.size, ...c.rates.map((x) => `${Math.round(x * 100)}%`)])
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
line();
|
|
218
|
+
line(color.dim(`Open in dashboard: ${result.dashboard}`));
|
|
219
|
+
showSql(result.sql, flags2);
|
|
220
|
+
}
|
|
221
|
+
async function sql(statement, flags2) {
|
|
222
|
+
if (!statement) throw new UsageError('Pass a query: glass sql "SELECT event, COUNT(*) AS n FROM events GROUP BY event"');
|
|
223
|
+
const r = await call("POST", "/api/sql", withProject({ query: statement, limit: flags2.limit ? Number(flags2.limit) : void 0, project: str(flags2.project) }));
|
|
224
|
+
if (flags2.json) return printJson(r);
|
|
225
|
+
table(r.columns, r.rows.map((row) => r.columns.map((c) => typeof row[c] === "number" ? row[c] : String(row[c] ?? ""))));
|
|
226
|
+
line(color.dim(r.summary));
|
|
227
|
+
}
|
|
228
|
+
async function events(name, flags2) {
|
|
229
|
+
const project = str(flags2.project);
|
|
230
|
+
if (name && typeof flags2.describe === "string") {
|
|
231
|
+
const r2 = await call("PUT", `/api/events/${encodeURIComponent(name)}`, withProject({ description: flags2.describe, project }));
|
|
232
|
+
return flags2.json ? printJson(r2) : line(`Saved the description of "${name}".`);
|
|
233
|
+
}
|
|
234
|
+
if (name) {
|
|
235
|
+
const q = project ? `?project=${encodeURIComponent(project)}` : "";
|
|
236
|
+
const r2 = await call("GET", `/api/events/${encodeURIComponent(name)}${q}`);
|
|
237
|
+
if (flags2.json) return printJson(r2);
|
|
238
|
+
line(color.bold(r2.summary));
|
|
239
|
+
if (r2.description) line(r2.description);
|
|
240
|
+
line();
|
|
241
|
+
if (r2.properties.length) table(["Property", "Type", "Coverage", "Examples"], r2.properties.map((p) => [p.name, p.types.join("|"), pct(p.coverage), p.examples.join(", ")]));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const r = await call(
|
|
245
|
+
"GET",
|
|
246
|
+
`/api/events?days=${Number(flags2.days ?? 30)}${project ? `&project=${encodeURIComponent(project)}` : ""}`
|
|
247
|
+
);
|
|
248
|
+
if (flags2.json) return printJson(r);
|
|
249
|
+
line(color.bold(r.summary));
|
|
250
|
+
line();
|
|
251
|
+
table(["Event", "Count", "Last seen", "Description"], r.events.map((e) => [e.event, e.count, e.lastSeen.slice(0, 16).replace("T", " "), e.description]));
|
|
252
|
+
}
|
|
253
|
+
async function person(id, flags2) {
|
|
254
|
+
if (!id) throw new UsageError("Pass a person id: glass person <user-id>");
|
|
255
|
+
const q = str(flags2.project) ? `?project=${encodeURIComponent(str(flags2.project))}` : "";
|
|
256
|
+
if (flags2.delete) {
|
|
257
|
+
const r2 = await call("DELETE", `/api/persons/${encodeURIComponent(id)}${q}`);
|
|
258
|
+
return flags2.json ? printJson(r2) : line(r2.summary);
|
|
259
|
+
}
|
|
260
|
+
const r = await call("GET", `/api/persons/${encodeURIComponent(id)}${q}`);
|
|
261
|
+
if (flags2.json) return printJson(r);
|
|
262
|
+
line(color.bold(r.summary));
|
|
263
|
+
for (const [k, v] of Object.entries(r.properties)) line(` ${k}: ${JSON.stringify(v)}`);
|
|
264
|
+
line();
|
|
265
|
+
table(["Time", "Event", "Page"], r.recentEvents.slice(0, 25).map((e) => [e.time.slice(0, 19).replace("T", " "), e.event, e.path]));
|
|
266
|
+
}
|
|
267
|
+
async function replays(sub, arg, flags2) {
|
|
268
|
+
const project = str(flags2.project);
|
|
269
|
+
if (sub === "summary" || sub === "show") {
|
|
270
|
+
if (!arg) throw new UsageError("Pass a session id: glass replays summary <session>");
|
|
271
|
+
const r2 = await call("GET", `/api/replays/${encodeURIComponent(arg)}/summary${project ? `?project=${project}` : ""}`);
|
|
272
|
+
return flags2.json ? printJson(r2) : line(r2.transcript);
|
|
273
|
+
}
|
|
274
|
+
const params = new URLSearchParams();
|
|
275
|
+
if (project) params.set("project", project);
|
|
276
|
+
if (flags2.errors) params.set("with_errors", "true");
|
|
277
|
+
if (typeof flags2.person === "string") params.set("person", flags2.person);
|
|
278
|
+
const r = await call("GET", `/api/replays?${params}`);
|
|
279
|
+
if (flags2.json) return printJson(r);
|
|
280
|
+
line(color.bold(r.summary));
|
|
281
|
+
line();
|
|
282
|
+
table(["Session", "Started", "Person", "Seconds", "Errors", "First page"], r.replays.map((x) => [x.session, x.started.slice(0, 16).replace("T", " "), x.person, x.durationSeconds, x.errors, x.url]));
|
|
283
|
+
}
|
|
284
|
+
async function projects(sub, arg, flags2) {
|
|
285
|
+
if (sub === "add") {
|
|
286
|
+
if (!arg) throw new UsageError("Pass an id: glass projects add <id>");
|
|
287
|
+
const r2 = await call("POST", "/api/projects", { id: arg, name: str(flags2.name) });
|
|
288
|
+
return flags2.json ? printJson(r2) : line(r2.summary);
|
|
289
|
+
}
|
|
290
|
+
const r = await call("GET", "/api/projects");
|
|
291
|
+
if (flags2.json) return printJson(r);
|
|
292
|
+
table(["Project", "Name"], r.projects.map((p) => [p.id, p.name]));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// packages/cli/src/commands/flags.ts
|
|
296
|
+
var str2 = (v) => typeof v === "string" ? v : void 0;
|
|
297
|
+
var projectQuery = (flags2) => str2(flags2.project) ? `?project=${encodeURIComponent(str2(flags2.project))}` : "";
|
|
298
|
+
async function flags(sub, key2, flags2) {
|
|
299
|
+
if (!sub || sub === "list") {
|
|
300
|
+
const r = await call("GET", `/api/flags${projectQuery(flags2)}`);
|
|
301
|
+
if (flags2.json) return printJson(r);
|
|
302
|
+
if (!r.flags.length) return line("No flags yet. Create one: glass flags set new-checkout --rollout 10");
|
|
303
|
+
table(["Flag", "On", "Rollout", "Variants"], r.flags.map((f) => [f.key, f.enabled ? "yes" : "no", `${f.rollout}%`, (f.variants ?? []).map((v) => v.key).join(", ")]));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!key2) throw new UsageError(`Pass a flag key: glass flags ${sub} <key>`);
|
|
307
|
+
if (sub === "delete") {
|
|
308
|
+
const r = await call("DELETE", `/api/flags/${encodeURIComponent(key2)}${projectQuery(flags2)}`);
|
|
309
|
+
return flags2.json ? printJson(r) : line(r.summary);
|
|
310
|
+
}
|
|
311
|
+
if (sub === "set") {
|
|
312
|
+
const body = { project: str2(flags2.project) };
|
|
313
|
+
if (flags2.rollout !== void 0) body.rollout = Number(flags2.rollout);
|
|
314
|
+
if (flags2.enable) body.enabled = true;
|
|
315
|
+
if (flags2.disable) body.enabled = false;
|
|
316
|
+
if (typeof flags2.description === "string") body.description = flags2.description;
|
|
317
|
+
if (typeof flags2.variants === "string") {
|
|
318
|
+
body.variants = flags2.variants.split(",").map((v) => {
|
|
319
|
+
const [k, w] = v.split(":");
|
|
320
|
+
return { key: k.trim(), weight: Number(w ?? 1) };
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (typeof flags2.rules === "string") body.rules = JSON.parse(flags2.rules);
|
|
324
|
+
const r = await call("PUT", `/api/flags/${encodeURIComponent(key2)}`, withProject(body));
|
|
325
|
+
return flags2.json ? printJson(r) : line(r.summary);
|
|
326
|
+
}
|
|
327
|
+
throw new UsageError(`Unknown subcommand "flags ${sub}".`, "Use: glass flags [list | set <key> | delete <key>]");
|
|
328
|
+
}
|
|
329
|
+
async function experiments(sub, key2, rest, flags2) {
|
|
330
|
+
if (!sub || sub === "list") {
|
|
331
|
+
const r = await call("GET", `/api/experiments${projectQuery(flags2)}`);
|
|
332
|
+
if (flags2.json) return printJson(r);
|
|
333
|
+
if (!r.experiments.length) return line("No experiments yet. Create one: glass experiments create pricing-v2 --goal signup_completed");
|
|
334
|
+
table(["Experiment", "Status", "Goal"], r.experiments.map((e) => [e.key, e.status, e.goal.event]));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (!key2) throw new UsageError(`Pass an experiment key: glass experiments ${sub} <key>`);
|
|
338
|
+
if (sub === "create") {
|
|
339
|
+
const goal = str2(flags2.goal);
|
|
340
|
+
if (!goal) throw new UsageError("Pass the goal event: --goal signup_completed");
|
|
341
|
+
const r = await call(
|
|
342
|
+
"POST",
|
|
343
|
+
"/api/experiments",
|
|
344
|
+
withProject({ key: key2, goal: { event: goal, value: str2(flags2.value) }, flag: str2(flags2.flag), hypothesis: str2(flags2.hypothesis), project: str2(flags2.project) })
|
|
345
|
+
);
|
|
346
|
+
return flags2.json ? printJson(r) : line(r.summary);
|
|
347
|
+
}
|
|
348
|
+
if (sub === "start" || sub === "stop" || sub === "ship") {
|
|
349
|
+
const variant = rest[0] ?? str2(flags2.variant);
|
|
350
|
+
if (sub === "ship" && !variant) throw new UsageError("Pass the variant to ship: glass experiments ship <key> <variant>");
|
|
351
|
+
const r = await call("POST", `/api/experiments/${encodeURIComponent(key2)}/${sub}`, withProject({ variant, project: str2(flags2.project) }));
|
|
352
|
+
return flags2.json ? printJson(r) : line(r.summary);
|
|
353
|
+
}
|
|
354
|
+
if (sub === "results" || sub === "show") {
|
|
355
|
+
const r = await call("GET", `/api/experiments/${encodeURIComponent(key2)}${projectQuery(flags2)}`);
|
|
356
|
+
if (flags2.json) return printJson(r);
|
|
357
|
+
line(color.bold(r.summary));
|
|
358
|
+
line();
|
|
359
|
+
table(
|
|
360
|
+
["Variant", "Exposed", "Converted", "Rate", "P(beat control)"],
|
|
361
|
+
r.results.variants.map((v) => [v.variant, v.exposures, v.conversions, pct(v.conversionRate), v.vsControl ? pct(v.vsControl.probabilityToBeatControl) : "control"])
|
|
362
|
+
);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
throw new UsageError(`Unknown subcommand "experiments ${sub}".`, "Use: glass experiments [list | create | start | stop | ship | results]");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// packages/cli/src/commands/init.ts
|
|
369
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync5 } from "node:fs";
|
|
370
|
+
import { join as join5 } from "node:path";
|
|
371
|
+
import { randomToken } from "@glassanalytics/core/ids";
|
|
372
|
+
import { FRAMEWORKS, detectFramework } from "@glassanalytics/core/instrument";
|
|
373
|
+
import { GLASS_VERSION as GLASS_VERSION2 } from "@glassanalytics/core/version";
|
|
374
|
+
|
|
375
|
+
// packages/cli/src/agent-files.ts
|
|
376
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
377
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
378
|
+
import { EVENT_NAMING_GUIDE } from "@glassanalytics/core/instrument";
|
|
379
|
+
var START = "<!-- glass:start -->";
|
|
380
|
+
var END = "<!-- glass:end -->";
|
|
381
|
+
function agentsSection(o) {
|
|
382
|
+
return `${START}
|
|
383
|
+
## Analytics (Glass)
|
|
384
|
+
|
|
385
|
+
This project uses Glass for analytics, session replay, feature flags and A/B tests. It runs in our own Cloudflare account; deployment config is in \`.glass/\`.
|
|
386
|
+
|
|
387
|
+
- Dashboard: ${o.host}/app (print the key with \`npx @glassanalytics/cli key\`)
|
|
388
|
+
- MCP server \`glass\` is configured in \`.mcp.json\`. Start with the \`overview\` tool, then \`list_events\` for exact event names.
|
|
389
|
+
- Terminal: \`npx @glassanalytics/cli query web --from -7d\`, \`npx @glassanalytics/cli sql "SELECT \u2026"\`, \`npx @glassanalytics/cli --help\`
|
|
390
|
+
|
|
391
|
+
Tracking (project \`${o.project}\`):
|
|
392
|
+
- Install (${o.snippet.file}): \`${o.snippet.code.split("\n").pop()}\`
|
|
393
|
+
- Custom events: \`glass.capture('signup_completed', { plan: 'pro' })\`. After login: \`glass.identify(user.id, { plan })\`.
|
|
394
|
+
- ${EVENT_NAMING_GUIDE}
|
|
395
|
+
- After adding an event and deploying, confirm it arrives: \`npx @glassanalytics/cli events <name>\`. Document it: \`npx @glassanalytics/cli events <name> --describe "\u2026"\`.
|
|
396
|
+
- Feature flags: \`glass.flag('key')\` returns "on", a variant key, or null. Create with \`npx @glassanalytics/cli flags set <key> --rollout 10\`.
|
|
397
|
+
${END}`;
|
|
398
|
+
}
|
|
399
|
+
function upsertSection(path, section, createIfMissing) {
|
|
400
|
+
const exists = existsSync2(path);
|
|
401
|
+
if (!exists && !createIfMissing) return false;
|
|
402
|
+
const current = exists ? readFileSync2(path, "utf8") : "";
|
|
403
|
+
const pattern = new RegExp(`${START}[\\s\\S]*?${END}`);
|
|
404
|
+
const next = pattern.test(current) ? current.replace(pattern, section) : `${current.trimEnd()}${current.trim() ? "\n\n" : ""}${section}
|
|
405
|
+
`;
|
|
406
|
+
writeFileSync2(path, next);
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
function skillFile(o) {
|
|
410
|
+
return `---
|
|
411
|
+
name: glass
|
|
412
|
+
description: Use when adding analytics events, answering questions about traffic, signups, conversion, retention or errors, debugging what a user did in a session, or creating feature flags and A/B tests in this project.
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
# Glass analytics
|
|
416
|
+
|
|
417
|
+
Glass is deployed at ${o.host} (project \`${o.project}\`). Prefer the \`glass\` MCP tools; the CLI (\`npx @glassanalytics/cli \u2026\`) does the same things.
|
|
418
|
+
|
|
419
|
+
## Answer a question about usage
|
|
420
|
+
|
|
421
|
+
1. \`overview\` for the period (visitors, top pages, errors, events that moved).
|
|
422
|
+
2. \`list_events\` to get exact event names \u2014 never guess them.
|
|
423
|
+
3. The right query: \`query_trends\` (counts over time), \`query_funnel\` (conversion through steps), \`query_retention\` (who comes back), \`sql\` when nothing else fits.
|
|
424
|
+
4. Report numbers with the dashboard link and SQL from the result.
|
|
425
|
+
|
|
426
|
+
## Add tracking
|
|
427
|
+
|
|
428
|
+
1. \`instrument\` with the event name returns the exact code for this framework.
|
|
429
|
+
2. ${EVENT_NAMING_GUIDE}
|
|
430
|
+
3. After deploying, \`describe_event\` (or \`npx @glassanalytics/cli events <name>\`) confirms events arrive with the expected properties.
|
|
431
|
+
4. \`define_event\` documents what the event means.
|
|
432
|
+
|
|
433
|
+
## Debug a user problem
|
|
434
|
+
|
|
435
|
+
1. \`query_funnel\` with a breakdown (browser, device, utm_source) to find where people drop.
|
|
436
|
+
2. \`list_replays\` with \`with_errors: true\` (or a \`person\`) to find affected sessions.
|
|
437
|
+
3. \`summarise_replay\` to read what happened: pages, labelled clicks, rage clicks, errors.
|
|
438
|
+
|
|
439
|
+
## Run an experiment
|
|
440
|
+
|
|
441
|
+
1. \`create_experiment\` with a goal event (creates a control/test flag).
|
|
442
|
+
2. In code: \`if (glass.flag('<key>') === 'test') { \u2026 }\` \u2014 reading the flag records the exposure.
|
|
443
|
+
3. \`update_experiment\` with \`action: "start"\`; later \`experiment_results\`; ship the winner with \`action: "ship"\`.
|
|
444
|
+
`;
|
|
445
|
+
}
|
|
446
|
+
function upsertMcpConfig(path) {
|
|
447
|
+
let config = {};
|
|
448
|
+
if (existsSync2(path)) {
|
|
449
|
+
try {
|
|
450
|
+
config = JSON.parse(readFileSync2(path, "utf8"));
|
|
451
|
+
} catch {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
config.mcpServers = { ...config.mcpServers, glass: { command: "npx", args: ["-y", "@glassanalytics/cli", "mcp"] } };
|
|
456
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
457
|
+
writeFileSync2(path, `${JSON.stringify(config, null, 2)}
|
|
458
|
+
`);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
function writeAgentFiles(o) {
|
|
462
|
+
const written = [];
|
|
463
|
+
const section = agentsSection(o);
|
|
464
|
+
if (upsertSection(join2(o.root, "AGENTS.md"), section, true)) written.push("AGENTS.md");
|
|
465
|
+
if (upsertSection(join2(o.root, "CLAUDE.md"), section, false)) written.push("CLAUDE.md");
|
|
466
|
+
const skill = join2(o.root, ".claude", "skills", "glass", "SKILL.md");
|
|
467
|
+
mkdirSync2(dirname2(skill), { recursive: true });
|
|
468
|
+
writeFileSync2(skill, skillFile(o));
|
|
469
|
+
written.push(".claude/skills/glass/SKILL.md");
|
|
470
|
+
if (upsertMcpConfig(join2(o.root, ".mcp.json"))) written.push(".mcp.json");
|
|
471
|
+
if (existsSync2(join2(o.root, ".cursor")) && upsertMcpConfig(join2(o.root, ".cursor", "mcp.json"))) written.push(".cursor/mcp.json");
|
|
472
|
+
return written;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// packages/cli/src/cloudflare/provision.ts
|
|
476
|
+
import { copyFileSync, mkdirSync as mkdirSync3, readdirSync, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
477
|
+
import { join as join3 } from "node:path";
|
|
478
|
+
import { LAKE_TABLES, streamSchemaFile } from "@glassanalytics/core/lake";
|
|
479
|
+
|
|
480
|
+
// packages/cli/src/cloudflare/wrangler.ts
|
|
481
|
+
import { spawn } from "node:child_process";
|
|
482
|
+
import { createRequire } from "node:module";
|
|
483
|
+
var WranglerError = class extends Error {
|
|
484
|
+
result;
|
|
485
|
+
args;
|
|
486
|
+
constructor(args, result) {
|
|
487
|
+
const output = `${result.stderr}
|
|
488
|
+
${result.stdout}`.trim().split("\n").slice(-12).join("\n");
|
|
489
|
+
super(`wrangler ${args.slice(0, 3).join(" ")} failed:
|
|
490
|
+
${output}`);
|
|
491
|
+
this.args = args;
|
|
492
|
+
this.result = result;
|
|
493
|
+
}
|
|
494
|
+
get output() {
|
|
495
|
+
return `${this.result.stdout}
|
|
496
|
+
${this.result.stderr}`;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
function wranglerCommand() {
|
|
500
|
+
try {
|
|
501
|
+
const require2 = createRequire(import.meta.url);
|
|
502
|
+
return { cmd: process.execPath, prefix: [require2.resolve("wrangler/bin/wrangler.js")] };
|
|
503
|
+
} catch {
|
|
504
|
+
return { cmd: process.platform === "win32" ? "npx.cmd" : "npx", prefix: ["--yes", "wrangler@4"] };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function createWrangler(auth, cwd) {
|
|
508
|
+
const { cmd, prefix } = wranglerCommand();
|
|
509
|
+
return {
|
|
510
|
+
run(args, options = {}) {
|
|
511
|
+
return new Promise((resolve2, reject) => {
|
|
512
|
+
const child = spawn(cmd, [...prefix, ...args], {
|
|
513
|
+
cwd,
|
|
514
|
+
env: {
|
|
515
|
+
...process.env,
|
|
516
|
+
CLOUDFLARE_API_TOKEN: auth.token,
|
|
517
|
+
CLOUDFLARE_ACCOUNT_ID: auth.accountId,
|
|
518
|
+
CI: "1",
|
|
519
|
+
WRANGLER_SEND_METRICS: "false"
|
|
520
|
+
},
|
|
521
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
522
|
+
});
|
|
523
|
+
let stdout = "";
|
|
524
|
+
let stderr = "";
|
|
525
|
+
child.stdout.on("data", (d) => {
|
|
526
|
+
stdout += d.toString();
|
|
527
|
+
});
|
|
528
|
+
child.stderr.on("data", (d) => {
|
|
529
|
+
stderr += d.toString();
|
|
530
|
+
});
|
|
531
|
+
child.on("error", reject);
|
|
532
|
+
child.on("close", (code) => resolve2({ code: code ?? 1, stdout, stderr }));
|
|
533
|
+
if (options.input !== void 0) child.stdin.write(options.input);
|
|
534
|
+
child.stdin.end();
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
async function must(wrangler, args, options) {
|
|
540
|
+
const result = await wrangler.run(args, options);
|
|
541
|
+
if (result.code !== 0) throw new WranglerError(args, result);
|
|
542
|
+
return result.stdout;
|
|
543
|
+
}
|
|
544
|
+
function parseJsonOutput(stdout) {
|
|
545
|
+
const start = stdout.search(/[[{]/);
|
|
546
|
+
if (start < 0) throw new Error(`Expected JSON from wrangler, got: ${stdout.slice(0, 200)}`);
|
|
547
|
+
return JSON.parse(stdout.slice(start));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// packages/cli/src/cloudflare/provision.ts
|
|
551
|
+
var isAlreadyExists = (err) => err instanceof WranglerError && /already exists|already own|10004|11009/i.test(err.output);
|
|
552
|
+
var isR2Disabled = (err) => err instanceof WranglerError && /enable R2|R2 (is|has) not been enabled|purchase R2|10042/i.test(err.output);
|
|
553
|
+
async function ensureD1(wrangler, name, eu, report) {
|
|
554
|
+
const find = async () => parseJsonOutput(await must(wrangler, ["d1", "list", "--json"])).find((db2) => db2.name === name);
|
|
555
|
+
let db = await find();
|
|
556
|
+
if (db) {
|
|
557
|
+
report("ok", `D1 database "${name}"`, "exists");
|
|
558
|
+
} else {
|
|
559
|
+
await must(wrangler, ["d1", "create", name, ...eu ? ["--jurisdiction", "eu"] : []]);
|
|
560
|
+
db = await find();
|
|
561
|
+
if (!db) throw new Error(`Created D1 database "${name}" but could not find it afterwards.`);
|
|
562
|
+
report("created", `D1 database "${name}"`);
|
|
563
|
+
}
|
|
564
|
+
return { name, id: db.uuid };
|
|
565
|
+
}
|
|
566
|
+
async function ensureBucket(wrangler, bucket, eu, report, purpose) {
|
|
567
|
+
try {
|
|
568
|
+
await must(wrangler, ["r2", "bucket", "create", bucket, ...eu ? ["--jurisdiction", "eu"] : []]);
|
|
569
|
+
report("created", `R2 bucket "${bucket}" for ${purpose}`);
|
|
570
|
+
return true;
|
|
571
|
+
} catch (err) {
|
|
572
|
+
if (isAlreadyExists(err)) {
|
|
573
|
+
report("ok", `R2 bucket "${bucket}" for ${purpose}`, "exists");
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
if (isR2Disabled(err)) return false;
|
|
577
|
+
throw err;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
async function findByName(wrangler, kind, name) {
|
|
581
|
+
const args = kind ? ["pipelines", kind, "list", "--json", "--per-page", "100"] : ["pipelines", "list", "--json", "--per-page", "100"];
|
|
582
|
+
const items = parseJsonOutput(await must(wrangler, args));
|
|
583
|
+
return items.find((i) => i.name === name);
|
|
584
|
+
}
|
|
585
|
+
async function ensureLake(wrangler, options, report) {
|
|
586
|
+
if (!await ensureBucket(wrangler, options.bucket, options.eu, report, "event lake")) {
|
|
587
|
+
throw new Error("Lake mode needs R2. Enable R2 in the Cloudflare dashboard, then run `glass init --lake` again.");
|
|
588
|
+
}
|
|
589
|
+
let enabled = false;
|
|
590
|
+
for (const args of [
|
|
591
|
+
["r2", "bucket", "catalog", "enable", options.bucket],
|
|
592
|
+
["basin", "catalog", "enable", options.bucket]
|
|
593
|
+
]) {
|
|
594
|
+
const r = await wrangler.run(args);
|
|
595
|
+
if (r.code === 0 || /already enabled/i.test(r.stdout + r.stderr)) {
|
|
596
|
+
enabled = true;
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (!enabled) throw new Error(`Could not enable the data catalog on "${options.bucket}". Enable it in the R2 dashboard, then re-run.`);
|
|
601
|
+
report("ok", `Data catalog on "${options.bucket}"`);
|
|
602
|
+
const streams = {};
|
|
603
|
+
for (const table2 of Object.keys(LAKE_TABLES)) {
|
|
604
|
+
const streamName = LAKE_TABLES[table2].stream;
|
|
605
|
+
const sinkName = `${streamName}_sink`;
|
|
606
|
+
let stream = await findByName(wrangler, "streams", streamName);
|
|
607
|
+
if (!stream) {
|
|
608
|
+
const schemaPath = join3(options.dir, "schemas", `${table2}.json`);
|
|
609
|
+
mkdirSync3(join3(options.dir, "schemas"), { recursive: true });
|
|
610
|
+
writeFileSync3(schemaPath, `${JSON.stringify(streamSchemaFile(table2), null, 2)}
|
|
611
|
+
`);
|
|
612
|
+
await must(wrangler, ["pipelines", "streams", "create", streamName, "--schema-file", schemaPath, "--http-enabled", "false"]);
|
|
613
|
+
stream = await findByName(wrangler, "streams", streamName);
|
|
614
|
+
if (!stream) throw new Error(`Created stream "${streamName}" but could not find it.`);
|
|
615
|
+
report("created", `Pipelines stream "${streamName}"`);
|
|
616
|
+
}
|
|
617
|
+
streams[table2] = stream.id;
|
|
618
|
+
if (!await findByName(wrangler, "sinks", sinkName)) {
|
|
619
|
+
await must(wrangler, [
|
|
620
|
+
"pipelines",
|
|
621
|
+
"sinks",
|
|
622
|
+
"create",
|
|
623
|
+
sinkName,
|
|
624
|
+
"--type",
|
|
625
|
+
"r2-data-catalog",
|
|
626
|
+
"--bucket",
|
|
627
|
+
options.bucket,
|
|
628
|
+
"--namespace",
|
|
629
|
+
options.namespace,
|
|
630
|
+
"--table",
|
|
631
|
+
table2,
|
|
632
|
+
"--catalog-token",
|
|
633
|
+
options.token,
|
|
634
|
+
"--roll-interval",
|
|
635
|
+
"60"
|
|
636
|
+
]);
|
|
637
|
+
report("created", `Iceberg table "${options.namespace}.${table2}"`);
|
|
638
|
+
}
|
|
639
|
+
if (!await findByName(wrangler, null, streamName)) {
|
|
640
|
+
await must(wrangler, ["pipelines", "create", streamName, "--sql", `INSERT INTO ${sinkName} SELECT * FROM ${streamName}`]);
|
|
641
|
+
report("created", `Pipeline "${streamName}"`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return { bucket: options.bucket, namespace: options.namespace, streams };
|
|
645
|
+
}
|
|
646
|
+
function renderWranglerConfig(config) {
|
|
647
|
+
const vars = { GLASS_STORE: config.store };
|
|
648
|
+
if (config.lake) {
|
|
649
|
+
vars.LAKE_BUCKET = config.lake.bucket;
|
|
650
|
+
vars.LAKE_NAMESPACE = config.lake.namespace;
|
|
651
|
+
vars.CF_ACCOUNT_ID = config.accountId;
|
|
652
|
+
}
|
|
653
|
+
if (config.route) vars.GLASS_PUBLIC_URL = `https://${config.route.replace(/\/\*$/, "")}`;
|
|
654
|
+
const wrangler = {
|
|
655
|
+
name: config.name,
|
|
656
|
+
main: "worker.js",
|
|
657
|
+
compatibility_date: "2026-10-01",
|
|
658
|
+
workers_dev: true,
|
|
659
|
+
d1_databases: [{ binding: "DB", database_name: config.d1.name, database_id: config.d1.id, migrations_dir: "migrations" }],
|
|
660
|
+
vars,
|
|
661
|
+
triggers: { crons: ["17 3 * * *"] },
|
|
662
|
+
observability: { enabled: true }
|
|
663
|
+
};
|
|
664
|
+
if (config.replay) wrangler.r2_buckets = [{ binding: "REPLAYS", bucket_name: config.replay.bucket }];
|
|
665
|
+
if (config.lake) {
|
|
666
|
+
wrangler.pipelines = [
|
|
667
|
+
{ binding: "EVENTS_STREAM", stream: config.lake.streams.events },
|
|
668
|
+
{ binding: "IDENTITIES_STREAM", stream: config.lake.streams.identities }
|
|
669
|
+
];
|
|
670
|
+
}
|
|
671
|
+
if (config.route) {
|
|
672
|
+
const zone = config.route.split("/")[0].split(".").slice(-2).join(".");
|
|
673
|
+
wrangler.routes = [{ pattern: config.route, zone_name: zone }];
|
|
674
|
+
}
|
|
675
|
+
return `// Generated by \`glass\`. Edit .glass/config.json and run \`glass upgrade\` instead of editing this file.
|
|
676
|
+
${JSON.stringify(wrangler, null, 2)}
|
|
677
|
+
`;
|
|
678
|
+
}
|
|
679
|
+
function installWorkerFiles(dir, bundleDir) {
|
|
680
|
+
mkdirSync3(join3(dir, "migrations"), { recursive: true });
|
|
681
|
+
copyFileSync(join3(bundleDir, "worker.js"), join3(dir, "worker.js"));
|
|
682
|
+
for (const file of readdirSync(join3(bundleDir, "migrations"))) {
|
|
683
|
+
copyFileSync(join3(bundleDir, "migrations", file), join3(dir, "migrations", file));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async function deploy(wrangler, configPath) {
|
|
687
|
+
const out = await must(wrangler, ["deploy", "--config", configPath]);
|
|
688
|
+
return /https:\/\/[\w.-]+\.workers\.dev/.exec(out)?.[0] ?? null;
|
|
689
|
+
}
|
|
690
|
+
async function migrate(wrangler, database, configPath) {
|
|
691
|
+
await must(wrangler, ["d1", "migrations", "apply", database, "--remote", "--config", configPath]);
|
|
692
|
+
}
|
|
693
|
+
async function putSecrets(wrangler, secrets, configPath, dir) {
|
|
694
|
+
const file = join3(dir, ".secrets.tmp.json");
|
|
695
|
+
writeFileSync3(file, JSON.stringify(secrets), { mode: 384 });
|
|
696
|
+
try {
|
|
697
|
+
await must(wrangler, ["secret", "bulk", file, "--config", configPath]);
|
|
698
|
+
} finally {
|
|
699
|
+
rmSync(file, { force: true });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async function listAccounts(token, fetcher = fetch) {
|
|
703
|
+
const res = await fetcher("https://api.cloudflare.com/client/v4/accounts?per_page=50", { headers: { Authorization: `Bearer ${token}` } });
|
|
704
|
+
const body = await res.json().catch(() => ({}));
|
|
705
|
+
if (!res.ok || !body.success) {
|
|
706
|
+
throw new Error(`Cloudflare rejected the API token: ${body.errors?.[0]?.message ?? `HTTP ${res.status}`}`);
|
|
707
|
+
}
|
|
708
|
+
return body.result ?? [];
|
|
709
|
+
}
|
|
710
|
+
var TOKEN_SCOPES = [
|
|
711
|
+
"Account \u203A Workers Scripts \u203A Edit",
|
|
712
|
+
"Account \u203A D1 \u203A Edit",
|
|
713
|
+
"Account \u203A Workers R2 Storage \u203A Edit",
|
|
714
|
+
"Account \u203A Account Settings \u203A Read",
|
|
715
|
+
"Zone \u203A Workers Routes \u203A Edit (only with --route)",
|
|
716
|
+
"Account \u203A Pipelines \u203A Edit, Workers R2 Data Catalog \u203A Edit, Workers R2 SQL \u203A Read (only with --lake)"
|
|
717
|
+
];
|
|
718
|
+
|
|
719
|
+
// packages/cli/src/prompt.ts
|
|
720
|
+
import { createInterface } from "node:readline/promises";
|
|
721
|
+
var interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);
|
|
722
|
+
async function ask(question, options) {
|
|
723
|
+
if (!interactive()) {
|
|
724
|
+
if (options.default !== void 0) return options.default;
|
|
725
|
+
throw new UsageError(`Missing ${options.flag}.`, `Pass ${options.flag} (this terminal is not interactive).`);
|
|
726
|
+
}
|
|
727
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
728
|
+
if (options.secret) {
|
|
729
|
+
const write = rl._writeToOutput.bind(rl);
|
|
730
|
+
rl._writeToOutput = (s) => write(s.startsWith(question) ? s : "*");
|
|
731
|
+
}
|
|
732
|
+
const suffix = options.default ? ` (${options.default})` : "";
|
|
733
|
+
const answer = (await rl.question(`${question}${suffix}: `)).trim();
|
|
734
|
+
rl.close();
|
|
735
|
+
if (options.secret) process.stdout.write("\n");
|
|
736
|
+
return answer || options.default || "";
|
|
737
|
+
}
|
|
738
|
+
async function choose(question, items, label, flag) {
|
|
739
|
+
if (items.length === 1) return items[0];
|
|
740
|
+
if (!interactive()) throw new UsageError(`Several options for ${flag}: ${items.map(label).join(", ")}.`, `Pass ${flag}.`);
|
|
741
|
+
items.forEach((item2, i) => process.stdout.write(` ${i + 1}. ${label(item2)}
|
|
742
|
+
`));
|
|
743
|
+
const answer = Number(await ask(question, { flag, default: "1" }));
|
|
744
|
+
const item = items[answer - 1];
|
|
745
|
+
if (!item) throw new UsageError(`Pick a number from 1 to ${items.length}.`);
|
|
746
|
+
return item;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// packages/cli/src/commands/upgrade.ts
|
|
750
|
+
import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
751
|
+
import { join as join4 } from "node:path";
|
|
752
|
+
import { fileURLToPath } from "node:url";
|
|
753
|
+
import { GLASS_VERSION } from "@glassanalytics/core/version";
|
|
754
|
+
function workerBundleDir() {
|
|
755
|
+
const candidates = [new URL("./worker/", import.meta.url), new URL("../../../worker/dist/", import.meta.url)];
|
|
756
|
+
for (const c of candidates) {
|
|
757
|
+
const dir = fileURLToPath(c);
|
|
758
|
+
if (existsSync3(join4(dir, "worker.js")) && existsSync3(join4(dir, "migrations"))) return dir;
|
|
759
|
+
}
|
|
760
|
+
throw new UsageError("The Worker bundle is missing.", "If you are working on Glass itself, run `pnpm build` first.");
|
|
761
|
+
}
|
|
762
|
+
async function upgrade(options, deps = {}) {
|
|
763
|
+
const root = projectRoot();
|
|
764
|
+
const config = loadConfig(root);
|
|
765
|
+
const credentials = loadCredentials(root);
|
|
766
|
+
if (!config || !credentials) throw new UsageError("No Glass deployment in this project.", "Run `npx @glassanalytics/cli init` first.");
|
|
767
|
+
const token = options.token ?? process.env.CLOUDFLARE_API_TOKEN ?? await ask("Cloudflare API token", { flag: "--token (or CLOUDFLARE_API_TOKEN)", secret: true });
|
|
768
|
+
const wrangler = (deps.wrangler ?? createWrangler)({ token, accountId: config.accountId }, root);
|
|
769
|
+
const dir = glassDir(root);
|
|
770
|
+
const report = (status2, label, detail) => {
|
|
771
|
+
if (options.json) return;
|
|
772
|
+
(status2 === "skipped" ? skip : ok)(`${label}${detail ? color.dim(` (${detail})`) : ""}`);
|
|
773
|
+
};
|
|
774
|
+
if (!config.replay && await ensureBucket(wrangler, `${config.name}-replays`, config.eu, report, "session replay")) {
|
|
775
|
+
config.replay = { bucket: `${config.name}-replays` };
|
|
776
|
+
}
|
|
777
|
+
installWorkerFiles(dir, workerBundleDir());
|
|
778
|
+
const configPath = join4(dir, "wrangler.jsonc");
|
|
779
|
+
writeFileSync4(configPath, renderWranglerConfig(config));
|
|
780
|
+
await migrate(wrangler, config.d1.name, configPath);
|
|
781
|
+
report("ok", "Migrations applied");
|
|
782
|
+
await deploy(wrangler, configPath);
|
|
783
|
+
await putSecrets(
|
|
784
|
+
wrangler,
|
|
785
|
+
{
|
|
786
|
+
GLASS_ADMIN_KEY: credentials.adminKey,
|
|
787
|
+
GLASS_READ_KEY: credentials.readKey,
|
|
788
|
+
GLASS_SALT_SECRET: credentials.saltSecret
|
|
789
|
+
// CF_API_TOKEN (lake mode) is set by init and left alone here, so a narrower token you set stays.
|
|
790
|
+
},
|
|
791
|
+
configPath,
|
|
792
|
+
dir
|
|
793
|
+
);
|
|
794
|
+
const from = config.version;
|
|
795
|
+
config.version = GLASS_VERSION;
|
|
796
|
+
saveConfig(config, root);
|
|
797
|
+
if (options.json) printJson({ from, to: GLASS_VERSION, host: config.host, replay: Boolean(config.replay) });
|
|
798
|
+
else ok(`Glass ${from === GLASS_VERSION ? GLASS_VERSION : `${from} upgraded to ${GLASS_VERSION}`} is live at ${config.host}`);
|
|
799
|
+
}
|
|
800
|
+
async function status(options) {
|
|
801
|
+
const conn = connection();
|
|
802
|
+
const config = loadConfig();
|
|
803
|
+
const health = await call("GET", "/health");
|
|
804
|
+
const events2 = await call("GET", `/api/events?days=1${conn.project ? `&project=${conn.project}` : ""}`).catch(() => ({ events: [] }));
|
|
805
|
+
const total = events2.events.reduce((a, e) => a + e.count, 0);
|
|
806
|
+
const last = events2.events.map((e) => e.lastSeen).sort().at(-1);
|
|
807
|
+
const mcp = existsSync3(join4(projectRoot(), ".mcp.json"));
|
|
808
|
+
const result = {
|
|
809
|
+
host: conn.host,
|
|
810
|
+
project: conn.project,
|
|
811
|
+
version: health.version,
|
|
812
|
+
cliVersion: GLASS_VERSION,
|
|
813
|
+
store: health.store,
|
|
814
|
+
replay: health.replay,
|
|
815
|
+
eventsLast24h: total,
|
|
816
|
+
lastEvent: last ?? null,
|
|
817
|
+
mcpConfigured: mcp,
|
|
818
|
+
upgradeAvailable: health.version !== GLASS_VERSION,
|
|
819
|
+
account: config?.accountId
|
|
820
|
+
};
|
|
821
|
+
if (options.json) return printJson(result);
|
|
822
|
+
ok(`Worker reachable at ${conn.host} (Glass ${health.version}, ${health.store} store)`);
|
|
823
|
+
(total ? ok : skip)(total ? `${total.toLocaleString("en-US")} events in the last 24 hours, latest ${last}` : "No events in the last 24 hours. Is the snippet installed?");
|
|
824
|
+
(health.replay ? ok : skip)(health.replay ? "Session replay enabled" : "Session replay disabled (no R2 bucket); `glass upgrade` retries");
|
|
825
|
+
(mcp ? ok : skip)(mcp ? "MCP server configured in .mcp.json" : "No .mcp.json; run `glass init` again to write agent files");
|
|
826
|
+
if (result.upgradeAvailable) skip(`This CLI is Glass ${GLASS_VERSION}; the Worker runs ${health.version}. Run \`glass upgrade\`.`);
|
|
827
|
+
}
|
|
828
|
+
function key(options) {
|
|
829
|
+
const conn = connection();
|
|
830
|
+
const credentials = loadCredentials();
|
|
831
|
+
const value = options.read ? credentials?.readKey : conn.key;
|
|
832
|
+
if (options.json) return printJson({ host: conn.host, project: conn.project, dashboard: `${conn.host}/app`, key: value });
|
|
833
|
+
line(`Dashboard: ${conn.host}/app`);
|
|
834
|
+
line(`${options.read ? "Read-only key" : "Admin key"}: ${value}`);
|
|
835
|
+
line(color.dim(options.read ? "Safe to share for viewing." : "Full access. Use `glass key --read` for a key that can only view."));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// packages/cli/src/commands/init.ts
|
|
839
|
+
function defaultProjectId(root) {
|
|
840
|
+
try {
|
|
841
|
+
const pkg = JSON.parse(readFileSync3(join5(root, "package.json"), "utf8"));
|
|
842
|
+
const id = (pkg.name ?? "").replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
843
|
+
if (id) return id.slice(0, 63);
|
|
844
|
+
} catch {
|
|
845
|
+
}
|
|
846
|
+
return "default";
|
|
847
|
+
}
|
|
848
|
+
function readPackageJson(root) {
|
|
849
|
+
try {
|
|
850
|
+
return JSON.parse(readFileSync3(join5(root, "package.json"), "utf8"));
|
|
851
|
+
} catch {
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
async function waitForWorker(host, key2, fetcher, pollMs) {
|
|
856
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
857
|
+
try {
|
|
858
|
+
const res = await fetcher(`${host}/api`, { headers: { Authorization: `Bearer ${key2}` } });
|
|
859
|
+
if (res.ok) return;
|
|
860
|
+
} catch {
|
|
861
|
+
}
|
|
862
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
863
|
+
}
|
|
864
|
+
throw new UsageError(`The Worker at ${host} did not come up.`, "Run `glass status` in a minute; if it persists, `glass upgrade` redeploys.");
|
|
865
|
+
}
|
|
866
|
+
async function init(options, deps = {}) {
|
|
867
|
+
const root = deps.root ?? projectRoot();
|
|
868
|
+
const dir = glassDir(root);
|
|
869
|
+
const fetcher = deps.fetcher ?? fetch;
|
|
870
|
+
const existing = loadConfig(root);
|
|
871
|
+
const quiet = Boolean(options.json);
|
|
872
|
+
const steps = [];
|
|
873
|
+
const report = (status2, label, detail) => {
|
|
874
|
+
steps.push({ status: status2, label, detail });
|
|
875
|
+
if (quiet) return;
|
|
876
|
+
const text = `${label}${detail ? color.dim(` (${detail})`) : status2 === "created" ? color.dim(" (created)") : ""}`;
|
|
877
|
+
if (status2 === "skipped") skip(text);
|
|
878
|
+
else ok(text);
|
|
879
|
+
};
|
|
880
|
+
if (!quiet) {
|
|
881
|
+
line(color.bold("Glass: analytics in your own Cloudflare account"));
|
|
882
|
+
line();
|
|
883
|
+
}
|
|
884
|
+
const token = options.token ?? process.env.CLOUDFLARE_API_TOKEN ?? await ask("Cloudflare API token", { flag: "--token (or CLOUDFLARE_API_TOKEN)", secret: true });
|
|
885
|
+
if (!token) throw new UsageError("A Cloudflare API token is required.", `Create one at https://dash.cloudflare.com/profile/api-tokens with: ${TOKEN_SCOPES.join("; ")}.`);
|
|
886
|
+
const accounts = await listAccounts(token, fetcher).catch((err) => {
|
|
887
|
+
throw new UsageError(err instanceof Error ? err.message : String(err), "Check the token, or create a new one at https://dash.cloudflare.com/profile/api-tokens");
|
|
888
|
+
});
|
|
889
|
+
const accountId = options.account ?? process.env.CLOUDFLARE_ACCOUNT_ID ?? existing?.accountId;
|
|
890
|
+
const account = accountId ? accounts.find((a) => a.id === accountId) ?? { id: accountId, name: accountId } : await choose("Which account?", accounts, (a) => `${a.name} (${a.id})`, "--account");
|
|
891
|
+
if (!account) throw new UsageError("This token cannot see any Cloudflare accounts.", `Give it account permissions: ${TOKEN_SCOPES.join("; ")}.`);
|
|
892
|
+
report("ok", `Cloudflare token works (account ${account.name})`);
|
|
893
|
+
const name = options.name ?? existing?.name ?? "glass";
|
|
894
|
+
const project = options.project ?? existing?.project ?? defaultProjectId(root);
|
|
895
|
+
const lake = options.lake ?? existing?.store === "lake";
|
|
896
|
+
const eu = options.eu ?? existing?.eu ?? false;
|
|
897
|
+
const route = options.route ?? existing?.route ?? null;
|
|
898
|
+
const wantReplay = options.replay ?? true;
|
|
899
|
+
if (options.dryRun) {
|
|
900
|
+
const plan = {
|
|
901
|
+
account: account.id,
|
|
902
|
+
worker: name,
|
|
903
|
+
project,
|
|
904
|
+
store: lake ? "lake" : "d1",
|
|
905
|
+
eu,
|
|
906
|
+
route,
|
|
907
|
+
replay: wantReplay,
|
|
908
|
+
resources: [
|
|
909
|
+
`D1 database "${name}"`,
|
|
910
|
+
wantReplay ? `R2 bucket "${name}-replays"` : null,
|
|
911
|
+
lake ? `R2 bucket "${name}-data" with data catalog, Pipelines streams/sinks for events and identities` : null
|
|
912
|
+
].filter(Boolean),
|
|
913
|
+
files: [".glass/config.json", ".glass/wrangler.jsonc", ".glass/worker.js", ".glass/migrations/", ".glass/credentials.json (git-ignored)", "AGENTS.md", ".claude/skills/glass/SKILL.md", ".mcp.json"]
|
|
914
|
+
};
|
|
915
|
+
if (quiet) printJson(plan);
|
|
916
|
+
else {
|
|
917
|
+
line();
|
|
918
|
+
line("Dry run: nothing was changed. This would create:");
|
|
919
|
+
for (const r of plan.resources) line(` ${r}`);
|
|
920
|
+
line(`and deploy Worker "${name}" for project "${project}", writing ${plan.files.join(", ")}.`);
|
|
921
|
+
}
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const wrangler = (deps.wrangler ?? createWrangler)({ token, accountId: account.id }, root);
|
|
925
|
+
try {
|
|
926
|
+
const d1 = await ensureD1(wrangler, name, eu, report);
|
|
927
|
+
let replay = null;
|
|
928
|
+
if (wantReplay) {
|
|
929
|
+
const bucket = `${name}-replays`;
|
|
930
|
+
if (await ensureBucket(wrangler, bucket, eu, report, "session replay")) replay = { bucket };
|
|
931
|
+
else report("skipped", "Session replay", "R2 is not enabled on this account; enable it in the Cloudflare dashboard, then run `glass upgrade`");
|
|
932
|
+
} else {
|
|
933
|
+
report("skipped", "Session replay", "disabled with --no-replay");
|
|
934
|
+
}
|
|
935
|
+
const lakeConfig = lake ? await ensureLake(wrangler, { bucket: `${name}-data`, namespace: "glass", token, eu, dir }, report) : null;
|
|
936
|
+
const config = {
|
|
937
|
+
name,
|
|
938
|
+
accountId: account.id,
|
|
939
|
+
host: existing?.host ?? "",
|
|
940
|
+
project,
|
|
941
|
+
store: lake ? "lake" : "d1",
|
|
942
|
+
d1,
|
|
943
|
+
replay,
|
|
944
|
+
lake: lakeConfig,
|
|
945
|
+
route,
|
|
946
|
+
eu,
|
|
947
|
+
version: GLASS_VERSION2
|
|
948
|
+
};
|
|
949
|
+
const credentials = loadCredentials(root) ?? { adminKey: randomToken(), readKey: randomToken(), saltSecret: randomToken() };
|
|
950
|
+
installWorkerFiles(dir, deps.bundleDir ?? workerBundleDir());
|
|
951
|
+
const configPath = join5(dir, "wrangler.jsonc");
|
|
952
|
+
writeFileSync5(configPath, renderWranglerConfig(config));
|
|
953
|
+
saveCredentials(credentials, root);
|
|
954
|
+
await migrate(wrangler, d1.name, configPath);
|
|
955
|
+
report("ok", "Database migrated");
|
|
956
|
+
const url = await deploy(wrangler, configPath);
|
|
957
|
+
config.host = route ? `https://${route.replace(/\/\*$/, "")}` : url ?? config.host;
|
|
958
|
+
if (!config.host) throw new UsageError("Deployed, but could not read the Worker URL.", "Enable a workers.dev subdomain in the Cloudflare dashboard (Workers & Pages), then run `glass upgrade`.");
|
|
959
|
+
await putSecrets(
|
|
960
|
+
wrangler,
|
|
961
|
+
{
|
|
962
|
+
GLASS_ADMIN_KEY: credentials.adminKey,
|
|
963
|
+
GLASS_READ_KEY: credentials.readKey,
|
|
964
|
+
GLASS_SALT_SECRET: credentials.saltSecret,
|
|
965
|
+
...lake ? { CF_API_TOKEN: token } : {}
|
|
966
|
+
},
|
|
967
|
+
configPath,
|
|
968
|
+
dir
|
|
969
|
+
);
|
|
970
|
+
saveConfig(config, root);
|
|
971
|
+
report("ok", `Worker deployed at ${config.host}`);
|
|
972
|
+
await waitForWorker(config.host, credentials.adminKey, fetcher, deps.pollMs ?? 1500);
|
|
973
|
+
await call("POST", "/api/projects", { id: project }, { host: config.host, key: credentials.adminKey, fetcher });
|
|
974
|
+
report("ok", `Project "${project}" ready`);
|
|
975
|
+
const auth = { host: config.host, key: credentials.adminKey, fetcher };
|
|
976
|
+
await call("POST", "/e", { project, events: [{ id: randomToken(16), event: "$install_check", ts: Date.now(), distinct_id: "glass-cli" }] }, auth);
|
|
977
|
+
if (lake) {
|
|
978
|
+
report("ok", "Test event sent", "the lake shows events within about two minutes");
|
|
979
|
+
} else {
|
|
980
|
+
const seen = await call("GET", `/api/events?days=1&project=${encodeURIComponent(project)}`, void 0, auth);
|
|
981
|
+
if (seen.events.some((e) => e.event === "$install_check")) report("ok", "Test event received");
|
|
982
|
+
else report("skipped", "Test event not visible yet", "run `glass status` in a minute");
|
|
983
|
+
}
|
|
984
|
+
const framework = detectFramework(readPackageJson(root));
|
|
985
|
+
const snippet = FRAMEWORKS[framework].snippet({ host: config.host, project });
|
|
986
|
+
const written = options.agentFiles === false ? [] : writeAgentFiles({ root, host: config.host, project, snippet });
|
|
987
|
+
if (quiet) {
|
|
988
|
+
printJson({ host: config.host, project, dashboard: `${config.host}/app`, framework, snippet, written, steps });
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
line();
|
|
992
|
+
line(color.bold(`Next: add Glass to your site (${FRAMEWORKS[framework].name})`));
|
|
993
|
+
line();
|
|
994
|
+
line(color.dim(` ${snippet.file}`));
|
|
995
|
+
for (const l of snippet.code.split("\n")) line(` ${l}`);
|
|
996
|
+
line();
|
|
997
|
+
line(`Then track what matters: ${color.cyan("glass.capture('signup_completed', { plan: 'pro' })")}`);
|
|
998
|
+
if (written.length) {
|
|
999
|
+
line();
|
|
1000
|
+
line(color.bold("Written for your agent"));
|
|
1001
|
+
for (const f of written) line(` ${f}`);
|
|
1002
|
+
line(` .glass/ ${color.dim("(deployment config: commit it; credentials.json is git-ignored)")}`);
|
|
1003
|
+
}
|
|
1004
|
+
line();
|
|
1005
|
+
line(`Dashboard: ${color.cyan(`${config.host}/app`)} ${color.dim("(sign in with the key from `npx @glassanalytics/cli key`)")}`);
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
if (err instanceof WranglerError && /Authentication|not authorized|permission|10000|9109/i.test(err.output)) {
|
|
1008
|
+
throw new UsageError(`Cloudflare refused a request: ${err.message.split("\n")[0]}`, `The API token needs: ${TOKEN_SCOPES.join("; ")}.`);
|
|
1009
|
+
}
|
|
1010
|
+
throw err;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// packages/cli/src/commands/mcp.ts
|
|
1015
|
+
import { createInterface as createInterface2 } from "node:readline";
|
|
1016
|
+
async function mcpBridge(options = {}) {
|
|
1017
|
+
const conn = connection();
|
|
1018
|
+
const fetcher = options.fetcher ?? fetch;
|
|
1019
|
+
const output = options.output ?? process.stdout;
|
|
1020
|
+
const rl = createInterface2({ input: options.input ?? process.stdin, crlfDelay: Number.POSITIVE_INFINITY });
|
|
1021
|
+
const pending = [];
|
|
1022
|
+
const send = (message) => output.write(`${JSON.stringify(message)}
|
|
1023
|
+
`);
|
|
1024
|
+
const forward = async (lineText) => {
|
|
1025
|
+
let message;
|
|
1026
|
+
try {
|
|
1027
|
+
message = JSON.parse(lineText);
|
|
1028
|
+
} catch {
|
|
1029
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
try {
|
|
1033
|
+
const res = await fetcher(`${conn.host}/mcp`, {
|
|
1034
|
+
method: "POST",
|
|
1035
|
+
headers: { Authorization: `Bearer ${conn.key}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
1036
|
+
body: lineText
|
|
1037
|
+
});
|
|
1038
|
+
if (res.status === 202 || message.id === void 0) return;
|
|
1039
|
+
const text = await res.text();
|
|
1040
|
+
if (!res.ok && !text.startsWith("{")) {
|
|
1041
|
+
send({ jsonrpc: "2.0", id: message.id ?? null, error: { code: -32603, message: `Glass returned HTTP ${res.status}: ${text.slice(0, 200)}` } });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
output.write(`${text.replace(/\n/g, "")}
|
|
1045
|
+
`);
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
send({ jsonrpc: "2.0", id: message.id ?? null, error: { code: -32603, message: `Could not reach ${conn.host}: ${err instanceof Error ? err.message : String(err)}` } });
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
for await (const lineText of rl) {
|
|
1051
|
+
if (lineText.trim()) pending.push(forward(lineText));
|
|
1052
|
+
}
|
|
1053
|
+
await Promise.all(pending);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// packages/cli/src/index.ts
|
|
1057
|
+
var HELP = `${color.bold("glass")} ${GLASS_VERSION3}: agent-first analytics in your own Cloudflare account
|
|
1058
|
+
|
|
1059
|
+
${color.bold("Set up")}
|
|
1060
|
+
glass init Deploy Glass and wire it into this project
|
|
1061
|
+
--token <token> Cloudflare API token (or CLOUDFLARE_API_TOKEN)
|
|
1062
|
+
--account <id> Cloudflare account id (or CLOUDFLARE_ACCOUNT_ID)
|
|
1063
|
+
--project <id> Project id (default: package.json name)
|
|
1064
|
+
--name <name> Worker and resource name (default: glass)
|
|
1065
|
+
--route <host/_glass/*> Also serve from your domain, e.g. acme.com/_glass/*
|
|
1066
|
+
--lake Store events in Iceberg on R2 (Pipelines + Basin SQL)
|
|
1067
|
+
--eu Keep data in the EU jurisdiction
|
|
1068
|
+
--no-replay Skip session replay storage
|
|
1069
|
+
--no-agent-files Don't write AGENTS.md, SKILL.md or .mcp.json
|
|
1070
|
+
--dry-run Show what would be created
|
|
1071
|
+
glass upgrade Redeploy this version of Glass and migrate
|
|
1072
|
+
glass status Check the deployment end to end
|
|
1073
|
+
glass key [--read] Print the dashboard URL and API key
|
|
1074
|
+
|
|
1075
|
+
${color.bold("Ask questions")}
|
|
1076
|
+
glass query web [--from -7d]
|
|
1077
|
+
glass query trends --event signup_completed [--math unique_persons] [--interval week] [--breakdown browser]
|
|
1078
|
+
glass query funnel --steps '$pageview,signup_started,signup_completed' [--window 1d] [--breakdown utm_source]
|
|
1079
|
+
glass query retention --cohort signup_completed [--return '$pageview'] [--period week]
|
|
1080
|
+
glass query <kind> --spec '<json>' Any spec; see GET /api/schema
|
|
1081
|
+
common: --from, --to, --where prop=value[,\u2026], --project, --sql (print SQL)
|
|
1082
|
+
glass sql "SELECT \u2026" Read-only SQL against your events
|
|
1083
|
+
glass events [name] Tracked events, or one event's properties
|
|
1084
|
+
--describe "<text>" Document an event
|
|
1085
|
+
glass person <id> [--delete] One person's properties and events (or erase them)
|
|
1086
|
+
glass replays [--errors] Recorded sessions
|
|
1087
|
+
glass replays summary <id> A session as a text transcript
|
|
1088
|
+
|
|
1089
|
+
${color.bold("Flags and experiments")}
|
|
1090
|
+
glass flags [list]
|
|
1091
|
+
glass flags set <key> [--rollout 25] [--variants control:50,test:50] [--enable|--disable] [--rules '<json>']
|
|
1092
|
+
glass flags delete <key>
|
|
1093
|
+
glass experiments [list]
|
|
1094
|
+
glass experiments create <key> --goal <event> [--flag <key>] [--hypothesis "\u2026"]
|
|
1095
|
+
glass experiments start|stop <key>
|
|
1096
|
+
glass experiments ship <key> <variant>
|
|
1097
|
+
glass experiments results <key>
|
|
1098
|
+
|
|
1099
|
+
${color.bold("Agents")}
|
|
1100
|
+
glass mcp MCP server over stdio (what .mcp.json runs)
|
|
1101
|
+
|
|
1102
|
+
Environment: GLASS_HOST, GLASS_API_KEY and GLASS_PROJECT override .glass/ settings.
|
|
1103
|
+
Docs: https://github.com/ISNIT0/glass-analytics`;
|
|
1104
|
+
var STRING_OPTIONS = [
|
|
1105
|
+
"token",
|
|
1106
|
+
"account",
|
|
1107
|
+
"project",
|
|
1108
|
+
"name",
|
|
1109
|
+
"route",
|
|
1110
|
+
"from",
|
|
1111
|
+
"to",
|
|
1112
|
+
"event",
|
|
1113
|
+
"math",
|
|
1114
|
+
"property",
|
|
1115
|
+
"interval",
|
|
1116
|
+
"breakdown",
|
|
1117
|
+
"steps",
|
|
1118
|
+
"window",
|
|
1119
|
+
"cohort",
|
|
1120
|
+
"return",
|
|
1121
|
+
"period",
|
|
1122
|
+
"periods",
|
|
1123
|
+
"spec",
|
|
1124
|
+
"where",
|
|
1125
|
+
"limit",
|
|
1126
|
+
"days",
|
|
1127
|
+
"describe",
|
|
1128
|
+
"person",
|
|
1129
|
+
"rollout",
|
|
1130
|
+
"variants",
|
|
1131
|
+
"rules",
|
|
1132
|
+
"description",
|
|
1133
|
+
"goal",
|
|
1134
|
+
"flag",
|
|
1135
|
+
"value",
|
|
1136
|
+
"hypothesis",
|
|
1137
|
+
"variant"
|
|
1138
|
+
];
|
|
1139
|
+
var BOOLEAN_OPTIONS = ["json", "lake", "eu", "no-replay", "no-agent-files", "dry-run", "read", "sql", "errors", "enable", "disable", "delete"];
|
|
1140
|
+
async function main(argv) {
|
|
1141
|
+
const { values, positionals } = parseArgs({
|
|
1142
|
+
args: argv,
|
|
1143
|
+
allowPositionals: true,
|
|
1144
|
+
strict: false,
|
|
1145
|
+
options: {
|
|
1146
|
+
...Object.fromEntries(STRING_OPTIONS.map((o) => [o, { type: "string" }])),
|
|
1147
|
+
...Object.fromEntries(BOOLEAN_OPTIONS.map((o) => [o, { type: "boolean" }])),
|
|
1148
|
+
help: { type: "boolean", short: "h" },
|
|
1149
|
+
version: { type: "boolean", short: "v" }
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
const flags2 = values;
|
|
1153
|
+
const [command, sub, arg, ...rest] = positionals;
|
|
1154
|
+
if (flags2.version) return line(GLASS_VERSION3);
|
|
1155
|
+
if (!command || command === "help" || flags2.help && !command) return line(HELP);
|
|
1156
|
+
switch (command) {
|
|
1157
|
+
case "init":
|
|
1158
|
+
return init({
|
|
1159
|
+
token: str3(flags2.token),
|
|
1160
|
+
account: str3(flags2.account),
|
|
1161
|
+
project: str3(flags2.project),
|
|
1162
|
+
name: str3(flags2.name),
|
|
1163
|
+
route: str3(flags2.route),
|
|
1164
|
+
lake: flags2.lake === true ? true : void 0,
|
|
1165
|
+
eu: flags2.eu === true ? true : void 0,
|
|
1166
|
+
replay: flags2["no-replay"] ? false : void 0,
|
|
1167
|
+
agentFiles: flags2["no-agent-files"] ? false : void 0,
|
|
1168
|
+
dryRun: Boolean(flags2["dry-run"]),
|
|
1169
|
+
json: Boolean(flags2.json)
|
|
1170
|
+
});
|
|
1171
|
+
case "upgrade":
|
|
1172
|
+
case "deploy":
|
|
1173
|
+
return upgrade({ token: str3(flags2.token), json: Boolean(flags2.json) });
|
|
1174
|
+
case "status":
|
|
1175
|
+
case "doctor":
|
|
1176
|
+
return status({ json: Boolean(flags2.json) });
|
|
1177
|
+
case "key":
|
|
1178
|
+
return key({ read: Boolean(flags2.read), json: Boolean(flags2.json) });
|
|
1179
|
+
case "query":
|
|
1180
|
+
return query(sub, flags2);
|
|
1181
|
+
case "sql":
|
|
1182
|
+
return sql(sub, flags2);
|
|
1183
|
+
case "events":
|
|
1184
|
+
return events(sub, flags2);
|
|
1185
|
+
case "person":
|
|
1186
|
+
case "persons":
|
|
1187
|
+
return person(sub, flags2);
|
|
1188
|
+
case "replays":
|
|
1189
|
+
return replays(sub, arg, flags2);
|
|
1190
|
+
case "projects":
|
|
1191
|
+
return projects(sub, arg, flags2);
|
|
1192
|
+
case "flags":
|
|
1193
|
+
return flags(sub, arg, flags2);
|
|
1194
|
+
case "experiments":
|
|
1195
|
+
return experiments(sub, arg, rest, flags2);
|
|
1196
|
+
case "mcp":
|
|
1197
|
+
return mcpBridge();
|
|
1198
|
+
default:
|
|
1199
|
+
throw new UsageError(`Unknown command "${command}".`, "Run `glass help` for the list of commands.");
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
function str3(v) {
|
|
1203
|
+
return typeof v === "string" ? v : void 0;
|
|
1204
|
+
}
|
|
1205
|
+
var invoked = process.argv[1] ?? "";
|
|
1206
|
+
if (/glass(\.js)?$|cli[\\/]src[\\/]index\.ts$/.test(invoked)) {
|
|
1207
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
1208
|
+
if (err instanceof UsageError) {
|
|
1209
|
+
fail(err.message);
|
|
1210
|
+
if (err.hint) process.stderr.write(` ${err.hint}
|
|
1211
|
+
`);
|
|
1212
|
+
} else {
|
|
1213
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
1214
|
+
if (process.env.GLASS_DEBUG && err instanceof Error) process.stderr.write(`${err.stack}
|
|
1215
|
+
`);
|
|
1216
|
+
}
|
|
1217
|
+
process.exitCode = 1;
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
export {
|
|
1221
|
+
main
|
|
1222
|
+
};
|