From 0ef1f330c5b2541654a81d2e76a3124a55fd180c Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:43:17 +0100 Subject: [PATCH 1/6] feat: implement guarded Stellar Testnet deployment --- docs/reference/commands.md | 32 ++++++-- src/commands/deploy.ts | 52 +++++++++++++ src/commands/root.ts | 4 +- src/deployment/project.ts | 51 +++++++++++++ tests/deploy-command.test.ts | 123 +++++++++++++++++++++++++++++++ tests/deployment-project.test.ts | 76 +++++++++++++++++++ tests/root-command.test.ts | 2 + 7 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 src/commands/deploy.ts create mode 100644 src/deployment/project.ts create mode 100644 tests/deploy-command.test.ts create mode 100644 tests/deployment-project.test.ts diff --git a/docs/reference/commands.md b/docs/reference/commands.md index b1f80a9..63bb522 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -6,15 +6,15 @@ This reference tracks the v1.0 MVP command surface. |---|---|---| | `stellarforge --help` | Show global usage/help | Implemented | | `stellarforge --version` | Show installed CLI version | Implemented | -| `stellarforge new ` | Scaffold a supported Stellar project | Planned | -| `stellarforge doctor` | Validate the development environment | Planned | -| `stellarforge dev` | Orchestrate local development | Planned | -| `stellarforge test` | Run supported tests | Planned | -| `stellarforge deploy` | Deploy supported components to Stellar Testnet | Planned | +| `stellarforge new ` | Scaffold a supported Stellar project | Implemented | +| `stellarforge doctor` | Validate the development environment | Implemented | +| `stellarforge dev` | Orchestrate local development | Implemented | +| `stellarforge test` | Run supported tests | Implemented | +| `stellarforge deploy --network testnet --source ` | Deploy a supported smart contract to Stellar Testnet | Implemented | ## Root Command -The implemented root command is `stellarforge`. It currently provides global help, installed-package version output, and deterministic parser errors without registering unfinished product commands. +The root command is `stellarforge`. It provides global help, installed-package version output, deterministic parser errors, and the implemented MVP product commands. ```bash stellarforge --help @@ -23,8 +23,24 @@ stellarforge --version `stellarforge --version` reads the version from the installed package metadata. It does not contact a package registry, run a subprocess, or maintain a second hard-coded version constant. -Unknown options or positional commands fail with a non-zero exit code and include root usage information. Product commands are added only when their implementation issues are complete. +Unknown options or positional commands fail with a non-zero exit code and include root usage information. `stellarforge add` is not part of the v1.0 MVP. -`stellarforge add` is not part of the v1.0 MVP. +## Deploy + +MVP deployment is intentionally restricted to generated Stellar smart-contract Cargo workspaces and Stellar Testnet. + +```bash +stellarforge deploy --network testnet --source deployer +``` + +The network value must be written explicitly as `testnet`; Mainnet, Futurenet, local networks, and implicit defaults are not accepted. The source must be the name of a Stellar CLI identity already configured by the developer. StellarForge does not accept raw secret keys, seed phrases, public keys, or path-like source values for deployment. + +The command invokes Stellar CLI using an executable and argument array equivalent to: + +```text +stellar contract deploy --source-account deployer --network testnet +``` + +No shell interpolation is used. Deployment output is streamed to the terminal. A failed Stellar CLI process produces a non-zero StellarForge subprocess result. Signing material remains under Stellar CLI identity management and must never be committed to the project or passed as a raw StellarForge argument. Each implemented command should document arguments/options, examples, exit behavior, security-sensitive behavior, and configuration requirements here or in a dedicated reference page. diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts new file mode 100644 index 0000000..f1ce465 --- /dev/null +++ b/src/commands/deploy.ts @@ -0,0 +1,52 @@ +import { Command } from 'commander'; +import { + superviseDevProcesses, + type DevOrchestratorOptions, +} from '../dev/orchestrator.js'; +import type { DevProcessSpec } from '../dev/project.js'; +import { + resolveTestnetDeploymentPlan, + type TestnetDeploymentInput, +} from '../deployment/project.js'; +import { TerminalOutput } from '../output/terminal.js'; + +export interface DeployCommandOptions extends DevOrchestratorOptions { + readonly cwd?: () => string; + readonly resolvePlan?: ( + input: TestnetDeploymentInput, + ) => readonly DevProcessSpec[]; +} + +interface DeployCommandFlags { + readonly network: string; + readonly source: string; +} + +export function createDeployCommand( + options: DeployCommandOptions = {}, +): Command { + const cwd = options.cwd ?? process.cwd; + const output = options.output ?? new TerminalOutput(); + const resolvePlan = options.resolvePlan ?? resolveTestnetDeploymentPlan; + + return new Command('deploy') + .description('Deploy a supported StellarForge project to Stellar Testnet.') + .requiredOption( + '--network ', + 'Deployment network. MVP requires the explicit value `testnet`.', + ) + .requiredOption( + '--source ', + 'Named Stellar CLI identity alias used to sign the deployment.', + ) + .action(async (flags: DeployCommandFlags) => { + const plan = resolvePlan({ + cwd: cwd(), + network: flags.network, + source: flags.source, + }); + + output.info('Deploying smart contract to Stellar Testnet.'); + await superviseDevProcesses(plan, { ...options, output }); + }); +} diff --git a/src/commands/root.ts b/src/commands/root.ts index 0777bcb..0e01a18 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { resolveCliVersion } from '../version.js'; +import { createDeployCommand } from './deploy.js'; import { createDevCommand } from './dev.js'; import { createDoctorCommand } from './doctor.js'; import { createNewCommand } from './new.js'; @@ -20,5 +21,6 @@ export function createRootCommand(version = resolveCliVersion()): Command { .addCommand(createDoctorCommand()) .addCommand(createNewCommand()) .addCommand(createDevCommand()) - .addCommand(createTestCommand()); + .addCommand(createTestCommand()) + .addCommand(createDeployCommand()); } diff --git a/src/deployment/project.ts b/src/deployment/project.ts new file mode 100644 index 0000000..a3cdabe --- /dev/null +++ b/src/deployment/project.ts @@ -0,0 +1,51 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +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}$/; + +export interface TestnetDeploymentInput { + readonly cwd: string; + readonly network: string; + readonly source: string; +} + +export function resolveTestnetDeploymentPlan( + input: TestnetDeploymentInput, +): readonly DevProcessSpec[] { + if (input.network !== 'testnet') { + throw new ValidationCliError( + 'StellarForge deploy supports only the explicit `--network testnet` target in the MVP.', + ); + } + + if (!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.', + ); + } + + const projectRoot = resolve(input.cwd); + if (!existsSync(resolve(projectRoot, 'Cargo.toml'))) { + throw new ValidationCliError( + 'No Cargo.toml workspace was found. MVP deployment currently supports generated Stellar smart-contract projects only.', + ); + } + + return [ + { + label: 'stellar:deploy', + command: process.platform === 'win32' ? 'stellar.exe' : 'stellar', + args: [ + 'contract', + 'deploy', + '--source-account', + input.source, + '--network', + 'testnet', + ], + cwd: projectRoot, + }, + ]; +} diff --git a/tests/deploy-command.test.ts b/tests/deploy-command.test.ts new file mode 100644 index 0000000..056b49a --- /dev/null +++ b/tests/deploy-command.test.ts @@ -0,0 +1,123 @@ +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 { createCapturedTerminalOutput } from './helpers/index.js'; + +class DeployChild implements ManagedChildProcess { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + #exitListener: + | ((code: number | null, signal: NodeJS.Signals | null) => void) + | undefined; + + once(event: 'error', listener: (error: Error) => void): this; + once( + event: 'exit', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): this; + once( + event: 'error' | 'exit', + listener: + | ((error: Error) => void) + | ((code: number | null, signal: NodeJS.Signals | null) => void), + ): this { + if (event === 'exit') { + this.#exitListener = listener as ( + code: number | null, + signal: NodeJS.Signals | null, + ) => void; + } + return this; + } + + kill(): boolean { + return true; + } + + exit(code: number): void { + this.#exitListener?.(code, null); + } +} + +describe('deploy command', () => { + it('forwards explicit Testnet and identity input to a safe deployment plan', async () => { + const child = new DeployChild(); + const captured = createCapturedTerminalOutput(); + let received: + | { readonly cwd: string; readonly network: string; readonly source: string } + | undefined; + const command = createDeployCommand({ + cwd: () => '/workspace', + output: captured.output, + resolvePlan(input) { + received = input; + return [ + { + label: 'stellar:deploy', + command: 'stellar', + args: [ + 'contract', + 'deploy', + '--source-account', + input.source, + '--network', + input.network, + ], + cwd: input.cwd, + }, + ]; + }, + spawnProcess: () => child, + }); + + const running = command.parseAsync([ + 'node', + 'deploy', + '--network', + 'testnet', + '--source', + 'deployer', + ]); + child.stdout.write('CABC123\n'); + child.exit(0); + await running; + + expect(received).toEqual({ + cwd: '/workspace', + network: 'testnet', + source: 'deployer', + }); + expect(captured.stdoutText()).toContain('Deploying smart contract to Stellar Testnet.'); + expect(captured.stdoutText()).toContain('[stellar:deploy] CABC123'); + }); + + it('preserves a failed Stellar CLI deployment as a subprocess failure', async () => { + const child = new DeployChild(); + const command = createDeployCommand({ + cwd: () => '/workspace', + resolvePlan: () => [ + { + label: 'stellar:deploy', + command: 'stellar', + args: ['contract', 'deploy', '--source-account', 'alice', '--network', 'testnet'], + cwd: '/workspace', + }, + ], + spawnProcess: () => child, + }); + + const running = command.parseAsync([ + 'node', + 'deploy', + '--network', + 'testnet', + '--source', + 'alice', + ]); + child.exit(2); + + await expect(running).rejects.toBeInstanceOf(SubprocessCliError); + }); +}); diff --git a/tests/deployment-project.test.ts b/tests/deployment-project.test.ts new file mode 100644 index 0000000..c0f9d1a --- /dev/null +++ b/tests/deployment-project.test.ts @@ -0,0 +1,76 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { resolveTestnetDeploymentPlan } from '../src/deployment/project.js'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { withTempDirectory } from './helpers/index.js'; + +describe('Testnet deployment plan', () => { + it('builds a safe Stellar CLI argument array for an explicit Testnet deployment', () => + withTempDirectory((root) => { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); + + expect( + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source: 'deployer', + }), + ).toEqual([ + { + label: 'stellar:deploy', + command: process.platform === 'win32' ? 'stellar.exe' : 'stellar', + args: [ + 'contract', + 'deploy', + '--source-account', + 'deployer', + '--network', + 'testnet', + ], + cwd: root, + }, + ]); + })); + + it.each(['mainnet', 'futurenet', 'local', 'TESTNET', '']) ( + 'rejects unsupported network %j before side effects', + (network) => + withTempDirectory((root) => { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); + expect(() => + resolveTestnetDeploymentPlan({ cwd: root, network, source: 'alice' }), + ).toThrow('only the explicit `--network testnet`'); + }), + ); + + it.each([ + 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'seed phrase words', + '../identity', + 'alice/bob', + '-flag', + ])('rejects unsafe source value %j', (source) => + withTempDirectory((root) => { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); + expect(() => + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source, + }), + ).toThrow(ValidationCliError); + })); + + it('rejects unsupported project layouts', () => + withTempDirectory((root) => { + expect(() => + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source: 'alice', + }), + ).toThrow('No Cargo.toml workspace was found'); + })); +}); diff --git a/tests/root-command.test.ts b/tests/root-command.test.ts index 36deb99..6c6bc55 100644 --- a/tests/root-command.test.ts +++ b/tests/root-command.test.ts @@ -11,6 +11,7 @@ describe('StellarForge root command', () => { 'new', 'dev', 'test', + 'deploy', ]); }); @@ -25,6 +26,7 @@ describe('StellarForge root command', () => { expect(help).toContain('new'); expect(help).toContain('dev'); expect(help).toContain('test'); + expect(help).toContain('deploy'); expect(help).toContain('-V, --version'); expect(help).toContain('-h, --help'); }); From 435c091e743134288b5c411f81ce49d73163398c Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:44:18 +0100 Subject: [PATCH 2/6] chore: diagnose CLI-033 formatting --- .github/workflows/format-cli-033.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/format-cli-033.yml diff --git a/.github/workflows/format-cli-033.yml b/.github/workflows/format-cli-033.yml new file mode 100644 index 0000000..9dfd5eb --- /dev/null +++ b/.github/workflows/format-cli-033.yml @@ -0,0 +1,25 @@ +name: Diagnose CLI-033 formatting + +on: + push: + branches: + - feat/cli-033-testnet-deploy + +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/deploy-command.test.ts tests/deployment-project.test.ts + - run: git diff -- tests/deploy-command.test.ts tests/deployment-project.test.ts From e50d1eb4e426a2088ba6944acdbbd1860934cff8 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:45:25 +0100 Subject: [PATCH 3/6] style: format deploy command tests --- tests/deploy-command.test.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/deploy-command.test.ts b/tests/deploy-command.test.ts index 056b49a..73a2b64 100644 --- a/tests/deploy-command.test.ts +++ b/tests/deploy-command.test.ts @@ -9,8 +9,7 @@ class DeployChild implements ManagedChildProcess { readonly stdout = new PassThrough(); readonly stderr = new PassThrough(); #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; once( @@ -46,7 +45,11 @@ describe('deploy command', () => { const child = new DeployChild(); const captured = createCapturedTerminalOutput(); let received: - | { readonly cwd: string; readonly network: string; readonly source: string } + | { + readonly cwd: string; + readonly network: string; + readonly source: string; + } | undefined; const command = createDeployCommand({ cwd: () => '/workspace', @@ -89,7 +92,9 @@ describe('deploy command', () => { network: 'testnet', source: 'deployer', }); - expect(captured.stdoutText()).toContain('Deploying smart contract to Stellar Testnet.'); + expect(captured.stdoutText()).toContain( + 'Deploying smart contract to Stellar Testnet.', + ); expect(captured.stdoutText()).toContain('[stellar:deploy] CABC123'); }); @@ -101,7 +106,14 @@ describe('deploy command', () => { { label: 'stellar:deploy', command: 'stellar', - args: ['contract', 'deploy', '--source-account', 'alice', '--network', 'testnet'], + args: [ + 'contract', + 'deploy', + '--source-account', + 'alice', + '--network', + 'testnet', + ], cwd: '/workspace', }, ], From 2fa0f52fef7d95cdebd8e1a6a6779cafa1aa4901 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:45:41 +0100 Subject: [PATCH 4/6] style: format deployment project tests --- tests/deployment-project.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/deployment-project.test.ts b/tests/deployment-project.test.ts index c0f9d1a..5c1dc5d 100644 --- a/tests/deployment-project.test.ts +++ b/tests/deployment-project.test.ts @@ -33,7 +33,7 @@ describe('Testnet deployment plan', () => { ]); })); - it.each(['mainnet', 'futurenet', 'local', 'TESTNET', '']) ( + it.each(['mainnet', 'futurenet', 'local', 'TESTNET', ''])( 'rejects unsupported network %j before side effects', (network) => withTempDirectory((root) => { @@ -61,7 +61,8 @@ describe('Testnet deployment plan', () => { source, }), ).toThrow(ValidationCliError); - })); + }), + ); it('rejects unsupported project layouts', () => withTempDirectory((root) => { From 8f41160a99bb37a7213cdd3c4c314cb95063a28f Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:46:00 +0100 Subject: [PATCH 5/6] chore: remove CLI-033 formatting diagnostic --- .github/workflows/format-cli-033.yml | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/format-cli-033.yml diff --git a/.github/workflows/format-cli-033.yml b/.github/workflows/format-cli-033.yml deleted file mode 100644 index 9dfd5eb..0000000 --- a/.github/workflows/format-cli-033.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Diagnose CLI-033 formatting - -on: - push: - branches: - - feat/cli-033-testnet-deploy - -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/deploy-command.test.ts tests/deployment-project.test.ts - - run: git diff -- tests/deploy-command.test.ts tests/deployment-project.test.ts From 994aeba77911e05cfd11b535f562b8e5178d26e1 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:47:16 +0100 Subject: [PATCH 6/6] fix: reject raw Stellar StrKey deployment sources --- src/deployment/project.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/deployment/project.ts b/src/deployment/project.ts index a3cdabe..0a243ca 100644 --- a/src/deployment/project.ts +++ b/src/deployment/project.ts @@ -4,6 +4,7 @@ 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; @@ -20,7 +21,10 @@ export function resolveTestnetDeploymentPlan( ); } - if (!SAFE_IDENTITY_ALIAS.test(input.source)) { + 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.', );