diff --git a/libs/telemetry/src/node/postinstall.spec.ts b/libs/telemetry/src/node/postinstall.spec.ts index 71065e4a3..171105ded 100644 --- a/libs/telemetry/src/node/postinstall.spec.ts +++ b/libs/telemetry/src/node/postinstall.spec.ts @@ -35,7 +35,7 @@ describe('postinstall script', () => { expect(stdout.join('')).toMatch(/DO_NOT_TRACK=1/); }); - test('suppresses stdout notice when CI=true', async () => { + test('CI=true is full opt-out: no event sent and no stdout notice', async () => { const stdout: string[] = []; await capturePostinstallScript({ readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), @@ -43,6 +43,18 @@ describe('postinstall script', () => { env: { ...process.env, CI: 'true' }, }); expect(stdout).toEqual([]); + expect(capturePostinstall).not.toHaveBeenCalled(); + }); + + test('DO_NOT_TRACK=1 is full opt-out: no event sent and no stdout notice', async () => { + const stdout: string[] = []; + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), + write: (s: string) => stdout.push(s), + env: { ...process.env, DO_NOT_TRACK: '1' }, + }); + expect(stdout).toEqual([]); + expect(capturePostinstall).not.toHaveBeenCalled(); }); test('swallows readPackageJson errors silently', async () => { diff --git a/libs/telemetry/src/node/postinstall.ts b/libs/telemetry/src/node/postinstall.ts index 808069385..f83dffc36 100644 --- a/libs/telemetry/src/node/postinstall.ts +++ b/libs/telemetry/src/node/postinstall.ts @@ -1,5 +1,5 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { readFileSync, realpathSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import { capturePostinstall } from './client.js'; import { isTelemetryDisabled } from '../shared/env.js'; @@ -11,6 +11,9 @@ interface PostinstallDeps { } export async function capturePostinstallScript(deps: PostinstallDeps): Promise { + // Single opt-out gate. DO_NOT_TRACK, NGAF_TELEMETRY_DISABLED, and CI envs + // all funnel through isTelemetryDisabled and return early — no event sent, + // no stdout notice. Matches libs/telemetry/README.md trust contract. if (isTelemetryDisabled(deps.env)) return; let pkg: { name: string; version: string }; try { @@ -20,18 +23,30 @@ export async function capturePostinstallScript(deps: PostinstallDeps): Promise { + return new Promise((resolve) => { + if (process.stdout.writableNeedDrain) { + process.stdout.once('drain', () => resolve()); + } else { + // Yield one tick so any pending write callbacks run before exit. + setImmediate(() => resolve()); + } + }); +} + // Entry point — invoked by package.json scripts.postinstall. async function main(): Promise { await capturePostinstallScript({ @@ -42,9 +57,19 @@ async function main(): Promise { write: (s) => process.stdout.write(s), env: process.env, }); + await flushStdout(); } // Only run as main entry, not when imported by tests. -const isDirectRun = - process.argv[1] && import.meta.url === `file://${process.argv[1]}`; -if (isDirectRun) main(); +// Resolves symlinks on both sides so `/tmp` vs `/private/tmp` on macOS, +// pnpm content-addressed stores, and similar setups all match correctly. +function isDirectRun(): boolean { + const entry = process.argv[1]; + if (!entry) return false; + try { + return pathToFileURL(realpathSync(entry)).href === import.meta.url; + } catch { + return false; + } +} +if (isDirectRun()) main();