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
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,46 @@ jobs:

- name: Build
run: npm run build

platform:
name: Platform / ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux / Node 22
os: ubuntu-latest
node: '22.13.0'
- name: Linux / Node 24
os: ubuntu-latest
node: '24'
- name: macOS / Node 22
os: macos-latest
node: '22.13.0'
- name: Windows / Node 22
os: windows-latest
node: '22.13.0'

runs-on: ${{ matrix.os }}
timeout-minutes: 15

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
package-manager-cache: false

- name: Install from committed lockfile
run: npm ci --ignore-scripts --no-audit --no-fund

- name: Run platform test suite
run: npm test

- name: Build platform artifact
run: npm run build
2 changes: 1 addition & 1 deletion docs/guides/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The CLI foundation supports maintained Node.js LTS lines covered by the package
- npm `>=10.9.0`
- repository toolchain metadata records npm `10.9.2`

Node.js 22 and Node.js 24 are the intended supported LTS majors for the current foundation. The minimum Node.js 22 patch level is aligned with the CLI's current linting/tooling dependency requirements. Do not use an end-of-life Node release for development or CI.
Node.js 22 and Node.js 24 are the supported LTS majors for the current MVP. The minimum Node.js 22 patch level is aligned with the CLI's current linting/tooling dependency requirements. Linux, macOS, and Windows are exercised by the repository platform matrix. See [Platform Support](../reference/platform-support.md). Do not use an end-of-life Node release for development or CI.

## Contributor Setup

Expand Down
57 changes: 57 additions & 0 deletions docs/reference/platform-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Platform Support

StellarForge CLI treats supported-platform failures as release blockers unless a limitation is explicitly documented and reviewed.

## Supported MVP matrix

| Operating system | Runtime | Validation level |
| --- | --- | --- |
| Linux (GitHub-hosted Ubuntu) | Node.js 22.13+ LTS | Full quality gate, tests, build |
| Linux (GitHub-hosted Ubuntu) | Node.js 24 LTS | Tests and build |
| macOS (GitHub-hosted macOS) | Node.js 22.13+ LTS | Tests and build |
| Windows (GitHub-hosted Windows) | Node.js 22.13+ LTS | Tests and build |

The package engine range remains `Node.js >=22.13.0 <25`. Node.js 22 and Node.js 24 are the supported LTS majors for the current MVP. Intermediate non-LTS Node releases are not release-validation targets.

## What the platform matrix exercises

The platform jobs run the repository test suite and production build. This includes coverage for:

- compiled CLI startup, help, version, and parser exit behavior;
- project path validation and generation safety;
- generated Basic App, Full Stack, Smart Contract, and API Service smoke checks;
- platform-specific executable selection such as the Windows npm wrapper, `cargo.exe`, and `stellar.exe`;
- subprocess supervision and failure propagation;
- project test/development workflow resolution;
- project configuration loading and security validation.

The Linux quality job additionally runs type checking, linting, and Prettier validation.

## Platform differences

StellarForge code should expose platform differences deliberately instead of hiding them behind shell behavior.

### Windows

- npm subprocesses use a fixed `cmd.exe /d /s /c npm ...` wrapper because Windows `.cmd` shims are not directly executable under the CLI's `shell: false` policy;
- Cargo subprocesses use `cargo.exe`;
- Stellar deployment subprocesses use `stellar.exe`;
- path tests must use Node path APIs rather than assuming POSIX separators;
- commands must not rely on Bash being the default shell.

### macOS and Linux

- npm, Cargo, and Stellar executables use their ordinary executable names;
- generated projects and CLI subprocesses still use argument arrays rather than shell command concatenation.

## External tools

A green platform job validates StellarForge's code and tests on that OS; it does not guarantee every optional external tool is preinstalled on every runner.

`stellarforge doctor` is responsible for reporting the actual local availability of Git, Rust, Cargo, Stellar CLI, Docker, and supported runtimes. Tests accept either a healthy or diagnostic-failure result where runner tool availability is intentionally variable.

## Release policy

A failing Linux, macOS, or Windows matrix job blocks merge/release for changes that affect the supported CLI surface. A failure may be waived only through an explicit project decision that documents the affected platform, reason, user impact, and follow-up plan.

Platform-specific failures must not be hidden with `continue-on-error` in the supported matrix.
7 changes: 5 additions & 2 deletions src/dev/project.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ValidationCliError } from '../errors/errors.js';
import { createNpmInvocation } from '../process/npm.js';

export interface DevProcessSpec {
readonly label: string;
Expand Down Expand Up @@ -64,11 +65,13 @@ export function resolveDevProcessPlan(cwd: string): readonly DevProcessSpec[] {
? projectPackage.name
: 'app';

const npm = createNpmInvocation(['run', script]);

return [
{
label,
command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
args: ['run', script],
command: npm.command,
args: npm.args,
cwd: projectRoot,
},
];
Expand Down
3 changes: 3 additions & 0 deletions src/diagnostics/process.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { spawnSync } from 'node:child_process';

export const DIAGNOSTIC_COMMAND_TIMEOUT_MS = 5_000;

export interface CommandResult {
readonly status: number | null;
readonly stdout: string;
Expand All @@ -16,6 +18,7 @@ export const runCommand: RunCommand = (executable, args) => {
const result = spawnSync(executable, [...args], {
encoding: 'utf8',
shell: false,
timeout: DIAGNOSTIC_COMMAND_TIMEOUT_MS,
});

return {
Expand Down
4 changes: 3 additions & 1 deletion src/diagnostics/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createNpmInvocation } from '../process/npm.js';
import type { DiagnosticCheck, DiagnosticResult } from './types.js';
import { runCommand, type RunCommand } from './process.js';
import {
Expand Down Expand Up @@ -67,7 +68,8 @@ export function createNpmDiagnostic(
id: 'npm',
label: 'npm',
run(): DiagnosticResult {
const result = execute('npm', ['--version']);
const npm = createNpmInvocation(['--version']);
const result = execute(npm.command, npm.args);

if (result.error || result.status !== 0) {
return failure(
Expand Down
18 changes: 18 additions & 0 deletions src/process/npm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export interface NpmInvocation {
readonly command: string;
readonly args: readonly string[];
}

export function createNpmInvocation(args: readonly string[]): NpmInvocation {
if (process.platform === 'win32') {
return {
command: 'cmd.exe',
args: ['/d', '/s', '/c', 'npm', ...args],
};
}

return {
command: 'npm',
args: [...args],
};
}
7 changes: 5 additions & 2 deletions src/testing/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { DevProcessSpec } from '../dev/project.js';
import { ValidationCliError } from '../errors/errors.js';
import { createNpmInvocation } from '../process/npm.js';

interface ProjectPackage {
readonly name?: unknown;
Expand Down Expand Up @@ -46,10 +47,12 @@ function resolveNodeTestPlan(projectRoot: string): DevProcessSpec | undefined {
? projectPackage.name
: 'app';

const npm = createNpmInvocation(['run', 'test']);

return {
label: `${projectName}:test`,
command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
args: ['run', 'test'],
command: npm.command,
args: npm.args,
cwd: projectRoot,
};
}
Expand Down
17 changes: 8 additions & 9 deletions tests/api-service-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,20 @@ import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { createNewCommand } from '../src/commands/new.js';
import { createNpmInvocation } from '../src/process/npm.js';
import {
createCapturedTerminalOutput,
withTempDirectory,
} from './helpers/index.js';

function runNpmScript(projectRoot: string, script: string) {
return spawnSync(
process.platform === 'win32' ? 'npm.cmd' : 'npm',
['run', script],
{
cwd: projectRoot,
encoding: 'utf8',
shell: false,
},
);
const npm = createNpmInvocation(['run', script]);

return spawnSync(npm.command, [...npm.args], {
cwd: projectRoot,
encoding: 'utf8',
shell: false,
});
}

describe('API Service template', () => {
Expand Down
17 changes: 8 additions & 9 deletions tests/basic-app-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,20 @@ import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { createNewCommand } from '../src/commands/new.js';
import { createNpmInvocation } from '../src/process/npm.js';
import {
createCapturedTerminalOutput,
withTempDirectory,
} from './helpers/index.js';

function runNpmScript(projectRoot: string, script: string) {
return spawnSync(
process.platform === 'win32' ? 'npm.cmd' : 'npm',
['run', script],
{
cwd: projectRoot,
encoding: 'utf8',
shell: false,
},
);
const npm = createNpmInvocation(['run', script]);

return spawnSync(npm.command, [...npm.args], {
cwd: projectRoot,
encoding: 'utf8',
shell: false,
});
}

describe('Basic App template', () => {
Expand Down
9 changes: 6 additions & 3 deletions tests/dev-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { createNpmInvocation } from '../src/process/npm.js';
import { resolveDevProcessPlan } from '../src/dev/project.js';
import { withTempDirectory } from './helpers/index.js';

Expand All @@ -19,10 +20,11 @@ describe('development project plan', () => {
const plan = resolveDevProcessPlan(root);

expect(plan).toHaveLength(1);
const npm = createNpmInvocation(['run', 'start']);
expect(plan[0]).toEqual({
label: 'demo',
command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
args: ['run', 'start'],
command: npm.command,
args: npm.args,
cwd: root,
});
}));
Expand All @@ -42,7 +44,8 @@ describe('development project plan', () => {

const [spec] = resolveDevProcessPlan(root);

expect(spec?.args).toEqual(['run', 'dev']);
const npm = createNpmInvocation(['run', 'dev']);
expect(spec).toMatchObject({ command: npm.command, args: npm.args });
expect(spec?.args).not.toContain(
'node dev.js --flag && echo unsafe-if-shell-expanded',
);
Expand Down
17 changes: 8 additions & 9 deletions tests/full-stack-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,20 @@ import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { createNewCommand } from '../src/commands/new.js';
import { createNpmInvocation } from '../src/process/npm.js';
import {
createCapturedTerminalOutput,
withTempDirectory,
} from './helpers/index.js';

function runNpmScript(projectRoot: string, script: string) {
return spawnSync(
process.platform === 'win32' ? 'npm.cmd' : 'npm',
['run', script],
{
cwd: projectRoot,
encoding: 'utf8',
shell: false,
},
);
const npm = createNpmInvocation(['run', script]);

return spawnSync(npm.command, [...npm.args], {
cwd: projectRoot,
encoding: 'utf8',
shell: false,
});
}

describe('Full Stack template', () => {
Expand Down
4 changes: 3 additions & 1 deletion tests/runtime-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createNpmDiagnostic,
} from '../src/diagnostics/runtime.js';
import type { RunCommand } from '../src/diagnostics/process.js';
import { createNpmInvocation } from '../src/process/npm.js';

function commandResult(
status: number | null,
Expand Down Expand Up @@ -59,7 +60,8 @@ describe('npm diagnostic', () => {

const result = createNpmDiagnostic(execute).run();

expect(calls).toEqual([{ executable: 'npm', args: ['--version'] }]);
const npm = createNpmInvocation(['--version']);
expect(calls).toEqual([{ executable: npm.command, args: npm.args }]);
expect(result.status).toBe('pass');
expect(result.message).toContain('10.9.2');
});
Expand Down
6 changes: 3 additions & 3 deletions tests/static-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('static quality enforcement', () => {

expect(result.status).not.toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain('no-unused-vars');
});
}, 15_000);

it('rejects a formatting violation with the repository Prettier configuration', async () => {
await withTempDirectory((directory) => {
Expand All @@ -73,7 +73,7 @@ describe('static quality enforcement', () => {
'Code style issues found',
);
});
});
}, 15_000);

it('rejects a type violation with strict repository compiler options', async () => {
await withTempDirectory((directory) => {
Expand All @@ -98,5 +98,5 @@ describe('static quality enforcement', () => {
expect(result.status).not.toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain('TS2322');
});
});
}, 15_000);
});
6 changes: 4 additions & 2 deletions tests/test-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { ValidationCliError } from '../src/errors/errors.js';
import { createNpmInvocation } from '../src/process/npm.js';
import { resolveTestProcessPlan } from '../src/testing/project.js';
import { withTempDirectory } from './helpers/index.js';

Expand All @@ -16,11 +17,12 @@ describe('project test workflow resolution', () => {
}),
);

const npm = createNpmInvocation(['run', 'test']);
expect(resolveTestProcessPlan(root)).toEqual([
{
label: 'demo:test',
command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
args: ['run', 'test'],
command: npm.command,
args: npm.args,
cwd: root,
},
]);
Expand Down
Loading