Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion libs/telemetry/src/node/postinstall.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,26 @@ 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' }),
write: (s: string) => stdout.push(s),
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 () => {
Expand Down
49 changes: 37 additions & 12 deletions libs/telemetry/src/node/postinstall.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,6 +11,9 @@ interface PostinstallDeps {
}

export async function capturePostinstallScript(deps: PostinstallDeps): Promise<void> {
// 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 {
Expand All @@ -20,18 +23,30 @@ export async function capturePostinstallScript(deps: PostinstallDeps): Promise<v
}
try {
await capturePostinstall({ pkg: pkg.name, version: pkg.version });
if (!deps.env.CI) {
deps.write(
`@ngaf/telemetry: sent install ping (${pkg.name}@${pkg.version}). ` +
`Disable: DO_NOT_TRACK=1 or NGAF_TELEMETRY_DISABLED=1. ` +
`See https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md\n`,
);
}
deps.write(
`@ngaf/telemetry: sent install ping (${pkg.name}@${pkg.version}). ` +
`Disable: DO_NOT_TRACK=1 or NGAF_TELEMETRY_DISABLED=1. ` +
`See https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md\n`,
);
} catch {
// never break npm install
}
}

// Flush stdout so the opt-out notice is visible to npm. Without this,
// posthog-node's await chain can leave the notice in stdout's pipe buffer
// when the process exits, and npm reaps the script before the buffer drains.
async function flushStdout(): Promise<void> {
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<void> {
await capturePostinstallScript({
Expand All @@ -42,9 +57,19 @@ async function main(): Promise<void> {
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();
Loading