@glassanalytics/cli 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/glass.js ADDED
@@ -0,0 +1,1631 @@
1
+ #!/usr/bin/env node
2
+
3
+ // packages/cli/src/index.ts
4
+ import { parseArgs } from "node:util";
5
+
6
+ // packages/core/src/version.ts
7
+ var GLASS_VERSION = "0.2.1";
8
+ var UPGRADE_COMMAND = "npx @glassanalytics/cli@latest upgrade";
9
+ function parseSemver(version) {
10
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
11
+ if (!m) return null;
12
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
13
+ }
14
+ function isNewerThan(a, b) {
15
+ const pa = parseSemver(a);
16
+ const pb = parseSemver(b);
17
+ if (!pa || !pb) return a !== b && a > b;
18
+ for (let i = 0; i < 3; i++) {
19
+ const d = pa[i] - pb[i];
20
+ if (d) return d > 0;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ // packages/cli/src/config.ts
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { dirname, join, resolve } from "node:path";
28
+ var GLASS_DIR = ".glass";
29
+ function projectRoot(start = process.cwd()) {
30
+ let dir = resolve(start);
31
+ let pkg = null;
32
+ for (; ; ) {
33
+ if (existsSync(join(dir, GLASS_DIR, "config.json"))) return dir;
34
+ if (!pkg && existsSync(join(dir, "package.json"))) pkg = dir;
35
+ const parent = dirname(dir);
36
+ if (parent === dir) return pkg ?? resolve(start);
37
+ dir = parent;
38
+ }
39
+ }
40
+ function glassDir(root = projectRoot()) {
41
+ return join(root, GLASS_DIR);
42
+ }
43
+ function readJson(path) {
44
+ try {
45
+ return JSON.parse(readFileSync(path, "utf8"));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+ function writeJson(path, value) {
51
+ mkdirSync(dirname(path), { recursive: true });
52
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}
53
+ `);
54
+ }
55
+ function loadConfig(root = projectRoot()) {
56
+ return readJson(join(glassDir(root), "config.json"));
57
+ }
58
+ function saveConfig(config, root = projectRoot()) {
59
+ writeJson(join(glassDir(root), "config.json"), config);
60
+ }
61
+ function loadCredentials(root = projectRoot()) {
62
+ return readJson(join(glassDir(root), "credentials.json"));
63
+ }
64
+ function saveCredentials(credentials, root = projectRoot()) {
65
+ const dir = glassDir(root);
66
+ writeJson(join(dir, "credentials.json"), credentials);
67
+ writeFileSync(join(dir, ".gitignore"), "credentials.json\n");
68
+ }
69
+ function connection() {
70
+ const config = loadConfig();
71
+ const credentials = loadCredentials();
72
+ const host = process.env.GLASS_HOST ?? config?.host;
73
+ const key2 = process.env.GLASS_API_KEY ?? credentials?.adminKey;
74
+ if (!host || !key2) {
75
+ throw new UsageError(
76
+ "No Glass deployment found here.",
77
+ "Run `npx @glassanalytics/cli init` in your project, or set GLASS_HOST and GLASS_API_KEY."
78
+ );
79
+ }
80
+ return { host: host.replace(/\/+$/, ""), key: key2, project: process.env.GLASS_PROJECT ?? config?.project };
81
+ }
82
+ var UsageError = class extends Error {
83
+ hint;
84
+ constructor(message, hint) {
85
+ super(message);
86
+ this.name = "UsageError";
87
+ this.hint = hint;
88
+ }
89
+ };
90
+
91
+ // packages/cli/src/client.ts
92
+ async function call(method, path, body, options = {}) {
93
+ const conn = options.host && options.key ? { host: options.host, key: options.key } : connection();
94
+ const res = await (options.fetcher ?? fetch)(`${conn.host}${path}`, {
95
+ method,
96
+ headers: { Authorization: `Bearer ${conn.key}`, ...body === void 0 ? {} : { "Content-Type": "application/json" } },
97
+ body: body === void 0 ? void 0 : JSON.stringify(body)
98
+ }).catch((err) => {
99
+ throw new UsageError(`Could not reach ${conn.host}: ${err instanceof Error ? err.message : String(err)}`, "Check your network, or run `glass status`.");
100
+ });
101
+ const data = await res.json().catch(() => ({}));
102
+ if (!res.ok) throw new UsageError(data.error ?? `HTTP ${res.status}`, data.hint);
103
+ return data;
104
+ }
105
+ function withProject(value) {
106
+ const project = value.project ?? connection().project;
107
+ return project ? { ...value, project } : value;
108
+ }
109
+
110
+ // packages/cli/src/output.ts
111
+ var useColor = process.stdout.isTTY && !process.env.NO_COLOR;
112
+ var wrap = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
113
+ var color = {
114
+ bold: wrap(1),
115
+ dim: wrap(2),
116
+ green: wrap(32),
117
+ red: wrap(31),
118
+ yellow: wrap(33),
119
+ cyan: wrap(36)
120
+ };
121
+ function line(text = "") {
122
+ process.stdout.write(`${text}
123
+ `);
124
+ }
125
+ function ok(text) {
126
+ line(`${color.green("\u2713")} ${text}`);
127
+ }
128
+ function skip(text) {
129
+ line(`${color.yellow("\u2013")} ${text}`);
130
+ }
131
+ function fail(text) {
132
+ process.stderr.write(`${color.red("\u2717")} ${text}
133
+ `);
134
+ }
135
+ function printJson(value) {
136
+ line(JSON.stringify(value, null, 2));
137
+ }
138
+ function table(headers, rows) {
139
+ const cells = rows.map((r) => r.map((c) => String(c)));
140
+ const widths = headers.map((h, i) => Math.min(60, Math.max(h.length, ...cells.map((r) => (r[i] ?? "").length))));
141
+ const numeric = headers.map((_, i) => rows.length > 0 && rows.every((r) => typeof r[i] === "number"));
142
+ const fmt = (values) => values.map((v, i) => {
143
+ const text = v.length > widths[i] ? `${v.slice(0, widths[i] - 1)}\u2026` : v;
144
+ return numeric[i] ? text.padStart(widths[i]) : text.padEnd(widths[i]);
145
+ }).join(" ").trimEnd();
146
+ line(color.dim(fmt(headers)));
147
+ for (const r of cells) line(fmt(r));
148
+ }
149
+ function pct(value) {
150
+ return `${(value * 100).toFixed(1)}%`;
151
+ }
152
+
153
+ // packages/cli/src/commands/data.ts
154
+ var str = (v) => typeof v === "string" ? v : void 0;
155
+ function showSql(sql2, flags2) {
156
+ if (!flags2.sql || !sql2) return;
157
+ line();
158
+ for (const q of sql2) line(color.dim(q));
159
+ }
160
+ function specFromFlags(kind, flags2) {
161
+ if (typeof flags2.spec === "string") {
162
+ try {
163
+ return JSON.parse(flags2.spec);
164
+ } catch {
165
+ throw new UsageError("--spec is not valid JSON.");
166
+ }
167
+ }
168
+ const common = { from: str(flags2.from), to: str(flags2.to), breakdown: str(flags2.breakdown) };
169
+ const filters = typeof flags2.where === "string" ? flags2.where.split(",").map((w) => {
170
+ const [property, value] = w.split("=");
171
+ if (!property || value === void 0) throw new UsageError(`--where expects property=value pairs, got "${w}".`);
172
+ return { property: property.trim(), value: value.trim() };
173
+ }) : void 0;
174
+ switch (kind) {
175
+ case "web":
176
+ return { from: common.from, to: common.to, filters };
177
+ case "trends": {
178
+ const events2 = str(flags2.event)?.split(",") ?? [];
179
+ if (!events2.length) throw new UsageError("`glass query trends` needs --event <name>[,<name>\u2026]");
180
+ return { ...common, filters, interval: str(flags2.interval), series: events2.map((event) => ({ event, math: str(flags2.math), property: str(flags2.property) })) };
181
+ }
182
+ case "funnel": {
183
+ const steps = str(flags2.steps)?.split(",") ?? [];
184
+ if (steps.length < 2) throw new UsageError("`glass query funnel` needs --steps a,b[,c\u2026]");
185
+ return { ...common, filters, window: str(flags2.window), steps: steps.map((event) => ({ event: event.trim() })) };
186
+ }
187
+ case "retention": {
188
+ const cohort = str(flags2.cohort);
189
+ if (!cohort) throw new UsageError("`glass query retention` needs --cohort <event> (and optionally --return <event>)");
190
+ return {
191
+ from: common.from,
192
+ to: common.to,
193
+ filters,
194
+ cohortEvent: { event: cohort },
195
+ returnEvent: { event: str(flags2.return) ?? cohort },
196
+ period: str(flags2.period),
197
+ periods: flags2.periods ? Number(flags2.periods) : void 0
198
+ };
199
+ }
200
+ default:
201
+ throw new UsageError(`Unknown query "${kind}".`, "Use web, trends, funnel or retention (or `glass sql`).");
202
+ }
203
+ }
204
+ async function query(kind, flags2) {
205
+ if (!kind) throw new UsageError("Which query?", "glass query web|trends|funnel|retention [options]; see `glass help query`.");
206
+ const result = await call("POST", "/api/query", withProject({ kind, ...specFromFlags(kind, flags2), project: str(flags2.project) }));
207
+ if (flags2.json) return printJson(result);
208
+ line(color.bold(result.summary));
209
+ line();
210
+ if (kind === "web") {
211
+ const r = result;
212
+ table(["Metric", "Value", "Previous"], [
213
+ ["Visitors", r.totals.visitors, r.previous?.visitors ?? 0],
214
+ ["Pageviews", r.totals.pageviews, r.previous?.pageviews ?? 0],
215
+ ["Sessions", r.totals.sessions, r.previous?.sessions ?? 0],
216
+ ["Bounce rate", pct(r.totals.bounceRate), r.previous ? pct(r.previous.bounceRate) : "\u2013"]
217
+ ]);
218
+ line();
219
+ table(["Top page", "Visitors"], r.breakdowns.pages.slice(0, 8).map((p) => [p.value, p.visitors]));
220
+ line();
221
+ table(["Referrer", "Visitors"], r.breakdowns.referrers.slice(0, 8).map((p) => [p.value, p.visitors]));
222
+ } else if (kind === "trends") {
223
+ const r = result;
224
+ table(["Series", "Total"], r.series.map((s) => [s.label, s.total]));
225
+ } else if (kind === "funnel") {
226
+ const r = result;
227
+ table(["Step", "People", "From start", "From previous"], r.steps.map((s) => [s.label, s.count, pct(s.conversionFromStart), pct(s.conversionFromPrevious)]));
228
+ } else if (kind === "retention") {
229
+ const r = result;
230
+ table(
231
+ ["Cohort", "People", ...r.average.map((_, k) => `${r.period[0]}${k}`)],
232
+ r.cohorts.map((c) => [new Date(c.start).toISOString().slice(0, 10), c.size, ...c.rates.map((x) => `${Math.round(x * 100)}%`)])
233
+ );
234
+ }
235
+ line();
236
+ line(color.dim(`Open in dashboard: ${result.dashboard}`));
237
+ showSql(result.sql, flags2);
238
+ }
239
+ async function sql(statement, flags2) {
240
+ if (!statement) throw new UsageError('Pass a query: glass sql "SELECT event, COUNT(*) AS n FROM events GROUP BY event"');
241
+ const r = await call("POST", "/api/sql", withProject({ query: statement, limit: flags2.limit ? Number(flags2.limit) : void 0, project: str(flags2.project) }));
242
+ if (flags2.json) return printJson(r);
243
+ table(r.columns, r.rows.map((row) => r.columns.map((c) => typeof row[c] === "number" ? row[c] : String(row[c] ?? ""))));
244
+ line(color.dim(r.summary));
245
+ }
246
+ async function events(name, flags2) {
247
+ const project = str(flags2.project);
248
+ if (name && typeof flags2.describe === "string") {
249
+ const r2 = await call("PUT", `/api/events/${encodeURIComponent(name)}`, withProject({ description: flags2.describe, project }));
250
+ return flags2.json ? printJson(r2) : line(`Saved the description of "${name}".`);
251
+ }
252
+ if (name) {
253
+ const q = project ? `?project=${encodeURIComponent(project)}` : "";
254
+ const r2 = await call("GET", `/api/events/${encodeURIComponent(name)}${q}`);
255
+ if (flags2.json) return printJson(r2);
256
+ line(color.bold(r2.summary));
257
+ if (r2.description) line(r2.description);
258
+ line();
259
+ if (r2.properties.length) table(["Property", "Type", "Coverage", "Examples"], r2.properties.map((p) => [p.name, p.types.join("|"), pct(p.coverage), p.examples.join(", ")]));
260
+ return;
261
+ }
262
+ const r = await call(
263
+ "GET",
264
+ `/api/events?days=${Number(flags2.days ?? 30)}${project ? `&project=${encodeURIComponent(project)}` : ""}`
265
+ );
266
+ if (flags2.json) return printJson(r);
267
+ line(color.bold(r.summary));
268
+ line();
269
+ table(["Event", "Count", "Last seen", "Description"], r.events.map((e) => [e.event, e.count, e.lastSeen.slice(0, 16).replace("T", " "), e.description]));
270
+ }
271
+ async function person(id, flags2) {
272
+ if (!id) throw new UsageError("Pass a person id: glass person <user-id>");
273
+ const q = str(flags2.project) ? `?project=${encodeURIComponent(str(flags2.project))}` : "";
274
+ if (flags2.delete) {
275
+ const r2 = await call("DELETE", `/api/persons/${encodeURIComponent(id)}${q}`);
276
+ return flags2.json ? printJson(r2) : line(r2.summary);
277
+ }
278
+ const r = await call("GET", `/api/persons/${encodeURIComponent(id)}${q}`);
279
+ if (flags2.json) return printJson(r);
280
+ line(color.bold(r.summary));
281
+ for (const [k, v] of Object.entries(r.properties)) line(` ${k}: ${JSON.stringify(v)}`);
282
+ line();
283
+ table(["Time", "Event", "Page"], r.recentEvents.slice(0, 25).map((e) => [e.time.slice(0, 19).replace("T", " "), e.event, e.path]));
284
+ }
285
+ async function replays(sub, arg, flags2) {
286
+ const project = str(flags2.project);
287
+ if (sub === "summary" || sub === "show") {
288
+ if (!arg) throw new UsageError("Pass a session id: glass replays summary <session>");
289
+ const r2 = await call("GET", `/api/replays/${encodeURIComponent(arg)}/summary${project ? `?project=${project}` : ""}`);
290
+ return flags2.json ? printJson(r2) : line(r2.transcript);
291
+ }
292
+ const params = new URLSearchParams();
293
+ if (project) params.set("project", project);
294
+ if (flags2.errors) params.set("with_errors", "true");
295
+ if (typeof flags2.person === "string") params.set("person", flags2.person);
296
+ const r = await call("GET", `/api/replays?${params}`);
297
+ if (flags2.json) return printJson(r);
298
+ line(color.bold(r.summary));
299
+ line();
300
+ 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]));
301
+ }
302
+ async function projects(sub, arg, flags2) {
303
+ if (sub === "add") {
304
+ if (!arg) throw new UsageError("Pass an id: glass projects add <id>");
305
+ const r2 = await call("POST", "/api/projects", { id: arg, name: str(flags2.name) });
306
+ return flags2.json ? printJson(r2) : line(r2.summary);
307
+ }
308
+ const r = await call("GET", "/api/projects");
309
+ if (flags2.json) return printJson(r);
310
+ table(["Project", "Name"], r.projects.map((p) => [p.id, p.name]));
311
+ }
312
+
313
+ // packages/cli/src/commands/flags.ts
314
+ var str2 = (v) => typeof v === "string" ? v : void 0;
315
+ var projectQuery = (flags2) => str2(flags2.project) ? `?project=${encodeURIComponent(str2(flags2.project))}` : "";
316
+ async function flags(sub, key2, flags2) {
317
+ if (!sub || sub === "list") {
318
+ const r = await call("GET", `/api/flags${projectQuery(flags2)}`);
319
+ if (flags2.json) return printJson(r);
320
+ if (!r.flags.length) return line("No flags yet. Create one: glass flags set new-checkout --rollout 10");
321
+ table(["Flag", "On", "Rollout", "Variants"], r.flags.map((f) => [f.key, f.enabled ? "yes" : "no", `${f.rollout}%`, (f.variants ?? []).map((v) => v.key).join(", ")]));
322
+ return;
323
+ }
324
+ if (!key2) throw new UsageError(`Pass a flag key: glass flags ${sub} <key>`);
325
+ if (sub === "delete") {
326
+ const r = await call("DELETE", `/api/flags/${encodeURIComponent(key2)}${projectQuery(flags2)}`);
327
+ return flags2.json ? printJson(r) : line(r.summary);
328
+ }
329
+ if (sub === "set") {
330
+ const body = { project: str2(flags2.project) };
331
+ if (flags2.rollout !== void 0) body.rollout = Number(flags2.rollout);
332
+ if (flags2.enable) body.enabled = true;
333
+ if (flags2.disable) body.enabled = false;
334
+ if (typeof flags2.description === "string") body.description = flags2.description;
335
+ if (typeof flags2.variants === "string") {
336
+ body.variants = flags2.variants.split(",").map((v) => {
337
+ const [k, w] = v.split(":");
338
+ return { key: k.trim(), weight: Number(w ?? 1) };
339
+ });
340
+ }
341
+ if (typeof flags2.rules === "string") body.rules = JSON.parse(flags2.rules);
342
+ const r = await call("PUT", `/api/flags/${encodeURIComponent(key2)}`, withProject(body));
343
+ return flags2.json ? printJson(r) : line(r.summary);
344
+ }
345
+ throw new UsageError(`Unknown subcommand "flags ${sub}".`, "Use: glass flags [list | set <key> | delete <key>]");
346
+ }
347
+ async function experiments(sub, key2, rest, flags2) {
348
+ if (!sub || sub === "list") {
349
+ const r = await call("GET", `/api/experiments${projectQuery(flags2)}`);
350
+ if (flags2.json) return printJson(r);
351
+ if (!r.experiments.length) return line("No experiments yet. Create one: glass experiments create pricing-v2 --goal signup_completed");
352
+ table(["Experiment", "Status", "Goal"], r.experiments.map((e) => [e.key, e.status, e.goal.event]));
353
+ return;
354
+ }
355
+ if (!key2) throw new UsageError(`Pass an experiment key: glass experiments ${sub} <key>`);
356
+ if (sub === "create") {
357
+ const goal = str2(flags2.goal);
358
+ if (!goal) throw new UsageError("Pass the goal event: --goal signup_completed");
359
+ const r = await call(
360
+ "POST",
361
+ "/api/experiments",
362
+ withProject({ key: key2, goal: { event: goal, value: str2(flags2.value) }, flag: str2(flags2.flag), hypothesis: str2(flags2.hypothesis), project: str2(flags2.project) })
363
+ );
364
+ return flags2.json ? printJson(r) : line(r.summary);
365
+ }
366
+ if (sub === "start" || sub === "stop" || sub === "ship") {
367
+ const variant = rest[0] ?? str2(flags2.variant);
368
+ if (sub === "ship" && !variant) throw new UsageError("Pass the variant to ship: glass experiments ship <key> <variant>");
369
+ const r = await call("POST", `/api/experiments/${encodeURIComponent(key2)}/${sub}`, withProject({ variant, project: str2(flags2.project) }));
370
+ return flags2.json ? printJson(r) : line(r.summary);
371
+ }
372
+ if (sub === "results" || sub === "show") {
373
+ const r = await call("GET", `/api/experiments/${encodeURIComponent(key2)}${projectQuery(flags2)}`);
374
+ if (flags2.json) return printJson(r);
375
+ line(color.bold(r.summary));
376
+ line();
377
+ table(
378
+ ["Variant", "Exposed", "Converted", "Rate", "P(beat control)"],
379
+ r.results.variants.map((v) => [v.variant, v.exposures, v.conversions, pct(v.conversionRate), v.vsControl ? pct(v.vsControl.probabilityToBeatControl) : "control"])
380
+ );
381
+ return;
382
+ }
383
+ throw new UsageError(`Unknown subcommand "experiments ${sub}".`, "Use: glass experiments [list | create | start | stop | ship | results]");
384
+ }
385
+
386
+ // packages/cli/src/commands/init.ts
387
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync6 } from "node:fs";
388
+ import { join as join6 } from "node:path";
389
+
390
+ // packages/core/src/ids.ts
391
+ function randomBytes(n) {
392
+ const bytes = new Uint8Array(n);
393
+ crypto.getRandomValues(bytes);
394
+ return bytes;
395
+ }
396
+ function hex(bytes) {
397
+ let out = "";
398
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
399
+ return out;
400
+ }
401
+ function randomToken(bytes = 32) {
402
+ return hex(randomBytes(bytes));
403
+ }
404
+
405
+ // packages/core/src/instrument.ts
406
+ var scriptTag = (o) => `<script defer src="${o.host}/s.js" data-project="${o.project}"></script>`;
407
+ var browserCapture = (event, props) => `window.glass?.capture(${JSON.stringify(event)}${props && Object.keys(props).length ? `, ${JSON.stringify(props)}` : ""});`;
408
+ var moduleCapture = (event, props) => `import { glass } from '@glassanalytics/web';
409
+
410
+ glass.capture(${JSON.stringify(event)}${props && Object.keys(props).length ? `, ${JSON.stringify(props)}` : ""});`;
411
+ var FRAMEWORKS = {
412
+ html: {
413
+ name: "Plain HTML",
414
+ snippet: (o) => ({ file: "index.html (inside <head>, on every page)", code: scriptTag(o), capture: browserCapture })
415
+ },
416
+ nextjs: {
417
+ name: "Next.js",
418
+ snippet: (o) => ({
419
+ file: "app/layout.tsx (App Router) or pages/_app.tsx",
420
+ code: `import Script from 'next/script';
421
+
422
+ // inside <body>:
423
+ <Script src="${o.host}/s.js" data-project="${o.project}" strategy="afterInteractive" />`,
424
+ capture: browserCapture
425
+ })
426
+ },
427
+ astro: {
428
+ name: "Astro",
429
+ snippet: (o) => ({ file: "src/layouts/Layout.astro (inside <head>)", code: scriptTag(o).replace("<script", "<script is:inline"), capture: browserCapture })
430
+ },
431
+ sveltekit: {
432
+ name: "SvelteKit",
433
+ snippet: (o) => ({ file: "src/app.html (inside <head>)", code: scriptTag(o), capture: browserCapture })
434
+ },
435
+ nuxt: {
436
+ name: "Nuxt",
437
+ snippet: (o) => ({
438
+ file: "nuxt.config.ts",
439
+ code: `export default defineNuxtConfig({
440
+ app: { head: { script: [{ src: '${o.host}/s.js', defer: true, 'data-project': '${o.project}' }] } },
441
+ });`,
442
+ capture: browserCapture
443
+ })
444
+ },
445
+ remix: {
446
+ name: "Remix / React Router",
447
+ snippet: (o) => ({ file: "app/root.tsx (inside <head>)", code: scriptTag(o).replace("defer ", "defer={true} "), capture: browserCapture })
448
+ },
449
+ vite: {
450
+ name: "Vite (React, Vue, Svelte, Solid\u2026)",
451
+ snippet: (o) => ({
452
+ file: "src/main.ts (or main.tsx) \u2014 after `npm install @glassanalytics/web`",
453
+ code: `import { glass } from '@glassanalytics/web';
454
+
455
+ glass.init({ host: '${o.host}', project: '${o.project}' });`,
456
+ capture: moduleCapture
457
+ })
458
+ },
459
+ worker: {
460
+ name: "Cloudflare Worker / server-side",
461
+ snippet: (o) => ({
462
+ file: "any server code",
463
+ code: `// Server-side capture: POST events to Glass.
464
+ await fetch('${o.host}/e', {
465
+ method: 'POST',
466
+ body: JSON.stringify({ project: '${o.project}', events: [{ id: crypto.randomUUID(), event: 'order_paid', ts: Date.now(), distinct_id: userId, user_id: userId, props: { amount: 42 } }] }),
467
+ });`,
468
+ capture: (event, props) => `await fetch(GLASS_HOST + '/e', { method: 'POST', body: JSON.stringify({ project: GLASS_PROJECT, events: [{ id: crypto.randomUUID(), event: ${JSON.stringify(event)}, ts: Date.now(), distinct_id: userId, user_id: userId, props: ${JSON.stringify(props ?? {})} }] }) });`
469
+ })
470
+ }
471
+ };
472
+ function detectFramework(pkg) {
473
+ if (!pkg) return "html";
474
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
475
+ const has = (name) => Object.hasOwn(deps, name);
476
+ if (has("next")) return "nextjs";
477
+ if (has("astro")) return "astro";
478
+ if (has("@sveltejs/kit")) return "sveltekit";
479
+ if (has("nuxt")) return "nuxt";
480
+ if (has("@remix-run/react") || has("react-router")) return "remix";
481
+ if (has("vite")) return "vite";
482
+ if (has("wrangler") || has("hono")) return "worker";
483
+ return "html";
484
+ }
485
+ var EVENT_NAMING_GUIDE = 'Name events object_action in snake_case and past tense (signup_completed, plan_upgraded, invite_sent). Put variable details in properties ({ plan: "pro" }), never in the event name. Never send emails, names or other personal data as properties.';
486
+ var EVENT_DOCUMENTATION_GUIDE = 'After a custom event is shipping, call define_event (or `glass events <name> --describe "\u2026"`). The description must say how and when the event is fired (which user action or code path). Document every property with what it means and which values to expect (e.g. description: "Fired when the user finishes checkout after payment succeeds."; properties: { plan: "Chosen plan id (free|pro|enterprise)", amount: "Charge in the account currency" }). list_events returns undocumented custom events \u2014 document those before relying on them in funnels or trends.';
487
+
488
+ // packages/cli/src/agent-files.ts
489
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
490
+ import { dirname as dirname2, join as join2 } from "node:path";
491
+ var START = "<!-- glass:start -->";
492
+ var END = "<!-- glass:end -->";
493
+ function agentsSection(o) {
494
+ return `${START}
495
+ ## Analytics (Glass)
496
+
497
+ 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/\`.
498
+
499
+ - Dashboard: ${o.host}/app (print the key with \`npx @glassanalytics/cli key\`)
500
+ - MCP server \`glass\` is configured in \`.mcp.json\`. Start with the \`overview\` tool, then \`list_events\` for exact event names.
501
+ - Terminal: \`npx @glassanalytics/cli query web --from -7d\`, \`npx @glassanalytics/cli sql "SELECT \u2026"\`, \`npx @glassanalytics/cli --help\`
502
+
503
+ Tracking (project \`${o.project}\`):
504
+ - Install (${o.snippet.file}): \`${o.snippet.code.split("\n").pop()}\`
505
+ - Custom events: \`glass.capture('signup_completed', { plan: 'pro' })\`. After login: \`glass.identify(user.id, { plan })\`.
506
+ - ${EVENT_NAMING_GUIDE}
507
+ - After adding an event and deploying, confirm it arrives: \`npx @glassanalytics/cli events <name>\` (or MCP \`describe_event\`).
508
+ - ${EVENT_DOCUMENTATION_GUIDE}
509
+ - Prefer MCP \`define_event\` so you can set both the event description and each property's meaning in one call.
510
+ - Feature flags: \`glass.flag('key')\` returns "on", a variant key, or null. Create with \`npx @glassanalytics/cli flags set <key> --rollout 10\`.
511
+
512
+ Upgrades (the Worker version is pinned in \`.glass/config.json\`):
513
+ - \`.github/workflows/glass.yml\` deploys whatever \`.glass/\` holds when it changes on the default branch. If that file is missing, run \`npx @glassanalytics/cli ci\` and add the \`CLOUDFLARE_API_TOKEN\` repository secret.
514
+ - To upgrade: \`npx @glassanalytics/cli@latest upgrade\` stages the new Worker and migrations into \`.glass/\` without deploying. Commit and push (or open a PR); CI deploys. Ask before pushing if pushes also deploy the site.
515
+ - Do not deploy Glass from a laptop (\`glass upgrade --local\`, wrangler) once the workflow exists, and never commit \`.glass/credentials.json\`.
516
+ - \`glass upgrade\` refuses to replace a newer Worker with an older CLI. If it does, use \`@latest\`; do not pass \`--allow-downgrade\`.
517
+ ${END}`;
518
+ }
519
+ function upsertSection(path, section, createIfMissing) {
520
+ const exists = existsSync2(path);
521
+ if (!exists && !createIfMissing) return false;
522
+ const current = exists ? readFileSync2(path, "utf8") : "";
523
+ const pattern = new RegExp(`${START}[\\s\\S]*?${END}`);
524
+ const next = pattern.test(current) ? current.replace(pattern, section) : `${current.trimEnd()}${current.trim() ? "\n\n" : ""}${section}
525
+ `;
526
+ writeFileSync2(path, next);
527
+ return true;
528
+ }
529
+ function skillFile(o) {
530
+ return `---
531
+ name: glass
532
+ 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.
533
+ ---
534
+
535
+ # Glass analytics
536
+
537
+ Glass is deployed at ${o.host} (project \`${o.project}\`). Prefer the \`glass\` MCP tools; the CLI (\`npx @glassanalytics/cli \u2026\`) does the same things.
538
+
539
+ ## Answer a question about usage
540
+
541
+ 1. \`overview\` for the period (visitors, top pages, errors, events that moved). If it (or MCP init) says an upgrade is available, tell the user; with their go-ahead, follow "Upgrade Glass" below.
542
+ 2. \`list_events\` to get exact event names \u2014 never guess them.
543
+ 3. The right query: \`query_trends\` (counts over time), \`query_funnel\` (conversion through steps), \`query_retention\` (who comes back), \`sql\` when nothing else fits.
544
+ 4. Report numbers with the dashboard link and SQL from the result.
545
+
546
+ ## Add tracking
547
+
548
+ 1. \`instrument\` with the event name returns the exact code for this framework.
549
+ 2. ${EVENT_NAMING_GUIDE}
550
+ 3. After deploying, \`describe_event\` (or \`npx @glassanalytics/cli events <name>\`) confirms events arrive with the expected properties.
551
+ 4. Always \`define_event\` next. ${EVENT_DOCUMENTATION_GUIDE}
552
+
553
+ ## First session on a project
554
+
555
+ 1. \`overview\` then \`list_events\`.
556
+ 2. For every name in \`undocumented\`, read the code that fires it (or ask), then \`define_event\` with how/when it fires and property meanings.
557
+ 3. Do not invent funnel or trend queries against undocumented custom events until those descriptions exist.
558
+
559
+ ## Debug a user problem
560
+
561
+ 1. \`query_funnel\` with a breakdown (browser, device, utm_source) to find where people drop.
562
+ 2. \`list_replays\` with \`with_errors: true\` (or a \`person\`) to find affected sessions.
563
+ 3. \`summarise_replay\` to read what happened: pages, labelled clicks, rage clicks, errors.
564
+
565
+ ## Upgrade Glass
566
+
567
+ 1. \`npx @glassanalytics/cli@latest upgrade\`. With \`.github/workflows/glass.yml\` present this only stages \`.glass/\` (Worker, migrations, pinned version); nothing deploys.
568
+ 2. No workflow yet? \`npx @glassanalytics/cli ci\` writes it; the repo needs secret \`CLOUDFLARE_API_TOKEN\` (\`gh secret set CLOUDFLARE_API_TOKEN\`).
569
+ 3. Commit \`.glass/\` and push to the default branch (or open a PR). The Glass workflow migrates D1 and deploys.
570
+ 4. \`npx @glassanalytics/cli status\` confirms the new version is live.
571
+
572
+ ## Run an experiment
573
+
574
+ 1. \`create_experiment\` with a goal event (creates a control/test flag).
575
+ 2. In code: \`if (glass.flag('<key>') === 'test') { \u2026 }\` \u2014 reading the flag records the exposure.
576
+ 3. \`update_experiment\` with \`action: "start"\`; later \`experiment_results\`; ship the winner with \`action: "ship"\`.
577
+ `;
578
+ }
579
+ function upsertMcpConfig(path) {
580
+ let config = {};
581
+ if (existsSync2(path)) {
582
+ try {
583
+ config = JSON.parse(readFileSync2(path, "utf8"));
584
+ } catch {
585
+ return false;
586
+ }
587
+ }
588
+ config.mcpServers = { ...config.mcpServers, glass: { command: "npx", args: ["-y", "@glassanalytics/cli", "mcp"] } };
589
+ mkdirSync2(dirname2(path), { recursive: true });
590
+ writeFileSync2(path, `${JSON.stringify(config, null, 2)}
591
+ `);
592
+ return true;
593
+ }
594
+ function writeAgentFiles(o) {
595
+ const written = [];
596
+ const section = agentsSection(o);
597
+ if (upsertSection(join2(o.root, "AGENTS.md"), section, true)) written.push("AGENTS.md");
598
+ if (upsertSection(join2(o.root, "CLAUDE.md"), section, false)) written.push("CLAUDE.md");
599
+ const skill = join2(o.root, ".claude", "skills", "glass", "SKILL.md");
600
+ mkdirSync2(dirname2(skill), { recursive: true });
601
+ writeFileSync2(skill, skillFile(o));
602
+ written.push(".claude/skills/glass/SKILL.md");
603
+ if (upsertMcpConfig(join2(o.root, ".mcp.json"))) written.push(".mcp.json");
604
+ if (existsSync2(join2(o.root, ".cursor")) && upsertMcpConfig(join2(o.root, ".cursor", "mcp.json"))) written.push(".cursor/mcp.json");
605
+ return written;
606
+ }
607
+
608
+ // packages/cli/src/ci.ts
609
+ import { execFileSync } from "node:child_process";
610
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
611
+ import { dirname as dirname3, join as join3 } from "node:path";
612
+ var CI_WORKFLOW_PATH = ".github/workflows/glass.yml";
613
+ var CI_SECRET = "CLOUDFLARE_API_TOKEN";
614
+ var MARKER = "# Generated by `glass ci`.";
615
+ function inCi(env = process.env) {
616
+ return env.CI === "true" || env.CI === "1" || env.GITHUB_ACTIONS === "true";
617
+ }
618
+ function hasCiWorkflow(root) {
619
+ return existsSync3(join3(root, CI_WORKFLOW_PATH));
620
+ }
621
+ function defaultBranch(root) {
622
+ const git = (args) => {
623
+ try {
624
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
625
+ } catch {
626
+ return "";
627
+ }
628
+ };
629
+ const head = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).replace(/^origin\//, "");
630
+ return head || git(["branch", "--show-current"]) || "main";
631
+ }
632
+ function ciWorkflow(o) {
633
+ return `${MARKER} Deploys the Glass analytics Worker from .glass/.
634
+ #
635
+ # The version is pinned in .glass/config.json. To upgrade:
636
+ # npx @glassanalytics/cli@latest upgrade # stages the new Worker into .glass/
637
+ # git commit .glass && git push # this workflow migrates and deploys it
638
+ #
639
+ # Needs repository secret ${o.secret}: a Cloudflare API token with
640
+ # Workers Scripts Edit, D1 Edit, Workers R2 Storage Edit, Account Settings Read
641
+ # (and Zone Workers Routes Edit if .glass/config.json has a route).
642
+ # Worker secrets (API keys) are set by \`glass init\` and survive deploys.
643
+ name: Glass
644
+
645
+ on:
646
+ push:
647
+ branches: [${o.branch}]
648
+ paths: ['.glass/**', '${CI_WORKFLOW_PATH}']
649
+ workflow_dispatch:
650
+
651
+ concurrency:
652
+ group: glass-deploy
653
+ cancel-in-progress: false
654
+
655
+ jobs:
656
+ deploy:
657
+ runs-on: ubuntu-latest
658
+ permissions:
659
+ contents: read
660
+ steps:
661
+ - uses: actions/checkout@v4
662
+ - uses: actions/setup-node@v4
663
+ with:
664
+ node-version: 22
665
+ - name: Deploy the Glass version pinned in .glass/config.json
666
+ run: |
667
+ version="$(node -p "require('./.glass/config.json').version")"
668
+ npx -y "@glassanalytics/cli@\${version}" upgrade --ci
669
+ env:
670
+ CLOUDFLARE_API_TOKEN: \${{ secrets.${o.secret} }}
671
+ `;
672
+ }
673
+ function writeCiWorkflow(root, options = {}) {
674
+ const branch = options.branch ?? defaultBranch(root);
675
+ const secret = options.secret ?? CI_SECRET;
676
+ const path = join3(root, CI_WORKFLOW_PATH);
677
+ const next = ciWorkflow({ branch, secret });
678
+ if (existsSync3(path)) {
679
+ const current = readFileSync3(path, "utf8");
680
+ if (!current.startsWith(MARKER)) return { path: CI_WORKFLOW_PATH, status: "kept", branch, secret };
681
+ if (current === next) return { path: CI_WORKFLOW_PATH, status: "kept", branch, secret };
682
+ writeFileSync3(path, next);
683
+ return { path: CI_WORKFLOW_PATH, status: "updated", branch, secret };
684
+ }
685
+ mkdirSync3(dirname3(path), { recursive: true });
686
+ writeFileSync3(path, next);
687
+ return { path: CI_WORKFLOW_PATH, status: "created", branch, secret };
688
+ }
689
+ function ciNextSteps(o) {
690
+ return [
691
+ `Add repository secret ${o.secret} (a Cloudflare API token; same scopes as init): gh secret set ${o.secret}`,
692
+ `Commit .glass/ (not credentials.json, which is git-ignored) and ${CI_WORKFLOW_PATH}, then push to ${o.branch}.`,
693
+ "Upgrade later with `npx @glassanalytics/cli@latest upgrade`: it stages the new version into .glass/ without deploying. Commit and push; the workflow deploys it.",
694
+ "Do not deploy Glass from a laptop once this workflow exists. `glass upgrade --local` overrides that for emergencies only."
695
+ ];
696
+ }
697
+
698
+ // packages/cli/src/cloudflare/provision.ts
699
+ import { copyFileSync, mkdirSync as mkdirSync4, readdirSync, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
700
+ import { join as join4 } from "node:path";
701
+
702
+ // packages/core/src/events.ts
703
+ var EVENT_COLUMNS = [
704
+ "id",
705
+ "project",
706
+ "event",
707
+ "ts_ms",
708
+ "distinct_id",
709
+ "user_id",
710
+ "person_id",
711
+ "session_id",
712
+ "url",
713
+ "host",
714
+ "path",
715
+ "referrer",
716
+ "referrer_host",
717
+ "title",
718
+ "utm_source",
719
+ "utm_medium",
720
+ "utm_campaign",
721
+ "utm_term",
722
+ "utm_content",
723
+ "country",
724
+ "region",
725
+ "city",
726
+ "device",
727
+ "browser",
728
+ "os",
729
+ "flags",
730
+ "props",
731
+ "person_props",
732
+ "lib",
733
+ "received_ms"
734
+ ];
735
+
736
+ // packages/core/src/lake.ts
737
+ var INTEGER_COLUMNS = /* @__PURE__ */ new Set(["ts_ms", "received_ms"]);
738
+ var OPTIONAL_COLUMNS = /* @__PURE__ */ new Set(["user_id"]);
739
+ var LAKE_TABLES = {
740
+ events: {
741
+ stream: "glass_events",
742
+ fields: [
743
+ ...EVENT_COLUMNS.map(
744
+ (name) => ({
745
+ name,
746
+ type: INTEGER_COLUMNS.has(name) ? "int64" : "string",
747
+ required: !OPTIONAL_COLUMNS.has(name)
748
+ })
749
+ ),
750
+ { name: "ts", type: "timestamp", required: true }
751
+ ]
752
+ },
753
+ identities: {
754
+ stream: "glass_identities",
755
+ fields: [
756
+ { name: "project", type: "string", required: true },
757
+ { name: "distinct_id", type: "string", required: true },
758
+ { name: "person_id", type: "string", required: true },
759
+ { name: "created_at", type: "int64", required: true }
760
+ ]
761
+ }
762
+ };
763
+ function streamSchemaFile(table2) {
764
+ return { fields: [...LAKE_TABLES[table2].fields] };
765
+ }
766
+
767
+ // packages/cli/src/cloudflare/wrangler.ts
768
+ import { spawn } from "node:child_process";
769
+ import { createRequire } from "node:module";
770
+ var WranglerError = class extends Error {
771
+ result;
772
+ args;
773
+ constructor(args, result) {
774
+ const output = `${result.stderr}
775
+ ${result.stdout}`.trim().split("\n").slice(-12).join("\n");
776
+ super(`wrangler ${args.slice(0, 3).join(" ")} failed:
777
+ ${output}`);
778
+ this.args = args;
779
+ this.result = result;
780
+ }
781
+ get output() {
782
+ return `${this.result.stdout}
783
+ ${this.result.stderr}`;
784
+ }
785
+ };
786
+ function wranglerCommand() {
787
+ try {
788
+ const require2 = createRequire(import.meta.url);
789
+ return { cmd: process.execPath, prefix: [require2.resolve("wrangler/bin/wrangler.js")] };
790
+ } catch {
791
+ return { cmd: process.platform === "win32" ? "npx.cmd" : "npx", prefix: ["--yes", "wrangler@4"] };
792
+ }
793
+ }
794
+ function createWrangler(auth, cwd) {
795
+ const { cmd, prefix } = wranglerCommand();
796
+ return {
797
+ run(args, options = {}) {
798
+ return new Promise((resolve2, reject) => {
799
+ const child = spawn(cmd, [...prefix, ...args], {
800
+ cwd,
801
+ env: {
802
+ ...process.env,
803
+ CLOUDFLARE_API_TOKEN: auth.token,
804
+ CLOUDFLARE_ACCOUNT_ID: auth.accountId,
805
+ CI: "1",
806
+ WRANGLER_SEND_METRICS: "false"
807
+ },
808
+ stdio: ["pipe", "pipe", "pipe"]
809
+ });
810
+ let stdout = "";
811
+ let stderr = "";
812
+ child.stdout.on("data", (d) => {
813
+ stdout += d.toString();
814
+ });
815
+ child.stderr.on("data", (d) => {
816
+ stderr += d.toString();
817
+ });
818
+ child.on("error", reject);
819
+ child.on("close", (code) => resolve2({ code: code ?? 1, stdout, stderr }));
820
+ if (options.input !== void 0) child.stdin.write(options.input);
821
+ child.stdin.end();
822
+ });
823
+ }
824
+ };
825
+ }
826
+ async function must(wrangler, args, options) {
827
+ const result = await wrangler.run(args, options);
828
+ if (result.code !== 0) throw new WranglerError(args, result);
829
+ return result.stdout;
830
+ }
831
+ function parseJsonOutput(stdout) {
832
+ const start = stdout.search(/[[{]/);
833
+ if (start < 0) throw new Error(`Expected JSON from wrangler, got: ${stdout.slice(0, 200)}`);
834
+ return JSON.parse(stdout.slice(start));
835
+ }
836
+
837
+ // packages/cli/src/cloudflare/provision.ts
838
+ var isAlreadyExists = (err) => err instanceof WranglerError && /already exists|already own|10004|11009/i.test(err.output);
839
+ var isR2Disabled = (err) => err instanceof WranglerError && /enable R2|R2 (is|has) not been enabled|purchase R2|10042/i.test(err.output);
840
+ async function ensureD1(wrangler, name, eu, report) {
841
+ const find = async () => parseJsonOutput(await must(wrangler, ["d1", "list", "--json"])).find((db2) => db2.name === name);
842
+ let db = await find();
843
+ if (db) {
844
+ report("ok", `D1 database "${name}"`, "exists");
845
+ } else {
846
+ await must(wrangler, ["d1", "create", name, ...eu ? ["--jurisdiction", "eu"] : []]);
847
+ db = await find();
848
+ if (!db) throw new Error(`Created D1 database "${name}" but could not find it afterwards.`);
849
+ report("created", `D1 database "${name}"`);
850
+ }
851
+ return { name, id: db.uuid };
852
+ }
853
+ async function ensureBucket(wrangler, bucket, eu, report, purpose) {
854
+ try {
855
+ await must(wrangler, ["r2", "bucket", "create", bucket, ...eu ? ["--jurisdiction", "eu"] : []]);
856
+ report("created", `R2 bucket "${bucket}" for ${purpose}`);
857
+ return true;
858
+ } catch (err) {
859
+ if (isAlreadyExists(err)) {
860
+ report("ok", `R2 bucket "${bucket}" for ${purpose}`, "exists");
861
+ return true;
862
+ }
863
+ if (isR2Disabled(err)) return false;
864
+ throw err;
865
+ }
866
+ }
867
+ async function findByName(wrangler, kind, name) {
868
+ const args = kind ? ["pipelines", kind, "list", "--json", "--per-page", "100"] : ["pipelines", "list", "--json", "--per-page", "100"];
869
+ const items = parseJsonOutput(await must(wrangler, args));
870
+ return items.find((i) => i.name === name);
871
+ }
872
+ async function ensureLake(wrangler, options, report) {
873
+ if (!await ensureBucket(wrangler, options.bucket, options.eu, report, "event lake")) {
874
+ throw new Error("Lake mode needs R2. Enable R2 in the Cloudflare dashboard, then run `glass init --lake` again.");
875
+ }
876
+ let enabled = false;
877
+ for (const args of [
878
+ ["r2", "bucket", "catalog", "enable", options.bucket],
879
+ ["basin", "catalog", "enable", options.bucket]
880
+ ]) {
881
+ const r = await wrangler.run(args);
882
+ if (r.code === 0 || /already enabled/i.test(r.stdout + r.stderr)) {
883
+ enabled = true;
884
+ break;
885
+ }
886
+ }
887
+ if (!enabled) throw new Error(`Could not enable the data catalog on "${options.bucket}". Enable it in the R2 dashboard, then re-run.`);
888
+ report("ok", `Data catalog on "${options.bucket}"`);
889
+ const streams = {};
890
+ for (const table2 of Object.keys(LAKE_TABLES)) {
891
+ const streamName = LAKE_TABLES[table2].stream;
892
+ const sinkName = `${streamName}_sink`;
893
+ let stream = await findByName(wrangler, "streams", streamName);
894
+ if (!stream) {
895
+ const schemaPath = join4(options.dir, "schemas", `${table2}.json`);
896
+ mkdirSync4(join4(options.dir, "schemas"), { recursive: true });
897
+ writeFileSync4(schemaPath, `${JSON.stringify(streamSchemaFile(table2), null, 2)}
898
+ `);
899
+ await must(wrangler, ["pipelines", "streams", "create", streamName, "--schema-file", schemaPath, "--http-enabled", "false"]);
900
+ stream = await findByName(wrangler, "streams", streamName);
901
+ if (!stream) throw new Error(`Created stream "${streamName}" but could not find it.`);
902
+ report("created", `Pipelines stream "${streamName}"`);
903
+ }
904
+ streams[table2] = stream.id;
905
+ if (!await findByName(wrangler, "sinks", sinkName)) {
906
+ await must(wrangler, [
907
+ "pipelines",
908
+ "sinks",
909
+ "create",
910
+ sinkName,
911
+ "--type",
912
+ "r2-data-catalog",
913
+ "--bucket",
914
+ options.bucket,
915
+ "--namespace",
916
+ options.namespace,
917
+ "--table",
918
+ table2,
919
+ "--catalog-token",
920
+ options.token,
921
+ "--roll-interval",
922
+ "60"
923
+ ]);
924
+ report("created", `Iceberg table "${options.namespace}.${table2}"`);
925
+ }
926
+ if (!await findByName(wrangler, null, streamName)) {
927
+ await must(wrangler, ["pipelines", "create", streamName, "--sql", `INSERT INTO ${sinkName} SELECT * FROM ${streamName}`]);
928
+ report("created", `Pipeline "${streamName}"`);
929
+ }
930
+ }
931
+ return { bucket: options.bucket, namespace: options.namespace, streams };
932
+ }
933
+ function renderWranglerConfig(config) {
934
+ const vars = { GLASS_STORE: config.store };
935
+ if (config.lake) {
936
+ vars.LAKE_BUCKET = config.lake.bucket;
937
+ vars.LAKE_NAMESPACE = config.lake.namespace;
938
+ vars.CF_ACCOUNT_ID = config.accountId;
939
+ }
940
+ if (config.route) vars.GLASS_PUBLIC_URL = `https://${config.route.replace(/\/\*$/, "")}`;
941
+ const wrangler = {
942
+ name: config.name,
943
+ main: "worker.js",
944
+ compatibility_date: "2026-10-01",
945
+ workers_dev: true,
946
+ d1_databases: [{ binding: "DB", database_name: config.d1.name, database_id: config.d1.id, migrations_dir: "migrations" }],
947
+ vars,
948
+ triggers: { crons: ["17 3 * * *"] },
949
+ observability: { enabled: true }
950
+ };
951
+ if (config.replay) wrangler.r2_buckets = [{ binding: "REPLAYS", bucket_name: config.replay.bucket }];
952
+ if (config.lake) {
953
+ wrangler.pipelines = [
954
+ { binding: "EVENTS_STREAM", stream: config.lake.streams.events },
955
+ { binding: "IDENTITIES_STREAM", stream: config.lake.streams.identities }
956
+ ];
957
+ }
958
+ if (config.route) {
959
+ const zone = config.route.split("/")[0].split(".").slice(-2).join(".");
960
+ wrangler.routes = [{ pattern: config.route, zone_name: zone }];
961
+ }
962
+ return `// Generated by \`glass\`. Edit .glass/config.json and run \`glass upgrade\` instead of editing this file.
963
+ ${JSON.stringify(wrangler, null, 2)}
964
+ `;
965
+ }
966
+ function installWorkerFiles(dir, bundleDir) {
967
+ mkdirSync4(join4(dir, "migrations"), { recursive: true });
968
+ copyFileSync(join4(bundleDir, "worker.js"), join4(dir, "worker.js"));
969
+ for (const file of readdirSync(join4(bundleDir, "migrations"))) {
970
+ copyFileSync(join4(bundleDir, "migrations", file), join4(dir, "migrations", file));
971
+ }
972
+ }
973
+ async function deploy(wrangler, configPath) {
974
+ const out = await must(wrangler, ["deploy", "--config", configPath]);
975
+ return /https:\/\/[\w.-]+\.workers\.dev/.exec(out)?.[0] ?? null;
976
+ }
977
+ async function migrate(wrangler, database, configPath) {
978
+ await must(wrangler, ["d1", "migrations", "apply", database, "--remote", "--config", configPath]);
979
+ }
980
+ async function putSecrets(wrangler, secrets, configPath, dir) {
981
+ const file = join4(dir, ".secrets.tmp.json");
982
+ writeFileSync4(file, JSON.stringify(secrets), { mode: 384 });
983
+ try {
984
+ await must(wrangler, ["secret", "bulk", file, "--config", configPath]);
985
+ } finally {
986
+ rmSync(file, { force: true });
987
+ }
988
+ }
989
+ async function listAccounts(token, fetcher = fetch) {
990
+ const res = await fetcher("https://api.cloudflare.com/client/v4/accounts?per_page=50", { headers: { Authorization: `Bearer ${token}` } });
991
+ const body = await res.json().catch(() => ({}));
992
+ if (!res.ok || !body.success) {
993
+ throw new Error(`Cloudflare rejected the API token: ${body.errors?.[0]?.message ?? `HTTP ${res.status}`}`);
994
+ }
995
+ return body.result ?? [];
996
+ }
997
+ var TOKEN_SCOPES = [
998
+ "Account \u203A Workers Scripts \u203A Edit",
999
+ "Account \u203A D1 \u203A Edit",
1000
+ "Account \u203A Workers R2 Storage \u203A Edit",
1001
+ "Account \u203A Account Settings \u203A Read",
1002
+ "Zone \u203A Workers Routes \u203A Edit (only with --route)",
1003
+ "Account \u203A Pipelines \u203A Edit, Workers R2 Data Catalog \u203A Edit, Workers R2 SQL \u203A Read (only with --lake)"
1004
+ ];
1005
+
1006
+ // packages/cli/src/prompt.ts
1007
+ import { createInterface } from "node:readline/promises";
1008
+ var interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);
1009
+ async function ask(question, options) {
1010
+ if (!interactive()) {
1011
+ if (options.default !== void 0) return options.default;
1012
+ throw new UsageError(`Missing ${options.flag}.`, `Pass ${options.flag} (this terminal is not interactive).`);
1013
+ }
1014
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
1015
+ if (options.secret) {
1016
+ const write = rl._writeToOutput.bind(rl);
1017
+ rl._writeToOutput = (s) => write(s.startsWith(question) ? s : "*");
1018
+ }
1019
+ const suffix = options.default ? ` (${options.default})` : "";
1020
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
1021
+ rl.close();
1022
+ if (options.secret) process.stdout.write("\n");
1023
+ return answer || options.default || "";
1024
+ }
1025
+ async function choose(question, items, label, flag) {
1026
+ if (items.length === 1) return items[0];
1027
+ if (!interactive()) throw new UsageError(`Several options for ${flag}: ${items.map(label).join(", ")}.`, `Pass ${flag}.`);
1028
+ items.forEach((item2, i) => process.stdout.write(` ${i + 1}. ${label(item2)}
1029
+ `));
1030
+ const answer = Number(await ask(question, { flag, default: "1" }));
1031
+ const item = items[answer - 1];
1032
+ if (!item) throw new UsageError(`Pick a number from 1 to ${items.length}.`);
1033
+ return item;
1034
+ }
1035
+
1036
+ // packages/cli/src/commands/upgrade.ts
1037
+ import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "node:fs";
1038
+ import { join as join5 } from "node:path";
1039
+ import { fileURLToPath } from "node:url";
1040
+ function workerBundleDir() {
1041
+ const candidates = [new URL("./worker/", import.meta.url), new URL("../../../worker/dist/", import.meta.url)];
1042
+ for (const c of candidates) {
1043
+ const dir = fileURLToPath(c);
1044
+ if (existsSync4(join5(dir, "worker.js")) && existsSync4(join5(dir, "migrations"))) return dir;
1045
+ }
1046
+ throw new UsageError("The Worker bundle is missing.", "If you are working on Glass itself, run `pnpm build` first.");
1047
+ }
1048
+ async function liveVersion(host, fetcher) {
1049
+ if (!host) return null;
1050
+ try {
1051
+ const res = await fetcher(`${host}/health`, { signal: AbortSignal.timeout(5e3) });
1052
+ if (!res.ok) return null;
1053
+ const body = await res.json();
1054
+ return typeof body.version === "string" ? body.version : null;
1055
+ } catch {
1056
+ return null;
1057
+ }
1058
+ }
1059
+ async function upgrade(options, deps = {}) {
1060
+ const root = deps.root ?? projectRoot();
1061
+ const env = deps.env ?? process.env;
1062
+ const config = loadConfig(root);
1063
+ if (!config) throw new UsageError("No Glass deployment in this project.", "Run `npx @glassanalytics/cli init` first.");
1064
+ const ci2 = options.ci ?? inCi(env);
1065
+ const credentials = loadCredentials(root);
1066
+ if (!credentials && !ci2) throw new UsageError("No .glass/credentials.json in this project.", "Run `npx @glassanalytics/cli init` again to restore keys, or deploy from CI (`glass upgrade --ci`), which leaves Worker secrets as they are.");
1067
+ const dir = glassDir(root);
1068
+ const bundle = deps.bundleDir ?? workerBundleDir();
1069
+ const live = await liveVersion(config.host, deps.fetcher ?? fetch);
1070
+ const deployed = [config.version, live].filter((v) => Boolean(v)).sort((a, b) => isNewerThan(a, b) ? -1 : 1)[0];
1071
+ if (deployed && isNewerThan(deployed, GLASS_VERSION) && !options.allowDowngrade) {
1072
+ throw new UsageError(
1073
+ `This deployment runs Glass ${deployed}, which is newer than this CLI (${GLASS_VERSION}). Nothing was changed.`,
1074
+ `Run \`${UPGRADE_COMMAND}\` (or npx @glassanalytics/cli@${deployed} or later). --allow-downgrade forces the older Worker.`
1075
+ );
1076
+ }
1077
+ const from = config.version;
1078
+ if (hasCiWorkflow(root) && !ci2 && !options.local) {
1079
+ installWorkerFiles(dir, bundle);
1080
+ config.version = GLASS_VERSION;
1081
+ writeFileSync5(join5(dir, "wrangler.jsonc"), renderWranglerConfig(config));
1082
+ saveConfig(config, root);
1083
+ const branch = defaultBranch(root);
1084
+ if (options.json) {
1085
+ printJson({ from, to: GLASS_VERSION, staged: true, deploy: CI_WORKFLOW_PATH, next: `Commit .glass/ and push to ${branch}` });
1086
+ return;
1087
+ }
1088
+ ok(`Staged Glass ${from === GLASS_VERSION ? GLASS_VERSION : `${from} \u2192 ${GLASS_VERSION}`} in .glass/ (nothing deployed)`);
1089
+ line(` Commit .glass/ and push to ${branch}; ${CI_WORKFLOW_PATH} migrates and deploys it.`);
1090
+ line(color.dim(" Deploying from this machine instead: glass upgrade --local"));
1091
+ return;
1092
+ }
1093
+ const token = options.token ?? env.CLOUDFLARE_API_TOKEN ?? (ci2 ? void 0 : await ask("Cloudflare API token", { flag: "--token (or CLOUDFLARE_API_TOKEN)", secret: true }));
1094
+ if (!token) throw new UsageError("CLOUDFLARE_API_TOKEN is not set.", `In CI, map the repository secret to it (see ${CI_WORKFLOW_PATH}).`);
1095
+ const wrangler = (deps.wrangler ?? createWrangler)({ token, accountId: config.accountId }, root);
1096
+ const report = (status2, label, detail) => {
1097
+ if (options.json) return;
1098
+ (status2 === "skipped" ? skip : ok)(`${label}${detail ? color.dim(` (${detail})`) : ""}`);
1099
+ };
1100
+ if (!config.replay && await ensureBucket(wrangler, `${config.name}-replays`, config.eu, report, "session replay")) {
1101
+ config.replay = { bucket: `${config.name}-replays` };
1102
+ }
1103
+ installWorkerFiles(dir, bundle);
1104
+ const configPath = join5(dir, "wrangler.jsonc");
1105
+ writeFileSync5(configPath, renderWranglerConfig(config));
1106
+ await migrate(wrangler, config.d1.name, configPath);
1107
+ report("ok", "Migrations applied");
1108
+ await deploy(wrangler, configPath);
1109
+ if (credentials) {
1110
+ await putSecrets(
1111
+ wrangler,
1112
+ {
1113
+ GLASS_ADMIN_KEY: credentials.adminKey,
1114
+ GLASS_READ_KEY: credentials.readKey,
1115
+ GLASS_SALT_SECRET: credentials.saltSecret
1116
+ // CF_API_TOKEN (lake mode) is set by init and left alone here, so a narrower token you set stays.
1117
+ },
1118
+ configPath,
1119
+ dir
1120
+ );
1121
+ } else {
1122
+ report("skipped", "Worker secrets", "no credentials.json here; the keys set by init stay in place");
1123
+ }
1124
+ config.version = GLASS_VERSION;
1125
+ saveConfig(config, root);
1126
+ if (options.json) printJson({ from, to: GLASS_VERSION, host: config.host, replay: Boolean(config.replay), staged: false });
1127
+ else ok(`Glass ${from === GLASS_VERSION ? GLASS_VERSION : `${from} upgraded to ${GLASS_VERSION}`} is live at ${config.host}`);
1128
+ if (!ci2 && !hasCiWorkflow(root) && !options.json) {
1129
+ line(color.dim(` Tip: \`glass ci\` writes ${CI_WORKFLOW_PATH} so future upgrades deploy from CI instead of this machine.`));
1130
+ }
1131
+ }
1132
+ function ci(options, deps = {}) {
1133
+ const root = deps.root ?? projectRoot();
1134
+ if (!loadConfig(root)) throw new UsageError("No Glass deployment in this project.", "Run `npx @glassanalytics/cli init` first.");
1135
+ const result = writeCiWorkflow(root, { branch: options.branch, secret: options.secret });
1136
+ const next = ciNextSteps(result);
1137
+ if (options.json) {
1138
+ printJson({ ...result, next });
1139
+ return;
1140
+ }
1141
+ (result.status === "kept" ? skip : ok)(`${result.path} ${result.status === "kept" ? "already exists (left as is)" : result.status}`);
1142
+ line();
1143
+ line(color.bold("Finish CI setup"));
1144
+ for (const [i, step] of next.entries()) line(` ${i + 1}. ${step}`);
1145
+ }
1146
+ async function status(options) {
1147
+ const conn = connection();
1148
+ const config = loadConfig();
1149
+ const health = await call("GET", "/health");
1150
+ const events2 = await call("GET", `/api/events?days=1${conn.project ? `&project=${conn.project}` : ""}`).catch(() => ({ events: [] }));
1151
+ const total = events2.events.reduce((a, e) => a + e.count, 0);
1152
+ const last = events2.events.map((e) => e.lastSeen).sort().at(-1);
1153
+ const mcp = existsSync4(join5(projectRoot(), ".mcp.json"));
1154
+ const cliMismatch = health.version !== GLASS_VERSION;
1155
+ const npmNewer = Boolean(health.latest && isNewerThan(health.latest, health.version));
1156
+ const upgradeAvailable = Boolean(health.upgradeAvailable) || cliMismatch || npmNewer;
1157
+ const upgradeCommand = health.upgradeCommand ?? UPGRADE_COMMAND;
1158
+ const result = {
1159
+ host: conn.host,
1160
+ project: conn.project,
1161
+ version: health.version,
1162
+ latest: health.latest ?? null,
1163
+ cliVersion: GLASS_VERSION,
1164
+ store: health.store,
1165
+ replay: health.replay,
1166
+ eventsLast24h: total,
1167
+ lastEvent: last ?? null,
1168
+ mcpConfigured: mcp,
1169
+ upgradeAvailable,
1170
+ upgradeCommand,
1171
+ account: config?.accountId
1172
+ };
1173
+ if (options.json) return printJson(result);
1174
+ ok(`Worker reachable at ${conn.host} (Glass ${health.version}, ${health.store} store)`);
1175
+ (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?");
1176
+ (health.replay ? ok : skip)(health.replay ? "Session replay enabled" : "Session replay disabled (no R2 bucket); `glass upgrade` retries");
1177
+ (mcp ? ok : skip)(mcp ? "MCP server configured in .mcp.json" : "No .mcp.json; run `glass init` again to write agent files");
1178
+ if (health.latest && isNewerThan(health.latest, health.version)) {
1179
+ skip(`Glass ${health.latest} is available (this deployment is ${health.version}). Run: ${upgradeCommand}`);
1180
+ } else if (cliMismatch) {
1181
+ skip(`This CLI is Glass ${GLASS_VERSION}; the Worker runs ${health.version}. Run: ${upgradeCommand}`);
1182
+ } else {
1183
+ ok(`Up to date (Glass ${health.version})`);
1184
+ }
1185
+ }
1186
+ function key(options) {
1187
+ const conn = connection();
1188
+ const credentials = loadCredentials();
1189
+ const value = options.read ? credentials?.readKey : conn.key;
1190
+ if (options.json) return printJson({ host: conn.host, project: conn.project, dashboard: `${conn.host}/app`, key: value });
1191
+ line(`Dashboard: ${conn.host}/app`);
1192
+ line(`${options.read ? "Read-only key" : "Admin key"}: ${value}`);
1193
+ line(color.dim(options.read ? "Safe to share for viewing." : "Full access. Use `glass key --read` for a key that can only view."));
1194
+ }
1195
+
1196
+ // packages/cli/src/commands/init.ts
1197
+ function defaultProjectId(root) {
1198
+ try {
1199
+ const pkg = JSON.parse(readFileSync4(join6(root, "package.json"), "utf8"));
1200
+ const id = (pkg.name ?? "").replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
1201
+ if (id) return id.slice(0, 63);
1202
+ } catch {
1203
+ }
1204
+ return "default";
1205
+ }
1206
+ function readPackageJson(root) {
1207
+ try {
1208
+ return JSON.parse(readFileSync4(join6(root, "package.json"), "utf8"));
1209
+ } catch {
1210
+ return null;
1211
+ }
1212
+ }
1213
+ async function waitForWorker(host, key2, fetcher, pollMs) {
1214
+ for (let attempt = 0; attempt < 20; attempt++) {
1215
+ try {
1216
+ const res = await fetcher(`${host}/api`, { headers: { Authorization: `Bearer ${key2}` } });
1217
+ if (res.ok) return;
1218
+ } catch {
1219
+ }
1220
+ await new Promise((r) => setTimeout(r, pollMs));
1221
+ }
1222
+ throw new UsageError(`The Worker at ${host} did not come up.`, "Run `glass status` in a minute; if it persists, `glass upgrade` redeploys.");
1223
+ }
1224
+ async function init(options, deps = {}) {
1225
+ const root = deps.root ?? projectRoot();
1226
+ const dir = glassDir(root);
1227
+ const fetcher = deps.fetcher ?? fetch;
1228
+ const existing = loadConfig(root);
1229
+ const quiet = Boolean(options.json);
1230
+ const steps = [];
1231
+ const report = (status2, label, detail) => {
1232
+ steps.push({ status: status2, label, detail });
1233
+ if (quiet) return;
1234
+ const text = `${label}${detail ? color.dim(` (${detail})`) : status2 === "created" ? color.dim(" (created)") : ""}`;
1235
+ if (status2 === "skipped") skip(text);
1236
+ else ok(text);
1237
+ };
1238
+ if (!quiet) {
1239
+ line(color.bold("Glass: analytics in your own Cloudflare account"));
1240
+ line();
1241
+ }
1242
+ const token = options.token ?? process.env.CLOUDFLARE_API_TOKEN ?? await ask("Cloudflare API token", { flag: "--token (or CLOUDFLARE_API_TOKEN)", secret: true });
1243
+ 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("; ")}.`);
1244
+ const accounts = await listAccounts(token, fetcher).catch((err) => {
1245
+ 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");
1246
+ });
1247
+ const accountId = options.account ?? process.env.CLOUDFLARE_ACCOUNT_ID ?? existing?.accountId;
1248
+ const account = accountId ? accounts.find((a) => a.id === accountId) ?? { id: accountId, name: accountId } : await choose("Which account?", accounts, (a) => `${a.name} (${a.id})`, "--account");
1249
+ if (!account) throw new UsageError("This token cannot see any Cloudflare accounts.", `Give it account permissions: ${TOKEN_SCOPES.join("; ")}.`);
1250
+ report("ok", `Cloudflare token works (account ${account.name})`);
1251
+ const name = options.name ?? existing?.name ?? "glass";
1252
+ const project = options.project ?? existing?.project ?? defaultProjectId(root);
1253
+ const lake = options.lake ?? existing?.store === "lake";
1254
+ const eu = options.eu ?? existing?.eu ?? false;
1255
+ const route = options.route ?? existing?.route ?? null;
1256
+ const wantReplay = options.replay ?? true;
1257
+ if (options.dryRun) {
1258
+ const plan = {
1259
+ account: account.id,
1260
+ worker: name,
1261
+ project,
1262
+ store: lake ? "lake" : "d1",
1263
+ eu,
1264
+ route,
1265
+ replay: wantReplay,
1266
+ resources: [
1267
+ `D1 database "${name}"`,
1268
+ wantReplay ? `R2 bucket "${name}-replays"` : null,
1269
+ lake ? `R2 bucket "${name}-data" with data catalog, Pipelines streams/sinks for events and identities` : null
1270
+ ].filter(Boolean),
1271
+ 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", ...options.ci === false ? [] : [CI_WORKFLOW_PATH]]
1272
+ };
1273
+ if (quiet) printJson(plan);
1274
+ else {
1275
+ line();
1276
+ line("Dry run: nothing was changed. This would create:");
1277
+ for (const r of plan.resources) line(` ${r}`);
1278
+ line(`and deploy Worker "${name}" for project "${project}", writing ${plan.files.join(", ")}.`);
1279
+ }
1280
+ return;
1281
+ }
1282
+ const wrangler = (deps.wrangler ?? createWrangler)({ token, accountId: account.id }, root);
1283
+ try {
1284
+ const d1 = await ensureD1(wrangler, name, eu, report);
1285
+ let replay = null;
1286
+ if (wantReplay) {
1287
+ const bucket = `${name}-replays`;
1288
+ if (await ensureBucket(wrangler, bucket, eu, report, "session replay")) replay = { bucket };
1289
+ else report("skipped", "Session replay", "R2 is not enabled on this account; enable it in the Cloudflare dashboard, then run `glass upgrade`");
1290
+ } else {
1291
+ report("skipped", "Session replay", "disabled with --no-replay");
1292
+ }
1293
+ const lakeConfig = lake ? await ensureLake(wrangler, { bucket: `${name}-data`, namespace: "glass", token, eu, dir }, report) : null;
1294
+ const config = {
1295
+ name,
1296
+ accountId: account.id,
1297
+ host: existing?.host ?? "",
1298
+ project,
1299
+ store: lake ? "lake" : "d1",
1300
+ d1,
1301
+ replay,
1302
+ lake: lakeConfig,
1303
+ route,
1304
+ eu,
1305
+ version: GLASS_VERSION
1306
+ };
1307
+ const credentials = loadCredentials(root) ?? { adminKey: randomToken(), readKey: randomToken(), saltSecret: randomToken() };
1308
+ installWorkerFiles(dir, deps.bundleDir ?? workerBundleDir());
1309
+ const configPath = join6(dir, "wrangler.jsonc");
1310
+ writeFileSync6(configPath, renderWranglerConfig(config));
1311
+ saveCredentials(credentials, root);
1312
+ await migrate(wrangler, d1.name, configPath);
1313
+ report("ok", "Database migrated");
1314
+ const url = await deploy(wrangler, configPath);
1315
+ config.host = route ? `https://${route.replace(/\/\*$/, "")}` : url ?? config.host;
1316
+ 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`.");
1317
+ await putSecrets(
1318
+ wrangler,
1319
+ {
1320
+ GLASS_ADMIN_KEY: credentials.adminKey,
1321
+ GLASS_READ_KEY: credentials.readKey,
1322
+ GLASS_SALT_SECRET: credentials.saltSecret,
1323
+ ...lake ? { CF_API_TOKEN: token } : {}
1324
+ },
1325
+ configPath,
1326
+ dir
1327
+ );
1328
+ saveConfig(config, root);
1329
+ report("ok", `Worker deployed at ${config.host}`);
1330
+ await waitForWorker(config.host, credentials.adminKey, fetcher, deps.pollMs ?? 1500);
1331
+ await call("POST", "/api/projects", { id: project }, { host: config.host, key: credentials.adminKey, fetcher });
1332
+ report("ok", `Project "${project}" ready`);
1333
+ const auth = { host: config.host, key: credentials.adminKey, fetcher };
1334
+ await call("POST", "/e", { project, events: [{ id: randomToken(16), event: "$install_check", ts: Date.now(), distinct_id: "glass-cli" }] }, auth);
1335
+ if (lake) {
1336
+ report("ok", "Test event sent", "the lake shows events within about two minutes");
1337
+ } else {
1338
+ const seen = await call("GET", `/api/events?days=1&project=${encodeURIComponent(project)}`, void 0, auth);
1339
+ if (seen.events.some((e) => e.event === "$install_check")) report("ok", "Test event received");
1340
+ else report("skipped", "Test event not visible yet", "run `glass status` in a minute");
1341
+ }
1342
+ const framework = detectFramework(readPackageJson(root));
1343
+ const snippet = FRAMEWORKS[framework].snippet({ host: config.host, project });
1344
+ const written = options.agentFiles === false ? [] : writeAgentFiles({ root, host: config.host, project, snippet });
1345
+ const workflow = options.ci === false ? null : writeCiWorkflow(root);
1346
+ if (workflow && workflow.status !== "kept") written.push(workflow.path);
1347
+ const ci2 = workflow ? { workflow: workflow.path, status: workflow.status, branch: workflow.branch, secret: workflow.secret, next: ciNextSteps(workflow) } : null;
1348
+ if (quiet) {
1349
+ printJson({ host: config.host, project, dashboard: `${config.host}/app`, framework, snippet, written, ci: ci2, steps });
1350
+ return;
1351
+ }
1352
+ line();
1353
+ line(color.bold(`Next: add Glass to your site (${FRAMEWORKS[framework].name})`));
1354
+ line();
1355
+ line(color.dim(` ${snippet.file}`));
1356
+ for (const l of snippet.code.split("\n")) line(` ${l}`);
1357
+ line();
1358
+ line(`Then track what matters: ${color.cyan("glass.capture('signup_completed', { plan: 'pro' })")}`);
1359
+ if (written.length) {
1360
+ line();
1361
+ line(color.bold("Written for your agent"));
1362
+ for (const f of written) line(` ${f}`);
1363
+ line(` .glass/ ${color.dim("(deployment config: commit it; credentials.json is git-ignored)")}`);
1364
+ }
1365
+ if (ci2) {
1366
+ line();
1367
+ line(color.bold("Upgrades deploy from CI"));
1368
+ line(color.dim(` ${ci2.workflow} deploys the version pinned in .glass/config.json. Agents: finish these steps now.`));
1369
+ for (const [i, step] of ci2.next.entries()) line(` ${i + 1}. ${step}`);
1370
+ }
1371
+ line();
1372
+ line(`Dashboard: ${color.cyan(`${config.host}/app`)} ${color.dim("(sign in with the key from `npx @glassanalytics/cli key`)")}`);
1373
+ } catch (err) {
1374
+ if (err instanceof WranglerError && /Authentication|not authorized|permission|10000|9109/i.test(err.output)) {
1375
+ throw new UsageError(`Cloudflare refused a request: ${err.message.split("\n")[0]}`, `The API token needs: ${TOKEN_SCOPES.join("; ")}.`);
1376
+ }
1377
+ throw err;
1378
+ }
1379
+ }
1380
+
1381
+ // packages/cli/src/commands/mcp.ts
1382
+ import { createInterface as createInterface2 } from "node:readline";
1383
+ async function mcpBridge(options = {}) {
1384
+ const conn = connection();
1385
+ const fetcher = options.fetcher ?? fetch;
1386
+ const output = options.output ?? process.stdout;
1387
+ const rl = createInterface2({ input: options.input ?? process.stdin, crlfDelay: Number.POSITIVE_INFINITY });
1388
+ const pending = [];
1389
+ const send = (message) => output.write(`${JSON.stringify(message)}
1390
+ `);
1391
+ const forward = async (lineText) => {
1392
+ let message;
1393
+ try {
1394
+ message = JSON.parse(lineText);
1395
+ } catch {
1396
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
1397
+ return;
1398
+ }
1399
+ try {
1400
+ const res = await fetcher(`${conn.host}/mcp`, {
1401
+ method: "POST",
1402
+ headers: { Authorization: `Bearer ${conn.key}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
1403
+ body: lineText
1404
+ });
1405
+ if (res.status === 202 || message.id === void 0) return;
1406
+ const text = await res.text();
1407
+ if (!res.ok && !text.startsWith("{")) {
1408
+ send({ jsonrpc: "2.0", id: message.id ?? null, error: { code: -32603, message: `Glass returned HTTP ${res.status}: ${text.slice(0, 200)}` } });
1409
+ return;
1410
+ }
1411
+ output.write(`${text.replace(/\n/g, "")}
1412
+ `);
1413
+ } catch (err) {
1414
+ send({ jsonrpc: "2.0", id: message.id ?? null, error: { code: -32603, message: `Could not reach ${conn.host}: ${err instanceof Error ? err.message : String(err)}` } });
1415
+ }
1416
+ };
1417
+ for await (const lineText of rl) {
1418
+ if (lineText.trim()) pending.push(forward(lineText));
1419
+ }
1420
+ await Promise.all(pending);
1421
+ }
1422
+
1423
+ // packages/cli/src/upgrade-check.ts
1424
+ var checked = false;
1425
+ async function warnIfOutdated(options = {}) {
1426
+ if (checked || options.json) return;
1427
+ checked = true;
1428
+ try {
1429
+ const health = await call("GET", "/health");
1430
+ const command = health.upgradeCommand ?? UPGRADE_COMMAND;
1431
+ if (health.upgradeAvailable && health.latest) {
1432
+ skip(`Glass ${health.latest} is available (this deployment is ${health.version}). Run: ${command}`);
1433
+ return;
1434
+ }
1435
+ if (health.version !== GLASS_VERSION) {
1436
+ skip(`Worker is Glass ${health.version}; this CLI is ${GLASS_VERSION}. Run: ${command}`);
1437
+ }
1438
+ } catch {
1439
+ }
1440
+ }
1441
+
1442
+ // packages/cli/src/index.ts
1443
+ var HELP = `${color.bold("glass")} ${GLASS_VERSION}: agent-first analytics in your own Cloudflare account
1444
+
1445
+ ${color.bold("Set up")}
1446
+ glass init Deploy Glass and wire it into this project
1447
+ --token <token> Cloudflare API token (or CLOUDFLARE_API_TOKEN)
1448
+ --account <id> Cloudflare account id (or CLOUDFLARE_ACCOUNT_ID)
1449
+ --project <id> Project id (default: package.json name)
1450
+ --name <name> Worker and resource name (default: glass)
1451
+ --route <host/_glass/*> Also serve from your domain, e.g. acme.com/_glass/*
1452
+ --lake Store events in Iceberg on R2 (Pipelines + Basin SQL)
1453
+ --eu Keep data in the EU jurisdiction
1454
+ --no-replay Skip session replay storage
1455
+ --no-agent-files Don't write AGENTS.md, SKILL.md or .mcp.json
1456
+ --no-ci Don't write .github/workflows/glass.yml
1457
+ --dry-run Show what would be created
1458
+ glass upgrade Move to this CLI's version of Glass and migrate
1459
+ With .github/workflows/glass.yml: stage into .glass/ only;
1460
+ commit and push, and CI deploys it
1461
+ --ci Deploy as CI (no prompts; default when CI=true)
1462
+ --local Deploy from here even though a CI workflow exists
1463
+ --allow-downgrade Replace a newer deployed Worker with this older one
1464
+ glass ci [--branch main] Write the workflow that deploys Glass from CI
1465
+ --secret <name> Repo secret with the Cloudflare token (default CLOUDFLARE_API_TOKEN)
1466
+ glass status Check the deployment end to end
1467
+ glass key [--read] Print the dashboard URL and API key
1468
+
1469
+ ${color.bold("Ask questions")}
1470
+ glass query web [--from -7d]
1471
+ glass query trends --event signup_completed [--math unique_persons] [--interval week] [--breakdown browser]
1472
+ glass query funnel --steps '$pageview,signup_started,signup_completed' [--window 1d] [--breakdown utm_source]
1473
+ glass query retention --cohort signup_completed [--return '$pageview'] [--period week]
1474
+ glass query <kind> --spec '<json>' Any spec; see GET /api/schema
1475
+ common: --from, --to, --where prop=value[,\u2026], --project, --sql (print SQL)
1476
+ glass sql "SELECT \u2026" Read-only SQL against your events
1477
+ glass events [name] Tracked events, or one event's properties
1478
+ --describe "<text>" How/when the event fires (document custom events)
1479
+ glass person <id> [--delete] One person's properties and events (or erase them)
1480
+ glass replays [--errors] Recorded sessions
1481
+ glass replays summary <id> A session as a text transcript
1482
+
1483
+ ${color.bold("Flags and experiments")}
1484
+ glass flags [list]
1485
+ glass flags set <key> [--rollout 25] [--variants control:50,test:50] [--enable|--disable] [--rules '<json>']
1486
+ glass flags delete <key>
1487
+ glass experiments [list]
1488
+ glass experiments create <key> --goal <event> [--flag <key>] [--hypothesis "\u2026"]
1489
+ glass experiments start|stop <key>
1490
+ glass experiments ship <key> <variant>
1491
+ glass experiments results <key>
1492
+
1493
+ ${color.bold("Agents")}
1494
+ glass mcp MCP server over stdio (what .mcp.json runs)
1495
+
1496
+ Environment: GLASS_HOST, GLASS_API_KEY and GLASS_PROJECT override .glass/ settings.
1497
+ Docs: https://github.com/ISNIT0/glass-analytics`;
1498
+ var STRING_OPTIONS = [
1499
+ "token",
1500
+ "account",
1501
+ "project",
1502
+ "name",
1503
+ "route",
1504
+ "branch",
1505
+ "secret",
1506
+ "from",
1507
+ "to",
1508
+ "event",
1509
+ "math",
1510
+ "property",
1511
+ "interval",
1512
+ "breakdown",
1513
+ "steps",
1514
+ "window",
1515
+ "cohort",
1516
+ "return",
1517
+ "period",
1518
+ "periods",
1519
+ "spec",
1520
+ "where",
1521
+ "limit",
1522
+ "days",
1523
+ "describe",
1524
+ "person",
1525
+ "rollout",
1526
+ "variants",
1527
+ "rules",
1528
+ "description",
1529
+ "goal",
1530
+ "flag",
1531
+ "value",
1532
+ "hypothesis",
1533
+ "variant"
1534
+ ];
1535
+ var BOOLEAN_OPTIONS = ["json", "lake", "eu", "no-replay", "no-agent-files", "no-ci", "ci", "local", "allow-downgrade", "dry-run", "read", "sql", "errors", "enable", "disable", "delete"];
1536
+ async function main(argv) {
1537
+ const { values, positionals } = parseArgs({
1538
+ args: argv,
1539
+ allowPositionals: true,
1540
+ strict: false,
1541
+ options: {
1542
+ ...Object.fromEntries(STRING_OPTIONS.map((o) => [o, { type: "string" }])),
1543
+ ...Object.fromEntries(BOOLEAN_OPTIONS.map((o) => [o, { type: "boolean" }])),
1544
+ help: { type: "boolean", short: "h" },
1545
+ version: { type: "boolean", short: "v" }
1546
+ }
1547
+ });
1548
+ const flags2 = values;
1549
+ const [command, sub, arg, ...rest] = positionals;
1550
+ if (flags2.version) return line(GLASS_VERSION);
1551
+ if (!command || command === "help" || flags2.help && !command) return line(HELP);
1552
+ const skipUpgradeWarn = /* @__PURE__ */ new Set(["init", "upgrade", "deploy", "ci", "status", "doctor", "mcp", "key"]);
1553
+ if (command && !skipUpgradeWarn.has(command)) {
1554
+ await warnIfOutdated({ json: Boolean(flags2.json) });
1555
+ }
1556
+ switch (command) {
1557
+ case "init":
1558
+ return init({
1559
+ token: str3(flags2.token),
1560
+ account: str3(flags2.account),
1561
+ project: str3(flags2.project),
1562
+ name: str3(flags2.name),
1563
+ route: str3(flags2.route),
1564
+ lake: flags2.lake === true ? true : void 0,
1565
+ eu: flags2.eu === true ? true : void 0,
1566
+ replay: flags2["no-replay"] ? false : void 0,
1567
+ agentFiles: flags2["no-agent-files"] ? false : void 0,
1568
+ ci: flags2["no-ci"] ? false : void 0,
1569
+ dryRun: Boolean(flags2["dry-run"]),
1570
+ json: Boolean(flags2.json)
1571
+ });
1572
+ case "upgrade":
1573
+ case "deploy":
1574
+ return upgrade({
1575
+ token: str3(flags2.token),
1576
+ json: Boolean(flags2.json),
1577
+ ci: flags2.ci === true ? true : void 0,
1578
+ local: Boolean(flags2.local),
1579
+ allowDowngrade: Boolean(flags2["allow-downgrade"])
1580
+ });
1581
+ case "ci":
1582
+ return ci({ branch: str3(flags2.branch), secret: str3(flags2.secret), json: Boolean(flags2.json) });
1583
+ case "status":
1584
+ case "doctor":
1585
+ return status({ json: Boolean(flags2.json) });
1586
+ case "key":
1587
+ return key({ read: Boolean(flags2.read), json: Boolean(flags2.json) });
1588
+ case "query":
1589
+ return query(sub, flags2);
1590
+ case "sql":
1591
+ return sql(sub, flags2);
1592
+ case "events":
1593
+ return events(sub, flags2);
1594
+ case "person":
1595
+ case "persons":
1596
+ return person(sub, flags2);
1597
+ case "replays":
1598
+ return replays(sub, arg, flags2);
1599
+ case "projects":
1600
+ return projects(sub, arg, flags2);
1601
+ case "flags":
1602
+ return flags(sub, arg, flags2);
1603
+ case "experiments":
1604
+ return experiments(sub, arg, rest, flags2);
1605
+ case "mcp":
1606
+ return mcpBridge();
1607
+ default:
1608
+ throw new UsageError(`Unknown command "${command}".`, "Run `glass help` for the list of commands.");
1609
+ }
1610
+ }
1611
+ function str3(v) {
1612
+ return typeof v === "string" ? v : void 0;
1613
+ }
1614
+ var invoked = process.argv[1] ?? "";
1615
+ if (/glass(\.js)?$|cli[\\/]src[\\/]index\.ts$/.test(invoked)) {
1616
+ main(process.argv.slice(2)).catch((err) => {
1617
+ if (err instanceof UsageError) {
1618
+ fail(err.message);
1619
+ if (err.hint) process.stderr.write(` ${err.hint}
1620
+ `);
1621
+ } else {
1622
+ fail(err instanceof Error ? err.message : String(err));
1623
+ if (process.env.GLASS_DEBUG && err instanceof Error) process.stderr.write(`${err.stack}
1624
+ `);
1625
+ }
1626
+ process.exitCode = 1;
1627
+ });
1628
+ }
1629
+ export {
1630
+ main
1631
+ };