@eventra_dev/eventra-cli 1.0.3 → 1.0.5

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-59%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
11
+ <img alt="unit tests" src="https://img.shields.io/badge/unit-74%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
12
12
  <img alt="e2e fixtures" src="https://img.shields.io/badge/e2e-12%20fixtures-brightgreen?style=flat-square">
13
13
  <img alt="node" src="https://img.shields.io/node/v/@eventra_dev/eventra-cli?style=flat-square&color=darkgreen&logo=node.js&logoColor=white">
14
14
  <a href="https://www.typescriptlang.org/"><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-ready-blue?style=flat-square&logo=typescript&logoColor=white"></a>
@@ -155,7 +155,23 @@ Incremental scan with the same rules as `sync`.
155
155
 
156
156
  ### `eventra send`
157
157
 
158
- Uploads `events` from config to the Eventra API. Requires `apiKey` (and optionally `endpoint`) in `eventra.json`. Events are POSTed to `POST /api/v1/cli/events` and marked as non-billable on the backend.
158
+ Uploads `events` from config to the Eventra API. Requires an API key and (optionally) an `endpoint`. Events are POSTed to `POST /api/v1/cli/events` and marked as non-billable on the backend.
159
+
160
+ **API key resolution** (checked in this order, never written to `eventra.json`):
161
+
162
+ 1. `EVENTRA_API_KEY` environment variable — recommended for CI
163
+ 2. `eventra.local.json` (created by `eventra init`/`eventra send`, automatically gitignored)
164
+ 3. legacy inline `apiKey` in `eventra.json` (deprecated — avoid committing real keys here)
165
+
166
+ `eventra.json` is meant to be committed so CI can diff it via `eventra check`; keeping the key out of it avoids leaking it into source control. In a non-interactive shell (no TTY), `send` fails fast with a message pointing at `EVENTRA_API_KEY` instead of hanging on a prompt.
167
+
168
+ **Endpoint trust.** The default production endpoint is always used with no extra step. A *custom* `endpoint` is only trusted without approval when it comes from the `EVENTRA_ENDPOINT` environment variable (setting an env var is already a local/CI action). A custom `endpoint` committed inline in `eventra.json` is trust-on-first-use: `send` refuses to run until it's approved once with:
169
+
170
+ ```bash
171
+ eventra send --trust-endpoint
172
+ ```
173
+
174
+ This records the endpoint in `eventra.local.json` (gitignored). If `eventra.json`'s `endpoint` later changes to a different value — e.g. a PR silently pointing it at another host — `send` blocks again until re-approved, so a committed config change alone can't redirect where your API key and events get sent.
159
175
 
160
176
  Network resilience:
161
177
 
@@ -224,6 +240,8 @@ External plugins export an object (or factory) with:
224
240
 
225
241
  No dependency on `@eventra_dev/eventra-cli` is required. See [`ARCHITECTURE.md`](./ARCHITECTURE.md#plugin-kernel-phase-15--foundation-shipped) for details.
226
242
 
243
+ **Only official `@eventra_dev/cli-plugin-*` packages can be loaded.** `eventra.json` is meant to be committed to git, so its `plugins` array is effectively PR-editable; since the CLI `import()`s whatever is listed there, an arbitrary specifier would be arbitrary code execution on every machine that runs `sync`/`check`/`watch`. Because `@eventra_dev` is an npm scope only the Eventra maintainers can publish to, restricting to `@eventra_dev/cli-plugin-*` means nothing outside that scope can ever be loaded this way. Any other entry in `plugins` is skipped with a console warning instead of being imported — third-party/community plugins aren't supported today.
244
+
227
245
  ---
228
246
 
229
247
  ## Configuration
@@ -244,10 +262,14 @@ No dependency on `@eventra_dev/eventra-cli` is required. See [`ARCHITECTURE.md`]
244
262
 
245
263
  | Field | Description |
246
264
  |-------|-------------|
247
- | `plugins` | Module specifiers to `import()` at startup (e.g. `@eventra_dev/cli-plugin-vue`) |
265
+ | `apiKey` | Legacy inline API key — leave empty. Use `EVENTRA_API_KEY` or `eventra.local.json` instead (see [`eventra send`](#eventra-send)) so a real key never ends up in this committed file |
266
+ | `endpoint` | Custom `eventra send` target (e.g. self-hosted). Non-default values from this file need one-time local approval — see [`eventra send`](#eventra-send) |
267
+ | `plugins` | `@eventra_dev/cli-plugin-*` specifiers to `import()` at startup. Anything outside that scope is skipped (see [Plugin contract](#plugin-contract-for-authors)) |
248
268
  | `sync.include` | Base glob patterns; plugin `includeGlobs` are merged automatically |
249
269
  | `sync.exclude` | Paths skipped during scan |
250
270
 
271
+ `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`.
272
+
251
273
  ---
252
274
 
253
275
  ## How it works
@@ -275,7 +297,7 @@ No runtime execution. No monkey-patching.
275
297
 
276
298
  ## Test Coverage
277
299
 
278
- Two test layers, **59 unit tests + 12 e2e fixtures + 3 `check` exit-code scenarios**.
300
+ Two test layers, **74 unit tests + 12 e2e fixtures + 3 `check` exit-code scenarios**.
279
301
 
280
302
  **Unit tests (vitest)** — 13 suites covering core modules:
281
303
 
@@ -322,6 +344,8 @@ pnpm --filter @eventra_dev/eventra-cli test:unit # vitest only
322
344
  pnpm --filter @eventra_dev/eventra-cli test:e2e # fixtures + check exit codes
323
345
  ```
324
346
 
347
+ > **Node version note:** the CLI itself supports Node 18+ (see `engines`), but `vitest@4` depends on `node:util`'s `styleText`, which requires Node **≥ 20.12** — running the test suite on an older Node 20.x patch (or Node 18) fails to start. CI runs the matrix on Node 20 and 22; locally, use the version pinned in the repo's `.nvmrc`.
348
+
325
349
  ---
326
350
 
327
351
  ## License
@@ -9,8 +9,10 @@ const resolver_1 = require("../resolver/resolver");
9
9
  const evaluationContext_1 = require("../shared/evaluationContext");
10
10
  const propagationExtractor_1 = require("./propagationExtractor");
11
11
  const eventValidation_1 = require("../shared/eventValidation");
12
+ const dynamicOccurrence_1 = require("../shared/dynamicOccurrence");
12
13
  function extractEvents(index, checker, _config, evaluationCache, exportCache, resolvedCallCache, returnPropagationCache, wrapperRegistry) {
13
14
  const events = new Set();
15
+ const dynamicOccurrences = [];
14
16
  // direct track sinks
15
17
  for (const call of index.trackCalls) {
16
18
  for (const argument of call.trackedArguments) {
@@ -21,12 +23,15 @@ function extractEvents(index, checker, _config, evaluationCache, exportCache, re
21
23
  events.add(normalized);
22
24
  }
23
25
  }
26
+ if (resolved.dynamic) {
27
+ (0, dynamicOccurrence_1.recordDynamicOccurrence)(call.node, resolved.values, dynamicOccurrences);
28
+ }
24
29
  }
25
30
  }
26
31
  const propagationVisited = new Set();
27
32
  function visit(node) {
28
33
  if (typescript_1.default.isCallExpression(node)) {
29
- (0, propagationExtractor_1.extractPropagationEvents)(node, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, propagationVisited);
34
+ (0, propagationExtractor_1.extractPropagationEvents)(node, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, propagationVisited, dynamicOccurrences);
30
35
  }
31
36
  typescript_1.default.forEachChild(node, visit);
32
37
  }
@@ -34,5 +39,6 @@ function extractEvents(index, checker, _config, evaluationCache, exportCache, re
34
39
  return {
35
40
  events,
36
41
  detectedFunctionWrappers: new Set(index.wrappers.map(wrapper => wrapper.symbol.getName())),
42
+ dynamicOccurrences,
37
43
  };
38
44
  }
@@ -9,6 +9,7 @@ const callResolver_1 = require("../resolver/callResolver");
9
9
  const resolver_1 = require("../resolver/resolver");
10
10
  const evaluationContext_1 = require("../shared/evaluationContext");
11
11
  const eventValidation_1 = require("../shared/eventValidation");
12
+ const dynamicOccurrence_1 = require("../shared/dynamicOccurrence");
12
13
  function resolveObjectPath(expression, path) {
13
14
  let current = expression;
14
15
  for (const segment of path) {
@@ -34,7 +35,7 @@ function resolveObjectPath(expression, path) {
34
35
  }
35
36
  return current;
36
37
  }
37
- function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, visited) {
38
+ function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, visited, dynamicOccurrences) {
38
39
  const resolved = (0, callResolver_1.resolveFunctionFromCall)(call.expression, checker, resolvedCallCache, exportCache);
39
40
  if (!resolved) {
40
41
  return;
@@ -53,14 +54,14 @@ function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCa
53
54
  visited.add(signature);
54
55
  try {
55
56
  for (const propagation of semantic.propagations) {
56
- extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events);
57
+ extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events, dynamicOccurrences);
57
58
  }
58
59
  }
59
60
  finally {
60
61
  visited.delete(signature);
61
62
  }
62
63
  }
63
- function extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events) {
64
+ function extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events, dynamicOccurrences) {
64
65
  let argument = call.arguments[propagation.sourceParameterIndex];
65
66
  if (!argument) {
66
67
  return;
@@ -89,4 +90,7 @@ function extractPropagation(propagation, call, checker, evaluationCache, resolve
89
90
  events.add(normalized);
90
91
  }
91
92
  }
93
+ if (resolved.dynamic) {
94
+ (0, dynamicOccurrence_1.recordDynamicOccurrence)(call, resolved.values, dynamicOccurrences);
95
+ }
92
96
  }
@@ -72,7 +72,8 @@ function scanSource(source, checker, wrapperRegistry, plugins) {
72
72
  }
73
73
  return false;
74
74
  });
75
- const wrapper = (0, propagationAnalyzer_1.analyzeWrapperPropagation)(fn, checker, localSinks);
75
+ const wrapper = plugins.detectWrapper({ fn, checker, sinks: localSinks }) ??
76
+ (0, propagationAnalyzer_1.analyzeWrapperPropagation)(fn, checker, localSinks);
76
77
  if (!wrapper) {
77
78
  continue;
78
79
  }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ // src/analysis/shared/dynamicOccurrence.ts
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.recordDynamicOccurrence = recordDynamicOccurrence;
8
+ const typescript_1 = __importDefault(require("typescript"));
9
+ const CALL_TEXT_MAX_LENGTH = 120;
10
+ function recordDynamicOccurrence(call, resolvedValues, out) {
11
+ const sourceFile = call.getSourceFile();
12
+ const { line, character } = typescript_1.default.getLineAndCharacterOfPosition(sourceFile, call.getStart(sourceFile));
13
+ const callText = call.getText(sourceFile);
14
+ out.push({
15
+ fileName: sourceFile.fileName,
16
+ line: line + 1,
17
+ character,
18
+ calleeText: call.expression.getText(sourceFile),
19
+ callText: callText.length > CALL_TEXT_MAX_LENGTH ? `${callText.slice(0, CALL_TEXT_MAX_LENGTH)}…` : callText,
20
+ resolvedValues,
21
+ });
22
+ }
package/dist/cli/check.js CHANGED
@@ -21,7 +21,8 @@ async function check({ fix = false }) {
21
21
  process.exit(1);
22
22
  }
23
23
  console.log(chalk_1.default.blue("Checking events and function wrappers..."));
24
- const { engine } = await (0, projectScanner_1.scanProject)(config);
24
+ const { engine, plugins } = await (0, projectScanner_1.scanProject)(config);
25
+ await plugins.runDynamicEventReporters(engine.getAllDynamicOccurrences());
25
26
  const foundEvents = new Set(engine.getAllEvents());
26
27
  const knownEvents = new Set(config.events ?? []);
27
28
  const eventDiff = diffSets(foundEvents, knownEvents);
package/dist/cli/init.js CHANGED
@@ -29,7 +29,7 @@ async function init() {
29
29
  console.log(chalk_1.default.gray("• cross-file propagation chains"));
30
30
  console.log("");
31
31
  const config = {
32
- apiKey,
32
+ apiKey: "",
33
33
  endpoint,
34
34
  events: [],
35
35
  functionWrappers: [],
@@ -47,6 +47,12 @@ async function init() {
47
47
  };
48
48
  await (0, config_1.saveConfig)(config);
49
49
  console.log(chalk_1.default.green("eventra.json created"));
50
+ // Never write the key into eventra.json — that file is meant to be
51
+ // committed (CI diffs it via `eventra check`). Keep secrets out of git.
52
+ if (apiKey) {
53
+ await (0, config_1.saveLocalApiKey)(apiKey);
54
+ console.log(chalk_1.default.green("API key saved to eventra.local.json (added to .gitignore)"));
55
+ }
50
56
  console.log("");
51
57
  console.log(chalk_1.default.gray("Run `eventra sync`"));
52
58
  }
package/dist/cli/send.js CHANGED
@@ -9,6 +9,8 @@ const inquirer_1 = __importDefault(require("inquirer"));
9
9
  const config_1 = require("../config/config");
10
10
  const package_json_1 = __importDefault(require("../../package.json"));
11
11
  const CLI_VERSION = package_json_1.default.version;
12
+ // Always trusted, no local approval needed — matches `eventra init`'s prompt default.
13
+ const DEFAULT_SEND_ENDPOINT = "https://api.eventra.dev/api/v1/cli/events";
12
14
  // retry/backoff tuning (mirrors SDK defaults)
13
15
  const MAX_ATTEMPTS = 4;
14
16
  const BASE_DELAY_MS = 500;
@@ -56,16 +58,40 @@ async function attemptDelivery(endpoint, apiKey, payload) {
56
58
  clearTimeout(timeout);
57
59
  }
58
60
  }
59
- async function send() {
61
+ async function send(opts = {}) {
60
62
  const config = await (0, config_1.loadConfig)();
61
- const endpoint = config?.endpoint || process.env.EVENTRA_ENDPOINT;
62
63
  if (!config) {
63
64
  console.log(chalk_1.default.red("eventra.json not found. Run 'eventra init'"));
64
65
  return;
65
66
  }
66
- let apiKey = config.apiKey;
67
- // ask api key
67
+ const endpoint = config.endpoint || process.env.EVENTRA_ENDPOINT || undefined;
68
+ // A non-default endpoint sourced from the committed eventra.json (not from
69
+ // EVENTRA_ENDPOINT, which already requires a local/CI env-var change) needs
70
+ // one-time local approval — otherwise a PR silently editing `endpoint` could
71
+ // redirect send (and the API key) to an attacker's server.
72
+ if (endpoint &&
73
+ endpoint !== DEFAULT_SEND_ENDPOINT &&
74
+ endpoint === config.endpoint &&
75
+ !process.env.EVENTRA_ENDPOINT) {
76
+ const trusted = await (0, config_1.getTrustedEndpoint)();
77
+ if (endpoint !== trusted) {
78
+ if (!opts.trustEndpoint) {
79
+ console.log(chalk_1.default.red(`Endpoint "${endpoint}" from eventra.json is not locally approved.`));
80
+ console.log(chalk_1.default.gray("If this is expected (e.g. a self-hosted ingest URL), approve it once with:"));
81
+ console.log(chalk_1.default.gray(" eventra send --trust-endpoint"));
82
+ return;
83
+ }
84
+ await (0, config_1.trustEndpoint)(endpoint);
85
+ console.log(chalk_1.default.green(`Endpoint "${endpoint}" approved and saved to eventra.local.json`));
86
+ }
87
+ }
88
+ let apiKey = await (0, config_1.resolveApiKey)(config);
89
+ // ask for the key only when interactive — never hang a CI/non-TTY run on a prompt
68
90
  if (!apiKey) {
91
+ if (!process.stdin.isTTY) {
92
+ console.log(chalk_1.default.red("API key required. Set the EVENTRA_API_KEY environment variable (recommended for CI) or run 'eventra init'."));
93
+ return;
94
+ }
69
95
  const answers = await inquirer_1.default.prompt([
70
96
  {
71
97
  type: "input",
@@ -74,11 +100,12 @@ async function send() {
74
100
  }
75
101
  ]);
76
102
  apiKey = answers.apiKey;
77
- await (0, config_1.saveConfig)({
78
- ...config,
79
- apiKey,
80
- });
81
- console.log(chalk_1.default.green("API key saved"));
103
+ if (apiKey) {
104
+ // Saved to the gitignored local secrets file, never to eventra.json —
105
+ // that file is meant to be committed for `eventra check` drift detection.
106
+ await (0, config_1.saveLocalApiKey)(apiKey);
107
+ console.log(chalk_1.default.green("API key saved to eventra.local.json (added to .gitignore)"));
108
+ }
82
109
  }
83
110
  // no events
84
111
  if (!config.events?.length) {
package/dist/cli/sync.js CHANGED
@@ -15,9 +15,10 @@ async function sync() {
15
15
  return;
16
16
  }
17
17
  console.log(chalk_1.default.blue("Scanning..."));
18
- const { engine } = await (0, projectScanner_1.scanProject)(config);
18
+ const { engine, plugins } = await (0, projectScanner_1.scanProject)(config);
19
19
  await (0, scanResults_1.persistScanResults)(config, engine);
20
20
  const events = engine.getAllEvents();
21
21
  const wrappers = engine.getAllFunctionWrappers();
22
22
  console.log(chalk_1.default.green(`Found ${events.length} events, ${wrappers.length} function wrappers`));
23
+ await plugins.runDynamicEventReporters(engine.getAllDynamicOccurrences());
23
24
  }
@@ -3,13 +3,22 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.CONFIG_NAME = void 0;
6
+ exports.LOCAL_CONFIG_NAME = exports.CONFIG_NAME = void 0;
7
7
  exports.normalizeConfig = normalizeConfig;
8
8
  exports.loadConfig = loadConfig;
9
9
  exports.saveConfig = saveConfig;
10
+ exports.resolveApiKey = resolveApiKey;
11
+ exports.saveLocalApiKey = saveLocalApiKey;
12
+ exports.getTrustedEndpoint = getTrustedEndpoint;
13
+ exports.trustEndpoint = trustEndpoint;
10
14
  const fs_extra_1 = __importDefault(require("fs-extra"));
11
15
  const path_1 = __importDefault(require("path"));
12
16
  exports.CONFIG_NAME = "eventra.json";
17
+ /**
18
+ * Secrets live here instead of `eventra.json`, since the latter is meant to be
19
+ * committed (CI diffs `events`/`functionWrappers` against it via `eventra check`).
20
+ */
21
+ exports.LOCAL_CONFIG_NAME = "eventra.local.json";
13
22
  const DEFAULT_INCLUDE = ["**/*.{ts,tsx,js,jsx}"];
14
23
  const DEFAULT_EXCLUDE = ["node_modules", "dist", ".next", ".git"];
15
24
  function normalizeConfig(config) {
@@ -52,3 +61,74 @@ async function saveConfig(config) {
52
61
  const configPath = path_1.default.join(process.cwd(), exports.CONFIG_NAME);
53
62
  await fs_extra_1.default.writeJSON(configPath, normalizeConfig(config), { spaces: 2 });
54
63
  }
64
+ async function readLocalSecrets() {
65
+ const localPath = path_1.default.join(process.cwd(), exports.LOCAL_CONFIG_NAME);
66
+ if (!(await fs_extra_1.default.pathExists(localPath)))
67
+ return {};
68
+ try {
69
+ const parsed = await fs_extra_1.default.readJSON(localPath);
70
+ return {
71
+ apiKey: typeof parsed?.apiKey === "string" && parsed.apiKey ? parsed.apiKey : undefined,
72
+ trustedEndpoint: typeof parsed?.trustedEndpoint === "string" && parsed.trustedEndpoint
73
+ ? parsed.trustedEndpoint
74
+ : undefined,
75
+ };
76
+ }
77
+ catch {
78
+ return {};
79
+ }
80
+ }
81
+ async function writeLocalSecrets(patch) {
82
+ const localPath = path_1.default.join(process.cwd(), exports.LOCAL_CONFIG_NAME);
83
+ const next = { ...(await readLocalSecrets()), ...patch };
84
+ const out = {};
85
+ if (next.apiKey)
86
+ out.apiKey = next.apiKey;
87
+ if (next.trustedEndpoint)
88
+ out.trustedEndpoint = next.trustedEndpoint;
89
+ await fs_extra_1.default.writeJSON(localPath, out, { spaces: 2 });
90
+ await ensureGitignored(exports.LOCAL_CONFIG_NAME);
91
+ }
92
+ async function ensureGitignored(entry) {
93
+ const gitignorePath = path_1.default.join(process.cwd(), ".gitignore");
94
+ let content = "";
95
+ if (await fs_extra_1.default.pathExists(gitignorePath)) {
96
+ content = await fs_extra_1.default.readFile(gitignorePath, "utf8");
97
+ if (content.split(/\r?\n/).some((line) => line.trim() === entry))
98
+ return;
99
+ }
100
+ const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
101
+ await fs_extra_1.default.appendFile(gitignorePath, `${needsLeadingNewline ? "\n" : ""}${entry}\n`);
102
+ }
103
+ /**
104
+ * Resolves the API key for outbound requests only — never persisted back onto
105
+ * the `EventraConfig` object, so it can't leak into `eventra.json` via `saveConfig`.
106
+ * Priority: `EVENTRA_API_KEY` env var > `eventra.local.json` (gitignored) >
107
+ * legacy inline `apiKey` in `eventra.json` (kept for backward compatibility only).
108
+ */
109
+ async function resolveApiKey(config) {
110
+ if (process.env.EVENTRA_API_KEY)
111
+ return process.env.EVENTRA_API_KEY;
112
+ const { apiKey } = await readLocalSecrets();
113
+ if (apiKey)
114
+ return apiKey;
115
+ return config.apiKey || undefined;
116
+ }
117
+ /** Persists an API key to the gitignored local secrets file, never to `eventra.json`. */
118
+ async function saveLocalApiKey(apiKey) {
119
+ await writeLocalSecrets({ apiKey });
120
+ }
121
+ /**
122
+ * The `endpoint` a locally-run `eventra send` last approved, trust-on-first-use
123
+ * style — a non-default `endpoint` sourced from the *committed* `eventra.json`
124
+ * must match this before it's used, so a PR silently changing it can't redirect
125
+ * `send` (and the API key with it) without a human approving it locally once.
126
+ */
127
+ async function getTrustedEndpoint() {
128
+ const { trustedEndpoint } = await readLocalSecrets();
129
+ return trustedEndpoint;
130
+ }
131
+ /** Records `endpoint` as locally approved (see `getTrustedEndpoint`). */
132
+ async function trustEndpoint(endpoint) {
133
+ await writeLocalSecrets({ trustedEndpoint: endpoint });
134
+ }
@@ -20,6 +20,7 @@ const loadPlugins_1 = require("../plugin/loadPlugins");
20
20
  const EMPTY_RESULT = () => ({
21
21
  events: new Set(),
22
22
  detectedFunctionWrappers: new Set(),
23
+ dynamicOccurrences: [],
23
24
  });
24
25
  class EventraEngine {
25
26
  compiler;
@@ -253,5 +254,12 @@ class EventraEngine {
253
254
  getScanResult(fileName) {
254
255
  return this.fileResults.get(this.normalize(fileName)) ?? EMPTY_RESULT();
255
256
  }
257
+ getAllDynamicOccurrences() {
258
+ const occurrences = [];
259
+ for (const result of this.fileResults.values()) {
260
+ occurrences.push(...result.dynamicOccurrences);
261
+ }
262
+ return occurrences.sort((a, b) => a.fileName.localeCompare(b.fileName) || a.line - b.line);
263
+ }
256
264
  }
257
265
  exports.EventraEngine = EventraEngine;
@@ -76,5 +76,6 @@ async function scanProject(config) {
76
76
  engine,
77
77
  results,
78
78
  files,
79
+ plugins,
79
80
  };
80
81
  }
package/dist/index.js CHANGED
@@ -72,8 +72,9 @@ program
72
72
  });
73
73
  program
74
74
  .command("send")
75
- .action(async () => {
75
+ .option("--trust-endpoint", "approve the current eventra.json endpoint for local use")
76
+ .action(async (opts) => {
76
77
  const { send } = await Promise.resolve().then(() => __importStar(require("./cli/send")));
77
- await send();
78
+ await send({ trustEndpoint: opts.trustEndpoint });
78
79
  });
79
80
  program.parse();
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerConsoleDynamicEventReporterPlugin = registerConsoleDynamicEventReporterPlugin;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const consoleDynamicEventReporter = {
9
+ name: "console",
10
+ report({ occurrences }) {
11
+ if (occurrences.length === 0) {
12
+ return;
13
+ }
14
+ console.log(chalk_1.default.blue("\nDynamic event names:"));
15
+ for (const occ of occurrences) {
16
+ const values = occ.resolvedValues.length > 0 ? ` (resolved: ${occ.resolvedValues.join(", ")})` : "";
17
+ console.log(chalk_1.default.yellow(`~ ${occ.fileName}:${occ.line} ${occ.calleeText}(...)${values}`));
18
+ }
19
+ },
20
+ };
21
+ /** Default reporter: prints unresolved/dynamic event-name call sites to the console (always enabled). */
22
+ function registerConsoleDynamicEventReporterPlugin(registry) {
23
+ registry.registerDynamicEventReporter(consoleDynamicEventReporter);
24
+ }
@@ -33,11 +33,25 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isTrustedPluginSpecifier = isTrustedPluginSpecifier;
36
37
  exports.createBuiltinPluginRegistry = createBuiltinPluginRegistry;
37
38
  exports.createPluginRegistry = createPluginRegistry;
38
39
  const external_1 = require("./adapters/external");
39
40
  const eventra_sdk_1 = require("./builtins/eventra-sdk");
41
+ const consoleDynamicEventReporter_1 = require("./builtins/consoleDynamicEventReporter");
40
42
  const registry_1 = require("./registry");
43
+ /**
44
+ * Only packages published under the `@eventra_dev` npm scope can ever be
45
+ * `import()`-ed as a plugin. `eventra.json` (and its `plugins` array) is meant
46
+ * to be committed to git, so anything else listed there is attacker-editable
47
+ * via a plain PR — restricting to a scope only the maintainers can publish to
48
+ * turns "arbitrary code execution via a one-line JSON edit" into "not possible
49
+ * without also compromising the @eventra_dev npm account."
50
+ */
51
+ const TRUSTED_PLUGIN_RE = /^@eventra_dev\/cli-plugin-[a-z0-9]+(?:-[a-z0-9]+)*$/;
52
+ function isTrustedPluginSpecifier(spec) {
53
+ return TRUSTED_PLUGIN_RE.test(spec);
54
+ }
41
55
  async function resolvePluginExport(mod) {
42
56
  let candidate = mod.default ?? mod;
43
57
  if (typeof candidate === "function") {
@@ -76,13 +90,21 @@ async function loadExternalPlugin(registry, spec) {
76
90
  function createBuiltinPluginRegistry() {
77
91
  const registry = new registry_1.PluginRegistry();
78
92
  (0, eventra_sdk_1.registerEventraSdkPlugin)(registry);
93
+ (0, consoleDynamicEventReporter_1.registerConsoleDynamicEventReporterPlugin)(registry);
79
94
  return registry;
80
95
  }
81
96
  /** Built-ins + plugins listed in eventra.json `plugins` array. */
82
97
  async function createPluginRegistry(config) {
83
98
  const registry = createBuiltinPluginRegistry();
84
99
  for (const spec of config.plugins ?? []) {
85
- await loadExternalPlugin(registry, spec);
100
+ const trimmed = spec.trim();
101
+ if (!trimmed)
102
+ continue;
103
+ if (!isTrustedPluginSpecifier(trimmed)) {
104
+ console.warn(`Eventra: skipping plugin "${trimmed}" — only official @eventra_dev/cli-plugin-* packages can be loaded.${pluginLoadHint(trimmed)}`);
105
+ continue;
106
+ }
107
+ await loadExternalPlugin(registry, trimmed);
86
108
  }
87
109
  return registry;
88
110
  }
@@ -8,6 +8,8 @@ const path_1 = __importDefault(require("path"));
8
8
  class PluginRegistry {
9
9
  preprocessors = [];
10
10
  sinkDetectors = [];
11
+ wrapperDetectors = [];
12
+ dynamicEventReporters = [];
11
13
  includePatterns = [];
12
14
  sourceToVirtuals = new Map();
13
15
  registerFilePreprocessor(preprocessor) {
@@ -22,6 +24,18 @@ class PluginRegistry {
22
24
  }
23
25
  this.sinkDetectors.push(detector);
24
26
  }
27
+ registerWrapperDetector(detector) {
28
+ if (this.wrapperDetectors.some((d) => d.name === detector.name)) {
29
+ throw new Error(`Wrapper detector already registered: ${detector.name}`);
30
+ }
31
+ this.wrapperDetectors.push(detector);
32
+ }
33
+ registerDynamicEventReporter(reporter) {
34
+ if (this.dynamicEventReporters.some((r) => r.name === reporter.name)) {
35
+ throw new Error(`Dynamic event reporter already registered: ${reporter.name}`);
36
+ }
37
+ this.dynamicEventReporters.push(reporter);
38
+ }
25
39
  registerIncludePattern(pattern) {
26
40
  const trimmed = pattern.trim();
27
41
  if (!trimmed)
@@ -36,6 +50,12 @@ class PluginRegistry {
36
50
  getSinkDetectors() {
37
51
  return this.sinkDetectors;
38
52
  }
53
+ getWrapperDetectors() {
54
+ return this.wrapperDetectors;
55
+ }
56
+ getDynamicEventReporters() {
57
+ return this.dynamicEventReporters;
58
+ }
39
59
  getFilePreprocessors() {
40
60
  return this.preprocessors;
41
61
  }
@@ -71,6 +91,20 @@ class PluginRegistry {
71
91
  }
72
92
  return null;
73
93
  }
94
+ detectWrapper(context) {
95
+ for (const detector of this.wrapperDetectors) {
96
+ const wrapper = detector.detect(context);
97
+ if (wrapper) {
98
+ return wrapper;
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+ async runDynamicEventReporters(occurrences) {
104
+ for (const reporter of this.dynamicEventReporters) {
105
+ await reporter.report({ occurrences });
106
+ }
107
+ }
74
108
  normalizePath(fileName) {
75
109
  return path_1.default.resolve(fileName).replace(/\\/g, "/");
76
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventra_dev/eventra-cli",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Static analytics event extraction CLI powered by the TypeScript compiler API",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",