@prisma-next/cli-telemetry 0.12.0 → 0.13.0-dev.10

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.
@@ -211,4 +211,4 @@ async function buildTelemetryEventFromProcess(payload) {
211
211
  //#endregion
212
212
  export { loadProjectConfig as n, buildTelemetryEventFromProcess as t };
213
213
 
214
- //# sourceMappingURL=enrich-CGZ8Za8Y.mjs.map
214
+ //# sourceMappingURL=enrich-BhHLLBfL.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"enrich-CGZ8Za8Y.mjs","names":[],"sources":["../src/detect-agent.ts","../src/enrich.ts"],"sourcesContent":["/**\n * Best-effort identification of AI coding-agent sessions from an\n * env-var allowlist. Detector property: false positives are negligible\n * (a marker present ⇒ confidently an agent); false negatives are\n * expected and documented in the user-facing telemetry docs. New\n * entries should land here, not in per-CLI hand-rolls.\n *\n * Each entry is a `(envVar, agent)` pair with uniform comparison shape:\n * the marker counts as \"present\" when `process.env[envVar]` is set to a\n * truthy string. Truthy = anything other than the empty string, `'0'`,\n * or `'false'` (case-insensitive); see `gating.isTruthyOptOut` for the\n * same convention applied to opt-out env vars.\n *\n * The detector runs in the **child** sender process, never the parent;\n * the parent does not probe env at command start.\n *\n * Codex CLI note: `CODEX_SANDBOX` is the only clear marker available here.\n * Non-sandboxed Codex sessions may be false negatives.\n *\n * TODO: a ci-info-for-agents would be nice — this allowlist drifts the\n * moment a new agent ships its env marker, and consolidating with the\n * other ecosystems that need the same lookup (rate-limited LLM\n * gateways, agent-aware metrics, etc.) would let one library carry the\n * matrix instead of every consumer re-doing it.\n */\nexport interface AgentMarker {\n /** The env-var name to read. Exact-match; no prefix or fuzzy logic. */\n readonly envVar: string;\n /** The agent label written to the `agent` field of the telemetry event. */\n readonly agent: string;\n}\n\nexport const AGENT_MARKERS: readonly AgentMarker[] = [\n { envVar: 'CLAUDECODE', agent: 'Claude Code' },\n { envVar: 'CURSOR_AGENT', agent: 'Cursor' },\n { envVar: 'CODEX_SANDBOX', agent: 'Codex CLI' },\n { envVar: 'GEMINI_CLI', agent: 'Gemini CLI' },\n { envVar: 'WINDSURF', agent: 'Windsurf' },\n { envVar: 'AIDER', agent: 'Aider' },\n { envVar: 'CODY', agent: 'Cody' },\n { envVar: 'CONTINUE', agent: 'Continue' },\n];\n\nfunction isTruthyMarker(raw: string | undefined): boolean {\n if (raw === undefined) return false;\n const normalised = raw.trim().toLowerCase();\n if (normalised === '') return false;\n if (normalised === '0') return false;\n if (normalised === 'false') return false;\n return true;\n}\n\n/**\n * Resolve the agent label from an env snapshot, or `null` if no marker\n * is set. Returns the **first** matching marker in `AGENT_MARKERS`\n * order, so when multiple markers are set the agent label is\n * deterministic and the allowlist's first entry wins.\n *\n * Pure: takes an env record, returns a string or null. No I/O.\n */\nexport function detectAgent(env: Readonly<Record<string, string | undefined>>): string | null {\n for (const marker of AGENT_MARKERS) {\n if (isTruthyMarker(env[marker.envVar])) {\n return marker.agent;\n }\n }\n return null;\n}\n","import { readFileSync } from 'node:fs';\nimport type { PrismaNextConfig } from '@prisma-next/config/config-types';\nimport { join } from 'pathe';\nimport { detectAgent } from './detect-agent';\nimport type { ParentToSenderPayload, TelemetryEvent } from './payload';\n\n/**\n * Subset of the user's `prisma-next.config.*` the telemetry event\n * surfaces. Loaded inside the detached child via {@link loadProjectConfig}\n * — see the design rationale on {@link ParentToSenderPayload} for why\n * this side runs c12 instead of the parent CLI.\n */\nexport interface ProjectConfigFields {\n readonly databaseTarget: string | null;\n readonly extensions: readonly string[];\n}\n\nconst EMPTY_PROJECT_CONFIG: ProjectConfigFields = {\n databaseTarget: null,\n extensions: [],\n};\n\n/**\n * Best-effort load of `prisma-next.config.*` from `projectRoot`,\n * validated against the canonical `@prisma-next/config` schema.\n * Returns `{ databaseTarget: null, extensions: [] }` on any failure\n * mode — missing config file (e.g. before `prisma-next init`), c12\n * throws while evaluating user TS, validator rejects a malformed\n * shape, etc. Telemetry is non-blocking and best-effort; an empty\n * result is the only downside of an unloadable or invalid config.\n *\n * Both `c12` and `@prisma-next/config/config-validation` are imported\n * lazily so the detached sender's cold-start cost is paid only when\n * telemetry actually fires, not on every fork even when gates\n * short-circuit before reaching this code path.\n */\nexport async function loadProjectConfig(projectRoot: string): Promise<ProjectConfigFields> {\n try {\n const { loadConfig } = await import('c12');\n const result = await loadConfig<Record<string, unknown>>({\n name: 'prisma-next',\n cwd: projectRoot,\n dotenv: false,\n rcFile: false,\n globalRc: false,\n });\n const config = result.config ?? null;\n // c12 returns an empty object when no config file exists in the\n // search path — distinct from \"file existed but parsed to an empty\n // object\". Either way, the canonical validator below would reject\n // it on the first required field (`family`), so short-circuit\n // without paying the import cost.\n if (config === null || Object.keys(config).length === 0) {\n return EMPTY_PROJECT_CONFIG;\n }\n const validation = await import('@prisma-next/config/config-validation');\n // TS 4.7+ only flows `asserts cfg is X` narrowing when the\n // assertion function is called via a directly-declared name with\n // an explicit signature. The dynamic-import binding doesn't\n // satisfy that, so wrap the call in a local declaration that\n // re-asserts the signature.\n const validate: (cfg: unknown) => asserts cfg is PrismaNextConfig = validation.validateConfig;\n validate(config);\n return {\n databaseTarget: config.target.targetId,\n extensions: (config.extensionPacks ?? []).map((pack) => pack.id),\n };\n } catch {\n return EMPTY_PROJECT_CONFIG;\n }\n}\n\n/**\n * Versions surface the enrichment cares about. Modelled as a structural\n * record with a required `node` field so tests can pass a literal object\n * without faking every field of `NodeJS.ProcessVersions` (which adds\n * properties between Node versions and includes a long tail the\n * enrichment never touches). Both `bun` and `deno` are read on the\n * runtime-resolution path; everything else is ignored.\n */\nexport interface VersionsSnapshot {\n readonly node: string;\n readonly bun?: string;\n readonly deno?: string;\n}\n\n/**\n * Snapshot of process-level inputs the enrichment reads. Tests pass an\n * explicit snapshot so the enrichment is deterministic per case; the\n * sender entry point passes a fresh snapshot from `process`.\n */\nexport interface EnrichEnvironment {\n readonly platform: NodeJS.Platform;\n readonly arch: string;\n readonly versions: VersionsSnapshot;\n /**\n * Included because package-manager and agent detection intentionally read\n * environment variables from the same process snapshot as platform/versions.\n */\n readonly env: Readonly<Record<string, string | undefined>>;\n /**\n * Best-effort reader for the project's `package.json`, used only to derive\n * the optional `tsVersion` telemetry field. Returning `null` means unknown.\n */\n readonly readProjectPackageJson: () => string | null;\n}\n\n/**\n * Identify the runtime the sender is running in. Same-runtime as the\n * parent is a correctness requirement: the parent forked us via\n * `child_process.fork`, which inherits the parent's runtime. Detection\n * keys on the runtime-specific version field rather than env vars so a\n * spoofed env can't lie about the actual interpreter.\n */\nfunction resolveRuntime(versions: VersionsSnapshot): {\n readonly name: 'node' | 'bun' | 'deno';\n readonly version: string;\n} {\n if (versions.bun !== undefined) {\n return { name: 'bun', version: versions.bun };\n }\n if (versions.deno !== undefined) {\n return { name: 'deno', version: versions.deno };\n }\n return { name: 'node', version: versions.node };\n}\n\n/**\n * Parse `npm_config_user_agent` into a `<pm>/<version>` token. The\n * value, when present, looks like\n * `\"pnpm/10.27.0 npm/? node/v24.13.0 darwin arm64\"` — we take the first\n * whitespace-separated token. Any failure → `null`.\n */\nexport function parsePackageManager(userAgent: string | undefined): string | null {\n if (userAgent === undefined) return null;\n const first = userAgent.split(/\\s+/)[0];\n if (first === undefined || first.length === 0) return null;\n if (!first.includes('/')) return null;\n return first;\n}\n\n/**\n * Read the user's project `package.json` and resolve a TypeScript\n * version from `devDependencies.typescript` (preferred) or\n * `dependencies.typescript`. Strips a leading `^` or `~` semver\n * prefix. Returns `null` on any failure mode — file missing,\n * unreadable, malformed JSON, key absent, not a string.\n */\nexport function readTsVersionFromPackageJson(raw: string | null): string | null {\n if (raw === null) return null;\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(raw) as Record<string, unknown>;\n } catch {\n return null;\n }\n const candidate =\n pickStringDep(parsed['devDependencies']) ?? pickStringDep(parsed['dependencies']);\n if (candidate === null) return null;\n return candidate.replace(/^[\\^~]/, '');\n}\n\nfunction pickStringDep(deps: unknown): string | null {\n if (deps === null || typeof deps !== 'object' || Array.isArray(deps)) return null;\n const value = (deps as Record<string, unknown>)['typescript'];\n return typeof value === 'string' ? value : null;\n}\n\n/**\n * Build the full backend event from the parent's payload, the\n * c12-loaded project-config slice, and the child's per-process\n * snapshot. Pure given a `projectConfig` + `EnrichEnvironment`.\n */\nexport function buildTelemetryEvent(\n payload: ParentToSenderPayload,\n projectConfig: ProjectConfigFields,\n env: EnrichEnvironment,\n): TelemetryEvent {\n const runtime = resolveRuntime(env.versions);\n return {\n installationId: payload.installationId,\n version: payload.version,\n command: payload.command,\n flags: payload.flags,\n runtimeName: runtime.name,\n runtimeVersion: runtime.version,\n os: env.platform,\n arch: env.arch,\n packageManager: parsePackageManager(env.env['npm_config_user_agent']),\n databaseTarget: projectConfig.databaseTarget,\n tsVersion: readTsVersionFromPackageJson(env.readProjectPackageJson()),\n agent: detectAgent(env.env),\n extensions: projectConfig.extensions,\n };\n}\n\n/**\n * Convenience for the sender entry: build the event from the live\n * `process` plus a c12 load of `prisma-next.config.*` from\n * `payload.projectRoot` plus a real project-package.json reader,\n * swallowing any I/O errors in the file read.\n *\n * The parent's `payload.databaseTarget` (when present) wins over the\n * c12-derived value. The parent sets this for the first-`init` run,\n * where the config file does not exist on disk yet but the user has\n * just declared a target via the consent prompt; every other\n * invocation leaves it unset and the c12 load supplies the value.\n */\nexport async function buildTelemetryEventFromProcess(\n payload: ParentToSenderPayload,\n): Promise<TelemetryEvent> {\n const loadedConfig = await loadProjectConfig(payload.projectRoot);\n const projectConfig: ProjectConfigFields = {\n databaseTarget: payload.databaseTarget ?? loadedConfig.databaseTarget,\n extensions: loadedConfig.extensions,\n };\n return buildTelemetryEvent(payload, projectConfig, {\n platform: process.platform,\n arch: process.arch,\n versions: process.versions,\n env: process.env,\n readProjectPackageJson: () => {\n try {\n return readFileSync(join(payload.projectRoot, 'package.json'), 'utf-8');\n } catch {\n return null;\n }\n },\n });\n}\n"],"mappings":";;;AAgCA,MAAa,gBAAwC;CACnD;EAAE,QAAQ;EAAc,OAAO;CAAc;CAC7C;EAAE,QAAQ;EAAgB,OAAO;CAAS;CAC1C;EAAE,QAAQ;EAAiB,OAAO;CAAY;CAC9C;EAAE,QAAQ;EAAc,OAAO;CAAa;CAC5C;EAAE,QAAQ;EAAY,OAAO;CAAW;CACxC;EAAE,QAAQ;EAAS,OAAO;CAAQ;CAClC;EAAE,QAAQ;EAAQ,OAAO;CAAO;CAChC;EAAE,QAAQ;EAAY,OAAO;CAAW;AAC1C;AAEA,SAAS,eAAe,KAAkC;CACxD,IAAI,QAAQ,KAAA,GAAW,OAAO;CAC9B,MAAM,aAAa,IAAI,KAAK,EAAE,YAAY;CAC1C,IAAI,eAAe,IAAI,OAAO;CAC9B,IAAI,eAAe,KAAK,OAAO;CAC/B,IAAI,eAAe,SAAS,OAAO;CACnC,OAAO;AACT;;;;;;;;;AAUA,SAAgB,YAAY,KAAkE;CAC5F,KAAK,MAAM,UAAU,eACnB,IAAI,eAAe,IAAI,OAAO,OAAO,GACnC,OAAO,OAAO;CAGlB,OAAO;AACT;;;AClDA,MAAM,uBAA4C;CAChD,gBAAgB;CAChB,YAAY,CAAC;AACf;;;;;;;;;;;;;;;AAgBA,eAAsB,kBAAkB,aAAmD;CACzF,IAAI;EACF,MAAM,EAAE,eAAe,MAAM,OAAO;EAQpC,MAAM,UAAS,MAPM,WAAoC;GACvD,MAAM;GACN,KAAK;GACL,QAAQ;GACR,QAAQ;GACR,UAAU;EACZ,CAAC,GACqB,UAAU;EAMhC,IAAI,WAAW,QAAQ,OAAO,KAAK,MAAM,EAAE,WAAW,GACpD,OAAO;EAQT,MAAM,YAA8D,MAN3C,OAAO,0CAM+C;EAC/E,SAAS,MAAM;EACf,OAAO;GACL,gBAAgB,OAAO,OAAO;GAC9B,aAAa,OAAO,kBAAkB,CAAC,GAAG,KAAK,SAAS,KAAK,EAAE;EACjE;CACF,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;AA4CA,SAAS,eAAe,UAGtB;CACA,IAAI,SAAS,QAAQ,KAAA,GACnB,OAAO;EAAE,MAAM;EAAO,SAAS,SAAS;CAAI;CAE9C,IAAI,SAAS,SAAS,KAAA,GACpB,OAAO;EAAE,MAAM;EAAQ,SAAS,SAAS;CAAK;CAEhD,OAAO;EAAE,MAAM;EAAQ,SAAS,SAAS;CAAK;AAChD;;;;;;;AAQA,SAAgB,oBAAoB,WAA8C;CAChF,IAAI,cAAc,KAAA,GAAW,OAAO;CACpC,MAAM,QAAQ,UAAU,MAAM,KAAK,EAAE;CACrC,IAAI,UAAU,KAAA,KAAa,MAAM,WAAW,GAAG,OAAO;CACtD,IAAI,CAAC,MAAM,SAAS,GAAG,GAAG,OAAO;CACjC,OAAO;AACT;;;;;;;;AASA,SAAgB,6BAA6B,KAAmC;CAC9E,IAAI,QAAQ,MAAM,OAAO;CACzB,IAAI;CACJ,IAAI;EACF,SAAS,KAAK,MAAM,GAAG;CACzB,QAAQ;EACN,OAAO;CACT;CACA,MAAM,YACJ,cAAc,OAAO,kBAAkB,KAAK,cAAc,OAAO,eAAe;CAClF,IAAI,cAAc,MAAM,OAAO;CAC/B,OAAO,UAAU,QAAQ,UAAU,EAAE;AACvC;AAEA,SAAS,cAAc,MAA8B;CACnD,IAAI,SAAS,QAAQ,OAAO,SAAS,YAAY,MAAM,QAAQ,IAAI,GAAG,OAAO;CAC7E,MAAM,QAAS,KAAiC;CAChD,OAAO,OAAO,UAAU,WAAW,QAAQ;AAC7C;;;;;;AAOA,SAAgB,oBACd,SACA,eACA,KACgB;CAChB,MAAM,UAAU,eAAe,IAAI,QAAQ;CAC3C,OAAO;EACL,gBAAgB,QAAQ;EACxB,SAAS,QAAQ;EACjB,SAAS,QAAQ;EACjB,OAAO,QAAQ;EACf,aAAa,QAAQ;EACrB,gBAAgB,QAAQ;EACxB,IAAI,IAAI;EACR,MAAM,IAAI;EACV,gBAAgB,oBAAoB,IAAI,IAAI,wBAAwB;EACpE,gBAAgB,cAAc;EAC9B,WAAW,6BAA6B,IAAI,uBAAuB,CAAC;EACpE,OAAO,YAAY,IAAI,GAAG;EAC1B,YAAY,cAAc;CAC5B;AACF;;;;;;;;;;;;;AAcA,eAAsB,+BACpB,SACyB;CACzB,MAAM,eAAe,MAAM,kBAAkB,QAAQ,WAAW;CAKhE,OAAO,oBAAoB,SAAS;EAHlC,gBAAgB,QAAQ,kBAAkB,aAAa;EACvD,YAAY,aAAa;CAEqB,GAAG;EACjD,UAAU,QAAQ;EAClB,MAAM,QAAQ;EACd,UAAU,QAAQ;EAClB,KAAK,QAAQ;EACb,8BAA8B;GAC5B,IAAI;IACF,OAAO,aAAa,KAAK,QAAQ,aAAa,cAAc,GAAG,OAAO;GACxE,QAAQ;IACN,OAAO;GACT;EACF;CACF,CAAC;AACH"}
1
+ {"version":3,"file":"enrich-BhHLLBfL.mjs","names":[],"sources":["../src/detect-agent.ts","../src/enrich.ts"],"sourcesContent":["/**\n * Best-effort identification of AI coding-agent sessions from an\n * env-var allowlist. Detector property: false positives are negligible\n * (a marker present ⇒ confidently an agent); false negatives are\n * expected and documented in the user-facing telemetry docs. New\n * entries should land here, not in per-CLI hand-rolls.\n *\n * Each entry is a `(envVar, agent)` pair with uniform comparison shape:\n * the marker counts as \"present\" when `process.env[envVar]` is set to a\n * truthy string. Truthy = anything other than the empty string, `'0'`,\n * or `'false'` (case-insensitive); see `gating.isTruthyOptOut` for the\n * same convention applied to opt-out env vars.\n *\n * The detector runs in the **child** sender process, never the parent;\n * the parent does not probe env at command start.\n *\n * Codex CLI note: `CODEX_SANDBOX` is the only clear marker available here.\n * Non-sandboxed Codex sessions may be false negatives.\n *\n * TODO: a ci-info-for-agents would be nice — this allowlist drifts the\n * moment a new agent ships its env marker, and consolidating with the\n * other ecosystems that need the same lookup (rate-limited LLM\n * gateways, agent-aware metrics, etc.) would let one library carry the\n * matrix instead of every consumer re-doing it.\n */\nexport interface AgentMarker {\n /** The env-var name to read. Exact-match; no prefix or fuzzy logic. */\n readonly envVar: string;\n /** The agent label written to the `agent` field of the telemetry event. */\n readonly agent: string;\n}\n\nexport const AGENT_MARKERS: readonly AgentMarker[] = [\n { envVar: 'CLAUDECODE', agent: 'Claude Code' },\n { envVar: 'CURSOR_AGENT', agent: 'Cursor' },\n { envVar: 'CODEX_SANDBOX', agent: 'Codex CLI' },\n { envVar: 'GEMINI_CLI', agent: 'Gemini CLI' },\n { envVar: 'WINDSURF', agent: 'Windsurf' },\n { envVar: 'AIDER', agent: 'Aider' },\n { envVar: 'CODY', agent: 'Cody' },\n { envVar: 'CONTINUE', agent: 'Continue' },\n];\n\nfunction isTruthyMarker(raw: string | undefined): boolean {\n if (raw === undefined) return false;\n const normalised = raw.trim().toLowerCase();\n if (normalised === '') return false;\n if (normalised === '0') return false;\n if (normalised === 'false') return false;\n return true;\n}\n\n/**\n * Resolve the agent label from an env snapshot, or `null` if no marker\n * is set. Returns the **first** matching marker in `AGENT_MARKERS`\n * order, so when multiple markers are set the agent label is\n * deterministic and the allowlist's first entry wins.\n *\n * Pure: takes an env record, returns a string or null. No I/O.\n */\nexport function detectAgent(env: Readonly<Record<string, string | undefined>>): string | null {\n for (const marker of AGENT_MARKERS) {\n if (isTruthyMarker(env[marker.envVar])) {\n return marker.agent;\n }\n }\n return null;\n}\n","import { readFileSync } from 'node:fs';\nimport type { PrismaNextConfig } from '@prisma-next/config/config-types';\nimport { join } from 'pathe';\nimport { detectAgent } from './detect-agent';\nimport type { ParentToSenderPayload, TelemetryEvent } from './payload';\n\n/**\n * Subset of the user's `prisma-next.config.*` the telemetry event\n * surfaces. Loaded inside the detached child via {@link loadProjectConfig}\n * — see the design rationale on {@link ParentToSenderPayload} for why\n * this side runs c12 instead of the parent CLI.\n */\nexport interface ProjectConfigFields {\n readonly databaseTarget: string | null;\n readonly extensions: readonly string[];\n}\n\nconst EMPTY_PROJECT_CONFIG: ProjectConfigFields = {\n databaseTarget: null,\n extensions: [],\n};\n\n/**\n * Best-effort load of `prisma-next.config.*` from `projectRoot`,\n * validated against the canonical `@prisma-next/config` schema.\n * Returns `{ databaseTarget: null, extensions: [] }` on any failure\n * mode — missing config file (e.g. before `prisma-next init`), c12\n * throws while evaluating user TS, validator rejects a malformed\n * shape, etc. Telemetry is non-blocking and best-effort; an empty\n * result is the only downside of an unloadable or invalid config.\n *\n * Both `c12` and `@prisma-next/config/config-validation` are imported\n * lazily so the detached sender's cold-start cost is paid only when\n * telemetry actually fires, not on every fork even when gates\n * short-circuit before reaching this code path.\n */\nexport async function loadProjectConfig(projectRoot: string): Promise<ProjectConfigFields> {\n try {\n const { loadConfig } = await import('c12');\n const result = await loadConfig<Record<string, unknown>>({\n name: 'prisma-next',\n cwd: projectRoot,\n dotenv: false,\n rcFile: false,\n globalRc: false,\n });\n const config = result.config ?? null;\n // c12 returns an empty object when no config file exists in the\n // search path — distinct from \"file existed but parsed to an empty\n // object\". Either way, the canonical validator below would reject\n // it on the first required field (`family`), so short-circuit\n // without paying the import cost.\n if (config === null || Object.keys(config).length === 0) {\n return EMPTY_PROJECT_CONFIG;\n }\n const validation = await import('@prisma-next/config/config-validation');\n // TS 4.7+ only flows `asserts cfg is X` narrowing when the\n // assertion function is called via a directly-declared name with\n // an explicit signature. The dynamic-import binding doesn't\n // satisfy that, so wrap the call in a local declaration that\n // re-asserts the signature.\n const validate: (cfg: unknown) => asserts cfg is PrismaNextConfig = validation.validateConfig;\n validate(config);\n return {\n databaseTarget: config.target.targetId,\n extensions: (config.extensionPacks ?? []).map((pack) => pack.id),\n };\n } catch {\n return EMPTY_PROJECT_CONFIG;\n }\n}\n\n/**\n * Versions surface the enrichment cares about. Modelled as a structural\n * record with a required `node` field so tests can pass a literal object\n * without faking every field of `NodeJS.ProcessVersions` (which adds\n * properties between Node versions and includes a long tail the\n * enrichment never touches). Both `bun` and `deno` are read on the\n * runtime-resolution path; everything else is ignored.\n */\nexport interface VersionsSnapshot {\n readonly node: string;\n readonly bun?: string;\n readonly deno?: string;\n}\n\n/**\n * Snapshot of process-level inputs the enrichment reads. Tests pass an\n * explicit snapshot so the enrichment is deterministic per case; the\n * sender entry point passes a fresh snapshot from `process`.\n */\nexport interface EnrichEnvironment {\n readonly platform: NodeJS.Platform;\n readonly arch: string;\n readonly versions: VersionsSnapshot;\n /**\n * Included because package-manager and agent detection intentionally read\n * environment variables from the same process snapshot as platform/versions.\n */\n readonly env: Readonly<Record<string, string | undefined>>;\n /**\n * Best-effort reader for the project's `package.json`, used only to derive\n * the optional `tsVersion` telemetry field. Returning `null` means unknown.\n */\n readonly readProjectPackageJson: () => string | null;\n}\n\n/**\n * Identify the runtime the sender is running in. Same-runtime as the\n * parent is a correctness requirement: the parent forked us via\n * `child_process.fork`, which inherits the parent's runtime. Detection\n * keys on the runtime-specific version field rather than env vars so a\n * spoofed env can't lie about the actual interpreter.\n */\nfunction resolveRuntime(versions: VersionsSnapshot): {\n readonly name: 'node' | 'bun' | 'deno';\n readonly version: string;\n} {\n if (versions.bun !== undefined) {\n return { name: 'bun', version: versions.bun };\n }\n if (versions.deno !== undefined) {\n return { name: 'deno', version: versions.deno };\n }\n return { name: 'node', version: versions.node };\n}\n\n/**\n * Parse `npm_config_user_agent` into a `<pm>/<version>` token. The\n * value, when present, looks like\n * `\"pnpm/10.27.0 npm/? node/v24.13.0 darwin arm64\"` — we take the first\n * whitespace-separated token. Any failure → `null`.\n */\nexport function parsePackageManager(userAgent: string | undefined): string | null {\n if (userAgent === undefined) return null;\n const first = userAgent.split(/\\s+/)[0];\n if (first === undefined || first.length === 0) return null;\n if (!first.includes('/')) return null;\n return first;\n}\n\n/**\n * Read the user's project `package.json` and resolve a TypeScript\n * version from `devDependencies.typescript` (preferred) or\n * `dependencies.typescript`. Strips a leading `^` or `~` semver\n * prefix. Returns `null` on any failure mode — file missing,\n * unreadable, malformed JSON, key absent, not a string.\n */\nexport function readTsVersionFromPackageJson(raw: string | null): string | null {\n if (raw === null) return null;\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(raw) as Record<string, unknown>;\n } catch {\n return null;\n }\n const candidate =\n pickStringDep(parsed['devDependencies']) ?? pickStringDep(parsed['dependencies']);\n if (candidate === null) return null;\n return candidate.replace(/^[\\^~]/, '');\n}\n\nfunction pickStringDep(deps: unknown): string | null {\n if (deps === null || typeof deps !== 'object' || Array.isArray(deps)) return null;\n const value = (deps as Record<string, unknown>)['typescript'];\n return typeof value === 'string' ? value : null;\n}\n\n/**\n * Build the full backend event from the parent's payload, the\n * c12-loaded project-config slice, and the child's per-process\n * snapshot. Pure given a `projectConfig` + `EnrichEnvironment`.\n */\nexport function buildTelemetryEvent(\n payload: ParentToSenderPayload,\n projectConfig: ProjectConfigFields,\n env: EnrichEnvironment,\n): TelemetryEvent {\n const runtime = resolveRuntime(env.versions);\n return {\n installationId: payload.installationId,\n version: payload.version,\n command: payload.command,\n flags: payload.flags,\n runtimeName: runtime.name,\n runtimeVersion: runtime.version,\n os: env.platform,\n arch: env.arch,\n packageManager: parsePackageManager(env.env['npm_config_user_agent']),\n databaseTarget: projectConfig.databaseTarget,\n tsVersion: readTsVersionFromPackageJson(env.readProjectPackageJson()),\n agent: detectAgent(env.env),\n extensions: projectConfig.extensions,\n };\n}\n\n/**\n * Convenience for the sender entry: build the event from the live\n * `process` plus a c12 load of `prisma-next.config.*` from\n * `payload.projectRoot` plus a real project-package.json reader,\n * swallowing any I/O errors in the file read.\n *\n * The parent's `payload.databaseTarget` (when present) wins over the\n * c12-derived value. The parent sets this for the first-`init` run,\n * where the config file does not exist on disk yet but the user has\n * just declared a target via the consent prompt; every other\n * invocation leaves it unset and the c12 load supplies the value.\n */\nexport async function buildTelemetryEventFromProcess(\n payload: ParentToSenderPayload,\n): Promise<TelemetryEvent> {\n const loadedConfig = await loadProjectConfig(payload.projectRoot);\n const projectConfig: ProjectConfigFields = {\n databaseTarget: payload.databaseTarget ?? loadedConfig.databaseTarget,\n extensions: loadedConfig.extensions,\n };\n return buildTelemetryEvent(payload, projectConfig, {\n platform: process.platform,\n arch: process.arch,\n versions: process.versions,\n env: process.env,\n readProjectPackageJson: () => {\n try {\n return readFileSync(join(payload.projectRoot, 'package.json'), 'utf-8');\n } catch {\n return null;\n }\n },\n });\n}\n"],"mappings":"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"}
@@ -126,10 +126,12 @@ declare function loadProjectConfig(projectRoot: string): Promise<ProjectConfigFi
126
126
  //#endregion
127
127
  //#region src/user-config.d.ts
128
128
  /**
129
- * The user-level config file. Persists the consent flag and the
130
- * installation UUID together so an env-var opt-out never mutates disk,
131
- * and so an opt-in opt-out opt-in cycle keeps the same UUID (correct
132
- * for MAU continuity).
129
+ * The user-level config file. Persists the telemetry flag and the
130
+ * installation UUID. Under the opt-out model the flag stays `undefined`
131
+ * until the user makes an explicit choice (default-on first run mints
132
+ * only the id via {@link ensureInstallationId}), and an env-var opt-out
133
+ * never mutates disk. Once the id exists it survives any
134
+ * on → off → on cycle, keeping the same UUID (correct for MAU continuity).
133
135
  *
134
136
  * Readers tolerate unknown fields for forward compat; writers merge
135
137
  * partials into the existing object so unknown fields are preserved.
@@ -158,19 +160,32 @@ declare function readUserConfig(): UserConfig;
158
160
  *
159
161
  * When `partial.enableTelemetry === true` and no `installationId` is
160
162
  * stored yet, generates a v4 random UUID and persists both fields in
161
- * the same write. An existing `installationId` is never rotated.
162
- *
163
- * `writeUserConfig({ enableTelemetry: false })` does *not* generate an
164
- * installation id only an affirmative consent answer produces one.
163
+ * the same write. An existing `installationId` is never rotated. This is
164
+ * the *explicit-consent* mint path: a `false` answer
165
+ * (`writeUserConfig({ enableTelemetry: false })`) writes no id, and a bare
166
+ * `writeUserConfig({ installationId })` mints nothing extra. The default-on
167
+ * first-send path mints its id separately via {@link ensureInstallationId},
168
+ * which records no consent answer.
165
169
  */
166
170
  declare function writeUserConfig(partial: Partial<UserConfig>): void;
171
+ /**
172
+ * Returns the stored `installationId`, minting and persisting a fresh v4
173
+ * UUID when none exists yet. Crucially, this persists *only* the id —
174
+ * `enableTelemetry` is left untouched (stays `undefined` on a default-on
175
+ * first run), so the interactive `init` consent prompt is not wrongly
176
+ * suppressed and no explicit consent the user never gave is recorded.
177
+ *
178
+ * Used by the default-on first-run fire path: the gate has already
179
+ * resolved enabled, so this only ever runs when telemetry is on.
180
+ */
181
+ declare function ensureInstallationId(): string;
167
182
  //#endregion
168
183
  //#region src/gating.d.ts
169
184
  /**
170
185
  * Why telemetry was disabled. Useful for debug-mode logging in the
171
186
  * parent; never surfaces to users.
172
187
  */
173
- type GatingDisabledReason = 'env-override' | 'stored-opt-out' | 'default-off';
188
+ type GatingDisabledReason = 'env-override' | 'stored-opt-out';
174
189
  type GatingResolution = {
175
190
  readonly enabled: true;
176
191
  } | {
@@ -195,14 +210,17 @@ interface GatingInputs {
195
210
  *
196
211
  * Decision order:
197
212
  * 1. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or
198
- * `DO_NOT_TRACK=1`) → disabled.
199
- * 2. Stored `enableTelemetry === true` enabled.
200
- * 3. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
213
+ * `DO_NOT_TRACK=1`) → disabled. The env check runs first, so an
214
+ * opt-out env var wins over any stored or unset preference.
215
+ * 2. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
216
+ * 3. Stored `enableTelemetry === true` → enabled.
201
217
  * 4. Stored `enableTelemetry === undefined` (file missing, or field
202
- * not set) → disabled (`default-off`).
218
+ * not set) → ENABLED. This is the opt-out default: absence of an
219
+ * explicit choice means telemetry is on. This is the load-bearing,
220
+ * counter-intuitive branch — do not "fix" it to default-off.
203
221
  *
204
- * Telemetry is enabled only when no env override is active **and**
205
- * `enableTelemetry` is explicitly `true`.
222
+ * Telemetry is disabled only when an env override is active or
223
+ * `enableTelemetry` is explicitly `false`.
206
224
  */
207
225
  declare function resolveGating(inputs: GatingInputs): GatingResolution;
208
226
  //#endregion
@@ -344,5 +362,5 @@ declare function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome;
344
362
  */
345
363
  declare function senderModuleUrl(importMetaUrl: string): string;
346
364
  //#endregion
347
- export { type CommanderOptionShape, type CommanderResultShape, type GatingDisabledReason, type GatingInputs, type GatingResolution, type ParentToSenderPayload, type ProjectConfigFields, type RunTelemetryInputs, type SanitisedCommand, TELEMETRY_BACKEND_URL, TELEMETRY_ENDPOINT_PATH, type TelemetryEvent, type TelemetryRunOutcome, type UserConfig, loadProjectConfig, readUserConfig, resolveGating, resolveTelemetryEndpoint, runTelemetry, sanitizeCommanderResult, senderModuleUrl, userConfigPath, writeUserConfig };
365
+ export { type CommanderOptionShape, type CommanderResultShape, type GatingDisabledReason, type GatingInputs, type GatingResolution, type ParentToSenderPayload, type ProjectConfigFields, type RunTelemetryInputs, type SanitisedCommand, TELEMETRY_BACKEND_URL, TELEMETRY_ENDPOINT_PATH, type TelemetryEvent, type TelemetryRunOutcome, type UserConfig, ensureInstallationId, loadProjectConfig, readUserConfig, resolveGating, resolveTelemetryEndpoint, runTelemetry, sanitizeCommanderResult, senderModuleUrl, userConfigPath, writeUserConfig };
348
366
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/endpoint.ts","../../src/payload.ts","../../src/enrich.ts","../../src/user-config.ts","../../src/gating.ts","../../src/sanitize.ts","../../src/spawn.ts"],"mappings":";;;;AAIA;cAAa,qBAAA;;;AAAqB;cAKrB,uBAAA;;;;AAAuB;AAapC;;;;;;;iBAAgB,wBAAA,CACd,GAAA,GAAK,QAAQ,CAAC,MAAA;;;;;;AAnBhB;;;;AAAkC;AAKlC;;;;AAAoC;AAapC;;;;;;;;AACiE;;;;ACQjE;;;;UAAiB,qBAAA;EAAA,SACN,cAAA;EAAA,SACA,OAAA;EAAA,SACA,OAAA;EAAA,SACA,KAAA;EASA;;;AAWc;AAoCzB;;EA/CW,SAFA,WAAA;EAiDoB;EAAA,SA/CpB,QAAA;EAiDA;;;;;;;;;;EAAA,SAtCA,cAAA;AAAA;;;;AC3CX;UD+EiB,cAAA;EAAA,SACN,cAAA;EAAA,SACA,OAAA;EAAA,SACA,OAAA;EAAA,SACA,KAAA;EAAA,SACA,WAAA;EAAA,SACA,cAAA;EAAA,SACA,EAAA;EAAA,SACA,IAAA;EAAA,SACA,cAAA;EAAA,SACA,cAAA;EAAA,SACA,SAAA;EAAA,SACA,KAAA;EAAA,SACA,UAAA;AAAA;;;;;ADpGX;;;;UEQiB,mBAAA;EAAA,SACN,cAAA;EAAA,SACA,UAAU;AAAA;;AFLe;AAapC;;;;;;;;AACiE;;;;iBEa3C,iBAAA,CAAkB,WAAA,WAAsB,OAAO,CAAC,mBAAA;;;;;;AFhCtE;;;;AAAkC;AAKlC;UGKiB,UAAA;EAAA,SACN,eAAA;EAAA,SACA,cAAA;EAAA,UACC,GAAA;AAAA;;;;;iBA0CI,cAAA,CAAA;;;AHpCiD;;;iBG6CjD,cAAA,CAAA,GAAkB,UAAU;AFrC5C;;;;;;;;;;;;AAwByB;AAxBzB,iBEiEgB,eAAA,CAAgB,OAAA,EAAS,OAAO,CAAC,UAAA;;;;;AH5FjD;;KIEY,oBAAA;AAAA,KAEA,gBAAA;EAAA,SACG,OAAA;AAAA;EAAA,SACA,OAAA;EAAA,SAAyB,MAAA,EAAQ,oBAAoB;AAAA;AAAA,UAEnD,YAAA;EJUD;;;;;;EAAA,SIHL,GAAA,EAAK,QAAA,CAAS,MAAA;EJIwC;EAAA,SIFtD,MAAA,EAAQ,UAAA;AAAA;;;AHUnB;;;;;;;;;;;;AAwByB;AAoCzB;iBGnCgB,aAAA,CAAc,MAAA,EAAQ,YAAA,GAAe,gBAAgB;;;UCxDpD,oBAAA;;WAEN,aAAA;ELEE;EAAA,SKAF,QAAA;;WAEA,MAAA;AAAA;ALGX;;;;AAAoC;AAapC;AAbA,UKMiB,oBAAA;;;;;;WAMN,WAAA;ELEsD;;;;ACQjE;;EDRiE,SKKtD,cAAA;EJG2B;;;;;;EAAA,SII3B,OAAA,WAAkB,oBAAoB;AAAA;;AJoBxB;AAoCzB;;;UIhDiB,gBAAA;EAAA,SACN,OAAA;EAAA,SACA,KAAK;AAAA;;;;;;;;;;;;AJ2DK;;;;iBInCL,uBAAA,CAAwB,KAAA,EAAO,oBAAA,GAAuB,gBAAgB;;;;ALjEtF;;;;AAAkC;AAKlC;;;;AAAoC;AAapC;;;;;UMGiB,kBAAA;ENFf;EAAA,SMIS,OAAA,EAAS,oBAAA;ENJ6C;EAAA,SMMtD,OAAA;;WAEA,WAAA;ELAM;;;;;EAAA,SKMN,cAAA;ELHA;;;;;;EAAA,SKUA,UAAA;EL+CM;;;;;EAAA,SKzCN,IAAA;EL4CA;EAAA,SK1CA,GAAA,GAAM,QAAA,CAAS,MAAA;EL4Cf;EAAA,SK1CA,UAAA,GAAa,UAAA;AAAA;;;;;;;;ALkDH;;;;KKpCT,mBAAA;EAAA,SACG,OAAA;AAAA;EAAA,SACA,OAAA;EAAA,SAAyB,MAAA;AAAA;AAAA,iBAExB,YAAA,CAAa,MAAA,EAAQ,kBAAA,GAAqB,mBAAmB;;;;;;;AJpCY;iBIgGzE,eAAA,CAAgB,aAAqB"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/endpoint.ts","../../src/payload.ts","../../src/enrich.ts","../../src/user-config.ts","../../src/gating.ts","../../src/sanitize.ts","../../src/spawn.ts"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { n as loadProjectConfig } from "../enrich-CGZ8Za8Y.mjs";
1
+ import { n as loadProjectConfig } from "../enrich-BhHLLBfL.mjs";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "pathe";
4
4
  import { fork } from "node:child_process";
@@ -60,29 +60,28 @@ function isTruthyOptOut(raw) {
60
60
  *
61
61
  * Decision order:
62
62
  * 1. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or
63
- * `DO_NOT_TRACK=1`) → disabled.
64
- * 2. Stored `enableTelemetry === true` enabled.
65
- * 3. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
63
+ * `DO_NOT_TRACK=1`) → disabled. The env check runs first, so an
64
+ * opt-out env var wins over any stored or unset preference.
65
+ * 2. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
66
+ * 3. Stored `enableTelemetry === true` → enabled.
66
67
  * 4. Stored `enableTelemetry === undefined` (file missing, or field
67
- * not set) → disabled (`default-off`).
68
+ * not set) → ENABLED. This is the opt-out default: absence of an
69
+ * explicit choice means telemetry is on. This is the load-bearing,
70
+ * counter-intuitive branch — do not "fix" it to default-off.
68
71
  *
69
- * Telemetry is enabled only when no env override is active **and**
70
- * `enableTelemetry` is explicitly `true`.
72
+ * Telemetry is disabled only when an env override is active or
73
+ * `enableTelemetry` is explicitly `false`.
71
74
  */
72
75
  function resolveGating(inputs) {
73
76
  if (isTruthyOptOut(inputs.env["PRISMA_NEXT_DISABLE_TELEMETRY"]) || inputs.env["DO_NOT_TRACK"] === "1") return {
74
77
  enabled: false,
75
78
  reason: "env-override"
76
79
  };
77
- if (inputs.config.enableTelemetry === true) return { enabled: true };
78
80
  if (inputs.config.enableTelemetry === false) return {
79
81
  enabled: false,
80
82
  reason: "stored-opt-out"
81
83
  };
82
- return {
83
- enabled: false,
84
- reason: "default-off"
85
- };
84
+ return { enabled: true };
86
85
  }
87
86
  //#endregion
88
87
  //#region src/sanitize.ts
@@ -179,10 +178,12 @@ function readUserConfig() {
179
178
  *
180
179
  * When `partial.enableTelemetry === true` and no `installationId` is
181
180
  * stored yet, generates a v4 random UUID and persists both fields in
182
- * the same write. An existing `installationId` is never rotated.
183
- *
184
- * `writeUserConfig({ enableTelemetry: false })` does *not* generate an
185
- * installation id only an affirmative consent answer produces one.
181
+ * the same write. An existing `installationId` is never rotated. This is
182
+ * the *explicit-consent* mint path: a `false` answer
183
+ * (`writeUserConfig({ enableTelemetry: false })`) writes no id, and a bare
184
+ * `writeUserConfig({ installationId })` mints nothing extra. The default-on
185
+ * first-send path mints its id separately via {@link ensureInstallationId},
186
+ * which records no consent answer.
186
187
  */
187
188
  function writeUserConfig(partial) {
188
189
  const merged = {
@@ -197,6 +198,23 @@ function writeUserConfig(partial) {
197
198
  writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
198
199
  renameSync(tmpPath, path);
199
200
  }
201
+ /**
202
+ * Returns the stored `installationId`, minting and persisting a fresh v4
203
+ * UUID when none exists yet. Crucially, this persists *only* the id —
204
+ * `enableTelemetry` is left untouched (stays `undefined` on a default-on
205
+ * first run), so the interactive `init` consent prompt is not wrongly
206
+ * suppressed and no explicit consent the user never gave is recorded.
207
+ *
208
+ * Used by the default-on first-run fire path: the gate has already
209
+ * resolved enabled, so this only ever runs when telemetry is on.
210
+ */
211
+ function ensureInstallationId() {
212
+ const existing = readUserConfig().installationId;
213
+ if (typeof existing === "string" && existing.length > 0) return existing;
214
+ const installationId = randomUUID();
215
+ writeUserConfig({ installationId });
216
+ return installationId;
217
+ }
200
218
  //#endregion
201
219
  //#region src/spawn.ts
202
220
  function runTelemetry(inputs) {
@@ -262,6 +280,6 @@ function senderModuleUrl(importMetaUrl) {
262
280
  return fileURLToPath(new URL("./sender.mjs", importMetaUrl));
263
281
  }
264
282
  //#endregion
265
- export { TELEMETRY_BACKEND_URL, TELEMETRY_ENDPOINT_PATH, loadProjectConfig, readUserConfig, resolveGating, resolveTelemetryEndpoint, runTelemetry, sanitizeCommanderResult, senderModuleUrl, userConfigPath, writeUserConfig };
283
+ export { TELEMETRY_BACKEND_URL, TELEMETRY_ENDPOINT_PATH, ensureInstallationId, loadProjectConfig, readUserConfig, resolveGating, resolveTelemetryEndpoint, runTelemetry, sanitizeCommanderResult, senderModuleUrl, userConfigPath, writeUserConfig };
266
284
 
267
285
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/endpoint.ts","../../src/gating.ts","../../src/sanitize.ts","../../src/user-config.ts","../../src/spawn.ts"],"sourcesContent":["/**\n * Production endpoint pinned to the deployed Prisma Compute backend.\n * Compiled as a build-time constant; not user-configurable.\n */\nexport const TELEMETRY_BACKEND_URL = 'https://cmpbfbsdp09hr3jf7pojjs5qs.ewr.prisma.build';\n\n/**\n * Path within the backend that accepts telemetry POSTs.\n */\nexport const TELEMETRY_ENDPOINT_PATH = '/events';\n\n/**\n * Resolve the full POST URL the sender targets. The\n * `PRISMA_NEXT_TELEMETRY_ENDPOINT` env var is an integration-testing\n * affordance only — it lets the test suite spin up a mock HTTP server\n * on an ephemeral port and point the spawned sender at it. The override\n * is intentionally undocumented in user-facing material.\n *\n * Fail-open: a malformed override (typo in a dev shell, bad CI config)\n * silently falls back to the production backend rather than throwing,\n * matching the telemetry layer's broader silent-on-failure contract.\n */\nexport function resolveTelemetryEndpoint(\n env: Readonly<Record<string, string | undefined>> = process.env,\n): string {\n const override = env['PRISMA_NEXT_TELEMETRY_ENDPOINT'];\n const base = override !== undefined && override.length > 0 ? override : TELEMETRY_BACKEND_URL;\n try {\n return new URL(TELEMETRY_ENDPOINT_PATH, base).toString();\n } catch {\n return new URL(TELEMETRY_ENDPOINT_PATH, TELEMETRY_BACKEND_URL).toString();\n }\n}\n","import type { UserConfig } from './user-config';\n\n/**\n * Why telemetry was disabled. Useful for debug-mode logging in the\n * parent; never surfaces to users.\n */\nexport type GatingDisabledReason = 'env-override' | 'stored-opt-out' | 'default-off';\n\nexport type GatingResolution =\n | { readonly enabled: true }\n | { readonly enabled: false; readonly reason: GatingDisabledReason };\n\nexport interface GatingInputs {\n /**\n * Environment-variable lookups the resolver consults. Tests pass a\n * literal record; production passes `process.env`. The two opt-out\n * signals are `PRISMA_NEXT_DISABLE_TELEMETRY` (Prisma-specific) and\n * `DO_NOT_TRACK` (community convention).\n */\n readonly env: Readonly<Record<string, string | undefined>>;\n /** Result of `readUserConfig()` — file-missing tolerated as `{}`. */\n readonly config: UserConfig;\n}\n\n/**\n * A `PRISMA_NEXT_DISABLE_TELEMETRY` value counts as an opt-out only if\n * it parses as a truthy string. The set-but-falsy spellings (`''`,\n * `'0'`, `'false'`) are intentionally treated as not-set so a parent\n * shell that exports the variable to a benign value doesn't accidentally\n * disable telemetry for child processes.\n */\nfunction isTruthyOptOut(raw: string | undefined): boolean {\n if (raw === undefined) return false;\n const normalised = raw.trim().toLowerCase();\n if (normalised === '') return false;\n if (normalised === '0') return false;\n if (normalised === 'false') return false;\n return true;\n}\n\n/**\n * Pure-function resolution of the gating decision. Same input → same\n * output; no I/O. The caller is responsible for reading the env and the\n * user config.\n *\n * Decision order:\n * 1. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or\n * `DO_NOT_TRACK=1`) → disabled.\n * 2. Stored `enableTelemetry === true` → enabled.\n * 3. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).\n * 4. Stored `enableTelemetry === undefined` (file missing, or field\n * not set) → disabled (`default-off`).\n *\n * Telemetry is enabled only when no env override is active **and**\n * `enableTelemetry` is explicitly `true`.\n */\nexport function resolveGating(inputs: GatingInputs): GatingResolution {\n if (\n isTruthyOptOut(inputs.env['PRISMA_NEXT_DISABLE_TELEMETRY']) ||\n inputs.env['DO_NOT_TRACK'] === '1'\n ) {\n return { enabled: false, reason: 'env-override' };\n }\n if (inputs.config.enableTelemetry === true) {\n return { enabled: true };\n }\n if (inputs.config.enableTelemetry === false) {\n return { enabled: false, reason: 'stored-opt-out' };\n }\n return { enabled: false, reason: 'default-off' };\n}\n","export interface CommanderOptionShape {\n /** Commander's option attribute name, e.g. `dryRun` for `--dry-run`. */\n readonly attributeName: string;\n /** Commander's long, user-facing flag spelling, e.g. `--dry-run` or `--no-install`. */\n readonly longName: string | null;\n /** Commander's value source for this option. Only `cli` is user-supplied. */\n readonly source: string | null;\n}\n\n/**\n * Input shape: a thin projection of commander's parsed-result surface.\n * The parent extracts the command path, positional args, and per-option\n * metadata from the leaf command. The sanitiser never consumes raw\n * argv, never reads `process.argv`, and never sees flag values.\n */\nexport interface CommanderResultShape {\n /**\n * The full command path from the root program to the leaf, including\n * the root program name as the first element (the sanitiser drops it).\n * Example: `['prisma-next', 'migration', 'new']`.\n */\n readonly commandPath: readonly string[];\n /**\n * Positional arguments commander parsed for the leaf command.\n * **Intentionally never read.** Accepted so the call site doesn't have\n * to think about whether to pass it; the sanitiser's contract is that\n * positionals never leave the parent process.\n */\n readonly positionalArgs: readonly string[];\n /**\n * Per-option Commander metadata. The sanitiser emits only options whose\n * source is `cli`, and uses `longName` so telemetry sees user-facing\n * names (`dry-run`, `connection-string`, `no-install`) rather than\n * Commander's internal camelCase attribute names or defaulted options.\n */\n readonly options: readonly CommanderOptionShape[];\n}\n\n/**\n * Output shape: the sanitised projection that flows into the telemetry\n * payload. Two fields only — command name (space-delimited subcommand\n * path) and flag names (in commander's option declaration order).\n */\nexport interface SanitisedCommand {\n readonly command: string;\n readonly flags: readonly string[];\n}\n\nfunction flagNameFromLongName(longName: string | null): string | null {\n if (longName === null || !longName.startsWith('--')) return null;\n const withoutPrefix = longName.slice(2);\n return withoutPrefix.length > 0 ? withoutPrefix : null;\n}\n\n/**\n * Project commander's parsed result into the wire-shape command and\n * flag-name list. Pure; the only allowed inputs are the fields of\n * `CommanderResultShape`.\n *\n * Sanitiser contract — no flag values, no positionals, no raw argv:\n * - Drop the root program name (`commandPath[0]`); the wire ships\n * `migration new`, not `prisma-next migration new`.\n * - Emit only options whose Commander source is `cli`.\n * - Emit the long user-facing flag spelling without the `--` prefix;\n * never emit Commander's camelCase attribute names.\n * - `positionalArgs` is accepted but never consumed; the field exists\n * in the input type to make it obvious at the call site that\n * positionals were deliberately excluded.\n */\nexport function sanitizeCommanderResult(input: CommanderResultShape): SanitisedCommand {\n const command = input.commandPath.slice(1).join(' ');\n const flags = input.options.flatMap((option) => {\n if (option.source !== 'cli') return [];\n const flagName = flagNameFromLongName(option.longName);\n return flagName === null ? [] : [flagName];\n });\n return { command, flags };\n}\n","import { randomUUID } from 'node:crypto';\nimport { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { dirname, join } from 'pathe';\n\n/**\n * The user-level config file. Persists the consent flag and the\n * installation UUID together so an env-var opt-out never mutates disk,\n * and so an opt-in → opt-out → opt-in cycle keeps the same UUID (correct\n * for MAU continuity).\n *\n * Readers tolerate unknown fields for forward compat; writers merge\n * partials into the existing object so unknown fields are preserved.\n */\nexport interface UserConfig {\n readonly enableTelemetry?: boolean;\n readonly installationId?: string;\n readonly [key: string]: unknown;\n}\n\nconst APP_DIR = 'prisma-next';\nconst FILE_NAME = 'config.json';\n\n/**\n * Resolves the user-level config directory:\n * - Windows: `%APPDATA%\\prisma-next\\` (fallback: `%USERPROFILE%\\AppData\\Roaming\\prisma-next\\`).\n * - Unix (incl. macOS): `$XDG_CONFIG_HOME/prisma-next/` if set, else\n * `$HOME/.config/prisma-next/` per the XDG Base Directory Specification.\n *\n * The spec deliberately picks XDG over the macOS-native\n * `~/Library/Preferences/` convention so the path resolution is\n * test-overridable via `XDG_CONFIG_HOME` and matches the documented\n * behaviour on all *nix platforms. We intentionally do not use\n * `env-paths`: its macOS choice of `~/Library/Preferences` is for\n * OS-managed plist preferences, not arbitrary JSON files. Apple documents\n * that apps access that directory through system APIs such as\n * `NSUserDefaults`, while cross-platform CLI and developer tools conventionally\n * use `~/.config` on macOS too:\n * https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/MacOSXDirectories/MacOSXDirectories.html\n */\nfunction configDir(): string {\n if (process.platform === 'win32') {\n const appData = process.env['APPDATA'];\n if (appData !== undefined && appData.length > 0) {\n return join(appData, APP_DIR);\n }\n return join(homedir(), 'AppData', 'Roaming', APP_DIR);\n }\n const xdg = process.env['XDG_CONFIG_HOME'];\n if (xdg !== undefined && xdg.length > 0) {\n return join(xdg, APP_DIR);\n }\n return join(homedir(), '.config', APP_DIR);\n}\n\n/**\n * Path to the user-level config file. Resolved per call so test\n * harnesses can mutate `$XDG_CONFIG_HOME` between cases.\n */\nexport function userConfigPath(): string {\n return join(configDir(), FILE_NAME);\n}\n\n/**\n * Reads the user-level config. File-missing, unreadable, or malformed →\n * `{}` (the absence of consent is the same answer in every error mode).\n * Unknown fields from a future client are passed through verbatim.\n */\nexport function readUserConfig(): UserConfig {\n const path = userConfigPath();\n if (!existsSync(path)) return {};\n try {\n const raw = readFileSync(path, 'utf-8');\n const parsed: unknown = JSON.parse(raw);\n if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as UserConfig;\n }\n return {};\n } catch {\n return {};\n }\n}\n\n/**\n * Merges `partial` into the current config and writes the result\n * atomically (temp file + rename) so a crash mid-write never leaves a\n * half-baked file readable on disk. Unknown fields already on disk are\n * preserved.\n *\n * When `partial.enableTelemetry === true` and no `installationId` is\n * stored yet, generates a v4 random UUID and persists both fields in\n * the same write. An existing `installationId` is never rotated.\n *\n * `writeUserConfig({ enableTelemetry: false })` does *not* generate an\n * installation id — only an affirmative consent answer produces one.\n */\nexport function writeUserConfig(partial: Partial<UserConfig>): void {\n const current = readUserConfig();\n const merged: Record<string, unknown> = { ...current, ...partial };\n if (partial.enableTelemetry === true && merged['installationId'] === undefined) {\n merged['installationId'] = randomUUID();\n }\n const path = userConfigPath();\n const dir = dirname(path);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n const tmpPath = `${path}.${process.pid}.tmp`;\n writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\\n`, 'utf-8');\n renameSync(tmpPath, path);\n}\n","import { fork } from 'node:child_process';\nimport { fileURLToPath } from 'node:url';\nimport { ifDefined } from '@prisma-next/utils/defined';\nimport { resolveTelemetryEndpoint } from './endpoint';\nimport { resolveGating } from './gating';\nimport type { ParentToSenderPayload } from './payload';\nimport { type CommanderResultShape, sanitizeCommanderResult } from './sanitize';\nimport { readUserConfig, type UserConfig } from './user-config';\n\n/**\n * Inputs the CLI entry point hands the telemetry layer at command\n * start. The CLI is responsible for stitching commander's result and\n * the project root together; the telemetry module does no I/O of its\n * own except for the user-config read (skipped when `userConfig` is\n * provided). `extensions` is deliberately absent: the detached child\n * loads `prisma-next.config.*` via c12 itself and derives the\n * extension-pack ids from the validated config — see the rationale\n * on `ParentToSenderPayload` for why c12 lives in the child rather\n * than on the parent's hot path.\n *\n * `databaseTarget` is an optional parent-side override forwarded to\n * the child. Set by `fireTelemetryAfterInitConsent` (where the\n * config file does not yet exist on disk); left unset by the\n * preAction-hook path so the child's c12 load supplies the value.\n */\nexport interface RunTelemetryInputs {\n /** Sanitised commander snapshot — see `CommanderResultShape`. */\n readonly command: CommanderResultShape;\n /** This CLI's own version (from its `package.json`). */\n readonly version: string;\n /** Absolute path of the project root (typically `process.cwd()`). */\n readonly projectRoot: string;\n /**\n * Optional parent-side override for the c12-derived database target,\n * forwarded verbatim to the child sender. Wins over the child's\n * c12-derived value when present; `undefined` means \"no override\".\n */\n readonly databaseTarget?: string;\n /**\n * Path to the sender entry compiled into this package's `dist/`.\n * Resolved by the caller because the compiled sender lives at\n * `<package>/dist/sender.mjs` and only the consumer knows its own\n * `import.meta.url`.\n */\n readonly senderPath: string;\n /**\n * `isCI()` result from the consumer. Telemetry is suppressed when\n * `true` regardless of the stored consent answer — CI environments\n * never emit (matches the colour-output convention's CI suppression).\n */\n readonly isCI: boolean;\n /** Process env to read for opt-out signals. Defaults to `process.env`. */\n readonly env?: Readonly<Record<string, string | undefined>>;\n /** Cached user config when the caller already read it to resolve gates before other work. */\n readonly userConfig?: UserConfig;\n}\n\n/**\n * Best-effort telemetry spawn at command start. Returns synchronously —\n * the fork runs in the background and never blocks the parent. Every\n * failure mode is swallowed; the parent's stdout/stderr is untouched in\n * normal operation, the only escape valve being\n * `PRISMA_NEXT_DEBUG=1` which routes diagnostics to stderr.\n *\n * Returns the spawn outcome so debug-mode logging and the test-harness\n * probe (which verifies test runs short-circuit the fork) can inspect\n * the decision without scraping stderr.\n */\nexport type TelemetryRunOutcome =\n | { readonly spawned: true }\n | { readonly spawned: false; readonly reason: 'gated-off' | 'ci' | 'fork-failed' };\n\nexport function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome {\n const env = inputs.env ?? process.env;\n\n if (inputs.isCI) {\n return { spawned: false, reason: 'ci' };\n }\n\n const config = inputs.userConfig ?? readUserConfig();\n const gating = resolveGating({ env, config });\n if (!gating.enabled) {\n return { spawned: false, reason: 'gated-off' };\n }\n\n const sanitised = sanitizeCommanderResult(inputs.command);\n // Gating already confirmed enableTelemetry === true, so installationId\n // must be set (writeUserConfig generates it alongside that field).\n // Defence-in-depth: if a stale config has the flag but no id, skip\n // rather than send a junk event.\n if (typeof config.installationId !== 'string' || config.installationId.length === 0) {\n return { spawned: false, reason: 'gated-off' };\n }\n\n const payload: ParentToSenderPayload = {\n installationId: config.installationId,\n version: inputs.version,\n command: sanitised.command,\n flags: sanitised.flags,\n projectRoot: inputs.projectRoot,\n endpoint: resolveTelemetryEndpoint(env),\n ...ifDefined('databaseTarget', inputs.databaseTarget),\n };\n\n try {\n const child = fork(inputs.senderPath, [], {\n detached: true,\n stdio: ['pipe', 'ignore', 'ignore', 'ipc'],\n });\n child.send(payload, (err) => {\n if (err !== null && process.env['PRISMA_NEXT_DEBUG'] === '1') {\n process.stderr.write(`[cli-telemetry] parent send error: ${String(err)}\\n`);\n }\n });\n child.disconnect();\n child.unref();\n return { spawned: true };\n } catch (err) {\n if (process.env['PRISMA_NEXT_DEBUG'] === '1') {\n process.stderr.write(`[cli-telemetry] parent fork failed: ${String(err)}\\n`);\n }\n return { spawned: false, reason: 'fork-failed' };\n }\n}\n\n/**\n * Resolve the path to the compiled sender entry relative to a consumer\n * that has captured its own `import.meta.url`. The CLI's\n * `tsdown`-emitted entry sits at `<package>/dist/sender.mjs`; the\n * consumer asks `senderModuleUrl()` and forwards the result to\n * `runTelemetry({ senderPath })`.\n */\nexport function senderModuleUrl(importMetaUrl: string): string {\n return fileURLToPath(new URL('./sender.mjs', importMetaUrl));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/endpoint.ts","../../src/gating.ts","../../src/sanitize.ts","../../src/user-config.ts","../../src/spawn.ts"],"sourcesContent":["/**\n * Production endpoint pinned to the deployed Prisma Compute backend.\n * Compiled as a build-time constant; not user-configurable.\n */\nexport const TELEMETRY_BACKEND_URL = 'https://cmpbfbsdp09hr3jf7pojjs5qs.ewr.prisma.build';\n\n/**\n * Path within the backend that accepts telemetry POSTs.\n */\nexport const TELEMETRY_ENDPOINT_PATH = '/events';\n\n/**\n * Resolve the full POST URL the sender targets. The\n * `PRISMA_NEXT_TELEMETRY_ENDPOINT` env var is an integration-testing\n * affordance only — it lets the test suite spin up a mock HTTP server\n * on an ephemeral port and point the spawned sender at it. The override\n * is intentionally undocumented in user-facing material.\n *\n * Fail-open: a malformed override (typo in a dev shell, bad CI config)\n * silently falls back to the production backend rather than throwing,\n * matching the telemetry layer's broader silent-on-failure contract.\n */\nexport function resolveTelemetryEndpoint(\n env: Readonly<Record<string, string | undefined>> = process.env,\n): string {\n const override = env['PRISMA_NEXT_TELEMETRY_ENDPOINT'];\n const base = override !== undefined && override.length > 0 ? override : TELEMETRY_BACKEND_URL;\n try {\n return new URL(TELEMETRY_ENDPOINT_PATH, base).toString();\n } catch {\n return new URL(TELEMETRY_ENDPOINT_PATH, TELEMETRY_BACKEND_URL).toString();\n }\n}\n","import type { UserConfig } from './user-config';\n\n/**\n * Why telemetry was disabled. Useful for debug-mode logging in the\n * parent; never surfaces to users.\n */\nexport type GatingDisabledReason = 'env-override' | 'stored-opt-out';\n\nexport type GatingResolution =\n | { readonly enabled: true }\n | { readonly enabled: false; readonly reason: GatingDisabledReason };\n\nexport interface GatingInputs {\n /**\n * Environment-variable lookups the resolver consults. Tests pass a\n * literal record; production passes `process.env`. The two opt-out\n * signals are `PRISMA_NEXT_DISABLE_TELEMETRY` (Prisma-specific) and\n * `DO_NOT_TRACK` (community convention).\n */\n readonly env: Readonly<Record<string, string | undefined>>;\n /** Result of `readUserConfig()` — file-missing tolerated as `{}`. */\n readonly config: UserConfig;\n}\n\n/**\n * A `PRISMA_NEXT_DISABLE_TELEMETRY` value counts as an opt-out only if\n * it parses as a truthy string. The set-but-falsy spellings (`''`,\n * `'0'`, `'false'`) are intentionally treated as not-set so a parent\n * shell that exports the variable to a benign value doesn't accidentally\n * disable telemetry for child processes.\n */\nfunction isTruthyOptOut(raw: string | undefined): boolean {\n if (raw === undefined) return false;\n const normalised = raw.trim().toLowerCase();\n if (normalised === '') return false;\n if (normalised === '0') return false;\n if (normalised === 'false') return false;\n return true;\n}\n\n/**\n * Pure-function resolution of the gating decision. Same input → same\n * output; no I/O. The caller is responsible for reading the env and the\n * user config.\n *\n * Decision order:\n * 1. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or\n * `DO_NOT_TRACK=1`) → disabled. The env check runs first, so an\n * opt-out env var wins over any stored or unset preference.\n * 2. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).\n * 3. Stored `enableTelemetry === true` → enabled.\n * 4. Stored `enableTelemetry === undefined` (file missing, or field\n * not set) → ENABLED. This is the opt-out default: absence of an\n * explicit choice means telemetry is on. This is the load-bearing,\n * counter-intuitive branch — do not \"fix\" it to default-off.\n *\n * Telemetry is disabled only when an env override is active or\n * `enableTelemetry` is explicitly `false`.\n */\nexport function resolveGating(inputs: GatingInputs): GatingResolution {\n if (\n isTruthyOptOut(inputs.env['PRISMA_NEXT_DISABLE_TELEMETRY']) ||\n inputs.env['DO_NOT_TRACK'] === '1'\n ) {\n return { enabled: false, reason: 'env-override' };\n }\n if (inputs.config.enableTelemetry === false) {\n return { enabled: false, reason: 'stored-opt-out' };\n }\n return { enabled: true };\n}\n","export interface CommanderOptionShape {\n /** Commander's option attribute name, e.g. `dryRun` for `--dry-run`. */\n readonly attributeName: string;\n /** Commander's long, user-facing flag spelling, e.g. `--dry-run` or `--no-install`. */\n readonly longName: string | null;\n /** Commander's value source for this option. Only `cli` is user-supplied. */\n readonly source: string | null;\n}\n\n/**\n * Input shape: a thin projection of commander's parsed-result surface.\n * The parent extracts the command path, positional args, and per-option\n * metadata from the leaf command. The sanitiser never consumes raw\n * argv, never reads `process.argv`, and never sees flag values.\n */\nexport interface CommanderResultShape {\n /**\n * The full command path from the root program to the leaf, including\n * the root program name as the first element (the sanitiser drops it).\n * Example: `['prisma-next', 'migration', 'new']`.\n */\n readonly commandPath: readonly string[];\n /**\n * Positional arguments commander parsed for the leaf command.\n * **Intentionally never read.** Accepted so the call site doesn't have\n * to think about whether to pass it; the sanitiser's contract is that\n * positionals never leave the parent process.\n */\n readonly positionalArgs: readonly string[];\n /**\n * Per-option Commander metadata. The sanitiser emits only options whose\n * source is `cli`, and uses `longName` so telemetry sees user-facing\n * names (`dry-run`, `connection-string`, `no-install`) rather than\n * Commander's internal camelCase attribute names or defaulted options.\n */\n readonly options: readonly CommanderOptionShape[];\n}\n\n/**\n * Output shape: the sanitised projection that flows into the telemetry\n * payload. Two fields only — command name (space-delimited subcommand\n * path) and flag names (in commander's option declaration order).\n */\nexport interface SanitisedCommand {\n readonly command: string;\n readonly flags: readonly string[];\n}\n\nfunction flagNameFromLongName(longName: string | null): string | null {\n if (longName === null || !longName.startsWith('--')) return null;\n const withoutPrefix = longName.slice(2);\n return withoutPrefix.length > 0 ? withoutPrefix : null;\n}\n\n/**\n * Project commander's parsed result into the wire-shape command and\n * flag-name list. Pure; the only allowed inputs are the fields of\n * `CommanderResultShape`.\n *\n * Sanitiser contract — no flag values, no positionals, no raw argv:\n * - Drop the root program name (`commandPath[0]`); the wire ships\n * `migration new`, not `prisma-next migration new`.\n * - Emit only options whose Commander source is `cli`.\n * - Emit the long user-facing flag spelling without the `--` prefix;\n * never emit Commander's camelCase attribute names.\n * - `positionalArgs` is accepted but never consumed; the field exists\n * in the input type to make it obvious at the call site that\n * positionals were deliberately excluded.\n */\nexport function sanitizeCommanderResult(input: CommanderResultShape): SanitisedCommand {\n const command = input.commandPath.slice(1).join(' ');\n const flags = input.options.flatMap((option) => {\n if (option.source !== 'cli') return [];\n const flagName = flagNameFromLongName(option.longName);\n return flagName === null ? [] : [flagName];\n });\n return { command, flags };\n}\n","import { randomUUID } from 'node:crypto';\nimport { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { dirname, join } from 'pathe';\n\n/**\n * The user-level config file. Persists the telemetry flag and the\n * installation UUID. Under the opt-out model the flag stays `undefined`\n * until the user makes an explicit choice (default-on first run mints\n * only the id via {@link ensureInstallationId}), and an env-var opt-out\n * never mutates disk. Once the id exists it survives any\n * on → off → on cycle, keeping the same UUID (correct for MAU continuity).\n *\n * Readers tolerate unknown fields for forward compat; writers merge\n * partials into the existing object so unknown fields are preserved.\n */\nexport interface UserConfig {\n readonly enableTelemetry?: boolean;\n readonly installationId?: string;\n readonly [key: string]: unknown;\n}\n\nconst APP_DIR = 'prisma-next';\nconst FILE_NAME = 'config.json';\n\n/**\n * Resolves the user-level config directory:\n * - Windows: `%APPDATA%\\prisma-next\\` (fallback: `%USERPROFILE%\\AppData\\Roaming\\prisma-next\\`).\n * - Unix (incl. macOS): `$XDG_CONFIG_HOME/prisma-next/` if set, else\n * `$HOME/.config/prisma-next/` per the XDG Base Directory Specification.\n *\n * The spec deliberately picks XDG over the macOS-native\n * `~/Library/Preferences/` convention so the path resolution is\n * test-overridable via `XDG_CONFIG_HOME` and matches the documented\n * behaviour on all *nix platforms. We intentionally do not use\n * `env-paths`: its macOS choice of `~/Library/Preferences` is for\n * OS-managed plist preferences, not arbitrary JSON files. Apple documents\n * that apps access that directory through system APIs such as\n * `NSUserDefaults`, while cross-platform CLI and developer tools conventionally\n * use `~/.config` on macOS too:\n * https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/MacOSXDirectories/MacOSXDirectories.html\n */\nfunction configDir(): string {\n if (process.platform === 'win32') {\n const appData = process.env['APPDATA'];\n if (appData !== undefined && appData.length > 0) {\n return join(appData, APP_DIR);\n }\n return join(homedir(), 'AppData', 'Roaming', APP_DIR);\n }\n const xdg = process.env['XDG_CONFIG_HOME'];\n if (xdg !== undefined && xdg.length > 0) {\n return join(xdg, APP_DIR);\n }\n return join(homedir(), '.config', APP_DIR);\n}\n\n/**\n * Path to the user-level config file. Resolved per call so test\n * harnesses can mutate `$XDG_CONFIG_HOME` between cases.\n */\nexport function userConfigPath(): string {\n return join(configDir(), FILE_NAME);\n}\n\n/**\n * Reads the user-level config. File-missing, unreadable, or malformed →\n * `{}` (the absence of consent is the same answer in every error mode).\n * Unknown fields from a future client are passed through verbatim.\n */\nexport function readUserConfig(): UserConfig {\n const path = userConfigPath();\n if (!existsSync(path)) return {};\n try {\n const raw = readFileSync(path, 'utf-8');\n const parsed: unknown = JSON.parse(raw);\n if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as UserConfig;\n }\n return {};\n } catch {\n return {};\n }\n}\n\n/**\n * Merges `partial` into the current config and writes the result\n * atomically (temp file + rename) so a crash mid-write never leaves a\n * half-baked file readable on disk. Unknown fields already on disk are\n * preserved.\n *\n * When `partial.enableTelemetry === true` and no `installationId` is\n * stored yet, generates a v4 random UUID and persists both fields in\n * the same write. An existing `installationId` is never rotated. This is\n * the *explicit-consent* mint path: a `false` answer\n * (`writeUserConfig({ enableTelemetry: false })`) writes no id, and a bare\n * `writeUserConfig({ installationId })` mints nothing extra. The default-on\n * first-send path mints its id separately via {@link ensureInstallationId},\n * which records no consent answer.\n */\nexport function writeUserConfig(partial: Partial<UserConfig>): void {\n const current = readUserConfig();\n const merged: Record<string, unknown> = { ...current, ...partial };\n if (partial.enableTelemetry === true && merged['installationId'] === undefined) {\n merged['installationId'] = randomUUID();\n }\n const path = userConfigPath();\n const dir = dirname(path);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n const tmpPath = `${path}.${process.pid}.tmp`;\n writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\\n`, 'utf-8');\n renameSync(tmpPath, path);\n}\n\n/**\n * Returns the stored `installationId`, minting and persisting a fresh v4\n * UUID when none exists yet. Crucially, this persists *only* the id —\n * `enableTelemetry` is left untouched (stays `undefined` on a default-on\n * first run), so the interactive `init` consent prompt is not wrongly\n * suppressed and no explicit consent the user never gave is recorded.\n *\n * Used by the default-on first-run fire path: the gate has already\n * resolved enabled, so this only ever runs when telemetry is on.\n */\nexport function ensureInstallationId(): string {\n const existing = readUserConfig().installationId;\n if (typeof existing === 'string' && existing.length > 0) {\n return existing;\n }\n const installationId = randomUUID();\n writeUserConfig({ installationId });\n return installationId;\n}\n","import { fork } from 'node:child_process';\nimport { fileURLToPath } from 'node:url';\nimport { ifDefined } from '@prisma-next/utils/defined';\nimport { resolveTelemetryEndpoint } from './endpoint';\nimport { resolveGating } from './gating';\nimport type { ParentToSenderPayload } from './payload';\nimport { type CommanderResultShape, sanitizeCommanderResult } from './sanitize';\nimport { readUserConfig, type UserConfig } from './user-config';\n\n/**\n * Inputs the CLI entry point hands the telemetry layer at command\n * start. The CLI is responsible for stitching commander's result and\n * the project root together; the telemetry module does no I/O of its\n * own except for the user-config read (skipped when `userConfig` is\n * provided). `extensions` is deliberately absent: the detached child\n * loads `prisma-next.config.*` via c12 itself and derives the\n * extension-pack ids from the validated config — see the rationale\n * on `ParentToSenderPayload` for why c12 lives in the child rather\n * than on the parent's hot path.\n *\n * `databaseTarget` is an optional parent-side override forwarded to\n * the child. Set by `fireTelemetryAfterInitConsent` (where the\n * config file does not yet exist on disk); left unset by the\n * preAction-hook path so the child's c12 load supplies the value.\n */\nexport interface RunTelemetryInputs {\n /** Sanitised commander snapshot — see `CommanderResultShape`. */\n readonly command: CommanderResultShape;\n /** This CLI's own version (from its `package.json`). */\n readonly version: string;\n /** Absolute path of the project root (typically `process.cwd()`). */\n readonly projectRoot: string;\n /**\n * Optional parent-side override for the c12-derived database target,\n * forwarded verbatim to the child sender. Wins over the child's\n * c12-derived value when present; `undefined` means \"no override\".\n */\n readonly databaseTarget?: string;\n /**\n * Path to the sender entry compiled into this package's `dist/`.\n * Resolved by the caller because the compiled sender lives at\n * `<package>/dist/sender.mjs` and only the consumer knows its own\n * `import.meta.url`.\n */\n readonly senderPath: string;\n /**\n * `isCI()` result from the consumer. Telemetry is suppressed when\n * `true` regardless of the stored consent answer — CI environments\n * never emit (matches the colour-output convention's CI suppression).\n */\n readonly isCI: boolean;\n /** Process env to read for opt-out signals. Defaults to `process.env`. */\n readonly env?: Readonly<Record<string, string | undefined>>;\n /** Cached user config when the caller already read it to resolve gates before other work. */\n readonly userConfig?: UserConfig;\n}\n\n/**\n * Best-effort telemetry spawn at command start. Returns synchronously —\n * the fork runs in the background and never blocks the parent. Every\n * failure mode is swallowed; the parent's stdout/stderr is untouched in\n * normal operation, the only escape valve being\n * `PRISMA_NEXT_DEBUG=1` which routes diagnostics to stderr.\n *\n * Returns the spawn outcome so debug-mode logging and the test-harness\n * probe (which verifies test runs short-circuit the fork) can inspect\n * the decision without scraping stderr.\n */\nexport type TelemetryRunOutcome =\n | { readonly spawned: true }\n | { readonly spawned: false; readonly reason: 'gated-off' | 'ci' | 'fork-failed' };\n\nexport function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome {\n const env = inputs.env ?? process.env;\n\n if (inputs.isCI) {\n return { spawned: false, reason: 'ci' };\n }\n\n const config = inputs.userConfig ?? readUserConfig();\n const gating = resolveGating({ env, config });\n if (!gating.enabled) {\n return { spawned: false, reason: 'gated-off' };\n }\n\n const sanitised = sanitizeCommanderResult(inputs.command);\n // Gating resolved enabled, so installationId should be set: the parent\n // fire path mints it before calling runTelemetry on the default-on\n // first run, and the init consent flow mints it on explicit opt-in.\n // Defence-in-depth: a missing id here means a stale/corrupt config, so\n // skip rather than send a junk event.\n if (typeof config.installationId !== 'string' || config.installationId.length === 0) {\n return { spawned: false, reason: 'gated-off' };\n }\n\n const payload: ParentToSenderPayload = {\n installationId: config.installationId,\n version: inputs.version,\n command: sanitised.command,\n flags: sanitised.flags,\n projectRoot: inputs.projectRoot,\n endpoint: resolveTelemetryEndpoint(env),\n ...ifDefined('databaseTarget', inputs.databaseTarget),\n };\n\n try {\n const child = fork(inputs.senderPath, [], {\n detached: true,\n stdio: ['pipe', 'ignore', 'ignore', 'ipc'],\n });\n child.send(payload, (err) => {\n if (err !== null && process.env['PRISMA_NEXT_DEBUG'] === '1') {\n process.stderr.write(`[cli-telemetry] parent send error: ${String(err)}\\n`);\n }\n });\n child.disconnect();\n child.unref();\n return { spawned: true };\n } catch (err) {\n if (process.env['PRISMA_NEXT_DEBUG'] === '1') {\n process.stderr.write(`[cli-telemetry] parent fork failed: ${String(err)}\\n`);\n }\n return { spawned: false, reason: 'fork-failed' };\n }\n}\n\n/**\n * Resolve the path to the compiled sender entry relative to a consumer\n * that has captured its own `import.meta.url`. The CLI's\n * `tsdown`-emitted entry sits at `<package>/dist/sender.mjs`; the\n * consumer asks `senderModuleUrl()` and forwards the result to\n * `runTelemetry({ senderPath })`.\n */\nexport function senderModuleUrl(importMetaUrl: string): string {\n return fileURLToPath(new URL('./sender.mjs', importMetaUrl));\n}\n"],"mappings":"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"}
package/dist/sender.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as buildTelemetryEventFromProcess } from "./enrich-CGZ8Za8Y.mjs";
1
+ import { t as buildTelemetryEventFromProcess } from "./enrich-BhHLLBfL.mjs";
2
2
  import { type } from "arktype";
3
3
  //#region src/payload.ts
4
4
  /**
@@ -85,8 +85,7 @@ process.once("message", (message) => {
85
85
  }
86
86
  postEvent(message).catch((err) => debugLog("post threw", err)).finally(exitClean);
87
87
  });
88
- const SENDER_IDLE_EXIT_MS = REQUEST_TIMEOUT_MS * 2;
89
- setTimeout(exitClean, SENDER_IDLE_EXIT_MS).unref();
88
+ setTimeout(exitClean, REQUEST_TIMEOUT_MS * 2).unref();
90
89
  //#endregion
91
90
  export {};
92
91
 
@@ -1 +1 @@
1
- {"version":3,"file":"sender.mjs","names":[],"sources":["../src/payload.ts","../src/sender.ts"],"sourcesContent":["import { type } from 'arktype';\n\n/**\n * Wire-shape payload the parent IPC-sends to the forked child sender.\n * Mirrors only the fields the parent has naturally in hand at command\n * start: installation id, sanitised command + flags, CLI version, and\n * the project root the child uses to discover everything else. The\n * child probes its own process (runtime/os/arch, package manager, ts\n * version, agent) and reads the user's `prisma-next.config.*` via\n * c12 to derive `databaseTarget` and `extensions`.\n *\n * Loading c12 on the parent side would put a `loadConfig()` await on\n * the command's hot path between gate resolution and `fork()`,\n * opening a race against any CLI command that throws synchronously\n * before that await resolves (the parent exits before forking the\n * sender, and the telemetry event is lost). Moving the load into the\n * detached child eliminates that race; the trade is that the child\n * now evaluates user TS config code, so it's gated behind the same\n * privacy checks the parent already resolved before forking.\n *\n * `databaseTarget` is an optional parent-side override for the\n * c12-derived value: the first-`init` invocation supplies the\n * prompt-chosen target via this field because the config file does\n * not yet exist on disk at that moment. Every other invocation\n * leaves it unset (`undefined`) and the child's c12 load determines\n * the value — there is no third state, so the field's type is\n * `string | undefined`, not `string | null | undefined`.\n *\n * Both sides version-couple on this shape because the IPC carrier is\n * structured-cloned by Node and there's no on-wire compat to maintain.\n */\nexport interface ParentToSenderPayload {\n readonly installationId: string;\n readonly version: string;\n readonly command: string;\n readonly flags: readonly string[];\n /**\n * Absolute path of the user's project. The child reads\n * `<projectRoot>/package.json` for `tsVersion` and loads\n * `<projectRoot>/prisma-next.config.*` via c12 for `databaseTarget`\n * + `extensions`.\n */\n readonly projectRoot: string;\n /** Resolved endpoint URL (already includes the `/events` path). */\n readonly endpoint: string;\n /**\n * Optional parent-side override for the c12-derived database target.\n * Set by `fireTelemetryAfterInitConsent` (the first-`init` path,\n * where the config file is about to be written but doesn't exist\n * yet); left undefined by `fireTelemetryFromPreAction` (steady\n * state, child resolves the value via c12). The wire-format\n * `TelemetryEvent.databaseTarget: string | null` keeps `null` as\n * the on-the-wire \"no target known\" marker, but the IPC override\n * channel only needs two states so it's `string | undefined`.\n */\n readonly databaseTarget?: string;\n}\n\n/**\n * Runtime validator for {@link ParentToSenderPayload}. The child sender\n * uses this to gate `postEvent` so a payload missing a required field\n * cannot silently produce a degraded telemetry event downstream.\n *\n * Mirrors the backend's own arktype schema in spirit: required scalars\n * must be non-empty strings; the optional `databaseTarget` override is\n * `string` when present (no `null` — see the type's doc-block); the\n * string array is validated element-by-element. Size caps are enforced\n * by the backend, not here — IPC is structured-cloned and the\n * parent/child agree on the schema by version-coupling.\n */\nconst requiredString = type.string.moreThanLength(0);\nconst stringArray = type.string.array();\n\nexport const parentToSenderPayloadSchema = type({\n installationId: requiredString,\n version: requiredString,\n command: requiredString,\n flags: stringArray,\n projectRoot: requiredString,\n endpoint: requiredString,\n 'databaseTarget?': type.string,\n});\n\nexport function isParentToSenderPayload(value: unknown): value is ParentToSenderPayload {\n return !(parentToSenderPayloadSchema(value) instanceof type.errors);\n}\n\n/**\n * The full event the child POSTs to the backend. Shape matches the\n * backend's arktype schema (`apps/telemetry-backend/src/schema.ts`).\n */\nexport interface TelemetryEvent {\n readonly installationId: string;\n readonly version: string;\n readonly command: string;\n readonly flags: readonly string[];\n readonly runtimeName: string;\n readonly runtimeVersion: string;\n readonly os: string;\n readonly arch: string;\n readonly packageManager: string | null;\n readonly databaseTarget: string | null;\n readonly tsVersion: string | null;\n readonly agent: string | null;\n readonly extensions: readonly string[];\n}\n","/**\n * Sender script entry — forked into a detached child by the parent CLI via\n * `child_process.fork(senderPath, [], { detached: true, ... })`.\n *\n * Lifecycle:\n * 1. Wait for the parent's IPC `message` event carrying a\n * `ParentToSenderPayload`.\n * 2. Enrich with the local-process probes (runtime, os, arch, agent,\n * package manager, tsVersion).\n * 3. POST the event to the endpoint URL with a hard 1.5 s timeout.\n * 4. Exit 0 unconditionally — successful POST, network failure, server\n * error, parse error of the response, anything else: same outcome.\n *\n * Every error is swallowed; the only escape valve for visibility is\n * `PRISMA_NEXT_DEBUG=1`, which routes diagnostics to stderr. In normal\n * operation no telemetry-originating output ever reaches the user — the\n * parent's stdio map ignores our streams anyway, but we also gate\n * stderr writes behind the debug flag so the same binary is safe to\n * invoke directly outside the spawn flow.\n */\nimport { buildTelemetryEventFromProcess } from './enrich';\nimport { isParentToSenderPayload, type ParentToSenderPayload } from './payload';\n\nconst REQUEST_TIMEOUT_MS = 1500;\n\nfunction debugLog(message: string, error?: unknown): void {\n if (process.env['PRISMA_NEXT_DEBUG'] !== '1') return;\n if (error !== undefined) {\n process.stderr.write(`[cli-telemetry] ${message}: ${String(error)}\\n`);\n } else {\n process.stderr.write(`[cli-telemetry] ${message}\\n`);\n }\n}\n\nasync function postEvent(payload: ParentToSenderPayload): Promise<void> {\n const event = await buildTelemetryEventFromProcess(payload);\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);\n try {\n const response = await fetch(payload.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(event),\n signal: controller.signal,\n });\n debugLog(`sent event: status=${response.status}`);\n } catch (err) {\n debugLog('send failed', err);\n } finally {\n clearTimeout(timer);\n }\n}\n\nfunction exitClean(): void {\n // `process.disconnect()` lets the parent's `.disconnect()` complete\n // without lingering IPC handles when the parent is fast.\n try {\n process.disconnect?.();\n } catch {\n // ignore\n }\n process.exit(0);\n}\n\nprocess.once('message', (message: unknown) => {\n if (!isParentToSenderPayload(message)) {\n debugLog('received malformed payload; exiting');\n exitClean();\n return;\n }\n postEvent(message)\n .catch((err) => debugLog('post threw', err))\n .finally(exitClean);\n});\n\n// Defensive: if the parent never sends a payload (or the IPC channel\n// closes before `message` arrives), exit after a generous grace period\n// so the child process is not stuck holding a handle.\nconst SENDER_IDLE_EXIT_MS = REQUEST_TIMEOUT_MS * 2;\nsetTimeout(exitClean, SENDER_IDLE_EXIT_MS).unref();\n"],"mappings":";;;;;;;;;;;;;;;AAsEA,MAAM,iBAAiB,KAAK,OAAO,eAAe,CAAC;AAGnD,MAAa,8BAA8B,KAAK;CAC9C,gBAAgB;CAChB,SAAS;CACT,SAAS;CACT,OANkB,KAAK,OAAO,MAMvB;CACP,aAAa;CACb,UAAU;CACV,mBAAmB,KAAK;AAC1B,CAAC;AAED,SAAgB,wBAAwB,OAAgD;CACtF,OAAO,EAAE,4BAA4B,KAAK,aAAa,KAAK;AAC9D;;;;;;;;;;;;;;;;;;;;;;;AC9DA,MAAM,qBAAqB;AAE3B,SAAS,SAAS,SAAiB,OAAuB;CACxD,IAAI,QAAQ,IAAI,yBAAyB,KAAK;CAC9C,IAAI,UAAU,KAAA,GACZ,QAAQ,OAAO,MAAM,mBAAmB,QAAQ,IAAI,OAAO,KAAK,EAAE,GAAG;MAErE,QAAQ,OAAO,MAAM,mBAAmB,QAAQ,GAAG;AAEvD;AAEA,eAAe,UAAU,SAA+C;CACtE,MAAM,QAAQ,MAAM,+BAA+B,OAAO;CAC1D,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,QAAQ,iBAAiB,WAAW,MAAM,GAAG,kBAAkB;CACrE,IAAI;EAOF,SAAS,uBAAsB,MANR,MAAM,QAAQ,UAAU;GAC7C,QAAQ;GACR,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C,MAAM,KAAK,UAAU,KAAK;GAC1B,QAAQ,WAAW;EACrB,CAAC,GACuC,QAAQ;CAClD,SAAS,KAAK;EACZ,SAAS,eAAe,GAAG;CAC7B,UAAU;EACR,aAAa,KAAK;CACpB;AACF;AAEA,SAAS,YAAkB;CAGzB,IAAI;EACF,QAAQ,aAAa;CACvB,QAAQ,CAER;CACA,QAAQ,KAAK,CAAC;AAChB;AAEA,QAAQ,KAAK,YAAY,YAAqB;CAC5C,IAAI,CAAC,wBAAwB,OAAO,GAAG;EACrC,SAAS,qCAAqC;EAC9C,UAAU;EACV;CACF;CACA,UAAU,OAAO,EACd,OAAO,QAAQ,SAAS,cAAc,GAAG,CAAC,EAC1C,QAAQ,SAAS;AACtB,CAAC;AAKD,MAAM,sBAAsB,qBAAqB;AACjD,WAAW,WAAW,mBAAmB,EAAE,MAAM"}
1
+ {"version":3,"file":"sender.mjs","names":[],"sources":["../src/payload.ts","../src/sender.ts"],"sourcesContent":["import { type } from 'arktype';\n\n/**\n * Wire-shape payload the parent IPC-sends to the forked child sender.\n * Mirrors only the fields the parent has naturally in hand at command\n * start: installation id, sanitised command + flags, CLI version, and\n * the project root the child uses to discover everything else. The\n * child probes its own process (runtime/os/arch, package manager, ts\n * version, agent) and reads the user's `prisma-next.config.*` via\n * c12 to derive `databaseTarget` and `extensions`.\n *\n * Loading c12 on the parent side would put a `loadConfig()` await on\n * the command's hot path between gate resolution and `fork()`,\n * opening a race against any CLI command that throws synchronously\n * before that await resolves (the parent exits before forking the\n * sender, and the telemetry event is lost). Moving the load into the\n * detached child eliminates that race; the trade is that the child\n * now evaluates user TS config code, so it's gated behind the same\n * privacy checks the parent already resolved before forking.\n *\n * `databaseTarget` is an optional parent-side override for the\n * c12-derived value: the first-`init` invocation supplies the\n * prompt-chosen target via this field because the config file does\n * not yet exist on disk at that moment. Every other invocation\n * leaves it unset (`undefined`) and the child's c12 load determines\n * the value — there is no third state, so the field's type is\n * `string | undefined`, not `string | null | undefined`.\n *\n * Both sides version-couple on this shape because the IPC carrier is\n * structured-cloned by Node and there's no on-wire compat to maintain.\n */\nexport interface ParentToSenderPayload {\n readonly installationId: string;\n readonly version: string;\n readonly command: string;\n readonly flags: readonly string[];\n /**\n * Absolute path of the user's project. The child reads\n * `<projectRoot>/package.json` for `tsVersion` and loads\n * `<projectRoot>/prisma-next.config.*` via c12 for `databaseTarget`\n * + `extensions`.\n */\n readonly projectRoot: string;\n /** Resolved endpoint URL (already includes the `/events` path). */\n readonly endpoint: string;\n /**\n * Optional parent-side override for the c12-derived database target.\n * Set by `fireTelemetryAfterInitConsent` (the first-`init` path,\n * where the config file is about to be written but doesn't exist\n * yet); left undefined by `fireTelemetryFromPreAction` (steady\n * state, child resolves the value via c12). The wire-format\n * `TelemetryEvent.databaseTarget: string | null` keeps `null` as\n * the on-the-wire \"no target known\" marker, but the IPC override\n * channel only needs two states so it's `string | undefined`.\n */\n readonly databaseTarget?: string;\n}\n\n/**\n * Runtime validator for {@link ParentToSenderPayload}. The child sender\n * uses this to gate `postEvent` so a payload missing a required field\n * cannot silently produce a degraded telemetry event downstream.\n *\n * Mirrors the backend's own arktype schema in spirit: required scalars\n * must be non-empty strings; the optional `databaseTarget` override is\n * `string` when present (no `null` — see the type's doc-block); the\n * string array is validated element-by-element. Size caps are enforced\n * by the backend, not here — IPC is structured-cloned and the\n * parent/child agree on the schema by version-coupling.\n */\nconst requiredString = type.string.moreThanLength(0);\nconst stringArray = type.string.array();\n\nexport const parentToSenderPayloadSchema = type({\n installationId: requiredString,\n version: requiredString,\n command: requiredString,\n flags: stringArray,\n projectRoot: requiredString,\n endpoint: requiredString,\n 'databaseTarget?': type.string,\n});\n\nexport function isParentToSenderPayload(value: unknown): value is ParentToSenderPayload {\n return !(parentToSenderPayloadSchema(value) instanceof type.errors);\n}\n\n/**\n * The full event the child POSTs to the backend. Shape matches the\n * backend's arktype schema (`apps/telemetry-backend/src/schema.ts`).\n */\nexport interface TelemetryEvent {\n readonly installationId: string;\n readonly version: string;\n readonly command: string;\n readonly flags: readonly string[];\n readonly runtimeName: string;\n readonly runtimeVersion: string;\n readonly os: string;\n readonly arch: string;\n readonly packageManager: string | null;\n readonly databaseTarget: string | null;\n readonly tsVersion: string | null;\n readonly agent: string | null;\n readonly extensions: readonly string[];\n}\n","/**\n * Sender script entry — forked into a detached child by the parent CLI via\n * `child_process.fork(senderPath, [], { detached: true, ... })`.\n *\n * Lifecycle:\n * 1. Wait for the parent's IPC `message` event carrying a\n * `ParentToSenderPayload`.\n * 2. Enrich with the local-process probes (runtime, os, arch, agent,\n * package manager, tsVersion).\n * 3. POST the event to the endpoint URL with a hard 1.5 s timeout.\n * 4. Exit 0 unconditionally — successful POST, network failure, server\n * error, parse error of the response, anything else: same outcome.\n *\n * Every error is swallowed; the only escape valve for visibility is\n * `PRISMA_NEXT_DEBUG=1`, which routes diagnostics to stderr. In normal\n * operation no telemetry-originating output ever reaches the user — the\n * parent's stdio map ignores our streams anyway, but we also gate\n * stderr writes behind the debug flag so the same binary is safe to\n * invoke directly outside the spawn flow.\n */\nimport { buildTelemetryEventFromProcess } from './enrich';\nimport { isParentToSenderPayload, type ParentToSenderPayload } from './payload';\n\nconst REQUEST_TIMEOUT_MS = 1500;\n\nfunction debugLog(message: string, error?: unknown): void {\n if (process.env['PRISMA_NEXT_DEBUG'] !== '1') return;\n if (error !== undefined) {\n process.stderr.write(`[cli-telemetry] ${message}: ${String(error)}\\n`);\n } else {\n process.stderr.write(`[cli-telemetry] ${message}\\n`);\n }\n}\n\nasync function postEvent(payload: ParentToSenderPayload): Promise<void> {\n const event = await buildTelemetryEventFromProcess(payload);\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);\n try {\n const response = await fetch(payload.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(event),\n signal: controller.signal,\n });\n debugLog(`sent event: status=${response.status}`);\n } catch (err) {\n debugLog('send failed', err);\n } finally {\n clearTimeout(timer);\n }\n}\n\nfunction exitClean(): void {\n // `process.disconnect()` lets the parent's `.disconnect()` complete\n // without lingering IPC handles when the parent is fast.\n try {\n process.disconnect?.();\n } catch {\n // ignore\n }\n process.exit(0);\n}\n\nprocess.once('message', (message: unknown) => {\n if (!isParentToSenderPayload(message)) {\n debugLog('received malformed payload; exiting');\n exitClean();\n return;\n }\n postEvent(message)\n .catch((err) => debugLog('post threw', err))\n .finally(exitClean);\n});\n\n// Defensive: if the parent never sends a payload (or the IPC channel\n// closes before `message` arrives), exit after a generous grace period\n// so the child process is not stuck holding a handle.\nconst SENDER_IDLE_EXIT_MS = REQUEST_TIMEOUT_MS * 2;\nsetTimeout(exitClean, SENDER_IDLE_EXIT_MS).unref();\n"],"mappings":";;;;;;;;;;;;;;;AAsEA,MAAM,iBAAiB,KAAK,OAAO,eAAe,CAAC;AAGnD,MAAa,8BAA8B,KAAK;CAC9C,gBAAgB;CAChB,SAAS;CACT,SAAS;CACT,OANkB,KAAK,OAAO,MAMvB;CACP,aAAa;CACb,UAAU;CACV,mBAAmB,KAAK;AAC1B,CAAC;AAED,SAAgB,wBAAwB,OAAgD;CACtF,OAAO,EAAE,4BAA4B,KAAK,aAAa,KAAK;AAC9D;;;;;;;;;;;;;;;;;;;;;;;AC9DA,MAAM,qBAAqB;AAE3B,SAAS,SAAS,SAAiB,OAAuB;CACxD,IAAI,QAAQ,IAAI,yBAAyB,KAAK;CAC9C,IAAI,UAAU,KAAA,GACZ,QAAQ,OAAO,MAAM,mBAAmB,QAAQ,IAAI,OAAO,KAAK,EAAE,GAAG;MAErE,QAAQ,OAAO,MAAM,mBAAmB,QAAQ,GAAG;AAEvD;AAEA,eAAe,UAAU,SAA+C;CACtE,MAAM,QAAQ,MAAM,+BAA+B,OAAO;CAC1D,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,QAAQ,iBAAiB,WAAW,MAAM,GAAG,kBAAkB;CACrE,IAAI;EAOF,SAAS,uBAAsB,MANR,MAAM,QAAQ,UAAU;GAC7C,QAAQ;GACR,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C,MAAM,KAAK,UAAU,KAAK;GAC1B,QAAQ,WAAW;EACrB,CAAC,EAAA,CACuC,QAAQ;CAClD,SAAS,KAAK;EACZ,SAAS,eAAe,GAAG;CAC7B,UAAU;EACR,aAAa,KAAK;CACpB;AACF;AAEA,SAAS,YAAkB;CAGzB,IAAI;EACF,QAAQ,aAAa;CACvB,QAAQ,CAER;CACA,QAAQ,KAAK,CAAC;AAChB;AAEA,QAAQ,KAAK,YAAY,YAAqB;CAC5C,IAAI,CAAC,wBAAwB,OAAO,GAAG;EACrC,SAAS,qCAAqC;EAC9C,UAAU;EACV;CACF;CACA,UAAU,OAAO,CAAC,CACf,OAAO,QAAQ,SAAS,cAAc,GAAG,CAAC,CAAC,CAC3C,QAAQ,SAAS;AACtB,CAAC;AAMD,WAAW,WADiB,qBAAqB,CACR,CAAC,CAAC,MAAM"}
package/package.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "name": "@prisma-next/cli-telemetry",
3
- "version": "0.12.0",
3
+ "version": "0.13.0-dev.10",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "description": "CLI telemetry client for Prisma Next: detached subprocess sender, gating resolution, user-config store, and consent surface",
8
8
  "dependencies": {
9
- "@prisma-next/config": "0.12.0",
10
- "@prisma-next/utils": "0.12.0",
9
+ "@prisma-next/config": "0.13.0-dev.10",
10
+ "@prisma-next/utils": "0.13.0-dev.10",
11
11
  "arktype": "^2.2.0",
12
12
  "c12": "^3.3.4",
13
13
  "pathe": "^2.0.3"
14
14
  },
15
15
  "devDependencies": {
16
- "@prisma-next/test-utils": "0.12.0",
17
- "@prisma-next/tsconfig": "0.12.0",
18
- "@prisma-next/tsdown": "0.12.0",
19
- "@types/node": "25.6.0",
20
- "tsdown": "0.22.0",
16
+ "@prisma-next/test-utils": "0.13.0-dev.10",
17
+ "@prisma-next/tsconfig": "0.13.0-dev.10",
18
+ "@prisma-next/tsdown": "0.13.0-dev.10",
19
+ "@types/node": "25.9.1",
20
+ "tsdown": "0.22.1",
21
21
  "typescript": "5.9.3",
22
- "vitest": "4.1.6"
22
+ "vitest": "4.1.8"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "typescript": ">=5.9"
@@ -13,4 +13,9 @@ export { sanitizeCommanderResult } from '../sanitize';
13
13
  export type { RunTelemetryInputs, TelemetryRunOutcome } from '../spawn';
14
14
  export { runTelemetry, senderModuleUrl } from '../spawn';
15
15
  export type { UserConfig } from '../user-config';
16
- export { readUserConfig, userConfigPath, writeUserConfig } from '../user-config';
16
+ export {
17
+ ensureInstallationId,
18
+ readUserConfig,
19
+ userConfigPath,
20
+ writeUserConfig,
21
+ } from '../user-config';
package/src/gating.ts CHANGED
@@ -4,7 +4,7 @@ import type { UserConfig } from './user-config';
4
4
  * Why telemetry was disabled. Useful for debug-mode logging in the
5
5
  * parent; never surfaces to users.
6
6
  */
7
- export type GatingDisabledReason = 'env-override' | 'stored-opt-out' | 'default-off';
7
+ export type GatingDisabledReason = 'env-override' | 'stored-opt-out';
8
8
 
9
9
  export type GatingResolution =
10
10
  | { readonly enabled: true }
@@ -45,14 +45,17 @@ function isTruthyOptOut(raw: string | undefined): boolean {
45
45
  *
46
46
  * Decision order:
47
47
  * 1. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or
48
- * `DO_NOT_TRACK=1`) → disabled.
49
- * 2. Stored `enableTelemetry === true` enabled.
50
- * 3. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
48
+ * `DO_NOT_TRACK=1`) → disabled. The env check runs first, so an
49
+ * opt-out env var wins over any stored or unset preference.
50
+ * 2. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
51
+ * 3. Stored `enableTelemetry === true` → enabled.
51
52
  * 4. Stored `enableTelemetry === undefined` (file missing, or field
52
- * not set) → disabled (`default-off`).
53
+ * not set) → ENABLED. This is the opt-out default: absence of an
54
+ * explicit choice means telemetry is on. This is the load-bearing,
55
+ * counter-intuitive branch — do not "fix" it to default-off.
53
56
  *
54
- * Telemetry is enabled only when no env override is active **and**
55
- * `enableTelemetry` is explicitly `true`.
57
+ * Telemetry is disabled only when an env override is active or
58
+ * `enableTelemetry` is explicitly `false`.
56
59
  */
57
60
  export function resolveGating(inputs: GatingInputs): GatingResolution {
58
61
  if (
@@ -61,11 +64,8 @@ export function resolveGating(inputs: GatingInputs): GatingResolution {
61
64
  ) {
62
65
  return { enabled: false, reason: 'env-override' };
63
66
  }
64
- if (inputs.config.enableTelemetry === true) {
65
- return { enabled: true };
66
- }
67
67
  if (inputs.config.enableTelemetry === false) {
68
68
  return { enabled: false, reason: 'stored-opt-out' };
69
69
  }
70
- return { enabled: false, reason: 'default-off' };
70
+ return { enabled: true };
71
71
  }
package/src/spawn.ts CHANGED
@@ -84,10 +84,11 @@ export function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome {
84
84
  }
85
85
 
86
86
  const sanitised = sanitizeCommanderResult(inputs.command);
87
- // Gating already confirmed enableTelemetry === true, so installationId
88
- // must be set (writeUserConfig generates it alongside that field).
89
- // Defence-in-depth: if a stale config has the flag but no id, skip
90
- // rather than send a junk event.
87
+ // Gating resolved enabled, so installationId should be set: the parent
88
+ // fire path mints it before calling runTelemetry on the default-on
89
+ // first run, and the init consent flow mints it on explicit opt-in.
90
+ // Defence-in-depth: a missing id here means a stale/corrupt config, so
91
+ // skip rather than send a junk event.
91
92
  if (typeof config.installationId !== 'string' || config.installationId.length === 0) {
92
93
  return { spawned: false, reason: 'gated-off' };
93
94
  }
@@ -4,10 +4,12 @@ import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'pathe';
5
5
 
6
6
  /**
7
- * The user-level config file. Persists the consent flag and the
8
- * installation UUID together so an env-var opt-out never mutates disk,
9
- * and so an opt-in opt-out opt-in cycle keeps the same UUID (correct
10
- * for MAU continuity).
7
+ * The user-level config file. Persists the telemetry flag and the
8
+ * installation UUID. Under the opt-out model the flag stays `undefined`
9
+ * until the user makes an explicit choice (default-on first run mints
10
+ * only the id via {@link ensureInstallationId}), and an env-var opt-out
11
+ * never mutates disk. Once the id exists it survives any
12
+ * on → off → on cycle, keeping the same UUID (correct for MAU continuity).
11
13
  *
12
14
  * Readers tolerate unknown fields for forward compat; writers merge
13
15
  * partials into the existing object so unknown fields are preserved.
@@ -89,10 +91,12 @@ export function readUserConfig(): UserConfig {
89
91
  *
90
92
  * When `partial.enableTelemetry === true` and no `installationId` is
91
93
  * stored yet, generates a v4 random UUID and persists both fields in
92
- * the same write. An existing `installationId` is never rotated.
93
- *
94
- * `writeUserConfig({ enableTelemetry: false })` does *not* generate an
95
- * installation id only an affirmative consent answer produces one.
94
+ * the same write. An existing `installationId` is never rotated. This is
95
+ * the *explicit-consent* mint path: a `false` answer
96
+ * (`writeUserConfig({ enableTelemetry: false })`) writes no id, and a bare
97
+ * `writeUserConfig({ installationId })` mints nothing extra. The default-on
98
+ * first-send path mints its id separately via {@link ensureInstallationId},
99
+ * which records no consent answer.
96
100
  */
97
101
  export function writeUserConfig(partial: Partial<UserConfig>): void {
98
102
  const current = readUserConfig();
@@ -109,3 +113,23 @@ export function writeUserConfig(partial: Partial<UserConfig>): void {
109
113
  writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf-8');
110
114
  renameSync(tmpPath, path);
111
115
  }
116
+
117
+ /**
118
+ * Returns the stored `installationId`, minting and persisting a fresh v4
119
+ * UUID when none exists yet. Crucially, this persists *only* the id —
120
+ * `enableTelemetry` is left untouched (stays `undefined` on a default-on
121
+ * first run), so the interactive `init` consent prompt is not wrongly
122
+ * suppressed and no explicit consent the user never gave is recorded.
123
+ *
124
+ * Used by the default-on first-run fire path: the gate has already
125
+ * resolved enabled, so this only ever runs when telemetry is on.
126
+ */
127
+ export function ensureInstallationId(): string {
128
+ const existing = readUserConfig().installationId;
129
+ if (typeof existing === 'string' && existing.length > 0) {
130
+ return existing;
131
+ }
132
+ const installationId = randomUUID();
133
+ writeUserConfig({ installationId });
134
+ return installationId;
135
+ }