diff --git a/src/diagnostics/docker.ts b/src/diagnostics/docker.ts index 6fb5d2b..cd720b2 100644 --- a/src/diagnostics/docker.ts +++ b/src/diagnostics/docker.ts @@ -1,4 +1,8 @@ -import { runCommand, type RunCommand } from './process.js'; +import { + classifyCommandResult, + runCommand, + type RunCommand, +} from './process.js'; import type { DiagnosticCheck, DiagnosticResult, @@ -31,7 +35,7 @@ export function createDockerDiagnostic( run(): DiagnosticResult { const client = execute('docker', ['--version']); - if (client.error || client.status !== 0) { + if (classifyCommandResult(client) !== 'ok') { return { id: 'docker', label: 'Docker', diff --git a/src/diagnostics/git.ts b/src/diagnostics/git.ts index 0669300..fd21109 100644 --- a/src/diagnostics/git.ts +++ b/src/diagnostics/git.ts @@ -1,4 +1,8 @@ -import { runCommand, type RunCommand } from './process.js'; +import { + classifyCommandResult, + runCommand, + type RunCommand, +} from './process.js'; import type { DiagnosticCheck, DiagnosticResult } from './types.js'; function parseGitVersion(output: string): string | null { @@ -15,7 +19,7 @@ export function createGitDiagnostic( run(): DiagnosticResult { const result = execute('git', ['--version']); - if (result.error || result.status !== 0) { + if (classifyCommandResult(result) !== 'ok') { return { id: 'git', label: 'Git', diff --git a/src/diagnostics/process.ts b/src/diagnostics/process.ts index 6f6494d..1ed4e0e 100644 --- a/src/diagnostics/process.ts +++ b/src/diagnostics/process.ts @@ -28,3 +28,38 @@ export const runCommand: RunCommand = (executable, args) => { ...(result.error === undefined ? {} : { error: result.error }), }; }; + +export type CommandOutcome = 'ok' | 'missing-executable' | 'failed'; + +/** + * Recognizes the platform-specific ways Node reports that an executable could + * not be found, without inspecting or reflecting raw process output. + */ +export function isExecutableNotFound(error: unknown): boolean { + if (error === null || typeof error !== 'object') { + return false; + } + + if ((error as { code?: unknown }).code === 'ENOENT') { + return true; + } + + const message = (error as { message?: unknown }).message; + return typeof message === 'string' && /\bENOENT\b/.test(message); +} + +/** + * Classifies a command result into a small, shared set of outcomes so every + * diagnostic treats a missing executable consistently. + */ +export function classifyCommandResult(result: CommandResult): CommandOutcome { + if (result.error !== undefined && isExecutableNotFound(result.error)) { + return 'missing-executable'; + } + + if (result.error !== undefined || result.status !== 0) { + return 'failed'; + } + + return 'ok'; +} diff --git a/src/diagnostics/runtime.ts b/src/diagnostics/runtime.ts index 428ebf6..f683fc6 100644 --- a/src/diagnostics/runtime.ts +++ b/src/diagnostics/runtime.ts @@ -1,6 +1,10 @@ import { createNpmInvocation } from '../process/npm.js'; import type { DiagnosticCheck, DiagnosticResult } from './types.js'; -import { runCommand, type RunCommand } from './process.js'; +import { + classifyCommandResult, + runCommand, + type RunCommand, +} from './process.js'; import { compareVersions, isAtLeast, @@ -71,7 +75,7 @@ export function createNpmDiagnostic( const npm = createNpmInvocation(['--version']); const result = execute(npm.command, npm.args); - if (result.error || result.status !== 0) { + if (classifyCommandResult(result) !== 'ok') { return failure( 'npm', 'npm', diff --git a/src/diagnostics/rust.ts b/src/diagnostics/rust.ts index b271bcc..d0460e3 100644 --- a/src/diagnostics/rust.ts +++ b/src/diagnostics/rust.ts @@ -1,4 +1,8 @@ -import { runCommand, type RunCommand } from './process.js'; +import { + classifyCommandResult, + runCommand, + type RunCommand, +} from './process.js'; import type { DiagnosticCheck, DiagnosticResult } from './types.js'; import { isAtLeast, parseNumericVersion } from './version.js'; @@ -23,7 +27,7 @@ export function createRustDiagnostic( run(): DiagnosticResult { const result = execute('rustc', ['--version']); - if (result.error || result.status !== 0) { + if (classifyCommandResult(result) !== 'ok') { return { id: 'rust', label: 'Rust', @@ -78,7 +82,7 @@ export function createCargoDiagnostic( run(): DiagnosticResult { const result = execute('cargo', ['--version']); - if (result.error || result.status !== 0) { + if (classifyCommandResult(result) !== 'ok') { return { id: 'cargo', label: 'Cargo', diff --git a/src/diagnostics/stellar.ts b/src/diagnostics/stellar.ts index 3d8e559..216991e 100644 --- a/src/diagnostics/stellar.ts +++ b/src/diagnostics/stellar.ts @@ -1,4 +1,8 @@ -import { runCommand, type RunCommand } from './process.js'; +import { + classifyCommandResult, + runCommand, + type RunCommand, +} from './process.js'; import type { DiagnosticCheck, DiagnosticResult } from './types.js'; import { isAtLeast, parseNumericVersion } from './version.js'; @@ -24,7 +28,7 @@ export function createStellarDiagnostic( // version command. It does not inspect identities, keys, or config. const result = execute('stellar', ['version', '--only-version']); - if (result.error || result.status !== 0) { + if (classifyCommandResult(result) !== 'ok') { return { id: 'stellar', label: 'Stellar CLI', diff --git a/templates/full-stack/README.md b/templates/full-stack/README.md index 22398f4..0780cfe 100644 --- a/templates/full-stack/README.md +++ b/templates/full-stack/README.md @@ -25,13 +25,23 @@ npm test These checks are dependency-free and do not require network access. +## Scripts + +| Script | Purpose | +|---|---| +| `npm run check` | Syntax-checks the frontend and backend entry points. | +| `npm test` | Runs the frontend and backend test files with the Node.js test runner. | +| `npm run start:backend` | Starts the backend server. | + +The starter has no build step and no runtime dependencies; `frontend/src/app.js` is plain ES module code that a host page or bundler can import. + ## Run the backend ```bash npm run start:backend ``` -The backend exposes `GET /health` on `127.0.0.1:3000` by default. +The backend exposes `GET /health` on `127.0.0.1:3000` by default. Set `BACKEND_PORT` to override the port. This starter targets Stellar Testnet only; it does not configure or claim Mainnet or hosted deployment support. ## Environment diff --git a/tests/dev-project-diagnostics.test.ts b/tests/dev-project-diagnostics.test.ts new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/tests/dev-project-diagnostics.test.ts @@ -0,0 +1,85 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { resolveDevProcessPlan } from '../src/dev/project.js'; +import { withTempDirectory } from './helpers/index.js'; + +function writePackage(root: string, value: unknown): void { + writeFileSync( + join(root, 'package.json'), + typeof value === 'string' ? value : JSON.stringify(value), + ); +} + +describe('stellarforge dev project diagnostics', () => { + it('reports an empty directory as unsupported before spawning', () => + withTempDirectory((root) => { + expect(() => resolveDevProcessPlan(root)).toThrow(ValidationCliError); + expect(() => resolveDevProcessPlan(root)).toThrow(/package\.json/i); + })); + + it('reports an incomplete scripts object as unsupported', () => + withTempDirectory((root) => { + writePackage(root, { name: 'demo' }); + expect(() => resolveDevProcessPlan(root)).toThrow(/scripts object/); + })); + + it('reports a non-object scripts value as unsupported', () => + withTempDirectory((root) => { + writePackage(root, { name: 'demo', scripts: ['start'] }); + expect(() => resolveDevProcessPlan(root)).toThrow(/scripts object/); + })); + + it('reports unsupported scripts with actionable guidance', () => + withTempDirectory((root) => { + writePackage(root, { + name: 'demo', + scripts: { build: 'node build.js', lint: 'eslint .' }, + }); + expect(() => resolveDevProcessPlan(root)).toThrow( + /No supported development script/, + ); + })); + + it('reports malformed package metadata clearly', () => + withTempDirectory((root) => { + writePackage(root, '{ not-json'); + expect(() => resolveDevProcessPlan(root)).toThrow(/not valid JSON/); + })); + + it('fails ambiguous layouts before any child process can start', () => + withTempDirectory((root) => { + mkdirSync(join(root, 'nested')); + let spawned = false; + const spawn = () => { + spawned = true; + }; + + try { + resolveDevProcessPlan(root); + spawn(); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + } + + expect(spawned).toBe(false); + })); + + it.each(['dev', 'start'])( + 'supports generated Node projects using %s', + (script) => + withTempDirectory((root) => { + writePackage(root, { + name: 'demo', + scripts: { [script]: 'node x.js' }, + }); + + const plan = resolveDevProcessPlan(root); + + expect(plan).toHaveLength(1); + expect(plan[0]?.label).toBe('demo'); + expect(plan[0]?.args).toContain(script); + }), + ); +}); diff --git a/tests/executable-classification.test.ts b/tests/executable-classification.test.ts new file mode 100644 index 0000000..acd3882 --- /dev/null +++ b/tests/executable-classification.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyCommandResult, + isExecutableNotFound, + type CommandResult, +} from '../src/diagnostics/process.js'; +import { + createCargoDiagnostic, + createRustDiagnostic, +} from '../src/diagnostics/rust.js'; +import { createGitDiagnostic } from '../src/diagnostics/git.js'; +import { createStellarDiagnostic } from '../src/diagnostics/stellar.js'; +import { createNpmDiagnostic } from '../src/diagnostics/runtime.js'; +import { createDockerDiagnostic } from '../src/diagnostics/docker.js'; +import type { RunCommand } from '../src/diagnostics/process.js'; + +function result( + status: number | null, + stdout = '', + stderr = '', + error?: Error, +): CommandResult { + return { + status, + stdout, + stderr, + ...(error === undefined ? {} : { error }), + }; +} + +function enoent(message = 'spawn tool ENOENT'): Error { + const error = new Error(message) as NodeJS.ErrnoException; + error.code = 'ENOENT'; + return error; +} + +describe('executable-not-found classification', () => { + it('recognizes an ENOENT error code', () => { + expect(isExecutableNotFound(enoent())).toBe(true); + }); + + it('recognizes only an ENOENT substring in the error message', () => { + expect(isExecutableNotFound(new Error('spawn git ENOENT'))).toBe(true); + expect(isExecutableNotFound(new Error('spawn git EACCES'))).toBe(false); + expect(isExecutableNotFound(new Error('enoent-looking lowercase'))).toBe( + false, + ); + }); + + it('does not classify non-error or nullish values', () => { + expect(isExecutableNotFound(undefined)).toBe(false); + expect(isExecutableNotFound(null)).toBe(false); + expect(isExecutableNotFound('ENOENT')).toBe(false); + }); +}); + +describe('command outcome classification', () => { + it('returns ok for a successful command', () => { + expect(classifyCommandResult(result(0, 'ok'))).toBe('ok'); + }); + + it('distinguishes a missing executable from a generic failure', () => { + expect(classifyCommandResult(result(null, '', '', enoent()))).toBe( + 'missing-executable', + ); + expect( + classifyCommandResult(result(null, '', '', new Error('timed out'))), + ).toBe('failed'); + }); + + it('treats a non-zero exit as failed', () => { + expect(classifyCommandResult(result(1, '', 'boom'))).toBe('failed'); + }); +}); + +describe('diagnostics classify missing executables consistently', () => { + const missing: RunCommand = () => result(null, '', '', enoent()); + + it('fails Git, Rust, Cargo, Stellar CLI, and npm uniformly', () => { + const checks = [ + createGitDiagnostic(missing).run(), + createRustDiagnostic(missing).run(), + createCargoDiagnostic(missing).run(), + createStellarDiagnostic(missing).run(), + createNpmDiagnostic(missing).run(), + ]; + + for (const check of checks) { + expect(check.status).toBe('fail'); + expect(JSON.stringify(check)).not.toContain('ENOENT'); + } + }); + + it('warns for an optional missing Docker CLI without leaking output', () => { + const check = createDockerDiagnostic({ execute: missing }).run(); + + expect(check.status).toBe('warn'); + expect(JSON.stringify(check)).not.toContain('ENOENT'); + }); + + it('never invokes a shell fallback for missing executables', () => { + const calls: Array<{ executable: string; args: readonly string[] }> = []; + const execute: RunCommand = (executable, args) => { + calls.push({ executable, args }); + return result(null, '', '', enoent()); + }; + + createGitDiagnostic(execute).run(); + + expect(calls).toEqual([{ executable: 'git', args: ['--version'] }]); + }); +}); diff --git a/tests/generator-destination-containment.test.ts b/tests/generator-destination-containment.test.ts new file mode 100644 index 0000000..7caeec6 --- /dev/null +++ b/tests/generator-destination-containment.test.ts @@ -0,0 +1,140 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, resolve, sep } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { + planProjectDestination, + validateProjectInput, + type DestinationFileSystem, +} from '../src/generator/validation.js'; +import { withTempDirectory } from './helpers/index.js'; + +const platformSep = process.platform === 'win32' ? '\\' : '/'; + +const emptyFileSystem: DestinationFileSystem = { + exists: () => false, + isDirectory: () => false, + entries: () => [], +}; + +function validated(cwd: string, projectName: string) { + return validateProjectInput({ cwd, projectName }); +} + +describe('generator destination containment', () => { + it('keeps a normalized destination inside the selected root', () => { + const input = validated('/workspace/./nested/..', 'demo'); + const plan = planProjectDestination(input, emptyFileSystem); + + expect(plan.destination).toBe(resolve('/workspace', 'demo')); + expect(plan.destination.startsWith(resolve('/workspace'))).toBe(true); + }); + + it('resolves relative cwd segments before planning', () => { + const input = validated('./workspace/../workspace', 'app'); + const plan = planProjectDestination(input, emptyFileSystem); + + expect(plan.destination).toBe(resolve('workspace', 'app')); + expect(plan.destination).toBe(resolve(input.cwd, 'app')); + }); + + it('rejects parent-directory traversal in the project name', () => { + expect(() => validated('/workspace', '..')).toThrow(ValidationCliError); + expect(() => validated('/workspace', '../evil')).toThrow( + ValidationCliError, + ); + expect(() => validated('/workspace', `..${platformSep}evil`)).toThrow( + ValidationCliError, + ); + }); + + it.each(['/absolute', 'nested/child', `nested${sep}child`, 'a\\b', 'a/b'])( + 'rejects path-bearing project name %j', + (projectName) => { + expect(() => validated('/workspace', projectName)).toThrow( + ValidationCliError, + ); + }, + ); + + it('fails containment when a crafted destination escapes the root', () => { + const input = validateProjectInput({ + projectName: 'demo', + cwd: '/workspace', + }); + + expect(() => + planProjectDestination({ ...input, projectName: '..' }, emptyFileSystem), + ).toThrow(ValidationCliError); + }); + + it('revalidates an existing empty destination as writable', () => + withTempDirectory((root) => { + mkdirSync(join(root, 'demo')); + + const fs: DestinationFileSystem = { + exists: () => true, + isDirectory: () => true, + entries: () => [], + }; + + const plan = planProjectDestination(validated(root, 'demo'), fs); + + expect(plan.destination).toBe(join(resolve(root), 'demo')); + })); + + it('rejects an existing non-empty destination without touching it', () => + withTempDirectory((root) => { + mkdirSync(join(root, 'demo')); + writeFileSync(join(root, 'demo', 'existing.txt'), 'keep'); + + const fs: DestinationFileSystem = { + exists: () => true, + isDirectory: () => true, + entries: () => ['existing.txt'], + }; + + expect(() => planProjectDestination(validated(root, 'demo'), fs)).toThrow( + 'not empty', + ); + })); + + it('rejects a destination that already exists as a file', () => + withTempDirectory((root) => { + writeFileSync(join(root, 'demo'), 'file'); + + const fs: DestinationFileSystem = { + exists: () => true, + isDirectory: () => false, + entries: () => [], + }; + + expect(() => planProjectDestination(validated(root, 'demo'), fs)).toThrow( + 'not a directory', + ); + })); + + it('resolves an existing ancestor without escaping the root', () => + withTempDirectory((root) => { + mkdirSync(join(root, 'existing')); + const input = validated(join(root, 'existing', '..'), 'demo'); + const plan = planProjectDestination(input, emptyFileSystem); + + expect(input.cwd).toBe(resolve(root)); + expect(plan.destination).toBe(join(resolve(root), 'demo')); + })); + + it('does not accept a destination equal to the selected root', () => { + const input = validateProjectInput({ + projectName: 'demo', + cwd: '/workspace', + }); + + expect(() => + planProjectDestination( + { ...input, projectName: '.' }, + emptyFileSystem, + ), + ).toThrow(ValidationCliError); + }); +});