diff --git a/docs/adr/ADR-0005-project-configuration.md b/docs/adr/ADR-0005-project-configuration.md new file mode 100644 index 0000000..4cbcff7 --- /dev/null +++ b/docs/adr/ADR-0005-project-configuration.md @@ -0,0 +1,74 @@ +# ADR-0005: Project Configuration Contract + +- **Status:** Accepted +- **Date:** 2026-09-11 +- **Decision owners:** StellarForge Core Team + +## Context + +StellarForge commands increasingly need project-scoped settings such as the selected Stellar network and the named Stellar CLI identity used for deployment. Those settings must be deterministic, testable, safe to load in developer machines and CI, and compatible with the security baseline. + +Executable JavaScript or TypeScript configuration would introduce code-execution risk. Environment-variable precedence would also make effective behavior less visible for the MVP. Raw signing material must not become ordinary project configuration. + +## Decision + +The MVP project configuration file is a single root-level file named `stellarforge.config.json`. + +The schema is versioned and intentionally small: + +```json +{ + "version": 1, + "network": "testnet", + "identity": "deployer" +} +``` + +Supported fields are: + +- `version`: required and currently must equal `1`; +- `network`: optional and must be one of `testnet`, `futurenet`, or `mainnet`; +- `identity`: optional and must be a named Stellar CLI identity alias. + +No raw secret keys, seed phrases, mnemonic material, access tokens, passwords, private keys, or arbitrary extension fields are accepted. + +## Precedence + +For commands that expose an explicit CLI override, effective configuration is resolved as: + +1. explicit CLI option; +2. `stellarforge.config.json`; +3. missing. + +There is no implicit network default. In particular, StellarForge must never silently select Mainnet. + +The presence of `mainnet` in the configuration schema does not authorize every command to use Mainnet. Commands retain their own safety boundary. The MVP `deploy` command remains Testnet-only and rejects other networks after configuration resolution. + +## Loading Rules + +- configuration is loaded only from the current project root; +- the file must be a regular file and must not be a symbolic link; +- the MVP file-size limit is 64 KiB; +- configuration is parsed as JSON and never executed; +- malformed or invalid configuration produces actionable errors; +- errors must not include raw configuration contents or secret values. + +## Consequences + +### Positive + +- project behavior is deterministic and visible in source control; +- configuration parsing cannot execute project code; +- command-line overrides remain explicit and testable; +- network-sensitive behavior cannot silently fall through to Mainnet; +- secret-bearing configuration is rejected early. + +### Trade-offs + +- the MVP does not support environment-specific config files or environment-variable precedence; +- advanced network/custom-RPC configuration is deferred; +- schema additions require deliberate compatibility and security review. + +## Follow-up + +Future schema versions may add non-secret network metadata or command-specific configuration. Any extension involving credentials, RPC authentication, remote configuration, or executable plugins requires separate security review. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b8ff516..00dfca7 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1,30 +1,65 @@ # Configuration Reference -StellarForge CLI configuration should be explicit, validated, minimal, and command-scoped. +StellarForge uses an optional project-root configuration file named `stellarforge.config.json`. -## Principles +## MVP schema -- Validate configuration before side effects. -- Prefer typed schemas over ad-hoc object access. -- Never print secrets or full sensitive config values in normal output. -- Separate user/project configuration from environment-provided credentials. -- Do not silently fall back to Mainnet. -- Network-sensitive commands should require an explicit, validated network target. +```json +{ + "version": 1, + "network": "testnet", + "identity": "deployer" +} +``` -## MVP Direction +`version` is required and must currently be `1`. -The final configuration file format and precedence rules are not yet accepted. They should be recorded in an ADR before becoming a stable public contract. +`network` is optional and accepts `testnet`, `futurenet`, or `mainnet`. A configured network does not bypass command-specific restrictions: the MVP deploy command still permits Testnet only. -Expected configuration areas may include: +`identity` is optional and must be the name of an identity already managed by Stellar CLI. StellarForge does not accept raw secret keys, seed phrases, public StrKeys, filesystem paths, tokens, or password-like fields in project configuration. -- selected network; -- Stellar RPC endpoint/reference; -- generated-project/template metadata; -- command-specific local development options; -- deployment options that do not contain raw secrets. +## Precedence -Credentials should be referenced securely rather than committed to project configuration. +Where a command exposes a CLI override, the order is: -## Precedence +1. explicit CLI option; +2. project configuration; +3. missing. + +StellarForge does not silently default a missing network to Mainnet or any other network. + +For example: + +```json +{ + "version": 1, + "network": "testnet", + "identity": "team-deployer" +} +``` + +allows: + +```bash +stellarforge deploy +``` + +to use those project settings, while: + +```bash +stellarforge deploy --source release-deployer +``` + +overrides only the identity. + +## Loading and validation + +The file must: + +- live at the project root; +- be a regular file rather than a symbolic link; +- be no larger than 64 KiB; +- contain valid JSON; +- contain only supported schema fields. -Until formally decided, do not invent implicit precedence between CLI flags, project configuration, and environment variables. Command implementation issues must document required input sources explicitly. +Validation failures are designed to be actionable without echoing raw file contents or sensitive values. diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index f1ce465..32d4db2 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,4 +1,9 @@ import { Command } from 'commander'; +import { + resolveProjectConfig, + type ProjectConfigOverrides, + type ResolvedProjectConfig, +} from '../config/project.js'; import { superviseDevProcesses, type DevOrchestratorOptions, @@ -8,18 +13,23 @@ import { resolveTestnetDeploymentPlan, type TestnetDeploymentInput, } from '../deployment/project.js'; +import { ValidationCliError } from '../errors/errors.js'; import { TerminalOutput } from '../output/terminal.js'; export interface DeployCommandOptions extends DevOrchestratorOptions { readonly cwd?: () => string; + readonly resolveConfig?: ( + cwd: string, + overrides: ProjectConfigOverrides, + ) => ResolvedProjectConfig; readonly resolvePlan?: ( input: TestnetDeploymentInput, ) => readonly DevProcessSpec[]; } interface DeployCommandFlags { - readonly network: string; - readonly source: string; + readonly network?: string; + readonly source?: string; } export function createDeployCommand( @@ -27,23 +37,43 @@ export function createDeployCommand( ): Command { const cwd = options.cwd ?? process.cwd; const output = options.output ?? new TerminalOutput(); + const resolveConfig = options.resolveConfig ?? resolveProjectConfig; const resolvePlan = options.resolvePlan ?? resolveTestnetDeploymentPlan; return new Command('deploy') .description('Deploy a supported StellarForge project to Stellar Testnet.') - .requiredOption( + .option( '--network ', - 'Deployment network. MVP requires the explicit value `testnet`.', + 'Deployment network. CLI value overrides stellarforge.config.json.', ) - .requiredOption( + .option( '--source ', - 'Named Stellar CLI identity alias used to sign the deployment.', + 'Named Stellar CLI identity alias. CLI value overrides project configuration.', ) .action(async (flags: DeployCommandFlags) => { + const projectRoot = cwd(); + const overrides: ProjectConfigOverrides = { + ...(flags.network === undefined ? {} : { network: flags.network }), + ...(flags.source === undefined ? {} : { identity: flags.source }), + }; + const configuration = resolveConfig(projectRoot, overrides); + + if (configuration.network === undefined) { + throw new ValidationCliError( + 'Deployment network is required. Pass `--network testnet` or set an explicit network in stellarforge.config.json.', + ); + } + + if (configuration.identity === undefined) { + throw new ValidationCliError( + 'Deployment identity is required. Pass `--source ` or set a named identity in stellarforge.config.json.', + ); + } + const plan = resolvePlan({ - cwd: cwd(), - network: flags.network, - source: flags.source, + cwd: projectRoot, + network: configuration.network, + source: configuration.identity, }); output.info('Deploying smart contract to Stellar Testnet.'); diff --git a/src/config/project.ts b/src/config/project.ts new file mode 100644 index 0000000..88df1d7 --- /dev/null +++ b/src/config/project.ts @@ -0,0 +1,88 @@ +import { existsSync, lstatSync, readFileSync, type Stats } from 'node:fs'; +import { resolve } from 'node:path'; +import { ValidationCliError } from '../errors/errors.js'; +import { + validateIdentityAlias, + validateStellarForgeConfig, + type StellarForgeConfig, +} from './schema.js'; + +export const PROJECT_CONFIG_FILENAME = 'stellarforge.config.json'; +const MAX_CONFIG_BYTES = 64 * 1024; + +export interface ProjectConfigOverrides { + readonly network?: string; + readonly identity?: string; +} + +export interface ResolvedProjectConfig { + readonly network?: string; + readonly identity?: string; +} + +export function loadProjectConfig(cwd: string): StellarForgeConfig | undefined { + const projectRoot = resolve(cwd); + const configPath = resolve(projectRoot, PROJECT_CONFIG_FILENAME); + + if (!existsSync(configPath)) { + return undefined; + } + + let metadata: Stats; + try { + metadata = lstatSync(configPath); + } catch (error) { + throw new ValidationCliError( + 'Unable to inspect stellarforge.config.json.', + { cause: error }, + ); + } + + if (metadata.isSymbolicLink()) { + throw new ValidationCliError( + 'stellarforge.config.json must be a regular project file, not a symbolic link.', + ); + } + + if (!metadata.isFile()) { + throw new ValidationCliError( + 'stellarforge.config.json must be a regular project file.', + ); + } + + if (metadata.size > MAX_CONFIG_BYTES) { + throw new ValidationCliError( + 'stellarforge.config.json is larger than the 64 KiB MVP limit.', + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(configPath, 'utf8')) as unknown; + } catch (error) { + throw new ValidationCliError( + 'stellarforge.config.json is not valid JSON.', + { cause: error }, + ); + } + + return validateStellarForgeConfig(parsed); +} + +export function resolveProjectConfig( + cwd: string, + overrides: ProjectConfigOverrides = {}, +): ResolvedProjectConfig { + const projectConfig = loadProjectConfig(cwd); + + const identity = + overrides.identity === undefined + ? projectConfig?.identity + : validateIdentityAlias(overrides.identity); + const network = overrides.network ?? projectConfig?.network; + + return { + ...(network === undefined ? {} : { network }), + ...(identity === undefined ? {} : { identity }), + }; +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..a2c162e --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,98 @@ +import { ValidationCliError } from '../errors/errors.js'; + +export type StellarForgeNetwork = 'testnet' | 'futurenet' | 'mainnet'; + +export interface StellarForgeConfig { + readonly version: 1; + readonly network?: StellarForgeNetwork; + readonly identity?: string; +} + +const SUPPORTED_NETWORKS = new Set([ + 'testnet', + 'futurenet', + 'mainnet', +]); +const SAFE_IDENTITY_ALIAS = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; +const STELLAR_ACCOUNT_OR_SECRET_STRKEY = /^[GS][A-Z2-7]{55}$/; +const SENSITIVE_FIELD_NAME = + /(?:secret|private[_-]?key|seed|mnemonic|password|passphrase|token)/i; +const ALLOWED_FIELDS = new Set(['version', 'network', 'identity']); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function validateIdentityAlias(identity: string): string { + if ( + STELLAR_ACCOUNT_OR_SECRET_STRKEY.test(identity) || + !SAFE_IDENTITY_ALIAS.test(identity) + ) { + throw new ValidationCliError( + 'Stellar identity must be a named Stellar CLI identity alias. Raw secret keys, seed phrases, public keys, and path-like values are not accepted.', + ); + } + + return identity; +} + +export function validateStellarForgeConfig(value: unknown): StellarForgeConfig { + if (!isRecord(value)) { + throw new ValidationCliError( + 'stellarforge.config.json must contain a JSON object.', + ); + } + + for (const field of Object.keys(value)) { + if (ALLOWED_FIELDS.has(field)) { + continue; + } + + if (SENSITIVE_FIELD_NAME.test(field)) { + throw new ValidationCliError( + 'StellarForge project configuration must not contain secret material. Store signing keys in Stellar CLI identity management instead.', + ); + } + + throw new ValidationCliError( + 'stellarforge.config.json contains an unsupported field.', + ); + } + + if (value.version !== 1) { + throw new ValidationCliError( + 'stellarforge.config.json must declare "version": 1.', + ); + } + + let network: StellarForgeNetwork | undefined; + if (value.network !== undefined) { + if ( + typeof value.network !== 'string' || + !SUPPORTED_NETWORKS.has(value.network as StellarForgeNetwork) + ) { + throw new ValidationCliError( + 'Configuration network must be one of: testnet, futurenet, mainnet.', + ); + } + + network = value.network as StellarForgeNetwork; + } + + let identity: string | undefined; + if (value.identity !== undefined) { + if (typeof value.identity !== 'string') { + throw new ValidationCliError( + 'Configuration identity must be a named Stellar CLI identity alias.', + ); + } + + identity = validateIdentityAlias(value.identity); + } + + return { + version: 1, + ...(network === undefined ? {} : { network }), + ...(identity === undefined ? {} : { identity }), + }; +} diff --git a/src/deployment/project.ts b/src/deployment/project.ts index 0a243ca..471c54a 100644 --- a/src/deployment/project.ts +++ b/src/deployment/project.ts @@ -1,11 +1,9 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; +import { validateIdentityAlias } from '../config/schema.js'; import type { DevProcessSpec } from '../dev/project.js'; import { ValidationCliError } from '../errors/errors.js'; -const SAFE_IDENTITY_ALIAS = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; -const STELLAR_ACCOUNT_OR_SECRET_STRKEY = /^[GS][A-Z2-7]{55}$/; - export interface TestnetDeploymentInput { readonly cwd: string; readonly network: string; @@ -21,14 +19,7 @@ export function resolveTestnetDeploymentPlan( ); } - if ( - STELLAR_ACCOUNT_OR_SECRET_STRKEY.test(input.source) || - !SAFE_IDENTITY_ALIAS.test(input.source) - ) { - throw new ValidationCliError( - 'Deployment source must be a named Stellar CLI identity alias. Raw secret keys, seed phrases, public keys, and path-like values are not accepted.', - ); - } + validateIdentityAlias(input.source); const projectRoot = resolve(input.cwd); if (!existsSync(resolve(projectRoot, 'Cargo.toml'))) { diff --git a/tests/config-project.test.ts b/tests/config-project.test.ts new file mode 100644 index 0000000..f23220d --- /dev/null +++ b/tests/config-project.test.ts @@ -0,0 +1,144 @@ +import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + loadProjectConfig, + PROJECT_CONFIG_FILENAME, + resolveProjectConfig, +} from '../src/config/project.js'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { withTempDirectory } from './helpers/index.js'; + +function writeConfig(root: string, value: unknown): void { + writeFileSync(join(root, PROJECT_CONFIG_FILENAME), JSON.stringify(value)); +} + +describe('project configuration', () => { + it('loads a valid versioned project configuration', () => + withTempDirectory((root) => { + writeConfig(root, { + version: 1, + network: 'testnet', + identity: 'deployer', + }); + + expect(loadProjectConfig(root)).toEqual({ + version: 1, + network: 'testnet', + identity: 'deployer', + }); + })); + + it('returns undefined when project configuration is missing', () => + withTempDirectory((root) => { + expect(loadProjectConfig(root)).toBeUndefined(); + expect(resolveProjectConfig(root)).toEqual({}); + })); + + it('applies CLI overrides above project configuration', () => + withTempDirectory((root) => { + writeConfig(root, { + version: 1, + network: 'futurenet', + identity: 'configured', + }); + + expect( + resolveProjectConfig(root, { + network: 'testnet', + identity: 'cli-identity', + }), + ).toEqual({ + network: 'testnet', + identity: 'cli-identity', + }); + })); + + it.each([ + {}, + { version: 2 }, + { version: 1, network: 'local' }, + { version: 1, identity: '../identity' }, + { version: 1, extra: true }, + ])('rejects invalid configuration', (value) => + withTempDirectory((root) => { + writeConfig(root, value); + expect(() => loadProjectConfig(root)).toThrow(ValidationCliError); + })); + + it.each(['secret', 'privateKey', 'seed_phrase', 'password', 'token'])( + 'rejects secret-bearing field %s without echoing its value', + (field) => + withTempDirectory((root) => { + writeConfig(root, { + version: 1, + [field]: 'DO_NOT_ECHO_THIS_VALUE', + }); + + try { + loadProjectConfig(root); + throw new Error('expected configuration validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + expect((error as Error).message).not.toContain( + 'DO_NOT_ECHO_THIS_VALUE', + ); + } + }), + ); + + it('rejects raw Stellar StrKeys as identity configuration', () => + withTempDirectory((root) => { + writeConfig(root, { + version: 1, + identity: 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }); + + expect(() => loadProjectConfig(root)).toThrow(ValidationCliError); + })); + + it('rejects malformed JSON without echoing file contents', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + '{"version":1,"secret":"DO_NOT_ECHO"', + ); + + try { + loadProjectConfig(root); + throw new Error('expected parsing to fail'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + expect((error as Error).message).toContain('not valid JSON'); + expect((error as Error).message).not.toContain('DO_NOT_ECHO'); + } + })); + + it('rejects symbolic-link configuration', () => + withTempDirectory((root) => { + const target = join(root, 'actual-config.json'); + writeFileSync(target, '{"version":1}'); + symlinkSync(target, join(root, PROJECT_CONFIG_FILENAME)); + + expect(() => loadProjectConfig(root)).toThrow('symbolic link'); + })); + + it('rejects configuration larger than the MVP size limit', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ + version: 1, + padding: 'x'.repeat(70 * 1024), + }), + ); + + expect(() => loadProjectConfig(root)).toThrow('64 KiB'); + })); + + it('rejects a directory at the project configuration path', () => + withTempDirectory((root) => { + mkdirSync(join(root, PROJECT_CONFIG_FILENAME)); + expect(() => loadProjectConfig(root)).toThrow('regular project file'); + })); +}); diff --git a/tests/configuration.test.ts b/tests/configuration.test.ts new file mode 100644 index 0000000..20cdc21 --- /dev/null +++ b/tests/configuration.test.ts @@ -0,0 +1,122 @@ +import { symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + loadProjectConfig, + PROJECT_CONFIG_FILENAME, + resolveProjectConfig, +} from '../src/config/project.js'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { withTempDirectory } from './helpers/index.js'; + +describe('project configuration', () => { + it('returns undefined when project configuration is missing', () => + withTempDirectory((root) => { + expect(loadProjectConfig(root)).toBeUndefined(); + expect(resolveProjectConfig(root)).toEqual({}); + })); + + it('loads a valid versioned configuration from the project root', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ + version: 1, + network: 'testnet', + identity: 'deployer', + }), + ); + + expect(loadProjectConfig(root)).toEqual({ + version: 1, + network: 'testnet', + identity: 'deployer', + }); + })); + + it('applies CLI overrides above project configuration without inventing missing values', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ + version: 1, + network: 'futurenet', + identity: 'project-deployer', + }), + ); + + expect( + resolveProjectConfig(root, { + network: 'testnet', + identity: 'cli-deployer', + }), + ).toEqual({ + network: 'testnet', + identity: 'cli-deployer', + }); + + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1 }), + ); + expect(resolveProjectConfig(root)).toEqual({}); + })); + + it('rejects unsupported networks with an actionable error', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, network: 'local' }), + ); + + expect(() => loadProjectConfig(root)).toThrow( + 'Configuration network must be one of: testnet, futurenet, mainnet.', + ); + })); + + it('rejects secret-bearing fields without echoing their values', () => + withTempDirectory((root) => { + const secret = 'S-DO-NOT-ECHO-THIS-VALUE'; + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ + version: 1, + privateKey: secret, + }), + ); + + try { + loadProjectConfig(root); + throw new Error('Expected configuration validation to fail.'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + expect((error as Error).message).toContain( + 'must not contain secret material', + ); + expect((error as Error).message).not.toContain(secret); + } + })); + + it.each([ + 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ])('rejects raw Stellar StrKey identity value %j', (identity) => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, identity }), + ); + + expect(() => loadProjectConfig(root)).toThrow(ValidationCliError); + }), + ); + + it('rejects a symlinked project configuration', () => + withTempDirectory((root) => { + const target = join(root, 'actual-config.json'); + writeFileSync(target, JSON.stringify({ version: 1, network: 'testnet' })); + symlinkSync(target, join(root, PROJECT_CONFIG_FILENAME)); + + expect(() => loadProjectConfig(root)).toThrow('not a symbolic link'); + })); +}); diff --git a/tests/deploy-command.test.ts b/tests/deploy-command.test.ts index 73a2b64..9373175 100644 --- a/tests/deploy-command.test.ts +++ b/tests/deploy-command.test.ts @@ -2,7 +2,7 @@ import { PassThrough } from 'node:stream'; import { describe, expect, it } from 'vitest'; import { createDeployCommand } from '../src/commands/deploy.js'; import type { ManagedChildProcess } from '../src/dev/orchestrator.js'; -import { SubprocessCliError } from '../src/errors/errors.js'; +import { SubprocessCliError, ValidationCliError } from '../src/errors/errors.js'; import { createCapturedTerminalOutput } from './helpers/index.js'; class DeployChild implements ManagedChildProcess { @@ -98,6 +98,57 @@ describe('deploy command', () => { expect(captured.stdoutText()).toContain('[stellar:deploy] CABC123'); }); + it('uses resolved project configuration when CLI overrides are absent', async () => { + const child = new DeployChild(); + let received: + | { + readonly cwd: string; + readonly network: string; + readonly source: string; + } + | undefined; + const command = createDeployCommand({ + cwd: () => '/workspace', + resolveConfig: () => ({ + network: 'testnet', + identity: 'configured-deployer', + }), + resolvePlan(input) { + received = input; + return [ + { + label: 'stellar:deploy', + command: 'stellar', + args: ['contract', 'deploy'], + cwd: input.cwd, + }, + ]; + }, + spawnProcess: () => child, + }); + + const running = command.parseAsync(['node', 'deploy']); + child.exit(0); + await running; + + expect(received).toEqual({ + cwd: '/workspace', + network: 'testnet', + source: 'configured-deployer', + }); + }); + + it('fails when neither CLI input nor project configuration supplies required values', async () => { + const command = createDeployCommand({ + cwd: () => '/workspace', + resolveConfig: () => ({}), + }); + + await expect(command.parseAsync(['node', 'deploy'])).rejects.toBeInstanceOf( + ValidationCliError, + ); + }); + it('preserves a failed Stellar CLI deployment as a subprocess failure', async () => { const child = new DeployChild(); const command = createDeployCommand({