@glassanalytics/cli 0.2.0 → 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 CHANGED
@@ -2,7 +2,25 @@
2
2
 
3
3
  // packages/cli/src/index.ts
4
4
  import { parseArgs } from "node:util";
5
- import { GLASS_VERSION as GLASS_VERSION3 } from "@glassanalytics/core/version";
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
+ }
6
24
 
7
25
  // packages/cli/src/config.ts
8
26
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -366,16 +384,110 @@ async function experiments(sub, key2, rest, flags2) {
366
384
  }
367
385
 
368
386
  // 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";
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.';
374
487
 
375
488
  // packages/cli/src/agent-files.ts
376
489
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
377
490
  import { dirname as dirname2, join as join2 } from "node:path";
378
- import { EVENT_NAMING_GUIDE } from "@glassanalytics/core/instrument";
379
491
  var START = "<!-- glass:start -->";
380
492
  var END = "<!-- glass:end -->";
381
493
  function agentsSection(o) {
@@ -392,8 +504,16 @@ Tracking (project \`${o.project}\`):
392
504
  - Install (${o.snippet.file}): \`${o.snippet.code.split("\n").pop()}\`
393
505
  - Custom events: \`glass.capture('signup_completed', { plan: 'pro' })\`. After login: \`glass.identify(user.id, { plan })\`.
394
506
  - ${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"\`.
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.
396
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\`.
397
517
  ${END}`;
398
518
  }
399
519
  function upsertSection(path, section, createIfMissing) {
@@ -418,7 +538,7 @@ Glass is deployed at ${o.host} (project \`${o.project}\`). Prefer the \`glass\`
418
538
 
419
539
  ## Answer a question about usage
420
540
 
421
- 1. \`overview\` for the period (visitors, top pages, errors, events that moved).
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.
422
542
  2. \`list_events\` to get exact event names \u2014 never guess them.
423
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.
424
544
  4. Report numbers with the dashboard link and SQL from the result.
@@ -428,7 +548,13 @@ Glass is deployed at ${o.host} (project \`${o.project}\`). Prefer the \`glass\`
428
548
  1. \`instrument\` with the event name returns the exact code for this framework.
429
549
  2. ${EVENT_NAMING_GUIDE}
430
550
  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.
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.
432
558
 
433
559
  ## Debug a user problem
434
560
 
@@ -436,6 +562,13 @@ Glass is deployed at ${o.host} (project \`${o.project}\`). Prefer the \`glass\`
436
562
  2. \`list_replays\` with \`with_errors: true\` (or a \`person\`) to find affected sessions.
437
563
  3. \`summarise_replay\` to read what happened: pages, labelled clicks, rage clicks, errors.
438
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
+
439
572
  ## Run an experiment
440
573
 
441
574
  1. \`create_experiment\` with a goal event (creates a control/test flag).
@@ -472,10 +605,164 @@ function writeAgentFiles(o) {
472
605
  return written;
473
606
  }
474
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
+
475
698
  // 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";
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
+ }
479
766
 
480
767
  // packages/cli/src/cloudflare/wrangler.ts
481
768
  import { spawn } from "node:child_process";
@@ -605,9 +892,9 @@ async function ensureLake(wrangler, options, report) {
605
892
  const sinkName = `${streamName}_sink`;
606
893
  let stream = await findByName(wrangler, "streams", streamName);
607
894
  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)}
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)}
611
898
  `);
612
899
  await must(wrangler, ["pipelines", "streams", "create", streamName, "--schema-file", schemaPath, "--http-enabled", "false"]);
613
900
  stream = await findByName(wrangler, "streams", streamName);
@@ -677,10 +964,10 @@ ${JSON.stringify(wrangler, null, 2)}
677
964
  `;
678
965
  }
679
966
  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));
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));
684
971
  }
685
972
  }
686
973
  async function deploy(wrangler, configPath) {
@@ -691,8 +978,8 @@ async function migrate(wrangler, database, configPath) {
691
978
  await must(wrangler, ["d1", "migrations", "apply", database, "--remote", "--config", configPath]);
692
979
  }
693
980
  async function putSecrets(wrangler, secrets, configPath, dir) {
694
- const file = join3(dir, ".secrets.tmp.json");
695
- writeFileSync3(file, JSON.stringify(secrets), { mode: 384 });
981
+ const file = join4(dir, ".secrets.tmp.json");
982
+ writeFileSync4(file, JSON.stringify(secrets), { mode: 384 });
696
983
  try {
697
984
  await must(wrangler, ["secret", "bulk", file, "--config", configPath]);
698
985
  } finally {
@@ -747,26 +1034,65 @@ async function choose(question, items, label, flag) {
747
1034
  }
748
1035
 
749
1036
  // 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";
1037
+ import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "node:fs";
1038
+ import { join as join5 } from "node:path";
752
1039
  import { fileURLToPath } from "node:url";
753
- import { GLASS_VERSION } from "@glassanalytics/core/version";
754
1040
  function workerBundleDir() {
755
1041
  const candidates = [new URL("./worker/", import.meta.url), new URL("../../../worker/dist/", import.meta.url)];
756
1042
  for (const c of candidates) {
757
1043
  const dir = fileURLToPath(c);
758
- if (existsSync3(join4(dir, "worker.js")) && existsSync3(join4(dir, "migrations"))) return dir;
1044
+ if (existsSync4(join5(dir, "worker.js")) && existsSync4(join5(dir, "migrations"))) return dir;
759
1045
  }
760
1046
  throw new UsageError("The Worker bundle is missing.", "If you are working on Glass itself, run `pnpm build` first.");
761
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
+ }
762
1059
  async function upgrade(options, deps = {}) {
763
- const root = projectRoot();
1060
+ const root = deps.root ?? projectRoot();
1061
+ const env = deps.env ?? process.env;
764
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);
765
1065
  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);
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.");
769
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);
770
1096
  const report = (status2, label, detail) => {
771
1097
  if (options.json) return;
772
1098
  (status2 === "skipped" ? skip : ok)(`${label}${detail ? color.dim(` (${detail})`) : ""}`);
@@ -774,28 +1100,48 @@ async function upgrade(options, deps = {}) {
774
1100
  if (!config.replay && await ensureBucket(wrangler, `${config.name}-replays`, config.eu, report, "session replay")) {
775
1101
  config.replay = { bucket: `${config.name}-replays` };
776
1102
  }
777
- installWorkerFiles(dir, workerBundleDir());
778
- const configPath = join4(dir, "wrangler.jsonc");
779
- writeFileSync4(configPath, renderWranglerConfig(config));
1103
+ installWorkerFiles(dir, bundle);
1104
+ const configPath = join5(dir, "wrangler.jsonc");
1105
+ writeFileSync5(configPath, renderWranglerConfig(config));
780
1106
  await migrate(wrangler, config.d1.name, configPath);
781
1107
  report("ok", "Migrations applied");
782
1108
  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;
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
+ }
795
1124
  config.version = GLASS_VERSION;
796
1125
  saveConfig(config, root);
797
- if (options.json) printJson({ from, to: GLASS_VERSION, host: config.host, replay: Boolean(config.replay) });
1126
+ if (options.json) printJson({ from, to: GLASS_VERSION, host: config.host, replay: Boolean(config.replay), staged: false });
798
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}`);
799
1145
  }
800
1146
  async function status(options) {
801
1147
  const conn = connection();
@@ -804,18 +1150,24 @@ async function status(options) {
804
1150
  const events2 = await call("GET", `/api/events?days=1${conn.project ? `&project=${conn.project}` : ""}`).catch(() => ({ events: [] }));
805
1151
  const total = events2.events.reduce((a, e) => a + e.count, 0);
806
1152
  const last = events2.events.map((e) => e.lastSeen).sort().at(-1);
807
- const mcp = existsSync3(join4(projectRoot(), ".mcp.json"));
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;
808
1158
  const result = {
809
1159
  host: conn.host,
810
1160
  project: conn.project,
811
1161
  version: health.version,
1162
+ latest: health.latest ?? null,
812
1163
  cliVersion: GLASS_VERSION,
813
1164
  store: health.store,
814
1165
  replay: health.replay,
815
1166
  eventsLast24h: total,
816
1167
  lastEvent: last ?? null,
817
1168
  mcpConfigured: mcp,
818
- upgradeAvailable: health.version !== GLASS_VERSION,
1169
+ upgradeAvailable,
1170
+ upgradeCommand,
819
1171
  account: config?.accountId
820
1172
  };
821
1173
  if (options.json) return printJson(result);
@@ -823,7 +1175,13 @@ async function status(options) {
823
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?");
824
1176
  (health.replay ? ok : skip)(health.replay ? "Session replay enabled" : "Session replay disabled (no R2 bucket); `glass upgrade` retries");
825
1177
  (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\`.`);
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
+ }
827
1185
  }
828
1186
  function key(options) {
829
1187
  const conn = connection();
@@ -838,7 +1196,7 @@ function key(options) {
838
1196
  // packages/cli/src/commands/init.ts
839
1197
  function defaultProjectId(root) {
840
1198
  try {
841
- const pkg = JSON.parse(readFileSync3(join5(root, "package.json"), "utf8"));
1199
+ const pkg = JSON.parse(readFileSync4(join6(root, "package.json"), "utf8"));
842
1200
  const id = (pkg.name ?? "").replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
843
1201
  if (id) return id.slice(0, 63);
844
1202
  } catch {
@@ -847,7 +1205,7 @@ function defaultProjectId(root) {
847
1205
  }
848
1206
  function readPackageJson(root) {
849
1207
  try {
850
- return JSON.parse(readFileSync3(join5(root, "package.json"), "utf8"));
1208
+ return JSON.parse(readFileSync4(join6(root, "package.json"), "utf8"));
851
1209
  } catch {
852
1210
  return null;
853
1211
  }
@@ -910,7 +1268,7 @@ async function init(options, deps = {}) {
910
1268
  wantReplay ? `R2 bucket "${name}-replays"` : null,
911
1269
  lake ? `R2 bucket "${name}-data" with data catalog, Pipelines streams/sinks for events and identities` : null
912
1270
  ].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"]
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]]
914
1272
  };
915
1273
  if (quiet) printJson(plan);
916
1274
  else {
@@ -944,12 +1302,12 @@ async function init(options, deps = {}) {
944
1302
  lake: lakeConfig,
945
1303
  route,
946
1304
  eu,
947
- version: GLASS_VERSION2
1305
+ version: GLASS_VERSION
948
1306
  };
949
1307
  const credentials = loadCredentials(root) ?? { adminKey: randomToken(), readKey: randomToken(), saltSecret: randomToken() };
950
1308
  installWorkerFiles(dir, deps.bundleDir ?? workerBundleDir());
951
- const configPath = join5(dir, "wrangler.jsonc");
952
- writeFileSync5(configPath, renderWranglerConfig(config));
1309
+ const configPath = join6(dir, "wrangler.jsonc");
1310
+ writeFileSync6(configPath, renderWranglerConfig(config));
953
1311
  saveCredentials(credentials, root);
954
1312
  await migrate(wrangler, d1.name, configPath);
955
1313
  report("ok", "Database migrated");
@@ -984,8 +1342,11 @@ async function init(options, deps = {}) {
984
1342
  const framework = detectFramework(readPackageJson(root));
985
1343
  const snippet = FRAMEWORKS[framework].snippet({ host: config.host, project });
986
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;
987
1348
  if (quiet) {
988
- printJson({ host: config.host, project, dashboard: `${config.host}/app`, framework, snippet, written, steps });
1349
+ printJson({ host: config.host, project, dashboard: `${config.host}/app`, framework, snippet, written, ci: ci2, steps });
989
1350
  return;
990
1351
  }
991
1352
  line();
@@ -1001,6 +1362,12 @@ async function init(options, deps = {}) {
1001
1362
  for (const f of written) line(` ${f}`);
1002
1363
  line(` .glass/ ${color.dim("(deployment config: commit it; credentials.json is git-ignored)")}`);
1003
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
+ }
1004
1371
  line();
1005
1372
  line(`Dashboard: ${color.cyan(`${config.host}/app`)} ${color.dim("(sign in with the key from `npx @glassanalytics/cli key`)")}`);
1006
1373
  } catch (err) {
@@ -1053,8 +1420,27 @@ async function mcpBridge(options = {}) {
1053
1420
  await Promise.all(pending);
1054
1421
  }
1055
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
+
1056
1442
  // packages/cli/src/index.ts
1057
- var HELP = `${color.bold("glass")} ${GLASS_VERSION3}: agent-first analytics in your own Cloudflare account
1443
+ var HELP = `${color.bold("glass")} ${GLASS_VERSION}: agent-first analytics in your own Cloudflare account
1058
1444
 
1059
1445
  ${color.bold("Set up")}
1060
1446
  glass init Deploy Glass and wire it into this project
@@ -1067,8 +1453,16 @@ ${color.bold("Set up")}
1067
1453
  --eu Keep data in the EU jurisdiction
1068
1454
  --no-replay Skip session replay storage
1069
1455
  --no-agent-files Don't write AGENTS.md, SKILL.md or .mcp.json
1456
+ --no-ci Don't write .github/workflows/glass.yml
1070
1457
  --dry-run Show what would be created
1071
- glass upgrade Redeploy this version of Glass and migrate
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)
1072
1466
  glass status Check the deployment end to end
1073
1467
  glass key [--read] Print the dashboard URL and API key
1074
1468
 
@@ -1081,7 +1475,7 @@ ${color.bold("Ask questions")}
1081
1475
  common: --from, --to, --where prop=value[,\u2026], --project, --sql (print SQL)
1082
1476
  glass sql "SELECT \u2026" Read-only SQL against your events
1083
1477
  glass events [name] Tracked events, or one event's properties
1084
- --describe "<text>" Document an event
1478
+ --describe "<text>" How/when the event fires (document custom events)
1085
1479
  glass person <id> [--delete] One person's properties and events (or erase them)
1086
1480
  glass replays [--errors] Recorded sessions
1087
1481
  glass replays summary <id> A session as a text transcript
@@ -1107,6 +1501,8 @@ var STRING_OPTIONS = [
1107
1501
  "project",
1108
1502
  "name",
1109
1503
  "route",
1504
+ "branch",
1505
+ "secret",
1110
1506
  "from",
1111
1507
  "to",
1112
1508
  "event",
@@ -1136,7 +1532,7 @@ var STRING_OPTIONS = [
1136
1532
  "hypothesis",
1137
1533
  "variant"
1138
1534
  ];
1139
- var BOOLEAN_OPTIONS = ["json", "lake", "eu", "no-replay", "no-agent-files", "dry-run", "read", "sql", "errors", "enable", "disable", "delete"];
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"];
1140
1536
  async function main(argv) {
1141
1537
  const { values, positionals } = parseArgs({
1142
1538
  args: argv,
@@ -1151,8 +1547,12 @@ async function main(argv) {
1151
1547
  });
1152
1548
  const flags2 = values;
1153
1549
  const [command, sub, arg, ...rest] = positionals;
1154
- if (flags2.version) return line(GLASS_VERSION3);
1550
+ if (flags2.version) return line(GLASS_VERSION);
1155
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
+ }
1156
1556
  switch (command) {
1157
1557
  case "init":
1158
1558
  return init({
@@ -1165,12 +1565,21 @@ async function main(argv) {
1165
1565
  eu: flags2.eu === true ? true : void 0,
1166
1566
  replay: flags2["no-replay"] ? false : void 0,
1167
1567
  agentFiles: flags2["no-agent-files"] ? false : void 0,
1568
+ ci: flags2["no-ci"] ? false : void 0,
1168
1569
  dryRun: Boolean(flags2["dry-run"]),
1169
1570
  json: Boolean(flags2.json)
1170
1571
  });
1171
1572
  case "upgrade":
1172
1573
  case "deploy":
1173
- return upgrade({ token: str3(flags2.token), json: Boolean(flags2.json) });
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) });
1174
1583
  case "status":
1175
1584
  case "doctor":
1176
1585
  return status({ json: Boolean(flags2.json) });