Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-cli-build-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@diginodes/stellarforge-cli": patch
---

Restore CLI compilation, consistent timeout diagnostics, and the existing output-redaction pipeline.
14 changes: 14 additions & 0 deletions docs/contributing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions src/diagnostics/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.',
};
Expand Down
9 changes: 8 additions & 1 deletion src/diagnostics/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
}
Expand Down
6 changes: 3 additions & 3 deletions src/output/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}`);
}
2 changes: 1 addition & 1 deletion src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type {
ProjectGenerationResult,
ProjectGeneratorInput,
ProjectGeneratorServices,
} from './types.js';
} from './generator/types.js';

export function orchestrateProjectGeneration(
input: ProjectGeneratorInput,
Expand Down
46 changes: 46 additions & 0 deletions tests/executable-classification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
},
);
});
191 changes: 191 additions & 0 deletions tests/smart-contract-cargo-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
Loading