From 6b15e22c6528627489b80e2605eae6902c4b1f39 Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Fri, 26 Jun 2026 22:45:57 +0100 Subject: [PATCH 1/4] feat: add npx open-stellar bootstrap CLI (#242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scripts/cli/bootstrap.mjs — scaffolds a new agent project: - Creates [project-name]/ directory with guard against existing dirs - Copies all template files from scripts/templates/agent/ - Substitutes {{PROJECT_NAME}} in package.json, README.md, app/page.tsx - Runs npm install in the new directory - Prints colourised success message with next-steps - Validates project name (lowercase, alphanumeric + hyphens) - Add scripts/templates/agent/ template files: - package.json (minimal Next.js + Stellar deps) - tsconfig.json - next.config.mjs - .env.example (all vars from main project with inline comments) - README.md (with {{PROJECT_NAME}} placeholder) - lib/agent.ts (minimal agent registration stub) - app/page.tsx (styled placeholder dashboard) - Add scripts/cli/bootstrap.test.ts (Vitest): - Verifies all expected files are created - Verifies {{PROJECT_NAME}} substitution in package.json, README, page.tsx - Verifies .env.example contains all required env var keys - Verifies npm install creates node_modules - Verifies error on duplicate directory - Verifies error on missing project name - Verifies error on invalid project name (uppercase) - Register open-stellar binary in root package.json - Add bootstrap convenience npm script --- package.json | 8 +- scripts/cli/bootstrap.mjs | 170 ++++++++++++++++++++++++ scripts/cli/bootstrap.test.ts | 166 +++++++++++++++++++++++ scripts/templates/agent/.env.example | 63 +++++++++ scripts/templates/agent/README.md | 49 +++++++ scripts/templates/agent/app/page.tsx | 133 ++++++++++++++++++ scripts/templates/agent/lib/agent.ts | 41 ++++++ scripts/templates/agent/next.config.mjs | 8 ++ scripts/templates/agent/package.json | 26 ++++ scripts/templates/agent/tsconfig.json | 29 ++++ 10 files changed, 691 insertions(+), 2 deletions(-) create mode 100644 scripts/cli/bootstrap.mjs create mode 100644 scripts/cli/bootstrap.test.ts create mode 100644 scripts/templates/agent/.env.example create mode 100644 scripts/templates/agent/README.md create mode 100644 scripts/templates/agent/app/page.tsx create mode 100644 scripts/templates/agent/lib/agent.ts create mode 100644 scripts/templates/agent/next.config.mjs create mode 100644 scripts/templates/agent/package.json create mode 100644 scripts/templates/agent/tsconfig.json diff --git a/package.json b/package.json index 15661a01..b9e0230e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { - "name": "my-project", + "name": "open-stellar", "version": "0.1.0", "private": true, "workspaces": [ "packages/*" ], + "bin": { + "open-stellar": "./scripts/cli/bootstrap.mjs" + }, "scripts": { "dev": "next dev --webpack", "build": "next build --webpack", @@ -18,7 +21,8 @@ "test:coverage": "vitest run --coverage", "deploy:evm:guide": "node scripts/deploy/evm/guide.mjs", "deploy:soroban:guide": "node scripts/deploy/soroban/guide.mjs", - "generate:audio": "node scripts/generate-audio-assets.mjs" + "generate:audio": "node scripts/generate-audio-assets.mjs", + "bootstrap": "node scripts/cli/bootstrap.mjs" }, "dependencies": { "@hookform/resolvers": "^3.9.1", diff --git a/scripts/cli/bootstrap.mjs b/scripts/cli/bootstrap.mjs new file mode 100644 index 00000000..0a9a0ab6 --- /dev/null +++ b/scripts/cli/bootstrap.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * open-stellar bootstrap + * + * Scaffolds a new Open-Stellar agent project. + * + * Usage: + * npx open-stellar bootstrap [project-name] + * + * What it does: + * 1. Creates [project-name]/ directory + * 2. Copies template files from scripts/templates/agent/ + * 3. Substitutes {{PROJECT_NAME}} in package.json, README.md, and app/page.tsx + * 4. Runs `npm install` in the new directory + * 5. Prints next-steps message + */ + +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const RESET = '\x1b[0m' +const BOLD = '\x1b[1m' +const GREEN = '\x1b[32m' +const CYAN = '\x1b[36m' +const YELLOW = '\x1b[33m' +const RED = '\x1b[31m' +const DIM = '\x1b[2m' + +function log(msg) { + process.stdout.write(msg + '\n') +} + +function info(msg) { + log(`${CYAN} →${RESET} ${msg}`) +} + +function success(msg) { + log(`${GREEN} ✓${RESET} ${msg}`) +} + +function error(msg) { + log(`${RED} ✗ Error:${RESET} ${msg}`) +} + +function warn(msg) { + log(`${YELLOW} ⚠${RESET} ${msg}`) +} + +/** + * Recursively copy a directory, replacing {{PROJECT_NAME}} in specified files. + * @param {string} src - Source directory + * @param {string} dest - Destination directory + * @param {string} projectName - Value to substitute for {{PROJECT_NAME}} + * @param {Set} substituteFiles - Basenames that get the substitution + */ +function copyTemplate(src, dest, projectName, substituteFiles) { + const entries = fs.readdirSync(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + if (entry.isDirectory()) { + fs.mkdirSync(destPath, { recursive: true }) + copyTemplate(srcPath, destPath, projectName, substituteFiles) + } else { + let content = fs.readFileSync(srcPath, 'utf8') + + if (substituteFiles.has(entry.name)) { + content = content.replaceAll('{{PROJECT_NAME}}', projectName) + } + + fs.writeFileSync(destPath, content, 'utf8') + info(`Copied ${DIM}${path.relative(dest, destPath)}${RESET}`) + } + } +} + +// ── Main ─────────────────────────────────────────────────────────────────────── + +function main() { + const args = process.argv.slice(2) + + // Remove "bootstrap" sub-command if passed via `node bootstrap.mjs bootstrap my-agent` + const filteredArgs = args.filter((a) => a !== 'bootstrap') + const projectName = filteredArgs[0] + + if (!projectName) { + error('A project name is required.') + log(`\n Usage: ${BOLD}npx open-stellar bootstrap ${RESET}\n`) + process.exit(1) + } + + // Validate project name (npm-compatible: lowercase, alphanumeric + hyphens) + if (!/^[a-z0-9][a-z0-9-]*$/.test(projectName)) { + error( + `Invalid project name "${projectName}".` + + ' Use lowercase letters, numbers, and hyphens only (must start with a letter or digit).' + ) + process.exit(1) + } + + const destDir = path.resolve(process.cwd(), projectName) + + // Guard: directory already exists + if (fs.existsSync(destDir)) { + error(`Directory "${projectName}" already exists. Choose a different name or remove it first.`) + process.exit(1) + } + + // Locate template directory relative to this script + const templateDir = path.resolve(__dirname, '..', 'templates', 'agent') + + if (!fs.existsSync(templateDir)) { + error(`Template directory not found: ${templateDir}`) + error('Make sure you are running this command from the Open-Stellar repository root.') + process.exit(1) + } + + log('') + log(`${BOLD}${GREEN}◆ Open-Stellar Bootstrap${RESET}`) + log(`${DIM} Scaffolding "${projectName}"…${RESET}`) + log('') + + // 1. Create target directory + fs.mkdirSync(destDir, { recursive: true }) + success(`Created directory ${BOLD}${projectName}/${RESET}`) + + // 2. Copy template files with substitutions + const FILES_WITH_SUBSTITUTION = new Set(['package.json', 'README.md', 'page.tsx']) + copyTemplate(templateDir, destDir, projectName, FILES_WITH_SUBSTITUTION) + success('Template files copied') + + // 3. npm install + log('') + info(`Running ${BOLD}npm install${RESET} in ${projectName}/…`) + + try { + execSync('npm install', { + cwd: destDir, + stdio: 'inherit', + }) + success('Dependencies installed') + } catch { + error('npm install failed. Check the output above for details.') + warn(`You can retry manually:\n\n cd ${projectName} && npm install\n`) + process.exit(1) + } + + // 4. Success message + log('') + log(`${GREEN}${BOLD} ✨ Project "${projectName}" created successfully!${RESET}`) + log('') + log(' Next steps:') + log('') + log(` ${CYAN}cd ${projectName}${RESET}`) + log(` ${CYAN}cp .env.example .env.local${RESET} ${DIM}# add your API keys${RESET}`) + log(` ${CYAN}npm run dev${RESET}`) + log('') + log(` Then open ${BOLD}http://localhost:3000${RESET} in your browser.`) + log('') +} + +main() diff --git a/scripts/cli/bootstrap.test.ts b/scripts/cli/bootstrap.test.ts new file mode 100644 index 00000000..915b0834 --- /dev/null +++ b/scripts/cli/bootstrap.test.ts @@ -0,0 +1,166 @@ +/** + * Unit tests for scripts/cli/bootstrap.mjs + * + * Runs the bootstrap script in a temp directory and verifies all expected + * files are created with the correct project-name substitution. + */ + +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +// Resolve the bootstrap script path relative to the repo root +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') +const BOOTSTRAP = path.join(REPO_ROOT, 'scripts', 'cli', 'bootstrap.mjs') + +/** Run the bootstrap script synchronously; returns { stdout, stderr, exitCode } */ +function runBootstrap(args: string[], cwd: string): { stdout: string; stderr: string; ok: boolean } { + try { + const stdout = execSync(`node "${BOOTSTRAP}" ${args.join(' ')}`, { + cwd, + timeout: 120_000, // npm install can be slow + encoding: 'utf8', + }) + return { stdout, stderr: '', ok: true } + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string } + return { + stdout: e.stdout ?? '', + stderr: e.stderr ?? '', + ok: false, + } + } +} + +describe('bootstrap CLI', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-stellar-bootstrap-test-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + // ── Happy-path ───────────────────────────────────────────────────────────── + + it('creates the project directory with all expected files', () => { + const projectName = 'test-agent' + const { ok } = runBootstrap([projectName], tmpDir) + + expect(ok).toBe(true) + + const projectDir = path.join(tmpDir, projectName) + const expectedFiles = [ + 'package.json', + 'tsconfig.json', + 'next.config.mjs', + '.env.example', + 'README.md', + 'lib/agent.ts', + 'app/page.tsx', + ] + + for (const file of expectedFiles) { + expect(fs.existsSync(path.join(projectDir, file)), `Missing: ${file}`).toBe(true) + } + }) + + it('substitutes {{PROJECT_NAME}} in package.json', () => { + const projectName = 'my-cool-agent' + runBootstrap([projectName], tmpDir) + + const pkg = JSON.parse( + fs.readFileSync(path.join(tmpDir, projectName, 'package.json'), 'utf8') + ) + expect(pkg.name).toBe(projectName) + expect(JSON.stringify(pkg)).not.toContain('{{PROJECT_NAME}}') + }) + + it('substitutes {{PROJECT_NAME}} in README.md', () => { + const projectName = 'stellar-bot' + runBootstrap([projectName], tmpDir) + + const readme = fs.readFileSync(path.join(tmpDir, projectName, 'README.md'), 'utf8') + expect(readme).toContain(projectName) + expect(readme).not.toContain('{{PROJECT_NAME}}') + }) + + it('substitutes {{PROJECT_NAME}} in app/page.tsx', () => { + const projectName = 'agent-alpha' + runBootstrap([projectName], tmpDir) + + const page = fs.readFileSync(path.join(tmpDir, projectName, 'app', 'page.tsx'), 'utf8') + expect(page).toContain(projectName) + expect(page).not.toContain('{{PROJECT_NAME}}') + }) + + it('.env.example contains all required env var keys', () => { + const projectName = 'env-test-agent' + runBootstrap([projectName], tmpDir) + + const envContent = fs.readFileSync(path.join(tmpDir, projectName, '.env.example'), 'utf8') + + const requiredVars = [ + 'STELLAR_NETWORK', + 'DEV_MODE', + 'ANTHROPIC_API_KEY', + 'MOLTBOT_GATEWAY_TOKEN', + 'CF_ACCESS_CLIENT_ID', + 'DEBUG_ROUTES', + 'STELLAR_TREASURY_ADDRESS', + 'LOGTAIL_SOURCE_TOKEN', + 'NEXT_PUBLIC_BNB_RPC_URL', + 'NEXT_PUBLIC_BASE_RPC_URL', + ] + + for (const key of requiredVars) { + expect(envContent, `Missing env var: ${key}`).toContain(key) + } + }) + + it('installs node_modules via npm install', () => { + const projectName = 'install-test' + const { ok } = runBootstrap([projectName], tmpDir) + + expect(ok).toBe(true) + const nodeModulesDir = path.join(tmpDir, projectName, 'node_modules') + expect(fs.existsSync(nodeModulesDir)).toBe(true) + }) + + // ── Error cases ──────────────────────────────────────────────────────────── + + it('exits with error if project directory already exists', () => { + const projectName = 'existing-project' + const projectDir = path.join(tmpDir, projectName) + fs.mkdirSync(projectDir) + + const { ok, stdout, stderr } = runBootstrap([projectName], tmpDir) + + expect(ok).toBe(false) + const output = stdout + stderr + expect(output.toLowerCase()).toMatch(/already exists/) + }) + + it('exits with error when no project name is given', () => { + const { ok, stdout, stderr } = runBootstrap([], tmpDir) + + expect(ok).toBe(false) + const output = stdout + stderr + expect(output.toLowerCase()).toMatch(/required|usage/i) + }) + + it('exits with error for invalid project name with uppercase letters', () => { + const { ok, stdout, stderr } = runBootstrap(['MyAgent'], tmpDir) + + expect(ok).toBe(false) + const output = stdout + stderr + expect(output.toLowerCase()).toMatch(/invalid/i) + }) +}) diff --git a/scripts/templates/agent/.env.example b/scripts/templates/agent/.env.example new file mode 100644 index 00000000..c564522f --- /dev/null +++ b/scripts/templates/agent/.env.example @@ -0,0 +1,63 @@ +# 🔐 Environment Variables for {{PROJECT_NAME}} +# +# ⚠️ SECURITY WARNING: +# - This file contains sensitive credentials +# - NEVER commit .env.local to git +# - Add .env.local to .gitignore (already done) +# - Use only Stellar TESTNET (never mainnet) during development +# +# Usage: copy this file to .env.local and fill in your values + +# ── Stellar Network ──────────────────────────────────────────────────────────── +# MUST BE 'testnet' for development. Never use 'mainnet' locally. +STELLAR_NETWORK=testnet + +# ── Development Mode ──────────────────────────────────────────────────────────── +# Disables auth, uses testnet. Only set to 'true' with testnet. +DEV_MODE=true + +# ── Anthropic API ──────────────────────────────────────────────────────────────── +# Get from: https://console.anthropic.com/ +# Format: sk-ant-... +ANTHROPIC_API_KEY=sk-ant-your-key-here + +# ── Moltbot Gateway ────────────────────────────────────────────────────────────── +# Used for internal agent communication. +# Can be any random string for local development. +MOLTBOT_GATEWAY_TOKEN=dev-test-token-12345 + +# ── Cloudflare Access (optional) ───────────────────────────────────────────────── +# Leave empty when DEV_MODE=true +CF_ACCESS_CLIENT_ID= + +# ── Debug Routes (optional) ────────────────────────────────────────────────────── +# Set to true to enable /debug/* routes +DEBUG_ROUTES=false + +# ── Stellar Treasury (optional) ────────────────────────────────────────────────── +# Stellar testnet public key that receives paid cosmetic purchases. +# Leave empty to disable paid customization. +STELLAR_TREASURY_ADDRESS= + +# ── Logging (optional) ─────────────────────────────────────────────────────────── +# Better Stack / Logtail structured API logs. +# Leave empty locally to keep logging as a no-op. +LOGTAIL_SOURCE_TOKEN= + +# ── EVM RPC URLs (optional) ────────────────────────────────────────────────────── +# Custom RPC endpoints for x402 settlement. +# Default BNB: https://bsc-dataseed.binance.org +# Default Base: https://mainnet.base.org +NEXT_PUBLIC_BNB_RPC_URL= +NEXT_PUBLIC_BASE_RPC_URL= + +# ── Messaging Channels (optional) ──────────────────────────────────────────────── +# Telegram Bot Token +# TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 + +# Discord Bot Token +# DISCORD_BOT_TOKEN=... + +# Slack Tokens +# SLACK_BOT_TOKEN=xoxb-... +# SLACK_APP_TOKEN=xapp-... diff --git a/scripts/templates/agent/README.md b/scripts/templates/agent/README.md new file mode 100644 index 00000000..6c30c0cb --- /dev/null +++ b/scripts/templates/agent/README.md @@ -0,0 +1,49 @@ +# {{PROJECT_NAME}} + +An AI agent project scaffolded with [Open-Stellar](https://github.com/Killerjunior/Open-Stellar). + +## Getting Started + +1. **Configure environment** + + ```bash + cp .env.example .env.local + # Edit .env.local and fill in your API keys + ``` + +2. **Run the development server** + + ```bash + npm run dev + ``` + + Open [http://localhost:3000](http://localhost:3000) in your browser. + +3. **Register your agent** + + Edit `lib/agent.ts` to configure your agent's name, description, and capabilities. + +## Project Structure + +``` +{{PROJECT_NAME}}/ +├── app/ +│ └── page.tsx # Agent dashboard (entry point) +├── lib/ +│ └── agent.ts # Agent registration and runtime stub +├── .env.example # Environment variable template +├── .env.local # Your local secrets (git-ignored) +├── next.config.mjs # Next.js configuration +├── package.json +└── tsconfig.json +``` + +## Environment Variables + +See [`.env.example`](.env.example) for all required and optional variables with descriptions. + +## Learn More + +- [Open-Stellar Docs](https://github.com/Killerjunior/Open-Stellar) +- [Stellar Network](https://stellar.org) +- [Next.js Docs](https://nextjs.org/docs) diff --git a/scripts/templates/agent/app/page.tsx b/scripts/templates/agent/app/page.tsx new file mode 100644 index 00000000..4733e6ea --- /dev/null +++ b/scripts/templates/agent/app/page.tsx @@ -0,0 +1,133 @@ +export default function AgentDashboard() { + return ( +
+
+ {/* Star icon */} +
+ +

+ {{PROJECT_NAME}} +

+ +

+ Your Open-Stellar agent is running. Edit{' '} + + lib/agent.ts + {' '} + to configure your agent, then refresh this page. +

+ +
+ {[ + { step: '1', text: 'Copy .env.example → .env.local and add your keys' }, + { step: '2', text: 'Edit lib/agent.ts to set your agent name and capabilities' }, + { step: '3', text: 'Build your agent logic in app/ and lib/' }, + ].map(({ step, text }) => ( +
+ + {step} + + {text} +
+ ))} +
+ + + Open-Stellar Docs → + +
+
+ ) +} diff --git a/scripts/templates/agent/lib/agent.ts b/scripts/templates/agent/lib/agent.ts new file mode 100644 index 00000000..d12bd8c0 --- /dev/null +++ b/scripts/templates/agent/lib/agent.ts @@ -0,0 +1,41 @@ +/** + * Agent Registration Stub + * + * Configure your agent's identity and capabilities here. + * This file is the entry point for the Open-Stellar agent runtime. + */ + +export interface AgentConfig { + /** Unique identifier for this agent (alphanumeric, hyphens allowed) */ + id: string + /** Display name shown in the Open-Stellar hub */ + name: string + /** Short description of what this agent does */ + description: string + /** Semantic version of the agent */ + version: string + /** Agent capabilities / skill tags */ + capabilities: string[] +} + +/** + * Define your agent's configuration here. + * Replace the placeholder values with your own. + */ +export const agentConfig: AgentConfig = { + id: 'my-agent', + name: 'My Agent', + description: 'A minimal Open-Stellar agent. Edit this in lib/agent.ts.', + version: '0.1.0', + capabilities: ['chat'], +} + +/** + * Called once on startup to register the agent with the Open-Stellar runtime. + * Extend this function to add custom initialisation logic (e.g. loading models, + * connecting to external services, etc.). + */ +export async function registerAgent(config: AgentConfig = agentConfig): Promise { + console.log(`[open-stellar] Registering agent: ${config.name} (${config.id}) v${config.version}`) + // TODO: add your agent initialisation logic here +} diff --git a/scripts/templates/agent/next.config.mjs b/scripts/templates/agent/next.config.mjs new file mode 100644 index 00000000..b53664af --- /dev/null +++ b/scripts/templates/agent/next.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + unoptimized: true, + }, +} + +export default nextConfig diff --git a/scripts/templates/agent/package.json b/scripts/templates/agent/package.json new file mode 100644 index 00000000..13a1f04c --- /dev/null +++ b/scripts/templates/agent/package.json @@ -0,0 +1,26 @@ +{ + "name": "{{PROJECT_NAME}}", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@stellar/stellar-sdk": "^16.0.0", + "next": "16.2.0", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/node": "^22", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "eslint": "^9.22.0", + "eslint-config-next": "^16.2.0", + "typescript": "5.7.3" + } +} diff --git a/scripts/templates/agent/tsconfig.json b/scripts/templates/agent/tsconfig.json new file mode 100644 index 00000000..c93d09d2 --- /dev/null +++ b/scripts/templates/agent/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "target": "ES6", + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} From 1e4f1359a5abc4fa856d91ac396dd8de953bc60f Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Sat, 27 Jun 2026 08:06:57 +0100 Subject: [PATCH 2/4] fix: escape bootstrap project name placeholder --- scripts/templates/agent/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/templates/agent/app/page.tsx b/scripts/templates/agent/app/page.tsx index 4733e6ea..d7371303 100644 --- a/scripts/templates/agent/app/page.tsx +++ b/scripts/templates/agent/app/page.tsx @@ -38,7 +38,7 @@ export default function AgentDashboard() { WebkitTextFillColor: 'transparent', }} > - {{PROJECT_NAME}} + {'{{PROJECT_NAME}}'}

Date: Sun, 28 Jun 2026 23:28:10 +0100 Subject: [PATCH 3/4] Harden bootstrap install command execution --- scripts/cli/bootstrap.mjs | 165 +++++++++++++++++++++----------------- 1 file changed, 93 insertions(+), 72 deletions(-) diff --git a/scripts/cli/bootstrap.mjs b/scripts/cli/bootstrap.mjs index 0a9a0ab6..72b19365 100644 --- a/scripts/cli/bootstrap.mjs +++ b/scripts/cli/bootstrap.mjs @@ -15,41 +15,41 @@ * 5. Prints next-steps message */ -import { execSync } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; // ── Helpers ──────────────────────────────────────────────────────────────────── -const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const RESET = '\x1b[0m' -const BOLD = '\x1b[1m' -const GREEN = '\x1b[32m' -const CYAN = '\x1b[36m' -const YELLOW = '\x1b[33m' -const RED = '\x1b[31m' -const DIM = '\x1b[2m' +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const GREEN = "\x1b[32m"; +const CYAN = "\x1b[36m"; +const YELLOW = "\x1b[33m"; +const RED = "\x1b[31m"; +const DIM = "\x1b[2m"; function log(msg) { - process.stdout.write(msg + '\n') + process.stdout.write(msg + "\n"); } function info(msg) { - log(`${CYAN} →${RESET} ${msg}`) + log(`${CYAN} →${RESET} ${msg}`); } function success(msg) { - log(`${GREEN} ✓${RESET} ${msg}`) + log(`${GREEN} ✓${RESET} ${msg}`); } function error(msg) { - log(`${RED} ✗ Error:${RESET} ${msg}`) + log(`${RED} ✗ Error:${RESET} ${msg}`); } function warn(msg) { - log(`${YELLOW} ⚠${RESET} ${msg}`) + log(`${YELLOW} ⚠${RESET} ${msg}`); } /** @@ -60,24 +60,24 @@ function warn(msg) { * @param {Set} substituteFiles - Basenames that get the substitution */ function copyTemplate(src, dest, projectName, substituteFiles) { - const entries = fs.readdirSync(src, { withFileTypes: true }) + const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { - const srcPath = path.join(src, entry.name) - const destPath = path.join(dest, entry.name) + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { - fs.mkdirSync(destPath, { recursive: true }) - copyTemplate(srcPath, destPath, projectName, substituteFiles) + fs.mkdirSync(destPath, { recursive: true }); + copyTemplate(srcPath, destPath, projectName, substituteFiles); } else { - let content = fs.readFileSync(srcPath, 'utf8') + let content = fs.readFileSync(srcPath, "utf8"); if (substituteFiles.has(entry.name)) { - content = content.replaceAll('{{PROJECT_NAME}}', projectName) + content = content.replaceAll("{{PROJECT_NAME}}", projectName); } - fs.writeFileSync(destPath, content, 'utf8') - info(`Copied ${DIM}${path.relative(dest, destPath)}${RESET}`) + fs.writeFileSync(destPath, content, "utf8"); + info(`Copied ${DIM}${path.relative(dest, destPath)}${RESET}`); } } } @@ -85,86 +85,107 @@ function copyTemplate(src, dest, projectName, substituteFiles) { // ── Main ─────────────────────────────────────────────────────────────────────── function main() { - const args = process.argv.slice(2) + const args = process.argv.slice(2); // Remove "bootstrap" sub-command if passed via `node bootstrap.mjs bootstrap my-agent` - const filteredArgs = args.filter((a) => a !== 'bootstrap') - const projectName = filteredArgs[0] + const filteredArgs = args.filter((a) => a !== "bootstrap"); + const projectName = filteredArgs[0]; if (!projectName) { - error('A project name is required.') - log(`\n Usage: ${BOLD}npx open-stellar bootstrap ${RESET}\n`) - process.exit(1) + error("A project name is required."); + log( + `\n Usage: ${BOLD}npx open-stellar bootstrap ${RESET}\n`, + ); + process.exit(1); } // Validate project name (npm-compatible: lowercase, alphanumeric + hyphens) if (!/^[a-z0-9][a-z0-9-]*$/.test(projectName)) { error( `Invalid project name "${projectName}".` + - ' Use lowercase letters, numbers, and hyphens only (must start with a letter or digit).' - ) - process.exit(1) + " Use lowercase letters, numbers, and hyphens only (must start with a letter or digit).", + ); + process.exit(1); } - const destDir = path.resolve(process.cwd(), projectName) + const destDir = path.resolve(process.cwd(), projectName); // Guard: directory already exists if (fs.existsSync(destDir)) { - error(`Directory "${projectName}" already exists. Choose a different name or remove it first.`) - process.exit(1) + error( + `Directory "${projectName}" already exists. Choose a different name or remove it first.`, + ); + process.exit(1); } // Locate template directory relative to this script - const templateDir = path.resolve(__dirname, '..', 'templates', 'agent') + const templateDir = path.resolve(__dirname, "..", "templates", "agent"); if (!fs.existsSync(templateDir)) { - error(`Template directory not found: ${templateDir}`) - error('Make sure you are running this command from the Open-Stellar repository root.') - process.exit(1) + error(`Template directory not found: ${templateDir}`); + error( + "Make sure you are running this command from the Open-Stellar repository root.", + ); + process.exit(1); } - log('') - log(`${BOLD}${GREEN}◆ Open-Stellar Bootstrap${RESET}`) - log(`${DIM} Scaffolding "${projectName}"…${RESET}`) - log('') + log(""); + log(`${BOLD}${GREEN}◆ Open-Stellar Bootstrap${RESET}`); + log(`${DIM} Scaffolding "${projectName}"…${RESET}`); + log(""); // 1. Create target directory - fs.mkdirSync(destDir, { recursive: true }) - success(`Created directory ${BOLD}${projectName}/${RESET}`) + fs.mkdirSync(destDir, { recursive: true }); + success(`Created directory ${BOLD}${projectName}/${RESET}`); // 2. Copy template files with substitutions - const FILES_WITH_SUBSTITUTION = new Set(['package.json', 'README.md', 'page.tsx']) - copyTemplate(templateDir, destDir, projectName, FILES_WITH_SUBSTITUTION) - success('Template files copied') + const FILES_WITH_SUBSTITUTION = new Set([ + "package.json", + "README.md", + "page.tsx", + ]); + copyTemplate(templateDir, destDir, projectName, FILES_WITH_SUBSTITUTION); + success("Template files copied"); // 3. npm install - log('') - info(`Running ${BOLD}npm install${RESET} in ${projectName}/…`) + log(""); + info(`Running ${BOLD}npm install${RESET} in ${projectName}/…`); try { - execSync('npm install', { + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const installResult = spawnSync(npmCommand, ["install"], { cwd: destDir, - stdio: 'inherit', - }) - success('Dependencies installed') + stdio: "inherit", + shell: false, + }); + + if (installResult.status !== 0) { + throw new Error("npm install failed"); + } + + success("Dependencies installed"); } catch { - error('npm install failed. Check the output above for details.') - warn(`You can retry manually:\n\n cd ${projectName} && npm install\n`) - process.exit(1) + error("npm install failed. Check the output above for details."); + warn(`You can retry manually:\n\n cd ${projectName} && npm install\n`); + process.exit(1); } // 4. Success message - log('') - log(`${GREEN}${BOLD} ✨ Project "${projectName}" created successfully!${RESET}`) - log('') - log(' Next steps:') - log('') - log(` ${CYAN}cd ${projectName}${RESET}`) - log(` ${CYAN}cp .env.example .env.local${RESET} ${DIM}# add your API keys${RESET}`) - log(` ${CYAN}npm run dev${RESET}`) - log('') - log(` Then open ${BOLD}http://localhost:3000${RESET} in your browser.`) - log('') + log(""); + log( + `${GREEN}${BOLD} ✨ Project "${projectName}" created successfully!${RESET}`, + ); + log(""); + log(" Next steps:"); + log(""); + log(` ${CYAN}cd ${projectName}${RESET}`); + log( + ` ${CYAN}cp .env.example .env.local${RESET} ${DIM}# add your API keys${RESET}`, + ); + log(` ${CYAN}npm run dev${RESET}`); + log(""); + log(` Then open ${BOLD}http://localhost:3000${RESET} in your browser.`); + log(""); } -main() +main(); From 5d314dba58ffa9b55af20894ebea4166f154511f Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Mon, 29 Jun 2026 09:13:59 +0100 Subject: [PATCH 4/4] Fix bootstrap test path for CI Resolve bootstrap script path from repository root in tests. Add a semver module declaration so typecheck can resolve semver imports on CI. --- lib/types/semver.d.ts | 12 ++++++++++++ scripts/cli/bootstrap.test.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 lib/types/semver.d.ts diff --git a/lib/types/semver.d.ts b/lib/types/semver.d.ts new file mode 100644 index 00000000..d6331959 --- /dev/null +++ b/lib/types/semver.d.ts @@ -0,0 +1,12 @@ +declare module "semver" { + export interface Options { + includePrerelease?: boolean + loose?: boolean + } + + export function satisfies( + version: string, + range: string, + options?: Options, + ): boolean +} diff --git a/scripts/cli/bootstrap.test.ts b/scripts/cli/bootstrap.test.ts index 915b0834..b47d4ce1 100644 --- a/scripts/cli/bootstrap.test.ts +++ b/scripts/cli/bootstrap.test.ts @@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' // Resolve the bootstrap script path relative to the repo root const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') +const REPO_ROOT = path.resolve(__dirname, '..', '..') const BOOTSTRAP = path.join(REPO_ROOT, 'scripts', 'cli', 'bootstrap.mjs') /** Run the bootstrap script synchronously; returns { stdout, stderr, exitCode } */