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
28 changes: 28 additions & 0 deletions src/commands/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Command } from 'commander';
import {
superviseDevProcesses,
type DevOrchestratorOptions,
} from '../dev/orchestrator.js';
import { resolveDevProcessPlan, type DevProcessSpec } from '../dev/project.js';
import { TerminalOutput } from '../output/terminal.js';

export interface DevCommandOptions extends DevOrchestratorOptions {
readonly cwd?: () => string;
readonly resolvePlan?: (cwd: string) => readonly DevProcessSpec[];
}

export function createDevCommand(options: DevCommandOptions = {}): Command {
const cwd = options.cwd ?? process.cwd;
const output = options.output ?? new TerminalOutput();
const resolvePlan = options.resolvePlan ?? resolveDevProcessPlan;

return new Command('dev')
.description('Start development processes for a StellarForge project.')
.action(async () => {
const plan = resolvePlan(cwd());
output.info(
`Starting ${plan.length} development process${plan.length === 1 ? '' : 'es'}.`,
);
await superviseDevProcesses(plan, { ...options, output });
});
}
4 changes: 3 additions & 1 deletion src/commands/root.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command } from 'commander';
import { resolveCliVersion } from '../version.js';
import { createDevCommand } from './dev.js';
import { createDoctorCommand } from './doctor.js';
import { createNewCommand } from './new.js';

Expand All @@ -16,5 +17,6 @@ export function createRootCommand(version = resolveCliVersion()): Command {
.showSuggestionAfterError(true)
.action(() => undefined)
.addCommand(createDoctorCommand())
.addCommand(createNewCommand());
.addCommand(createNewCommand())
.addCommand(createDevCommand());
}
41 changes: 41 additions & 0 deletions src/dev/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const SAFE_ENVIRONMENT_KEYS = [
'PATH',
'Path',
'PATHEXT',
'SystemRoot',
'ComSpec',
'HOME',
'USERPROFILE',
'TMPDIR',
'TMP',
'TEMP',
'TERM',
'COLORTERM',
'NO_COLOR',
'FORCE_COLOR',
'CI',
'NODE_ENV',
'PORT',
'API_HOST',
'API_PORT',
'BACKEND_PORT',
'STELLAR_NETWORK',
'STELLAR_HORIZON_URL',
'STELLAR_RPC_URL',
] as const;

export function buildSafeChildEnvironment(
source: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = {};

for (const key of SAFE_ENVIRONMENT_KEYS) {
const value = source[key];

if (value !== undefined) {
environment[key] = value;
}
}

return environment;
}
171 changes: 171 additions & 0 deletions src/dev/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { spawn } from 'node:child_process';
import { SubprocessCliError, ValidationCliError } from '../errors/errors.js';
import { TerminalOutput } from '../output/terminal.js';
import { buildSafeChildEnvironment } from './environment.js';
import type { DevProcessSpec } from './project.js';

interface ManagedStream {
on(event: 'data', listener: (chunk: Buffer | string) => void): unknown;
}

export interface ManagedChildProcess {
readonly stdout: ManagedStream;
readonly stderr: ManagedStream;
once(event: 'error', listener: (error: Error) => void): ManagedChildProcess;
once(
event: 'exit',
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
): ManagedChildProcess;
kill(signal?: NodeJS.Signals): boolean;
}

export type DevProcessSpawner = (
spec: DevProcessSpec,
environment: NodeJS.ProcessEnv,
) => ManagedChildProcess;

export interface DevSignalSource {
once(signal: NodeJS.Signals, listener: () => void): unknown;
removeListener(signal: NodeJS.Signals, listener: () => void): unknown;
}

export interface DevOrchestratorOptions {
readonly output?: TerminalOutput;
readonly environment?: NodeJS.ProcessEnv;
readonly spawnProcess?: DevProcessSpawner;
readonly signalSource?: DevSignalSource;
}

function defaultSpawnProcess(
spec: DevProcessSpec,
environment: NodeJS.ProcessEnv,
): ManagedChildProcess {
return spawn(spec.command, [...spec.args], {
cwd: spec.cwd,
env: environment,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
}) as unknown as ManagedChildProcess;
}

function writePrefixedLines(
output: TerminalOutput,
label: string,
chunk: Buffer | string,
isError: boolean,
): void {
for (const line of String(chunk).split(/\r?\n/)) {
if (line.length === 0) {
continue;
}

const message = `[${label}] ${line}`;
if (isError) {
output.warning(message);
} else {
output.info(message);
}
}
}

export async function superviseDevProcesses(
specs: readonly DevProcessSpec[],
options: DevOrchestratorOptions = {},
): Promise<void> {
if (specs.length === 0) {
throw new ValidationCliError('No development processes are configured.');
}

const output = options.output ?? new TerminalOutput();
const environment = buildSafeChildEnvironment(
options.environment ?? process.env,
);
const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
const signalSource = options.signalSource ?? process;
const children: ManagedChildProcess[] = [];
let shuttingDown = false;

const stopChildren = (signal: NodeJS.Signals): void => {
if (shuttingDown) {
return;
}

shuttingDown = true;
for (const child of children) {
child.kill(signal);
}
};

const handleSigint = (): void => stopChildren('SIGINT');
const handleSigterm = (): void => stopChildren('SIGTERM');

signalSource.once('SIGINT', handleSigint);
signalSource.once('SIGTERM', handleSigterm);

try {
await new Promise<void>((resolve, reject) => {
let remaining = specs.length;
let settled = false;

const fail = (message: string, cause?: Error): void => {
if (settled) {
return;
}

settled = true;
stopChildren('SIGTERM');
reject(
new SubprocessCliError(
message,
cause === undefined ? undefined : { cause },
),
);
};

for (const spec of specs) {
let child: ManagedChildProcess;

try {
child = spawnProcess(spec, environment);
} catch (error) {
fail(
`Failed to start development process '${spec.label}'.`,
error instanceof Error ? error : undefined,
);
return;
}

children.push(child);
child.stdout.on('data', (chunk) =>
writePrefixedLines(output, spec.label, chunk, false),
);
child.stderr.on('data', (chunk) =>
writePrefixedLines(output, spec.label, chunk, true),
);
child.once('error', (error) => {
fail(`Development process '${spec.label}' failed to start.`, error);
});
child.once('exit', (code, signal) => {
remaining -= 1;

if (!shuttingDown && code !== 0) {
fail(
`Development process '${spec.label}' exited unexpectedly${
code === null ? '' : ` with code ${code}`
}${signal === null ? '' : ` after ${signal}`}.`,
);
return;
}

if (!settled && remaining === 0) {
settled = true;
resolve();
}
});
}
});
} finally {
signalSource.removeListener('SIGINT', handleSigint);
signalSource.removeListener('SIGTERM', handleSigterm);
}
}
75 changes: 75 additions & 0 deletions src/dev/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ValidationCliError } from '../errors/errors.js';

export interface DevProcessSpec {
readonly label: string;
readonly command: string;
readonly args: readonly string[];
readonly cwd: string;
}

interface ProjectPackage {
readonly name?: unknown;
readonly scripts?: unknown;
}

function readProjectPackage(projectRoot: string): ProjectPackage {
const packagePath = resolve(projectRoot, 'package.json');

if (!existsSync(packagePath)) {
throw new ValidationCliError(
'No package.json was found. The dev command currently supports generated Node.js StellarForge projects.',
);
}

try {
return JSON.parse(readFileSync(packagePath, 'utf8')) as ProjectPackage;
} catch (error) {
throw new ValidationCliError('package.json is not valid JSON.', {
cause: error,
});
}
}

function selectDevelopmentScript(scripts: Record<string, unknown>): string {
for (const candidate of ['dev', 'start', 'start:backend']) {
if (typeof scripts[candidate] === 'string') {
return candidate;
}
}

throw new ValidationCliError(
'No supported development script was found. Expected one of: dev, start, start:backend.',
);
}

export function resolveDevProcessPlan(cwd: string): readonly DevProcessSpec[] {
const projectRoot = resolve(cwd);
const projectPackage = readProjectPackage(projectRoot);

if (
projectPackage.scripts === null ||
typeof projectPackage.scripts !== 'object' ||
Array.isArray(projectPackage.scripts)
) {
throw new ValidationCliError('package.json must define a scripts object.');
}

const script = selectDevelopmentScript(
projectPackage.scripts as Record<string, unknown>,
);
const label =
typeof projectPackage.name === 'string' && projectPackage.name.length > 0
? projectPackage.name
: 'app';

return [
{
label,
command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
args: ['run', script],
cwd: projectRoot,
},
];
}
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createRootCommand } from './commands/root.js';

export function runCli(argv: string[] = process.argv): void {
export async function runCli(argv: string[] = process.argv): Promise<void> {
const program = createRootCommand();
program.parse(argv);
await program.parseAsync(argv);
}
Loading
Loading