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
30 changes: 8 additions & 22 deletions src/commands/new.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Command } from 'commander';
import { InternalCliError, ValidationCliError } from '../errors/errors.js';
import { InternalCliError } from '../errors/errors.js';
import { orchestrateProjectGeneration } from '../generator/orchestrator.js';
import type {
ProjectGeneratorServices,
ValidatedProjectInput,
} from '../generator/types.js';
import type { ProjectGeneratorServices } from '../generator/types.js';
import {
planProjectDestination,
validateProjectInput,
} from '../generator/validation.js';
import { TerminalOutput } from '../output/terminal.js';

export interface NewCommandOptions {
Expand All @@ -15,23 +16,8 @@ export interface NewCommandOptions {

function createScaffoldServices(): ProjectGeneratorServices {
return {
validate(input): ValidatedProjectInput {
const projectName = input.projectName.trim();

if (projectName.length === 0) {
throw new ValidationCliError('Project name must not be empty.');
}

return {
projectName,
cwd: input.cwd,
};
},
planDestination() {
throw new InternalCliError({
cause: new Error('Destination planning is not implemented yet.'),
});
},
validate: validateProjectInput,
planDestination: planProjectDestination,
selectTemplate() {
throw new InternalCliError({
cause: new Error('Template selection is not implemented yet.'),
Expand Down
127 changes: 127 additions & 0 deletions src/generator/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { existsSync, readdirSync, statSync } from 'node:fs';
import { isAbsolute, relative, resolve } from 'node:path';
import { ValidationCliError } from '../errors/errors.js';
import type {
DestinationPlan,
ProjectGeneratorInput,
ValidatedProjectInput,
} from './types.js';

const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const SAFE_PROJECT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;

export interface DestinationFileSystem {
exists(path: string): boolean;
isDirectory(path: string): boolean;
entries(path: string): readonly string[];
}

const nodeFileSystem: DestinationFileSystem = {
exists: existsSync,
isDirectory(path) {
return statSync(path).isDirectory();
},
entries: readdirSync,
};

function validateProjectName(projectName: string): string {
const normalized = projectName.trim();

if (normalized.length === 0) {
throw new ValidationCliError('Project name must not be empty.');
}

if (normalized === '.' || normalized === '..') {
throw new ValidationCliError(
'Project name must identify a new child directory, not `.` or `..`.',
);
}

if (
isAbsolute(normalized) ||
normalized.includes('/') ||
normalized.includes('\\')
) {
throw new ValidationCliError(
'Project name must be a single directory name and must not contain a path.',
);
}

if (!SAFE_PROJECT_NAME.test(normalized)) {
throw new ValidationCliError(
'Project name may contain only letters, numbers, dots, underscores, and hyphens, and must start with a letter or number.',
);
}

if (normalized.endsWith('.') || normalized.endsWith(' ')) {
throw new ValidationCliError(
'Project name must not end with a dot or space.',
);
}

if (WINDOWS_RESERVED_NAME.test(normalized)) {
throw new ValidationCliError(
'Project name conflicts with a reserved Windows device name.',
);
}

return normalized;
}

export function validateProjectInput(
input: ProjectGeneratorInput,
): ValidatedProjectInput {
const projectName = validateProjectName(input.projectName);
const cwd = resolve(input.cwd);

return {
projectName,
cwd,
};
}

function assertDestinationWithinRoot(root: string, destination: string): void {
const relativeDestination = relative(root, destination);

if (
relativeDestination.length === 0 ||
relativeDestination === '..' ||
relativeDestination.startsWith(
`..${process.platform === 'win32' ? '\\' : '/'}`,
) ||
isAbsolute(relativeDestination)
) {
throw new ValidationCliError(
'Project destination must remain inside the current working directory.',
);
}
}

export function planProjectDestination(
input: ValidatedProjectInput,
fileSystem: DestinationFileSystem = nodeFileSystem,
): DestinationPlan {
const root = resolve(input.cwd);
const destination = resolve(root, input.projectName);

assertDestinationWithinRoot(root, destination);

if (fileSystem.exists(destination)) {
if (!fileSystem.isDirectory(destination)) {
throw new ValidationCliError(
`Destination already exists and is not a directory: ${destination}`,
);
}

if (fileSystem.entries(destination).length > 0) {
throw new ValidationCliError(
`Destination directory is not empty: ${destination}. StellarForge will not overwrite existing files.`,
);
}
}

return {
projectName: input.projectName,
destination,
};
}
102 changes: 102 additions & 0 deletions tests/project-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { ValidationCliError } from '../src/errors/errors.js';
import {
planProjectDestination,
validateProjectInput,
} from '../src/generator/validation.js';
import { withTempDirectory } from './helpers/index.js';

describe('project input validation', () => {
it.each([
'../escape',
'..\\escape',
'/tmp/escape',
'C:\\temp\\escape',
'C:/temp/escape',
'.',
'..',
'.hidden',
'nested/project',
'nested\\project',
])('rejects path-like or traversal project name %j', (projectName) => {
expect(() =>
validateProjectInput({ projectName, cwd: '/workspace' }),
).toThrow(ValidationCliError);
});

it.each(['CON', 'prn', 'AUX.txt', 'COM1', 'lpt9.log'])(
'rejects Windows reserved device name %j',
(projectName) => {
expect(() =>
validateProjectInput({ projectName, cwd: '/workspace' }),
).toThrow('reserved Windows device name');
},
);

it.each(['demo', 'demo-app', 'demo_app', 'demo.app', 'Demo123'])(
'accepts safe single-segment project name %j',
(projectName) => {
const validated = validateProjectInput({
projectName,
cwd: './workspace/..',
});

expect(validated.projectName).toBe(projectName);
expect(validated.cwd).toBe(resolve('./workspace/..'));
},
);
});

describe('project destination planning', () => {
it('plans a direct child without creating it', () =>
withTempDirectory((path) => {
const validated = validateProjectInput({
projectName: 'demo',
cwd: path,
});
const plan = planProjectDestination(validated);

expect(plan).toEqual({
projectName: 'demo',
destination: join(path, 'demo'),
});
}));

it('allows an existing empty destination directory', () =>
withTempDirectory((path) => {
const destination = join(path, 'demo');
mkdirSync(destination);

const plan = planProjectDestination(
validateProjectInput({ projectName: 'demo', cwd: path }),
);

expect(plan.destination).toBe(destination);
}));

it('refuses an existing non-empty destination directory', () =>
withTempDirectory((path) => {
const destination = join(path, 'demo');
mkdirSync(destination);
writeFileSync(join(destination, 'existing.txt'), 'do not overwrite');

expect(() =>
planProjectDestination(
validateProjectInput({ projectName: 'demo', cwd: path }),
),
).toThrow('will not overwrite existing files');
}));

it('refuses a destination occupied by a file', () =>
withTempDirectory((path) => {
writeFileSync(join(path, 'demo'), 'existing file');

expect(() =>
planProjectDestination(
validateProjectInput({ projectName: 'demo', cwd: path }),
),
).toThrow('is not a directory');
}));
});