Skip to content
Merged
2 changes: 1 addition & 1 deletion create-streamfi-app/bin/create-streamfi-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ async function main() {
console.log(`Usage: npx create-streamfi-app <project-name> [options]

Options:
--template <url> Git repository to clone instead of the bundled StreamFi template
--template <name|url> 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;
Expand Down
79 changes: 61 additions & 18 deletions create-streamfi-app/lib/scaffold.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <git-url> to clone an external starter instead.
// used by default. Pass --template <name|url> 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) {
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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 };
5 changes: 5 additions & 0 deletions create-streamfi-app/templates/cron-worker/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# StreamFi Cron Worker environment
STREAM_NETWORK=testnet
FACTORY_ADDRESS=
STELLAR_SECRET=
RECIPIENT_ADDRESS=
10 changes: 10 additions & 0 deletions create-streamfi-app/templates/cron-worker/README.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions create-streamfi-app/templates/cron-worker/index.js
Original file line number Diff line number Diff line change
@@ -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);
});
14 changes: 14 additions & 0 deletions create-streamfi-app/templates/cron-worker/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 5 additions & 0 deletions create-streamfi-app/templates/node-script/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# StreamFi Node script environment
STREAM_NETWORK=testnet
FACTORY_ADDRESS=
STELLAR_SECRET=
TOKEN_ADDRESS=
10 changes: 10 additions & 0 deletions create-streamfi-app/templates/node-script/README.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 41 additions & 0 deletions create-streamfi-app/templates/node-script/index.js
Original file line number Diff line number Diff line change
@@ -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);
});
12 changes: 12 additions & 0 deletions create-streamfi-app/templates/node-script/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
35 changes: 32 additions & 3 deletions create-streamfi-app/test/scaffold.test.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
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']), {
projectName: 'my-app', template: DEFAULT_TEMPLATE, skipInstall: false, help: false,
});
});

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 = [];
Expand All @@ -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 = [];
Expand All @@ -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 <SDK_PACKAGE>` — the bundled template already
// declares it as a dependency.
assert.deepEqual(calls, [
Expand Down