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..0a243ca --- /dev/null +++ b/src/deployment/project.ts @@ -0,0 +1,55 @@ +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}$/; +const STELLAR_ACCOUNT_OR_SECRET_STRKEY = /^[GS][A-Z2-7]{55}$/; + +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 ( + 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.', + ); + } + + 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..73a2b64 --- /dev/null +++ b/tests/deploy-command.test.ts @@ -0,0 +1,135 @@ +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..5c1dc5d --- /dev/null +++ b/tests/deployment-project.test.ts @@ -0,0 +1,77 @@ +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'); });