Skip to content

Commit 7cdbba8

Browse files
committed
feat: add telemetry notice and preference management
1 parent 4a3d91d commit 7cdbba8

File tree

16 files changed

+589
-20
lines changed

16 files changed

+589
-20
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { getOrCreateInstallationId, readGlobalConfig, updateGlobalConfig } from '../global-config';
2+
import { createTempConfig } from './helpers/temp-config';
3+
import { readFile, writeFile } from 'fs/promises';
4+
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
5+
6+
const tmp = createTempConfig('gc');
7+
8+
describe('global-config', () => {
9+
beforeEach(() => tmp.setup());
10+
afterAll(() => tmp.cleanup());
11+
12+
describe('readGlobalConfig', () => {
13+
it('returns parsed config when file exists', async () => {
14+
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } }));
15+
16+
const config = await readGlobalConfig(tmp.configFile);
17+
18+
expect(config).toEqual({ telemetry: { enabled: false } });
19+
});
20+
21+
it('returns empty object when file does not exist', async () => {
22+
const config = await readGlobalConfig(tmp.testDir + '/nonexistent.json');
23+
24+
expect(config).toEqual({});
25+
});
26+
27+
it('returns empty object when file contains invalid JSON', async () => {
28+
await writeFile(tmp.configFile, 'not json');
29+
30+
const config = await readGlobalConfig(tmp.configFile);
31+
32+
expect(config).toEqual({});
33+
});
34+
35+
it('returns empty object when Zod validation fails (wrong types)', async () => {
36+
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: 'false' } }));
37+
38+
const config = await readGlobalConfig(tmp.configFile);
39+
40+
expect(config).toEqual({});
41+
});
42+
43+
it('returns empty object for non-object JSON values', async () => {
44+
await writeFile(tmp.configFile, '[1, 2, 3]');
45+
46+
const config = await readGlobalConfig(tmp.configFile);
47+
48+
expect(config).toEqual({});
49+
});
50+
51+
it('preserves unknown fields via passthrough', async () => {
52+
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: true }, futureField: 'hello' }));
53+
54+
const config = await readGlobalConfig(tmp.configFile);
55+
56+
expect(config).toMatchObject({ telemetry: { enabled: true }, futureField: 'hello' });
57+
});
58+
59+
it('parses all config fields', async () => {
60+
const full = {
61+
installationId: 'abc-123',
62+
telemetry: { enabled: true, endpoint: 'https://example.com', audit: false },
63+
};
64+
await writeFile(tmp.configFile, JSON.stringify(full));
65+
66+
const config = await readGlobalConfig(tmp.configFile);
67+
68+
expect(config).toEqual(full);
69+
});
70+
});
71+
72+
describe('updateGlobalConfig', () => {
73+
it('creates directory and writes config', async () => {
74+
const fresh = createTempConfig('gc-fresh');
75+
76+
const ok = await updateGlobalConfig({ telemetry: { enabled: false } }, fresh.configDir, fresh.configFile);
77+
78+
expect(ok).toBe(true);
79+
const written = JSON.parse(await readFile(fresh.configFile, 'utf-8'));
80+
expect(written).toEqual({ telemetry: { enabled: false } });
81+
82+
await fresh.cleanup();
83+
});
84+
85+
it('merges telemetry sub-object with existing config', async () => {
86+
await writeFile(
87+
tmp.configFile,
88+
JSON.stringify({ installationId: 'keep-me', telemetry: { enabled: true, endpoint: 'https://x.com' } })
89+
);
90+
91+
await updateGlobalConfig({ telemetry: { enabled: false } }, tmp.configDir, tmp.configFile);
92+
93+
const written = JSON.parse(await readFile(tmp.configFile, 'utf-8'));
94+
expect(written).toEqual({
95+
installationId: 'keep-me',
96+
telemetry: { enabled: false, endpoint: 'https://x.com' },
97+
});
98+
});
99+
100+
it('returns false on write failures', async () => {
101+
const ok = await updateGlobalConfig(
102+
{ telemetry: { enabled: true } },
103+
tmp.testDir + '/\0invalid',
104+
tmp.testDir + '/\0invalid/config.json'
105+
);
106+
107+
expect(ok).toBe(false);
108+
});
109+
110+
it('handles missing existing config gracefully', async () => {
111+
const ok = await updateGlobalConfig({ telemetry: { enabled: true } }, tmp.configDir, tmp.configFile);
112+
113+
expect(ok).toBe(true);
114+
const written = JSON.parse(await readFile(tmp.configFile, 'utf-8'));
115+
expect(written).toEqual({ telemetry: { enabled: true } });
116+
});
117+
});
118+
119+
describe('getOrCreateInstallationId', () => {
120+
it('generates installationId on first run and returns created: true', async () => {
121+
const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);
122+
123+
expect(result.created).toBe(true);
124+
expect(result.id).toMatch(/^[0-9a-f-]{36}$/);
125+
const config = await readGlobalConfig(tmp.configFile);
126+
expect(config.installationId).toBe(result.id);
127+
});
128+
129+
it('returns existing id with created: false', async () => {
130+
await writeFile(tmp.configFile, JSON.stringify({ installationId: 'existing-id' }));
131+
132+
const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);
133+
134+
expect(result).toEqual({ id: 'existing-id', created: false });
135+
});
136+
137+
it('preserves existing config when generating installationId', async () => {
138+
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } }));
139+
140+
const { id } = await getOrCreateInstallationId(tmp.configDir, tmp.configFile);
141+
142+
const config = await readGlobalConfig(tmp.configFile);
143+
expect(config.telemetry?.enabled).toBe(false);
144+
expect(config.installationId).toBe(id);
145+
});
146+
});
147+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { mkdir, rm } from 'fs/promises';
2+
import { randomUUID } from 'node:crypto';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
6+
export interface TempConfigPaths {
7+
testDir: string;
8+
configDir: string;
9+
configFile: string;
10+
setup: () => Promise<void>;
11+
cleanup: () => Promise<void>;
12+
}
13+
14+
export function createTempConfig(label: string): TempConfigPaths {
15+
const testDir = join(tmpdir(), `agentcore-${label}-${randomUUID()}`);
16+
const configDir = join(testDir, '.agentcore');
17+
const configFile = join(configDir, 'config.json');
18+
return {
19+
testDir,
20+
configDir,
21+
configFile,
22+
setup: async () => {
23+
await rm(testDir, { recursive: true, force: true });
24+
await mkdir(configDir, { recursive: true });
25+
},
26+
cleanup: () => rm(testDir, { recursive: true, force: true }),
27+
};
28+
}

src/cli/ansi.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** ANSI escape codes for console output. */
2+
export const ANSI = {
3+
red: '\x1b[31m',
4+
green: '\x1b[32m',
5+
yellow: '\x1b[33m',
6+
cyan: '\x1b[36m',
7+
dim: '\x1b[2m',
8+
reset: '\x1b[0m',
9+
} as const;

src/cli/cli.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ANSI } from './ansi';
12
import { registerAdd } from './commands/add';
23
import { registerCreate } from './commands/create';
34
import { registerDeploy } from './commands/deploy';
@@ -14,10 +15,12 @@ import { registerRemove } from './commands/remove';
1415
import { registerResume } from './commands/resume';
1516
import { registerRun } from './commands/run';
1617
import { registerStatus } from './commands/status';
18+
import { registerTelemetry } from './commands/telemetry';
1719
import { registerTraces } from './commands/traces';
1820
import { registerUpdate } from './commands/update';
1921
import { registerValidate } from './commands/validate';
2022
import { PACKAGE_VERSION } from './constants';
23+
import { getOrCreateInstallationId } from './global-config';
2124
import { ALL_PRIMITIVES } from './primitives';
2225
import { App } from './tui/App';
2326
import { LayoutProvider } from './tui/context';
@@ -61,10 +64,24 @@ function setupGlobalCleanup() {
6164
});
6265
}
6366

67+
function printTelemetryNotice(): void {
68+
process.stderr.write(
69+
[
70+
'',
71+
`${ANSI.yellow}The AgentCore CLI will soon begin collecting aggregated, anonymous usage`,
72+
'analytics to help improve the tool.',
73+
'To opt out: agentcore telemetry disable',
74+
`To learn more: agentcore telemetry --help${ANSI.reset}`,
75+
'',
76+
'',
77+
].join('\n')
78+
);
79+
}
80+
6481
/**
6582
* Render the TUI in alternate screen buffer mode.
6683
*/
67-
function renderTUI(updateCheck: Promise<UpdateCheckResult | null>) {
84+
function renderTUI(updateCheck: Promise<UpdateCheckResult | null>, isFirstRun: boolean) {
6885
inAltScreen = true;
6986
process.stdout.write(ENTER_ALT_SCREEN);
7087

@@ -82,6 +99,10 @@ function renderTUI(updateCheck: Promise<UpdateCheckResult | null>) {
8299
clearExitMessage();
83100
}
84101

102+
if (isFirstRun) {
103+
printTelemetryNotice();
104+
}
105+
85106
// Print update notification after TUI exits
86107
const result = await updateCheck;
87108
if (result?.updateAvailable) {
@@ -148,6 +169,7 @@ export function registerCommands(program: Command) {
148169
registerResume(program);
149170
registerRun(program);
150171
registerStatus(program);
172+
registerTelemetry(program);
151173
registerTraces(program);
152174
registerUpdate(program);
153175
registerValidate(program);
@@ -162,6 +184,9 @@ export const main = async (argv: string[]) => {
162184
// Register global cleanup handlers once at startup
163185
setupGlobalCleanup();
164186

187+
// Generate installationId on first run and show telemetry notice
188+
const { created: isFirstRun } = await getOrCreateInstallationId();
189+
165190
const program = createProgram();
166191

167192
const args = argv.slice(2);
@@ -172,10 +197,14 @@ export const main = async (argv: string[]) => {
172197

173198
// Show TUI for no arguments, commander handles --help via configureHelp()
174199
if (args.length === 0) {
175-
renderTUI(updateCheck);
200+
renderTUI(updateCheck, isFirstRun);
176201
return;
177202
}
178203

204+
if (isFirstRun) {
205+
printTelemetryNotice();
206+
}
207+
179208
await program.parseAsync(argv);
180209

181210
// Print notification after command finishes

src/cli/commands/import/constants.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,7 @@
22
export const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/;
33

44
/** ANSI escape codes for console output. */
5-
export const ANSI = {
6-
red: '\x1b[31m',
7-
green: '\x1b[32m',
8-
yellow: '\x1b[33m',
9-
cyan: '\x1b[36m',
10-
dim: '\x1b[2m',
11-
reset: '\x1b[0m',
12-
} as const;
5+
export { ANSI } from '../../ansi.js';
136

147
/**
158
* CloudFormation resource type to identifier key mapping for IMPORT.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { createTempConfig } from '../../../__tests__/helpers/temp-config';
2+
import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from '../actions';
3+
import { chmod, mkdir, rm, writeFile } from 'fs/promises';
4+
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
5+
6+
const tmp = createTempConfig('actions');
7+
8+
describe('telemetry actions', () => {
9+
beforeEach(() => tmp.setup());
10+
afterAll(() => tmp.cleanup());
11+
12+
describe('handleTelemetryDisable', () => {
13+
it('writes disabled config and returns confirmation', async () => {
14+
const msg = await handleTelemetryDisable(tmp.configDir, tmp.configFile);
15+
16+
expect(msg).toBe('Telemetry has been disabled.');
17+
});
18+
19+
it('returns warning when config write fails', async () => {
20+
await rm(tmp.testDir, { recursive: true, force: true });
21+
await mkdir(tmp.testDir, { recursive: true });
22+
await chmod(tmp.testDir, 0o444);
23+
24+
const msg = await handleTelemetryDisable(tmp.configDir, tmp.configFile);
25+
26+
expect(msg).toBe(`Warning: could not write config to ${tmp.configFile}`);
27+
28+
await chmod(tmp.testDir, 0o755);
29+
});
30+
});
31+
32+
describe('handleTelemetryEnable', () => {
33+
it('writes enabled config and returns confirmation', async () => {
34+
const msg = await handleTelemetryEnable(tmp.configDir, tmp.configFile);
35+
36+
expect(msg).toBe('Telemetry has been enabled.');
37+
});
38+
});
39+
40+
describe('handleTelemetryStatus', () => {
41+
it('returns enabled status with default source', async () => {
42+
const lines = await handleTelemetryStatus(tmp.configFile);
43+
44+
expect(lines).toContain('Telemetry: Enabled');
45+
expect(lines).toContain('Source: default');
46+
});
47+
48+
it('returns disabled status with global-config source', async () => {
49+
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } }));
50+
51+
const lines = await handleTelemetryStatus(tmp.configFile);
52+
53+
expect(lines).toContain('Telemetry: Disabled');
54+
expect(lines).toContain(`Source: global config (${tmp.configFile})`);
55+
});
56+
57+
it('includes env var note when source is environment', async () => {
58+
const originalEnv = process.env;
59+
process.env = { ...originalEnv, AGENTCORE_TELEMETRY_DISABLED: 'true' };
60+
61+
const lines = await handleTelemetryStatus(tmp.configFile);
62+
63+
expect(lines).toContain('Telemetry: Disabled');
64+
expect(lines).toContain('Source: environment variable');
65+
expect(lines).toContain('\nNote: AGENTCORE_TELEMETRY_DISABLED=true is set in your environment.');
66+
67+
process.env = originalEnv;
68+
});
69+
});
70+
});

0 commit comments

Comments
 (0)