From 7eb7bca03b9f7246cd66276fe467a5d31b3f6b45 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:50:18 +0100 Subject: [PATCH 01/13] feat: define typed StellarForge configuration schema --- src/config/schema.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/config/schema.ts 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 }), + }; +} From 7bc9df242cfabed799fd762aa519c2ab199c8fa0 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:50:32 +0100 Subject: [PATCH 02/13] feat: load and resolve project configuration safely --- src/config/project.ts | 88 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/config/project.ts diff --git a/src/config/project.ts b/src/config/project.ts new file mode 100644 index 0000000..d2d47b4 --- /dev/null +++ b/src/config/project.ts @@ -0,0 +1,88 @@ +import { existsSync, lstatSync, readFileSync } 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; + 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 }), + }; +} From 748e74151369ef81d6c9bdaa92fdc9946d218d7c Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:51:11 +0100 Subject: [PATCH 03/13] feat: resolve deploy settings from project configuration --- src/commands/deploy.ts | 48 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 9 deletions(-) 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.'); From f8a244c01d1487faafc7ddc7bd2db3bd7720d3f4 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:51:25 +0100 Subject: [PATCH 04/13] refactor: reuse shared identity validation for deployment --- src/deployment/project.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) 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'))) { From d32af5e04c9195e2fbdd6e850e0f156ee92e2e64 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Fri, 11 Sep 2026 10:52:46 +0100 Subject: [PATCH 05/13] fix: type project config metadata --- src/config/project.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/project.ts b/src/config/project.ts index d2d47b4..88df1d7 100644 --- a/src/config/project.ts +++ b/src/config/project.ts @@ -1,4 +1,4 @@ -import { existsSync, lstatSync, readFileSync } from 'node:fs'; +import { existsSync, lstatSync, readFileSync, type Stats } from 'node:fs'; import { resolve } from 'node:path'; import { ValidationCliError } from '../errors/errors.js'; import { @@ -28,7 +28,7 @@ export function loadProjectConfig(cwd: string): StellarForgeConfig | undefined { return undefined; } - let metadata; + let metadata: Stats; try { metadata = lstatSync(configPath); } catch (error) { From 88a522bde6ca6a1f09b702526b57080031c31bfa Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Fri, 11 Sep 2026 10:53:13 +0100 Subject: [PATCH 06/13] test: cover project configuration contract --- tests/configuration.test.ts | 121 ++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/configuration.test.ts diff --git a/tests/configuration.test.ts b/tests/configuration.test.ts new file mode 100644 index 0000000..cbd9c28 --- /dev/null +++ b/tests/configuration.test.ts @@ -0,0 +1,121 @@ +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'); + })); +}); From 1f90efc505fdb2f9659f4fe25ecb376b8ed8a73d Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Fri, 11 Sep 2026 10:53:15 +0100 Subject: [PATCH 07/13] docs: record project configuration architecture --- docs/adr/ADR-0005-project-configuration.md | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/adr/ADR-0005-project-configuration.md 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. From e27f24c56dac7ee38db4ee1cdf474540d76e484a Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Fri, 11 Sep 2026 10:53:44 +0100 Subject: [PATCH 08/13] docs: finalize configuration reference --- docs/reference/configuration.md | 73 ++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 19 deletions(-) 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. From d73afff87ba93b33bbd843e690c889e22987270e Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Fri, 11 Sep 2026 10:55:17 +0100 Subject: [PATCH 09/13] chore: diagnose CLI-034 formatting --- .github/workflows/format-cli-034.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/format-cli-034.yml diff --git a/.github/workflows/format-cli-034.yml b/.github/workflows/format-cli-034.yml new file mode 100644 index 0000000..008001e --- /dev/null +++ b/.github/workflows/format-cli-034.yml @@ -0,0 +1,25 @@ +name: Diagnose CLI-034 formatting + +on: + push: + branches: + - feat/cli-034-configuration-schema + +permissions: + contents: read + +jobs: + format-diff: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '22.13.0' + package-manager-cache: false + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npx --no-install prettier --write tests/configuration.test.ts + - run: git diff -- tests/configuration.test.ts From 78c92caf10c7e12c6f66d0291e08d7f4e62e9df3 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Fri, 11 Sep 2026 10:56:00 +0100 Subject: [PATCH 10/13] style: format configuration tests --- tests/configuration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/configuration.test.ts b/tests/configuration.test.ts index cbd9c28..20cdc21 100644 --- a/tests/configuration.test.ts +++ b/tests/configuration.test.ts @@ -108,7 +108,8 @@ describe('project configuration', () => { ); expect(() => loadProjectConfig(root)).toThrow(ValidationCliError); - })); + }), + ); it('rejects a symlinked project configuration', () => withTempDirectory((root) => { From 3630c222220231706c4a20fffb8b6c8db940a451 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Fri, 11 Sep 2026 10:56:10 +0100 Subject: [PATCH 11/13] chore: remove CLI-034 formatting diagnostic --- .github/workflows/format-cli-034.yml | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/format-cli-034.yml diff --git a/.github/workflows/format-cli-034.yml b/.github/workflows/format-cli-034.yml deleted file mode 100644 index 008001e..0000000 --- a/.github/workflows/format-cli-034.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Diagnose CLI-034 formatting - -on: - push: - branches: - - feat/cli-034-configuration-schema - -permissions: - contents: read - -jobs: - format-diff: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: '22.13.0' - package-manager-cache: false - - run: npm ci --ignore-scripts --no-audit --no-fund - - run: npx --no-install prettier --write tests/configuration.test.ts - - run: git diff -- tests/configuration.test.ts From e40efb89f9ab2ba353220958ba0f7acc4c100a2c Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Fri, 11 Sep 2026 11:20:35 +0100 Subject: [PATCH 12/13] test: cover project configuration contract --- tests/config-project.test.ts | 144 +++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/config-project.test.ts 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'); + })); +}); From 7b047fecbdb6e71025b01946a4cead5c47977ecb Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Fri, 11 Sep 2026 11:20:57 +0100 Subject: [PATCH 13/13] test: cover deploy configuration resolution --- tests/deploy-command.test.ts | 53 +++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) 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({