diff --git a/create-streamfi-app/bin/create-streamfi-app.js b/create-streamfi-app/bin/create-streamfi-app.js index 6333ed3..cf635b2 100644 --- a/create-streamfi-app/bin/create-streamfi-app.js +++ b/create-streamfi-app/bin/create-streamfi-app.js @@ -9,7 +9,7 @@ async function main() { console.log(`Usage: npx create-streamfi-app [options] Options: - --template Git repository to clone instead of the bundled StreamFi template + --template Starter to use: next-app, node-script, cron-worker, or a git URL to clone --skip-install Do not install dependencies --help Show this message`); return; diff --git a/create-streamfi-app/lib/scaffold.js b/create-streamfi-app/lib/scaffold.js index f2f027d..92d642e 100644 --- a/create-streamfi-app/lib/scaffold.js +++ b/create-streamfi-app/lib/scaffold.js @@ -3,11 +3,14 @@ const { existsSync, writeFileSync, cpSync } = require('node:fs'); const { basename, resolve, join } = require('node:path'); // Bundled, StreamFi-wired Next.js template (a copy of examples/nextjs-app), -// used by default. Pass --template to clone an external starter instead. +// used by default. Pass --template to pick a different starter +// (next-app, node-script, cron-worker) or clone an external git repository. const BUNDLED_TEMPLATE_DIR = join(__dirname, '..', 'template'); +const TEMPLATES_DIR = join(__dirname, '..', 'templates'); // null means "use the bundled template"; parseArguments() only replaces this -// with a URL when the caller explicitly passes --template. +// with a name/URL when the caller explicitly passes --template. const DEFAULT_TEMPLATE = null; +const BUILT_IN_TEMPLATES = ['next-app', 'node-script', 'cron-worker']; const SDK_PACKAGE = '@conduit-protocol/sdk'; function parseArguments(args) { @@ -19,7 +22,7 @@ function parseArguments(args) { else if (argument === '--skip-install') options.skipInstall = true; else if (argument === '--template') { options.template = args[++index]; - if (!options.template) throw new Error('--template requires a repository URL'); + if (!options.template) throw new Error('--template requires a template name or repository URL'); } else if (argument.startsWith('-')) throw new Error(`Unknown option: ${argument}`); else if (options.projectName) throw new Error('Only one project name may be provided'); else options.projectName = argument; @@ -34,8 +37,39 @@ function parseArguments(args) { // Matches the env vars examples/nextjs-app (and the bundled template, which // is a copy of it) actually read — see lib/conduit.ts. Values that can't be // known ahead of time are left blank with a comment rather than guessed. -function testnetEnv() { - return `# StreamFi / Stellar testnet\nNEXT_PUBLIC_NETWORK=testnet\n\n# Deployed DripFactory contract ID for the chosen network. Required for\n# list()/streamCount()/streamAddress() queries.\nFACTORY_ADDRESS=\n\n# Secret key for signing transactions. Required for creating/withdrawing streams.\n# Deliberately NOT NEXT_PUBLIC_-prefixed; see lib/conduit.ts.\nSTELLAR_SECRET=\n\n# Default address to query streams for (can also be typed in the UI).\nNEXT_PUBLIC_ADDRESS=\n`; +function testnetEnv(template) { + if (template === 'node-script' || template === 'cron-worker') { + return `# StreamFi / Stellar testnet +STREAM_NETWORK=testnet + +# Deployed DripFactory contract ID for the chosen network. +FACTORY_ADDRESS= + +# Secret key for signing transactions. +STELLAR_SECRET= +${template === 'cron-worker' ? ' +# Recipient address to watch for auto-withdrawals. +RECIPIENT_ADDRESS= +' : ' +# Token address to stream (omit to use native XLM). +TOKEN_ADDRESS= +'} +`; + } + return `# StreamFi / Stellar testnet +NEXT_PUBLIC_NETWORK=testnet + +# Deployed DripFactory contract ID for the chosen network. Required for +# list()/streamCount()/streamAddress() queries. +FACTORY_ADDRESS= + +# Secret key for signing transactions. Required for creating/withdrawing streams. +# Deliberately NOT NEXT_PUBLIC_-prefixed; see lib/conduit.ts. +STELLAR_SECRET= + +# Default address to query streams for (can also be typed in the UI). +NEXT_PUBLIC_ADDRESS= +`; } function run(command, args, options) { @@ -48,33 +82,42 @@ function scaffold(options, dependencies = { existsSync, writeFileSync, cpSync, r throw new Error(`The directory "${options.projectName}" already exists`); } - // No --template given: copy the bundled, already StreamFi/Stellar-wired - // Next.js template instead of cloning an unrelated external starter. - const usingBundledTemplate = !options.template; + // Resolve built-in template names to their directories; treat anything else + // as a git URL to clone. + const templateName = options.template || 'next-app'; + const isBuiltIn = BUILT_IN_TEMPLATES.includes(templateName); + const isGitUrl = !isBuiltIn && Boolean(options.template) && /^https?:\/\//.test(options.template); - if (usingBundledTemplate) { - console.log(`Creating a StreamFi app in ${targetDirectory} (from the bundled StreamFi template)...`); - dependencies.cpSync(BUNDLED_TEMPLATE_DIR, targetDirectory, { recursive: true }); - } else { + if (!isBuiltIn && !isGitUrl && options.template) { + throw new Error(`Unknown template "${templateName}". Choose one of: ${BUILT_IN_TEMPLATES.join(', ')}, or pass a git URL.`); + } + + if (isGitUrl) { console.log(`Creating a StreamFi app in ${targetDirectory}...`); dependencies.run('git', ['clone', '--depth', '1', options.template, targetDirectory]); + } else { + const templateDir = templateName === 'next-app' + ? BUNDLED_TEMPLATE_DIR + : dependencies.resolve(TEMPLATES_DIR, templateName); + console.log(`Creating a StreamFi app in ${targetDirectory} (from the "${templateName}" template)...`); + dependencies.cpSync(templateDir, targetDirectory, { recursive: true }); } const envPath = dependencies.resolve(targetDirectory, '.env.local'); - dependencies.writeFileSync(envPath, testnetEnv(), 'utf8'); + dependencies.writeFileSync(envPath, testnetEnv(templateName), 'utf8'); if (!options.skipInstall) { dependencies.run('npm', ['install'], { cwd: targetDirectory }); - // The bundled template already declares @conduit-protocol/sdk as a - // dependency; an external --template starter generally won't. - if (!usingBundledTemplate) { + // The bundled templates already declare @conduit-protocol/sdk as a + // dependency; an external git-url starter generally won't. + if (isGitUrl) { dependencies.run('npm', ['install', SDK_PACKAGE], { cwd: targetDirectory }); } } console.log('\nYour StreamFi app is ready!'); console.log(`\n cd ${basename(targetDirectory)}`); - console.log(' npm run dev'); + console.log(templateName === 'next-app' ? ' npm run dev' : ' npm start'); } -module.exports = { DEFAULT_TEMPLATE, BUNDLED_TEMPLATE_DIR, SDK_PACKAGE, parseArguments, scaffold, testnetEnv }; +module.exports = { DEFAULT_TEMPLATE, BUNDLED_TEMPLATE_DIR, TEMPLATES_DIR, SDK_PACKAGE, BUILT_IN_TEMPLATES, parseArguments, scaffold, testnetEnv }; diff --git a/create-streamfi-app/templates/cron-worker/.env.example b/create-streamfi-app/templates/cron-worker/.env.example new file mode 100644 index 0000000..83a97c0 --- /dev/null +++ b/create-streamfi-app/templates/cron-worker/.env.example @@ -0,0 +1,5 @@ +# StreamFi Cron Worker environment +STREAM_NETWORK=testnet +FACTORY_ADDRESS= +STELLAR_SECRET= +RECIPIENT_ADDRESS= diff --git a/create-streamfi-app/templates/cron-worker/README.md b/create-streamfi-app/templates/cron-worker/README.md new file mode 100644 index 0000000..5a293b8 --- /dev/null +++ b/create-streamfi-app/templates/cron-worker/README.md @@ -0,0 +1,10 @@ +# StreamFi Cron Worker + +A tiny scheduled worker that checks a recipient's withdrawable balance every minute and auto-withdraws. + +```bash +npm install +npm start +``` + +Copy `.env.example` to `.env` and fill in your factory address, a funded secret key, and the recipient to watch. diff --git a/create-streamfi-app/templates/cron-worker/index.js b/create-streamfi-app/templates/cron-worker/index.js new file mode 100644 index 0000000..6fb0d31 --- /dev/null +++ b/create-streamfi-app/templates/cron-worker/index.js @@ -0,0 +1,46 @@ +import { ConduitClient, KeypairSigner } from '@conduit-protocol/sdk'; +import cron from 'node-cron'; +import 'dotenv/config'; + +async function main() { + const network = process.env.STREAM_NETWORK || 'testnet'; + const factoryAddress = process.env.FACTORY_ADDRESS; + const secret = process.env.STELLAR_SECRET; + const recipient = process.env.RECIPIENT_ADDRESS; + + if (!factoryAddress || !secret || !recipient) { + console.error('Missing FACTORY_ADDRESS, STELLAR_SECRET, or RECIPIENT_ADDRESS in .env'); + process.exit(1); + } + + const signer = KeypairSigner.fromSecret(secret); + const client = new ConduitClient({ + network, + factoryAddress, + signer, + }); + + // Run every minute. + cron.schedule('* * * * *', async () => { + console.log('[cron] Checking withdrawable balances...'); + try { + const streams = await client.streams.forRecipient(recipient); + for (const stream of streams) { + const withdrawable = await client.streams.withdrawable(stream.id); + if (withdrawable > 0n) { + const tx = await client.streams.withdraw(stream.id, withdrawable); + console.log('[cron] Withdrew from stream', stream.id, tx.hash); + } + } + } catch (err) { + console.error('[cron] Error:', err.message); + } + }); + + console.log('Cron worker started; checking every minute.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/create-streamfi-app/templates/cron-worker/package.json b/create-streamfi-app/templates/cron-worker/package.json new file mode 100644 index 0000000..0b1c8c0 --- /dev/null +++ b/create-streamfi-app/templates/cron-worker/package.json @@ -0,0 +1,14 @@ +{ + "name": "{{project-name}}", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "@conduit-protocol/sdk": "^0.2.0", + "node-cron": "^3.0.3", + "dotenv": "^16.4.5" + } +} diff --git a/create-streamfi-app/templates/node-script/.env.example b/create-streamfi-app/templates/node-script/.env.example new file mode 100644 index 0000000..209ee80 --- /dev/null +++ b/create-streamfi-app/templates/node-script/.env.example @@ -0,0 +1,5 @@ +# StreamFi Node script environment +STREAM_NETWORK=testnet +FACTORY_ADDRESS= +STELLAR_SECRET= +TOKEN_ADDRESS= diff --git a/create-streamfi-app/templates/node-script/README.md b/create-streamfi-app/templates/node-script/README.md new file mode 100644 index 0000000..ff56372 --- /dev/null +++ b/create-streamfi-app/templates/node-script/README.md @@ -0,0 +1,10 @@ +# StreamFi Node Script + +A minimal Node.js starter for the Conduit SDK. + +```bash +npm install +npm start +``` + +Copy `.env.example` to `.env` and fill in your testnet factory address and a funded secret key. diff --git a/create-streamfi-app/templates/node-script/index.js b/create-streamfi-app/templates/node-script/index.js new file mode 100644 index 0000000..66d439d --- /dev/null +++ b/create-streamfi-app/templates/node-script/index.js @@ -0,0 +1,41 @@ +import { ConduitClient, KeypairSigner } from '@conduit-protocol/sdk'; +import 'dotenv/config'; + +async function main() { + const network = process.env.STREAM_NETWORK || 'testnet'; + const factoryAddress = process.env.FACTORY_ADDRESS; + const secret = process.env.STELLAR_SECRET; + + if (!factoryAddress || !secret) { + console.error('Missing FACTORY_ADDRESS or STELLAR_SECRET in .env'); + process.exit(1); + } + + const signer = KeypairSigner.fromSecret(secret); + const client = new ConduitClient({ + network, + factoryAddress, + signer, + }); + + // Example: create a 1-minute test stream to yourself on testnet. + const sender = signer.publicKey; + const recipient = sender; + const now = Math.floor(Date.now() / 1000); + const stream = await client.streams.create({ + sender, + recipient, + token: process.env.TOKEN_ADDRESS || 'native', + deposit: '10', + ratePerSecond: '0.001', + startTime: now, + endTime: now + 60, + }); + + console.log('Created stream:', stream.id); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/create-streamfi-app/templates/node-script/package.json b/create-streamfi-app/templates/node-script/package.json new file mode 100644 index 0000000..a6e5e55 --- /dev/null +++ b/create-streamfi-app/templates/node-script/package.json @@ -0,0 +1,12 @@ +{ + "name": "{{project-name}}", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "@conduit-protocol/sdk": "^0.2.0" + } +} diff --git a/create-streamfi-app/test/scaffold.test.js b/create-streamfi-app/test/scaffold.test.js index 5d870b1..1b88e9c 100644 --- a/create-streamfi-app/test/scaffold.test.js +++ b/create-streamfi-app/test/scaffold.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { DEFAULT_TEMPLATE, BUNDLED_TEMPLATE_DIR, SDK_PACKAGE, parseArguments, scaffold, testnetEnv } = require('../lib/scaffold'); +const { DEFAULT_TEMPLATE, BUNDLED_TEMPLATE_DIR, TEMPLATES_DIR, SDK_PACKAGE, BUILT_IN_TEMPLATES, parseArguments, scaffold, testnetEnv } = require('../lib/scaffold'); test('parses a project name and defaults', () => { assert.deepEqual(parseArguments(['my-app']), { @@ -8,12 +8,22 @@ test('parses a project name and defaults', () => { }); }); -test('parses template and skip-install options', () => { +test('parses built-in template name', () => { + assert.deepEqual(parseArguments(['my-app', '--template', 'node-script']), { + projectName: 'my-app', template: 'node-script', skipInstall: false, help: false, + }); +}); + +test('parses template URL and skip-install options', () => { assert.deepEqual(parseArguments(['my-app', '--template', 'https://example.test/template.git', '--skip-install']), { projectName: 'my-app', template: 'https://example.test/template.git', skipInstall: true, help: false, }); }); +test('rejects unknown built-in template name', () => { + assert.throws(() => parseArguments(['my-app', '--template', 'unknown-template']), /Unknown template/); +}); + test('with an explicit --template, clones it and installs the SDK separately', () => { const calls = []; const writes = []; @@ -33,7 +43,24 @@ test('with an explicit --template, clones it and installs the SDK separately', ( assert.deepEqual(writes, [[process.cwd() + '/my-app/.env.local', testnetEnv(), 'utf8']]); }); -test('with no --template, copies the bundled StreamFi template and installs once', () => { +test('with --template node-script, copies the node-script template and installs once', () => { + const calls = []; + const copies = []; + scaffold({ projectName: 'my-app', template: 'node-script', skipInstall: false }, { + existsSync: () => false, + resolve: (...parts) => parts.join('/'), + writeFileSync: () => {}, + cpSync: (...args) => copies.push(args), + run: (...args) => calls.push(args), + }); + + assert.deepEqual(copies, [[TEMPLATES_DIR + '/node-script', process.cwd() + '/my-app', { recursive: true }]]); + assert.deepEqual(calls, [ + ['npm', ['install'], { cwd: process.cwd() + '/my-app' }], + ]); +}); + +test('with no --template, copies the bundled next-app template and installs once', () => { const calls = []; const writes = []; const copies = []; @@ -46,6 +73,8 @@ test('with no --template, copies the bundled StreamFi template and installs once }); assert.deepEqual(copies, [[BUNDLED_TEMPLATE_DIR, process.cwd() + '/my-app', { recursive: true }]]); + assert.ok(testnetEnv('next-app').includes('NEXT_PUBLIC_NETWORK'), 'next-app env uses NEXT_PUBLIC_NETWORK'); + assert.ok(testnetEnv('node-script').includes('STREAM_NETWORK'), 'node-script env uses STREAM_NETWORK'); // No separate `npm install ` — the bundled template already // declares it as a dependency. assert.deepEqual(calls, [