Skip to content
Open
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
9 changes: 7 additions & 2 deletions packages/create-nx-workspace/bin/create-nx-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ let chosenPreset: string;
let useCloud: boolean;
// For stats
let packageManager: string;
// Analytics opt-in answer for the completion stat.
let analyticsPrompt: 'yes' | 'no' | 'unset' = 'unset';

type AngularUnitTestRunner =
| 'none'
Expand Down Expand Up @@ -427,6 +429,7 @@ async function main(parsedArgs: yargs.Arguments<Arguments>) {
setupCloudPrompt:
messages.codeOfSelectedPromptMessage('setupNxCloudV2') ||
messages.codeOfSelectedPromptMessage('setupNxCloud'),
analyticsPrompt,
nxCloudArg: parsedArgs.nxCloud ?? '',
nxCloudArgRaw: rawArgs.nxCloud ?? '',
pushedToVcs: workspaceInfo.pushedToVcs ?? '',
Expand Down Expand Up @@ -674,7 +677,8 @@ async function normalizeArgsMiddleware(
: getCompletionMessageKeyForVariant();
}

const analytics = await determineAnalytics(argv);
analyticsPrompt = await determineAnalytics(argv);
const analytics = analyticsPrompt === 'yes';
packageManager = argv.packageManager ?? detectInvokedPackageManager();
Object.assign(argv, {
nxCloud,
Expand Down Expand Up @@ -769,7 +773,8 @@ async function normalizeArgsMiddleware(
: getCompletionMessageKeyForVariant();
}

const analytics = await determineAnalytics(argv);
analyticsPrompt = await determineAnalytics(argv);
const analytics = analyticsPrompt === 'yes';

Object.assign(argv, {
nxCloud,
Expand Down
10 changes: 5 additions & 5 deletions packages/create-nx-workspace/src/internal-utils/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,14 @@ async function aiAgentsPrompt(): Promise<Agent[]> {

export async function determineAnalytics(
parsedArgs: yargs.Arguments<{ analytics?: boolean }>
): Promise<boolean> {
): Promise<'yes' | 'no' | 'unset'> {
if (typeof parsedArgs.analytics === 'boolean') {
return parsedArgs.analytics;
return parsedArgs.analytics ? 'yes' : 'no';
}

if (!parsedArgs.interactive || isCI()) {
// Default to false in non-interactive/CI
return false;
// Not asked in non-interactive/CI.
return 'unset';
}

const { enableAnalytics } = await enquirer.prompt<{
Expand All @@ -207,7 +207,7 @@ export async function determineAnalytics(
initial: 0,
},
]);
return enableAnalytics === 'Yes';
return enableAnalytics === 'Yes' ? 'yes' : 'no';
}

export async function determineDefaultBase(
Expand Down
7 changes: 7 additions & 0 deletions packages/nx/src/command-line/init/init-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { isAiAgent } from '../../native';
import { Agent } from '../../ai/utils';
import { detectAiAgent } from '../../ai/detect-ai-agent';
import { MessageOptionKey, recordStat } from '../../utils/ab-testing';
import { ensureAnalyticsPreferenceSet } from '../../utils/analytics-prompt';
import { isCI } from '../../utils/is-ci';
import { detectPackageManager } from '../../utils/package-manager';
import {
Expand Down Expand Up @@ -465,13 +466,19 @@ async function runInit(
setNeverConnectToCloud(repoRoot);
}

const analyticsPrompt = await ensureAnalyticsPreferenceSet(
repoRoot,
options.interactive
);

await recordStat({
command: 'init',
nxVersion: version,
useCloud: nxCloudChoice === 'yes',
meta: {
type: 'complete',
nxCloudArg: nxCloudChoice,
analyticsPrompt,
nodeVersion: process.versions.node,
os: process.platform,
packageManager: detectPackageManager(),
Expand Down
81 changes: 81 additions & 0 deletions packages/nx/src/utils/analytics-prompt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ jest.mock('enquirer', () => ({
prompt: (...args: any[]) => mockPrompt(...args),
}));

import { mkdtempSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { ensureAnalyticsPreferenceSet } from './analytics-prompt';
import * as isCi from './is-ci';
import * as fileUtils from './fileutils';
Expand Down Expand Up @@ -141,4 +144,82 @@ describe('analytics-prompt', () => {
);
});
});

describe('ensureAnalyticsPreferenceSet with explicit root/interactive', () => {
let root: string;

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'nx-analytics-init-'));
writeFileSync(join(root, 'nx.json'), '{}');
});

beforeEach(() => {
mockReadJsonFile.mockReturnValue({});
});

it("returns 'unset' in CI without prompting", async () => {
mockIsCI.mockReturnValue(true);

expect(await ensureAnalyticsPreferenceSet(root, true)).toBe('unset');
expect(mockPrompt).not.toHaveBeenCalled();
});

it("returns 'unset' when non-interactive without prompting", async () => {
mockIsCI.mockReturnValue(false);

expect(await ensureAnalyticsPreferenceSet(root, false)).toBe('unset');
expect(mockPrompt).not.toHaveBeenCalled();
});

it("returns 'unset' when nx.json does not exist", async () => {
mockIsCI.mockReturnValue(false);

expect(
await ensureAnalyticsPreferenceSet(join(root, 'missing'), true)
).toBe('unset');
expect(mockPrompt).not.toHaveBeenCalled();
});

it("returns existing 'yes' without re-prompting", async () => {
mockIsCI.mockReturnValue(false);
mockReadNxJson.mockReturnValue({ analytics: true });

expect(await ensureAnalyticsPreferenceSet(root, true)).toBe('yes');
expect(mockPrompt).not.toHaveBeenCalled();
expect(mockWriteFormattedJsonFile).not.toHaveBeenCalled();
});

it("returns existing 'no' without re-prompting", async () => {
mockIsCI.mockReturnValue(false);
mockReadNxJson.mockReturnValue({ analytics: false });

expect(await ensureAnalyticsPreferenceSet(root, true)).toBe('no');
expect(mockPrompt).not.toHaveBeenCalled();
expect(mockWriteFormattedJsonFile).not.toHaveBeenCalled();
});

it("prompts, saves, and returns 'yes' when accepted", async () => {
mockIsCI.mockReturnValue(false);
mockReadNxJson.mockReturnValue({});
mockPrompt.mockResolvedValue({ enableAnalytics: true });

expect(await ensureAnalyticsPreferenceSet(root, true)).toBe('yes');
expect(mockWriteFormattedJsonFile).toHaveBeenCalledWith(
expect.stringContaining('nx.json'),
expect.objectContaining({ analytics: true })
);
});

it("prompts, saves, and returns 'no' when declined", async () => {
mockIsCI.mockReturnValue(false);
mockReadNxJson.mockReturnValue({});
mockPrompt.mockResolvedValue({ enableAnalytics: false });

expect(await ensureAnalyticsPreferenceSet(root, true)).toBe('no');
expect(mockWriteFormattedJsonFile).toHaveBeenCalledWith(
expect.stringContaining('nx.json'),
expect.objectContaining({ analytics: false })
);
});
});
});
46 changes: 24 additions & 22 deletions packages/nx/src/utils/analytics-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,36 @@ import { writeFormattedJsonFile } from './write-formatted-json-file';
import { workspaceRoot } from './workspace-root';

/**
* Prompts user for analytics preference if not already set in nx.json.
* Only prompts in interactive terminals, not in CI.
* Prompts for analytics preference if not already set in nx.json, persists the
* answer so later commands don't re-ask, and returns it for telemetry. Returns
* 'unset' when not prompted (CI / non-interactive / no nx.json). `nx init`
* passes its own root + interactive flag; the default call (bin/nx.ts) derives
* interactive from the TTY.
*/
export async function ensureAnalyticsPreferenceSet(): Promise<void> {
if (isCI()) {
return;
}

// Only prompt in interactive terminals
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
if (!isInteractive) {
return;
export async function ensureAnalyticsPreferenceSet(
root: string = workspaceRoot,
interactive: boolean = !!(process.stdin.isTTY && process.stdout.isTTY)
): Promise<'yes' | 'no' | 'unset'> {
if (!interactive || isCI()) {
return 'unset';
}

// Only prompt inside a workspace that has nx.json — avoid creating
// nx.json in arbitrary directories (e.g. when running cloud commands
// outside a workspace).
const nxJsonPath = join(workspaceRoot, 'nx.json');
if (!existsSync(nxJsonPath)) {
return;
if (!existsSync(join(root, 'nx.json'))) {
return 'unset';
}

const nxJson = readNxJson(workspaceRoot);
// Check if already set (true = enabled, false = disabled)
const nxJson = readNxJson(root);
// Already chosen (true = enabled, false = disabled) — report it.
if (typeof nxJson?.analytics === 'boolean') {
return;
return nxJson.analytics ? 'yes' : 'no';
}

const analyticsEnabled = await promptForAnalyticsPreference();

await saveAnalyticsPreference(analyticsEnabled);
const enabled = await promptForAnalyticsPreference();
await saveAnalyticsPreference(root, enabled);
return enabled ? 'yes' : 'no';
}

export async function promptForAnalyticsPreference(): Promise<boolean> {
Expand Down Expand Up @@ -69,9 +68,12 @@ export async function promptForAnalyticsPreference(): Promise<boolean> {
}
}

async function saveAnalyticsPreference(enabled: boolean): Promise<void> {
async function saveAnalyticsPreference(
root: string,
enabled: boolean
): Promise<void> {
try {
const nxJsonPath = join(workspaceRoot, 'nx.json');
const nxJsonPath = join(root, 'nx.json');
const nxJson = readJsonFile(nxJsonPath);
nxJson.analytics = enabled;
await writeFormattedJsonFile(nxJsonPath, nxJson);
Expand Down
Loading