diff --git a/packages/nx/src/command-line/init/command-object.ts b/packages/nx/src/command-line/init/command-object.ts index 07b4333dea9ae..cc6ac785c9481 100644 --- a/packages/nx/src/command-line/init/command-object.ts +++ b/packages/nx/src/command-line/init/command-object.ts @@ -1,17 +1,6 @@ import { Argv, CommandModule } from 'yargs'; import { handleImport } from '../../utils/handle-import'; import { parseCSV } from '../yargs-utils/shared-options'; -import { isAiAgent } from '../../native'; -import { - writeAiOutput, - buildErrorResult, - writeErrorLog, - determineErrorCode, -} from './utils/ai-output'; -import { recordStat } from '../../utils/ab-testing'; -import { nxVersion } from '../../utils/versions'; -import { isCI } from '../../utils/is-ci'; -import { detectPackageManager } from '../../utils/package-manager'; export const yargsInitCommand: CommandModule = { command: 'init', @@ -42,72 +31,14 @@ export const yargsInitCommand: CommandModule = { throw error; }); - const aiAgent = isAiAgent(); - - recordStat({ - command: 'init', - nxVersion, - useCloud: false, - meta: { - type: 'start', - nodeVersion: process.versions.node, - os: process.platform, - packageManager: detectPackageManager(), - aiAgent, - isCI: isCI(), - }, - }); - - try { - const useV2 = await isInitV2(); - if (useV2) { - // v2 records its own complete event with richer context - await require('./init-v2').initHandler(args); - } else { - await require('./init-v1').initHandler(args); - await recordStat({ - command: 'init', - nxVersion, - useCloud: false, - meta: { - type: 'complete', - nodeVersion: process.versions.node, - os: process.platform, - packageManager: detectPackageManager(), - aiAgent, - isCI: isCI(), - }, - }); - } - process.exit(0); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - const errorCode = determineErrorCode(error); - - await recordStat({ - command: 'init', - nxVersion, - useCloud: false, - meta: { - type: 'error', - errorCode, - errorMessage: errorMessage.substring(0, 250), - aiAgent, - }, - }); - - // Output structured error for AI agents - if (aiAgent) { - const errorLogPath = writeErrorLog(error); - writeAiOutput(buildErrorResult(errorMessage, errorCode, errorLogPath)); - } else { - // Ensure the cursor is always restored just in case the user has bailed during interactive prompts - // Skip for AI agents to avoid corrupting NDJSON output - process.stdout.write('\x1b[?25h'); - } - process.exit(1); + const useV2 = await isInitV2(); + if (useV2) { + await require('./init-v2').initHandler(args); + } else { + // v1 path retained for `NX_ADD_PLUGINS=false`; slated for removal. + await require('./init-v1').initHandler(args); } + process.exit(0); }, }; diff --git a/packages/nx/src/command-line/init/configure-plugins.ts b/packages/nx/src/command-line/init/configure-plugins.ts index a720ea770edfd..cb599f9451886 100644 --- a/packages/nx/src/command-line/init/configure-plugins.ts +++ b/packages/nx/src/command-line/init/configure-plugins.ts @@ -41,8 +41,12 @@ export function installPluginPackages( nxJson.installation.plugins[plugin] = nxVersion; } writeJsonFile(join(repoRoot, 'nx.json'), nxJson); - // Invoking nx wrapper to install plugins. - runNxSync('--version', { stdio: 'ignore' }); + try { + runNxSync('--version', { stdio: 'pipe' }); + } catch (e) { + if ((e as any)?.stderr) process.stderr.write((e as any).stderr); + throw e; + } } } diff --git a/packages/nx/src/command-line/init/implementation/utils.spec.ts b/packages/nx/src/command-line/init/implementation/utils.spec.ts index 56713dedebce5..63e574358c7c2 100644 --- a/packages/nx/src/command-line/init/implementation/utils.spec.ts +++ b/packages/nx/src/command-line/init/implementation/utils.spec.ts @@ -3,7 +3,12 @@ jest.mock('./deduce-default-base', () => ({ })); import { NxJsonConfiguration } from '../../../config/nx-json'; -import { createNxJsonFromTurboJson } from './utils'; +import { + createNxJsonFromTurboJson, + extractErrorName, + readErrorStderr, + toErrorString, +} from './utils'; describe('utils', () => { describe('createNxJsonFromTurboJson', () => { @@ -266,4 +271,84 @@ describe('utils', () => { expect(createNxJsonFromTurboJson(turbo)).toEqual(nx); }); }); + + describe('toErrorString', () => { + it('returns error.message when present', () => { + expect(toErrorString(new Error('boom'))).toBe('boom'); + }); + + it('returns "Error" for bare new Error() instead of empty string', () => { + expect(toErrorString(new Error())).toBe('Error'); + }); + + it('returns "Unknown error" for null/undefined', () => { + expect(toErrorString(null)).toBe('Unknown error'); + expect(toErrorString(undefined)).toBe('Unknown error'); + }); + + it('coerces primitive throws', () => { + expect(toErrorString('str')).toBe('str'); + expect(toErrorString(42)).toBe('42'); + }); + + it('includes own-property code when message is empty', () => { + const e = new Error('') as Error & { code?: string }; + e.code = 'E404'; + expect(toErrorString(e)).toContain('E404'); + }); + + it('serializes plain objects', () => { + expect(toErrorString({ foo: 'bar' })).toBe('{"foo":"bar"}'); + }); + + it('falls through to toString() for unserializable objects', () => { + const circular: any = {}; + circular.self = circular; + expect(toErrorString(circular)).toBe('[object Object]'); + }); + }); + + describe('readErrorStderr', () => { + it('returns string stderr as-is', () => { + expect(readErrorStderr({ stderr: 'hello' })).toBe('hello'); + }); + + it('decodes Buffer stderr to utf8', () => { + expect(readErrorStderr({ stderr: Buffer.from('boom', 'utf8') })).toBe( + 'boom' + ); + }); + + it('returns "" when stderr is absent or nullish', () => { + expect(readErrorStderr({})).toBe(''); + expect(readErrorStderr(null)).toBe(''); + expect(readErrorStderr({ stderr: null })).toBe(''); + }); + }); + + describe('extractErrorName', () => { + it('prefers Node e.code when set', () => { + expect(extractErrorName({ code: 'EACCES' }, 'stderr E404')).toBe( + 'EACCES' + ); + }); + + it.each([ + ['npm error code E404', 'E404'], + ['npm error code ERESOLVE', 'ERESOLVE'], + ['npm error code EINTEGRITY sha512 failure', 'EINTEGRITY'], + ['ERR_PNPM_PEER_DEP_ISSUES Unmet peer deps', 'ERR_PNPM_PEER_DEP_ISSUES'], + ])('extracts %s as %s', (stderr, expected) => { + expect(extractErrorName({}, stderr)).toBe(expected); + }); + + it('falls back to error.name for plain Errors', () => { + expect(extractErrorName(new TypeError('x'), '')).toBe('TypeError'); + }); + + it('returns typeof for non-Error throws', () => { + expect(extractErrorName('str', '')).toBe('string'); + expect(extractErrorName(42, '')).toBe('number'); + }); + }); }); diff --git a/packages/nx/src/command-line/init/implementation/utils.ts b/packages/nx/src/command-line/init/implementation/utils.ts index e68f776f48bc4..0991f512d8801 100644 --- a/packages/nx/src/command-line/init/implementation/utils.ts +++ b/packages/nx/src/command-line/init/implementation/utils.ts @@ -229,11 +229,66 @@ export function runInstall( repoRoot: string, pmc: PackageManagerCommands = getPackageManagerCommand() ) { - execSync(pmc.install, { - stdio: ['ignore', 'ignore', 'inherit'], - cwd: repoRoot, - windowsHide: true, - }); + try { + execSync(pmc.install, { + stdio: ['ignore', 'ignore', 'pipe'], + encoding: 'utf8', + cwd: repoRoot, + windowsHide: true, + }); + } catch (e) { + if ((e as any)?.stderr) process.stderr.write((e as any).stderr); + throw e; + } +} + +/** + * Coerce any thrown value into a non-empty telemetry string. The naive + * `error.message || String(error)` yields "" for bare `new Error()`. + */ +export function toErrorString(error: unknown): string { + if (error == null) return 'Unknown error'; + if (error instanceof Error) { + if (error.message) return error.message; + if (error.name && error.name !== 'Error') return error.name; + // Drop `stack` — large and contains absolute paths (PII). + const keys = Object.getOwnPropertyNames(error).filter((k) => k !== 'stack'); + const serialized = safeJsonStringify(error, keys); + if (serialized && serialized !== '{}') return serialized; + return error.name || 'Error'; + } + if (typeof error === 'object') { + const serialized = safeJsonStringify(error); + if (serialized && serialized !== '{}') return serialized; + return Object.prototype.toString.call(error); + } + return String(error); +} + +export function readErrorStderr(error: unknown): string { + const raw = (error as any)?.stderr; + if (typeof raw === 'string') return raw; + if (raw && typeof (raw as Buffer).toString === 'function') { + return (raw as Buffer).toString('utf8'); + } + return ''; +} + +export function extractErrorName(error: unknown, stderr: string): string { + const nodeCode = (error as any)?.code; + if (typeof nodeCode === 'string') return nodeCode; + const m = stderr.match(/\b(E[A-Z0-9_]{2,}|ERR_[A-Z0-9_]+)\b/); + if (m) return m[1]; + if (error instanceof Error) return error.name; + return typeof error; +} + +function safeJsonStringify(value: unknown, replacer?: string[]): string { + try { + return JSON.stringify(value, replacer); + } catch { + return ''; + } } export async function initCloud( diff --git a/packages/nx/src/command-line/init/init-v2.ts b/packages/nx/src/command-line/init/init-v2.ts index f5c3804b0e5c4..b92bd24ba808e 100644 --- a/packages/nx/src/command-line/init/init-v2.ts +++ b/packages/nx/src/command-line/init/init-v2.ts @@ -23,10 +23,13 @@ import { addNxToAngularCliRepo } from './implementation/angular'; import { generateDotNxSetup } from './implementation/dot-nx/add-nx-scripts'; import { createNxJsonFile, + extractErrorName, initCloud, isMonorepo, printFinalMessage, + readErrorStderr, setNeverConnectToCloud, + toErrorString, updateGitIgnore, } from './implementation/utils'; import { ensurePackageHasProvenance } from '../../utils/provenance'; @@ -92,8 +95,69 @@ export async function initHandler( } } +async function recordInitError( + error: unknown, + baseMeta: Record +): Promise { + const errorMessage = toErrorString(error); + const errorCode = determineErrorCode(error); + const stderr = readErrorStderr(error).trim(); + const telemetryMessage = ( + stderr ? `${errorMessage} | stderr: ${stderr.slice(-250)}` : errorMessage + ).slice(0, 500); + const errorName = extractErrorName(error, stderr); + + await recordStat({ + command: 'init', + nxVersion, + useCloud: false, + meta: { + type: 'error', + errorCode, + errorName, + errorMessage: telemetryMessage, + ...baseMeta, + }, + }); + + if (baseMeta.aiAgent) { + const errorLogPath = writeErrorLog(error); + writeAiOutput(buildErrorResult(errorMessage, errorCode, errorLogPath)); + } else { + // Restore the cursor in case the user bailed during an interactive + // prompt. Skip for AI agents — it would corrupt NDJSON output. + process.stdout.write('\x1b[?25h'); + } + process.exit(1); +} + async function initHandlerImpl(options: InitArgs): Promise { process.env.NX_RUNNING_NX_INIT = 'true'; + const baseMeta = { + nodeVersion: process.versions.node, + os: process.platform, + packageManager: detectPackageManager(), + aiAgent: isAiAgent(), + isCI: isCI(), + }; + recordStat({ + command: 'init', + nxVersion, + useCloud: false, + meta: { type: 'start', ...baseMeta }, + }); + + try { + return await runInit(options, baseMeta); + } catch (error) { + await recordInitError(error, baseMeta); + } +} + +async function runInit( + options: InitArgs, + baseMeta: Record +): Promise { const version = process.env.NX_VERSION ?? (prerelease(nxVersion) ? nxVersion : 'latest'); if (process.env.NX_VERSION) { diff --git a/packages/nx/src/command-line/nx-cloud/connect/command-object.ts b/packages/nx/src/command-line/nx-cloud/connect/command-object.ts index ba2d85cfa5857..a9f6a1c6fe337 100644 --- a/packages/nx/src/command-line/nx-cloud/connect/command-object.ts +++ b/packages/nx/src/command-line/nx-cloud/connect/command-object.ts @@ -1,7 +1,6 @@ import { Argv, CommandModule } from 'yargs'; import { handleImport } from '../../../utils/handle-import'; import { linkToNxDevAndExamples } from '../../yargs-utils/documentation'; -import { nxVersion } from '../../../utils/versions'; import { withVerbose } from '../../yargs-utils/shared-options'; export const yargsConnectCommand: CommandModule = { @@ -15,13 +14,6 @@ export const yargsConnectCommand: CommandModule = { await ( await handleImport('./connect-to-nx-cloud.js', __dirname) ).connectToNxCloudCommand({ ...args, checkRemote }); - await ( - await handleImport('../../../utils/ab-testing.js', __dirname) - ).recordStat({ - command: 'connect', - nxVersion, - useCloud: true, - }); process.exit(0); }, }; diff --git a/packages/nx/src/command-line/nx-cloud/connect/connect-to-nx-cloud.ts b/packages/nx/src/command-line/nx-cloud/connect/connect-to-nx-cloud.ts index 13ed299280041..be429d061cc91 100644 --- a/packages/nx/src/command-line/nx-cloud/connect/connect-to-nx-cloud.ts +++ b/packages/nx/src/command-line/nx-cloud/connect/connect-to-nx-cloud.ts @@ -82,6 +82,66 @@ export async function connectWorkspaceToCloud( export async function connectToNxCloudCommand( options: { generateToken?: boolean; checkRemote?: boolean }, command?: string +): Promise { + // `connectToNxCloudWithPrompt` (called from `migrate`) records its own stat; skip here to avoid double-counting. + const selfRecord = !command; + const baseMeta = { + nodeVersion: process.versions.node, + os: process.platform, + packageManager: detectPackageManager(), + aiAgent: isAiAgent(), + isCI: isCI(), + }; + if (selfRecord) { + await recordStat({ + command: 'connect', + nxVersion, + useCloud: true, + meta: { type: 'start', ...baseMeta }, + }); + } + try { + const result = await runConnectToNxCloud(options, command); + if (selfRecord) { + await recordStat({ + command: 'connect', + nxVersion, + useCloud: result, + meta: { type: 'complete', ...baseMeta }, + }); + } + return result; + } catch (error) { + if (selfRecord) { + const message = + (error instanceof Error && error.message) || + String(error ?? 'Unknown error'); + const errorName = + typeof (error as any)?.code === 'string' + ? ((error as any).code as string) + : error instanceof Error + ? error.name + : typeof error; + await recordStat({ + command: 'connect', + nxVersion, + useCloud: false, + meta: { + type: 'error', + errorCode: 'UNKNOWN', + errorName, + errorMessage: message.slice(0, 500), + ...baseMeta, + }, + }); + } + throw error; + } +} + +async function runConnectToNxCloud( + options: { generateToken?: boolean; checkRemote?: boolean }, + command?: string ): Promise { const nxJson = readNxJson();