diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e24c0b9..47fcc75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/guides/installation.md b/docs/guides/installation.md index 49051cb..bd93bb1 100644 --- a/docs/guides/installation.md +++ b/docs/guides/installation.md @@ -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 diff --git a/docs/reference/platform-support.md b/docs/reference/platform-support.md new file mode 100644 index 0000000..be8950f --- /dev/null +++ b/docs/reference/platform-support.md @@ -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. diff --git a/src/dev/project.ts b/src/dev/project.ts index 4534d1b..14ceb69 100644 --- a/src/dev/project.ts +++ b/src/dev/project.ts @@ -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; @@ -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, }, ]; diff --git a/src/diagnostics/process.ts b/src/diagnostics/process.ts index 93a5431..6f6494d 100644 --- a/src/diagnostics/process.ts +++ b/src/diagnostics/process.ts @@ -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; @@ -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 { diff --git a/src/diagnostics/runtime.ts b/src/diagnostics/runtime.ts index 8df6d7a..428ebf6 100644 --- a/src/diagnostics/runtime.ts +++ b/src/diagnostics/runtime.ts @@ -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 { @@ -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( diff --git a/src/process/npm.ts b/src/process/npm.ts new file mode 100644 index 0000000..d730e96 --- /dev/null +++ b/src/process/npm.ts @@ -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], + }; +} diff --git a/src/testing/project.ts b/src/testing/project.ts index 18e2cfc..49595e2 100644 --- a/src/testing/project.ts +++ b/src/testing/project.ts @@ -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; @@ -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, }; } diff --git a/tests/api-service-template.test.ts b/tests/api-service-template.test.ts index 097c621..9b6c780 100644 --- a/tests/api-service-template.test.ts +++ b/tests/api-service-template.test.ts @@ -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', () => { diff --git a/tests/basic-app-template.test.ts b/tests/basic-app-template.test.ts index 943d068..fa6b99e 100644 --- a/tests/basic-app-template.test.ts +++ b/tests/basic-app-template.test.ts @@ -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', () => { diff --git a/tests/dev-project.test.ts b/tests/dev-project.test.ts index a1736b0..371a203 100644 --- a/tests/dev-project.test.ts +++ b/tests/dev-project.test.ts @@ -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'; @@ -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, }); })); @@ -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', ); diff --git a/tests/full-stack-template.test.ts b/tests/full-stack-template.test.ts index d49c0ff..c90e7b9 100644 --- a/tests/full-stack-template.test.ts +++ b/tests/full-stack-template.test.ts @@ -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', () => { diff --git a/tests/runtime-diagnostics.test.ts b/tests/runtime-diagnostics.test.ts index db00c93..c4789cc 100644 --- a/tests/runtime-diagnostics.test.ts +++ b/tests/runtime-diagnostics.test.ts @@ -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, @@ -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'); }); diff --git a/tests/static-quality.test.ts b/tests/static-quality.test.ts index aa01750..f116ab1 100644 --- a/tests/static-quality.test.ts +++ b/tests/static-quality.test.ts @@ -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) => { @@ -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) => { @@ -98,5 +98,5 @@ describe('static quality enforcement', () => { expect(result.status).not.toBe(0); expect(`${result.stdout}${result.stderr}`).toContain('TS2322'); }); - }); + }, 15_000); }); diff --git a/tests/test-project.test.ts b/tests/test-project.test.ts index 186c282..7b18a36 100644 --- a/tests/test-project.test.ts +++ b/tests/test-project.test.ts @@ -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'; @@ -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, }, ]);