From 402275be7cc2142a09a4f30e91728636bed01fca Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 24 Feb 2026 23:26:56 +0000 Subject: [PATCH] feat: add telemetry preference controls and preinstall notice Add user-facing telemetry controls (opt-out command, env var support, config file, preinstall notice) without collecting any data. This establishes the user-facing messaging before launch. - Add `agentcore telemetry` command with enable/disable/status subcommands - Add telemetry notice as a warning in preinstall script (shown on install/upgrade) - Support AGENTCORE_TELEMETRY_DISABLED and DO_NOT_TRACK env vars - Add global config module (~/.agentcore/config.json) for persisting preferences - Refactor update-notifier to reuse shared GLOBAL_CONFIG_DIR constant - Add unit tests for global config, preference resolution, and command actions --- package.json | 2 +- ...ck-old-cli.mjs => preinstall-warnings.mjs} | 16 +++ src/cli/__tests__/global-config.test.ts | 87 ++++++++++++ src/cli/cli.ts | 2 + .../telemetry/__tests__/telemetry.test.ts | 92 +++++++++++++ src/cli/commands/telemetry/actions.ts | 40 ++++++ src/cli/commands/telemetry/command.ts | 34 +++++ src/cli/commands/telemetry/index.ts | 1 + src/cli/global-config.ts | 39 ++++++ src/cli/telemetry/__tests__/resolve.test.ts | 129 ++++++++++++++++++ src/cli/telemetry/index.ts | 2 + src/cli/telemetry/resolve.ts | 40 ++++++ src/cli/tui/copy.ts | 1 + src/cli/update-notifier.ts | 4 +- 14 files changed, 486 insertions(+), 3 deletions(-) rename scripts/{check-old-cli.mjs => preinstall-warnings.mjs} (66%) create mode 100644 src/cli/__tests__/global-config.test.ts create mode 100644 src/cli/commands/telemetry/__tests__/telemetry.test.ts create mode 100644 src/cli/commands/telemetry/actions.ts create mode 100644 src/cli/commands/telemetry/command.ts create mode 100644 src/cli/commands/telemetry/index.ts create mode 100644 src/cli/global-config.ts create mode 100644 src/cli/telemetry/__tests__/resolve.test.ts create mode 100644 src/cli/telemetry/index.ts create mode 100644 src/cli/telemetry/resolve.ts diff --git a/package.json b/package.json index 1bd2478b..967b9e9d 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "scripts" ], "scripts": { - "preinstall": "node scripts/check-old-cli.mjs", + "preinstall": "node scripts/preinstall-warnings.mjs", "build": "npm run build:lib && npm run build:cli && npm run build:assets", "build:lib": "tsc -p tsconfig.build.json", "build:cli": "node esbuild.config.mjs", diff --git a/scripts/check-old-cli.mjs b/scripts/preinstall-warnings.mjs similarity index 66% rename from scripts/check-old-cli.mjs rename to scripts/preinstall-warnings.mjs index 8a1b28b2..080f6190 100644 --- a/scripts/check-old-cli.mjs +++ b/scripts/preinstall-warnings.mjs @@ -24,3 +24,19 @@ try { } catch { // No agentcore binary found or unexpected error — nothing to do } + +// Telemetry notice — shown on every install/upgrade +try { + console.warn( + [ + '', + '\x1b[33m⚠ NOTICE: The AgentCore CLI collects aggregated, anonymous usage\x1b[0m', + '\x1b[33manalytics to help improve the tool. To opt out, run:\x1b[0m', + '\x1b[33m agentcore telemetry disable\x1b[0m', + '\x1b[33mOr set: AGENTCORE_TELEMETRY_DISABLED=true\x1b[0m', + '', + ].join('\n') + ); +} catch { + // Never fail the install +} diff --git a/src/cli/__tests__/global-config.test.ts b/src/cli/__tests__/global-config.test.ts new file mode 100644 index 00000000..cb7cd9d8 --- /dev/null +++ b/src/cli/__tests__/global-config.test.ts @@ -0,0 +1,87 @@ +import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, readGlobalConfig, updateGlobalConfig } from '../global-config'; +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('fs/promises'); + +const mockMkdir = vi.mocked(mkdir); +const mockReadFile = vi.mocked(readFile); +const mockWriteFile = vi.mocked(writeFile); + +describe('global-config', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('readGlobalConfig', () => { + it('returns parsed config when file exists', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ telemetry: { enabled: false } })); + + const config = await readGlobalConfig(); + + expect(config).toEqual({ telemetry: { enabled: false } }); + expect(mockReadFile).toHaveBeenCalledWith(GLOBAL_CONFIG_FILE, 'utf-8'); + }); + + it('returns empty object when file does not exist', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + + const config = await readGlobalConfig(); + + expect(config).toEqual({}); + }); + + it('returns empty object when file contains invalid JSON', async () => { + mockReadFile.mockResolvedValue('not json'); + + const config = await readGlobalConfig(); + + expect(config).toEqual({}); + }); + }); + + describe('updateGlobalConfig', () => { + it('creates directory and writes merged config', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ telemetry: { enabled: true } })); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + await updateGlobalConfig({ telemetry: { enabled: false } }); + + expect(mockMkdir).toHaveBeenCalledWith(GLOBAL_CONFIG_DIR, { recursive: true }); + const written = JSON.parse(mockWriteFile.mock.calls[0]![1] as string); + expect(written).toEqual({ telemetry: { enabled: false } }); + }); + + it('merges telemetry sub-object without overwriting other keys', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ telemetry: { enabled: true } })); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + await updateGlobalConfig({ telemetry: { enabled: false } }); + + const written = JSON.parse(mockWriteFile.mock.calls[0]![1] as string); + expect(written).toEqual({ telemetry: { enabled: false } }); + }); + + it('silently ignores write failures', async () => { + mockReadFile.mockResolvedValue('{}'); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockRejectedValue(new Error('EACCES')); + + // Should not throw + await updateGlobalConfig({ telemetry: { enabled: true } }); + }); + + it('handles missing existing config gracefully', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + await updateGlobalConfig({ telemetry: { enabled: true } }); + + const written = JSON.parse(mockWriteFile.mock.calls[0]![1] as string); + expect(written).toEqual({ telemetry: { enabled: true } }); + }); + }); +}); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index cc719f72..348d50df 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -7,6 +7,7 @@ import { registerInvoke } from './commands/invoke'; import { registerPackage } from './commands/package'; import { registerRemove } from './commands/remove'; import { registerStatus } from './commands/status'; +import { registerTelemetry } from './commands/telemetry'; import { registerUpdate } from './commands/update'; import { registerValidate } from './commands/validate'; import { PACKAGE_VERSION } from './constants'; @@ -132,6 +133,7 @@ export function registerCommands(program: Command) { registerPackage(program); registerRemove(program); registerStatus(program); + registerTelemetry(program); registerUpdate(program); registerValidate(program); } diff --git a/src/cli/commands/telemetry/__tests__/telemetry.test.ts b/src/cli/commands/telemetry/__tests__/telemetry.test.ts new file mode 100644 index 00000000..a9621691 --- /dev/null +++ b/src/cli/commands/telemetry/__tests__/telemetry.test.ts @@ -0,0 +1,92 @@ +import * as globalConfig from '../../../global-config'; +import * as resolve from '../../../telemetry/resolve'; +import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from '../actions'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../global-config'); +vi.mock('../../../telemetry/resolve'); + +const mockUpdateGlobalConfig = vi.mocked(globalConfig.updateGlobalConfig); +const mockResolveTelemetryPreference = vi.mocked(resolve.resolveTelemetryPreference); + +describe('telemetry actions', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockUpdateGlobalConfig.mockResolvedValue(undefined); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + describe('handleTelemetryDisable', () => { + it('writes disabled config and prints confirmation', async () => { + await handleTelemetryDisable(); + + expect(mockUpdateGlobalConfig).toHaveBeenCalledWith({ telemetry: { enabled: false } }); + expect(consoleSpy).toHaveBeenCalledWith('Telemetry has been disabled.'); + }); + }); + + describe('handleTelemetryEnable', () => { + it('writes enabled config and prints confirmation', async () => { + await handleTelemetryEnable(); + + expect(mockUpdateGlobalConfig).toHaveBeenCalledWith({ telemetry: { enabled: true } }); + expect(consoleSpy).toHaveBeenCalledWith('Telemetry has been enabled.'); + }); + }); + + describe('handleTelemetryStatus', () => { + it('shows enabled status with default source', async () => { + mockResolveTelemetryPreference.mockResolvedValue({ enabled: true, source: 'default' }); + + await handleTelemetryStatus(); + + expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Enabled'); + expect(consoleSpy).toHaveBeenCalledWith('Source: default'); + }); + + it('shows disabled status with global-config source', async () => { + mockResolveTelemetryPreference.mockResolvedValue({ enabled: false, source: 'global-config' }); + + await handleTelemetryStatus(); + + expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Disabled'); + expect(consoleSpy).toHaveBeenCalledWith('Source: global config (~/.agentcore/config.json)'); + }); + + it('shows env var note when source is environment (AGENTCORE_TELEMETRY_DISABLED)', async () => { + const originalEnv = process.env; + process.env = { ...originalEnv, AGENTCORE_TELEMETRY_DISABLED: 'true' }; + + mockResolveTelemetryPreference.mockResolvedValue({ enabled: false, source: 'environment' }); + + await handleTelemetryStatus(); + + expect(consoleSpy).toHaveBeenCalledWith('Telemetry: Disabled'); + expect(consoleSpy).toHaveBeenCalledWith('Source: environment variable'); + expect(consoleSpy).toHaveBeenCalledWith('\nNote: AGENTCORE_TELEMETRY_DISABLED=true is set in your environment.'); + + process.env = originalEnv; + }); + + it('shows env var note when source is environment (DO_NOT_TRACK)', async () => { + const originalEnv = process.env; + process.env = { ...originalEnv, DO_NOT_TRACK: '1' }; + delete process.env.AGENTCORE_TELEMETRY_DISABLED; + + mockResolveTelemetryPreference.mockResolvedValue({ enabled: false, source: 'environment' }); + + await handleTelemetryStatus(); + + expect(consoleSpy).toHaveBeenCalledWith('\nNote: DO_NOT_TRACK=1 is set in your environment.'); + + process.env = originalEnv; + }); + }); +}); diff --git a/src/cli/commands/telemetry/actions.ts b/src/cli/commands/telemetry/actions.ts new file mode 100644 index 00000000..19ac125c --- /dev/null +++ b/src/cli/commands/telemetry/actions.ts @@ -0,0 +1,40 @@ +import { updateGlobalConfig } from '../../global-config.js'; +import { resolveTelemetryPreference } from '../../telemetry/resolve.js'; + +export async function handleTelemetryDisable(): Promise { + await updateGlobalConfig({ telemetry: { enabled: false } }); + console.log('Telemetry has been disabled.'); +} + +export async function handleTelemetryEnable(): Promise { + await updateGlobalConfig({ telemetry: { enabled: true } }); + console.log('Telemetry has been enabled.'); +} + +export async function handleTelemetryStatus(): Promise { + const pref = await resolveTelemetryPreference(); + + const status = pref.enabled ? 'Enabled' : 'Disabled'; + + const sourceLabel = + pref.source === 'environment' + ? 'environment variable' + : pref.source === 'global-config' + ? 'global config (~/.agentcore/config.json)' + : 'default'; + + console.log(`Telemetry: ${status}`); + console.log(`Source: ${sourceLabel}`); + + if (pref.source === 'environment') { + // eslint-disable-next-line @typescript-eslint/dot-notation + const agentcoreEnv = process.env['AGENTCORE_TELEMETRY_DISABLED']; + // eslint-disable-next-line @typescript-eslint/dot-notation + const doNotTrack = process.env['DO_NOT_TRACK']; + if (agentcoreEnv !== undefined) { + console.log(`\nNote: AGENTCORE_TELEMETRY_DISABLED=${agentcoreEnv} is set in your environment.`); + } else if (doNotTrack !== undefined) { + console.log(`\nNote: DO_NOT_TRACK=${doNotTrack} is set in your environment.`); + } + } +} diff --git a/src/cli/commands/telemetry/command.ts b/src/cli/commands/telemetry/command.ts new file mode 100644 index 00000000..77c0e439 --- /dev/null +++ b/src/cli/commands/telemetry/command.ts @@ -0,0 +1,34 @@ +import { COMMAND_DESCRIPTIONS } from '../../tui/copy.js'; +import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from './actions.js'; +import type { Command } from '@commander-js/extra-typings'; + +export function registerTelemetry(program: Command) { + const telemetry = program + .command('telemetry') + .description(COMMAND_DESCRIPTIONS.telemetry) + .argument('[subcommand]', 'Subcommand to run (enable, disable, status)') + .action(() => { + telemetry.outputHelp(); + }); + + telemetry + .command('disable') + .description('Disable anonymous usage analytics') + .action(async () => { + await handleTelemetryDisable(); + }); + + telemetry + .command('enable') + .description('Enable anonymous usage analytics') + .action(async () => { + await handleTelemetryEnable(); + }); + + telemetry + .command('status') + .description('Show current telemetry preference and source') + .action(async () => { + await handleTelemetryStatus(); + }); +} diff --git a/src/cli/commands/telemetry/index.ts b/src/cli/commands/telemetry/index.ts new file mode 100644 index 00000000..abb2b012 --- /dev/null +++ b/src/cli/commands/telemetry/index.ts @@ -0,0 +1 @@ +export { registerTelemetry } from './command.js'; diff --git a/src/cli/global-config.ts b/src/cli/global-config.ts new file mode 100644 index 00000000..d949f492 --- /dev/null +++ b/src/cli/global-config.ts @@ -0,0 +1,39 @@ +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { homedir } from 'os'; +import { join } from 'path'; + +export const GLOBAL_CONFIG_DIR = join(homedir(), '.agentcore'); +export const GLOBAL_CONFIG_FILE = join(GLOBAL_CONFIG_DIR, 'config.json'); + +export interface GlobalConfig { + telemetry?: { + enabled?: boolean; + }; +} + +export async function readGlobalConfig(): Promise { + try { + const data = await readFile(GLOBAL_CONFIG_FILE, 'utf-8'); + return JSON.parse(data) as GlobalConfig; + } catch { + return {}; + } +} + +export async function updateGlobalConfig(partial: GlobalConfig): Promise { + try { + const existing = await readGlobalConfig(); + + // Shallow merge with one level of nesting for telemetry sub-object + const merged: GlobalConfig = { ...existing }; + + if (partial.telemetry !== undefined) { + merged.telemetry = { ...existing.telemetry, ...partial.telemetry }; + } + + await mkdir(GLOBAL_CONFIG_DIR, { recursive: true }); + await writeFile(GLOBAL_CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8'); + } catch { + // Silently ignore write failures + } +} diff --git a/src/cli/telemetry/__tests__/resolve.test.ts b/src/cli/telemetry/__tests__/resolve.test.ts new file mode 100644 index 00000000..9c64f34d --- /dev/null +++ b/src/cli/telemetry/__tests__/resolve.test.ts @@ -0,0 +1,129 @@ +import * as globalConfig from '../../global-config'; +import { resolveTelemetryPreference } from '../resolve'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../global-config'); + +const mockReadGlobalConfig = vi.mocked(globalConfig.readGlobalConfig); + +describe('resolveTelemetryPreference', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + // eslint-disable-next-line @typescript-eslint/dot-notation + delete process.env['AGENTCORE_TELEMETRY_DISABLED']; + // eslint-disable-next-line @typescript-eslint/dot-notation + delete process.env['DO_NOT_TRACK']; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('AGENTCORE_TELEMETRY_DISABLED env var', () => { + it('disables telemetry when set to "true"', async () => { + process.env.AGENTCORE_TELEMETRY_DISABLED = 'true'; + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: false, source: 'environment' }); + }); + + it('disables telemetry when set to "1"', async () => { + process.env.AGENTCORE_TELEMETRY_DISABLED = '1'; + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: false, source: 'environment' }); + }); + + it('enables telemetry when set to "false"', async () => { + process.env.AGENTCORE_TELEMETRY_DISABLED = 'false'; + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: true, source: 'environment' }); + }); + + it('enables telemetry when set to "0"', async () => { + process.env.AGENTCORE_TELEMETRY_DISABLED = '0'; + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: true, source: 'environment' }); + }); + + it('is case-insensitive', async () => { + process.env.AGENTCORE_TELEMETRY_DISABLED = 'TRUE'; + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: false, source: 'environment' }); + }); + }); + + describe('DO_NOT_TRACK env var', () => { + it('disables telemetry when set to "1"', async () => { + process.env.DO_NOT_TRACK = '1'; + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: false, source: 'environment' }); + }); + + it('disables telemetry when set to "true"', async () => { + process.env.DO_NOT_TRACK = 'true'; + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: false, source: 'environment' }); + }); + + it('AGENTCORE_TELEMETRY_DISABLED takes priority over DO_NOT_TRACK', async () => { + process.env.AGENTCORE_TELEMETRY_DISABLED = 'false'; + process.env.DO_NOT_TRACK = '1'; + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: true, source: 'environment' }); + }); + }); + + describe('global config', () => { + it('uses config file when no env vars set', async () => { + mockReadGlobalConfig.mockResolvedValue({ telemetry: { enabled: false } }); + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: false, source: 'global-config' }); + }); + + it('uses config file enabled value', async () => { + mockReadGlobalConfig.mockResolvedValue({ telemetry: { enabled: true } }); + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: true, source: 'global-config' }); + }); + }); + + describe('default', () => { + it('defaults to enabled when no env vars or config', async () => { + mockReadGlobalConfig.mockResolvedValue({}); + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: true, source: 'default' }); + }); + + it('defaults to enabled when config has no telemetry section', async () => { + mockReadGlobalConfig.mockResolvedValue({}); + + const result = await resolveTelemetryPreference(); + + expect(result).toEqual({ enabled: true, source: 'default' }); + }); + }); +}); diff --git a/src/cli/telemetry/index.ts b/src/cli/telemetry/index.ts new file mode 100644 index 00000000..2a12c518 --- /dev/null +++ b/src/cli/telemetry/index.ts @@ -0,0 +1,2 @@ +export { resolveTelemetryPreference } from './resolve.js'; +export type { TelemetryPreference } from './resolve.js'; diff --git a/src/cli/telemetry/resolve.ts b/src/cli/telemetry/resolve.ts new file mode 100644 index 00000000..f02598f4 --- /dev/null +++ b/src/cli/telemetry/resolve.ts @@ -0,0 +1,40 @@ +import { readGlobalConfig } from '../global-config.js'; + +export interface TelemetryPreference { + enabled: boolean; + source: 'environment' | 'global-config' | 'default'; +} + +export async function resolveTelemetryPreference(): Promise { + // 1. Check AGENTCORE_TELEMETRY_DISABLED env var + // eslint-disable-next-line @typescript-eslint/dot-notation + const agentcoreEnv = process.env['AGENTCORE_TELEMETRY_DISABLED']; + if (agentcoreEnv !== undefined) { + const normalized = agentcoreEnv.toLowerCase().trim(); + if (normalized === 'true' || normalized === '1') { + return { enabled: false, source: 'environment' }; + } + if (normalized === 'false' || normalized === '0') { + return { enabled: true, source: 'environment' }; + } + } + + // 2. Check DO_NOT_TRACK env var (cross-tool standard) + // eslint-disable-next-line @typescript-eslint/dot-notation + const doNotTrack = process.env['DO_NOT_TRACK']; + if (doNotTrack !== undefined) { + const normalized = doNotTrack.toLowerCase().trim(); + if (normalized === '1' || normalized === 'true') { + return { enabled: false, source: 'environment' }; + } + } + + // 3. Check global config file + const config = await readGlobalConfig(); + if (config.telemetry?.enabled !== undefined) { + return { enabled: config.telemetry.enabled, source: 'global-config' }; + } + + // 4. Default: enabled + return { enabled: true, source: 'default' }; +} diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 0ea080cf..2b4d183c 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -39,5 +39,6 @@ export const COMMAND_DESCRIPTIONS = { remove: 'Remove AgentCore resources and project', status: 'Retrieve details of deployed AgentCore resources.', update: 'Check for and install CLI updates', + telemetry: 'Manage anonymous usage analytics preferences.', validate: 'Validate agentcore/ config files.', } as const; diff --git a/src/cli/update-notifier.ts b/src/cli/update-notifier.ts index 98c46829..1f5b9333 100644 --- a/src/cli/update-notifier.ts +++ b/src/cli/update-notifier.ts @@ -1,10 +1,10 @@ import { compareVersions, fetchLatestVersion } from './commands/update/action.js'; import { PACKAGE_VERSION } from './constants.js'; +import { GLOBAL_CONFIG_DIR } from './global-config.js'; import { mkdir, readFile, writeFile } from 'fs/promises'; -import { homedir } from 'os'; import { join } from 'path'; -const CACHE_DIR = join(homedir(), '.agentcore'); +const CACHE_DIR = GLOBAL_CONFIG_DIR; const CACHE_FILE = join(CACHE_DIR, 'update-check.json'); const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // every 24 hours