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 ea4b83ad..fda7dd28 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -115,6 +115,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 e95cbcdb..b22d9253 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -76,7 +76,7 @@ came from, devices, countries, new or returning, entry and exit pages and funnel
(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
@@ -85,7 +85,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 2b85c051..f8152e4e 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 715c7751..fe759f12 100644
--- a/docs/runtime/ANALYTICS.md
+++ b/docs/runtime/ANALYTICS.md
@@ -158,7 +158,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, the setup below, 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;
@@ -215,6 +216,32 @@ 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.** The script tag loads `dist/analytics/index.js` by URL from its base, so a self-hosted `dist/` can
+ lack it or hold files of two releases (an app's bundler ships the chunk with the runtime it came with, so the ESM
+ entries cannot mix them). The tag's loader (`src/cdn/analytics-loader.ts`) warns when the chunk does not load,
+ once per load: `[GenClass] analytics: (@genclass/runtime ) did not load; nothing is recorded. Serve
+ every file of this version's dist/.`, and `runtime.analytics` reports `{ enabled: false, reason: "error" }`. It
+ passes the tag's version to the chunk, and a chunk of another release warns once per page with both versions and
+ the install line, and still records. Both live in the script-tag build and the lazy chunk: the ESM first load does
+ not grow for them.
+- **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.** Under the copy-prompt button, the Visitors view of an app without interaction data says that
+ analytics need 0.3.0-beta.1 or later with the install line and names the app's older runtime versions from its own
+ telemetry; 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 e5c92c07..635d9ecd 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,
@@ -78,6 +78,11 @@ whether the browser visited before. Every telemetry opt-out and Global Privacy C
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 and removes the visit entry). Also `data-analytics="on"`, `analytics=on` in the meta
tag, `window.GENCLASS_CONFIG.analytics`. Schema: [TELEMETRY.md](../../packages/runtime/TELEMETRY.md#interaction-analytics).
+With the script tag, a chunk that does not load warns once per load (`[GenClass] analytics: (@genclass/runtime
+) did not load; nothing is recorded. ...`, `reason: "error"`), and a chunk of another release than the tag
+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
@@ -211,7 +216,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
@@ -670,7 +675,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 29bddd29..01f06491 100644
--- a/packages/mcp/CHANGELOG.md
+++ b/packages/mcp/CHANGELOG.md
@@ -2,6 +2,10 @@
## 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.
- **`genclass_scan {url, phone?, hosted?}`: audit any page in one step** ([docs/runtime/AGENT.md](../../docs/runtime/AGENT.md)
section 4.2). It runs `@genclass/runtime`'s `scan --json` (the project's own runtime when it has the command, else
`npx -y @genclass/runtime@beta`) in the project's folder: headless Chromium at desktop and phone sizes with the 23
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
index 6f144f1c..3721c3cb 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 ba212fe3..12152e22 100644
--- a/packages/mcp/src/tools.ts
+++ b/packages/mcp/src/tools.ts
@@ -531,11 +531,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"`;
@@ -543,7 +554,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 44180f10..93d6309c 100644
--- a/packages/runtime/CHANGELOG.md
+++ b/packages/runtime/CHANGELOG.md
@@ -2,6 +2,28 @@
## Unreleased
+- **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, the script tag's analytics chunk that does not
+ load (a self-hosted `dist/` without `analytics/`) warns once per load, `[GenClass] analytics:
+ (@genclass/runtime ) did not load; nothing is recorded. ...`, with `runtime.analytics.reason` `"error"` as
+ before; the tag 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 (`src/cdn/analytics-loader.ts`, the chunk). Not in the ESM first
+ load; the `@beta` console messages above add 16 B gzip to `/auto` and 13 B to the main entry. Under the dashboard's
+ copy-prompt button, the Visitors view and the Connect panel name the version analytics need and the app's older
+ runtimes.
- **Interaction analytics: visits, where they come from, devices, entry and exit pages, funnels** (inside
`analytics: true`, off by default; [docs/runtime/ANALYTICS.md](../../docs/runtime/ANALYTICS.md) sections 1.2 and
3.1, TELEMETRY.md "Visits"). A view's first `interactions` event now names its **visit** (`visit`: 16 random hex
@@ -56,7 +78,7 @@
instead, saying so (`--hosted` asks for that directly, `--local` never uses it; a loopback or private address is
never sent). Exit code 0 when nothing is found, 1 when something is, 2 on an error. No runtime code, no telemetry
and no model-visible text changed. `@genclass/mcp`'s `genclass_scan` tool runs it.
-- **`dashboard` prints the link to this app's free dashboard.** `npx @genclass/runtime dashboard` prints the link
+- **`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 5d1126e5..51def310 100644
--- a/packages/runtime/INTERCEPTION.md
+++ b/packages/runtime/INTERCEPTION.md
@@ -259,7 +259,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 b72b025d..5092b0b2 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 `, `--no-sri` and `--sync` (plain HTML),
-`remove --keep-package`.
+Other flags: `--no-install`, `--no-devtools`, `--no-network`, `--cwd `, `--cdn `, `--no-sri` and `--sync`
+(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.
On a plain HTML site `init` writes the script tag with `defer` (after `0.3.0-beta.1`), so a slow or blocked CDN never
holds up the page. GenClass then starts once the page is parsed: before your `defer` and `type="module"` scripts and
@@ -161,7 +181,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).
```
@@ -246,15 +266,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:**
@@ -494,7 +514,13 @@ 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.
+With the script tag, when the analytics code itself does not load (a self-hosted `dist/` missing `analytics/`), the
+console says so 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 1729fa5b..d1e3f7a1 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` / `{ ... }` |
@@ -251,7 +251,7 @@ flag from `localStorage`.
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.
@@ -270,7 +270,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 e7ded49e..cf1f568c 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, littleToWatch, 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, though attributes added to its script tag, such as defer, are fine) and
uninstalls the package if nothing else imports it.
@@ -88,6 +80,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)
@@ -105,6 +99,7 @@ const BOOL = {
"dry-run": "dryRun",
"no-install": "noInstall",
"no-devtools": "noDevtools",
+ "no-network": "noNetwork",
"keep-package": "keepPackage",
"no-sri": "noSri",
sync: "sync",
@@ -261,7 +256,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();
}
@@ -737,6 +732,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);
@@ -775,6 +793,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) {
@@ -810,8 +829,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) {
@@ -851,16 +874,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;
@@ -893,7 +917,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 6608d825..bb97335e 100644
--- a/packages/runtime/src/analytics/index.ts
+++ b/packages/runtime/src/analytics/index.ts
@@ -28,6 +28,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. */
@@ -284,17 +288,43 @@ 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, visits, time on page, scroll depth, clicks per element, the referring site, campaign and device type, 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. */
+export const ANALYTICS_SINCE = "0.3.0-beta.1";
+
+/**
+ * The warning when this chunk and the script tag that loaded it are not the same release (a self-hosted dist/ with
+ * files of two versions), or null. `version`: the tag's (src/cdn/analytics-loader.ts); the ESM entries pass none,
+ * since an app's bundler ships the chunk with the runtime it came with.
+ */
+export function versionMismatch(version: string | undefined): string | null {
+ if (version === undefined || version === VERSION) return null;
+ return `[GenClass] analytics: this analytics code is @genclass/runtime ${VERSION} but the script tag that loaded it is ${version}. 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 script tag's 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/cdn/analytics-loader.ts b/packages/runtime/src/cdn/analytics-loader.ts
new file mode 100644
index 00000000..baeb056a
--- /dev/null
+++ b/packages/runtime/src/cdn/analytics-loader.ts
@@ -0,0 +1,19 @@
+// The script tag's loader for the interaction analytics chunk (src/cdn/global.ts; docs/runtime/ANALYTICS.md section
+// 4.1). The tag loads dist/analytics/index.js by URL from its base, so a self-hosted dist/ can hold files of two
+// releases, or none: the loader passes the tag's version to the chunk, which warns once when its own differs, and a
+// chunk that does not load says so on the console (once per load; the runtime then reports reason "error"). In the
+// script-tag build only: the ESM entries' first load does not carry it.
+
+import type { AnalyticsChunk } from "../analytics/index.js";
+
+/** Wraps `load` (the chunk's import by URL): the tag's `version` goes to the chunk; a failed import warns via `warn`. */
+export function analyticsLoader(load: () => Promise, version: string, url: string, warn: (m: string) => void): () => Promise {
+ return () =>
+ load().then(
+ (m) => ({ startAnalytics: (rt, t, ignored, data) => m.startAnalytics(rt, t, ignored, data, version) }),
+ (e: unknown) => {
+ warn(`[GenClass] analytics: ${url} (@genclass/runtime ${version}) did not load; nothing is recorded. Serve every file of this version's dist/.`);
+ throw e;
+ },
+ );
+}
diff --git a/packages/runtime/src/cdn/global.ts b/packages/runtime/src/cdn/global.ts
index f927ed09..6121f3a3 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),
@@ -24,6 +24,7 @@ import type { VisualChunk } from "../visual/host.js";
import { visualRegistry } from "../visual/registry.js";
import type { AnalyticsChunk } from "../analytics/index.js";
import { analyticsRegistry } from "../analytics/start.js";
+import { analyticsLoader } from "./analytics-loader.js";
import { devtoolsOptions, fromDataset, isKilled, mergeConfig, readMetaConfig, readWindowConfig, splitConfig, whenBody, type PageConfig } from "./config.js";
import { applySinks, type SinkResolver } from "./sinks.js";
import { fanout, type SinkModule } from "./sinks-fanout.js";
@@ -118,8 +119,9 @@ function install(): void {
const resolveSinks: SinkResolver = (names, warn) => fanout(names, (name) => load(`sinks/${name}.js`) as Promise, warn);
// the visual checks chunk comes from the same place as this file (never inlined into the script tag)
visualRegistry.loader = () => load("visual/index.js") as Promise;
- // interaction analytics too (docs/runtime/ANALYTICS.md), only when the app turned them on
- analyticsRegistry.loader = () => load("analytics/index.js") as Promise;
+ // interaction analytics too (docs/runtime/ANALYTICS.md), only when the app turned them on; with this tag's version,
+ // so files of two releases or a missing chunk say so (src/cdn/analytics-loader.ts)
+ analyticsRegistry.loader = analyticsLoader(() => load("analytics/index.js") as Promise, VERSION, `${base}analytics/index.js`, (m) => console.warn(m));
const withCdnModel = (opts: InitOptions): InitOptions => {
if (opts.decider !== undefined || opts.model === false) return opts;
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 df05d857..b997a32c 100644
--- a/packages/runtime/src/runtime.ts
+++ b/packages/runtime/src/runtime.ts
@@ -3315,7 +3315,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 a72cee7a..647c4438 100644
--- a/packages/runtime/src/types.ts
+++ b/packages/runtime/src/types.ts
@@ -860,7 +860,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.
@@ -955,6 +955,8 @@ export interface InitOptions {
* (`genclass.analytics.`) holds the visit id and the visited-before flag. 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, `runtime.analytics.reason` is "error" (the script tag also warns on the console).
*/
analytics?: boolean;
/**
@@ -982,7 +984,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 1d98640b..e1f0f5b4 100644
--- a/packages/runtime/test/analytics.test.ts
+++ b/packages/runtime/test/analytics.test.ts
@@ -8,7 +8,11 @@ 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, deviceOf, IDLE_MS, KEY_MAX, keyOf, refHost, utmValue, VISIT_IDLE_MS, VISIT_KEY, type KeyContext } from "../src/analytics/index.js";
+import { ANALYTICS_NOTICE, ANALYTICS_SINCE, deviceOf, IDLE_MS, KEY_MAX, keyOf, refHost, utmValue, versionMismatch, VISIT_IDLE_MS, VISIT_KEY, type KeyContext } from "../src/analytics/index.js";
+import { analyticsRegistry } from "../src/analytics/start.js";
+import type { AnalyticsChunk } from "../src/analytics/index.js";
+import { analyticsLoader } from "../src/cdn/analytics-loader.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 +63,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; storage?: boolean } = {}) {
+async function arun(o: { analytics?: boolean; token?: string | null; telemetry?: Record | false; nav?: boolean; user?: boolean; storage?: boolean; noWait?: boolean } = {}) {
const clock = new FakeClock();
const transport = recorder();
rt = createRuntime({
@@ -73,7 +77,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 };
}
@@ -166,6 +170,54 @@ 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;
+ });
+ const URL0 = "https://cdn.example/npm/@genclass/runtime@9.9.9/dist/analytics/index.js";
+ const chunk = () => import("../src/analytics/index.js") as Promise;
+
+ it("the script tag's loader: a chunk that does not load warns (once per load) naming the URL and version; reason error", async () => {
+ const said: string[] = [];
+ analyticsRegistry.loader = analyticsLoader(() => Promise.reject(new Error("404")), "9.9.9", URL0, (m) => said.push(m));
+ const { rt: r } = await arun({ noWait: true });
+ await vi.waitFor(() => expect(r.analytics).toMatchObject({ enabled: false, reason: "error" }));
+ expect(said).toEqual([`[GenClass] analytics: ${URL0} (@genclass/runtime 9.9.9) did not load; nothing is recorded. Serve every file of this version's dist/.`]);
+ expect(r.analytics!.start()).toBe(true);
+ await vi.waitFor(() => expect(said).toHaveLength(2));
+ });
+
+ it("the script tag's loader passes its version; the same release says nothing", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const seen: unknown[] = [];
+ analyticsRegistry.loader = analyticsLoader(
+ () => chunk().then((m) => ({ startAnalytics: (...a: Parameters) => (seen.push(a[4]), m.startAnalytics(...a)) })),
+ RUNTIME_VERSION,
+ URL0,
+ () => undefined,
+ );
+ const a = await arun();
+ expect(a.rt.analytics).toMatchObject({ enabled: true });
+ expect(seen).toEqual([RUNTIME_VERSION]);
+ expect(warn.mock.calls.flat().filter((m) => String(m).includes("script tag that loaded it"))).toEqual([]);
+ });
+
+ it("a chunk from another release than the script tag warns once per page and still records; the ESM entries pass no version", async () => {
+ expect(versionMismatch(undefined)).toBeNull();
+ expect(versionMismatch(RUNTIME_VERSION)).toBeNull();
+ expect(versionMismatch("0.3.0-beta.9")).toContain(`is @genclass/runtime ${RUNTIME_VERSION} but the script tag that loaded it is 0.3.0-beta.9`);
+ expect(versionMismatch("0.3.0-beta.9")).toContain(`need ${ANALYTICS_SINCE} or later: npm install @genclass/runtime@beta`);
+ expect(ANALYTICS_SINCE).toBe("0.3.0-beta.1");
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ analyticsRegistry.loader = analyticsLoader(chunk, "0.3.0-beta.9", URL0, () => undefined);
+ 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 script tag 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 f0f9e75f..e110f8d2 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 b862a27a..cab39703 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";
@@ -848,7 +849,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/);
@@ -867,8 +868,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'.");
});
@@ -949,7 +950,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");
@@ -1123,3 +1124,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 4fd8132a..e6fd7253 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.
@@ -79,7 +79,7 @@ genclass.dev/dashboard/ --> dashboard page --GET /api/projects/?
| `PUT /api/projects//funnels` | body `{ funnels: [{ name, steps }] }` (application/json, at most 4 KB): replaces the app's funnels (at most 5, 2 to 6 routes each; a step ending in `*` matches every route under it). 200 `{ ok, funnels }`, 400 with the reason, 403 for the sample. Account mode: `PUT /api/account/projects//funnels` with the browser session and the site's Origin |
| `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, under the copy-prompt button, the runtime analytics need, `ANALYTICS_RUNTIME` 0.3.0-beta.1, with the install line and the app's older versions from the `runtime` breakdown; 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
@@ -186,7 +186,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
@@ -245,7 +245,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.
@@ -402,7 +402,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 |
@@ -972,7 +972,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
@@ -981,7 +981,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 0544170b..c7642dd2 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. */
@@ -970,6 +974,19 @@ 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];
+ }
+ // under the setup button: the app's runtime versions that predate analytics (they ignore analytics: true)
+ function ixOld(B){
+ var old = (B.runtime || []).map(function(x){ return x.key; }).filter(function(v){ return vOlder(v, "${ANALYTICS_RUNTIME}"); });
+ document.getElementById("ixold").textContent = old.length ? " This app sends from @genclass/runtime " + old.slice(0, 3).join(", ") + ", which has no analytics." : "";
+ }
// a key as people read it: direct, other sites, unknown; a country code as its name
var REGION = null; try { REGION = new Intl.DisplayNames([], { type: "region" }); } catch (e) {}
function nice(k, country){
@@ -1028,7 +1045,7 @@ const DASH_JS = `
["ixsum", "ixpages"].forEach(function(id){ document.getElementById(id).classList.toggle("hidden", !on); });
IX_SECTIONS.forEach(function(id){ var e = document.getElementById(id); if (e) e.classList.toggle("hidden", !on || (!nv && /^ix-(sources|devices|entry)$/.test(id))); });
document.getElementById("c-visits").textContent = on ? fmt(views) : "";
- if (!on) return;
+ if (!on) return ixOld(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");
@@ -1517,6 +1534,7 @@ function connectPanel(token: string, connected: boolean, runtimeReady: boolean,
${analytics ? `
Visitor analytics
${analytics}
` : ""}
+
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.
`;
@@ -1648,7 +1666,7 @@ versions ignore it. ${LOCAL_HOSTS} Your deployed site sends by default.
How visitors use this app
Interaction analytics, for apps that turn them on
-
${promptBlock(project.token, acct && opts.id ? `this app's GenClass dashboard, under Visitors (${ORIGIN_OF(opts)}/account/apps/${opts.id}#visits)` : "this app's GenClass dashboard (the private link I opened it from), under Visitors", opts.endpoint ?? null, "This app has not turned visitor analytics on. Paste the prompt into your coding agent (Claude Code, Cursor, Codex) and it sets them up.")}
+
${promptBlock(project.token, acct && opts.id ? `this app's GenClass dashboard, under Visitors (${ORIGIN_OF(opts)}/account/apps/${opts.id}#visits)` : "this app's GenClass dashboard (the private link I opened it from), under Visitors", opts.endpoint ?? null, "This app has not turned visitor analytics on. Paste the prompt into your coding agent (Claude Code, Cursor, Codex) and it sets them up.")}
They need @genclass/runtime ${ANALYTICS_RUNTIME} or later (npm install ${RUNTIME_PKG}); earlier versions ignore analytics: true without a word.
diff --git a/telemetry-worker/test/pages.test.ts b/telemetry-worker/test/pages.test.ts
index 82f6a441..13479105 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 { analyticsPrompt, CDN_SCRIPT, DASH_CSS, dashboardPage, esc, KEY_RUNTIME, MESH_SCRIPT, RUNTIME_PKG, securityHeaders, SNIPPET_RUNTIME, startPage, type DashboardOptions } from "../src/pages.js";
+import { ANALYTICS_RUNTIME, analyticsPrompt, CDN_SCRIPT, DASH_CSS, dashboardPage, esc, 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();
});
});
@@ -473,6 +477,9 @@ describe("dashboard page: interaction analytics (docs/runtime/ANALYTICS.md secti
expect(box.children).toHaveLength(2);
expect(box.querySelectorAll("p.cp-sub br")).toHaveLength(0);
expect(text("#ixsetup .cp-sub").length).toBeLessThanOrEqual(160);
+ // under the button: the runtime analytics need and the install line (0.2.x ignores the option)
+ expect(text("#ixver")).toBe(`They need @genclass/runtime ${ANALYTICS_RUNTIME} or later (npm install ${RUNTIME_PKG}); earlier versions ignore analytics: true without a word.`);
+ 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();
@@ -593,6 +600,18 @@ describe("dashboard page: visitors, sources, devices, sessions, entry and exit p
});
});
+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("#ixold")).toBe("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 `