Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` | 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 <name>` | 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 <identity>` | 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
Expand All @@ -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.
52 changes: 52 additions & 0 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -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 <network>',
'Deployment network. MVP requires the explicit value `testnet`.',
)
.requiredOption(
'--source <identity>',
'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 });
});
}
4 changes: 3 additions & 1 deletion src/commands/root.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,5 +21,6 @@ export function createRootCommand(version = resolveCliVersion()): Command {
.addCommand(createDoctorCommand())
.addCommand(createNewCommand())
.addCommand(createDevCommand())
.addCommand(createTestCommand());
.addCommand(createTestCommand())
.addCommand(createDeployCommand());
}
55 changes: 55 additions & 0 deletions src/deployment/project.ts
Original file line number Diff line number Diff line change
@@ -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,
},
];
}
135 changes: 135 additions & 0 deletions tests/deploy-command.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
77 changes: 77 additions & 0 deletions tests/deployment-project.test.ts
Original file line number Diff line number Diff line change
@@ -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');
}));
});
Loading
Loading