@prisma-next/cli-telemetry 0.10.0-dev.8

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/src/spawn.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { fork } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { resolveTelemetryEndpoint } from './endpoint';
4
+ import { resolveGating } from './gating';
5
+ import type { ParentToSenderPayload } from './payload';
6
+ import { type CommanderResultShape, sanitizeCommanderResult } from './sanitize';
7
+ import { readUserConfig, type UserConfig } from './user-config';
8
+
9
+ /**
10
+ * Inputs the CLI entry point hands the telemetry layer at command
11
+ * start. The CLI is responsible for stitching commander's result, the
12
+ * loaded config, and the project root together; the telemetry module
13
+ * does no I/O of its own except for the user-config read (skipped when
14
+ * `userConfig` is provided).
15
+ */
16
+ export interface RunTelemetryInputs {
17
+ /** Sanitised commander snapshot — see `CommanderResultShape`. */
18
+ readonly command: CommanderResultShape;
19
+ /** This CLI's own version (from its `package.json`). */
20
+ readonly version: string;
21
+ /** Resolved `config.target.targetId`, or `null` when the config could not be loaded. */
22
+ readonly databaseTarget: string | null;
23
+ /** Declared extension-pack IDs, in any deterministic order. */
24
+ readonly extensions: readonly string[];
25
+ /** Absolute path of the project root (typically `process.cwd()`). */
26
+ readonly projectRoot: string;
27
+ /**
28
+ * Path to the sender entry compiled into this package's `dist/`.
29
+ * Resolved by the caller because the compiled sender lives at
30
+ * `<package>/dist/sender.mjs` and only the consumer knows its own
31
+ * `import.meta.url`.
32
+ */
33
+ readonly senderPath: string;
34
+ /**
35
+ * `isCI()` result from the consumer. Telemetry is suppressed when
36
+ * `true` regardless of the stored consent answer — CI environments
37
+ * never emit (matches the colour-output convention's CI suppression).
38
+ */
39
+ readonly isCI: boolean;
40
+ /** Process env to read for opt-out signals. Defaults to `process.env`. */
41
+ readonly env?: Readonly<Record<string, string | undefined>>;
42
+ /** Cached user config when the caller already read it to resolve gates before other work. */
43
+ readonly userConfig?: UserConfig;
44
+ }
45
+
46
+ /**
47
+ * Best-effort telemetry spawn at command start. Returns synchronously —
48
+ * the fork runs in the background and never blocks the parent. Every
49
+ * failure mode is swallowed; the parent's stdout/stderr is untouched in
50
+ * normal operation, the only escape valve being
51
+ * `PRISMA_NEXT_DEBUG=1` which routes diagnostics to stderr.
52
+ *
53
+ * Returns the spawn outcome so debug-mode logging and the test-harness
54
+ * probe (which verifies test runs short-circuit the fork) can inspect
55
+ * the decision without scraping stderr.
56
+ */
57
+ export type TelemetryRunOutcome =
58
+ | { readonly spawned: true }
59
+ | { readonly spawned: false; readonly reason: 'gated-off' | 'ci' | 'fork-failed' };
60
+
61
+ export function runTelemetry(inputs: RunTelemetryInputs): TelemetryRunOutcome {
62
+ const env = inputs.env ?? process.env;
63
+
64
+ if (inputs.isCI) {
65
+ return { spawned: false, reason: 'ci' };
66
+ }
67
+
68
+ const config = inputs.userConfig ?? readUserConfig();
69
+ const gating = resolveGating({ env, config });
70
+ if (!gating.enabled) {
71
+ return { spawned: false, reason: 'gated-off' };
72
+ }
73
+
74
+ const sanitised = sanitizeCommanderResult(inputs.command);
75
+ // Gating already confirmed enableTelemetry === true, so installationId
76
+ // must be set (writeUserConfig generates it alongside that field).
77
+ // Defence-in-depth: if a stale config has the flag but no id, skip
78
+ // rather than send a junk event.
79
+ if (typeof config.installationId !== 'string' || config.installationId.length === 0) {
80
+ return { spawned: false, reason: 'gated-off' };
81
+ }
82
+
83
+ const payload: ParentToSenderPayload = {
84
+ installationId: config.installationId,
85
+ version: inputs.version,
86
+ command: sanitised.command,
87
+ flags: sanitised.flags,
88
+ databaseTarget: inputs.databaseTarget,
89
+ extensions: inputs.extensions,
90
+ projectRoot: inputs.projectRoot,
91
+ endpoint: resolveTelemetryEndpoint(env),
92
+ };
93
+
94
+ try {
95
+ const child = fork(inputs.senderPath, [], {
96
+ detached: true,
97
+ stdio: ['pipe', 'ignore', 'ignore', 'ipc'],
98
+ });
99
+ child.send(payload, (err) => {
100
+ if (err !== null && process.env['PRISMA_NEXT_DEBUG'] === '1') {
101
+ process.stderr.write(`[cli-telemetry] parent send error: ${String(err)}\n`);
102
+ }
103
+ });
104
+ child.disconnect();
105
+ child.unref();
106
+ return { spawned: true };
107
+ } catch (err) {
108
+ if (process.env['PRISMA_NEXT_DEBUG'] === '1') {
109
+ process.stderr.write(`[cli-telemetry] parent fork failed: ${String(err)}\n`);
110
+ }
111
+ return { spawned: false, reason: 'fork-failed' };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Resolve the path to the compiled sender entry relative to a consumer
117
+ * that has captured its own `import.meta.url`. The CLI's
118
+ * `tsdown`-emitted entry sits at `<package>/dist/sender.mjs`; the
119
+ * consumer asks `senderModuleUrl()` and forwards the result to
120
+ * `runTelemetry({ senderPath })`.
121
+ */
122
+ export function senderModuleUrl(importMetaUrl: string): string {
123
+ return fileURLToPath(new URL('./sender.mjs', importMetaUrl));
124
+ }
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'pathe';
5
+
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).
11
+ *
12
+ * Readers tolerate unknown fields for forward compat; writers merge
13
+ * partials into the existing object so unknown fields are preserved.
14
+ */
15
+ export interface UserConfig {
16
+ readonly enableTelemetry?: boolean;
17
+ readonly installationId?: string;
18
+ readonly [key: string]: unknown;
19
+ }
20
+
21
+ const APP_DIR = 'prisma-next';
22
+ const FILE_NAME = 'config.json';
23
+
24
+ /**
25
+ * Resolves the user-level config directory:
26
+ * - Windows: `%APPDATA%\prisma-next\` (fallback: `%USERPROFILE%\AppData\Roaming\prisma-next\`).
27
+ * - Unix (incl. macOS): `$XDG_CONFIG_HOME/prisma-next/` if set, else
28
+ * `$HOME/.config/prisma-next/` per the XDG Base Directory Specification.
29
+ *
30
+ * The spec deliberately picks XDG over the macOS-native
31
+ * `~/Library/Preferences/` convention so the path resolution is
32
+ * test-overridable via `XDG_CONFIG_HOME` and matches the documented
33
+ * behaviour on all *nix platforms. We intentionally do not use
34
+ * `env-paths`: its macOS choice of `~/Library/Preferences` is for
35
+ * OS-managed plist preferences, not arbitrary JSON files. Apple documents
36
+ * that apps access that directory through system APIs such as
37
+ * `NSUserDefaults`, while cross-platform CLI and developer tools conventionally
38
+ * use `~/.config` on macOS too:
39
+ * https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/MacOSXDirectories/MacOSXDirectories.html
40
+ */
41
+ function configDir(): string {
42
+ if (process.platform === 'win32') {
43
+ const appData = process.env['APPDATA'];
44
+ if (appData !== undefined && appData.length > 0) {
45
+ return join(appData, APP_DIR);
46
+ }
47
+ return join(homedir(), 'AppData', 'Roaming', APP_DIR);
48
+ }
49
+ const xdg = process.env['XDG_CONFIG_HOME'];
50
+ if (xdg !== undefined && xdg.length > 0) {
51
+ return join(xdg, APP_DIR);
52
+ }
53
+ return join(homedir(), '.config', APP_DIR);
54
+ }
55
+
56
+ /**
57
+ * Path to the user-level config file. Resolved per call so test
58
+ * harnesses can mutate `$XDG_CONFIG_HOME` between cases.
59
+ */
60
+ export function userConfigPath(): string {
61
+ return join(configDir(), FILE_NAME);
62
+ }
63
+
64
+ /**
65
+ * Reads the user-level config. File-missing, unreadable, or malformed →
66
+ * `{}` (the absence of consent is the same answer in every error mode).
67
+ * Unknown fields from a future client are passed through verbatim.
68
+ */
69
+ export function readUserConfig(): UserConfig {
70
+ const path = userConfigPath();
71
+ if (!existsSync(path)) return {};
72
+ try {
73
+ const raw = readFileSync(path, 'utf-8');
74
+ const parsed: unknown = JSON.parse(raw);
75
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
76
+ return parsed as UserConfig;
77
+ }
78
+ return {};
79
+ } catch {
80
+ return {};
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Merges `partial` into the current config and writes the result
86
+ * atomically (temp file + rename) so a crash mid-write never leaves a
87
+ * half-baked file readable on disk. Unknown fields already on disk are
88
+ * preserved.
89
+ *
90
+ * When `partial.enableTelemetry === true` and no `installationId` is
91
+ * 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.
96
+ */
97
+ export function writeUserConfig(partial: Partial<UserConfig>): void {
98
+ const current = readUserConfig();
99
+ const merged: Record<string, unknown> = { ...current, ...partial };
100
+ if (partial.enableTelemetry === true && merged['installationId'] === undefined) {
101
+ merged['installationId'] = randomUUID();
102
+ }
103
+ const path = userConfigPath();
104
+ const dir = dirname(path);
105
+ if (!existsSync(dir)) {
106
+ mkdirSync(dir, { recursive: true });
107
+ }
108
+ const tmpPath = `${path}.${process.pid}.tmp`;
109
+ writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf-8');
110
+ renameSync(tmpPath, path);
111
+ }