From a8f52614c4d34f6fd07ceb8f9e12fc21fe6a75d2 Mon Sep 17 00:00:00 2001
From: Karan
Date: Sun, 25 Oct 2026 01:18:13 -0400
Subject: [PATCH 1/3] Newest runtime line everywhere; analytics loud on a
missing or mismatched chunk
npm's `latest` is still 0.2.2, so a bare `npm install @genclass/runtime` or
`npx @genclass/runtime init` gives an app the line without the token, dashboard,
analytics, visual checks or agent link, and 0.2.x ignores `analytics: true`
without a word.
- CLI: init/login/connect print their version and the dist-tag they came from,
ask npm's dist-tags once (3 s, never with --dry-run, --no-network, --no-token,
--no-telemetry or offline) and say when a newer release exists; an older
@genclass/runtime in the project is upgraded instead of kept; every printed
command names @beta while the version is a prerelease.
- Runtime: the analytics stub warns once when its chunk does not load and passes
its version to the chunk, which warns once on a mismatch (+48 B gzip /auto for
this, +67 B with the @beta console hints).
- Dashboard: Visitors empty state and Connect panel name 0.3.0-beta.1 and the
install line (and the app's older runtimes); SNIPPET_RUNTIME and RUNTIME_ON_NPM
0.3.0-beta.1.
- genclass_setup, Nuxt/Astro peer ranges (>=0.3.0-beta.1), templates
(^0.3.0-beta.1), READMEs, docs and pinned script tags put the newest line first;
AGENTS.md convention keeps it so.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/release.yml | 5 +-
AGENTS.md | 4 +
PRIVACY.md | 4 +-
README.md | 14 +-
demos/src/site/landing.ts | 4 +-
docs/adoption/listings/svelte.md | 2 +-
docs/adoption/listings/sveltesociety.md | 2 +-
docs/adoption/listings/vue-and-angular.md | 2 +-
docs/runtime/ANALYTICS.md | 27 +++-
docs/runtime/API.md | 12 +-
docs/runtime/THREAT-MODEL.md | 2 +-
package-lock.json | 4 +-
packages/astro/CHANGELOG.md | 4 +-
packages/astro/README.md | 5 +-
packages/astro/package.json | 2 +-
packages/astro/src/index.ts | 2 +-
packages/genclass-runtime/README.md | 14 +-
packages/mcp/CHANGELOG.md | 5 +
packages/mcp/README.md | 5 +-
packages/mcp/src/tools.ts | 21 ++-
packages/mcp/test/mcp.test.ts | 35 ++++-
packages/nuxt/CHANGELOG.md | 3 +-
packages/nuxt/README.md | 5 +-
packages/nuxt/package.json | 2 +-
packages/nuxt/src/source.ts | 2 +-
packages/playwright/README.md | 2 +-
packages/playwright/src/install.ts | 2 +-
packages/runtime-model/README.md | 2 +-
packages/runtime/CHANGELOG.md | 22 ++-
packages/runtime/INTERCEPTION.md | 2 +-
packages/runtime/README.md | 47 ++++--
packages/runtime/TELEMETRY.md | 6 +-
packages/runtime/bin/lib/init.mjs | 66 +++++---
packages/runtime/bin/lib/login.mjs | 7 +-
packages/runtime/bin/lib/version.mjs | 147 ++++++++++++++++++
packages/runtime/src/analytics/index.ts | 36 ++++-
packages/runtime/src/analytics/start.ts | 12 +-
packages/runtime/src/cdn/global.ts | 2 +-
packages/runtime/src/cdn/sinks.ts | 2 +-
packages/runtime/src/index.ts | 4 +-
packages/runtime/src/model/blocked.ts | 2 +-
packages/runtime/src/runtime.ts | 2 +-
packages/runtime/src/token.ts | 2 +-
packages/runtime/src/types.ts | 6 +-
packages/runtime/test/analytics.test.ts | 54 ++++++-
.../test/install/INSTALL-README-SNIPPET.md | 14 +-
packages/runtime/test/install/account.test.ts | 17 +-
packages/runtime/test/install/cli.test.ts | 89 ++++++++++-
.../runtime/test/install/mock-account.mjs | 3 +
packages/runtime/test/install/mock-fetch.mjs | 3 +
packages/runtime/test/model/blocked.test.ts | 2 +-
packages/sv/README.md | 2 +-
telemetry-worker/README.md | 14 +-
telemetry-worker/src/pages.ts | 41 +++--
telemetry-worker/test/pages.test.ts | 32 +++-
telemetry-worker/wrangler.selfhost.toml | 2 +-
telemetry-worker/wrangler.toml | 8 +-
templates/angular/README.md | 4 +-
templates/angular/package.json | 2 +-
templates/astro/README.md | 2 +-
templates/astro/package.json | 2 +-
templates/nextjs/README.md | 6 +-
templates/nextjs/package.json | 2 +-
templates/nuxt/README.md | 2 +-
templates/nuxt/package.json | 2 +-
templates/react-router/README.md | 4 +-
templates/react-router/package.json | 2 +-
templates/react-vite/README.md | 4 +-
templates/react-vite/package.json | 2 +-
templates/sveltekit/README.md | 4 +-
templates/sveltekit/package.json | 2 +-
templates/vue-vite/README.md | 4 +-
templates/vue-vite/package.json | 2 +-
73 files changed, 708 insertions(+), 177 deletions(-)
create mode 100644 packages/runtime/bin/lib/version.mjs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6f917efd..ac782a84 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,7 +21,10 @@ name: Release
# detection and keeps those aggregates 90 days; batches the old admin ingest has processed stay miscounted.
# After it: check the dist-tags (a prerelease lands on the tag named after it and `latest` stays), that the
# "Verify the registry signature and provenance" step passed, and that jsDelivr serves @,
-# /dist/cdn/worker.js and /dist/sinks/webhook.js; then set telemetry-worker RUNTIME_ON_NPM to it and deploy the worker.
+# /dist/cdn/worker.js and /dist/sinks/webhook.js; then set telemetry-worker RUNTIME_ON_NPM (wrangler.toml) and
+# src/pages.ts SNIPPET_RUNTIME to it and deploy the worker. The install lines name the newest release (AGENTS.md
+# conventions): a stable release publishes to `latest` below; also run `npm dist-tag add @genclass/runtime@
+# beta`, so the `@beta` lines never resolve to an older prerelease, and drop `@beta` from the install lines.
# The registry's top-level `readme` (`npm view @genclass/runtime readme`) is emptied by every publish to a tag other
# than `latest` and filled again by the next `latest` publish, cut to its first 65,536 characters; npmjs.com and
# jsDelivr show each version's full README either way (checked 2026-10-17). Nothing in package.json or this workflow
diff --git a/AGENTS.md b/AGENTS.md
index 9dfbda15..8b1ecfb5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -114,6 +114,10 @@ Azure, `git push`, `npm publish`.
- Modules open with a `//` header stating role and invariants; JSDoc on exports; named exports; relative imports
with `.js`; 2 spaces, double quotes. Never throw into the host app: hooks and callbacks run in `try/catch`.
+- Install lines name the newest runtime line, never npm's bare `latest`: while it is a prerelease, every command a user
+ or agent copies says `@genclass/runtime@beta` (`npm install`, `npx ... init`), script tags pin the exact version,
+ templates and peer ranges resolve to it, the stable line is mentioned only as the exception; at a stable release,
+ move `latest` and `beta` to it and drop `@beta` everywhere in one change.
- Tests: one `.test.ts` per area; `test/helpers.ts` → `setup()` gives a fake clock, server and scripted decider
(default `mode: "guard"`; pass `mode: undefined` for observe). Name the CONTRACT section in the `describe`.
- Done means: `tsc` clean, `tsup` builds, both vitest steps pass with new tests, no review test edited, no
diff --git a/PRIVACY.md b/PRIVACY.md
index d4639f0c..09b0a1ca 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -65,7 +65,7 @@ page views, time on page, scroll depth and clicks per page and element) and a li
(time, route or function name, diagnosis, action). Dashboards do not show the situation text. Data from browsers that opted out or
send Global Privacy Control never reaches a dashboard.
-When a developer creates a token with `npx @genclass/runtime init`, after approving its changes, the tool sends us
+When a developer creates a token with `npx @genclass/runtime@beta init`, after approving its changes, the tool sends us
the name in their project's `package.json`. We keep one record per token: the token, that name, when it was created,
when data first and last arrived, a one-way hash of the dashboard link's secret, and the account it belongs to, if
any. These records are kept until the owner
@@ -74,7 +74,7 @@ asks us to delete them (contact below, with the token); the dashboard data itsel
## Accounts on genclass.dev
An account is optional: GenClass, its diagnostics and the private dashboard links work without one. A developer who
-creates one (at genclass.dev/signup, or with `npx @genclass/runtime login`, runtime 0.3.0-beta.0 and later) can keep
+creates one (at genclass.dev/signup, or with `npx @genclass/runtime@beta login`, runtime 0.3.0-beta.0 and later) can keep
their apps in one place. An account stores:
- **An email address:** as Google returned it when you sign in with Google, or as you typed it (trimmed and
diff --git a/README.md b/README.md
index 31ef392d..734f037a 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,12 @@ One line, first in `` or first in your entry file:
import "@genclass/runtime/auto"; // after npm install @genclass/runtime@beta
```
-Pin the version in the script tag, as above: the unversioned URL may serve an older release from the CDN cache.
-While `0.3.0` is in beta, `@beta` is part of every npm and npx command here: without it npm installs the stable
-`0.2.x`, whose CLI has no `login`, `connect`, `whoami`, `logout` or `--sink` and whose runtime ignores a token.
+Use the newest line, `0.3.0-beta.1` (npm dist-tag `beta`): every npm and npx command here names `@beta`, and the
+script tag pins the version (the unversioned URL serves npm's `latest`, possibly an older release from the CDN cache).
+A bare `npm install @genclass/runtime` or `npx @genclass/runtime init` gets npm's `latest`, the older `0.2.x` line,
+which has no token, dashboard, interaction analytics, visual checks or agent link, whose CLI has no `login`,
+`connect`, `dashboard` or `--sink`, and whose runtime ignores `token` and `analytics: true` without a word. Keep
+`0.2.x` only for an app that cannot take a prerelease.
That covers the whole app: network, user actions, errors, timing, and the app's state (React component state,
Redux / Redux Toolkit and Zustand `devtools` stores are discovered automatically; discovered React and Zustand state
@@ -35,8 +38,9 @@ generic facts about each write, request and response, and asks a small GenClass
The model runs in the browser (WebGPU or WASM, in a Web Worker). It answers two questions: what is happening, and
which available action is best. There is no list of known bugs in the code.
-**Status: `@genclass/runtime@0.3.0-beta.1` (npm `beta` dist-tag: `npm install @genclass/runtime@beta`; the stable
-line is `0.2.x`) with model `@genclass/runtime-model@0.2.0`.** Observe by default; `guard` is
+**Status: `@genclass/runtime@0.3.0-beta.1`, the newest line (npm `beta` dist-tag: `npm install @genclass/runtime@beta`;
+npm's `latest` is still the older `0.2.x`, the exception for apps that cannot take a prerelease) with model
+`@genclass/runtime-model@0.2.0`.** Observe by default; `guard` is
opt-in; `heal` is experimental. Measured false-intervention and recall rates are under
[Measured behaviour](#measured-behaviour). AI coding agents: start at [AGENTS.md](AGENTS.md).
diff --git a/demos/src/site/landing.ts b/demos/src/site/landing.ts
index 6d790a07..c35cc5e3 100644
--- a/demos/src/site/landing.ts
+++ b/demos/src/site/landing.ts
@@ -14,7 +14,7 @@ import { icon } from "./icons.ts";
applyTheme();
const root = siteRoot();
-const INSTALL = `$ npm install @genclass/runtime
+const INSTALL = `$ npm install @genclass/runtime@beta
${highlight(`import { GenClass } from "@genclass/runtime";
GenClass.init();`)}`;
@@ -23,7 +23,7 @@ function hero(): HTMLElement {
const copy = h("button", { class: "btn btn-xs copy-btn", type: "button", html: `${icon("copy")}Copy` });
copy.addEventListener("click", async () => {
try {
- await navigator.clipboard.writeText(`npm install @genclass/runtime\n\nimport { GenClass } from "@genclass/runtime";\nGenClass.init();\n`);
+ await navigator.clipboard.writeText(`npm install @genclass/runtime@beta\n\nimport { GenClass } from "@genclass/runtime";\nGenClass.init();\n`);
toast("Copied");
} catch {
toast("Clipboard is not available here");
diff --git a/docs/adoption/listings/svelte.md b/docs/adoption/listings/svelte.md
index 8f5ce772..40cc2472 100644
--- a/docs/adoption/listings/svelte.md
+++ b/docs/adoption/listings/svelte.md
@@ -33,7 +33,7 @@ Longer description, if the form has one:
> EventSource and user input in the browser and reports stale responses, duplicate requests, retries and failure
> storms; it starts in observe mode, which never changes a request. Its model (about 13 MB, cached) is downloaded from
> jsDelivr on a browser's first visit. The add-on writes GenClass's telemetry opt-out unless you choose to send
-> anonymous diagnostics. `npx @genclass/runtime remove` undoes it.
+> anonymous diagnostics. `npx @genclass/runtime@beta remove` undoes it.
## The GenClass README
diff --git a/docs/adoption/listings/sveltesociety.md b/docs/adoption/listings/sveltesociety.md
index 68923311..8ed2a90b 100644
--- a/docs/adoption/listings/sveltesociety.md
+++ b/docs/adoption/listings/sveltesociety.md
@@ -30,7 +30,7 @@ Testing; getting there means being noticed, not applying.
## What the entry needs
GenClass has no Svelte-specific package: the runtime is framework-agnostic, and the SvelteKit path is one import in
-`src/hooks.client.ts` ([templates/sveltekit](../../../templates/sveltekit/README.md); `npx @genclass/runtime init`
+`src/hooks.client.ts` ([templates/sveltekit](../../../templates/sveltekit/README.md); `npx @genclass/runtime@beta init`
writes it). The entry should therefore describe `@genclass/runtime` as it behaves in a SvelteKit app, which is what the
template's README already does honestly: it works from the network and user input (stale typeahead responses, duplicate
submits, failure storms, retries, stalls); Svelte runes and stores are not discovered automatically, so decisions that
diff --git a/docs/adoption/listings/vue-and-angular.md b/docs/adoption/listings/vue-and-angular.md
index 6c7c98e8..bba40a3a 100644
--- a/docs/adoption/listings/vue-and-angular.md
+++ b/docs/adoption/listings/vue-and-angular.md
@@ -86,7 +86,7 @@ tool that finds leaked RxJS subscriptions in Angular apps.` A bullet, the name l
detects and highlights Angular components that are re-rendering", and `rxjs-leak-finder`). "Runtime" is for things
that run Angular itself (dynamic compile, platforms); "Performance" and "Networking" are stretches.
-**Before submitting.** The Angular path is one import in `src/main.ts` (`templates/angular`, `npx @genclass/runtime
+**Before submitting.** The Angular path is one import in `src/main.ts` (`templates/angular`, `npx @genclass/runtime@beta
init`), covered by the compat matrix (Angular 22 with SSR, `HttpClient` on fetch and on XHR). Angular signals are not
discovered automatically; the description must not imply they are.
diff --git a/docs/runtime/ANALYTICS.md b/docs/runtime/ANALYTICS.md
index 2977a34b..989f3473 100644
--- a/docs/runtime/ANALYTICS.md
+++ b/docs/runtime/ANALYTICS.md
@@ -110,7 +110,8 @@ Interaction counters get their own per-batch budget (200 counters), so they neve
(1 year on Enterprise), hourly rows 48 hours.
**Dashboard.** A "How visitors use this app" section on the app dashboard (`src/pages.ts`, every plan, the same range
-picker), shown once the app has sent `interactions` events, using the page's existing table and card styles:
+picker), filled once the app has sent `interactions` events (before that, an empty state with the runtime they need:
+section 4.1), using the page's existing table and card styles:
- page views, average engaged time per view, clicks, rage clicks, dead clicks, with the change against the previous
period, and page views on the timeline;
@@ -137,6 +138,30 @@ GenClass.init({ token: "gc_…", analytics: true });
`stopped`).
- `telemetry.sample` below 1 samples analytics with the page load; the dashboard does not scale counts up.
+### 4.1 Which runtime: loud, never silent
+
+Interaction analytics first shipped in `@genclass/runtime` **0.3.0-beta.1**, on npm's `beta` dist-tag (install it with
+`npm install @genclass/runtime@beta`, `npx @genclass/runtime@beta init`, or a script tag pinned to
+`@0.3.0-beta.1`). npm's `latest` is still `0.2.2` until 0.3.0 is stable, and `0.2.x` and `0.3.0-beta.0` have no
+analytics: they ignore `analytics: true` without a word (reproduced on 0.2.2 in jsdom: no warning, no
+`runtime.analytics`, nothing sent; a TypeScript app gets a type error for the unknown option). Those releases cannot
+change, so every path that the newest line controls says it instead:
+
+- **The runtime.** When the analytics chunk does not load (a self-hosted `dist/` without `analytics/`, a stale bundler
+ chunk after a deploy), the stub warns once, `[GenClass] analytics code of did not load; nothing is
+ recorded.`, and `runtime.analytics` reports `{ enabled: false, reason: "error" }`. The stub passes its own version to
+ the chunk; a chunk of another release (files of two versions in one `dist/`) warns once per page with both versions
+ and the install line, and still records. The version check is in the chunk, so the first load grows only by the
+ argument and the warning (48 B gzip on `/auto`, 40 B on the main entry).
+- **The CLI.** `init` prints its version and the dist-tag it came from, says when npm has a newer release, and
+ upgrades an older `@genclass/runtime` in the project instead of keeping it (packages/runtime/README.md, "Which
+ version").
+- **`genclass_setup`** (`@genclass/mcp`) prints `npm install @genclass/runtime@beta` for a project on 0.2.x, on a range
+ without a version (`*`, `latest`) or with an older installed copy.
+- **The dashboard.** The Visitors view of an app without interaction data says that analytics need 0.3.0-beta.1 or
+ later, names the app's older runtime versions from its own telemetry, and shows the install line; the Connect
+ panel says the same.
+
## 5. Consent and opt-outs
- Every telemetry opt-out also turns analytics off: `telemetry: false`, `?genclass=no-telemetry`, `?genclass=off`,
diff --git a/docs/runtime/API.md b/docs/runtime/API.md
index ee013143..069a26a6 100644
--- a/docs/runtime/API.md
+++ b/docs/runtime/API.md
@@ -58,9 +58,9 @@ hosts (`0.2.1` sends from them too, by mistake; fixed in `0.2.2`), outside a bro
[packages/runtime/TELEMETRY.md](../../packages/runtime/TELEMETRY.md).
**App token and dashboard** (0.3.0: `0.2.x` ignores `token`). `token: "gc_..."` (from
-`npx @genclass/runtime init` or https://genclass.dev/start) is sent with each telemetry batch so the app's diagnostics
+`npx @genclass/runtime@beta init` or https://genclass.dev/start) is sent with each telemetry batch so the app's diagnostics
show up on its private dashboard (`https://genclass.dev/dashboard/`; free on every plan, and
-`npx @genclass/runtime dashboard` prints the link for the app in the current folder); with a token, decision events also
+`npx @genclass/runtime@beta dashboard` prints the link for the app in the current folder); with a token, decision events also
carry `fn` (see "Protected functions"). Public; malformed values log one warning and are ignored; it does nothing
while telemetry is off (on a local host the console says so once). `rt.telemetry.token` reports it while telemetry
is on. `token` and `scope` are fixed when GenClass starts: on a runtime already started by `/auto` or the script tag,
@@ -76,6 +76,10 @@ path; never element text). Every telemetry opt-out and Global Privacy Control tu
event log: nothing reaches a situation, a decision or the model. `rt.analytics.start()` / `stop()` turn them on and
off later (consent; `stop()` drops the counts not sent yet). Also `data-analytics="on"`, `analytics=on` in the meta
tag, `window.GENCLASS_CONFIG.analytics`. Schema: [TELEMETRY.md](../../packages/runtime/TELEMETRY.md#interaction-analytics).
+A chunk that does not load warns once (`[GenClass] analytics code of did not load; nothing is recorded.`,
+`reason: "error"`); a chunk of another release warns once with both versions. `0.2.x` (npm's `latest` until 0.3.0)
+and `0.3.0-beta.0` have no analytics and ignore the option silently: install `@genclass/runtime@beta`
+([ANALYTICS.md](ANALYTICS.md) section 4.1).
## Options
@@ -208,7 +212,7 @@ the model's recent median latency, 150 to 800 ms), then proceed unchanged; nothi
expected to answer within that budget. Serving your page with `Cross-Origin-Opener-Policy: same-origin` and
`Cross-Origin-Embedder-Policy: require-corp` enables WASM threads (about 3× faster without WebGPU).
-Self-hosting the model: `npx @genclass/runtime fetch-model public/genclass-model` then
+Self-hosting the model: `npx @genclass/runtime@beta fetch-model public/genclass-model` then
`GenClass.init({ model: { baseUrl: "/genclass-model/" } })`.
## State
@@ -667,7 +671,7 @@ never caps its `summary` record.
Sinks by name, with no code, in the zero-code entries (`/auto`, the script tag): `window.GENCLASS_CONFIG = { sinks:
["webhook:/genclass"] }`, `` (`+` joins names,
since commas separate the meta keys), `data-sink="webhook:/genclass,console-json"` on the script tag, or
-`npx @genclass/runtime init --sink webhook:/genclass` (the names are `console-json` and `webhook:`;
+`npx @genclass/runtime@beta init --sink webhook:/genclass` (the names are `console-json` and `webhook:`;
`console-json:noevidence` makes its adapter with `evidence: false`). The names `sentry`, `posthog` and `datadog` (any
case, with or without an option) are left out at init with one console warning for them all, `Ignored sink :
Sentry, PostHog and Datadog forwarding is not in the free runtime: https://genclass.dev/pricing`, and nothing is loaded or sent for them; `init --sink sentry` is a usage error that says
diff --git a/docs/runtime/THREAT-MODEL.md b/docs/runtime/THREAT-MODEL.md
index 18833abe..bd858b77 100644
--- a/docs/runtime/THREAT-MODEL.md
+++ b/docs/runtime/THREAT-MODEL.md
@@ -85,7 +85,7 @@ gate, or run hostile code.
([decide/policy.ts](../../packages/runtime/src/decide/policy.ts) `gate`). In `observe` (the default) nothing it says
changes anything (`test/invariants/observe.test.ts`).
- Inference runs in a module Web Worker; the ONNX graph has no network or DOM access.
-- Self-hosting: `npx @genclass/runtime fetch-model ` plus `model: { baseUrl, ortWasmPaths }` and a
+- Self-hosting: `npx @genclass/runtime@beta fetch-model ` plus `model: { baseUrl, ortWasmPaths }` and a
Content-Security-Policy limiting `connect-src`, `script-src` and `worker-src` to your origin
([README](../../packages/runtime/README.md#content-security-policy-and-self-hosting)). The script-tag build pins
every URL it loads later to its own version ([cdn/config.ts](../../packages/runtime/src/cdn/config.ts)).
diff --git a/package-lock.json b/package-lock.json
index cd992455..41675fb7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16809,7 +16809,7 @@
"node": ">=20"
},
"peerDependencies": {
- "@genclass/runtime": ">=0.2.0 || ^0.3.0-beta.0",
+ "@genclass/runtime": ">=0.3.0-beta.1",
"astro": ">=4.0.0"
}
},
@@ -16865,7 +16865,7 @@
"node": ">=20"
},
"peerDependencies": {
- "@genclass/runtime": ">=0.2.0 || ^0.3.0-beta.0"
+ "@genclass/runtime": ">=0.3.0-beta.1"
}
},
"packages/playwright": {
diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md
index e6d70ba5..c8995c1d 100644
--- a/packages/astro/CHANGELOG.md
+++ b/packages/astro/CHANGELOG.md
@@ -8,8 +8,8 @@
(`@genclass/runtime/auto`, or `/auto/guard` for `mode: "guard"`); `token` and `telemetry` are written to
`window.GENCLASS_CONFIG` by a virtual module imported before the runtime. The devtools overlay loads under
`astro dev` only (`devtools: "dev"`, the default), also in builds with `"always"`, never with `"never"`. Nothing runs
- on the server. `astro` (4 or later; tested with 7.4) and `@genclass/runtime` (0.2.0 or later; `token` needs 0.3.0)
- are peer dependencies. Keywords `astro-integration` and `withastro` for the integrations library and `astro add`.
+ on the server. `astro` (4 or later; tested with 7.4) and `@genclass/runtime` (`>=0.3.0-beta.1`, the newest
+ line: `npm install @genclass/runtime@beta`; npm's `latest`, 0.2.x, does not satisfy it) are peer dependencies. Keywords `astro-integration` and `withastro` for the integrations library and `astro add`.
- The runtime modules the script imports are added to Vite's `optimizeDeps.include`: the injected script is a virtual
module Vite's dependency scan does not see, and without this `astro dev` discovered them on the first page request
and re-optimized with a reload (a "504 Outdated Optimize Dep" console error on a cold start).
diff --git a/packages/astro/README.md b/packages/astro/README.md
index 69ebd0d6..f0b841cd 100644
--- a/packages/astro/README.md
+++ b/packages/astro/README.md
@@ -39,8 +39,9 @@ export default defineConfig({
That is all the setup. The integration injects one script into every page, bundled by Vite with the page's other
scripts, whose first line is the runtime's `import "@genclass/runtime/auto"`: GenClass is installed when the page's
scripts run, before your islands hydrate and before their first request. On the server (SSR, endpoints, the build)
-nothing is imported and nothing runs. The `@beta` tag gets `@genclass/runtime` 0.3.0, which the `token` option needs;
-the stable `0.2.x` works with every other option.
+nothing is imported and nothing runs. The `@beta` tag gets the newest runtime line, `@genclass/runtime`
+0.3.0-beta.1 or later, which the peer range asks for (the `token` option, the dashboard and interaction analytics need
+it); a bare `npm install @genclass/runtime` gets npm's `latest`, the older `0.2.x`, which does not satisfy it.
With options:
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 27c15a35..db83c59d 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -46,7 +46,7 @@
"test": "vitest run"
},
"peerDependencies": {
- "@genclass/runtime": ">=0.2.0 || ^0.3.0-beta.0",
+ "@genclass/runtime": ">=0.3.0-beta.1",
"astro": ">=4.0.0"
},
"devDependencies": {
diff --git a/packages/astro/src/index.ts b/packages/astro/src/index.ts
index c8d7e7f1..f95aaf5b 100644
--- a/packages/astro/src/index.ts
+++ b/packages/astro/src/index.ts
@@ -18,7 +18,7 @@ export interface GenClassOptions {
*/
mode?: "observe" | "guard";
/**
- * The app's dashboard token (`gc_...`) from genclass.dev or `npx @genclass/runtime login`. Needs `@genclass/runtime`
+ * The app's dashboard token (`gc_...`) from genclass.dev or `npx @genclass/runtime@beta login`. Needs `@genclass/runtime`
* 0.3.0 or later; 0.2.x ignores it.
*/
token?: string;
diff --git a/packages/genclass-runtime/README.md b/packages/genclass-runtime/README.md
index 1a75a4ed..8d17b535 100644
--- a/packages/genclass-runtime/README.md
+++ b/packages/genclass-runtime/README.md
@@ -3,18 +3,20 @@
Short alias for the [`@genclass/runtime`](https://www.npmjs.com/package/@genclass/runtime) CLI.
```bash
-npx genclass-runtime init
+npx @genclass/runtime@beta init
```
-It does the same as `npx @genclass/runtime init`: it finds your framework, installs `@genclass/runtime`, adds one
-line to your entry file and shows you the diff first. From `0.3.0`, once you approve the diff, it
-also creates your app's token and private dashboard link at genclass.dev (sending your `package.json` name), writes
+Use that command, the newest line, for now: npm's `genclass-runtime` is still at `0.1.0-beta.3` (this alias is not
+published by the release workflow yet), so `npx genclass-runtime init` runs that old CLI. Once this alias is published
+at the newest version, `npx genclass-runtime init` does the same as `npx @genclass/runtime@beta init`: it finds your
+framework, installs `@genclass/runtime`, adds one line to your entry file and shows you the diff first. From `0.3.0`,
+once you approve the diff, it also creates your app's token and private dashboard link at genclass.dev (sending your `package.json` name), writes
the token into what it adds, and saves the link in `.genclass.local` (keep that link private; it is the only way to
open your dashboard).
```bash
-npx genclass-runtime init --token gc_... # use a token you already have (no network)
-npx genclass-runtime init --no-token # no token, no network
+npx @genclass/runtime@beta init --token gc_... # use a token you already have (no network)
+npx @genclass/runtime@beta init --no-token # no token, no network
```
Docs: [@genclass/runtime](https://www.npmjs.com/package/@genclass/runtime) (one-line setup, function-specific
diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md
index 0f43ee9f..0bb37141 100644
--- a/packages/mcp/CHANGELOG.md
+++ b/packages/mcp/CHANGELOG.md
@@ -2,6 +2,11 @@
## 0.1.0 (unreleased)
+- **`genclass_setup` installs the newest runtime line.** It prints `npm install @genclass/runtime@beta` (the agent
+ entry and interaction analytics need 0.3.0-beta.1 or later) for a project on 0.2.x, on a range without a version
+ (`*`, `latest`: npm's `latest` is still 0.2.x) or with an older copy in `node_modules`, and says what the project
+ has. The README's connect step names `@beta` first.
+
- **Opt-in reporting to the app's dashboard** (`GENCLASS_REPORT`; [docs/runtime/AGENT.md](../../docs/runtime/AGENT.md)
section 4.5). Set to `on` (with `GENCLASS_DASHBOARD`) or to a dashboard link, the server sends that dashboard each
finding the bridge makes, each replay verdict and each regression test written: `POST /api/projects//agent`,
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
index f0e11078..2844818f 100644
--- a/packages/mcp/README.md
+++ b/packages/mcp/README.md
@@ -267,8 +267,9 @@ AI Assistant: Settings, Tools, AI Assistant, Model Context Protocol (MCP), Add,
Any other MCP client: a stdio server, command `npx`, arguments `-y @genclass/mcp@0.1.0`, no environment variables
required.
-Then connect the app's development build (once). The agent link needs `@genclass/runtime` 0.3.0-beta.1 or later
-(`npm install @genclass/runtime@beta` until 0.3.0 is `latest`):
+Then connect the app's development build (once). The agent link needs the newest runtime line, `@genclass/runtime`
+0.3.0-beta.1 or later: `npm install @genclass/runtime@beta` (a bare `npm install @genclass/runtime` gets npm's
+`latest`, the older 0.2.x, which has no agent entry):
```ts
import { GenClass } from "@genclass/runtime";
diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts
index 5013e176..b017be8e 100644
--- a/packages/mcp/src/tools.ts
+++ b/packages/mcp/src/tools.ts
@@ -504,11 +504,22 @@ export function setupText(cwd: string, client?: string | null): string {
}
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
const has = (n: string) => n in deps;
- // the agent entry (@genclass/runtime/agent) ships from 0.3.0-beta.1; until 0.3.0 is `latest`, it is on the beta tag
+ // the agent entry (@genclass/runtime/agent) and interaction analytics ship from 0.3.0-beta.1, the newest line (npm
+ // dist-tag beta); npm's `latest` is the older 0.2.x until 0.3.0, so a range without a version ("*", "latest") gets
+ // 0.2.x. The installed copy, when there is one, says what the project really runs.
const range = String(deps["@genclass/runtime"] ?? "");
- const m = /(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?/.exec(range);
- const tooOld = !!m && (Number(m[1]) === 0 && (Number(m[2]) < 3 || (Number(m[2]) === 3 && Number(m[3]) === 0 && m[4] !== undefined && Number(m[4]) < 1)));
- const installed = has("@genclass/runtime") && !tooOld && range !== "latest";
+ let have = range;
+ try {
+ const v = JSON.parse(readFileSync(join(cwd, "node_modules", "@genclass", "runtime", "package.json"), "utf8")).version;
+ if (typeof v === "string") have = v;
+ } catch {
+ /* not installed here */
+ }
+ const m = /(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?/.exec(have);
+ const tooOld = m
+ ? Number(m[1]) === 0 && (Number(m[2]) < 3 || (Number(m[2]) === 3 && Number(m[3]) === 0 && m[4] !== undefined && Number(m[4]) < 1))
+ : /^\s*(\*|x|latest)?\s*$/i.test(range);
+ const installed = has("@genclass/runtime") && !tooOld;
const next = has("next");
const vite = has("vite") || existsSync(join(cwd, "vite.config.ts")) || existsSync(join(cwd, "vite.config.js"));
const guard = next ? `process.env.NODE_ENV === "development"` : vite ? "import.meta.env.DEV" : `process.env.NODE_ENV !== "production"`;
@@ -516,7 +527,7 @@ export function setupText(cwd: string, client?: string | null): string {
const lines = [
installed
? "@genclass/runtime is installed."
- : `1. Install the runtime with the agent entry (0.3.0-beta.1 or later${has("@genclass/runtime") ? `; this project has ${JSON.stringify(range)}` : ""}): npm install @genclass/runtime@beta`,
+ : `1. Install the newest runtime, 0.3.0-beta.1 or later (the agent entry and analytics; 0.2.x has neither${has("@genclass/runtime") ? `; this project has ${JSON.stringify(range)}${have !== range ? `, installed ${have}` : ""}` : ""}): npm install @genclass/runtime@beta`,
`${installed ? "1" : "2"}. In ${where}:`,
"```ts",
`import { GenClass } from "@genclass/runtime";`,
diff --git a/packages/mcp/test/mcp.test.ts b/packages/mcp/test/mcp.test.ts
index 9d32b69c..21acce01 100644
--- a/packages/mcp/test/mcp.test.ts
+++ b/packages/mcp/test/mcp.test.ts
@@ -1,10 +1,13 @@
// The MCP server (AGENT.md §4.2): the protocol over stdio, the tools end to end against a live bridge, channel pushes.
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it } from "vitest";
import { McpServer, PROTOCOL_VERSIONS } from "../src/mcp.js";
import { serve, channelEvent, type Served } from "../src/server.js";
import { VERSION } from "../src/http.js";
-import { INSTRUCTIONS, TOOLS } from "../src/tools.js";
+import { INSTRUCTIONS, TOOLS, setupText } from "../src/tools.js";
import { act, page, snap, tokenDir } from "./helpers.js";
/** A client on in-memory streams: send requests, read replies and notifications. */
@@ -80,6 +83,36 @@ describe("mcp: genclass_setup and the client named at initialize (README Install
});
});
+describe("mcp: genclass_setup installs the newest runtime line (README Install)", () => {
+ const app = (deps: Record, installed?: string) => {
+ const dir = mkdtempSync(join(tmpdir(), "gc-setup-"));
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "app", dependencies: { vite: "^7", ...deps } }));
+ if (installed) {
+ mkdirSync(join(dir, "node_modules", "@genclass", "runtime"), { recursive: true });
+ writeFileSync(join(dir, "node_modules", "@genclass", "runtime", "package.json"), JSON.stringify({ version: installed }));
+ }
+ return dir;
+ };
+ const INSTALL = "npm install @genclass/runtime@beta";
+
+ it("no runtime, 0.2.x, a range without a version, or an older installed copy: install @beta", () => {
+ expect(setupText(app({}))).toContain(INSTALL);
+ expect(setupText(app({ "@genclass/runtime": "^0.2.0" }))).toContain(`this project has "^0.2.0"): ${INSTALL}`);
+ for (const r of ["*", "latest", ""]) expect(setupText(app({ "@genclass/runtime": r }))).toContain(INSTALL);
+ expect(setupText(app({ "@genclass/runtime": "^0.3.0-beta.0" }))).toContain(INSTALL);
+ expect(setupText(app({ "@genclass/runtime": "^0.3.0-beta.1" }, "0.2.2"))).toContain(`this project has "^0.3.0-beta.1", installed 0.2.2): ${INSTALL}`);
+ });
+
+ it("0.3.0-beta.1 or later (or the beta tag) counts as installed", () => {
+ for (const r of ["^0.3.0-beta.1", "0.3.0-beta.2", "^0.3.0", "beta"]) {
+ const t = setupText(app({ "@genclass/runtime": r }));
+ expect(t).toContain("@genclass/runtime is installed.");
+ expect(t).not.toContain(INSTALL);
+ }
+ expect(setupText(app({ "@genclass/runtime": "*" }, "0.3.0-beta.1"))).toContain("@genclass/runtime is installed.");
+ });
+});
+
describe("mcp: protocol", () => {
it("initialize: version negotiation, instructions, tools and the channel capability", async () => {
const { c, init } = await start();
diff --git a/packages/nuxt/CHANGELOG.md b/packages/nuxt/CHANGELOG.md
index 174cf78f..e26337c3 100644
--- a/packages/nuxt/CHANGELOG.md
+++ b/packages/nuxt/CHANGELOG.md
@@ -9,5 +9,6 @@
`mode: "guard"`) first, before the app's own plugins; `token` and `telemetry` are written to
`window.GENCLASS_CONFIG` by a module imported before the runtime. The devtools overlay loads in `nuxt dev` only
(`devtools: "dev"`, the default), also in production with `"always"`, never with `"never"`. Nothing runs on the
- server. `@genclass/runtime` is a peer dependency (0.2.0 or later; `token` needs 0.3.0). Tested with Nuxt 4.6; the
+ server. `@genclass/runtime` is a peer dependency (`>=0.3.0-beta.1`, the newest line: `npm install @genclass/runtime@beta`;
+ npm's `latest`, 0.2.x, has no token, dashboard or analytics and does not satisfy it). Tested with Nuxt 4.6; the
module declares `nuxt: ">=3.17.0"`.
diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md
index d7869bf6..5c0cb53f 100644
--- a/packages/nuxt/README.md
+++ b/packages/nuxt/README.md
@@ -30,8 +30,9 @@ export default defineNuxtConfig({
That is all the setup. The module adds one client-only plugin, placed before your own plugins, whose first line is the
runtime's `import "@genclass/runtime/auto"`: GenClass is installed when the entry chunk runs, before Nuxt creates the
app and before any component's first request. On the server (SSR, Nitro routes, prerendering) nothing is imported and
-nothing runs. The `@beta` tag gets `@genclass/runtime` 0.3.0, which the `token` option needs; the stable `0.2.x` works
-with every other option.
+nothing runs. The `@beta` tag gets the newest runtime line, `@genclass/runtime`
+0.3.0-beta.1 or later, which the peer range asks for (the `token` option, the dashboard and interaction analytics need
+it); a bare `npm install @genclass/runtime` gets npm's `latest`, the older `0.2.x`, which does not satisfy it.
With options:
diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json
index 70535f9e..b3f5cfd9 100644
--- a/packages/nuxt/package.json
+++ b/packages/nuxt/package.json
@@ -55,7 +55,7 @@
"@nuxt/kit": "^4.6.2"
},
"peerDependencies": {
- "@genclass/runtime": ">=0.2.0 || ^0.3.0-beta.0"
+ "@genclass/runtime": ">=0.3.0-beta.1"
},
"devDependencies": {
"@nuxt/module-builder": "^1.0.3",
diff --git a/packages/nuxt/src/source.ts b/packages/nuxt/src/source.ts
index 825ed8de..570f0545 100644
--- a/packages/nuxt/src/source.ts
+++ b/packages/nuxt/src/source.ts
@@ -17,7 +17,7 @@ export interface ModuleOptions {
*/
mode?: "observe" | "guard";
/**
- * The app's dashboard token (`gc_...`) from genclass.dev or `npx @genclass/runtime login`. Needs `@genclass/runtime`
+ * The app's dashboard token (`gc_...`) from genclass.dev or `npx @genclass/runtime@beta login`. Needs `@genclass/runtime`
* 0.3.0 or later; 0.2.x ignores it.
*/
token?: string;
diff --git a/packages/playwright/README.md b/packages/playwright/README.md
index 01d5b4ed..6961d77b 100644
--- a/packages/playwright/README.md
+++ b/packages/playwright/README.md
@@ -28,7 +28,7 @@ response reaches the page, never what it says; no finding changes a test's outco
Not on npm yet (version 0.1.0 is unreleased). Once published:
```bash
-npm install -D @genclass/playwright @genclass/runtime
+npm install -D @genclass/playwright @genclass/runtime@beta
```
Playwright 1.40 or later; `@genclass/runtime` 0.3.0-beta.1 or later (the fixture reads its `dist/` from the
diff --git a/packages/playwright/src/install.ts b/packages/playwright/src/install.ts
index 660df113..5622d2f2 100644
--- a/packages/playwright/src/install.ts
+++ b/packages/playwright/src/install.ts
@@ -116,7 +116,7 @@ export function resolveRuntimeDir(given?: string): string {
try {
pkg = require.resolve("@genclass/runtime/package.json");
} catch {
- throw new Error("@genclass/playwright: @genclass/runtime is not installed (npm install -D @genclass/runtime), or pass genclass.runtimeDir");
+ throw new Error("@genclass/playwright: @genclass/runtime is not installed (npm install -D @genclass/runtime@beta), or pass genclass.runtimeDir");
}
const dir = join(dirname(pkg), "dist");
if (!existsSync(join(dir, "genclass.global.js"))) throw new Error(`@genclass/playwright: ${dir} has no genclass.global.js: the installed @genclass/runtime has no build`);
diff --git a/packages/runtime-model/README.md b/packages/runtime-model/README.md
index af719801..2898221f 100644
--- a/packages/runtime-model/README.md
+++ b/packages/runtime-model/README.md
@@ -46,7 +46,7 @@ GenClass.init({ model: { baseUrl: "/genclass-model/" } });
Or download it with the runtime's CLI, which verifies every hash:
```bash
-npx @genclass/runtime fetch-model public/genclass-model --from https://cdn.jsdelivr.net/npm/@genclass/runtime-model@0.2.0/files/
+npx @genclass/runtime@beta fetch-model public/genclass-model --from https://cdn.jsdelivr.net/npm/@genclass/runtime-model@0.2.0/files/
```
With the script tag, use `data-model="/genclass-model/"`.
diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md
index 4f9b19d1..f50258eb 100644
--- a/packages/runtime/CHANGELOG.md
+++ b/packages/runtime/CHANGELOG.md
@@ -2,7 +2,27 @@
## Unreleased
-- **`dashboard` prints the link to this app's free dashboard.** `npx @genclass/runtime dashboard` prints the link
+- **Every path lands on the newest line.** npm's `latest` is still `0.2.2`, so `npx @genclass/runtime init` and
+ `npm install @genclass/runtime` give an app the older line without the token, the dashboard, analytics, visual
+ checks or the agent link. Now `init` (and `login`, `connect`) prints its own version and the npm dist-tag it came
+ from (`Version @genclass/runtime 0.3.0-beta.1 (npm dist-tag beta)`), asks npm's dist-tags once
+ (`npm view @genclass/runtime dist-tags`, at most 3 s; never with `--dry-run`, the new `--no-network`, `--no-token`
+ or `--no-telemetry`; nothing when offline) and says when a newer release is on npm, with the command that runs it.
+ A project that already has an older `@genclass/runtime` (a 0.2.x from `init` without `@beta`) is upgraded to
+ `^` after the same diff and question, instead of being kept; the plain "install it by hand" hint
+ names the version too. Every command the CLI prints, and the console messages that name one (an invalid token, a
+ blocked model, the mode and token hints), say `npx @genclass/runtime@beta` while the runtime is a prerelease. The
+ READMEs, templates (`^0.3.0-beta.1`), TELEMETRY.md, API.md and the script tags (pinned to `@0.3.0-beta.1`) put the
+ newest line first and the stable `0.2.x` as the exception. No model-visible text changed (`situation-v2.3`).
+- **Interaction analytics are loud when their code is missing or from another release**
+ ([docs/runtime/ANALYTICS.md](../../docs/runtime/ANALYTICS.md) section 4.1). `0.2.x` and `0.3.0-beta.0` ignore
+ `analytics: true` without a word (reproduced on 0.2.2; they cannot change). On this line, a chunk that does not load
+ (a self-hosted `dist/` without `analytics/`, a stale bundler chunk) warns once, `[GenClass] analytics code of
+ did not load; nothing is recorded.`, with `runtime.analytics.reason` `"error"` as before; the stub passes
+ its version to the chunk, and a chunk of another release warns once per page with both versions and the install
+ line, and still records. First load: +48 B gzip on `/auto`, +40 B on the main entry for this (the check itself is
+ in the chunk); +67 B and +60 B with the `@beta` console messages above. The dashboard's Visitors view and Connect panel name the version analytics need.
+- **`dashboard` prints the link to this app's free dashboard.** `npx @genclass/runtime@beta dashboard` prints the link
to the dashboard of the app in the current folder: the private link in `.genclass.local` (nothing is contacted),
else the app's page in the signed-in account (one `GET /api/me`), else the account's apps; `--open` also opens it in
the browser. `init`'s dashboard notice says what the dashboard shows and how to print the link again, and `whoami`
diff --git a/packages/runtime/INTERCEPTION.md b/packages/runtime/INTERCEPTION.md
index 6c42fb1d..c9e88644 100644
--- a/packages/runtime/INTERCEPTION.md
+++ b/packages/runtime/INTERCEPTION.md
@@ -258,7 +258,7 @@ GenClass.init({ mode: "guard", requests: { protect: [...protectPreset("payments"
window.GENCLASS_CONFIG = { requests: { protect: ["preset:payments", "preset:auth"] } };
```
-`npx @genclass/runtime init` suggests the second form when the project depends on a payment SDK (Stripe, PayPal,
+`npx @genclass/runtime@beta init` suggests the second form when the project depends on a payment SDK (Stripe, PayPal,
Braintree, Adyen, Square, Paddle, ...) or has source files named like a checkout or payments page. The presets
([src/presets.ts](src/presets.ts)) match URL words such as `checkout`, `payment(s)`, `payment_intents`, `billing`,
`invoice`, `charge`, `refund`, `subscription`, `order`, `transaction`, `transfer`, `payout`, `wallet` and provider
diff --git a/packages/runtime/README.md b/packages/runtime/README.md
index e09985c5..dc3dd0e5 100644
--- a/packages/runtime/README.md
+++ b/packages/runtime/README.md
@@ -25,9 +25,14 @@ runtime has no list of known bugs: triage picks the situations, the model decide
(pin the version: the unversioned URL may serve an older release from the CDN cache) or first in your entry file (any bundler; Next.js: `instrumentation-client.ts`):
```ts
-import "@genclass/runtime/auto";
+import "@genclass/runtime/auto"; // after npm install @genclass/runtime@beta
```
+Use the newest line, `0.3.0-beta.1` (npm dist-tag `beta`): every command in this README names `@beta`. Without it,
+npm installs its `latest`, the older `0.2.x`, which has no token, dashboard, interaction analytics, visual checks or
+agent link and ignores those options without a word (a TypeScript app gets a type error for `analytics` and
+`token`). `0.2.x` is the exception, for an app that cannot take a prerelease.
+
That is the whole setup. Network traffic, user actions, errors and timing are observed, and so is your app's state:
React component state (`useState`, `useReducer`, `useSyncExternalStore`, class state), Redux and Redux Toolkit
stores, and Zustand stores with `devtools` are found automatically, with no store to register
@@ -48,8 +53,9 @@ GenClass at a few functions instead of the whole app. See [Your dashboard](#your
> browsers sending Global Privacy Control are never collected. Details: [Privacy and telemetry](#privacy-and-telemetry)
> and [TELEMETRY.md](https://github.com/genclass-dev/GenClass-lib/blob/main/packages/runtime/TELEMETRY.md).
-> **Status: `0.3.0-beta.1` (npm `beta` dist-tag: `npm install @genclass/runtime@beta`; the stable line is `0.2.x`),
-> with model `@genclass/runtime-model@0.2.0`.**
+> **Status: `0.3.0-beta.1`, the newest line (npm `beta` dist-tag: `npm install @genclass/runtime@beta`; npm's
+> `latest` is still the older `0.2.x`, the exception for apps that cannot take a prerelease), with model
+> `@genclass/runtime-model@0.2.0`.**
>
> - **The model loads by default.** A default `GenClass.init()` loads `@genclass/runtime-model@0.2.0` (10.2 MB q8,
> `genclass-runtime-r17` 2.0.0-rc4t, run `r17-v2dT`, with a gain gate and three aggressiveness profiles) from
@@ -139,8 +145,22 @@ npx @genclass/runtime@beta init --dry-run # show the diff, write nothing
npx @genclass/runtime@beta remove # undo exactly what init added
```
-Other flags: `--no-install`, `--no-devtools`, `--cwd `, `--cdn ` and `--no-sri` (plain HTML),
-`remove --keep-package`.
+Other flags: `--no-install`, `--no-devtools`, `--no-network`, `--cwd `, `--cdn ` and `--no-sri` (plain
+HTML), `remove --keep-package`.
+
+**Which version.** `init` (and `login`, `connect`) first prints its own version and the npm dist-tag it came from,
+asks npm's dist-tags once (`npm view @genclass/runtime dist-tags`, at most 3 s; never with `--dry-run`,
+`--no-network`, `--no-token` or `--no-telemetry`, nothing when offline) and says when a newer release is on npm, with
+the command that runs it:
+
+```
+ ✔ Version @genclass/runtime 0.3.0-beta.1 (npm dist-tag beta)
+ ▲ Newer @genclass/runtime 0.3.0-beta.2 is on npm (dist-tag beta): run npx @genclass/runtime@beta init instead
+```
+
+It installs its own version (`@genclass/runtime@^0.3.0-beta.1`), and a project that already has an older one (a
+`0.2.x` from `npx @genclass/runtime init` without `@beta`) is upgraded, after the same diff and question. Every
+command it prints names `@beta` while it is a prerelease.
**Token and dashboard** (`0.3.0`; `init` in `0.2.x` creates no token). On a new setup, once you approve the
diff (or pass `--yes`), `init` also creates a project for your app at genclass.dev: it sends `package.json`'s `name`
@@ -151,7 +171,7 @@ to `https://genclass.dev/api/projects`, writes the token it gets back into what
Your dashboard https://genclass.dev/dashboard/...
Keep this link private; it is the only way to open your dashboard.
Free for every app: what GenClass found, on which pages, and what it did, and (with analytics: true)
- how visitors use each page. Print this link again any time: npx @genclass/runtime dashboard
+ how visitors use each page. Print this link again any time: npx @genclass/runtime@beta dashboard
Saved in .genclass.local (added to .gitignore).
```
@@ -236,15 +256,15 @@ returns an inert runtime.
**3. One script tag** (no build step), first in ``:
```html
-
+
```
It exposes `window.GenClass`, discovers the app's state like the import does, and loads the model worker, ONNX
Runtime Web and the overlay on demand from the same version on the CDN. `data-mode` takes `observe` (the default), `guard` or `heal`; `data-devtools="local"` shows the
overlay only on localhost; `data-manual` skips the automatic `GenClass.init()`. Since `0.3.0`: `data-token="gc_..."`
-(your app's token), `data-scope="functions"` and `window.GenClass.protect`. Pin a version in production
-(`https://cdn.jsdelivr.net/npm/@genclass/runtime@0.3.0-beta.1`); the plain-HTML path of `init` writes a pinned
-jsDelivr URL with SRI.
+(your app's token), `data-scope="functions"` and `window.GenClass.protect`. Keep the version pinned, as above: the
+unversioned URL serves npm's `latest` (the older `0.2.x` until `0.3.0` is stable); the plain-HTML path of `init`
+writes a pinned jsDelivr URL with SRI.
**4. By hand:**
@@ -443,7 +463,12 @@ console warning and is ignored.
model load, backend (WebGPU or WASM) and latency, runtime and model versions, and recent detections. With
`analytics: true` (new in `0.3.0-beta.1`, off by default), also how visitors use the app: page views, time on page and
scroll depth per page, the most clicked elements, and rage and dead clicks ([Privacy and telemetry](#privacy-and-telemetry)).
-Put `data-genclass-name="pricing-cta"` on an element to give it a readable name there.
+Put `data-genclass-name="pricing-cta"` on an element to give it a readable name there. Analytics need
+`0.3.0-beta.1` or later (`npm install @genclass/runtime@beta`): `0.2.x`, npm's `latest` until 0.3.0, ignores
+`analytics: true` without a word, so the dashboard's Visitors view says which runtime versions your app sends from.
+When the analytics code itself does not load (a self-hosted `dist/` missing `analytics/`), the console says so once
+and `runtime.analytics.reason` is `"error"`; files of two releases in one `dist/` get one warning naming both
+([ANALYTICS.md](https://github.com/genclass-dev/GenClass-lib/blob/main/docs/runtime/ANALYTICS.md) section 4.1).
**What it needs:** telemetry on (the default with `GenClass.init()` in a browser) on a deployed host. Local hosts
(`localhost`, `*.local`, `*.test`, private networks) do not send by default, so a page there does not feed the
diff --git a/packages/runtime/TELEMETRY.md b/packages/runtime/TELEMETRY.md
index bd4b5c20..166eef6a 100644
--- a/packages/runtime/TELEMETRY.md
+++ b/packages/runtime/TELEMETRY.md
@@ -13,7 +13,7 @@ Details under [Opting out](#opting-out).
| how GenClass starts | default |
|---|---|
-| `GenClass.init()`, `import "@genclass/runtime/auto"` (and `/auto/*`), the CDN script tag, `npx @genclass/runtime init` setups | **on** in a browser on a public host |
+| `GenClass.init()`, `import "@genclass/runtime/auto"` (and `/auto/*`), the CDN script tag, `npx @genclass/runtime@beta init` setups | **on** in a browser on a public host |
| the same, on a local or private host (`localhost`, `*.localhost`, `*.local`, `*.test`, `*.internal`, `127.0.0.1`, `::1`, `10/8`, `172.16/12`, `192.168/16`, link-local, `file:`) | off unless `telemetry: true` / `{ ... }` (`0.2.1`: on, by mistake; fixed in `0.2.2`, see the CHANGELOG) |
| `GenClass.init()` outside a browser (Node, SSR, workers) | off unless `telemetry: true` / `{ ... }` |
| `createRuntime()` (headless, tests, the simulator) | off unless `telemetry: true` / `{ ... }` |
@@ -222,7 +222,7 @@ your consent banner), `runtime.analytics.stop()` turns them off and drops the co
New in `0.3.0` (`0.2.x` ignores a token and sends neither `token` nor `fn`). An app can set a **token**
(`InitOptions.token`, `data-token`, `` or `window.GENCLASS_CONFIG.token`):
-`gc_` followed by 22 letters or digits, created by `npx @genclass/runtime init` or at https://genclass.dev/start.
+`gc_` followed by 22 letters or digits, created by `npx @genclass/runtime@beta init` or at https://genclass.dev/start.
- The token is public (it is in the page). It is sent as the batch envelope's top-level `token` and nowhere else; it
lets the collector group the batches of one app. A malformed token logs one console warning and is not sent.
@@ -239,7 +239,7 @@ New in `0.3.0` (`0.2.x` ignores a token and sends neither `token` nor `fn`). An
`[redacted]`, so use fixed names. Without a token `fn` is never sent.
- With a token, the console notice ends with one more sentence: `With this app's token they also feed its private
GenClass dashboard.`
-- `npx @genclass/runtime init` creates a token (a "project") only on a new setup and only after the developer
+- `npx @genclass/runtime@beta init` creates a token (a "project") only on a new setup and only after the developer
approved its changes (or passed `--yes`): it sends one request, `POST https://genclass.dev/api/projects` with
`{ "name": }`. The collector keeps one record per project: the token,
that name, the creation time, the times of the first and the last batch, a SHA-256 hash of the dashboard secret
diff --git a/packages/runtime/bin/lib/init.mjs b/packages/runtime/bin/lib/init.mjs
index bdffc79f..362d4132 100644
--- a/packages/runtime/bin/lib/init.mjs
+++ b/packages/runtime/bin/lib/init.mjs
@@ -13,7 +13,6 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
-import { fileURLToPath } from "node:url";
import { detectCsp, detectProject, detectState, htmlFiles, isDir, isFile, readText, walkSkipped, walkSources } from "./detect.mjs";
import { MARK, hasMarker, isEmptyConfig, planRemoval, removeMarked, sinkBase, sinkNoEvidence, switchMetaConfig, switchMode, switchTagConfig, updateConfigText } from "./edit.mjs";
import { AUTO, configFileChange, configFileFor, planInit, scriptTag } from "./plan.mjs";
@@ -21,17 +20,8 @@ import { createAccountProject, readCredentials } from "./account.mjs";
import { hostConfig, hostUrl } from "./host.mjs";
import { LOCAL_FILE, PENDING_TOKEN, START_URL, TOKEN_RE, createProject, readLocal, saveLocal, tokenInFiles } from "./token.mjs";
import { banner, c, confirm, err, interactive, out, printChange, row, sym } from "./ui.mjs";
+import { CMD, PKG, PKG_DIR, VERSION, compareVersions, distTags, installedVersion, versionInSpec, versionStatus } from "./version.mjs";
-const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
-const VERSION = (() => {
- try {
- return JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8")).version;
- } catch {
- return "latest";
- }
-})();
-const CMD = "npx @genclass/runtime";
-const PKG = "@genclass/runtime";
const MODES = ["observe", "guard", "heal"];
const REPO = "https://github.com/genclass-dev/GenClass-lib/blob/main";
@@ -43,19 +33,21 @@ export const PRICING_URL = "https://genclass.dev/pricing";
/** What a dashboard shows, said under every dashboard link the CLI prints: analytics are free on every plan. */
export const FREE_LINES = [
"Free for every app: what GenClass found, on which pages, and what it did, and (with analytics: true)",
- "how visitors use each page. Print this link again any time: npx @genclass/runtime dashboard",
+ `how visitors use each page. Print this link again any time: ${CMD} dashboard`,
];
export const USAGE = `Usage:
genclass-runtime init [--mode observe|guard|heal] [--token | --no-token]
[--no-telemetry | --telemetry] [--model-url ]
[--sink console-json|webhook:]
- [--yes] [--dry-run] [--no-install] [--no-devtools] [--cwd ]
+ [--yes] [--dry-run] [--no-install] [--no-devtools] [--no-network] [--cwd ]
genclass-runtime remove [--yes] [--dry-run] [--keep-package] [--cwd ]
-init finds your framework, installs ${PKG}, adds one import to your entry file (and a dev-only devtools
- line), shows the diff and asks before writing. Running it again changes nothing; with another
- --mode it switches the import init added to that mode.
+init finds your framework, installs ${PKG} (this version, ${VERSION}; an older one already in the
+ project is upgraded), adds one import to your entry file (and a dev-only devtools line), shows
+ the diff and asks before writing. Running it again changes nothing; with another --mode it
+ switches the import init added to that mode. It prints its own version and asks npm's dist-tags
+ (at most 3 s) whether a newer release exists.
remove takes out exactly what init added (the lines and blocks marked "${MARK}"; it changes nothing if
one of them was edited) and uninstalls the package if nothing else imports it.
@@ -87,6 +79,8 @@ remove takes out exactly what init added (the lines and blocks marked "${MARK}
--dry-run show what would change; write nothing
--no-install do not run the package manager
--no-devtools do not add the dev-only devtools overlay
+ --no-network do not ask npm for newer releases (--dry-run, --no-token and --no-telemetry do not
+ ask either)
--keep-package remove: leave ${PKG} installed
--cwd the project directory (default: the current directory)
--from install ${PKG} from this spec (a version, tag or tarball path)
@@ -102,6 +96,7 @@ const BOOL = {
"dry-run": "dryRun",
"no-install": "noInstall",
"no-devtools": "noDevtools",
+ "no-network": "noNetwork",
"keep-package": "keepPackage",
"no-sri": "noSri",
"no-telemetry": "noTelemetry",
@@ -257,7 +252,7 @@ function manualHelp(project) {
out(` ${c.cyan(`import "${AUTO()}";`)}`);
out(` or, without a bundler, one tag first in `);
out(` ${c.cyan(scriptTag({ devtools: true }, PKG_DIR, VERSION).replace(/ integrity="[^"]*" crossorigin="anonymous"/, ""))}`);
- if (project?.pkg) out(` and install it: ${c.cyan(installArgs(project.pm ?? "npm", PKG).flat().join(" "))}`);
+ if (project?.pkg) out(` and install it: ${c.cyan(installArgs(project.pm ?? "npm", installSpec({})).flat().join(" "))}`);
out();
}
@@ -722,6 +717,29 @@ export function hostNotice() {
if (h.overridden && !h.error) row("Host", `${h.host} ${c.gray("(GENCLASS_HOST is set: for tests only, instead of https://genclass.dev)")}`, c.yellow(sym.warn));
}
+/**
+ * The version line: this CLI's version and the npm dist-tag that points at it, and a warning when a dist-tag points at
+ * a newer release (npm asked once, at most 3 s; not with --dry-run, --no-network, --no-token or --no-telemetry, nothing
+ * when offline).
+ */
+export async function versionNotice(o) {
+ const asked = !o.dryRun && !o.noNetwork && !o.noToken && !o.noTelemetry;
+ const st = versionStatus(VERSION, asked ? await distTags() : null);
+ const from = st.tag ? c.gray(` (npm dist-tag ${st.tag})`) : asked ? "" : c.gray(" (npm dist-tags not checked)");
+ row("Version", `${PKG} ${VERSION}${from}`);
+ if (st.newer) {
+ const run = st.newer.tag === "latest" ? `npx ${PKG}` : `npx ${PKG}@${st.newer.tag}`;
+ row("Newer", `${PKG} ${st.newer.version} is on npm (dist-tag ${st.newer.tag}): run ${c.cyan(`${run} init`)} instead`, c.yellow(sym.warn));
+ }
+}
+
+/** The version of ${PKG} the project has (installed, else the one its package.json names) when it is older than this CLI's. */
+function olderRuntime(project, cwd) {
+ if (!project.pkg || !project.has(PKG)) return null;
+ const have = installedVersion(cwd) ?? versionInSpec(project.deps[PKG]);
+ return have && compareVersions(have, VERSION) === -1 ? have : null;
+}
+
/** What init finds in the project directory (no writes, no network). */
export function inspectProject(o) {
const cwd = projectDir(o);
@@ -760,6 +778,7 @@ export async function init(o, ctx = {}) {
const cwd = projectDir(o);
if (ctx.banner !== false) banner("init");
if (ctx.banner !== false) hostNotice();
+ if (ctx.banner !== false) await versionNotice(o);
const project = detectProject(cwd);
let cfg = withLocalDevice(configOf(o), project);
if (project.refused) {
@@ -795,8 +814,12 @@ export async function init(o, ctx = {}) {
}
if (tk.write) cfg = { token: tk.token, ...cfg };
else if (tk.pending) cfg = { token: PENDING_TOKEN, ...cfg };
- const needsPackage = !!project.pkg && project.framework !== "html" && !project.has(PKG);
+ // an older @genclass/runtime in the project (0.2.x from `npx @genclass/runtime init` without @beta) is upgraded:
+ // without that, this init would write options and imports the installed runtime does not have
+ const older = project.framework !== "html" ? olderRuntime(project, cwd) : null;
+ const needsPackage = !!project.pkg && project.framework !== "html" && (!project.has(PKG) || !!older);
const install = needsPackage && !o.noInstall;
+ if (older) row("Package", `${PKG} ${older} is older than this init (${VERSION})${install ? "; init upgrades it" : ": upgrade it (--no-install)"}`, c.yellow(sym.warn));
const spec = installSpec(o);
if (marked.length || manualRefs(files).length) {
@@ -836,16 +859,17 @@ export async function init(o, ctx = {}) {
return r.code;
}
if (!install) {
+ if (older) out(` ${c.yellow(sym.warn)} --no-install: upgrade it yourself: ${c.cyan(installArgs(project.pm, spec).flat().join(" "))}`);
out(` ${c.green("Nothing to do.")}${marked.length ? ` To take it out: ${c.cyan(`${CMD} remove`)}` : ""}`);
out();
dashboardNotice(tk, cwd, false, true);
return 0;
}
out();
- out(` ${PKG} is not in package.json yet. Will run: ${c.cyan(installArgs(project.pm, spec).flat().join(" "))}`);
+ out(` ${older ? `${PKG} ${older} is older than this init.` : `${PKG} is not in package.json yet.`} Will run: ${c.cyan(installArgs(project.pm, spec).flat().join(" "))}`);
if (o.dryRun) return 0;
if (!o.yes) {
- const ok = await confirm("Install it?");
+ const ok = await confirm(older ? "Upgrade it?" : "Install it?");
if (ok === null) {
out(` Not a terminal: re-run with ${c.bold("--yes")}.`);
return 1;
@@ -878,7 +902,7 @@ export async function init(o, ctx = {}) {
out();
for (const ch of plan.changes) printChange({ ...ch, file: ch.rel });
if (install) out(` ${c.bold("Install")} ${c.cyan(installArgs(project.pm, spec).flat().join(" "))}`);
- else if (needsPackage) out(` ${c.yellow(sym.warn)} --no-install: add ${PKG} to your dependencies yourself.`);
+ else if (needsPackage) out(` ${c.yellow(sym.warn)} --no-install: ${older ? `upgrade ${PKG} yourself (${installArgs(project.pm, spec).flat().join(" ")})` : `add ${PKG} to your dependencies yourself`}.`);
for (const n of plan.notes ?? []) out(` ${c.yellow(sym.warn)} ${n}`);
out();
diff --git a/packages/runtime/bin/lib/login.mjs b/packages/runtime/bin/lib/login.mjs
index c716818b..028780b4 100644
--- a/packages/runtime/bin/lib/login.mjs
+++ b/packages/runtime/bin/lib/login.mjs
@@ -28,12 +28,11 @@ import {
startDeviceLogin,
} from "./account.mjs";
import { apiHost, hostConfig, hostUrl } from "./host.mjs";
-import { FREE_LINES, UsageError, accountLabel, hostNotice, init, inspectProject, parse } from "./init.mjs";
+import { FREE_LINES, UsageError, accountLabel, hostNotice, init, inspectProject, parse, versionNotice } from "./init.mjs";
import { AUTO } from "./plan.mjs";
import { LOCAL_FILE, tokenInFiles } from "./token.mjs";
import { banner, c, choose, confirm, err, interactive, out, row, sym } from "./ui.mjs";
-
-const CMD = "npx @genclass/runtime";
+import { CMD } from "./version.mjs";
export const USAGE = `Usage:
genclass-runtime login [--yes] [--no-browser] [--no-connect] [--app ] [--name ]
@@ -335,6 +334,7 @@ async function connectApp(o, creds, st, me) {
async function connect(o) {
banner("connect");
hostNotice();
+ await versionNotice(o);
const { creds, otherHost } = readCredentialsFor();
if (!creds) {
out(` Not signed in${otherHost ? ` at ${apiHost()} (the saved session is for ${otherHost})` : ""}. Sign in first: ${c.cyan(`${CMD} login`)} (it offers to connect this app too).`);
@@ -380,6 +380,7 @@ async function afterLogin(o, creds, me) {
async function login(o) {
banner("login");
hostNotice();
+ await versionNotice(o);
const host = apiHost();
const saved = readCredentialsFor(host);
if (saved.creds) {
diff --git a/packages/runtime/bin/lib/version.mjs b/packages/runtime/bin/lib/version.mjs
new file mode 100644
index 00000000..a5e178b3
--- /dev/null
+++ b/packages/runtime/bin/lib/version.mjs
@@ -0,0 +1,147 @@
+// This CLI's own version, the command that runs it again, and what npm's dist-tags say about newer releases
+// (owner: INSTALL). While the version is a prerelease, every command the CLI prints names its dist-tag
+// (`npx @genclass/runtime@beta ...`): a bare `npx @genclass/runtime` runs npm's `latest`, an older line without the
+// token, the dashboard, analytics or this CLI's commands. `init` prints the version line and, once per run, asks npm
+// for the dist-tags (`npm view @genclass/runtime dist-tags --json`, at most 3 s) to say when a newer release exists;
+// never with --dry-run, --no-network, --no-token or --no-telemetry, never when npm is offline.
+// GENCLASS_TEST_DIST_TAGS (tests only) replaces the npm call: a JSON object of tags, or "fail".
+
+import { spawn } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export const PKG = "@genclass/runtime";
+export const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
+
+/** This package's version ("latest" if package.json cannot be read). */
+export const VERSION = (() => {
+ try {
+ return JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8")).version;
+ } catch {
+ return "latest";
+ }
+})();
+
+const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
+
+/** The prerelease's first identifier ("beta" for 0.3.0-beta.1), or null for a release or something unreadable. */
+export function channelOf(version) {
+ const m = SEMVER.exec(String(version ?? ""));
+ const id = m?.[4]?.split(".")[0];
+ return id && /^[a-z][a-z0-9-]*$/i.test(id) ? id : null;
+}
+
+/** `npx @genclass/runtime@` while this is a prerelease, else `npx @genclass/runtime`. */
+export const cmdFor = (version) => {
+ const ch = channelOf(version);
+ return ch ? `npx ${PKG}@${ch}` : `npx ${PKG}`;
+};
+export const CMD = cmdFor(VERSION);
+
+/** Semver order (prereleases before their release); null when either side is not a version. */
+export function compareVersions(a, b) {
+ const x = SEMVER.exec(String(a ?? ""));
+ const y = SEMVER.exec(String(b ?? ""));
+ if (!x || !y) return null;
+ for (let i = 1; i <= 3; i++) if (Number(x[i]) !== Number(y[i])) return Number(x[i]) < Number(y[i]) ? -1 : 1;
+ if (!x[4] || !y[4]) return x[4] ? -1 : y[4] ? 1 : 0;
+ const p = x[4].split(".");
+ const q = y[4].split(".");
+ for (let i = 0; i < Math.max(p.length, q.length); i++) {
+ if (p[i] === undefined) return -1;
+ if (q[i] === undefined) return 1;
+ if (p[i] === q[i]) continue;
+ const pn = /^\d+$/.test(p[i]);
+ const qn = /^\d+$/.test(q[i]);
+ if (pn && qn) return Number(p[i]) < Number(q[i]) ? -1 : 1;
+ if (pn !== qn) return pn ? -1 : 1;
+ return p[i] < q[i] ? -1 : 1;
+ }
+ return 0;
+}
+
+/** The lowest version a dependency spec names ("^0.2.0" -> "0.2.0"), or null (a tag, "*", a path, a URL). */
+export function versionInSpec(spec) {
+ const m = /^\s*(?:[\^~]|>=?|=)?\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(String(spec ?? ""));
+ return m ? m[1] : null;
+}
+
+/** The @genclass/runtime version installed for the project in `dir` (its node_modules or a parent's), or null. */
+export function installedVersion(dir) {
+ for (let d = resolve(dir); ; d = dirname(d)) {
+ try {
+ const v = JSON.parse(readFileSync(join(d, "node_modules", "@genclass", "runtime", "package.json"), "utf8")).version;
+ if (typeof v === "string") return v;
+ } catch {
+ /* not here */
+ }
+ if (dirname(d) === d) return null;
+ }
+}
+
+/**
+ * npm's dist-tags for the package ({ latest, beta, ... }), or null: offline, npm missing, slower than `timeoutMs`,
+ * or an answer that is not an object of versions.
+ */
+export function distTags({ timeoutMs = 3000, env = process.env } = {}) {
+ const fake = env.GENCLASS_TEST_DIST_TAGS;
+ if (fake !== undefined) return Promise.resolve(fake === "fail" ? null : tagsOf(fake));
+ if (/^(1|true)$/i.test(String(env.npm_config_offline ?? ""))) return Promise.resolve(null);
+ return new Promise((done) => {
+ let text = "";
+ let settled = false;
+ const finish = (v) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ done(v);
+ };
+ let child;
+ try {
+ child = spawn("npm", ["view", PKG, "dist-tags", "--json"], { stdio: ["ignore", "pipe", "ignore"], shell: process.platform === "win32", env });
+ } catch {
+ finish(null);
+ return;
+ }
+ const timer = setTimeout(() => {
+ try {
+ child.kill();
+ } catch {
+ /* gone */
+ }
+ finish(null);
+ }, timeoutMs);
+ child.stdout?.on("data", (b) => (text += b));
+ child.on("error", () => finish(null));
+ child.on("close", (code) => finish(code === 0 ? tagsOf(text) : null));
+ });
+}
+
+function tagsOf(text) {
+ try {
+ const o = JSON.parse(text);
+ if (!o || typeof o !== "object" || Array.isArray(o)) return null;
+ const tags = {};
+ for (const [k, v] of Object.entries(o)) if (typeof v === "string" && SEMVER.test(v)) tags[k] = v;
+ return Object.keys(tags).length ? tags : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * What to say about `version` given npm's `tags` (null: not checked): { tag } the dist-tag that points at it (null:
+ * none), and { newer: { version, tag } } the highest release on any dist-tag above it (null: none).
+ */
+export function versionStatus(version, tags) {
+ if (!tags) return { tag: null, newer: null };
+ const entries = Object.entries(tags);
+ const tag = entries.find(([k, v]) => v === version && k === "latest")?.[0] ?? entries.find(([, v]) => v === version)?.[0] ?? null;
+ let newer = null;
+ for (const [k, v] of entries) {
+ if (compareVersions(v, version) !== 1) continue;
+ if (!newer || compareVersions(v, newer.version) === 1) newer = { version: v, tag: k };
+ }
+ return { tag, newer };
+}
diff --git a/packages/runtime/src/analytics/index.ts b/packages/runtime/src/analytics/index.ts
index 4ad76311..92bf4da3 100644
--- a/packages/runtime/src/analytics/index.ts
+++ b/packages/runtime/src/analytics/index.ts
@@ -21,6 +21,10 @@ import type { RuntimeImpl } from "../runtime.js";
import type { RtEvent } from "../types.js";
import type { TelemetryClient } from "../telemetry/client.js";
+// the version as a build-time constant (tsup.config.ts), not src/version.ts: this chunk has no static imports
+declare const __GENCLASS_PKG_VERSION__: string;
+const VERSION: string = typeof __GENCLASS_PKG_VERSION__ === "string" ? __GENCLASS_PKG_VERSION__ : "0.0.0";
+
/** Engaged time counts at most this long after the visitor's last input. */
export const IDLE_MS = 30_000;
/** Scroll depth milestones, in % of the page height reached by the bottom of the viewport. */
@@ -198,16 +202,42 @@ export interface AnalyticsController {
}
export interface AnalyticsChunk {
- startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: Event) => boolean, data: RegExp): AnalyticsController;
+ startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: Event) => boolean, data: RegExp, version?: string): AnalyticsController;
}
let noticeShown = false;
+let mismatchShown = false;
export const ANALYTICS_NOTICE = "[GenClass] Interaction analytics are on (this app turned them on): page views, time on page, scroll depth and clicks per element, never page text.";
-/** Start recording (the stub checked telemetry and the token). `ignored`: dom-user's ignoredEvent; `data`: DATA_LIKE. */
-export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: Event) => boolean, data: RegExp): AnalyticsController {
+/** Interaction analytics first shipped in this runtime; the stub (src/analytics/start.ts) passes its own version. */
+export const ANALYTICS_SINCE = "0.3.0-beta.1";
+
+/**
+ * The warning when this chunk and the runtime that loaded it are not the same release (a self-hosted dist/ with files
+ * of two versions, a stale bundler chunk), or null. `version`: what the stub passed; none means a runtime from before
+ * the stub passed it.
+ */
+export function versionMismatch(version: string | undefined): string | null {
+ if (version === VERSION) return null;
+ return `[GenClass] analytics: this analytics code is @genclass/runtime ${VERSION} but the runtime that loaded it is ${version ?? `older than ${ANALYTICS_SINCE}`}. Serve every file from one version's dist/ (interaction analytics need ${ANALYTICS_SINCE} or later: npm install @genclass/runtime@beta).`;
+}
+
+/**
+ * Start recording (the stub checked telemetry and the token). `ignored`: dom-user's ignoredEvent; `data`: DATA_LIKE;
+ * `version`: the stub's runtime version (one warning per page when it is not this chunk's; recording still starts).
+ */
+export function startAnalytics(rt: RuntimeImpl, t: TelemetryClient, ignored: (e: Event) => boolean, data: RegExp, version?: string): AnalyticsController {
const g = rt.global as G;
const doc = g.document;
+ const mismatch = versionMismatch(version);
+ if (mismatch && !mismatchShown) {
+ mismatchShown = true;
+ try {
+ ((g.console as Console | undefined) ?? (globalThis as { console?: Console }).console)?.warn?.(mismatch);
+ } catch {
+ /* ignore */
+ }
+ }
const clock = rt.clock;
if (t.stopped || !doc || typeof g.addEventListener !== "function") return { stop() {} };
const k: KeyContext = { route: (p) => t.route(p), loc: g.location, url: g.URL ?? (globalThis as { URL?: typeof URL }).URL, data };
diff --git a/packages/runtime/src/analytics/start.ts b/packages/runtime/src/analytics/start.ts
index feb20d0e..4b8113c1 100644
--- a/packages/runtime/src/analytics/start.ts
+++ b/packages/runtime/src/analytics/start.ts
@@ -1,7 +1,8 @@
// Interaction analytics: the first-load stub (docs/runtime/ANALYTICS.md section 2). It answers runtime.analytics and,
// once the app turned analytics on (InitOptions.analytics or runtime.analytics.start()) while telemetry runs with the
// app's token or a custom endpoint, imports the chunk (src/analytics/index.ts) that records. Off means nothing: no
-// listener, no import. Everything else lives in the chunk, so this file stays small (test/bundle.test.ts).
+// listener, no import. Everything else lives in the chunk, so this file stays small (test/bundle.test.ts). A chunk that
+// does not load warns once (nothing is recorded); the chunk itself warns when its version is not the runtime's.
import type { RuntimeImpl } from "../runtime.js";
import type { AnalyticsStatus } from "../types.js";
@@ -9,6 +10,7 @@ import type { AnalyticsChunk, AnalyticsController } from "./index.js";
import { ignoredEvent } from "../observe/dom-user.js";
import { DATA_LIKE, type TelemetryClient } from "../telemetry/client.js";
import { DEFAULT_TELEMETRY_ENDPOINT } from "../telemetry/config.js";
+import { RUNTIME_VERSION } from "../version.js";
/**
* Where the chunk comes from. The ESM build imports ./index.js (an app's bundler splits it into its own chunk); the
@@ -21,6 +23,8 @@ export function startAnalytics(rt: RuntimeImpl, on: unknown): AnalyticsStatus {
let c: AnalyticsController | null | undefined;
// bumped by stop() and destroy: a chunk that finishes loading after them does not start
let gen = 0;
+ let failed = false;
+ const warn = (m: string) => (rt.global.console as Console | undefined)?.warn?.("[GenClass] analytics" + m);
const s = {
enabled: false,
reason: "option" as string | undefined,
@@ -32,10 +36,12 @@ export function startAnalytics(rt: RuntimeImpl, on: unknown): AnalyticsStatus {
s.enabled = true;
const n = ++gen;
(analyticsRegistry.loader ?? (() => import("./index.js") as Promise))().then(
- (m) => void (n == gen && (c = m.startAnalytics(rt, t as TelemetryClient, ignoredEvent, DATA_LIKE))),
+ (m) => void (n == gen && (c = m.startAnalytics(rt, t as TelemetryClient, ignoredEvent, DATA_LIKE, RUNTIME_VERSION))),
() => {
s.enabled = false;
s.reason = "error";
+ if (!failed) warn(` code of ${RUNTIME_VERSION} did not load; nothing is recorded.`);
+ failed = true;
},
);
}
@@ -54,6 +60,6 @@ export function startAnalytics(rt: RuntimeImpl, on: unknown): AnalyticsStatus {
};
rt.addTeardown(() => gen++);
if (on === true && !s.start() && s.reason == "no-token")
- (rt.global.console as Console | undefined)?.warn?.("[GenClass] analytics: true needs the app's token; nothing is recorded.");
+ warn(": true needs the app's token; nothing is recorded.");
return s;
}
diff --git a/packages/runtime/src/cdn/global.ts b/packages/runtime/src/cdn/global.ts
index f927ed09..b7d25972 100644
--- a/packages/runtime/src/cdn/global.ts
+++ b/packages/runtime/src/cdn/global.ts
@@ -1,6 +1,6 @@
// Script-tag build (dist/genclass.global.js, dist/genclass.global.min.js). Owner: INSTALL.
//
-//
+//
//
// Runs GenClass.init() as soon as the tag executes (put it first in so it sees the page's first requests),
// with options from the tag's data attributes, and window.GENCLASS_CONFIG (src/cdn/config.ts),
diff --git a/packages/runtime/src/cdn/sinks.ts b/packages/runtime/src/cdn/sinks.ts
index 78515186..ac682868 100644
--- a/packages/runtime/src/cdn/sinks.ts
+++ b/packages/runtime/src/cdn/sinks.ts
@@ -4,7 +4,7 @@
// (+ joins names: commas separate meta keys)
//
// window.GENCLASS_CONFIG = { sinks: ["webhook:/genclass"] }
-// npx @genclass/runtime init --sink webhook:/genclass
+// npx @genclass/runtime@beta init --sink webhook:/genclass
//
// Names: console-json (optionally `:noevidence`), webhook:. The names sentry, posthog and datadog are not part of
// the free runtime (no plan offers forwarding to those tools yet): `applySinks` leaves them out with one console
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index 0ef6233d..6d079ca0 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -314,11 +314,11 @@ function warnIgnored(rt: Runtime, options: InitOptions): void {
const parts = [
`[GenClass] Already running in ${rt.mode} mode (started by @genclass/runtime/auto, the script tag or an earlier GenClass.init()), so this call's options are ignored.`,
];
- if (mode) parts.push(`To change the mode: GenClass.runtime.setMode("${options.mode}"), or re-run \`npx @genclass/runtime init --mode ${options.mode}\`.`);
+ if (mode) parts.push(`To change the mode: GenClass.runtime.setMode("${options.mode}"), or re-run \`npx @genclass/runtime@beta init --mode ${options.mode}\`.`);
if (token || scope) {
const what = [token ? "token" : null, scope ? `scope "${options.scope}"` : null].filter(Boolean).join(" and ");
parts.push(
- `The ${what} must be set where GenClass starts: GENCLASS_CONFIG = { ${token ? 'token: "gc_..."' : ""}${token && scope ? ", " : ""}${scope ? `scope: "${options.scope}"` : ""} } on window before the auto import (\`npx @genclass/runtime init\` writes it to genclass.config.ts/js), in , or ${[token ? "data-token" : null, scope ? "data-scope" : null].filter(Boolean).join(" / ")} on the script tag.`,
+ `The ${what} must be set where GenClass starts: GENCLASS_CONFIG = { ${token ? 'token: "gc_..."' : ""}${token && scope ? ", " : ""}${scope ? `scope: "${options.scope}"` : ""} } on window before the auto import (\`npx @genclass/runtime@beta init\` writes it to genclass.config.ts/js), in , or ${[token ? "data-token" : null, scope ? "data-scope" : null].filter(Boolean).join(" / ")} on the script tag.`,
);
}
(g.console as Console | undefined)?.warn?.(parts.join(" "));
diff --git a/packages/runtime/src/model/blocked.ts b/packages/runtime/src/model/blocked.ts
index e7d11f2d..01500331 100644
--- a/packages/runtime/src/model/blocked.ts
+++ b/packages/runtime/src/model/blocked.ts
@@ -73,7 +73,7 @@ export function blockedMessage(b: BlockedFetch): string {
: "could not be fetched (most likely a Content-Security-Policy connect-src without it; an extension or the network can also block it)";
return (
`The model could not load: ${b.origin} ${why}. GenClass keeps running without a model (observing only). ` +
- `Fix: self-host the model and ONNX Runtime files (npx @genclass/runtime fetch-model public/genclass-model, then ` +
+ `Fix: self-host the model and ONNX Runtime files (npx @genclass/runtime@beta fetch-model public/genclass-model, then ` +
`GenClass.init({ model: { baseUrl: "/genclass-model/", ortWasmPaths: "/genclass-model/ort/" } })), or allow ${b.origin} in the CSP's connect-src. ` +
`Blocked: ${b.url}`
);
diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts
index 1ccf2cf0..b340d205 100644
--- a/packages/runtime/src/runtime.ts
+++ b/packages/runtime/src/runtime.ts
@@ -3312,7 +3312,7 @@ export class RuntimeImpl implements Runtime {
this.warnOnce(
"worker-unavailable",
`The model worker could not start (${s.workerError ?? "unknown"}) and model.inlineFallback is false, so the model is not loaded; GenClass keeps observing without it. ` +
- `If a Content-Security-Policy blocks the worker, allow worker-src 'self' (blob: for the script tag) or self-host (npx @genclass/runtime fetch-model).`,
+ `If a Content-Security-Policy blocks the worker, allow worker-src 'self' (blob: for the script tag) or self-host (npx @genclass/runtime@beta fetch-model).`,
);
}
diff --git a/packages/runtime/src/token.ts b/packages/runtime/src/token.ts
index 0a505199..297c23d1 100644
--- a/packages/runtime/src/token.ts
+++ b/packages/runtime/src/token.ts
@@ -18,7 +18,7 @@ export function resolveToken(v: unknown, warn: (msg: string) => void): string |
if (isValidToken(t)) return t;
try {
const shown = typeof v === "string" ? JSON.stringify(v.length > 40 ? `${v.slice(0, 40)}…` : v) : typeof v;
- warn(`Ignoring token ${shown}: a GenClass token is "gc_" followed by 22 letters or digits. Get one with \`npx @genclass/runtime init\` or at https://genclass.dev/start.`);
+ warn(`Ignoring token ${shown}: a GenClass token is "gc_" followed by 22 letters or digits. Get one with \`npx @genclass/runtime@beta init\` or at https://genclass.dev/start.`);
} catch {
/* ignore */
}
diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts
index 25fe25d1..9566efc6 100644
--- a/packages/runtime/src/types.ts
+++ b/packages/runtime/src/types.ts
@@ -815,7 +815,7 @@ export interface InitOptions {
* record on the console) and `@genclass/runtime/sinks/webhook` (batched POSTs to a URL you configure). The zero-code
* entries also take them by name (`console-json:noevidence` sends no evidence text): `window.GENCLASS_CONFIG = {
* sinks: ["webhook:/genclass"] }`, ``, `data-sink="console-json"`
- * on the script tag, or `npx @genclass/runtime init --sink webhook:/genclass`. Forwarding to Sentry, PostHog or
+ * on the script tag, or `npx @genclass/runtime@beta init --sink webhook:/genclass`. Forwarding to Sentry, PostHog or
* Datadog is not part of the runtime (on every genclass.dev plan, Free included, the GenClass collector forwards to
* them once turned on: https://genclass.dev/pricing); a page config that names `sentry`, `posthog` or `datadog` gets one console warning
* that says so and nothing else.
@@ -906,6 +906,8 @@ export interface InitOptions {
* opt-out and Global Privacy Control turn it off too. Nothing is stored on the device. Where analytics need consent
* (the EU, EEA and UK), leave it off and call `runtime.analytics.start()` once the visitor agrees. Also
* `data-analytics="on"`, ``, `window.GENCLASS_CONFIG.analytics`.
+ * Since 0.3.0-beta.1 (`npm install @genclass/runtime@beta`; 0.2.x, npm's `latest` until 0.3.0, ignores it). When the
+ * analytics code does not load, one console warning and `runtime.analytics.reason` "error".
*/
analytics?: boolean;
/**
@@ -933,7 +935,7 @@ export interface InitOptions {
*/
visual?: boolean | "local" | VisualOptions;
/**
- * Your app's GenClass token (`gc_` + 22 letters/digits; get one with `npx @genclass/runtime init` or at
+ * Your app's GenClass token (`gc_` + 22 letters/digits; get one with `npx @genclass/runtime@beta init` or at
* https://genclass.dev/start). Public: it only lets this app's telemetry batches be grouped under your private
* dashboard link. It does nothing while telemetry is off (opt-outs and Global Privacy Control still win). An invalid
* value logs one console warning and is ignored. Also `data-token`, ``,
diff --git a/packages/runtime/test/analytics.test.ts b/packages/runtime/test/analytics.test.ts
index db3c64b7..3b6834fb 100644
--- a/packages/runtime/test/analytics.test.ts
+++ b/packages/runtime/test/analytics.test.ts
@@ -8,7 +8,9 @@ import { createRuntime } from "../src/index.js";
import type { RuntimeImpl } from "../src/runtime.js";
import { DATA_LIKE, type TelemetryClient } from "../src/telemetry/client.js";
import { fromPairs, readMetaConfig } from "../src/cdn/config.js";
-import { ANALYTICS_NOTICE, IDLE_MS, KEY_MAX, keyOf, type KeyContext } from "../src/analytics/index.js";
+import { ANALYTICS_NOTICE, ANALYTICS_SINCE, IDLE_MS, KEY_MAX, keyOf, versionMismatch, type KeyContext } from "../src/analytics/index.js";
+import { analyticsRegistry } from "../src/analytics/start.js";
+import { RUNTIME_VERSION } from "../src/version.js";
import { normalizePath } from "../src/util.js";
import type { TelemetryTransport } from "../src/types.js";
import { FakeClock, ScriptedDecider } from "./helpers.js";
@@ -59,7 +61,7 @@ let rt: RuntimeImpl | null = null;
const w = window as unknown as { crypto?: { getRandomValues?: unknown } };
if (typeof w.crypto?.getRandomValues !== "function") Object.defineProperty(window, "crypto", { value: globalThis.crypto, configurable: true });
-async function arun(o: { analytics?: boolean; token?: string | null; telemetry?: Record | false; nav?: boolean; user?: boolean } = {}) {
+async function arun(o: { analytics?: boolean; token?: string | null; telemetry?: Record | false; nav?: boolean; user?: boolean; noWait?: boolean } = {}) {
const clock = new FakeClock();
const transport = recorder();
rt = createRuntime({
@@ -73,7 +75,7 @@ async function arun(o: { analytics?: boolean; token?: string | null; telemetry?:
...(o.analytics === undefined ? { analytics: true } : { analytics: o.analytics }),
}) as RuntimeImpl;
const client = rt.telemetry as unknown as TelemetryClient;
- if (rt.analytics?.enabled) await vi.waitFor(() => expect(client.pre).toBeTypeOf("function"));
+ if (rt.analytics?.enabled && !o.noWait) await vi.waitFor(() => expect(client.pre).toBeTypeOf("function"));
return { clock, transport, rt, client };
}
@@ -164,6 +166,52 @@ describe("interaction analytics: off by default, telemetry and token required, e
});
});
+describe("interaction analytics: loud when the code is missing or from another version (ANALYTICS.md section 4.1)", () => {
+ afterEach(() => {
+ delete analyticsRegistry.loader;
+ });
+
+ it("a chunk that does not load: one warning naming the version, enabled false, reason error", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ analyticsRegistry.loader = () => Promise.reject(new Error("404"));
+ const { rt: r } = await arun({ noWait: true });
+ await vi.waitFor(() => expect(r.analytics).toMatchObject({ enabled: false, reason: "error" }));
+ expect(r.analytics!.start()).toBe(true);
+ await vi.waitFor(() => expect(r.analytics).toMatchObject({ reason: "error" }));
+ const said = warn.mock.calls.flat().filter((m) => String(m).includes("did not load"));
+ expect(said).toEqual([`[GenClass] analytics code of ${RUNTIME_VERSION} did not load; nothing is recorded.`]);
+ });
+
+ it("the stub passes its version to the chunk; the same release says nothing, another one warns", async () => {
+ const seen: unknown[] = [];
+ analyticsRegistry.loader = () =>
+ import("../src/analytics/index.js").then((m) => ({
+ startAnalytics: (...a: Parameters) => (seen.push(a[4]), m.startAnalytics(...a)),
+ }));
+ await arun();
+ expect(seen).toEqual([RUNTIME_VERSION]);
+ expect(versionMismatch(RUNTIME_VERSION)).toBeNull();
+ expect(versionMismatch("0.3.0-beta.9")).toContain(`is @genclass/runtime ${RUNTIME_VERSION} but the runtime that loaded it is 0.3.0-beta.9`);
+ const older = versionMismatch(undefined)!;
+ expect(older).toContain(`older than ${ANALYTICS_SINCE}`);
+ expect(older).toContain("npm install @genclass/runtime@beta");
+ expect(ANALYTICS_SINCE).toBe("0.3.0-beta.1");
+ });
+
+ it("a chunk from another release warns once per page and still records", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ analyticsRegistry.loader = () =>
+ import("../src/analytics/index.js").then((m) => ({
+ startAnalytics: (rt: Parameters[0], t: TelemetryClient, i: (e: Event) => boolean, d: RegExp) => m.startAnalytics(rt, t, i, d, "0.3.0-beta.9"),
+ }));
+ const a = await arun();
+ expect(a.rt.analytics).toMatchObject({ enabled: true });
+ a.rt.destroy();
+ await arun();
+ expect(warn.mock.calls.flat().filter((m) => String(m).includes("but the runtime that loaded it is 0.3.0-beta.9")).length).toBe(1);
+ });
+});
+
describe("interaction analytics: what a page view records (ANALYTICS.md section 1)", () => {
it("a page view: start, engaged time, scroll milestones, clicks per element key, sent with the token; no text", async () => {
document.body.innerHTML = `
Picture
`;
diff --git a/packages/runtime/test/install/INSTALL-README-SNIPPET.md b/packages/runtime/test/install/INSTALL-README-SNIPPET.md
index a0c2f3c1..f96f1808 100644
--- a/packages/runtime/test/install/INSTALL-README-SNIPPET.md
+++ b/packages/runtime/test/install/INSTALL-README-SNIPPET.md
@@ -5,7 +5,7 @@ Pick one. All three start the same runtime.
**1. One command** (Vite, Next.js, Create React App, Remix / React Router, Nuxt, SvelteKit, Astro, Angular, plain HTML)
```bash
-npx @genclass/runtime init
+npx @genclass/runtime@beta init
```
It finds your framework and package manager, installs `@genclass/runtime`, and adds one import as the first line of
@@ -14,10 +14,10 @@ writing anything. Running it again changes nothing; running it with another `--m
that mode. It sets up observe mode (reports only, never changes anything) unless you pass `--mode guard` or `--mode heal`.
```bash
-npx @genclass/runtime init --yes # no questions
-npx @genclass/runtime init --mode guard # let GenClass act (also: --mode heal); again later to switch
-npx @genclass/runtime init --dry-run # show the diff, write nothing
-npx @genclass/runtime remove # undo exactly what init added (refuses if you edited those lines)
+npx @genclass/runtime@beta init --yes # no questions
+npx @genclass/runtime@beta init --mode guard # let GenClass act (also: --mode heal); again later to switch
+npx @genclass/runtime@beta init --dry-run # show the diff, write nothing
+npx @genclass/runtime@beta remove # undo exactly what init added (refuses if you edited those lines)
```
Other flags: `--no-install` (don't run the package manager), `--no-devtools`, `--cwd `, `--cdn ` and
@@ -76,7 +76,7 @@ like:
**3. One script tag** (no build step)
```html
-
+
```
Put it first in `` so it sees the page's first requests. It sets `window.GenClass` (`GenClass.runtime`,
@@ -87,7 +87,7 @@ demand from the same CDN, at the same version as the tag, also when the URL has
|---|---|
| `data-mode` | `observe` (default), `guard` or `heal` |
| `data-devtools` | show the overlay; `="local"`: only on localhost, `*.localhost`, `*.local`, `*.test` |
-| `data-model` | your model directory (`npx @genclass/runtime fetch-model public/genclass-model`), or `off` |
+| `data-model` | your model directory (`npx @genclass/runtime@beta fetch-model public/genclass-model`), or `off` |
| `data-ort` | onnxruntime-web's `dist/` directory, if you self-host its wasm |
| `data-manual` | only define `window.GenClass`; call `GenClass.init({...})` yourself |
| `data-base` | where the package's `dist/` is, if you self-host this file |
diff --git a/packages/runtime/test/install/account.test.ts b/packages/runtime/test/install/account.test.ts
index 78b1f429..ed886b50 100644
--- a/packages/runtime/test/install/account.test.ts
+++ b/packages/runtime/test/install/account.test.ts
@@ -14,6 +14,7 @@ import { afterAll, describe, expect, it } from "vitest";
import { credentialsPath } from "../../bin/lib/account.mjs";
// @ts-expect-error plain ESM module without types
import { hostConfig } from "../../bin/lib/host.mjs";
+import { CMD } from "../../bin/lib/version.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const BIN = resolve(HERE, "..", "..", "bin", "genclass-runtime.mjs");
@@ -235,7 +236,7 @@ describe("login: the device flow (RFC 8628 shapes, fake genclass.dev)", () => {
it("is outside a project: no connect question, a hint instead", () => {
const r = run(["login", "--cwd", tmp("gc-empty-")], { env: { GENCLASS_TEST_DEVICE: "ok" } });
expect(r.code, r.out).toBe(0);
- expect(r.out).toContain("npx @genclass/runtime connect");
+ expect(r.out).toContain(`${CMD} connect`);
expect(routes(r.calls)).toEqual(["POST /api/device/code", "POST /api/device/token"]);
});
});
@@ -246,7 +247,7 @@ describe("connect: the app in this folder and an app of the account", () => {
const r = run(["connect", "--cwd", dir]);
expect(r.code).toBe(1);
expect(r.out).toContain("Not signed in");
- expect(r.out).toContain("npx @genclass/runtime login");
+ expect(r.out).toContain(`${CMD} login`);
expect(r.calls).toEqual([]);
});
@@ -390,12 +391,12 @@ describe("connect: the app in this folder and an app of the account", () => {
const unknown = run(["connect", "--yes", "--no-install", "--cwd", dir], { config, env: { GENCLASS_TEST_CLAIM: "404" } });
expect(unknown.code).toBe(1);
expect(unknown.out).toContain("does not know the dashboard link in .genclass.local");
- expect(unknown.out).toContain("npx @genclass/runtime connect --name shop-web");
+ expect(unknown.out).toContain(`${CMD} connect --name shop-web`);
expect(unknown.out).not.toContain("Add an existing dashboard");
const taken = run(["connect", "--yes", "--no-install", "--cwd", dir], { config, env: { GENCLASS_TEST_CLAIM: "409" } });
expect(taken.code).toBe(1);
expect(taken.out).toContain("That dashboard belongs to another GenClass account");
- expect(taken.out).toContain("npx @genclass/runtime connect --name shop-web");
+ expect(taken.out).toContain(`${CMD} connect --name shop-web`);
expect(taken.out).not.toContain("Add an existing dashboard");
const down = run(["connect", "--yes", "--no-install", "--cwd", dir], { config, env: { GENCLASS_TEST_CLAIM: "500" } });
expect(down.code).toBe(1);
@@ -431,7 +432,7 @@ describe("connect: the app in this folder and an app of the account", () => {
const r = run(["connect", "--yes", "--no-install", "--cwd", viteApp()], { config: signedIn(), env: { GENCLASS_TEST_ME: "401" } });
expect(r.code).toBe(1);
expect(r.out).toContain("no longer works");
- expect(r.out).toContain("npx @genclass/runtime login");
+ expect(r.out).toContain(`${CMD} login`);
});
it("a hand-written setup: prints the key to add, edits nothing", () => {
@@ -510,7 +511,7 @@ describe("dashboard: this app's free analytics, as a link in the terminal", () =
it("no token and not signed in: says how to get a free dashboard, exit 1, contacts nothing", () => {
const r = run(["dashboard", "--cwd", viteApp()]);
expect(r.code).toBe(1);
- expect(r.out).toContain("npx @genclass/runtime init");
+ expect(r.out).toContain(`${CMD} init`);
expect(r.out).toContain("free");
expect(r.out).not.toMatch(PAYWALL);
expect(r.calls).toEqual([]);
@@ -519,7 +520,7 @@ describe("dashboard: this app's free analytics, as a link in the terminal", () =
it("a key in this folder but no link here and not signed in: says where its dashboard is, exit 1, contacts nothing", () => {
const r = run(["dashboard", "--cwd", viteApp(KEYED)]);
expect(r.code).toBe(1);
- expect(r.out).toContain("npx @genclass/runtime login");
+ expect(r.out).toContain(`${CMD} login`);
expect(r.out).toContain("https://genclass.dev/account");
expect(r.calls).toEqual([]);
});
@@ -527,7 +528,7 @@ describe("dashboard: this app's free analytics, as a link in the terminal", () =
it("init and whoami point at it; help lists it", () => {
const r = run(["init", "--no-install", "--cwd", viteApp()], { config: signedIn(), input: "y\ny\n" });
expect(r.code, r.out).toBe(0);
- expect(r.out).toContain("npx @genclass/runtime dashboard");
+ expect(r.out).toContain(`${CMD} dashboard`);
expect(r.out).not.toMatch(PAYWALL);
const who = run(["whoami"], { config: signedIn(), env: { GENCLASS_TEST_PROJECTS: JSON.stringify([SHOP]) } });
expect(who.out).toContain("free");
diff --git a/packages/runtime/test/install/cli.test.ts b/packages/runtime/test/install/cli.test.ts
index c12baee5..aa1fe211 100644
--- a/packages/runtime/test/install/cli.test.ts
+++ b/packages/runtime/test/install/cli.test.ts
@@ -13,6 +13,7 @@ import { appendEnd, bodyTagEnd, codeStyle, insertTop, planRemoval, removeMarked,
import { integrityFor, nodeOnlyImport, scriptTag } from "../../bin/lib/plan.mjs";
// @ts-expect-error plain ESM module without types
import { libraryEvidence } from "../../bin/lib/detect.mjs";
+import { CMD, VERSION, channelOf, cmdFor, compareVersions, versionInSpec, versionStatus } from "../../bin/lib/version.mjs";
import { assetBase } from "../../src/cdn/global.js";
import { GenClass } from "../../src/index.js";
import { DEFAULT_MODEL_BASE_URL } from "../../src/model/host.js";
@@ -804,7 +805,7 @@ describe("init options: --no-telemetry / --telemetry / --model-url (Troy trial,
it("discloses that telemetry is on by default, how to turn it off, and links TELEMETRY.md and PRIVACY.md", () => {
const r = cli(viteVue(), "init", "--dry-run");
expect(r.out).toMatch(/Telemetry\s+on \(the default\)/);
- expect(r.out).toContain("npx @genclass/runtime init --no-telemetry");
+ expect(r.out).toContain(`${CMD} init --no-telemetry`);
expect(r.out).toContain("https://github.com/genclass-dev/GenClass-lib/blob/main/PRIVACY.md");
expect(r.out).toContain("packages/runtime/TELEMETRY.md");
expect(cli(viteVue(), "init", "--dry-run", "--no-telemetry").out).toMatch(/Telemetry\s+off/);
@@ -823,8 +824,8 @@ describe("init finds a Content-Security-Policy and prints the self-host steps",
const r = cli(next({ "next.config.ts": CSP_CONFIG }), "init", "--dry-run");
expect(r.out).toContain("Content-Security-Policy found (next.config.ts)");
expect(r.out).toContain("which your policy does not allow (connect-src)");
- expect(r.out).toContain("npx @genclass/runtime fetch-model public/genclass-model");
- expect(r.out).toContain("npx @genclass/runtime init --model-url /genclass-model/");
+ expect(r.out).toContain(`${CMD} fetch-model public/genclass-model`);
+ expect(r.out).toContain(`${CMD} init --model-url /genclass-model/`);
expect(r.out).toContain("or add https://cdn.jsdelivr.net to connect-src");
expect(r.out).not.toContain("wasm-unsafe-eval'.");
});
@@ -905,7 +906,7 @@ describe("init: app token and private dashboard link (fetch mocked; never the re
expect(r.out).toContain("keep this link private; it is the only way to open your dashboard".replace(/^k/, "K"));
// analytics are free: the notice says so and how to get the link again, never that it needs Pro or a trial
expect(r.out).toContain("Free for every app");
- expect(r.out).toContain("npx @genclass/runtime dashboard");
+ expect(r.out).toContain(`${CMD} dashboard`);
expect(r.out).not.toMatch(/needs\s+Pro|free trial|trial for analytics/i);
// again: the token is already there, no network, nothing to do
const r2 = cliEnv({ GENCLASS_TEST_FETCH: "ok", GENCLASS_TEST_FETCH_LOG: log }, dir, "init", "--yes", "--no-install");
@@ -1079,3 +1080,83 @@ describe("init: app token and private dashboard link (fetch mocked; never the re
expect(snapshot(html)["index.html"]).toBe(`\n\n x\n\n\n\n`);
});
});
+
+describe("the newest line: version line, npm dist-tags, older runtimes upgraded (README Install)", () => {
+ const app = (deps: Record = {}) => project({ "package.json": pkg({ vue: "^3", vite: "^7", ...deps }), "index.html": VITE_HTML.replace("main.tsx", "main.js"), "src/main.js": VITE_MAIN });
+
+ it("versions: semver order with prereleases, the channel, the command that runs this line again", () => {
+ expect(compareVersions("0.2.2", "0.3.0-beta.1")).toBe(-1);
+ expect(compareVersions("0.3.0-beta.1", "0.3.0-beta.10")).toBe(-1);
+ expect(compareVersions("0.3.0-beta.2", "0.3.0")).toBe(-1);
+ expect(compareVersions("0.3.0-rc.1", "0.3.0-beta.9")).toBe(1);
+ expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
+ expect(compareVersions("latest", "1.0.0")).toBeNull();
+ expect(channelOf("0.3.0-beta.1")).toBe("beta");
+ expect(channelOf("0.3.0")).toBeNull();
+ expect(cmdFor("0.3.0-beta.1")).toBe("npx @genclass/runtime@beta");
+ expect(cmdFor("0.3.0")).toBe("npx @genclass/runtime");
+ expect(CMD).toBe(cmdFor(VERSION));
+ expect(versionInSpec("^0.2.0")).toBe("0.2.0");
+ expect(versionInSpec(">=0.3.0-beta.1")).toBe("0.3.0-beta.1");
+ expect(versionInSpec("beta")).toBeNull();
+ expect(versionInSpec("file:../runtime")).toBeNull();
+ expect(versionStatus("0.3.0-beta.1", { latest: "0.2.2", beta: "0.3.0-beta.1" })).toEqual({ tag: "beta", newer: null });
+ expect(versionStatus("0.3.0-beta.1", { latest: "0.2.2", beta: "0.3.0-beta.3" })).toEqual({ tag: null, newer: { version: "0.3.0-beta.3", tag: "beta" } });
+ expect(versionStatus("0.3.0-beta.3", { latest: "0.3.0", beta: "0.3.0-beta.3" })).toEqual({ tag: "beta", newer: { version: "0.3.0", tag: "latest" } });
+ expect(versionStatus("0.3.0", null)).toEqual({ tag: null, newer: null });
+ });
+
+ it("init prints its own version and the dist-tag it came from", () => {
+ const r = cliEnv({ GENCLASS_TEST_DIST_TAGS: JSON.stringify({ latest: "0.2.2", beta: VERSION }) }, app(), "init", "--yes", "--no-install");
+ expect(r.code, r.out).toBe(0);
+ expect(r.out).toContain(`Version @genclass/runtime ${VERSION} (npm dist-tag beta)`);
+ expect(r.out).not.toContain("Newer");
+ });
+
+ it("says when npm has a newer release, with the command that runs it", () => {
+ const r = cliEnv({ GENCLASS_TEST_DIST_TAGS: JSON.stringify({ latest: "0.2.2", beta: "99.0.0-beta.1" }) }, app(), "init", "--dry-run");
+ // --dry-run never asks npm
+ expect(r.out).toContain("(npm dist-tags not checked)");
+ const n = cliEnv({ GENCLASS_TEST_DIST_TAGS: JSON.stringify({ latest: "0.2.2", beta: "99.0.0-beta.1" }) }, app(), "init", "--yes", "--no-install");
+ expect(n.out).toContain("Newer @genclass/runtime 99.0.0-beta.1 is on npm (dist-tag beta): run npx @genclass/runtime@beta init instead");
+ const l = cliEnv({ GENCLASS_TEST_DIST_TAGS: JSON.stringify({ latest: "99.0.0", beta: "99.0.0-beta.1" }) }, app(), "init", "--yes", "--no-install");
+ expect(l.out).toContain("Newer @genclass/runtime 99.0.0 is on npm (dist-tag latest): run npx @genclass/runtime init instead");
+ });
+
+ it("--no-network, --no-token, --no-telemetry and --dry-run do not ask npm; offline says nothing more", () => {
+ const tags = JSON.stringify({ latest: "99.0.0" });
+ for (const flag of ["--no-network", "--no-token", "--no-telemetry"]) {
+ const r = cliEnv({ GENCLASS_TEST_DIST_TAGS: tags }, app(), "init", "--yes", "--no-install", flag);
+ expect(r.code, r.out).toBe(0);
+ expect(r.out).toContain(`Version @genclass/runtime ${VERSION} (npm dist-tags not checked)`);
+ expect(r.out).not.toContain("Newer");
+ }
+ const off = cli(app(), "init", "--yes", "--no-install");
+ expect(off.out).toContain(`Version @genclass/runtime ${VERSION}\n`);
+ expect(off.out).not.toContain("Newer");
+ });
+
+ it("an older @genclass/runtime in the project (0.2.x) is upgraded to this version, not kept", () => {
+ const dir = app({ "@genclass/runtime": "^0.2.0" });
+ mkdirSync(join(dir, "node_modules/@genclass/runtime"), { recursive: true });
+ writeFileSync(join(dir, "node_modules/@genclass/runtime/package.json"), JSON.stringify({ name: "@genclass/runtime", version: "0.2.2" }));
+ const dry = cli(dir, "init", "--dry-run");
+ expect(dry.out).toContain(`@genclass/runtime 0.2.2 is older than this init (${VERSION}); init upgrades it`);
+ expect(dry.out).toContain(`Install npm install @genclass/runtime@^${VERSION} --no-audit --no-fund`);
+ const r = cli(dir, "init", "--yes", "--no-install");
+ expect(r.code, r.out).toBe(0);
+ expect(r.out).toContain(`--no-install: upgrade @genclass/runtime yourself (npm install @genclass/runtime@^${VERSION} --no-audit --no-fund)`);
+ // set up already: init again offers the upgrade alone
+ const again = cli(dir, "init", "--dry-run");
+ expect(again.out).toContain(`@genclass/runtime 0.2.2 is older than this init. Will run: npm install @genclass/runtime@^${VERSION}`);
+ // the same version (or a newer one) is left alone
+ writeFileSync(join(dir, "node_modules/@genclass/runtime/package.json"), JSON.stringify({ name: "@genclass/runtime", version: VERSION }));
+ expect(cli(dir, "init", "--yes", "--no-install").out).toContain("Nothing to do");
+ });
+
+ it("help names the newest line's behaviour", () => {
+ const r = cli(app(), "init", "--help");
+ expect(r.out).toContain("--no-network");
+ expect(r.out).toContain(`installs @genclass/runtime (this version, ${VERSION}; an older one already in the`);
+ });
+});
diff --git a/packages/runtime/test/install/mock-account.mjs b/packages/runtime/test/install/mock-account.mjs
index f7b0a834..bf23429d 100644
--- a/packages/runtime/test/install/mock-account.mjs
+++ b/packages/runtime/test/install/mock-account.mjs
@@ -17,6 +17,9 @@
import { appendFileSync } from "node:fs";
+// and no npm call: the dist-tags lookup (bin/lib/version.mjs) answers "unknown" unless a test sets them
+process.env.GENCLASS_TEST_DIST_TAGS ??= "fail";
+
const env = process.env;
const log = env.GENCLASS_TEST_FETCH_LOG;
const script = (env.GENCLASS_TEST_DEVICE || "pending,ok").split(",");
diff --git a/packages/runtime/test/install/mock-fetch.mjs b/packages/runtime/test/install/mock-fetch.mjs
index f215d0a3..49361c97 100644
--- a/packages/runtime/test/install/mock-fetch.mjs
+++ b/packages/runtime/test/install/mock-fetch.mjs
@@ -5,6 +5,9 @@
import { appendFileSync } from "node:fs";
+// and no npm call: the dist-tags lookup (bin/lib/version.mjs) answers "unknown" unless a test sets them
+process.env.GENCLASS_TEST_DIST_TAGS ??= "fail";
+
const mode = process.env.GENCLASS_TEST_FETCH || "fail";
const log = process.env.GENCLASS_TEST_FETCH_LOG;
diff --git a/packages/runtime/test/model/blocked.test.ts b/packages/runtime/test/model/blocked.test.ts
index 7f58fec3..ff12d1b6 100644
--- a/packages/runtime/test/model/blocked.test.ts
+++ b/packages/runtime/test/model/blocked.test.ts
@@ -55,7 +55,7 @@ describe("blockedFetch", () => {
it("the message names the origin, both fixes and the blocked URL", () => {
const m = blockedMessage({ url: CARD, origin: "https://cdn.jsdelivr.net", csp: true, directive: "connect-src" });
expect(m).toContain("https://cdn.jsdelivr.net was blocked by this page's Content-Security-Policy (connect-src)");
- expect(m).toContain("npx @genclass/runtime fetch-model public/genclass-model");
+ expect(m).toContain("npx @genclass/runtime@beta fetch-model public/genclass-model");
expect(m).toContain('model: { baseUrl: "/genclass-model/", ortWasmPaths: "/genclass-model/ort/" }');
expect(m).toContain("allow https://cdn.jsdelivr.net in the CSP's connect-src");
expect(m).toContain(CARD);
diff --git a/packages/sv/README.md b/packages/sv/README.md
index 5ee9613f..20d888b0 100644
--- a/packages/sv/README.md
+++ b/packages/sv/README.md
@@ -36,7 +36,7 @@ SvelteKit runs `src/hooks.client` in the browser before the app starts, so GenCl
components and stores. Server rendering, hydration and your server code are not touched. The overlay import sits
behind `import.meta.env.DEV`, so it is never in a production build.
-Every line it adds carries the `genclass:init` marker that `npx @genclass/runtime init` writes, so GenClass's own
+Every line it adds carries the `genclass:init` marker that `npx @genclass/runtime@beta init` writes, so GenClass's own
CLI recognises the setup and can take it out again. Running the add-on twice adds nothing twice; running it again
with other answers switches the mode or the telemetry line in place.
diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md
index a2e508b5..11f4a1cf 100644
--- a/telemetry-worker/README.md
+++ b/telemetry-worker/README.md
@@ -3,7 +3,7 @@
Cloudflare Worker that receives the default-on diagnostics batches from `@genclass/runtime` (see
[`packages/runtime/TELEMETRY.md`](../packages/runtime/TELEMETRY.md)) and stores them in R2, that gives every web app
a token and a private dashboard on genclass.dev, and that holds the optional accounts (Google, an email and a
-password, or GitHub, with passkeys as extra security) which keep several apps in one place and let `npx @genclass/runtime login` connect an app
+password, or GitHub, with passkeys as extra security) which keep several apps in one place and let `npx @genclass/runtime@beta login` connect an app
([Accounts](#accounts)). There are two plans, Free and Enterprise, and nothing is sold on the site: every plan
includes the dashboard, analytics, export, forwarding and your own classifier, which GenClass trains on request
([Paid plans](#paid-plans)). There is no trial.
@@ -78,7 +78,7 @@ genclass.dev/dashboard/ --> dashboard page --GET /api/projects/?
| `GET /api/projects/?range=24h\|7d\|30d` | dashboard stats JSON (every plan includes the dashboard; a plan file that left it out would answer 402 `{ ok:false, error, locked:true }`) (default `7d`): `name, token, created, lastEvent, range, from, generated, totals, breakdowns{metric: [{key,count}]}` (top 25 each; 200 for `detect_issue` and `possible_issue`), `distinct{metric: n}` (distinct keys before that cut, e.g. pages with a problem), `series` (24 hourly or 7/30 daily buckets: sessions, decisions, detections, acted, possible, ix_views), `recent` (last 50 detections/interventions in the window; possible ones are not listed). `totals` also has `latencyAvgMs`, `modelLoadAvgMs`, `failOpens`, `modelErrors`, `interventions`, `actionLimits`, `detectionsWithPage` and `possibleWithPage`. Apps that turned on interaction analytics (runtime `interactions` events, [docs/runtime/ANALYTICS.md](../docs/runtime/ANALYTICS.md)) also get `ix_views`, `ix_engaged_ms`, `ix_click`, `ix_rage` and `ix_dead` totals and breakdowns (by route, or route TAB element key; `ix_scroll` by route TAB milestone; top 200 each), counted with their own per-batch budget. 404 `{ ok:false, error:"not found" }` for an unknown or malformed secret. Same-origin only (no CORS). |
| `GET /start` | "Get a token" page |
| `POST /api/projects//agent` | what a developer's coding agent reports about the app through `@genclass/mcp` when they set `GENCLASS_REPORT` (docs/runtime/AGENT.md section 4.5): `{ schema: "genclass-agent/1", events: [{ t: "found" \| "replay" \| "test", id, kind, route, at, reproduced? }] }`, at most 50 events and 16 KB a request, rate limited per token (`SESSION_LIMIT`). Stored per finding in `agent_reports` (kind, normalised route, found, last replay and verdict, tested, proven: a replay that stops reproducing after one that did), 90 days; the stats carry them as `agent` and the dashboard's Loop view shows them. The sample and unknown secrets 404; other methods 405. Ids, kinds, routes, verdicts and times only, never page text |
-| `GET /dashboard/` | the dashboard (for an app with no account too: it is on Free), in views a sidebar switches between: Overview (the summary, the tiles, the trend), Problems, Pages and Actions, Activity, Visitors, Health, Loop (Fig. 3: the closed loop from a problem on this app to a proven, shipped fix, with the app's own numbers on the stages that happen here) and Setup on a private link (Connect, Forwarding and Export in account mode); the hash names the view (`#found`, `#recent-s`), the header has a breadcrumb and the Dashboard guide (genclass.dev/docs/dashboard), and without the script every section shows. With a plan file that left the dashboard out, the locked page (sign in, add the app to the account, see the plans; `src/locked.ts`). 404 page for an unknown secret; `GET /dashboard` redirects to `/start`. `/dashboard/sample` stays open |
+| `GET /dashboard/` | the dashboard (for an app with no account too: it is on Free), in views a sidebar switches between: Overview (the summary, the tiles, the trend), Problems, Pages and Actions, Activity, Visitors (without interaction data, an empty state with the runtime analytics need, `ANALYTICS_RUNTIME` 0.3.0-beta.1, the app's older versions from the `runtime` breakdown and the install line; the Connect panel says the same), Health, Loop (Fig. 3: the closed loop from a problem on this app to a proven, shipped fix, with the app's own numbers on the stages that happen here) and Setup on a private link (Connect, Forwarding and Export in account mode); the hash names the view (`#found`, `#recent-s`), the header has a breadcrumb and the Dashboard guide (genclass.dev/docs/dashboard), and without the script every section shows. With a plan file that left the dashboard out, the locked page (sign in, add the app to the account, see the plans; `src/locked.ts`). 404 page for an unknown secret; `GET /dashboard` redirects to `/start`. `/dashboard/sample` stays open |
Rate limits on batches: `LIMIT`, 1200 per 60 s per client IP (a ceiling for offices and campuses behind one address;
since runtime 0.2.2 a page stops sending after 3 failed requests in a row, so a low per-IP limit would silence every
@@ -185,7 +185,7 @@ rate-limit key.
## Accounts
Optional for the runtime and for collection: `/start`, secret dashboard links and ingest keep working without
-one. An account keeps several apps in one place, holds its plan, and lets the CLI (`npx @genclass/runtime login`, in `packages/runtime/bin/lib/login.mjs`
+one. An account keeps several apps in one place, holds its plan, and lets the CLI (`npx @genclass/runtime@beta login`, in `packages/runtime/bin/lib/login.mjs`
and `account.mjs`, from `@genclass/runtime` 0.3.0) connect an app without copying keys around. Code: `src/auth.ts`
(routes and rules), `src/password.ts` (passwords), `src/webauthn.ts` + `src/cbor.ts` (passkeys),
`src/account-pages.ts` (pages); tests in
@@ -244,7 +244,7 @@ the assertion must be of that account's passkey, with the checks of a passkey si
consumed and the web session created, marked as proven with a passkey (`auth_sessions.passkey_at`), and the browser
goes on to the `next` the sign-in started with. The pending step is never a session: `/api/me`, `/account` and the
device approval treat it as signed out. The CLI's device flow rides on the web session (`/login/device` approves only
-with a session), so `npx @genclass/runtime login` gets the same step. The email-collision choice
+with a session), so `npx @genclass/runtime@beta login` gets the same step. The email-collision choice
(`POST /api/auth/pending`) also goes through the step when the identity's account appeared meanwhile and has a
passkey.
@@ -401,7 +401,7 @@ The daily cron also deletes expired challenges and device codes and expired or r
|---|---|---|---|
| `AUTH_ORIGIN` | var | `https://genclass.dev` | expected `Origin`, WebAuthn origin, OAuth redirect URIs, cookie `Secure` |
| `AUTH_RP_ID` | var | `genclass.dev` | WebAuthn RP ID |
-| `RUNTIME_ON_NPM` | var (wrangler.toml) | `0.2.2` | the newest `@genclass/runtime` on npm, on any dist-tag; below `0.3.0-beta.0` the Connect panel, `/start` and private dashboards say first that keys and `login` are not published yet. Set it to `0.3.0-beta.0` once release.yml has published the beta and jsDelivr serves it, then deploy; at `0.3.0`, set it with `src/pages.ts` `SNIPPET_RUNTIME` and `RUNTIME_PKG` (the snippets pin the script tag to `SNIPPET_RUNTIME` and name the `@beta` dist-tag in their commands while it is a prerelease) |
+| `RUNTIME_ON_NPM` | var (wrangler.toml) | `0.3.0-beta.1` | the newest `@genclass/runtime` on npm, on any dist-tag; below `0.3.0-beta.0` the Connect panel, `/start` and private dashboards say first that keys and `login` are not published yet. Set it with `src/pages.ts` `SNIPPET_RUNTIME` to every release once release.yml has published it and jsDelivr serves it, then deploy; at `0.3.0` also set `RUNTIME_PKG` (the snippets pin the script tag to `SNIPPET_RUNTIME` and name the `@beta` dist-tag in their commands while it is a prerelease) |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | secret | unset | Google sign-in, the first way in; both needed. An email and a password are offered either way |
| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | secret | unset | GitHub sign-in; both needed |
@@ -959,7 +959,7 @@ curl https://genclass-collector..workers.dev/v1/health
Then open `https:///start`: it gives an app a token and a private dashboard link, and its snippets
already send to your worker. A collector whose origin is not genclass.dev names its own endpoint in every snippet it
shows (`src/pages.ts` ownEndpoint), through `GenClass.init` or `window.GENCLASS_CONFIG` (a meta tag may only name
-same-origin URLs), and leaves out `npx @genclass/runtime init` and `login`, which make their token at genclass.dev. In
+same-origin URLs), and leaves out `npx @genclass/runtime@beta init` and `login`, which make their token at genclass.dev. In
an app wired by hand:
```ts
@@ -968,7 +968,7 @@ GenClass.init({ token: "gc_...", telemetry: { endpoint: "https:///v
```html
-
+
```
Accounts work on it as on genclass.dev (an email and a password, and passkeys; Google and GitHub with your own OAuth
diff --git a/telemetry-worker/src/pages.ts b/telemetry-worker/src/pages.ts
index 5f318856..f3ddf43d 100644
--- a/telemetry-worker/src/pages.ts
+++ b/telemetry-worker/src/pages.ts
@@ -8,13 +8,17 @@
/** The first @genclass/runtime that reads project keys (tokens) and has `login`; earlier versions ignore a key. */
export const KEY_RUNTIME = "0.3.0-beta.0";
+/** The first @genclass/runtime with interaction analytics (`analytics: true`); earlier versions ignore the option. */
+export const ANALYTICS_RUNTIME = "0.3.0-beta.1";
/**
- * The runtime the snippets install, pinned in the script tag: the unversioned jsDelivr URL serves npm's `latest`
- * (0.2.x until 0.3.0, which ignores a key) and may serve an older release from the CDN cache. While it is a
- * prerelease, the npm and npx commands name its dist-tag (`@beta`): without it they install `latest`. At 0.3.0 set
- * SNIPPET_RUNTIME to "0.3.0" and RUNTIME_PKG to "@genclass/runtime" (and RUNTIME_ON_NPM in wrangler.toml), then deploy.
+ * The runtime the snippets install: always the newest release on npm, prerelease or not (release.yml: set it with
+ * RUNTIME_ON_NPM after each publish). The script tag pins it: the unversioned jsDelivr URL serves npm's `latest`
+ * (0.2.x until 0.3.0, which ignores a key and analytics) and may serve an older release from the CDN cache. While it
+ * is a prerelease, the npm and npx commands name its dist-tag (`@beta`): without it they install `latest`. At 0.3.0
+ * set SNIPPET_RUNTIME to "0.3.0" and RUNTIME_PKG to "@genclass/runtime" (and RUNTIME_ON_NPM in wrangler.toml), then
+ * deploy.
*/
-export const SNIPPET_RUNTIME = "0.3.0-beta.0";
+export const SNIPPET_RUNTIME = "0.3.0-beta.1";
export const RUNTIME_PKG = "@genclass/runtime@beta";
export const CDN_SCRIPT = `https://cdn.jsdelivr.net/npm/@genclass/runtime@${SNIPPET_RUNTIME}`;
/** What local and private hosts do, as the runtime behaves (src/telemetry/config.ts) and PRIVACY.md says. */
@@ -950,10 +954,28 @@ const DASH_JS = `
});
tb.appendChild(body); host.appendChild(tb);
}
+ // a.b.c(-pre.n) older than b.c.d(-pre.n); unreadable versions are not older
+ function vOlder(a, b){
+ var re = /^(\\d+)\\.(\\d+)\\.(\\d+)(?:-[a-z]+\\.(\\d+))?/i, x = re.exec(a || ""), y = re.exec(b || ""), i;
+ if (!x || !y) return false;
+ for (i = 1; i <= 3; i++) if (+x[i] !== +y[i]) return +x[i] < +y[i];
+ if (x[4] === undefined || y[4] === undefined) return x[4] !== undefined && y[4] === undefined;
+ return +x[4] < +y[4];
+ }
+ // no interaction data yet: what turns analytics on and the runtime they need (earlier ones ignore the option)
+ function ixEmpty(B){
+ var host = document.getElementById("ixsum"); host.textContent = ""; document.getElementById("ixpages").textContent = "";
+ var old = (B.runtime || []).map(function(x){ return x.key; }).filter(function(v){ return vOlder(v, "${ANALYTICS_RUNTIME}"); });
+ host.appendChild(el("p", null, "No interaction analytics in this period. Page views, time on page, scroll depth and clicks per element appear here once the app turns them on with analytics: true and its token."));
+ host.appendChild(el("p", "quiet", "They need @genclass/runtime ${ANALYTICS_RUNTIME} or later; earlier versions ignore analytics: true without a word." + (old.length ? " This app sends from @genclass/runtime " + old.slice(0, 3).join(", ") + ", which has no analytics." : "")));
+ host.appendChild(el("pre", "code", "npm install ${RUNTIME_PKG}\\n\\nGenClass.init({ token: \\"gc_...\\", analytics: true });"));
+ document.getElementById("c-visits").textContent = "";
+ }
function visits(S){
var B = S.breakdowns, T0 = S.totals, views = T0.ix_views || 0, on = views > 0 || (B.ix_click || []).length > 0;
- ["visits", "clicks", "nav-visits"].forEach(function(id){ var e = document.getElementById(id); if (e) e.classList.toggle("hidden", !on); });
- if (!on) return;
+ document.getElementById("clicks").classList.toggle("hidden", !on);
+ ["visits", "nav-visits"].forEach(function(id){ var e = document.getElementById(id); if (e) e.classList.remove("hidden"); });
+ if (!on) return ixEmpty(B);
var dl = el("dl", "health"), prev = (S.previous || {}).ix_views;
row(dl, "Page views", fmt(views), typeof prev === "number" && prev ? (views >= prev ? "+" : "") + share(views - prev, prev) + " on the previous period" : "page loads and in-app route changes");
row(dl, "Time on page", views ? dur((T0.ix_engaged_ms || 0) / views) : "-", "average engaged time per view: the tab visible, at most 30 s after the last input");
@@ -1313,6 +1335,7 @@ function connectPanel(token: string, connected: boolean, runtimeReady: boolean,
${snip(endpoint ? "With a bundler" : "Meta tag, with a bundler", endpoint ? "the bundler setup" : "the meta tag", meta)}
+
Interaction analytics (the Visitors view) need @genclass/runtime ${ANALYTICS_RUNTIME} or later and analytics: true: npm install ${RUNTIME_PKG}. Earlier versions ignore the option without a word.
The key needs @genclass/runtime ${KEY_RUNTIME} or later: 0.2.x does not send it. Events arrive in batches every 10 seconds.
Local hosts (localhost, *.local, *.test, private networks) do not send by default, even with a key (telemetry: true or a telemetry options object opts them in), so the first event usually comes from your deployed site. The status line above checks every 5 seconds.
`;
@@ -1359,7 +1382,7 @@ export function dashboardPage(
Pages Actions Activity
- Visitors
+ Visitors HealthLoop
${acct ? "" : 'Setup'}
@@ -1442,7 +1465,7 @@ versions ignore it. ${LOCAL_HOSTS} Your deployed site sends by default.
Nothing detected in this period.
-
+
How visitors use this app
Interaction analytics, for apps that turn them on
diff --git a/telemetry-worker/test/pages.test.ts b/telemetry-worker/test/pages.test.ts
index 7ccf4d28..8dc0b799 100644
--- a/telemetry-worker/test/pages.test.ts
+++ b/telemetry-worker/test/pages.test.ts
@@ -2,7 +2,7 @@
// through packages/runtime): what a developer reads on the page, the possible-issues tier and the display fixes.
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
-import { CDN_SCRIPT, DASH_CSS, dashboardPage, KEY_RUNTIME, MESH_SCRIPT, RUNTIME_PKG, securityHeaders, SNIPPET_RUNTIME, startPage, type DashboardOptions } from "../src/pages.js";
+import { ANALYTICS_RUNTIME, CDN_SCRIPT, DASH_CSS, dashboardPage, KEY_RUNTIME, MESH_SCRIPT, RUNTIME_PKG, securityHeaders, SNIPPET_RUNTIME, startPage, type DashboardOptions } from "../src/pages.js";
import { accountPage, devicePage, forgotPage, lockedLinkPage, loginPage, messagePage, passkeyStepPage, pendingPage, signupPage } from "../src/account-pages.js";
import { sampleStats } from "../src/sample.js";
import type { Stats } from "../src/dashboard.js";
@@ -210,6 +210,10 @@ describe("dashboard page: account mode (/account/apps/) with live stats", ()
// not on npm yet (runtimeReady unset): said first, with the @beta command
expect(r.text("#cnote")).toContain(`Project keys ship in @genclass/runtime ${KEY_RUNTIME} (npm dist-tag beta), which is not published yet`);
expect(r.text("#connect")).toContain(`npx ${RUNTIME_PKG} login`);
+ // analytics: the version they need and the exact install line
+ expect(r.text("#canalytics")).toBe(
+ `Interaction analytics (the Visitors view) need @genclass/runtime ${ANALYTICS_RUNTIME} or later and analytics: true: npm install ${RUNTIME_PKG}. Earlier versions ignore the option without a word.`,
+ );
await r.close();
});
});
@@ -452,20 +456,38 @@ describe("dashboard page: interaction analytics (docs/runtime/ANALYTICS.md secti
await close();
});
- it("hidden for an app that has not turned analytics on; the footer says what interaction data is", async () => {
+ it("an app without interaction data: the empty state says the version and install line; the footer says what interaction data is", async () => {
const st = sampleStats("7d", NOW);
for (const k of Object.keys(st.breakdowns)) if (k.startsWith("ix_")) delete st.breakdowns[k];
for (const k of Object.keys(st.totals)) if (k.startsWith("ix_")) delete st.totals[k];
- const { doc, hidden, close } = await render(st);
- expect(hidden("visits")).toBe(true);
+ const { doc, hidden, text, close } = await render(st);
+ expect(hidden("visits")).toBe(false);
expect(hidden("clicks")).toBe(true);
- expect(hidden("nav-visits")).toBe(true);
+ expect(hidden("nav-visits")).toBe(false);
+ expect(text("#c-visits")).toBe("");
+ expect(text("#ixsum")).toContain("No interaction analytics in this period.");
+ expect(text("#ixsum")).toContain(`They need @genclass/runtime ${ANALYTICS_RUNTIME} or later; earlier versions ignore analytics: true without a word.`);
+ expect(text("#ixsum")).not.toContain("This app sends from");
+ expect(doc.querySelector("#ixsum pre")!.textContent).toBe(`npm install ${RUNTIME_PKG}\n\nGenClass.init({ token: "gc_...", analytics: true });`);
+ expect(ANALYTICS_RUNTIME).toBe("0.3.0-beta.1");
expect(doc.querySelector(".dfoot")!.textContent).toContain("never its text");
expect(doc.querySelector(".dfoot")!.textContent).toContain("No text from your pages, no IP addresses and no user agents are stored.");
await close();
});
});
+describe("dashboard page: the Visitors empty state names the app's older runtime (docs/runtime/ANALYTICS.md section 4.1)", () => {
+ it("lists the versions older than the first with analytics, not the newer ones", async () => {
+ const st = sampleStats("7d", NOW);
+ for (const k of Object.keys(st.breakdowns)) if (k.startsWith("ix_")) delete st.breakdowns[k];
+ for (const k of Object.keys(st.totals)) if (k.startsWith("ix_")) delete st.totals[k];
+ st.breakdowns.runtime = [{ key: "0.2.2", count: 5 }, { key: "0.3.0-beta.0", count: 3 }, { key: "0.3.0-beta.1", count: 2 }, { key: "0.3.0", count: 1 }, { key: "(unknown)", count: 1 }];
+ const { text, close } = await render(st);
+ expect(text("#ixsum")).toContain("This app sends from @genclass/runtime 0.2.2, 0.3.0-beta.0, which has no analytics.");
+ await close();
+ });
+});
+
describe("dashboard page: the closed loop (Fig. 3)", () => {
it("draws this app's problems, one agent step each, the shared fix, prove and ship, and observe with the deltas", async () => {
const st = sampleStats("7d", NOW);
diff --git a/telemetry-worker/wrangler.selfhost.toml b/telemetry-worker/wrangler.selfhost.toml
index d51bee7c..f6350c66 100644
--- a/telemetry-worker/wrangler.selfhost.toml
+++ b/telemetry-worker/wrangler.selfhost.toml
@@ -27,7 +27,7 @@ AUTH_ORIGIN = "https://genclass-collector.YOUR-SUBDOMAIN.workers.dev"
AUTH_RP_ID = "genclass-collector.YOUR-SUBDOMAIN.workers.dev"
DASHBOARD_ORIGIN = "https://genclass-collector.YOUR-SUBDOMAIN.workers.dev"
# The newest @genclass/runtime on npm that sends app tokens (the Connect panel says so until it is published).
-RUNTIME_ON_NPM = "0.3.0-beta.0"
+RUNTIME_ON_NPM = "0.3.0-beta.1"
MANAGED_PAYMENTS = "off"
[observability]
diff --git a/telemetry-worker/wrangler.toml b/telemetry-worker/wrangler.toml
index 90fa099b..15a03689 100644
--- a/telemetry-worker/wrangler.toml
+++ b/telemetry-worker/wrangler.toml
@@ -29,11 +29,11 @@ routes = [
# are always offered, so the site always has a way in.
# The newest @genclass/runtime on npm, on any dist-tag. Below 0.3.0-beta.0 the Connect panel, /start and private
-# dashboards say first that project keys and `login` are not published yet. Set it to "0.3.0-beta.0" (then deploy) once
-# release.yml has published the beta and jsDelivr serves it, and to "0.3.0" at 0.3.0 (with pages.ts SNIPPET_RUNTIME and
-# RUNTIME_PKG).
+# dashboards say first that project keys and `login` are not published yet. Set it (with pages.ts SNIPPET_RUNTIME, then
+# deploy) to every release once release.yml has published it and jsDelivr serves it: 0.3.0-beta.1 now, "0.3.0" at 0.3.0
+# (with RUNTIME_PKG).
[vars]
-RUNTIME_ON_NPM = "0.3.0-beta.0"
+RUNTIME_ON_NPM = "0.3.0-beta.1"
# "on" sells the plans marked managed_payments in billing/plans.json through Stripe Managed Payments (Stripe, through
# Link, is the merchant of record, for 3.5% of each payment on top of Stripe's fees). No plan is marked today
# (no plan is sold on the site today), so it stays "off"; README "Paid plans".
diff --git a/templates/angular/README.md b/templates/angular/README.md
index 52899cdf..ca440135 100644
--- a/templates/angular/README.md
+++ b/templates/angular/README.md
@@ -26,7 +26,7 @@ import genclass from "@genclass/runtime/auto";
It goes first so GenClass is installed before Angular bootstraps and before your app's first requests. GenClass runs
in the browser only: `src/main.server.ts` and `src/server.ts` never import it, so server rendering, hydration and your
server's `fetch` are untouched. It watches fetch and XHR (both `HttpClient` backends), WebSocket, EventSource, user
-input and timers. `npx @genclass/runtime init` writes the same line into an existing app.
+input and timers. `npx @genclass/runtime@beta init` writes the same line into an existing app.
One Angular detail, nothing to do with GenClass: the SSR server only answers requests addressed to the hosts in
`angular.json`'s `security.allowedHosts` (`127.0.0.1` and `localhost` here, Angular's protection against server-side
@@ -63,7 +63,7 @@ if (isDevMode()) import("@genclass/runtime/devtools").then((d) => d.mountDevtool
- For one page load: add `?genclass=off` to the URL (nothing is installed).
- For a browser: `localStorage.genclass = "off"`.
-- For good: remove the import (or run `npx @genclass/runtime remove`).
+- For good: remove the import (or run `npx @genclass/runtime@beta remove`).
## Privacy
diff --git a/templates/angular/package.json b/templates/angular/package.json
index b4f05c46..4cf34693 100644
--- a/templates/angular/package.json
+++ b/templates/angular/package.json
@@ -17,7 +17,7 @@
"@angular/platform-server": "^22.2.0",
"@angular/router": "^22.2.0",
"@angular/ssr": "^22.2.0",
- "@genclass/runtime": "^0.2.0",
+ "@genclass/runtime": "^0.3.0-beta.1",
"express": "^5.3.0",
"rxjs": "~7.8.2",
"tslib": "^2.8.1"
diff --git a/templates/astro/README.md b/templates/astro/README.md
index e5843fb1..097465f3 100644
--- a/templates/astro/README.md
+++ b/templates/astro/README.md
@@ -28,7 +28,7 @@ The integration injects `import "@genclass/runtime/auto"` into every page as a s
other scripts, so GenClass is installed before your islands hydrate and before their first request. It runs in the
browser only: the build, endpoints and server rendering are untouched. It watches fetch, XHR, WebSocket, EventSource,
user input and timers; in React islands it also finds component state on its own (automatic state discovery).
-`npx @genclass/runtime init` installs GenClass in an existing Astro project without the integration (a `