diff --git a/src/commands/new.ts b/src/commands/new.ts index 5406dda..7181a93 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -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 { @@ -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.'), diff --git a/src/generator/validation.ts b/src/generator/validation.ts new file mode 100644 index 0000000..83b1790 --- /dev/null +++ b/src/generator/validation.ts @@ -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, + }; +} diff --git a/tests/project-validation.test.ts b/tests/project-validation.test.ts new file mode 100644 index 0000000..02c05e8 --- /dev/null +++ b/tests/project-validation.test.ts @@ -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'); + })); +});