@prisma-next/cli-telemetry 0.12.0-dev.17 → 0.12.0-dev.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/exports/index.d.mts +16 -34
- package/dist/exports/index.d.mts.map +1 -1
- package/dist/exports/index.mjs +16 -34
- package/dist/exports/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/exports/index.ts +1 -6
- package/src/gating.ts +11 -11
- package/src/spawn.ts +4 -5
- package/src/user-config.ts +8 -32
package/dist/exports/index.d.mts
CHANGED
|
@@ -126,12 +126,10 @@ 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
|
|
130
|
-
* installation UUID
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* never mutates disk. Once the id exists it survives any
|
|
134
|
-
* on → off → on cycle, keeping the same UUID (correct for MAU continuity).
|
|
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).
|
|
135
133
|
*
|
|
136
134
|
* Readers tolerate unknown fields for forward compat; writers merge
|
|
137
135
|
* partials into the existing object so unknown fields are preserved.
|
|
@@ -160,32 +158,19 @@ declare function readUserConfig(): UserConfig;
|
|
|
160
158
|
*
|
|
161
159
|
* When `partial.enableTelemetry === true` and no `installationId` is
|
|
162
160
|
* stored yet, generates a v4 random UUID and persists both fields in
|
|
163
|
-
* the same write. An existing `installationId` is never rotated.
|
|
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.
|
|
169
|
-
*/
|
|
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.
|
|
161
|
+
* the same write. An existing `installationId` is never rotated.
|
|
177
162
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
163
|
+
* `writeUserConfig({ enableTelemetry: false })` does *not* generate an
|
|
164
|
+
* installation id — only an affirmative consent answer produces one.
|
|
180
165
|
*/
|
|
181
|
-
declare function
|
|
166
|
+
declare function writeUserConfig(partial: Partial<UserConfig>): void;
|
|
182
167
|
//#endregion
|
|
183
168
|
//#region src/gating.d.ts
|
|
184
169
|
/**
|
|
185
170
|
* Why telemetry was disabled. Useful for debug-mode logging in the
|
|
186
171
|
* parent; never surfaces to users.
|
|
187
172
|
*/
|
|
188
|
-
type GatingDisabledReason = 'env-override' | 'stored-opt-out';
|
|
173
|
+
type GatingDisabledReason = 'env-override' | 'stored-opt-out' | 'default-off';
|
|
189
174
|
type GatingResolution = {
|
|
190
175
|
readonly enabled: true;
|
|
191
176
|
} | {
|
|
@@ -210,17 +195,14 @@ interface GatingInputs {
|
|
|
210
195
|
*
|
|
211
196
|
* Decision order:
|
|
212
197
|
* 1. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or
|
|
213
|
-
* `DO_NOT_TRACK=1`) → disabled.
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* 3. Stored `enableTelemetry === true` → enabled.
|
|
198
|
+
* `DO_NOT_TRACK=1`) → disabled.
|
|
199
|
+
* 2. Stored `enableTelemetry === true` → enabled.
|
|
200
|
+
* 3. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
|
|
217
201
|
* 4. Stored `enableTelemetry === undefined` (file missing, or field
|
|
218
|
-
* not set) →
|
|
219
|
-
* explicit choice means telemetry is on. This is the load-bearing,
|
|
220
|
-
* counter-intuitive branch — do not "fix" it to default-off.
|
|
202
|
+
* not set) → disabled (`default-off`).
|
|
221
203
|
*
|
|
222
|
-
* Telemetry is
|
|
223
|
-
* `enableTelemetry` is explicitly `
|
|
204
|
+
* Telemetry is enabled only when no env override is active **and**
|
|
205
|
+
* `enableTelemetry` is explicitly `true`.
|
|
224
206
|
*/
|
|
225
207
|
declare function resolveGating(inputs: GatingInputs): GatingResolution;
|
|
226
208
|
//#endregion
|
|
@@ -362,5 +344,5 @@ declare function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome;
|
|
|
362
344
|
*/
|
|
363
345
|
declare function senderModuleUrl(importMetaUrl: string): string;
|
|
364
346
|
//#endregion
|
|
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,
|
|
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 };
|
|
366
348
|
//# 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
|
|
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"}
|
package/dist/exports/index.mjs
CHANGED
|
@@ -60,28 +60,29 @@ 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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* 3. Stored `enableTelemetry === true` → enabled.
|
|
63
|
+
* `DO_NOT_TRACK=1`) → disabled.
|
|
64
|
+
* 2. Stored `enableTelemetry === true` → enabled.
|
|
65
|
+
* 3. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
|
|
67
66
|
* 4. Stored `enableTelemetry === undefined` (file missing, or field
|
|
68
|
-
* not set) →
|
|
69
|
-
* explicit choice means telemetry is on. This is the load-bearing,
|
|
70
|
-
* counter-intuitive branch — do not "fix" it to default-off.
|
|
67
|
+
* not set) → disabled (`default-off`).
|
|
71
68
|
*
|
|
72
|
-
* Telemetry is
|
|
73
|
-
* `enableTelemetry` is explicitly `
|
|
69
|
+
* Telemetry is enabled only when no env override is active **and**
|
|
70
|
+
* `enableTelemetry` is explicitly `true`.
|
|
74
71
|
*/
|
|
75
72
|
function resolveGating(inputs) {
|
|
76
73
|
if (isTruthyOptOut(inputs.env["PRISMA_NEXT_DISABLE_TELEMETRY"]) || inputs.env["DO_NOT_TRACK"] === "1") return {
|
|
77
74
|
enabled: false,
|
|
78
75
|
reason: "env-override"
|
|
79
76
|
};
|
|
77
|
+
if (inputs.config.enableTelemetry === true) return { enabled: true };
|
|
80
78
|
if (inputs.config.enableTelemetry === false) return {
|
|
81
79
|
enabled: false,
|
|
82
80
|
reason: "stored-opt-out"
|
|
83
81
|
};
|
|
84
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
enabled: false,
|
|
84
|
+
reason: "default-off"
|
|
85
|
+
};
|
|
85
86
|
}
|
|
86
87
|
//#endregion
|
|
87
88
|
//#region src/sanitize.ts
|
|
@@ -178,12 +179,10 @@ function readUserConfig() {
|
|
|
178
179
|
*
|
|
179
180
|
* When `partial.enableTelemetry === true` and no `installationId` is
|
|
180
181
|
* stored yet, generates a v4 random UUID and persists both fields in
|
|
181
|
-
* the same write. An existing `installationId` is never rotated.
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* first-send path mints its id separately via {@link ensureInstallationId},
|
|
186
|
-
* which records no consent answer.
|
|
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.
|
|
187
186
|
*/
|
|
188
187
|
function writeUserConfig(partial) {
|
|
189
188
|
const merged = {
|
|
@@ -198,23 +197,6 @@ function writeUserConfig(partial) {
|
|
|
198
197
|
writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
199
198
|
renameSync(tmpPath, path);
|
|
200
199
|
}
|
|
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
|
-
}
|
|
218
200
|
//#endregion
|
|
219
201
|
//#region src/spawn.ts
|
|
220
202
|
function runTelemetry(inputs) {
|
|
@@ -280,6 +262,6 @@ function senderModuleUrl(importMetaUrl) {
|
|
|
280
262
|
return fileURLToPath(new URL("./sender.mjs", importMetaUrl));
|
|
281
263
|
}
|
|
282
264
|
//#endregion
|
|
283
|
-
export { TELEMETRY_BACKEND_URL, TELEMETRY_ENDPOINT_PATH,
|
|
265
|
+
export { TELEMETRY_BACKEND_URL, TELEMETRY_ENDPOINT_PATH, loadProjectConfig, readUserConfig, resolveGating, resolveTelemetryEndpoint, runTelemetry, sanitizeCommanderResult, senderModuleUrl, userConfigPath, writeUserConfig };
|
|
284
266
|
|
|
285
267
|
//# 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';\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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"}
|
|
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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"}
|
package/package.json
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prisma-next/cli-telemetry",
|
|
3
|
-
"version": "0.12.0-dev.
|
|
3
|
+
"version": "0.12.0-dev.2",
|
|
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-dev.
|
|
10
|
-
"@prisma-next/utils": "0.12.0-dev.
|
|
9
|
+
"@prisma-next/config": "0.12.0-dev.2",
|
|
10
|
+
"@prisma-next/utils": "0.12.0-dev.2",
|
|
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-dev.
|
|
17
|
-
"@prisma-next/tsconfig": "0.12.0-dev.
|
|
18
|
-
"@prisma-next/tsdown": "0.12.0-dev.
|
|
16
|
+
"@prisma-next/test-utils": "0.12.0-dev.2",
|
|
17
|
+
"@prisma-next/tsconfig": "0.12.0-dev.2",
|
|
18
|
+
"@prisma-next/tsdown": "0.12.0-dev.2",
|
|
19
19
|
"@types/node": "25.6.0",
|
|
20
20
|
"tsdown": "0.22.0",
|
|
21
21
|
"typescript": "5.9.3",
|
package/src/exports/index.ts
CHANGED
|
@@ -13,9 +13,4 @@ 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 {
|
|
17
|
-
ensureInstallationId,
|
|
18
|
-
readUserConfig,
|
|
19
|
-
userConfigPath,
|
|
20
|
-
writeUserConfig,
|
|
21
|
-
} from '../user-config';
|
|
16
|
+
export { readUserConfig, userConfigPath, writeUserConfig } 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';
|
|
7
|
+
export type GatingDisabledReason = 'env-override' | 'stored-opt-out' | 'default-off';
|
|
8
8
|
|
|
9
9
|
export type GatingResolution =
|
|
10
10
|
| { readonly enabled: true }
|
|
@@ -45,17 +45,14 @@ 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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* 3. Stored `enableTelemetry === true` → enabled.
|
|
48
|
+
* `DO_NOT_TRACK=1`) → disabled.
|
|
49
|
+
* 2. Stored `enableTelemetry === true` → enabled.
|
|
50
|
+
* 3. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
|
|
52
51
|
* 4. Stored `enableTelemetry === undefined` (file missing, or field
|
|
53
|
-
* not set) →
|
|
54
|
-
* explicit choice means telemetry is on. This is the load-bearing,
|
|
55
|
-
* counter-intuitive branch — do not "fix" it to default-off.
|
|
52
|
+
* not set) → disabled (`default-off`).
|
|
56
53
|
*
|
|
57
|
-
* Telemetry is
|
|
58
|
-
* `enableTelemetry` is explicitly `
|
|
54
|
+
* Telemetry is enabled only when no env override is active **and**
|
|
55
|
+
* `enableTelemetry` is explicitly `true`.
|
|
59
56
|
*/
|
|
60
57
|
export function resolveGating(inputs: GatingInputs): GatingResolution {
|
|
61
58
|
if (
|
|
@@ -64,8 +61,11 @@ export function resolveGating(inputs: GatingInputs): GatingResolution {
|
|
|
64
61
|
) {
|
|
65
62
|
return { enabled: false, reason: 'env-override' };
|
|
66
63
|
}
|
|
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:
|
|
70
|
+
return { enabled: false, reason: 'default-off' };
|
|
71
71
|
}
|
package/src/spawn.ts
CHANGED
|
@@ -84,11 +84,10 @@ export function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const sanitised = sanitizeCommanderResult(inputs.command);
|
|
87
|
-
// Gating
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// skip rather than send a junk event.
|
|
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.
|
|
92
91
|
if (typeof config.installationId !== 'string' || config.installationId.length === 0) {
|
|
93
92
|
return { spawned: false, reason: 'gated-off' };
|
|
94
93
|
}
|
package/src/user-config.ts
CHANGED
|
@@ -4,12 +4,10 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'pathe';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The user-level config file. Persists the
|
|
8
|
-
* installation UUID
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* never mutates disk. Once the id exists it survives any
|
|
12
|
-
* on → off → on cycle, keeping the same UUID (correct for MAU continuity).
|
|
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).
|
|
13
11
|
*
|
|
14
12
|
* Readers tolerate unknown fields for forward compat; writers merge
|
|
15
13
|
* partials into the existing object so unknown fields are preserved.
|
|
@@ -91,12 +89,10 @@ export function readUserConfig(): UserConfig {
|
|
|
91
89
|
*
|
|
92
90
|
* When `partial.enableTelemetry === true` and no `installationId` is
|
|
93
91
|
* stored yet, generates a v4 random UUID and persists both fields in
|
|
94
|
-
* the same write. An existing `installationId` is never rotated.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* first-send path mints its id separately via {@link ensureInstallationId},
|
|
99
|
-
* which records no consent answer.
|
|
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.
|
|
100
96
|
*/
|
|
101
97
|
export function writeUserConfig(partial: Partial<UserConfig>): void {
|
|
102
98
|
const current = readUserConfig();
|
|
@@ -113,23 +109,3 @@ export function writeUserConfig(partial: Partial<UserConfig>): void {
|
|
|
113
109
|
writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf-8');
|
|
114
110
|
renameSync(tmpPath, path);
|
|
115
111
|
}
|
|
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
|
-
}
|