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

Interaction analytics (the Visitors view) need @genclass/runtime ${ANALYTICS_RUNTIME} or later and analytics: true: npm install ${RUNTIME_PKG}. Earlier versions ignore the option without a word.

The key needs @genclass/runtime ${KEY_RUNTIME} or later: 0.2.x does not send it. Events arrive in batches every 10 seconds. Local hosts (localhost, *.local, *.test, private networks) do not send by default, even with a key (telemetry: true or a telemetry options object opts them in), so the first event usually comes from your deployed site. The status line above checks every 5 seconds.

`; @@ -1359,7 +1382,7 @@ export function dashboardPage( Pages Actions Activity - + Visitors Health Loop ${acct ? "" : 'Setup'} @@ -1442,7 +1465,7 @@ versions ignore it. ${LOCAL_HOSTS} Your deployed site sends by default.

-