diff --git a/.stxforge/plugins.json b/.stxforge/plugins.json new file mode 100644 index 0000000..aac0610 --- /dev/null +++ b/.stxforge/plugins.json @@ -0,0 +1,3 @@ +{ + "plugins": {} +} diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md new file mode 100644 index 0000000..999c154 --- /dev/null +++ b/docs/plugin-authoring.md @@ -0,0 +1,125 @@ +# Writing a stxforge Plugin + +This guide explains how to create, publish, and use a community plugin for stxforge. + +## Quick Start + +```bash +mkdir stxforge-plugin-my-template +cd stxforge-plugin-my-template +npm init +``` + +## Plugin Package Structure + +``` +stxforge-plugin-my-template/ +├── package.json # declares stxforge.plugin = true +├── index.js # plugin entry point +├── templates/ +│ └── my-template/ +│ ├── schema.json # input prompt definition +│ ├── contract.clar.hbs # Handlebars contract template +│ └── test.ts.hbs # optional test template +└── README.md +``` + +## package.json Requirements + +```json +{ + "name": "stxforge-plugin-my-template", + "version": "1.0.0", + "stxforge": { + "plugin": true, + "verified": false, + "templates": ["my-template"] + } +} +``` + +- Name **must** start with `stxforge-plugin-` +- `stxforge.plugin` **must** be `true` +- `stxforge.templates` lists template directory names + +## schema.json Format + +```json +{ + "name": "my-template", + "description": "What this template generates", + "prompts": [ + { + "name": "tokenName", + "type": "input", + "message": "Token name:" + }, + { + "name": "decimals", + "type": "number", + "message": "Decimals:", + "default": 6 + }, + { + "name": "mintable", + "type": "confirm", + "message": "Enable minting?", + "default": false + } + ] +} +``` + +## Handlebars Templates + +Use `{{variableName}}` to inject prompt answers into your `.clar.hbs` file: + +```clarity +;; {{tokenName}} Contract +;; Generated by stxforge-plugin-my-template + +(define-fungible-token {{contractName}}) + +(define-constant DECIMALS u{{decimals}}) + +{{#if mintable}} +(define-public (mint (amount uint) (recipient principal)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) (err u100)) + (ft-mint? {{contractName}} amount recipient))) +{{/if}} +``` + +Available context variables: +- All prompt `name` fields as-is +- `contractName` — auto-generated from `name` (lowercased, hyphenated) + +## Trust Model + +| Badge | Meaning | +|-------|---------| +| `[verified]` | Published by the stxforge core team or audited partners | +| `[community]` | Published by the community — review before using | + +To request verified status, open a PR in the stxforge GitHub org. + +## Publishing + +```bash +npm publish --access public +``` + +Users install via: + +```bash +stxforge plugin install my-template +# or +stxforge plugin install stxforge-plugin-my-template +``` + +## Security Guidelines + +- Never access the filesystem outside the project directory +- Do not make network requests from templates +- Do not `require` arbitrary user-supplied modules +- All prompts should have sensible defaults diff --git a/package.json b/package.json index f50f00c..bdee524 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "dependencies": { "chalk": "^5.3.0", "commander": "^11.1.0", - "inquirer": "^9.2.12", "fs-extra": "^11.2.0", "handlebars": "^4.7.8", "ora": "^7.0.1" @@ -22,6 +21,6 @@ "devDependencies": { "vitest": "^1.2.0" }, - "keywords": ["stacks", "clarity", "blockchain", "cli", "scaffolding"], + "keywords": ["stacks", "clarity", "blockchain", "cli", "scaffolding", "plugins"], "license": "MIT" } diff --git a/plugins/stxforge-plugin-bonding-curve/index.js b/plugins/stxforge-plugin-bonding-curve/index.js new file mode 100644 index 0000000..ebff0cb --- /dev/null +++ b/plugins/stxforge-plugin-bonding-curve/index.js @@ -0,0 +1,5 @@ +module.exports = { + name: 'stxforge-plugin-bonding-curve', + version: require('./package.json').version, + templates: ['bonding-curve'], +}; diff --git a/plugins/stxforge-plugin-bonding-curve/package.json b/plugins/stxforge-plugin-bonding-curve/package.json new file mode 100644 index 0000000..e292388 --- /dev/null +++ b/plugins/stxforge-plugin-bonding-curve/package.json @@ -0,0 +1,13 @@ +{ + "name": "stxforge-plugin-bonding-curve", + "version": "1.0.0", + "description": "Bonding curve token contract template for stxforge", + "main": "index.js", + "stxforge": { + "plugin": true, + "verified": true, + "templates": ["bonding-curve"] + }, + "keywords": ["stxforge-plugin", "stacks", "clarity", "bonding-curve", "defi"], + "license": "MIT" +} diff --git a/plugins/stxforge-plugin-bonding-curve/templates/bonding-curve/contract.clar.hbs b/plugins/stxforge-plugin-bonding-curve/templates/bonding-curve/contract.clar.hbs new file mode 100644 index 0000000..b042f59 --- /dev/null +++ b/plugins/stxforge-plugin-bonding-curve/templates/bonding-curve/contract.clar.hbs @@ -0,0 +1,57 @@ +;; {{name}} — Bonding Curve Token +;; Generated by stxforge-plugin-bonding-curve +;; Price function: price = slope * supply^2 + +(define-fungible-token {{contractName}}) + +(define-constant CONTRACT-OWNER tx-sender) +(define-constant ERR-OWNER-ONLY (err u100)) +(define-constant ERR-INSUFFICIENT-STX (err u101)) +(define-constant ERR-ZERO-AMOUNT (err u102)) +(define-constant SLOPE u{{slope}}) + +(define-data-var total-reserve uint u0) + +;; Price to buy `amount` tokens given current supply +(define-read-only (get-buy-price (amount uint)) + (let ( + (supply (ft-get-supply {{contractName}})) + (new-supply (+ supply amount)) + ) + ;; Integral of slope*x from supply to new-supply = slope*(new^2 - old^2)/2 + (ok (/ (* SLOPE (- (* new-supply new-supply) (* supply supply))) u2)) + ) +) + +;; Price received for selling `amount` tokens +(define-read-only (get-sell-price (amount uint)) + (let ( + (supply (ft-get-supply {{contractName}})) + (new-supply (- supply amount)) + ) + (ok (/ (* SLOPE (- (* supply supply) (* new-supply new-supply))) u2)) + ) +) + +;; Buy tokens by sending STX +(define-public (buy (amount uint)) + (let ((price (unwrap! (get-buy-price amount) (err u500)))) + (asserts! (> amount u0) ERR-ZERO-AMOUNT) + (try! (stx-transfer? price tx-sender (as-contract tx-sender))) + (var-set total-reserve (+ (var-get total-reserve) price)) + (ft-mint? {{contractName}} amount tx-sender) + ) +) + +;; Sell tokens and receive STX +(define-public (sell (amount uint)) + (let ((proceeds (unwrap! (get-sell-price amount) (err u500)))) + (asserts! (> amount u0) ERR-ZERO-AMOUNT) + (try! (ft-burn? {{contractName}} amount tx-sender)) + (var-set total-reserve (- (var-get total-reserve) proceeds)) + (as-contract (stx-transfer? proceeds tx-sender tx-sender)) + ) +) + +(define-read-only (get-reserve) (ok (var-get total-reserve))) +(define-read-only (get-supply) (ok (ft-get-supply {{contractName}}))) diff --git a/plugins/stxforge-plugin-bonding-curve/templates/bonding-curve/schema.json b/plugins/stxforge-plugin-bonding-curve/templates/bonding-curve/schema.json new file mode 100644 index 0000000..39ca87a --- /dev/null +++ b/plugins/stxforge-plugin-bonding-curve/templates/bonding-curve/schema.json @@ -0,0 +1,29 @@ +{ + "name": "bonding-curve", + "description": "A token with an automated market maker using a bonding curve price function", + "prompts": [ + { + "name": "name", + "type": "input", + "message": "Token name:", + "validate": "length >= 2" + }, + { + "name": "symbol", + "type": "input", + "message": "Token symbol (uppercase):" + }, + { + "name": "reserveToken", + "type": "input", + "message": "Reserve token contract (e.g. .wrapped-stx):", + "default": ".wrapped-stx" + }, + { + "name": "slope", + "type": "number", + "message": "Curve slope (integer, higher = steeper price growth):", + "default": 1 + } + ] +} diff --git a/plugins/stxforge-plugin-subscription/index.js b/plugins/stxforge-plugin-subscription/index.js new file mode 100644 index 0000000..18423b0 --- /dev/null +++ b/plugins/stxforge-plugin-subscription/index.js @@ -0,0 +1,5 @@ +module.exports = { + name: 'stxforge-plugin-subscription', + version: require('./package.json').version, + templates: ['subscription'], +}; diff --git a/plugins/stxforge-plugin-subscription/package.json b/plugins/stxforge-plugin-subscription/package.json new file mode 100644 index 0000000..0a51f4d --- /dev/null +++ b/plugins/stxforge-plugin-subscription/package.json @@ -0,0 +1,13 @@ +{ + "name": "stxforge-plugin-subscription", + "version": "1.0.0", + "description": "Recurring subscription payment contract template for stxforge", + "main": "index.js", + "stxforge": { + "plugin": true, + "verified": true, + "templates": ["subscription"] + }, + "keywords": ["stxforge-plugin", "stacks", "clarity", "subscription", "payments"], + "license": "MIT" +} diff --git a/plugins/stxforge-plugin-subscription/templates/subscription/contract.clar.hbs b/plugins/stxforge-plugin-subscription/templates/subscription/contract.clar.hbs new file mode 100644 index 0000000..3372556 --- /dev/null +++ b/plugins/stxforge-plugin-subscription/templates/subscription/contract.clar.hbs @@ -0,0 +1,69 @@ +;; {{name}} Subscription — On-chain Recurring Payments +;; Generated by stxforge-plugin-subscription + +(define-constant CONTRACT-OWNER tx-sender) +(define-constant PRICE u{{pricePerPeriod}}) +(define-constant PERIOD u{{periodBlocks}}) +(define-constant GRACE-PERIOD u{{gracePeriod}}) +(define-constant ERR-OWNER-ONLY (err u100)) +(define-constant ERR-NOT-SUB (err u101)) +(define-constant ERR-ALREADY-SUB (err u102)) +(define-constant ERR-EXPIRED (err u103)) + +;; subscriber -> expiry block height +(define-map subscriptions principal uint) + +;; Subscribe for one billing period +(define-public (subscribe) + (let ( + (expiry (+ block-height PERIOD)) + (existing (map-get? subscriptions tx-sender)) + ) + (asserts! (is-none existing) ERR-ALREADY-SUB) + (try! (stx-transfer? PRICE tx-sender CONTRACT-OWNER)) + (map-set subscriptions tx-sender expiry) + (ok expiry) + ) +) + +;; Renew an existing subscription for one more period +(define-public (renew) + (let ( + (current-expiry (unwrap! (map-get? subscriptions tx-sender) ERR-NOT-SUB)) + (new-expiry (+ (max current-expiry block-height) PERIOD)) + ) + (try! (stx-transfer? PRICE tx-sender CONTRACT-OWNER)) + (map-set subscriptions tx-sender new-expiry) + (ok new-expiry) + ) +) + +;; Cancel and remove subscription record +(define-public (cancel) + (begin + (asserts! (is-some (map-get? subscriptions tx-sender)) ERR-NOT-SUB) + (map-delete subscriptions tx-sender) + (ok true) + ) +) + +;; Check if a principal has an active subscription (including grace period) +(define-read-only (is-active (subscriber principal)) + (match (map-get? subscriptions subscriber) + expiry (ok (<= block-height (+ expiry GRACE-PERIOD))) + (ok false) + ) +) + +;; Get subscription expiry block for a principal +(define-read-only (get-expiry (subscriber principal)) + (ok (map-get? subscriptions subscriber)) +) + +;; Owner withdraws accumulated STX +(define-public (withdraw (amount uint)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-OWNER-ONLY) + (as-contract (stx-transfer? amount tx-sender CONTRACT-OWNER)) + ) +) diff --git a/plugins/stxforge-plugin-subscription/templates/subscription/schema.json b/plugins/stxforge-plugin-subscription/templates/subscription/schema.json new file mode 100644 index 0000000..f60d521 --- /dev/null +++ b/plugins/stxforge-plugin-subscription/templates/subscription/schema.json @@ -0,0 +1,30 @@ +{ + "name": "subscription", + "description": "On-chain recurring subscription payments in STX with configurable billing period", + "prompts": [ + { + "name": "name", + "type": "input", + "message": "Service name:", + "validate": "length >= 2" + }, + { + "name": "pricePerPeriod", + "type": "number", + "message": "Price per billing period (in uSTX):", + "default": 1000000 + }, + { + "name": "periodBlocks", + "type": "number", + "message": "Billing period in blocks (e.g. 4320 = ~30 days):", + "default": 4320 + }, + { + "name": "gracePeriod", + "type": "number", + "message": "Grace period in blocks before access is revoked:", + "default": 144 + } + ] +} diff --git a/src/cli.js b/src/cli.js index 38aeb59..2a868dd 100644 --- a/src/cli.js +++ b/src/cli.js @@ -2,21 +2,58 @@ const { program } = require('commander'); const chalk = require('chalk'); -const { audit } = require('./commands/audit'); +const { pluginInstall, pluginUninstall, pluginList, pluginSearch } = require('./commands/plugin'); program .name('stxforge') .description('Smart contract scaffolding CLI for the Stacks ecosystem') .version('1.0.0'); -// stxforge audit [--json] -program - .command('audit') - .description('Run Clarity security checklist against contracts/ directory') - .option('--json', 'Output results as JSON (machine-readable)') - .action(async (opts) => { +// stxforge plugin +const plugin = program.command('plugin').description('Manage stxforge plugins'); + +plugin + .command('install ') + .description('Install a stxforge plugin from npm') + .action(async (name) => { + try { + await pluginInstall(name); + } catch (err) { + console.error(chalk.red('Error:'), err.message); + process.exit(1); + } + }); + +plugin + .command('uninstall ') + .description('Uninstall a stxforge plugin') + .action(async (name) => { + try { + await pluginUninstall(name); + } catch (err) { + console.error(chalk.red('Error:'), err.message); + process.exit(1); + } + }); + +plugin + .command('list') + .description('List all installed plugins') + .action(async () => { + try { + await pluginList(); + } catch (err) { + console.error(chalk.red('Error:'), err.message); + process.exit(1); + } + }); + +plugin + .command('search ') + .description('Search npm for stxforge plugins') + .action(async (keyword) => { try { - await audit(opts); + await pluginSearch(keyword); } catch (err) { console.error(chalk.red('Error:'), err.message); process.exit(1); diff --git a/src/commands/plugin.js b/src/commands/plugin.js new file mode 100644 index 0000000..bc9127b --- /dev/null +++ b/src/commands/plugin.js @@ -0,0 +1,92 @@ +const chalk = require('chalk'); +const ora = require('ora'); +const { installNpmPackage, uninstallNpmPackage, getInstalledVersion } = require('../plugin/npm-installer'); +const { loadPlugin } = require('../plugin/plugin-loader'); +const { + addPluginToManifest, + removePluginFromManifest, + listInstalledPlugins, + isPluginInstalled, +} = require('../plugin/plugin-manager'); + +async function pluginInstall(name) { + const fullName = name.startsWith('stxforge-plugin-') ? name : `stxforge-plugin-${name}`; + console.log(chalk.cyan(`\n✦ Installing plugin: ${fullName}\n`)); + + if (await isPluginInstalled(fullName)) { + console.log(chalk.yellow(`Plugin "${fullName}" is already installed.`)); + return; + } + + const spinner = ora('Installing from npm...').start(); + try { + installNpmPackage(fullName); + spinner.stop(); + + // Validate and load plugin metadata + const plugin = await loadPlugin(fullName); + const version = getInstalledVersion(fullName) || 'unknown'; + + await addPluginToManifest(fullName, version, { + verified: plugin.verified, + templates: plugin.templates.map((t) => t.name), + }); + + console.log(chalk.green(`✓ Plugin "${fullName}@${version}" installed successfully`)); + if (plugin.templates.length > 0) { + console.log(chalk.dim(` Templates: ${plugin.templates.map((t) => t.name).join(', ')}`)); + } + } catch (err) { + spinner.fail(chalk.red('Installation failed')); + throw err; + } +} + +async function pluginUninstall(name) { + const fullName = name.startsWith('stxforge-plugin-') ? name : `stxforge-plugin-${name}`; + console.log(chalk.cyan(`\n✦ Uninstalling plugin: ${fullName}\n`)); + + if (!(await isPluginInstalled(fullName))) { + console.log(chalk.yellow(`Plugin "${fullName}" is not installed.`)); + return; + } + + const spinner = ora('Removing...').start(); + try { + uninstallNpmPackage(fullName); + await removePluginFromManifest(fullName); + spinner.succeed(chalk.green(`Plugin "${fullName}" uninstalled.`)); + } catch (err) { + spinner.fail(chalk.red('Uninstall failed')); + throw err; + } +} + +async function pluginList() { + const plugins = await listInstalledPlugins(); + + console.log(chalk.cyan('\n✦ Installed stxforge plugins\n')); + + if (plugins.length === 0) { + console.log(chalk.dim(' No plugins installed.')); + console.log(chalk.dim(' Install one with: stxforge plugin install \n')); + return; + } + + for (const plugin of plugins) { + const badge = plugin.verified ? chalk.green('[verified]') : chalk.dim('[community]'); + const templates = plugin.templates.length > 0 + ? chalk.dim(` templates: ${plugin.templates.join(', ')}`) + : ''; + console.log(` ${chalk.bold(plugin.name)}@${plugin.version} ${badge}${templates}`); + } + console.log(''); +} + +async function pluginSearch(keyword) { + console.log(chalk.cyan(`\n✦ Searching npm for stxforge-plugin-${keyword}\n`)); + console.log(chalk.dim(' Visit https://www.npmjs.com/search?q=stxforge-plugin to browse all plugins')); + console.log(chalk.dim(` Or run: npm search stxforge-plugin-${keyword}\n`)); +} + +module.exports = { pluginInstall, pluginUninstall, pluginList, pluginSearch }; diff --git a/src/plugin/index.js b/src/plugin/index.js new file mode 100644 index 0000000..e4edebb --- /dev/null +++ b/src/plugin/index.js @@ -0,0 +1,27 @@ +const { loadPlugin } = require('./plugin-loader'); +const { renderPluginTemplate, writePluginOutput } = require('./template-renderer'); +const { validatePluginPackageJson, validateTemplateSchema } = require('./schema-validator'); +const { + loadPluginsManifest, + addPluginToManifest, + removePluginFromManifest, + listInstalledPlugins, + isPluginInstalled, +} = require('./plugin-manager'); +const { installNpmPackage, uninstallNpmPackage, getInstalledVersion } = require('./npm-installer'); + +module.exports = { + loadPlugin, + renderPluginTemplate, + writePluginOutput, + validatePluginPackageJson, + validateTemplateSchema, + loadPluginsManifest, + addPluginToManifest, + removePluginFromManifest, + listInstalledPlugins, + isPluginInstalled, + installNpmPackage, + uninstallNpmPackage, + getInstalledVersion, +}; diff --git a/src/plugin/npm-installer.js b/src/plugin/npm-installer.js new file mode 100644 index 0000000..ccc22c6 --- /dev/null +++ b/src/plugin/npm-installer.js @@ -0,0 +1,36 @@ +const { execSync } = require('child_process'); +const chalk = require('chalk'); + +function installNpmPackage(packageName, version = 'latest') { + const spec = version === 'latest' ? packageName : `${packageName}@${version}`; + console.log(chalk.dim(` Running: npm install ${spec} --save-dev`)); + execSync(`npm install ${spec} --save-dev`, { + stdio: 'inherit', + cwd: process.cwd(), + }); +} + +function uninstallNpmPackage(packageName) { + console.log(chalk.dim(` Running: npm uninstall ${packageName}`)); + execSync(`npm uninstall ${packageName}`, { + stdio: 'inherit', + cwd: process.cwd(), + }); +} + +function getInstalledVersion(packageName) { + try { + const result = execSync(`npm list ${packageName} --depth=0 --json`, { + cwd: process.cwd(), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + const parsed = JSON.parse(result); + const deps = parsed.dependencies || {}; + return deps[packageName]?.version || null; + } catch { + return null; + } +} + +module.exports = { installNpmPackage, uninstallNpmPackage, getInstalledVersion }; diff --git a/src/plugin/plugin-loader.js b/src/plugin/plugin-loader.js new file mode 100644 index 0000000..0489706 --- /dev/null +++ b/src/plugin/plugin-loader.js @@ -0,0 +1,70 @@ +const path = require('path'); +const fs = require('fs-extra'); +const { validatePluginPackageJson, validateTemplateSchema } = require('./schema-validator'); + +const SANDBOX_ALLOWLIST = ['templates', 'index.js', 'package.json', 'README.md']; + +async function loadPlugin(pluginName, nodeModulesDir = path.join(process.cwd(), 'node_modules')) { + const pluginDir = path.join(nodeModulesDir, pluginName); + + if (!(await fs.pathExists(pluginDir))) { + throw new Error(`Plugin "${pluginName}" not found in node_modules. Run: stxforge plugin install ${pluginName}`); + } + + const pkgPath = path.join(pluginDir, 'package.json'); + const pkg = await fs.readJson(pkgPath); + + // Validate plugin package.json + const pkgErrors = validatePluginPackageJson(pkg); + if (pkgErrors.length > 0) { + throw new Error(`Invalid plugin package.json:\n ${pkgErrors.join('\n ')}`); + } + + // Validate sandbox — only allowed files/dirs can be accessed + const dirContents = await fs.readdir(pluginDir); + for (const entry of dirContents) { + if (!SANDBOX_ALLOWLIST.includes(entry) && !entry.startsWith('node_modules')) { + // Warn but don't block — log it + console.warn(`[stxforge] Plugin "${pluginName}" has unexpected entry: ${entry}`); + } + } + + // Load templates + const templates = []; + const templatesDir = path.join(pluginDir, 'templates'); + + if (await fs.pathExists(templatesDir)) { + const templateDirs = await fs.readdir(templatesDir); + + for (const templateName of templateDirs) { + const templatePath = path.join(templatesDir, templateName); + const schemaPath = path.join(templatePath, 'schema.json'); + + if (!(await fs.pathExists(schemaPath))) continue; + + const schema = await fs.readJson(schemaPath); + const schemaErrors = validateTemplateSchema(schema); + + if (schemaErrors.length > 0) { + throw new Error(`Invalid template schema in "${templateName}":\n ${schemaErrors.join('\n ')}`); + } + + templates.push({ + name: templateName, + schema, + path: templatePath, + contractTemplate: path.join(templatePath, 'contract.clar.hbs'), + testTemplate: path.join(templatePath, 'test.ts.hbs'), + }); + } + } + + return { + name: pluginName, + version: pkg.version, + verified: pkg.stxforge.verified || false, + templates, + }; +} + +module.exports = { loadPlugin }; diff --git a/src/plugin/plugin-manager.js b/src/plugin/plugin-manager.js new file mode 100644 index 0000000..5e8ca53 --- /dev/null +++ b/src/plugin/plugin-manager.js @@ -0,0 +1,58 @@ +const path = require('path'); +const fs = require('fs-extra'); + +const PLUGINS_FILE = '.stxforge/plugins.json'; + +async function getPluginsFilePath(cwd = process.cwd()) { + return path.join(cwd, PLUGINS_FILE); +} + +async function loadPluginsManifest(cwd = process.cwd()) { + const filePath = await getPluginsFilePath(cwd); + if (!(await fs.pathExists(filePath))) { + return { plugins: {} }; + } + return fs.readJson(filePath); +} + +async function savePluginsManifest(manifest, cwd = process.cwd()) { + const filePath = await getPluginsFilePath(cwd); + await fs.ensureDir(path.dirname(filePath)); + await fs.writeJson(filePath, manifest, { spaces: 2 }); +} + +async function addPluginToManifest(name, version, metadata, cwd = process.cwd()) { + const manifest = await loadPluginsManifest(cwd); + manifest.plugins[name] = { + version, + installedAt: new Date().toISOString(), + verified: metadata.verified || false, + templates: metadata.templates || [], + }; + await savePluginsManifest(manifest, cwd); +} + +async function removePluginFromManifest(name, cwd = process.cwd()) { + const manifest = await loadPluginsManifest(cwd); + delete manifest.plugins[name]; + await savePluginsManifest(manifest, cwd); +} + +async function listInstalledPlugins(cwd = process.cwd()) { + const manifest = await loadPluginsManifest(cwd); + return Object.entries(manifest.plugins).map(([name, info]) => ({ name, ...info })); +} + +async function isPluginInstalled(name, cwd = process.cwd()) { + const manifest = await loadPluginsManifest(cwd); + return Boolean(manifest.plugins[name]); +} + +module.exports = { + loadPluginsManifest, + savePluginsManifest, + addPluginToManifest, + removePluginFromManifest, + listInstalledPlugins, + isPluginInstalled, +}; diff --git a/src/plugin/schema-validator.js b/src/plugin/schema-validator.js new file mode 100644 index 0000000..394863f --- /dev/null +++ b/src/plugin/schema-validator.js @@ -0,0 +1,60 @@ +// Validates a plugin package.json and schema.json before loading +// Ensures plugins conform to the stxforge plugin contract. + +const REQUIRED_PLUGIN_FIELDS = ['name', 'version', 'stxforge']; +const REQUIRED_STXFORGE_FIELDS = ['plugin', 'templates']; + +function validatePluginPackageJson(pkg) { + const errors = []; + + for (const field of REQUIRED_PLUGIN_FIELDS) { + if (!pkg[field]) { + errors.push(`Missing required field: "${field}" in package.json`); + } + } + + if (pkg.stxforge) { + if (pkg.stxforge.plugin !== true) { + errors.push('package.json must declare stxforge.plugin = true'); + } + + for (const field of REQUIRED_STXFORGE_FIELDS) { + if (!pkg.stxforge[field]) { + errors.push(`Missing required stxforge field: "${field}"`); + } + } + } + + if (!pkg.name || !pkg.name.startsWith('stxforge-plugin-')) { + errors.push('Plugin package name must start with "stxforge-plugin-"'); + } + + return errors; +} + +function validateTemplateSchema(schema) { + const errors = []; + + if (!schema.name || typeof schema.name !== 'string') { + errors.push('schema.json: "name" must be a string'); + } + + if (!schema.description || typeof schema.description !== 'string') { + errors.push('schema.json: "description" must be a string'); + } + + if (!Array.isArray(schema.prompts)) { + errors.push('schema.json: "prompts" must be an array'); + } else { + for (let i = 0; i < schema.prompts.length; i++) { + const prompt = schema.prompts[i]; + if (!prompt.name) errors.push(`schema.json prompts[${i}]: missing "name"`); + if (!prompt.type) errors.push(`schema.json prompts[${i}]: missing "type"`); + if (!prompt.message) errors.push(`schema.json prompts[${i}]: missing "message"`); + } + } + + return errors; +} + +module.exports = { validatePluginPackageJson, validateTemplateSchema }; diff --git a/src/plugin/template-renderer.js b/src/plugin/template-renderer.js new file mode 100644 index 0000000..97e372d --- /dev/null +++ b/src/plugin/template-renderer.js @@ -0,0 +1,51 @@ +const fs = require('fs-extra'); +const path = require('path'); +const Handlebars = require('handlebars'); + +async function renderTemplate(templatePath, context) { + if (!(await fs.pathExists(templatePath))) { + return null; + } + + const source = await fs.readFile(templatePath, 'utf8'); + const compiled = Handlebars.compile(source); + return compiled(context); +} + +async function renderPluginTemplate(template, answers) { + const context = { + ...answers, + contractName: (answers.name || 'mycontract').toLowerCase().replace(/\s+/g, '-'), + }; + + const contractSource = await renderTemplate(template.contractTemplate, context); + const testSource = template.testTemplate + ? await renderTemplate(template.testTemplate, context) + : null; + + return { contractSource, testSource, contractName: context.contractName }; +} + +async function writePluginOutput(rendered, projectDir = process.cwd()) { + const { contractSource, testSource, contractName } = rendered; + + const contractsDir = path.join(projectDir, 'contracts'); + const testsDir = path.join(projectDir, 'tests'); + + await fs.ensureDir(contractsDir); + await fs.ensureDir(testsDir); + + if (contractSource) { + const contractPath = path.join(contractsDir, `${contractName}.clar`); + await fs.writeFile(contractPath, contractSource, 'utf8'); + console.log(` ✔ Written: contracts/${contractName}.clar`); + } + + if (testSource) { + const testPath = path.join(testsDir, `${contractName}.test.ts`); + await fs.writeFile(testPath, testSource, 'utf8'); + console.log(` ✔ Written: tests/${contractName}.test.ts`); + } +} + +module.exports = { renderPluginTemplate, writePluginOutput }; diff --git a/tests/unit/plugin-manager.test.js b/tests/unit/plugin-manager.test.js new file mode 100644 index 0000000..d88b5b9 --- /dev/null +++ b/tests/unit/plugin-manager.test.js @@ -0,0 +1,56 @@ +const { describe, it, expect, beforeEach, afterEach } = require('vitest'); +const path = require('path'); +const fs = require('fs-extra'); +const os = require('os'); +const { + loadPluginsManifest, + addPluginToManifest, + removePluginFromManifest, + listInstalledPlugins, + isPluginInstalled, +} = require('../../src/plugin/plugin-manager'); + +let tmpDir; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stxforge-test-')); +}); + +afterEach(async () => { + await fs.remove(tmpDir); +}); + +describe('plugin-manager', () => { + it('returns empty manifest when no plugins.json exists', async () => { + const manifest = await loadPluginsManifest(tmpDir); + expect(manifest.plugins).toEqual({}); + }); + + it('adds a plugin to the manifest', async () => { + await addPluginToManifest('stxforge-plugin-test', '1.0.0', { templates: ['test-template'] }, tmpDir); + const manifest = await loadPluginsManifest(tmpDir); + expect(manifest.plugins['stxforge-plugin-test']).toBeDefined(); + expect(manifest.plugins['stxforge-plugin-test'].version).toBe('1.0.0'); + }); + + it('removes a plugin from the manifest', async () => { + await addPluginToManifest('stxforge-plugin-test', '1.0.0', {}, tmpDir); + await removePluginFromManifest('stxforge-plugin-test', tmpDir); + const manifest = await loadPluginsManifest(tmpDir); + expect(manifest.plugins['stxforge-plugin-test']).toBeUndefined(); + }); + + it('lists installed plugins', async () => { + await addPluginToManifest('stxforge-plugin-a', '1.0.0', {}, tmpDir); + await addPluginToManifest('stxforge-plugin-b', '2.0.0', {}, tmpDir); + const plugins = await listInstalledPlugins(tmpDir); + expect(plugins).toHaveLength(2); + expect(plugins.map((p) => p.name)).toContain('stxforge-plugin-a'); + }); + + it('correctly reports whether a plugin is installed', async () => { + await addPluginToManifest('stxforge-plugin-x', '1.0.0', {}, tmpDir); + expect(await isPluginInstalled('stxforge-plugin-x', tmpDir)).toBe(true); + expect(await isPluginInstalled('stxforge-plugin-y', tmpDir)).toBe(false); + }); +}); diff --git a/tests/unit/schema-validator.test.js b/tests/unit/schema-validator.test.js new file mode 100644 index 0000000..7b4cb7c --- /dev/null +++ b/tests/unit/schema-validator.test.js @@ -0,0 +1,78 @@ +const { describe, it, expect } = require('vitest'); +const { validatePluginPackageJson, validateTemplateSchema } = require('../../src/plugin/schema-validator'); + +describe('validatePluginPackageJson', () => { + const validPkg = { + name: 'stxforge-plugin-test', + version: '1.0.0', + stxforge: { plugin: true, templates: ['my-template'] }, + }; + + it('returns no errors for a valid plugin package.json', () => { + expect(validatePluginPackageJson(validPkg)).toHaveLength(0); + }); + + it('errors when name does not start with stxforge-plugin-', () => { + const errors = validatePluginPackageJson({ ...validPkg, name: 'my-plugin' }); + expect(errors.some(e => e.includes('stxforge-plugin-'))).toBe(true); + }); + + it('errors when stxforge.plugin is not true', () => { + const errors = validatePluginPackageJson({ + ...validPkg, + stxforge: { plugin: false, templates: [] }, + }); + expect(errors.some(e => e.includes('plugin = true'))).toBe(true); + }); + + it('errors when stxforge.templates is missing', () => { + const errors = validatePluginPackageJson({ + ...validPkg, + stxforge: { plugin: true }, + }); + expect(errors.some(e => e.includes('templates'))).toBe(true); + }); + + it('errors when version is missing', () => { + const { version: _, ...noVersion } = validPkg; + const errors = validatePluginPackageJson(noVersion); + expect(errors.some(e => e.includes('"version"'))).toBe(true); + }); +}); + +describe('validateTemplateSchema', () => { + const validSchema = { + name: 'my-template', + description: 'A test template', + prompts: [ + { name: 'tokenName', type: 'input', message: 'Token name:' }, + ], + }; + + it('returns no errors for a valid schema', () => { + expect(validateTemplateSchema(validSchema)).toHaveLength(0); + }); + + it('errors when name is missing', () => { + const { name: _, ...noName } = validSchema; + expect(validateTemplateSchema(noName).some(e => e.includes('"name"'))).toBe(true); + }); + + it('errors when description is missing', () => { + const { description: _, ...noDesc } = validSchema; + expect(validateTemplateSchema(noDesc).some(e => e.includes('"description"'))).toBe(true); + }); + + it('errors when prompts is not an array', () => { + const errors = validateTemplateSchema({ ...validSchema, prompts: 'bad' }); + expect(errors.some(e => e.includes('"prompts"'))).toBe(true); + }); + + it('errors when a prompt is missing name', () => { + const errors = validateTemplateSchema({ + ...validSchema, + prompts: [{ type: 'input', message: 'hi' }], + }); + expect(errors.some(e => e.includes('"name"'))).toBe(true); + }); +});