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
83 changes: 7 additions & 76 deletions packages/nx/src/command-line/init/command-object.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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);
},
};

Expand Down
8 changes: 6 additions & 2 deletions packages/nx/src/command-line/init/configure-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down
87 changes: 86 additions & 1 deletion packages/nx/src/command-line/init/implementation/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
});
65 changes: 60 additions & 5 deletions packages/nx/src/command-line/init/implementation/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
64 changes: 64 additions & 0 deletions packages/nx/src/command-line/init/init-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -92,8 +95,69 @@ export async function initHandler(
}
}

async function recordInitError(
error: unknown,
baseMeta: Record<string, string | boolean>
): Promise<void> {
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<void> {
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<string, string | boolean>
): Promise<void> {
const version =
process.env.NX_VERSION ?? (prerelease(nxVersion) ? nxVersion : 'latest');
if (process.env.NX_VERSION) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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);
},
};
Expand Down
Loading
Loading