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
8 changes: 6 additions & 2 deletions src/diagnostics/docker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { runCommand, type RunCommand } from './process.js';
import {
classifyCommandResult,
runCommand,
type RunCommand,
} from './process.js';
import type {
DiagnosticCheck,
DiagnosticResult,
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 6 additions & 2 deletions src/diagnostics/git.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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',
Expand Down
35 changes: 35 additions & 0 deletions src/diagnostics/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
8 changes: 6 additions & 2 deletions src/diagnostics/runtime.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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',
Expand Down
10 changes: 7 additions & 3 deletions src/diagnostics/rust.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 6 additions & 2 deletions src/diagnostics/stellar.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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',
Expand Down
12 changes: 11 additions & 1 deletion templates/full-stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
85 changes: 85 additions & 0 deletions tests/dev-project-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
);
});
112 changes: 112 additions & 0 deletions tests/executable-classification.test.ts
Original file line number Diff line number Diff line change
@@ -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'] }]);
});
});
Loading