Skip to content
Closed
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
12 changes: 12 additions & 0 deletions lib/types/semver.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
declare module "semver" {
export interface Options {
includePrerelease?: boolean
loose?: boolean
}

export function satisfies(
version: string,
range: string,
options?: Options,
): boolean
}
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
191 changes: 191 additions & 0 deletions scripts/cli/bootstrap.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/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 { 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 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<string>} 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");

Check warning on line 91 in scripts/cli/bootstrap.mjs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `.find(…)` over `.filter(…)`.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8GAagy0zpM3Lpng-qz&open=AZ8GAagy0zpM3Lpng-qz&pullRequest=307
const projectName = filteredArgs[0];

if (!projectName) {
error("A project name is required.");
log(
`\n Usage: ${BOLD}npx open-stellar bootstrap <project-name>${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 {
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const installResult = spawnSync(npmCommand, ["install"], {
cwd: destDir,
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);
}

// 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();
166 changes: 166 additions & 0 deletions scripts/cli/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {

Check failure on line 53 in scripts/cli/bootstrap.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck, tests, build, and guards

scripts/cli/bootstrap.test.ts > bootstrap CLI > creates the project directory with all expected files

Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ scripts/cli/bootstrap.test.ts:53:3
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', () => {

Check failure on line 75 in scripts/cli/bootstrap.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck, tests, build, and guards

scripts/cli/bootstrap.test.ts > bootstrap CLI > substitutes {{PROJECT_NAME}} in package.json

Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ scripts/cli/bootstrap.test.ts:75:3
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', () => {

Check failure on line 86 in scripts/cli/bootstrap.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck, tests, build, and guards

scripts/cli/bootstrap.test.ts > bootstrap CLI > substitutes {{PROJECT_NAME}} in README.md

Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ scripts/cli/bootstrap.test.ts:86:3
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', () => {

Check failure on line 95 in scripts/cli/bootstrap.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck, tests, build, and guards

scripts/cli/bootstrap.test.ts > bootstrap CLI > substitutes {{PROJECT_NAME}} in app/page.tsx

Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ scripts/cli/bootstrap.test.ts:95:3
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', () => {

Check failure on line 104 in scripts/cli/bootstrap.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck, tests, build, and guards

scripts/cli/bootstrap.test.ts > bootstrap CLI > .env.example contains all required env var keys

Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ scripts/cli/bootstrap.test.ts:104:3
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', () => {

Check failure on line 128 in scripts/cli/bootstrap.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck, tests, build, and guards

scripts/cli/bootstrap.test.ts > bootstrap CLI > installs node_modules via npm install

Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ scripts/cli/bootstrap.test.ts:128:3
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)
})
})
Loading
Loading