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
26 changes: 26 additions & 0 deletions packages/create-nx-workspace/bin/create-nx-workspace.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
applyEmptyPresetAlias,
validateWorkspaceName,
resolveSpecialFolderName,
determineFolder,
} from './create-nx-workspace';
import { CnwError } from '../src/utils/error-utils';
import { Preset } from '../src/utils/preset/preset';
import {
mkdtempSync,
mkdirSync,
Expand Down Expand Up @@ -325,3 +327,27 @@ describe('determineFolder - explicit "." confirmation', () => {
rmSync(tmpDir, { recursive: true });
});
});

describe('applyEmptyPresetAlias', () => {
it('maps --preset empty to the ts preset', () => {
const argv = { preset: 'empty' as const };
applyEmptyPresetAlias(argv);
expect(argv.preset).toBe('ts');
});

it('wins over --template so appending --preset=empty escapes the template download', () => {
const argv = { preset: 'empty' as const, template: 'nrwl/react-template' };
applyEmptyPresetAlias(argv);
expect(argv).toEqual({ preset: 'ts' });
});

it('leaves other presets and templates untouched', () => {
const preset = { preset: Preset.ReactMonorepo };
applyEmptyPresetAlias(preset);
expect(preset).toEqual({ preset: 'react-monorepo' });

const template = { template: 'empty' };
applyEmptyPresetAlias(template);
expect(template).toEqual({ template: 'empty' });
});
});
38 changes: 18 additions & 20 deletions packages/create-nx-workspace/bin/create-nx-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,25 +518,6 @@ async function normalizeArgsMiddleware(
try {
rawArgs = { ...argv };

// Map invalid/legacy presets to templates for all users
// These presets don't exist as npm packages and would fail if not mapped
const invalidPresetToTemplateMap: Record<string, string> = {
empty: 'nrwl/empty-template',
};

if (rawArgs.preset && !rawArgs.template) {
const mappedTemplate = invalidPresetToTemplateMap[rawArgs.preset];
if (mappedTemplate) {
output.log({
title: `Mapping preset '${rawArgs.preset}' to template '${mappedTemplate}'`,
});
argv.template = mappedTemplate;
rawArgs.template = mappedTemplate;
delete argv.preset;
delete rawArgs.preset;
}
}

// AI Agent Detection: When an AI agent is detected, switch to AI-optimized mode
const aiMode = isAiAgent();

Expand All @@ -545,7 +526,9 @@ async function normalizeArgsMiddleware(
argv.interactive = false;

// Map legacy presets to templates for AI agents
// Many AI models were trained on old preset syntax, so we convert them
// Many AI models were trained on old preset syntax, so we convert them.
// Never add `empty` here - it must stay npm-only as the escape hatch
// when github.com is unreachable (see applyEmptyPresetAlias).
const legacyPresetToTemplateMap: Record<string, string> = {
ts: 'nrwl/empty-template',
apps: 'nrwl/empty-template',
Expand Down Expand Up @@ -617,6 +600,8 @@ async function normalizeArgsMiddleware(
});
}

applyEmptyPresetAlias(argv);

argv.workspaces ??= true;
argv.useProjectJson ??= !argv.workspaces;

Expand Down Expand Up @@ -817,6 +802,19 @@ async function normalizeArgsMiddleware(
}
}

// Map `empty` to the `ts` preset, not the template - sandboxed agents often
// cannot reach github.com. Wins over --template so appending --preset=empty
// to a failed command escapes the download.
export function applyEmptyPresetAlias(argv: {
preset?: Preset | 'empty';
template?: string;
}): void {
if (argv.preset === 'empty') {
argv.preset = Preset.TS;
delete argv.template;
}
}

function invariant(
predicate: string | number | boolean,
errorCode: CnwErrorCode,
Expand Down
19 changes: 19 additions & 0 deletions packages/create-nx-workspace/src/create-workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ describe('createWorkspace - template flow', () => {
rmSync(tmpDir, { recursive: true, force: true });
}
});

it('rejects templates that escape the nrwl org via path traversal', async () => {
for (const template of [
'nrwl/../evil',
'nrwl/../../evil/repo',
'nrwl/..\\evil\\repo',
'nrwl/',
]) {
await expect(
createWorkspace(undefined, {
template,
name: 'proj',
packageManager: 'npm',
nxCloud: 'skip',
workingDir: tmpdir(),
} as any)
).rejects.toThrow(/Invalid template/);
}
});
});

describe('extractConnectUrl', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/create-nx-workspace/src/create-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export async function createWorkspace<T extends CreateWorkspaceOptions>(
// Resolve shorthand template names to full GitHub org/repo format
options.template = resolveTemplateShorthand(options.template);

if (!options.template.startsWith('nrwl/'))
// Strict slug match - a bare startsWith('nrwl/') check lets path
// traversal (`nrwl/../evil`) resolve to another org's repo.
if (!/^nrwl\/[\w.-]+$/.test(options.template))
throw new Error(
`Invalid template. Only templates from the 'nrwl' GitHub org are supported.`
);
Expand Down
22 changes: 22 additions & 0 deletions packages/create-nx-workspace/src/utils/ai/ai-output.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { buildErrorResult } from './ai-output';

describe('buildErrorResult hints', () => {
it('NETWORK_ERROR points at network/sandbox config and the --preset=empty escape hatch', () => {
const hints = buildErrorResult('boom', 'NETWORK_ERROR').hints.join('\n');
expect(hints).toMatch(/sandbox configuration/);
expect(hints).toMatch(/--preset=empty/);
});

it('TEMPLATE_CLONE_FAILED points at the template name and still offers the escape hatch', () => {
const hints = buildErrorResult('boom', 'TEMPLATE_CLONE_FAILED').hints.join(
'\n'
);
expect(hints).toMatch(/template name/);
expect(hints).toMatch(/--preset=empty/);
});

it('unknown codes fall through to generic hints', () => {
const hints = buildErrorResult('boom', 'UNKNOWN').hints.join('\n');
expect(hints).toMatch(/github\.com\/nrwl\/nx\/issues/);
});
});
13 changes: 9 additions & 4 deletions packages/create-nx-workspace/src/utils/ai/ai-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export function buildTemplateRequiredResult(
success: false,
title: 'Template Selection Required',
message:
'Ask the user which workspace type they want, then run again with --template. If the directory exists, append a number (e.g., my-nx-repo-2).',
'Ask the user which workspace type they want, then run again with --template. If the directory exists, append a number (e.g., my-nx-repo-2). If this environment cannot reach github.com, run with --preset=empty instead of --template to create a minimal workspace without downloading a template.',
suggestedName: name,
templates: [
{
Expand Down Expand Up @@ -349,9 +349,14 @@ function getErrorHints(errorCode: CnwErrorCode | 'UNKNOWN'): string[] {
];
case 'NETWORK_ERROR':
return [
'Check your internet connection',
'Try again in a few moments',
'Check if npm/yarn registry is accessible',
'Templates download from github.com, which may be blocked or unreachable in this environment',
'Check your network and sandbox configuration (allow https://github.com) and try again',
'Or re-run with --preset=empty (instead of --template) to create a minimal workspace without downloading a template, then build on top of it',
];
case 'TEMPLATE_CLONE_FAILED':
return [
'Check the template name (e.g. nrwl/empty-template)',
'If github.com is restricted in this environment, re-run with --preset=empty',
];
case 'PACKAGE_INSTALL_ERROR':
return [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { mkdtempSync, rmSync, realpathSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { downloadTemplate } from './download-template';

describe('downloadTemplate', () => {
let tmpDir: string;
const originalFetch = globalThis.fetch;

beforeEach(() => {
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'cnw-dl-')));
});

afterEach(() => {
globalThis.fetch = originalFetch;
rmSync(tmpDir, { recursive: true, force: true });
});

it('throws NETWORK_ERROR with a --preset=empty hint when github.com is unreachable', async () => {
globalThis.fetch = jest.fn(() =>
Promise.reject(new TypeError('fetch failed'))
) as any;

await expect(
downloadTemplate('nrwl/empty-template', join(tmpDir, 'proj'))
).rejects.toMatchObject({
code: 'NETWORK_ERROR',
message: expect.stringMatching(
/github\.com may be blocked or unreachable/
),
});
await expect(
downloadTemplate('nrwl/empty-template', join(tmpDir, 'proj'))
).rejects.toMatchObject({
message: expect.stringMatching(/--preset=empty/),
});
});

it.each([403, 407, 429, 503])(
'throws NETWORK_ERROR with the hint for HTTP %i (egress blocked by a proxy or transient failure)',
async (status) => {
globalThis.fetch = jest.fn(() =>
Promise.resolve({ ok: false, status, body: null })
) as any;

await expect(
downloadTemplate('nrwl/empty-template', join(tmpDir, 'proj'))
).rejects.toMatchObject({
code: 'NETWORK_ERROR',
message: expect.stringMatching(/--preset=empty/),
});
}
);

it('throws TEMPLATE_CLONE_FAILED without the hint for HTTP 404 (missing repo or branch)', async () => {
globalThis.fetch = jest.fn(() =>
Promise.resolve({ ok: false, status: 404, body: null })
) as any;

await expect(
downloadTemplate('nrwl/does-not-exist-template', join(tmpDir, 'proj'))
).rejects.toMatchObject({
code: 'TEMPLATE_CLONE_FAILED',
message: expect.not.stringMatching(/--preset=empty/),
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export async function downloadTemplate(
): Promise<void> {
let body: ReadableStream<Uint8Array> | undefined;
const attempts: string[] = [];
// A thrown fetch is a connectivity problem; a non-ok response is a missing
// branch/repo. Distinguish them so the error code (and its hints) are right.
// 404 means the repo/branch does not exist; any other failure (thrown fetch,
// 403 from a sandbox proxy, 407/429/5xx) means blocked or failed egress.
// The distinction picks the error code and its hints.
let networkError = false;
for (const branch of DEFAULT_BRANCHES) {
const url = `https://github.com/${template}/archive/refs/heads/${branch}.tar.gz`;
Expand All @@ -38,6 +39,7 @@ export async function downloadTemplate(
body = res.body;
break;
}
if (res.status !== 404) networkError = true;
attempts.push(`${branch}: HTTP ${res.status}`);
} catch (e) {
networkError = true;
Expand All @@ -46,9 +48,16 @@ export async function downloadTemplate(
}

if (!body) {
if (networkError) {
throw new CnwError(
'NETWORK_ERROR',
`Failed to download template '${template}' (${attempts.join('; ')}).\n` +
`github.com may be blocked or unreachable in this environment. Check your network and sandbox configuration and try again, or run with --preset=empty (instead of --template) to create a minimal workspace without downloading a template.`
);
}
throw new CnwError(
networkError ? 'NETWORK_ERROR' : 'TEMPLATE_CLONE_FAILED',
`Failed to download template '${template}' (${attempts.join('; ')})`
'TEMPLATE_CLONE_FAILED',
`Failed to download template '${template}' (${attempts.join('; ')}). Check that the template name is correct.`
);
}

Expand Down
Loading