@eventra_dev/eventra-cli 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  <a href="https://www.npmjs.com/package/@eventra_dev/eventra-cli"><img alt="npm version" src="https://img.shields.io/npm/v/@eventra_dev/eventra-cli.svg?style=flat-square&color=blue"></a>
9
9
  <a href="https://www.npmjs.com/package/@eventra_dev/eventra-cli"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@eventra_dev/eventra-cli.svg?style=flat-square&color=blue"></a>
10
10
  <a href="https://github.com/and-1991/eventra-cli/actions/workflows/test.yml"><img alt="tests" src="https://img.shields.io/github/actions/workflow/status/and-1991/eventra-cli/test.yml?branch=main&label=tests&style=flat-square&logo=vitest&logoColor=white"></a>
11
- <img alt="unit tests" src="https://img.shields.io/badge/unit-665%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
11
+ <img alt="unit tests" src="https://img.shields.io/badge/unit-783%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
12
12
  <img alt="e2e fixtures" src="https://img.shields.io/badge/e2e-14%20fixtures-brightgreen?style=flat-square">
13
13
  <img alt="coverage" src="https://img.shields.io/badge/coverage-100%25%20(unit)-brightgreen?style=flat-square&logo=vitest&logoColor=white">
14
14
  <img alt="node" src="https://img.shields.io/node/v/@eventra_dev/eventra-cli?style=flat-square&color=darkgreen&logo=node.js&logoColor=white">
@@ -212,7 +212,7 @@ Everything found is pre-selected; press Enter to accept or untick what you do no
212
212
  | `amplitude` | `@amplitude/analytics-browser`, `@amplitude/analytics-node`, `@amplitude/analytics-react-native`, `@amplitude/unified`, `@amplitude/marketing-analytics-browser`, and the older `amplitude-js` |
213
213
  | `ga4` | `react-ga4`, `vue-gtag`, `@next/third-parties`, `nuxt-gtag`, `@types/gtag.js`, and the older `vue-gtag-next` |
214
214
 
215
- Detection reads dependencies only. A tag pasted straight into the HTML (a Segment or `gtag.js` snippet with no package) is not seen: add the preset to `sinks` yourself. In a monorepo only the block-list form of `packages:` in `pnpm-workspace.yaml` is understood (not `packages: [a, b]`), and workspace patterns that are absolute or contain `..` are ignored. Running `init` again rewrites `eventra.json`.
215
+ Detection reads dependencies only. A tag pasted straight into the HTML (a Segment or `gtag.js` snippet with no package) is not seen: add the preset to `sinks` yourself. In a monorepo only the block-list form of `packages:` in `pnpm-workspace.yaml` is understood (not `packages: [a, b]`), and workspace patterns that are absolute or contain `..` are ignored. If `eventra.json` already exists, `init` asks before overwriting it (the default answer is no). `eventra init --yes` never prompts: it uses the default endpoint, accepts every detected preset, and leaves an existing `eventra.json` untouched, so it is safe in scripts and CI.
216
216
 
217
217
  ### `eventra sync`
218
218
 
@@ -221,9 +221,11 @@ Full project scan → updates `eventra.json`:
221
221
  - `events` — discovered event names
222
222
  - `functionWrappers` — detected wrapper functions
223
223
 
224
+ A file that cannot be analysed (a broken `.vue`, `.svelte`, `.astro` or `.html`, an unreadable file, or one over 2 MB) is skipped, and `sync` prints one summary at the end listing each skipped file and why. While anything is skipped, events already in `eventra.json` are kept, because a missing event may simply live in a skipped file. The exit code stays `0`; `eventra sync --strict` exits `1` if any file was skipped.
225
+
224
226
  ### `eventra check`
225
227
 
226
- Compares config with the current scan (events + wrappers). Exit code `1` on drift.
228
+ Compares config with the current scan (events + wrappers). Exit code `1` on drift. When files were skipped, known events that were not found are not reported as removed (they cannot be verified) and the skipped files are listed; `eventra check --strict` also exits `1` if any file was skipped.
227
229
 
228
230
  ### `eventra check --fix`
229
231
 
@@ -255,6 +257,14 @@ This records the endpoint in `eventra.local.json` (gitignored). If `eventra.json
255
257
 
256
258
  Once `eventra.json` already commits an `endpoint`, that committed value is what events actually get sent to - `EVENTRA_ENDPOINT` only controls whether the approval prompt is skipped; its own value is not read or compared against anything in that case. `EVENTRA_ENDPOINT` becomes the real destination only when `eventra.json` has no `endpoint` set at all. In other words, setting the variable to point somewhere else does not redirect traffic away from an already-committed endpoint - it only means you won't be asked to approve it.
257
259
 
260
+ **Catalog snapshot.** Besides the event list, `send` scans the project once more and tells the server how trustworthy that list is, so the dashboard can compare what is in your code with what actually arrived. It adds four optional fields to the request: `complete`, `dynamicCount`, `branch` and `cliVersion`.
261
+
262
+ - `complete` is `true` only when the scan skipped no file, hit no error, and `eventra.json` matches what the scan just found. The server marks names that are missing from a complete snapshot as removed from code, so anything doubtful is `false` (a file that could not be parsed, a failed scan, or an `eventra.json` that is out of date: run `eventra sync` first). A request that is not complete never removes anything.
263
+ - `dynamicCount` is the number of event-name call sites that could not be resolved to any string (for example `track(name)` with an unknown `name`). The dashboard uses it to warn that such names may show up as unexpected events.
264
+ - `branch` is the current branch, read from the usual CI variables (`GITHUB_REF_NAME`, `CI_COMMIT_REF_NAME`, `BRANCH_NAME`, `BITBUCKET_BRANCH`, `CIRCLE_BRANCH`, `BUILDKITE_BRANCH`, `VERCEL_GIT_COMMIT_REF`) and otherwise from `git`; it is left out when unknown (for example a detached checkout with no CI variable). `cliVersion` is the CLI version.
265
+
266
+ Run `send` from the main branch in CI: a snapshot from a feature branch describes that branch. A server that predates these fields rejects them, so `send` repeats the request once with the plain event list. The extra scan adds the same time as one `eventra sync`.
267
+
258
268
  Network resilience:
259
269
 
260
270
  - Up to 4 attempts with exponential backoff + jitter (capped at 8 s)
@@ -416,7 +426,7 @@ No dependency on `@eventra_dev/eventra-cli` is required — the host CLI loads t
416
426
  | `plugins` | `@eventra_dev/cli-plugin-*` specifiers to `import()` at startup. Anything outside that scope is skipped (see [Plugin contract](#plugin-contract-for-authors)) |
417
427
  | `sinks` | Third-party analytics calls to treat as event sources, e.g. `[{ "preset": "posthog" }]` (see [Third-party analytics](#third-party-analytics-sinks)) |
418
428
  | `sync.include` | Base glob patterns; plugin `includeGlobs` are merged automatically |
419
- | `sync.exclude` | Paths skipped during scan |
429
+ | `sync.exclude` | Paths skipped during scan. A plain name such as `node_modules` or `dist` matches at any depth (nested `packages/*/node_modules` too); entries containing `/` or glob characters are used as written |
420
430
 
421
431
  `eventra init`/`eventra send` never write a real key into this file — they write to `eventra.local.json` instead, which is automatically added to `.gitignore`.
422
432
 
@@ -440,14 +450,14 @@ No runtime execution. No monkey-patching.
440
450
 
441
451
  ## Requirements
442
452
 
443
- - Node.js 18+
453
+ - Node.js 18+ (`eventra watch` needs Node.js 20.19+, which its file-watching dependency requires; `init`, `sync`, `check` and `send` run on 18)
444
454
  - TypeScript/JavaScript source using `@eventra_dev/eventra-sdk`
445
455
 
446
456
  ---
447
457
 
448
458
  ## Test Coverage
449
459
 
450
- Two test layers, **665 unit tests + 14 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
460
+ Two test layers, **783 unit tests + 14 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
451
461
 
452
462
  **100% statement/branch/function/line coverage** (v8 provider, `pnpm test:coverage`), enforced via a `coverage.thresholds` block in `vitest.config.ts` so a regression fails the build instead of silently slipping under 100%. This measures the vitest unit layer only; the e2e/`check`/`watch` fixtures run against the compiled `dist/` binary via `tsx tests/run.ts` and aren't coverage-instrumented. A handful of genuinely unreachable defensive branches (TS-internal invariants — e.g. an aliased import symbol is always fully resolved before its own declarations are ever inspected — that don't hold a second outcome in practice) are marked with `/* v8 ignore */`, with an inline comment explaining why, rather than tested.
453
463
 
@@ -7,18 +7,27 @@ function isSymbol(value) {
7
7
  return typeof value.getDeclarations === "function";
8
8
  }
9
9
  class WrapperRegistry {
10
- checker;
10
+ pendingChecker;
11
11
  exportCache;
12
12
  wrappers = new WeakMap();
13
- constructor(checker, exportCache) {
14
- this.checker = checker;
13
+ resolvedChecker;
14
+ /** `checker` may be a provider, called once on first use (setChecker replaces it). */
15
+ constructor(pendingChecker, exportCache) {
16
+ this.pendingChecker = pendingChecker;
15
17
  this.exportCache = exportCache;
16
18
  }
17
19
  setChecker(checker) {
18
- this.checker = checker;
20
+ this.resolvedChecker = checker;
19
21
  }
20
22
  getChecker() {
21
- return this.checker;
23
+ if (!this.resolvedChecker) {
24
+ this.resolvedChecker =
25
+ typeof this.pendingChecker === "function" ? this.pendingChecker() : this.pendingChecker;
26
+ }
27
+ return this.resolvedChecker;
28
+ }
29
+ get checker() {
30
+ return this.getChecker();
22
31
  }
23
32
  normalize(symbol) {
24
33
  return ((0, exportResolver_1.resolveExportedSymbol)(symbol, this.checker, this.exportCache) ?? symbol);
package/dist/cli/check.js CHANGED
@@ -8,30 +8,41 @@ const chalk_1 = __importDefault(require("chalk"));
8
8
  const config_1 = require("../config/config");
9
9
  const projectScanner_1 = require("../core/projectScanner");
10
10
  const scanResults_1 = require("../core/scanResults");
11
+ const skipped_1 = require("../core/skipped");
11
12
  function diffSets(found, known) {
12
13
  return {
13
14
  added: [...found].filter((e) => !known.has(e)),
14
15
  removed: [...known].filter((e) => !found.has(e)),
15
16
  };
16
17
  }
17
- async function check({ fix = false }) {
18
- const config = await (0, config_1.loadConfig)();
19
- if (!config) {
20
- console.log(chalk_1.default.red("eventra.json not found"));
21
- process.exit(1);
22
- }
18
+ async function check({ fix = false, strict = false } = {}) {
19
+ const config = await (0, config_1.requireConfig)();
20
+ if (!config)
21
+ return;
23
22
  console.log(chalk_1.default.blue("Checking events and function wrappers..."));
24
- const { engine, plugins } = await (0, projectScanner_1.scanProject)(config);
23
+ const { engine, plugins, skipped, ignored } = await (0, projectScanner_1.scanProject)(config);
25
24
  await plugins.runDynamicEventReporters(engine.getAllDynamicOccurrences());
25
+ const partial = skipped.length > 0;
26
26
  const foundEvents = new Set(engine.getAllEvents());
27
27
  const knownEvents = new Set(config.events ?? []);
28
28
  const eventDiff = diffSets(foundEvents, knownEvents);
29
29
  const foundWrappers = new Set(engine.getAllFunctionWrappers());
30
30
  const knownWrappers = new Set(config.functionWrappers?.map((w) => w.name) ?? []);
31
31
  const wrapperDiff = diffSets(foundWrappers, knownWrappers);
32
+ // With skipped files the scan is incomplete: a known event that was not found
33
+ // may simply live in a skipped file, so it is not reported as removed.
34
+ const unverified = partial ? eventDiff.removed.length + wrapperDiff.removed.length : 0;
35
+ if (partial) {
36
+ eventDiff.removed = [];
37
+ wrapperDiff.removed = [];
38
+ }
32
39
  if (fix) {
33
- await (0, config_1.saveConfig)((0, scanResults_1.buildConfigFromScan)(config, engine));
40
+ await (0, config_1.saveConfig)((0, scanResults_1.buildConfigFromScan)(config, engine, { preserveKnown: partial }));
34
41
  console.log(chalk_1.default.green("Synced"));
42
+ (0, skipped_1.reportSkipped)(skipped);
43
+ (0, skipped_1.reportIgnored)(ignored);
44
+ if (strict && partial)
45
+ process.exitCode = 1;
35
46
  return;
36
47
  }
37
48
  let hasDiff = false;
@@ -47,7 +58,12 @@ async function check({ fix = false }) {
47
58
  wrapperDiff.added.forEach((w) => console.log(chalk_1.default.red(`+ ${w}`)));
48
59
  wrapperDiff.removed.forEach((w) => console.log(chalk_1.default.yellow(`- ${w}`)));
49
60
  }
50
- if (hasDiff) {
61
+ (0, skipped_1.reportSkipped)(skipped);
62
+ (0, skipped_1.reportIgnored)(ignored);
63
+ if (unverified > 0) {
64
+ console.error(chalk_1.default.gray(`${(0, skipped_1.plural)(unverified, "known entry", "known entries")} could not be verified because files were skipped.`));
65
+ }
66
+ if (hasDiff || (strict && partial)) {
51
67
  process.exit(1);
52
68
  }
53
69
  console.log(chalk_1.default.green("All good"));
package/dist/cli/init.js CHANGED
@@ -5,7 +5,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.init = init;
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
9
  const inquirer_1 = __importDefault(require("inquirer"));
10
+ const path_1 = __importDefault(require("path"));
9
11
  const config_1 = require("../config/config");
10
12
  const sinkPresets_1 = require("../plugin/builtins/sinkPresets");
11
13
  const projectDependencies_1 = require("./projectDependencies");
@@ -13,11 +15,13 @@ const projectDependencies_1 = require("./projectDependencies");
13
15
  * Looks for analytics libraries in package.json (and workspace members) and, if there are any,
14
16
  * asks which of them Eventra should read events from. Everything detected is pre-selected.
15
17
  */
16
- async function chooseSinks() {
18
+ async function chooseSinks(yes) {
17
19
  const detected = (0, sinkPresets_1.detectSinkPresets)(await (0, projectDependencies_1.collectDependencyNames)(process.cwd()));
18
20
  if (detected.length === 0)
19
21
  return [];
20
22
  console.log(chalk_1.default.gray(`Found analytics in package.json: ${detected.map((d) => d.packages.join(", ")).join("; ")}`));
23
+ if (yes)
24
+ return detected.map((d) => ({ preset: d.preset }));
21
25
  const { presets } = await inquirer_1.default.prompt([
22
26
  {
23
27
  type: "checkbox",
@@ -32,22 +36,47 @@ async function chooseSinks() {
32
36
  ]);
33
37
  return presets.map((preset) => ({ preset }));
34
38
  }
35
- async function init() {
39
+ const DEFAULT_ENDPOINT = "https://api.eventra.dev/api/v1/cli/events";
40
+ /**
41
+ * `yes` is the non-interactive mode (CI, scripts): defaults for every answer,
42
+ * and an existing eventra.json is never overwritten.
43
+ */
44
+ async function init({ yes = false } = {}) {
45
+ if (await fs_extra_1.default.pathExists(path_1.default.join(process.cwd(), config_1.CONFIG_NAME))) {
46
+ if (yes) {
47
+ console.log(chalk_1.default.yellow(`${config_1.CONFIG_NAME} already exists, keeping it`));
48
+ return;
49
+ }
50
+ const { overwrite } = await inquirer_1.default.prompt([
51
+ {
52
+ type: "confirm",
53
+ name: "overwrite",
54
+ message: `${config_1.CONFIG_NAME} already exists. Overwrite it? Its events, wrappers, plugins and sinks will be lost.`,
55
+ default: false,
56
+ },
57
+ ]);
58
+ if (!overwrite) {
59
+ console.log(chalk_1.default.gray(`Kept the existing ${config_1.CONFIG_NAME}`));
60
+ return;
61
+ }
62
+ }
36
63
  console.log(chalk_1.default.blue("Initializing Eventra..."));
37
- const { apiKey, endpoint, } = await inquirer_1.default.prompt([
38
- {
39
- type: "input",
40
- name: "apiKey",
41
- message: "API Key (optional):",
42
- },
43
- {
44
- type: "input",
45
- name: "endpoint",
46
- message: "Custom endpoint (optional):",
47
- default: "https://api.eventra.dev/api/v1/cli/events",
48
- },
49
- ]);
50
- const sinks = await chooseSinks();
64
+ const { apiKey, endpoint, } = yes
65
+ ? { apiKey: "", endpoint: DEFAULT_ENDPOINT }
66
+ : await inquirer_1.default.prompt([
67
+ {
68
+ type: "input",
69
+ name: "apiKey",
70
+ message: "API Key (optional):",
71
+ },
72
+ {
73
+ type: "input",
74
+ name: "endpoint",
75
+ message: "Custom endpoint (optional):",
76
+ default: DEFAULT_ENDPOINT,
77
+ },
78
+ ]);
79
+ const sinks = await chooseSinks(yes);
51
80
  console.log("");
52
81
  console.log(chalk_1.default.gray("Eventra detects @eventra_dev/eventra-sdk usage:"));
53
82
  console.log(chalk_1.default.gray('• tracker.track("event.name")'));
package/dist/cli/send.js CHANGED
@@ -7,6 +7,7 @@ exports.send = send;
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
8
  const inquirer_1 = __importDefault(require("inquirer"));
9
9
  const config_1 = require("../config/config");
10
+ const catalogMeta_1 = require("../core/catalogMeta");
10
11
  const package_json_1 = __importDefault(require("../../package.json"));
11
12
  const CLI_VERSION = package_json_1.default.version;
12
13
  // Always trusted, no local approval needed — matches `eventra init`'s prompt default.
@@ -64,6 +65,10 @@ async function attemptDelivery(endpoint, apiKey, payload) {
64
65
  clearTimeout(timeout);
65
66
  }
66
67
  }
68
+ // the API answers a rejected unknown property with {"error":{"code":"VALIDATION_ERROR",...}}
69
+ function isValidationError(body) {
70
+ return typeof body === "string" && body.includes("VALIDATION_ERROR");
71
+ }
67
72
  async function send(opts = {}) {
68
73
  const config = await (0, config_1.loadConfig)();
69
74
  if (!config) {
@@ -134,24 +139,48 @@ async function send(opts = {}) {
134
139
  }
135
140
  console.log("");
136
141
  console.log(chalk_1.default.blue(`Sending ${config.events.length} events...`));
137
- const payload = JSON.stringify({
142
+ // Tells the server whether this list is a full snapshot of the code (so names
143
+ // missing from it can be marked as removed) and how many event names could
144
+ // not be resolved. Older servers ignore the extra fields.
145
+ const meta = await (0, catalogMeta_1.collectCatalogMeta)(config);
146
+ if (!meta.complete) {
147
+ console.log(chalk_1.default.gray("Catalog snapshot marked incomplete (files were skipped, the scan failed, or eventra.json is out of date: run 'eventra sync')"));
148
+ }
149
+ const legacyBody = {
138
150
  events: config.events,
139
151
  cli: {
140
152
  name: "@eventra_dev/eventra-cli",
141
153
  version: CLI_VERSION,
142
154
  runtime: "node",
143
155
  },
156
+ };
157
+ const payload = JSON.stringify({
158
+ ...legacyBody,
159
+ complete: meta.complete,
160
+ cliVersion: CLI_VERSION,
161
+ ...(meta.dynamicCount !== undefined ? { dynamicCount: meta.dynamicCount } : {}),
162
+ ...(meta.branch ? { branch: meta.branch } : {}),
144
163
  });
164
+ // A server that predates the catalog fields rejects unknown properties with a
165
+ // generic validation 400; the request is then repeated once without them.
166
+ const legacyPayload = JSON.stringify(legacyBody);
167
+ let body = payload;
145
168
  // attempt delivery with retry on 429/5xx/network errors
146
169
  let data;
147
170
  let lastFailure = null;
148
171
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
149
172
  try {
150
- const outcome = await attemptDelivery(endpoint, apiKey, payload);
173
+ const outcome = await attemptDelivery(endpoint, apiKey, body);
151
174
  if (outcome.ok) {
152
175
  data = outcome.data;
153
176
  break;
154
177
  }
178
+ if (outcome.status === 400 && body !== legacyPayload && isValidationError(outcome.body)) {
179
+ body = legacyPayload;
180
+ console.log(chalk_1.default.gray("Server does not know the catalog fields yet, sending the plain event list"));
181
+ attempt--;
182
+ continue;
183
+ }
155
184
  if (!outcome.retryable) {
156
185
  // permanent failure — surface immediately
157
186
  console.log(chalk_1.default.red(`Request failed (${outcome.status})`));
package/dist/cli/sync.js CHANGED
@@ -8,17 +8,20 @@ const chalk_1 = __importDefault(require("chalk"));
8
8
  const config_1 = require("../config/config");
9
9
  const projectScanner_1 = require("../core/projectScanner");
10
10
  const scanResults_1 = require("../core/scanResults");
11
- async function sync() {
12
- const config = await (0, config_1.loadConfig)();
13
- if (!config) {
14
- console.log(chalk_1.default.red("eventra.json not found"));
11
+ const skipped_1 = require("../core/skipped");
12
+ async function sync({ strict = false } = {}) {
13
+ const config = await (0, config_1.requireConfig)();
14
+ if (!config)
15
15
  return;
16
- }
17
16
  console.log(chalk_1.default.blue("Scanning..."));
18
- const { engine, plugins } = await (0, projectScanner_1.scanProject)(config);
19
- await (0, scanResults_1.persistScanResults)(config, engine);
20
- const events = engine.getAllEvents();
21
- const wrappers = engine.getAllFunctionWrappers();
22
- console.log(chalk_1.default.green(`Found ${events.length} events, ${wrappers.length} function wrappers`));
17
+ const { engine, plugins, skipped, ignored } = await (0, projectScanner_1.scanProject)(config);
18
+ // report what was saved: with skipped files that is the union of the known and
19
+ // the found events, not just what this scan found
20
+ const saved = await (0, scanResults_1.persistScanResults)(config, engine, { preserveKnown: skipped.length > 0 });
21
+ console.log(chalk_1.default.green(`Found ${(0, skipped_1.plural)(saved.events?.length ?? 0, "event")}, ${(0, skipped_1.plural)(saved.functionWrappers?.length ?? 0, "function wrapper")}`));
23
22
  await plugins.runDynamicEventReporters(engine.getAllDynamicOccurrences());
23
+ (0, skipped_1.reportSkipped)(skipped);
24
+ (0, skipped_1.reportIgnored)(ignored);
25
+ if (strict && skipped.length > 0)
26
+ process.exitCode = 1;
24
27
  }