@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/LICENSE +201 -0
- package/README.md +43 -0
- package/dist/exports/index.d.mts +278 -0
- package/dist/exports/index.d.mts.map +1 -0
- package/dist/exports/index.mjs +266 -0
- package/dist/exports/index.mjs.map +1 -0
- package/dist/sender.d.mts +1 -0
- package/dist/sender.mjs +253 -0
- package/dist/sender.mjs.map +1 -0
- package/package.json +50 -0
- package/src/detect-agent.ts +68 -0
- package/src/endpoint.ts +33 -0
- package/src/enrich.ts +147 -0
- package/src/exports/index.ts +14 -0
- package/src/gating.ts +71 -0
- package/src/payload.ts +74 -0
- package/src/sanitize.ts +78 -0
- package/src/sender.ts +80 -0
- package/src/spawn.ts +124 -0
- package/src/user-config.ts +111 -0
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
|
+
}
|