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
69 changes: 69 additions & 0 deletions src/commands/new.ts
Original file line number Diff line number Diff line change
@@ -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('<project-name>', '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}.`,
);
});
}
4 changes: 3 additions & 1 deletion src/commands/root.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -14,5 +15,6 @@ export function createRootCommand(version = resolveCliVersion()): Command {
.showHelpAfterError()
.showSuggestionAfterError(true)
.action(() => undefined)
.addCommand(createDoctorCommand());
.addCommand(createDoctorCommand())
.addCommand(createNewCommand());
}
24 changes: 24 additions & 0 deletions src/generator/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions src/generator/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
99 changes: 99 additions & 0 deletions tests/generator-orchestration.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
67 changes: 67 additions & 0 deletions tests/new-command.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 3 additions & 1 deletion tests/root-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});

Expand All @@ -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');
});
Expand Down