diff --git a/src/generator/template-sources.ts b/src/generator/template-sources.ts index 3ed0cef..6690d5e 100644 --- a/src/generator/template-sources.ts +++ b/src/generator/template-sources.ts @@ -3,6 +3,7 @@ import { ValidationCliError } from '../errors/errors.js'; const BUNDLED_TEMPLATE_DIRECTORIES = { 'basic-app': 'basic-app', + 'full-stack': 'full-stack', } as const; type BundledTemplateId = keyof typeof BUNDLED_TEMPLATE_DIRECTORIES; diff --git a/templates/full-stack/.env.example b/templates/full-stack/.env.example new file mode 100644 index 0000000..8b501cf --- /dev/null +++ b/templates/full-stack/.env.example @@ -0,0 +1,4 @@ +STELLAR_NETWORK=TESTNET +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org +STELLAR_RPC_URL=https://soroban-testnet.stellar.org +BACKEND_PORT=3000 diff --git a/templates/full-stack/.gitignore b/templates/full-stack/.gitignore new file mode 100644 index 0000000..d7c8279 --- /dev/null +++ b/templates/full-stack/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +.env.* +!.env.example +.DS_Store +target/ +dist/ diff --git a/templates/full-stack/README.md b/templates/full-stack/README.md new file mode 100644 index 0000000..22398f4 --- /dev/null +++ b/templates/full-stack/README.md @@ -0,0 +1,44 @@ +# {{projectName}} + +Generated with the StellarForge `full-stack` template. + +## Structure + +```text +frontend/ Browser-facing application boundary and tests +backend/ Node.js API/runtime boundary and tests +contracts/ Soroban contract workspace boundary +``` + +The starter keeps these layers separate so application UI, server-side Stellar integration, and smart-contract code can evolve independently. + +## Requirements + +- Node.js 22.13 or newer + +## Validate the starter + +```bash +npm run check +npm test +``` + +These checks are dependency-free and do not require network access. + +## Run the backend + +```bash +npm run start:backend +``` + +The backend exposes `GET /health` on `127.0.0.1:3000` by default. + +## Environment + +Copy `.env.example` into your preferred local environment-management flow. The example contains public Stellar Testnet endpoints only and no credentials. + +Never commit wallet seeds, secret keys, signing credentials, production tokens, or private infrastructure credentials. + +## Contracts + +`contracts/` is intentionally a boundary rather than a fake production contract. Add reviewed Soroban code when the application domain is defined, or generate a dedicated StellarForge `smart-contract` starter for contract-focused work. diff --git a/templates/full-stack/backend/src/server.js b/templates/full-stack/backend/src/server.js new file mode 100644 index 0000000..01327ac --- /dev/null +++ b/templates/full-stack/backend/src/server.js @@ -0,0 +1,19 @@ +export function getBackendConfig(env = globalThis.process.env) { + return { + network: env.STELLAR_NETWORK ?? 'TESTNET', + horizonUrl: + env.STELLAR_HORIZON_URL ?? 'https://horizon-testnet.stellar.org', + rpcUrl: env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.org', + port: Number.parseInt(env.BACKEND_PORT ?? '3000', 10), + }; +} + +export function createHealthPayload(env = {}) { + const config = getBackendConfig(env); + + return { + status: 'ok', + app: '{{projectName}}', + network: config.network, + }; +} diff --git a/templates/full-stack/backend/src/start.js b/templates/full-stack/backend/src/start.js new file mode 100644 index 0000000..b2b6442 --- /dev/null +++ b/templates/full-stack/backend/src/start.js @@ -0,0 +1,16 @@ +import { createServer } from 'node:http'; +import { createHealthPayload, getBackendConfig } from './server.js'; + +const config = getBackendConfig(); +const server = createServer((request, response) => { + if (request.method === 'GET' && request.url === '/health') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(createHealthPayload(globalThis.process.env))); + return; + } + + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'not_found' })); +}); + +server.listen(config.port, '127.0.0.1'); diff --git a/templates/full-stack/backend/test/server.test.js b/templates/full-stack/backend/test/server.test.js new file mode 100644 index 0000000..592fe37 --- /dev/null +++ b/templates/full-stack/backend/test/server.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHealthPayload, getBackendConfig } from '../src/server.js'; + +test('uses safe Testnet defaults without secrets', () => { + const config = getBackendConfig({}); + + assert.equal(config.network, 'TESTNET'); + assert.equal(config.horizonUrl, 'https://horizon-testnet.stellar.org'); + assert.equal(config.rpcUrl, 'https://soroban-testnet.stellar.org'); + assert.equal(config.port, 3000); +}); + +test('returns a generated application health payload', () => { + assert.deepEqual(createHealthPayload({}), { + status: 'ok', + app: '{{projectName}}', + network: 'TESTNET', + }); +}); diff --git a/templates/full-stack/contracts/README.md b/templates/full-stack/contracts/README.md new file mode 100644 index 0000000..cf10663 --- /dev/null +++ b/templates/full-stack/contracts/README.md @@ -0,0 +1,16 @@ +# Contracts workspace + +This directory is the contract boundary for `{{projectName}}`. + +The Full Stack starter deliberately does not invent a production Soroban contract or embed signing credentials. Add reviewed Soroban contracts here when the application domain is defined, or start from StellarForge's dedicated `smart-contract` template. + +Recommended structure when contract development begins: + +```text +contracts/ + Cargo.toml + src/ + test/ +``` + +Keep network configuration and signing secrets outside source control. Contract build and deployment workflows should target Stellar Testnet until explicitly promoted through the project's release process. diff --git a/templates/full-stack/frontend/src/app.js b/templates/full-stack/frontend/src/app.js new file mode 100644 index 0000000..a2089d9 --- /dev/null +++ b/templates/full-stack/frontend/src/app.js @@ -0,0 +1,7 @@ +export function createAppModel() { + return { + name: '{{projectName}}', + network: 'Stellar Testnet', + backendHealthPath: '/health', + }; +} diff --git a/templates/full-stack/frontend/test/app.test.js b/templates/full-stack/frontend/test/app.test.js new file mode 100644 index 0000000..95b1c58 --- /dev/null +++ b/templates/full-stack/frontend/test/app.test.js @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAppModel } from '../src/app.js'; + +test('exposes the generated app identity and Testnet boundary', () => { + const model = createAppModel(); + + assert.equal(model.name, '{{projectName}}'); + assert.equal(model.network, 'Stellar Testnet'); + assert.equal(model.backendHealthPath, '/health'); +}); diff --git a/templates/full-stack/package.json b/templates/full-stack/package.json new file mode 100644 index 0000000..8c7bbfe --- /dev/null +++ b/templates/full-stack/package.json @@ -0,0 +1,14 @@ +{ + "name": "{{projectName}}", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "check": "node --check frontend/src/app.js && node --check backend/src/server.js && node --check backend/src/start.js", + "test": "node --test frontend/test/app.test.js backend/test/server.test.js", + "start:backend": "node backend/src/start.js" + }, + "engines": { + "node": ">=22.13.0" + } +} diff --git a/tests/executable.test.ts b/tests/executable.test.ts index 73e1609..9f22535 100644 --- a/tests/executable.test.ts +++ b/tests/executable.test.ts @@ -63,7 +63,7 @@ describe('StellarForge CLI executable', () => { expect(result.stdout).toMatch( /Summary: \d+ passed, \d+ warnings, \d+ failed\./, ); - }); + }, 15_000); it('prints the package version from the built executable', () => { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { diff --git a/tests/full-stack-template.test.ts b/tests/full-stack-template.test.ts new file mode 100644 index 0000000..d49c0ff --- /dev/null +++ b/tests/full-stack-template.test.ts @@ -0,0 +1,70 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; +import { createNewCommand } from '../src/commands/new.js'; +import { + createCapturedTerminalOutput, + withTempDirectory, +} from './helpers/index.js'; + +function runNpmScript(projectRoot: string, script: string) { + return spawnSync( + process.platform === 'win32' ? 'npm.cmd' : 'npm', + ['run', script], + { + cwd: projectRoot, + encoding: 'utf8', + shell: false, + }, + ); +} + +describe('Full Stack template', () => { + it('generates the documented frontend, backend, and contracts boundaries and passes smoke checks', () => + withTempDirectory((root) => { + const captured = createCapturedTerminalOutput(); + const command = createNewCommand({ + cwd: () => root, + output: captured.output, + }); + + command.parse(['node', 'new', 'demo-stack', '--template', 'full-stack']); + + const projectRoot = join(root, 'demo-stack'); + expect(existsSync(join(projectRoot, 'frontend', 'src', 'app.js'))).toBe( + true, + ); + expect(existsSync(join(projectRoot, 'backend', 'src', 'server.js'))).toBe( + true, + ); + expect(existsSync(join(projectRoot, 'contracts', 'README.md'))).toBe( + true, + ); + expect(captured.stdoutText()).toContain( + 'Created demo-stack from full-stack', + ); + + const environmentExample = readFileSync( + join(projectRoot, '.env.example'), + 'utf8', + ); + expect(environmentExample).toContain('STELLAR_NETWORK=TESTNET'); + expect(environmentExample).not.toMatch(/SECRET|SEED|PRIVATE_KEY/); + + const check = runNpmScript(projectRoot, 'check'); + expect(check.status, check.stderr).toBe(0); + + const test = runNpmScript(projectRoot, 'test'); + expect(test.status, test.stderr).toBe(0); + + const generatedReadme = readFileSync( + join(projectRoot, 'README.md'), + 'utf8', + ); + expect(generatedReadme).toContain('frontend/'); + expect(generatedReadme).toContain('backend/'); + expect(generatedReadme).toContain('contracts/'); + expect(generatedReadme).toContain('no credentials'); + })); +});