diff --git a/src/commands/new.ts b/src/commands/new.ts new file mode 100644 index 0000000..5406dda --- /dev/null +++ b/src/commands/new.ts @@ -0,0 +1,69 @@ +import { Command } from 'commander'; +import { InternalCliError, ValidationCliError } from '../errors/errors.js'; +import { orchestrateProjectGeneration } from '../generator/orchestrator.js'; +import type { + ProjectGeneratorServices, + ValidatedProjectInput, +} from '../generator/types.js'; +import { TerminalOutput } from '../output/terminal.js'; + +export interface NewCommandOptions { + readonly services?: ProjectGeneratorServices; + readonly output?: TerminalOutput; + readonly cwd?: () => string; +} + +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.'), + }); + }, + selectTemplate() { + throw new InternalCliError({ + cause: new Error('Template selection is not implemented yet.'), + }); + }, + generate() { + throw new InternalCliError({ + cause: new Error('Project generation is not implemented yet.'), + }); + }, + }; +} + +export function createNewCommand(options: NewCommandOptions = {}): Command { + const services = options.services ?? createScaffoldServices(); + const output = options.output ?? new TerminalOutput(); + const cwd = options.cwd ?? process.cwd; + + return new Command('new') + .description('Create a new StellarForge project.') + .argument('', 'Name of the project to create.') + .action((projectName: string) => { + const result = orchestrateProjectGeneration( + { + projectName, + cwd: cwd(), + }, + services, + ); + + output.success( + `Created ${result.projectName} from ${result.templateId} at ${result.destination}.`, + ); + }); +} diff --git a/src/commands/root.ts b/src/commands/root.ts index f021753..1396321 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { resolveCliVersion } from '../version.js'; import { createDoctorCommand } from './doctor.js'; +import { createNewCommand } from './new.js'; export function createRootCommand(version = resolveCliVersion()): Command { return new Command() @@ -14,5 +15,6 @@ export function createRootCommand(version = resolveCliVersion()): Command { .showHelpAfterError() .showSuggestionAfterError(true) .action(() => undefined) - .addCommand(createDoctorCommand()); + .addCommand(createDoctorCommand()) + .addCommand(createNewCommand()); } diff --git a/src/generator/orchestrator.ts b/src/generator/orchestrator.ts new file mode 100644 index 0000000..693f6c9 --- /dev/null +++ b/src/generator/orchestrator.ts @@ -0,0 +1,24 @@ +import type { + ProjectGenerationResult, + ProjectGeneratorInput, + ProjectGeneratorServices, +} from './types.js'; + +export function orchestrateProjectGeneration( + input: ProjectGeneratorInput, + services: ProjectGeneratorServices, +): ProjectGenerationResult { + const validated = services.validate(input); + const destination = services.planDestination(validated); + const template = services.selectTemplate(validated, destination); + const result = services.generate({ + input: validated, + destination, + template, + }); + + services.install?.(result); + services.initializeGit?.(result); + + return result; +} diff --git a/src/generator/types.ts b/src/generator/types.ts new file mode 100644 index 0000000..d2d0cc2 --- /dev/null +++ b/src/generator/types.ts @@ -0,0 +1,39 @@ +export interface ProjectGeneratorInput { + readonly projectName: string; + readonly cwd: string; +} + +export type ValidatedProjectInput = ProjectGeneratorInput; + +export interface DestinationPlan { + readonly projectName: string; + readonly destination: string; +} + +export interface TemplateSelection { + readonly templateId: string; +} + +export interface GenerationContext { + readonly input: ValidatedProjectInput; + readonly destination: DestinationPlan; + readonly template: TemplateSelection; +} + +export interface ProjectGenerationResult { + readonly projectName: string; + readonly destination: string; + readonly templateId: string; +} + +export interface ProjectGeneratorServices { + validate(input: ProjectGeneratorInput): ValidatedProjectInput; + planDestination(input: ValidatedProjectInput): DestinationPlan; + selectTemplate( + input: ValidatedProjectInput, + destination: DestinationPlan, + ): TemplateSelection; + generate(context: GenerationContext): ProjectGenerationResult; + install?(result: ProjectGenerationResult): void; + initializeGit?(result: ProjectGenerationResult): void; +} diff --git a/tests/generator-orchestration.test.ts b/tests/generator-orchestration.test.ts new file mode 100644 index 0000000..426a84c --- /dev/null +++ b/tests/generator-orchestration.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { orchestrateProjectGeneration } from '../src/generator/orchestrator.js'; +import type { ProjectGeneratorServices } from '../src/generator/types.js'; +import { ValidationCliError } from '../src/errors/errors.js'; + +function createServices(calls: string[]): ProjectGeneratorServices { + return { + validate(input) { + calls.push('validate'); + return input; + }, + planDestination(input) { + calls.push('planDestination'); + return { + projectName: input.projectName, + destination: `/tmp/${input.projectName}`, + }; + }, + selectTemplate() { + calls.push('selectTemplate'); + return { templateId: 'fixture-template' }; + }, + generate(context) { + calls.push('generate'); + return { + projectName: context.input.projectName, + destination: context.destination.destination, + templateId: context.template.templateId, + }; + }, + install() { + calls.push('install'); + }, + initializeGit() { + calls.push('initializeGit'); + }, + }; +} + +describe('project generation orchestration', () => { + it('executes generation stages in a deterministic order', () => { + const calls: string[] = []; + const result = orchestrateProjectGeneration( + { projectName: 'demo', cwd: '/workspace' }, + createServices(calls), + ); + + expect(calls).toEqual([ + 'validate', + 'planDestination', + 'selectTemplate', + 'generate', + 'install', + 'initializeGit', + ]); + expect(result).toEqual({ + projectName: 'demo', + destination: '/tmp/demo', + templateId: 'fixture-template', + }); + }); + + it('stops before any later stage when validation fails', () => { + const calls: string[] = []; + const services = createServices(calls); + services.validate = () => { + calls.push('validate'); + throw new ValidationCliError('Invalid project name.'); + }; + + expect(() => + orchestrateProjectGeneration( + { projectName: '../unsafe', cwd: '/workspace' }, + services, + ), + ).toThrow(ValidationCliError); + expect(calls).toEqual(['validate']); + }); + + it('does not require optional install or Git stages', () => { + const calls: string[] = []; + const services = createServices(calls); + delete services.install; + delete services.initializeGit; + + const result = orchestrateProjectGeneration( + { projectName: 'demo', cwd: '/workspace' }, + services, + ); + + expect(calls).toEqual([ + 'validate', + 'planDestination', + 'selectTemplate', + 'generate', + ]); + expect(result.projectName).toBe('demo'); + }); +}); diff --git a/tests/new-command.test.ts b/tests/new-command.test.ts new file mode 100644 index 0000000..6beb898 --- /dev/null +++ b/tests/new-command.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { createNewCommand } from '../src/commands/new.js'; +import type { ProjectGeneratorServices } from '../src/generator/types.js'; +import { createCapturedTerminalOutput } from './helpers/index.js'; + +function services(calls: string[]): ProjectGeneratorServices { + return { + validate(input) { + calls.push(`validate:${input.projectName}:${input.cwd}`); + return input; + }, + planDestination(input) { + calls.push('plan'); + return { + projectName: input.projectName, + destination: `/workspace/${input.projectName}`, + }; + }, + selectTemplate() { + calls.push('template'); + return { templateId: 'fixture-template' }; + }, + generate(context) { + calls.push('generate'); + return { + projectName: context.input.projectName, + destination: context.destination.destination, + templateId: context.template.templateId, + }; + }, + }; +} + +describe('new command', () => { + it('delegates project creation to the generator orchestration layer', () => { + const calls: string[] = []; + const captured = createCapturedTerminalOutput(); + const command = createNewCommand({ + services: services(calls), + output: captured.output, + cwd: () => '/workspace', + }); + + command.parse(['node', 'new', 'demo']); + + expect(calls).toEqual([ + 'validate:demo:/workspace', + 'plan', + 'template', + 'generate', + ]); + expect(captured.stdoutText()).toContain( + 'Created demo from fixture-template at /workspace/demo.', + ); + expect(captured.stderrText()).toBe(''); + }); + + it('requires a project name argument', () => { + const command = createNewCommand({ + services: services([]), + cwd: () => '/workspace', + }); + command.exitOverride(); + + expect(() => command.parse(['node', 'new'])).toThrow(); + }); +}); diff --git a/tests/root-command.test.ts b/tests/root-command.test.ts index 046a0fd..fb1bf8d 100644 --- a/tests/root-command.test.ts +++ b/tests/root-command.test.ts @@ -2,12 +2,13 @@ import { describe, expect, it } from 'vitest'; import { createRootCommand } from '../src/commands/root.js'; describe('StellarForge root command', () => { - it('defines the stable root identity and registers the doctor command', () => { + it('defines the stable root identity and registers current commands', () => { const program = createRootCommand('1.2.3'); expect(program.name()).toBe('stellarforge'); expect(program.commands.map((command) => command.name())).toEqual([ 'doctor', + 'new', ]); }); @@ -19,6 +20,7 @@ describe('StellarForge root command', () => { 'Open infrastructure for building production-ready Stellar applications.', ); expect(help).toContain('doctor'); + expect(help).toContain('new'); expect(help).toContain('-V, --version'); expect(help).toContain('-h, --help'); });