@revturbine/cli 0.17.1 → 0.18.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.
Files changed (3) hide show
  1. package/README.md +45 -1
  2. package/dist/cli.js +301 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -63,7 +63,7 @@ Commands that read a config name the version explicitly — there is no default:
63
63
 
64
64
  | Command | What it does |
65
65
  |---|---|
66
- | `init` (alias `create`) | Scaffold RevTurbine into this app: detect the package manager and stack, install the SDK, pin the CLI exactly, drop a starter Playbook, and install the Agent Skills. In a directory with no `package.json` it offers to start a new project (`--yes` to skip the prompt); `--dir`, `--dry-run`, `--no-skills`, `--json`. Runs the invoked CLI even inside a repo that pins a different one — setup establishes the pin, so it never delegates. |
66
+ | `init` (alias `create`) | Set up RevTurbine while preserving existing integrations: install missing packages, pin the CLI exactly, create a starter for a fresh integration, and install skills for one agent. Use `--scaffold` to create a missing root starter explicitly; existing files are never overwritten. `--agent claude-code\|cursor\|codex` overrides environment detection; an unknown agent skips skills and prints the selection command. Also supports `--dir`, `--dry-run`, `--no-skills`, `--json`. With no `package.json`, offers a new project (`--yes` for noninteractive creation). Runs the invoked CLI even inside a repo that pins a different one. |
67
67
  | `signup` | Create an account headlessly: email + password, then an emailed one-time code to verify, then a token is stored. |
68
68
  | `login` / `logout` | Device-flow auth; tokens stored at `~/.revturbine/credentials.json` (mode 0600). |
69
69
  | `whoami` | The resolved instance, tenant, credentials source, and whether the stored token works. |
@@ -83,10 +83,54 @@ Commands that read a config name the version explicitly — there is no default:
83
83
  | `evaluate` | Run a config version's placement/entitlement decisions locally for a user context. Use `--slot` with optional `--component-type`; deprecated `--surface-type` remains an alias. |
84
84
  | `generate types` | Generate a TypeScript module of typed Playbook handles from any config version — `Entitlements` (namespaced by type, with the `EntitlementHandle` union for type-safe `can()`/`gate()`/`checkEntitlement()` call sites), plus `Plans`, `Segments`, `SurfaceTemplates`, and `UiPathActionTypes`. Const objects + literal-union types (erasable — no enums). `--out <path>` writes the file; the generated header records the exact command to regenerate it. `--json` for the raw handle map. |
85
85
  | `analytics catalog\|templates\|views\|view\|create\|preview\|query` | Work with the hosted Semantic Catalog and canonical analytics-view documents. Create and preview pass the document through unchanged to the same server contract used by the web editor and MCP tools; preview remains subject to the server's query limits. |
86
+ | `ingest-keys create` (alias `mint`) | Mint a tenant-bound public ingest token for browser SDK use. Requires one or more `--origin` values; optional `--ip` restrictions. The full token is returned once. `ingest-keys list` shows ids/previews and `ingest-keys revoke <id>` invalidates one. |
87
+ | `events track` | Send analytics events to the ingest pipeline with your own login — no ingest key and no browser origin required. Takes `--file <path>` (JSON array, `{ "events": [...] }` envelope, or NDJSON) or `--event <json>`. Batches over 500 events are chunked automatically. Reports accepted, quarantined, rejected and dropped counts separately. |
88
+
89
+ `init` preserves declared SDK/CLI versions and application code. It skips a new root
90
+ Playbook when the SDK is already declared in `dependencies` or `devDependencies`,
91
+ or project source imports the SDK, or a non-root canonical Playbook is found.
92
+ Detection ignores hidden directories, symlinks, dependencies and build outputs.
93
+ If the integration is intentionally incomplete, `--scaffold` creates only the missing
94
+ `revturbine.playbook.json`; it never replaces an existing one. `--dry-run --json`
95
+ reports `playbook: "present"`, `"skipped"`, or the filename to create, and
96
+ `skills_agent` identifies the single installer target (or `null` when skipped).
97
+ `--no-skills` takes precedence over a detected or explicitly selected valid agent.
86
98
 
87
99
  `--json` on read commands emits machine-readable output. Results go to stdout,
88
100
  diagnostics to stderr.
89
101
 
102
+ ### Send analytics events
103
+
104
+ ```bash
105
+ revturbine login
106
+ revturbine events track --file ./events.ndjson
107
+
108
+ # or a single event inline
109
+ revturbine events track --event '{"environment_id":"production","user_id":"u-1","account_id":"a-1","event_name":"product_used","event_ts":"2026-09-10T00:00:00.000Z"}'
110
+ ```
111
+
112
+ Every event needs `environment_id`, `user_id`, `account_id`, `event_name` and
113
+ `event_ts`; anything else you include is carried through. Local validation
114
+ reports every malformed event at once and sends nothing until they are fixed.
115
+
116
+ This authenticates with your device-auth token rather than an ingest key, so
117
+ it works from a terminal or CI where there is no browser origin to allowlist.
118
+ Quarantined and rejected counts are reported separately from accepted — a
119
+ load is not clean merely because the server returned `202`.
120
+
121
+ ### Mint a publishable ingest token
122
+
123
+ ```bash
124
+ revturbine login
125
+ revturbine ingest-keys mint --origin https://app.example.com --json
126
+ # Store the once-only token, then use the returned id when it is no longer needed:
127
+ revturbine ingest-keys revoke <ingest-key-id> --yes
128
+ ```
129
+
130
+ Public ingest tokens are intentionally embeddable but remain tenant-bound,
131
+ origin-restricted, and optionally IP-restricted. They cannot mint other tokens
132
+ or access private control-plane APIs.
133
+
90
134
  ## Exit-code classes
91
135
 
92
136
  ```
package/dist/cli.js CHANGED
@@ -10981,6 +10981,130 @@ function formatIngestKeyLine(key2) {
10981
10981
  return `${key2.id} ${key2.tokenPreview} origins: ${origins}${ips}${last}`;
10982
10982
  }
10983
10983
 
10984
+ // src/lib/events-ingest.ts
10985
+ var MAX_EVENTS_PER_BATCH = 500;
10986
+ var REQUIRED_FIELDS = [
10987
+ "environment_id",
10988
+ "user_id",
10989
+ "account_id",
10990
+ "event_name",
10991
+ "event_ts"
10992
+ ];
10993
+ function parseEventBatch(text) {
10994
+ const trimmed = text.trim();
10995
+ if (trimmed === "") return { events: [], errors: ["input is empty"] };
10996
+ const errors = [];
10997
+ let raw;
10998
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
10999
+ let parsed;
11000
+ try {
11001
+ parsed = JSON.parse(trimmed);
11002
+ } catch {
11003
+ return parseNdjson(trimmed);
11004
+ }
11005
+ if (Array.isArray(parsed)) {
11006
+ raw = parsed;
11007
+ } else if (parsed !== null && typeof parsed === "object" && Array.isArray(parsed.events)) {
11008
+ raw = parsed.events;
11009
+ } else if (parsed !== null && typeof parsed === "object") {
11010
+ raw = [parsed];
11011
+ } else {
11012
+ return { events: [], errors: ["input must be an object, an array, or an { events: [...] } envelope"] };
11013
+ }
11014
+ } else {
11015
+ return parseNdjson(trimmed);
11016
+ }
11017
+ const events2 = validateAll(raw, errors);
11018
+ return { events: events2, errors };
11019
+ }
11020
+ function parseNdjson(text) {
11021
+ const errors = [];
11022
+ const raw = [];
11023
+ text.split("\n").forEach((line, i) => {
11024
+ const t = line.trim();
11025
+ if (t === "") return;
11026
+ try {
11027
+ raw.push(JSON.parse(t));
11028
+ } catch {
11029
+ errors.push(`line ${i + 1}: not valid JSON`);
11030
+ }
11031
+ });
11032
+ const events2 = validateAll(raw, errors);
11033
+ return { events: events2, errors };
11034
+ }
11035
+ function validateAll(raw, errors) {
11036
+ const events2 = [];
11037
+ raw.forEach((item, i) => {
11038
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
11039
+ errors.push(`event ${i}: not a JSON object`);
11040
+ return;
11041
+ }
11042
+ const bag = item;
11043
+ const missing = REQUIRED_FIELDS.filter(
11044
+ (f) => typeof bag[f] !== "string" || bag[f].trim() === ""
11045
+ );
11046
+ if (missing.length > 0) {
11047
+ errors.push(`event ${i}: missing or empty ${missing.join(", ")}`);
11048
+ return;
11049
+ }
11050
+ events2.push(bag);
11051
+ });
11052
+ return events2;
11053
+ }
11054
+ function chunkEvents(events2, size = MAX_EVENTS_PER_BATCH) {
11055
+ const limit = Math.max(1, Math.min(size, MAX_EVENTS_PER_BATCH));
11056
+ const out = [];
11057
+ for (let i = 0; i < events2.length; i += limit) out.push(events2.slice(i, i + limit));
11058
+ return out;
11059
+ }
11060
+ function readResult(json) {
11061
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
11062
+ return {
11063
+ accepted: num(json.accepted),
11064
+ quarantined: num(json.quarantined),
11065
+ dropped_unscoped: num(json.dropped_unscoped),
11066
+ rejected: Array.isArray(json.rejected) ? json.rejected : [],
11067
+ request_id: typeof json.request_id === "string" ? json.request_id : null
11068
+ };
11069
+ }
11070
+ async function postBatch(baseUrl, headers, events2, fetchImpl = fetch) {
11071
+ const res = await fetchImpl(`${baseUrl}/api/track`, {
11072
+ method: "POST",
11073
+ headers,
11074
+ body: JSON.stringify({ events: events2 })
11075
+ });
11076
+ const json = await res.json().catch(() => ({}));
11077
+ if (!res.ok) return { ok: false, status: res.status, result: null, error: json };
11078
+ return { ok: true, status: res.status, result: readResult(json), error: {} };
11079
+ }
11080
+ function summarize(results, eventCount) {
11081
+ return {
11082
+ batches: results.length,
11083
+ // @revturbine-graph gref:3f4d51af0a0af98edea0
11084
+ events: eventCount,
11085
+ accepted: results.reduce((n, r) => n + r.accepted, 0),
11086
+ quarantined: results.reduce((n, r) => n + r.quarantined, 0),
11087
+ dropped_unscoped: results.reduce((n, r) => n + r.dropped_unscoped, 0),
11088
+ rejected: results.reduce((n, r) => n + r.rejected.length, 0),
11089
+ results
11090
+ };
11091
+ }
11092
+ function formatSummary(summary) {
11093
+ const lines = [
11094
+ `ingested ${summary.accepted}/${summary.events} event(s) in ${summary.batches} batch(es)`
11095
+ ];
11096
+ if (summary.quarantined > 0) {
11097
+ lines.push(` quarantined: ${summary.quarantined} (payload failed its platform contract)`);
11098
+ }
11099
+ if (summary.rejected > 0) {
11100
+ lines.push(` rejected: ${summary.rejected} (malformed on the wire)`);
11101
+ }
11102
+ if (summary.dropped_unscoped > 0) {
11103
+ lines.push(` dropped (unscoped): ${summary.dropped_unscoped} (simulation rows without a dataset id)`);
11104
+ }
11105
+ return lines.join("\n");
11106
+ }
11107
+
10984
11108
  // src/lib/delegate.ts
10985
11109
  var DELEGATION_ENV = "REVTURBINE_DELEGATED";
10986
11110
  var NO_LOCAL_FLAG = "--no-local";
@@ -11116,6 +11240,29 @@ function detectStack(signals) {
11116
11240
  }
11117
11241
  var SDK_PACKAGE = "@revturbine/sdk";
11118
11242
  var CLI_PACKAGE = "@revturbine/cli";
11243
+ function declaredSdk(manifest) {
11244
+ return Boolean(manifest.dependencies?.[SDK_PACKAGE] || manifest.devDependencies?.[SDK_PACKAGE]);
11245
+ }
11246
+ function integrationFileReason(file, content) {
11247
+ if (file.endsWith("revturbine.playbook.json")) return `existing Playbook (${file})`;
11248
+ if (/\.[cm]?[jt]sx?$/.test(file) && /['"]@revturbine\/sdk(?:\/[^'"]*)?['"]/.test(content)) {
11249
+ return `SDK integration (${file})`;
11250
+ }
11251
+ if (file.endsWith(".json")) {
11252
+ try {
11253
+ const value = JSON.parse(content);
11254
+ if (value && typeof value === "object" && "artifact_type" in value && value.artifact_type === "playbook") {
11255
+ return `existing Playbook (${file})`;
11256
+ }
11257
+ } catch {
11258
+ }
11259
+ }
11260
+ return void 0;
11261
+ }
11262
+ function planStarter(params) {
11263
+ if (params.rootExists) return "present";
11264
+ return params.integrationReason && !params.scaffold ? "skipped" : "create";
11265
+ }
11119
11266
  function projectNameFromDir(dirName) {
11120
11267
  const slug = dirName.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-_.]+|[-_.]+$/g, "");
11121
11268
  return slug || "my-app";
@@ -11208,20 +11355,34 @@ function validatePlaybook(config) {
11208
11355
  // src/lib/init-skills.ts
11209
11356
  var SKILLS_SOURCE = "revt-eng/revturbine-skills";
11210
11357
  var START_HERE_SKILL = "revturbine-start-here";
11211
- function detectHarness(env) {
11212
- if (env["CLAUDECODE"] || env["CLAUDE_CODE_SESSION_ID"] || env["CLAUDE_CODE_ENTRYPOINT"]) {
11213
- return { label: "Claude Code", invocation: `Run the skill: /${START_HERE_SKILL}` };
11358
+ var SUPPORTED_AGENTS = ["claude-code", "cursor", "codex"];
11359
+ function isAgentId(value) {
11360
+ return SUPPORTED_AGENTS.some((id) => id === value);
11361
+ }
11362
+ function detectHarness(env, explicit) {
11363
+ let agentId = explicit;
11364
+ if (!agentId) {
11365
+ if (env["CLAUDECODE"] || env["CLAUDE_CODE_SESSION_ID"] || env["CLAUDE_CODE_ENTRYPOINT"]) agentId = "claude-code";
11366
+ else if (env["CURSOR_TRACE_ID"] || env["CURSOR"]) agentId = "cursor";
11367
+ else if (env["CODEX_THREAD_ID"]) agentId = "codex";
11214
11368
  }
11215
- if (env["CURSOR_TRACE_ID"] || env["CURSOR"]) {
11216
- return { label: "Cursor", invocation: `Mention the skill: @${START_HERE_SKILL}` };
11369
+ if (agentId === "claude-code") {
11370
+ return { agentId, label: "Claude Code", invocation: `Run the skill: /${START_HERE_SKILL}` };
11371
+ }
11372
+ if (agentId === "cursor") {
11373
+ return { agentId, label: "Cursor", invocation: `Mention the skill: @${START_HERE_SKILL}` };
11374
+ }
11375
+ if (agentId === "codex") {
11376
+ return { agentId, label: "Codex", invocation: `Run the skill: $${START_HERE_SKILL}` };
11217
11377
  }
11218
11378
  return {
11379
+ agentId: null,
11219
11380
  label: null,
11220
11381
  invocation: `Ask your coding agent to run the "${START_HERE_SKILL}" skill.`
11221
11382
  };
11222
11383
  }
11223
- function skillsAddArgs(source = SKILLS_SOURCE) {
11224
- return ["--yes", "skills", "add", source, "-y", "--copy"];
11384
+ function skillsAddArgs(agent) {
11385
+ return ["--yes", "skills", "add", SKILLS_SOURCE, "-y", "--copy", "-a", agent];
11225
11386
  }
11226
11387
  function finalOutputLines(params) {
11227
11388
  const lines = ["", "RevTurbine is set up.", ""];
@@ -11235,12 +11396,14 @@ function finalOutputLines(params) {
11235
11396
  lines.push(" \u2713 Installed the Agent Skills");
11236
11397
  } else if (params.skills === "skipped") {
11237
11398
  lines.push(" \u2022 Skipped the Agent Skills (--no-skills)");
11399
+ } else if (params.skills === "unknown") {
11400
+ lines.push(" \u2022 Agent Skills not installed \u2014 select a target with --agent");
11238
11401
  } else {
11239
11402
  lines.push(" \u26A0 Agent Skills not installed \u2014 see above to add them by hand");
11240
11403
  }
11241
11404
  lines.push("", "Next step:");
11242
11405
  lines.push(` ${params.harness.invocation}`);
11243
- if (params.harness.label) lines.push(` (detected ${params.harness.label})`);
11406
+ if (params.harness.label) lines.push(` (${params.harness.label})`);
11244
11407
  lines.push("", "Your setup path:");
11245
11408
  lines.push(" create playbook \u2192 app wiring \u2192 billing \u2192 verify \u2192 launch");
11246
11409
  lines.push("", "Docs: https://revturbine.com/docs", "");
@@ -11878,7 +12041,8 @@ Command groups:
11878
12041
  Inspect status, history, preview, evaluate
11879
12042
  Codegen generate types
11880
12043
  Analytics analytics catalog|templates|views|view|create|preview|query
11881
- Keys ingest-keys create|list|revoke
12044
+ Keys ingest-keys create (alias: mint)|list|revoke
12045
+ Events events track
11882
12046
 
11883
12047
  Version selectors (no defaults \u2014 a command that reads a config requires one):
11884
12048
  <file> a local Playbook file (positional, or --file <path>)
@@ -11889,6 +12053,10 @@ Version selectors (no defaults \u2014 a command that reads a config requires one
11889
12053
  Common workflows:
11890
12054
  # Add RevTurbine to an app (same routine as \`npm create revturbine@latest\`)
11891
12055
  revturbine init
12056
+ # Preserve an existing integration; select one skills target explicitly
12057
+ revturbine init --agent codex
12058
+ # Opt into a missing root starter; existing Playbooks are never overwritten
12059
+ revturbine init --scaffold --no-skills
11892
12060
 
11893
12061
  # Author, validate, and ship against the default instance (revturbine.com/app)
11894
12062
  revturbine login
@@ -11908,6 +12076,14 @@ Common workflows:
11908
12076
  revturbine history
11909
12077
  revturbine restore <playbook-version-id> --launch
11910
12078
 
12079
+ # Mint an origin-restricted browser token (shown once), then revoke it
12080
+ revturbine ingest-keys mint --origin https://app.example.com --json
12081
+
12082
+ # Send analytics events with your own login (no ingest key, no browser origin)
12083
+ revturbine events track --file ./events.ndjson
12084
+ revturbine ingest-keys list
12085
+ revturbine ingest-keys revoke <ingest-key-id> --yes
12086
+
11911
12087
  Exit-code classes: 0 ok \xB7 1 unexpected \xB7 2 usage \xB7 3 auth \xB7 4 validation
11912
12088
  blocked \xB7 5 conflict/stale \xB7 6 network \xB7 7 server error.
11913
12089
 
@@ -11936,7 +12112,26 @@ function runInstall(manager, args, cwd) {
11936
12112
  child.on("close", (code) => resolve(code ?? 1));
11937
12113
  });
11938
12114
  }
11939
- program.command("init").alias("create").description("Scaffold RevTurbine into this app: detect the stack, install the SDK, pin the CLI, drop a starter Playbook, and install the Agent Skills. Offers to start a new project when the directory has no package.json.").option("-d, --dir <path>", "Target directory (defaults to the current directory)").option("-y, --yes", "Skip prompts \u2014 create a new project non-interactively when the directory has none").option("--dry-run", "Report what would be installed without running the package manager").option("--no-skills", "Do not install the RevTurbine Agent Skills").option("--json", "Emit the scaffold plan as JSON").action(async (opts) => {
12115
+ function findIntegration(dir, root = dir) {
12116
+ if (!existsSync2(dir)) return void 0;
12117
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
12118
+ if (entry.name.startsWith(".") || ["node_modules", "dist", "build", "coverage", "target"].includes(entry.name)) continue;
12119
+ const file = path2.join(dir, entry.name);
12120
+ if (entry.isDirectory()) {
12121
+ const reason = findIntegration(file, root);
12122
+ if (reason) return reason;
12123
+ } else if (entry.isFile() && /\.(?:[cm]?[jt]sx?|json)$/.test(entry.name) && !entry.name.endsWith("lock.json")) {
12124
+ const reason = integrationFileReason(path2.relative(root, file), readFileSync2(file, "utf8"));
12125
+ if (reason) return reason;
12126
+ }
12127
+ }
12128
+ return void 0;
12129
+ }
12130
+ program.command("init").alias("create").description("Set up RevTurbine while preserving existing integrations: install missing packages, create a starter for a fresh integration, and install skills for one selected agent. Offers to start a new project when no package.json exists.").option("-d, --dir <path>", "Target directory (defaults to the current directory)").option("-y, --yes", "Skip prompts \u2014 create a new project non-interactively when the directory has none").option("--dry-run", "Report what would be installed without running the package manager").option("--no-skills", "Do not install the RevTurbine Agent Skills").option("--scaffold", "Create a missing root starter Playbook even when an integration exists; never overwrite a file").option("--agent <id>", `Install skills for one agent (${SUPPORTED_AGENTS.join(", ")}); overrides environment detection`).option("--json", "Emit the scaffold plan as JSON").action(async (opts) => {
12131
+ if (opts.agent !== void 0 && !isAgentId(opts.agent)) {
12132
+ fail(EXIT.USAGE, `unknown skills agent '${opts.agent}' \u2014 choose ${SUPPORTED_AGENTS.join(", ")}.`);
12133
+ }
12134
+ const harness = detectHarness(process.env, opts.agent !== void 0 && isAgentId(opts.agent) ? opts.agent : void 0);
11940
12135
  const dir = path2.resolve(opts.dir ?? process.cwd());
11941
12136
  const manifestPath = path2.join(dir, "package.json");
11942
12137
  let manifest;
@@ -11974,8 +12169,14 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
11974
12169
  for (const note of plan.skipped) diag(`\u2022 ${note}`);
11975
12170
  const playbookPath = path2.join(dir, STARTER_PLAYBOOK_FILENAME);
11976
12171
  const playbookExists = existsSync2(playbookPath);
11977
- if (playbookExists) diag(`\u2022 ${STARTER_PLAYBOOK_FILENAME} already present \u2014 left as-is`);
11978
- const installSkills = opts.skills !== false;
12172
+ const integrationReason = playbookExists || opts.scaffold ? void 0 : declaredSdk(manifest) ? "@revturbine/sdk already declared" : findIntegration(dir);
12173
+ const starter = planStarter({ rootExists: playbookExists, integrationReason, scaffold: opts.scaffold === true });
12174
+ if (starter === "present") diag(`\u2022 ${STARTER_PLAYBOOK_FILENAME} already present \u2014 left as-is`);
12175
+ if (starter === "skipped") diag(`\u2022 Skipping starter Playbook: ${integrationReason}. Use --scaffold to create a missing ${STARTER_PLAYBOOK_FILENAME}.`);
12176
+ const skillsArgs = opts.skills !== false && harness.agentId ? skillsAddArgs(harness.agentId) : null;
12177
+ if (opts.skills !== false && !harness.agentId) {
12178
+ diag(`\u2022 Skipping Agent Skills: no supported agent detected. Choose a target, for example: revturbine init --agent codex (${SUPPORTED_AGENTS.join(", ")}).`);
12179
+ }
11979
12180
  if (opts.json) {
11980
12181
  emit(
11981
12182
  {
@@ -11985,8 +12186,9 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
11985
12186
  stack,
11986
12187
  install: plan.install,
11987
12188
  skipped: plan.skipped,
11988
- playbook: playbookExists ? "present" : STARTER_PLAYBOOK_FILENAME,
11989
- skills: installSkills ? SKILLS_SOURCE : "skipped"
12189
+ playbook: starter === "create" ? STARTER_PLAYBOOK_FILENAME : starter,
12190
+ skills: skillsArgs ? SKILLS_SOURCE : "skipped",
12191
+ skills_agent: skillsArgs ? harness.agentId : null
11990
12192
  },
11991
12193
  true
11992
12194
  );
@@ -11996,9 +12198,9 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
11996
12198
  for (const step of plan.install) {
11997
12199
  diag(`would run: ${manager.name} ${installArgs(manager.name, step).join(" ")}`);
11998
12200
  }
11999
- if (!playbookExists) diag(`would write: ${STARTER_PLAYBOOK_FILENAME}`);
12000
- if (installSkills) diag(`would run: npx ${skillsAddArgs().join(" ")}`);
12001
- if (plan.install.length === 0 && playbookExists) diag("\u2713 Already set up \u2014 nothing to do.");
12201
+ if (starter === "create") diag(`would write: ${STARTER_PLAYBOOK_FILENAME}`);
12202
+ if (skillsArgs) diag(`would run: npx ${skillsArgs.join(" ")}`);
12203
+ if (plan.install.length === 0 && starter !== "create" && !skillsArgs) diag("\u2713 No setup changes planned.");
12002
12204
  return;
12003
12205
  }
12004
12206
  for (const step of plan.install) {
@@ -12015,7 +12217,7 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
12015
12217
  if (plan.install.length > 0) {
12016
12218
  diag(`\u2713 Installed the RevTurbine SDK and CLI (CLI pinned to ${pkgVersion})`);
12017
12219
  }
12018
- const playbookAdded = !playbookExists;
12220
+ const playbookAdded = starter === "create";
12019
12221
  if (playbookAdded) {
12020
12222
  const check = validatePlaybook(STARTER_PLAYBOOK);
12021
12223
  if (!check.ok) {
@@ -12025,13 +12227,13 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
12025
12227
  );
12026
12228
  }
12027
12229
  writeFileSync2(playbookPath, `${JSON.stringify(STARTER_PLAYBOOK, null, 2)}
12028
- `, "utf8");
12230
+ `, { encoding: "utf8", flag: "wx" });
12029
12231
  diag(`\u2713 Added a starter playbook (${STARTER_PLAYBOOK_FILENAME} \u2014 local mode, no account needed)`);
12030
12232
  }
12031
- let skillsOutcome = installSkills ? "installed" : "skipped";
12032
- if (installSkills) {
12233
+ let skillsOutcome = skillsArgs ? "installed" : opts.skills === false ? "skipped" : "unknown";
12234
+ if (skillsArgs) {
12033
12235
  diag("Installing the RevTurbine Agent Skills (npx skills)\u2026");
12034
- const args = skillsAddArgs();
12236
+ const args = skillsArgs;
12035
12237
  let code;
12036
12238
  try {
12037
12239
  code = await runInstall("npx", args, dir);
@@ -12045,7 +12247,7 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
12045
12247
  diag("\u26A0 Could not install the Agent Skills automatically. Add them by hand:");
12046
12248
  diag(` npx ${args.join(" ")}`);
12047
12249
  }
12048
- } else {
12250
+ } else if (opts.skills === false) {
12049
12251
  diag("\u2022 Skipping the Agent Skills (--no-skills)");
12050
12252
  }
12051
12253
  if (!opts.json) {
@@ -12055,7 +12257,7 @@ program.command("init").alias("create").description("Scaffold RevTurbine into th
12055
12257
  cliVersion: pkgVersion,
12056
12258
  playbookAdded,
12057
12259
  skills: skillsOutcome,
12058
- harness: detectHarness(process.env)
12260
+ harness
12059
12261
  });
12060
12262
  process.stdout.write(`${lines.join("\n")}
12061
12263
  `);
@@ -12579,7 +12781,18 @@ analyticsConnectionOptions(
12579
12781
  emitAnalyticsResult(conn, "analytics query", result, opts.json);
12580
12782
  });
12581
12783
  var ingestKeys = program.command("ingest-keys").description("Manage public ingest keys (embeddable SDK telemetry credentials) for the tenant.");
12582
- ingestKeys.command("create").description("Mint a public ingest key. The full token is printed ONCE and cannot be retrieved again.").requiredOption("--origin <url...>", "Allowed browser origin(s), e.g. https://app.example.com (repeatable; at least one required)").option("--ip <cidr...>", "Optional IP / CIDR allowlist (empty = no IP restriction)").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--json", "Machine-readable output (token on stdout for scripted capture)").action(async (opts) => {
12784
+ ingestKeys.command("create").alias("mint").description("Mint a public ingest key. The full token is printed ONCE and cannot be retrieved again.").requiredOption("--origin <url...>", "Allowed browser origin(s), e.g. https://app.example.com (repeatable; at least one required)").option("--ip <cidr...>", "Optional IP / CIDR allowlist (empty = no IP restriction)").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--json", "Machine-readable output (token on stdout for scripted capture)").addHelpText(
12785
+ "after",
12786
+ [
12787
+ "",
12788
+ "Examples:",
12789
+ " revturbine ingest-keys mint --origin https://app.example.com",
12790
+ " revturbine ingest-keys create --origin https://app.example.com --ip 203.0.113.0/24 --json",
12791
+ "",
12792
+ "The full token is returned only by this command. Store it securely or",
12793
+ "capture --json output, and revoke it by id when the integration or test ends."
12794
+ ].join("\n")
12795
+ ).action(async (opts) => {
12583
12796
  const conn = connect(opts.url, opts.tenantId);
12584
12797
  const result = await createIngestKey(conn.url, conn.headers, { originAllowlist: opts.origin, ipAllowlist: opts.ip });
12585
12798
  if (!result.ok || !result.key) httpFail(conn, "ingest-keys create", result.status, result.error);
@@ -12617,6 +12830,69 @@ ingestKeys.command("revoke").description("Revoke a public ingest key by id. The
12617
12830
  if (!result.ok) httpFail(conn, "ingest-keys revoke", result.status);
12618
12831
  diag(`\u2713 Revoked ingest key ${id}.`);
12619
12832
  });
12833
+ var events = program.command("events").description("Send analytics events to the RevTurbine ingest pipeline.");
12834
+ events.command("track").description("Ingest a batch of analytics events from a file or inline JSON.").option("-f, --file <path>", 'Batch file: JSON array, { "events": [...] } envelope, or NDJSON').option("-e, --event <json>", "A single event as inline JSON").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (selects among your tenants; defaults to the stored token tenant)").option("--json", "Machine-readable per-batch results").addHelpText(
12835
+ "after",
12836
+ [
12837
+ "",
12838
+ "Examples:",
12839
+ " revturbine events track --file ./events.ndjson",
12840
+ " revturbine events track --file ./batch.json --json",
12841
+ ` revturbine events track --event '{"environment_id":"production","user_id":"u1","account_id":"a1","event_name":"product_used","event_ts":"2026-09-10T00:00:00.000Z"}'`,
12842
+ "",
12843
+ `Every event needs environment_id, user_id, account_id, event_name and`,
12844
+ `event_ts. Batches larger than ${MAX_EVENTS_PER_BATCH} are chunked automatically.`,
12845
+ "",
12846
+ "This authenticates with your device-auth token, so it needs no ingest key",
12847
+ "and no browser origin. Quarantined and rejected counts are always reported",
12848
+ 'separately from accepted \u2014 a load is not "clean" because it returned 202.'
12849
+ ].join("\n")
12850
+ ).action(async (opts) => {
12851
+ if (!opts.file && !opts.event) fail(EXIT.USAGE, "events track needs --file <path> or --event <json>");
12852
+ if (opts.file && opts.event) fail(EXIT.USAGE, "events track takes --file or --event, not both");
12853
+ let text;
12854
+ if (opts.file) {
12855
+ try {
12856
+ text = readFileSync2(path2.resolve(opts.file), "utf8");
12857
+ } catch (err) {
12858
+ fail(EXIT.USAGE, `cannot read ${opts.file}: ${err.message}`);
12859
+ }
12860
+ } else {
12861
+ text = opts.event;
12862
+ }
12863
+ const parsed = parseEventBatch(text);
12864
+ if (parsed.errors.length > 0) {
12865
+ for (const e of parsed.errors) diagRaw(` ${e}`);
12866
+ fail(EXIT.VALIDATION, `${parsed.errors.length} event(s) failed local validation; nothing was sent`);
12867
+ }
12868
+ if (parsed.events.length === 0) fail(EXIT.VALIDATION, "no events to send");
12869
+ const conn = connect(opts.url, opts.tenantId);
12870
+ const chunks = chunkEvents(parsed.events);
12871
+ const results = [];
12872
+ for (const [i, chunk] of chunks.entries()) {
12873
+ if (chunks.length > 1 && !opts.json) {
12874
+ diag(`sending batch ${i + 1}/${chunks.length} (${chunk.length} event(s))\u2026`);
12875
+ }
12876
+ let posted;
12877
+ try {
12878
+ posted = await postBatch(conn.url, conn.headers, chunk);
12879
+ } catch (err) {
12880
+ if (isNetworkError(err)) {
12881
+ fail(EXIT.NETWORK, `network failure reaching ${conn.url}: ${err.message}`);
12882
+ }
12883
+ throw err;
12884
+ }
12885
+ if (!posted.ok || !posted.result) {
12886
+ if (results.length > 0) {
12887
+ diag(`${summarize(results, parsed.events.length).accepted} event(s) were accepted before this failure.`);
12888
+ }
12889
+ httpFail(conn, `events track (batch ${i + 1}/${chunks.length})`, posted.status, posted.error);
12890
+ }
12891
+ results.push(posted.result);
12892
+ }
12893
+ const summary = summarize(results, parsed.events.length);
12894
+ emit(summary, !!opts.json, formatSummary(summary));
12895
+ });
12620
12896
  var COMMAND_EXAMPLES = {
12621
12897
  download: [
12622
12898
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revturbine/cli",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "revturbine — validate RevTurbine Playbooks and ship them to a RevTurbine instance through the playbook-version lifecycle (draft → Release).",
5
5
  "license": "MIT",
6
6
  "repository": {