diff --git a/.changeset/fix-cli-build-diagnostics.md b/.changeset/fix-cli-build-diagnostics.md new file mode 100644 index 0000000..d47e0bd --- /dev/null +++ b/.changeset/fix-cli-build-diagnostics.md @@ -0,0 +1,5 @@ +--- +"@diginodes/stellarforge-cli": patch +--- + +Restore CLI compilation, consistent timeout diagnostics, and the existing output-redaction pipeline. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 438610d..1a45315 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -83,3 +83,17 @@ npm test `npm run test:e2e` builds and exercises the compiled CLI with temporary project directories and a fake Stellar executable; it does not contact live services. `npm run benchmark:doctor` runs five baseline samples and a timeout scenario with temporary fake PATH executables. It reports min/median/max timings without machine-dependent pass thresholds and requires a POSIX environment. Before opening a pull request, run the full local validation set documented in the installation and contribution guides. + +## Smart Contract Cargo metadata conformance + +Run `npx vitest run tests/smart-contract-cargo-metadata.test.ts` to check generated +workspace/package manifests, release settings, source paths, credential-free +metadata and the CLI's Cargo test plan. The checks generate isolated projects +through `stellarforge new` and do not install or compile the generated SDK. + +When Cargo is installed, the suite also runs +`cargo metadata --offline --no-deps --format-version=1` to verify workspace +membership, library targets and inherited normal/test dependencies. Only this +semantic check is skipped when Cargo is unavailable; the manifest and command +checks still run. Metadata validation does not replace a real `cargo test` or +`stellar contract build` with the supported Rust/Stellar toolchain. diff --git a/src/diagnostics/git.ts b/src/diagnostics/git.ts index 3658dee..544695c 100644 --- a/src/diagnostics/git.ts +++ b/src/diagnostics/git.ts @@ -20,7 +20,7 @@ export function createGitDiagnostic( const result = execute('git', ['--version']); const outcome = classifyCommandResult(result); - if (outcome === 'timeout') { + if (outcome === 'timed-out') { return { id: 'git', label: 'Git', @@ -48,7 +48,7 @@ export function createGitDiagnostic( id: 'git', label: 'Git', status: 'fail', - message: 'Git returned an unecognized version.', + message: 'Git returned an unrecognized version.', remediation: 'Verify `git --version` works and reinstall Git if necessary.', }; diff --git a/src/diagnostics/process.ts b/src/diagnostics/process.ts index 1ed4e0e..3f3345a 100644 --- a/src/diagnostics/process.ts +++ b/src/diagnostics/process.ts @@ -29,7 +29,8 @@ export const runCommand: RunCommand = (executable, args) => { }; }; -export type CommandOutcome = 'ok' | 'missing-executable' | 'failed'; +export type CommandOutcome = + 'ok' | 'missing-executable' | 'timed-out' | 'failed'; /** * Recognizes the platform-specific ways Node reports that an executable could @@ -57,6 +58,12 @@ export function classifyCommandResult(result: CommandResult): CommandOutcome { return 'missing-executable'; } + if ( + (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT' + ) { + return 'timed-out'; + } + if (result.error !== undefined || result.status !== 0) { return 'failed'; } diff --git a/src/output/redaction.ts b/src/output/redaction.ts index 3267788..2740adc 100644 --- a/src/output/redaction.ts +++ b/src/output/redaction.ts @@ -4,7 +4,7 @@ const STELLAR_SECRET_STRKEY = /\bS[A-Z2-7]{55}\b/g; const MAX_REDACTION_INPUT_LENGTH = 8192; const SENSITIVE_ASSIGNMENT = /((?:secret(?:[ _-]?key)?|private[ _-]?key|seed(?:[ _-]?phrase)?|mnemonic|password|passphrase|(?:api|auth|access)?[_-]?token)["']?\s*[:=]\s*)\S.*$/gi; -const BEARER_CREDENTIAL = /\b(Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi; +const BEARER_CREDENTIAL = /\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi; /** * Replaces secret-like material in a single line of untrusted text. @@ -20,6 +20,6 @@ export function redactSecretLikeText(line: string): string { : line; return bounded .replace(STELLAR_SECRET_STRKEY, REDACTED_PLACEHOLDER) - .replace(BEARER+_CREDENTIAL, `$1${REDACTED_PLACEHOLDER}`) - .replace(SENSITIVE_ASSOIGNMENT, `$1${REDACTED_PLACEHOLDER}`); + .replace(BEARER_CREDENTIAL, `$1${REDACTED_PLACEHOLDER}`) + .replace(SENSITIVE_ASSIGNMENT, `$1${REDACTED_PLACEHOLDER}`); } diff --git a/src/validate.ts b/src/validate.ts index 693f6c9..259faa2 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -2,7 +2,7 @@ import type { ProjectGenerationResult, ProjectGeneratorInput, ProjectGeneratorServices, -} from './types.js'; +} from './generator/types.js'; export function orchestrateProjectGeneration( input: ProjectGeneratorInput, diff --git a/tests/executable-classification.test.ts b/tests/executable-classification.test.ts index acd3882..6563a0d 100644 --- a/tests/executable-classification.test.ts +++ b/tests/executable-classification.test.ts @@ -110,3 +110,49 @@ describe('diagnostics classify missing executables consistently', () => { expect(calls).toEqual([{ executable: 'git', args: ['--version'] }]); }); }); + +describe('diagnostic timeouts', () => { + const timeout = Object.assign(new Error('private process details'), { + code: 'ETIMEDOUT', + }); + const execute: RunCommand = () => result(null, '', 'private stderr', timeout); + + it('classifies the structured timeout code without relying on message text', () => { + expect(classifyCommandResult(execute('tool', []))).toBe('timed-out'); + expect( + classifyCommandResult(result(null, '', '', new Error('ETIMEDOUT'))), + ).toBe('failed'); + }); + + it('returns safe timeout guidance for Git, Rust and Cargo', () => { + for (const check of [ + createGitDiagnostic(execute), + createRustDiagnostic(execute), + createCargoDiagnostic(execute), + ]) { + const diagnostic = check.run(); + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toContain('timed out'); + expect(diagnostic.remediation).toContain('responds promptly'); + expect(JSON.stringify(diagnostic)).not.toContain('private'); + } + }); + + it.each([false, true])( + 'preserves Docker required=%s for client and daemon timeouts', + (required) => { + for (const daemonTimeout of [false, true]) { + const diagnostic = createDockerDiagnostic({ + required, + execute: (executable, args) => + daemonTimeout && args[0] === '--version' + ? result(0, 'Docker version 27.0.0') + : execute(executable, args), + }).run(); + expect(diagnostic.status).toBe(required ? 'fail' : 'warn'); + expect(diagnostic.message).toContain('timed out'); + expect(JSON.stringify(diagnostic)).not.toContain('private'); + } + }, + ); +}); diff --git a/tests/smart-contract-cargo-metadata.test.ts b/tests/smart-contract-cargo-metadata.test.ts new file mode 100644 index 0000000..33c137d --- /dev/null +++ b/tests/smart-contract-cargo-metadata.test.ts @@ -0,0 +1,191 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createNewCommand } from '../src/commands/new.js'; +import { resolveTestProcessPlan } from '../src/testing/project.js'; +import { + createCapturedTerminalOutput, + withTempDirectory, +} from './helpers/index.js'; + +const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; +const cargoAvailable = + spawnSync(cargo, ['--version'], { shell: false, timeout: 5_000 }).status === + 0; + +function generate(root: string, name: string): string { + createNewCommand({ + cwd: () => root, + output: createCapturedTerminalOutput().output, + }).parse(['node', 'new', name, '--template', 'smart-contract']); + return join(root, name); +} + +// These small, controlled manifests intentionally have no extra metadata or +// credential-bearing registry overrides. Compare logical lines on all platforms; +// Cargo below provides the independent TOML/semantic validation when installed. +function manifestLines(path: string): string[] { + return readFileSync(path, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); +} + +interface CargoMetadata { + workspace_root: string; + workspace_members: string[]; + workspace_default_members: string[]; + packages: { + id: string; + name: string; + version: string; + edition: string; + manifest_path: string; + publish: string[]; + dependencies: { + name: string; + req: string; + kind: string | null; + features: string[]; + source: string; + }[]; + targets: { + name: string; + kind: string[]; + crate_types: string[]; + src_path: string; + doctest: boolean; + test: boolean; + }[]; + }[]; +} + +describe('Smart Contract Cargo metadata conformance (SW-046)', () => { + it.each(['demo-contract', 'demo.contract', 'Demo_Contract'])( + 'keeps workspace and package metadata consistent for %s', + (name) => + withTempDirectory((root) => { + const project = generate(root, name); + const contract = join(project, 'contracts', 'hello'); + expect(readdirSync(join(project, 'contracts'))).toEqual(['hello']); + expect(manifestLines(join(project, 'Cargo.toml'))).toEqual([ + '[workspace]', + 'resolver = "2"', + 'members = ["contracts/*"]', + '[workspace.dependencies]', + 'soroban-sdk = "26"', + '[profile.release]', + 'opt-level = "z"', + 'overflow-checks = true', + 'debug = 0', + 'strip = "symbols"', + 'debug-assertions = false', + 'panic = "abort"', + 'codegen-units = 1', + 'lto = true', + ]); + expect(manifestLines(join(contract, 'Cargo.toml'))).toEqual([ + '[package]', + 'name = "stellarforge-starter-contract"', + 'version = "0.1.0"', + 'edition = "2021"', + 'publish = false', + '[lib]', + 'crate-type = ["cdylib"]', + 'doctest = false', + '[dependencies]', + 'soroban-sdk = { workspace = true }', + '[dev-dependencies]', + 'soroban-sdk = { workspace = true, features = ["testutils"] }', + ]); + expect(existsSync(join(contract, 'src', 'lib.rs'))).toBe(true); + expect(existsSync(join(contract, 'src', 'test.rs'))).toBe(true); + expect(readFileSync(join(contract, 'src', 'lib.rs'), 'utf8')).toMatch( + /mod test;/, + ); + expect(resolveTestProcessPlan(project)).toEqual([ + { label: 'cargo:test', command: cargo, args: ['test'], cwd: project }, + ]); + const readme = readFileSync(join(project, 'README.md'), 'utf8').replace( + /\r\n/g, + '\n', + ); + expect(readme).toContain(`# ${name}`); + expect(readme).toContain('```bash\ncargo test\n```'); + expect(readme).toContain('```bash\nstellar contract build\n```'); + }), + ); + + it('does not embed signing material or registry credentials in either manifest', () => + withTempDirectory((root) => { + const project = generate(root, 'credential-free'); + for (const path of [ + join(project, 'Cargo.toml'), + join(project, 'contracts', 'hello', 'Cargo.toml'), + ]) { + const raw = readFileSync(path, 'utf8'); + expect(raw).not.toMatch(/SECRET|SEED|PRIVATE_KEY|TOKEN|PASSWORD/i); + expect(raw).not.toMatch(/S[A-Z0-9]{55}/); + expect(raw).not.toMatch(/https?:\/\/[^\s/]+@/i); + expect(raw).not.toContain('{{'); + } + expect(existsSync(join(project, '.cargo', 'credentials'))).toBe(false); + expect(existsSync(join(project, '.cargo', 'credentials.toml'))).toBe( + false, + ); + })); + + it.skipIf(!cargoAvailable)( + 'resolves the generated member, library target and inherited SDK offline', + () => + withTempDirectory((root) => { + const project = generate(root, 'cargo.metadata-project'); + const result = spawnSync( + cargo, + ['metadata', '--offline', '--no-deps', '--format-version=1'], + { cwd: project, encoding: 'utf8', shell: false, timeout: 10_000 }, + ); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + const metadata = JSON.parse(result.stdout) as CargoMetadata; + expect(resolve(metadata.workspace_root)).toBe(resolve(project)); + expect(metadata.packages).toHaveLength(1); + const pkg = metadata.packages[0]!; + expect(metadata.workspace_members).toEqual([pkg.id]); + expect(metadata.workspace_default_members).toEqual([pkg.id]); + expect(pkg).toMatchObject({ + name: 'stellarforge-starter-contract', + version: '0.1.0', + edition: '2021', + publish: [], + }); + expect(resolve(pkg.manifest_path)).toBe( + resolve(project, 'contracts', 'hello', 'Cargo.toml'), + ); + expect(pkg.targets).toHaveLength(1); + expect(pkg.targets[0]).toMatchObject({ + name: 'stellarforge_starter_contract', + kind: ['cdylib'], + crate_types: ['cdylib'], + doctest: false, + test: true, + }); + expect(resolve(pkg.targets[0]!.src_path)).toBe( + resolve(project, 'contracts', 'hello', 'src', 'lib.rs'), + ); + expect(pkg.dependencies).toHaveLength(2); + for (const kind of [null, 'dev']) { + expect( + pkg.dependencies.find((dep) => dep.kind === kind), + ).toMatchObject({ + name: 'soroban-sdk', + req: '^26', + features: kind === 'dev' ? ['testutils'] : [], + source: 'registry+https://github.com/rust-lang/crates.io-index', + }); + } + }), + 15_000, + ); +});