Skip to content
Merged
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
42 changes: 42 additions & 0 deletions .github/workflows/openapi-codegen.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: OpenAPI Codegen Drift Check

on:
pull_request:
branches: [main]
paths:
- 'backend/openapi.yaml'
- 'sdk/client/**'
push:
branches: [main]
paths:
- 'backend/openapi.yaml'

jobs:
codegen-drift:
name: Check generated client is up to date
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install sdk/client dependencies
run: npm install
working-directory: sdk/client

- name: Regenerate TypeScript client from openapi.yaml
run: npm run generate
working-directory: sdk/client

- name: Fail if generated output has drifted
run: |
if ! git diff --exit-code sdk/client/src/; then
echo ""
echo "ERROR: The generated TypeScript client in sdk/client/src/ is out of date."
echo "Run 'npm run generate' inside sdk/client/ and commit the updated files."
exit 1
fi
62 changes: 62 additions & 0 deletions .github/workflows/python-sdk-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Python SDK CI

on:
pull_request:
branches: [main]
paths:
- 'sdk/python/**'
- 'backend/openapi.yaml'
push:
branches: [main]
paths:
- 'sdk/python/**'

jobs:
test:
name: pytest (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.11', '3.12']

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install SDK with dev extras
run: pip install -e ".[dev]"
working-directory: sdk/python

- name: Run pytest
run: pytest tests/ -v
working-directory: sdk/python

publish:
name: Publish to PyPI
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/python-sdk-v')
environment: pypi
permissions:
id-token: write # OIDC trusted publishing

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Build distribution
run: |
pip install hatch
hatch build
working-directory: sdk/python

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: sdk/python/dist/
17 changes: 17 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Trivela development helpers
.PHONY: dev dev-down codegen codegen-check

# Issue #616: one-command local devnet (contracts + backend + frontend)
dev:
docker compose --profile devnet up --build

dev-down:
docker compose --profile devnet down -v

# Issue #615: regenerate TypeScript client from openapi.yaml
codegen:
cd sdk/client && npm install --silent && npm run generate

# Fail if the committed generated client is out of date
codegen-check:
cd sdk/client && npm install --silent && npm run check-drift
25 changes: 25 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@trivela/cli",
"version": "0.1.0",
"description": "Trivela CLI — campaign & contract lifecycle management",
"type": "module",
"bin": {
"trivela": "./src/index.js"
},
"scripts": {
"dev": "node src/index.js",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"lint": "eslint src/"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0",
"ora": "^8.0.0",
"conf": "^12.0.0",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"jest": "^29.0.0"
},
"license": "Apache-2.0"
}
57 changes: 57 additions & 0 deletions cli/src/commands/allowlist.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* trivela allowlist import — import a CSV/text allowlist and generate Merkle proof.
*/

import { readFileSync } from 'fs';
import { execSync } from 'child_process';
import { Command } from 'commander';
import chalk from 'chalk';
import { getConfig } from '../lib/config.js';
import { requireMainnetConfirmation } from '../lib/prompt.js';

export function makeAllowlistCommand() {
const cmd = new Command('allowlist');
cmd.description('Manage campaign allowlists and Merkle proofs');

cmd
.command('import <file>')
.description('Import a list of addresses from a file (one address per line or CSV)')
.option('--dry-run', 'Parse file and print summary without writing')
.option('--campaign-id <id>', 'Associate with a campaign')
.action(async (file, opts) => {
const cfg = getConfig();
let raw;
try {
raw = readFileSync(file, 'utf8');
} catch (err) {
console.error(chalk.red('Cannot read file:'), err.message);
process.exit(1);
}

const addresses = raw
.split('\n')
.map((l) => l.split(',')[0].trim())
.filter((a) => a && a.startsWith('G') && a.length === 56);

console.log(`Parsed ${chalk.bold(addresses.length)} valid Stellar addresses from ${file}.`);

if (opts.dryRun) {
console.log(chalk.cyan('[dry-run]'), 'First 5 addresses:', addresses.slice(0, 5));
return;
}

// Generate Merkle root via the existing script
const listPath = `/tmp/trivela-allowlist-${Date.now()}.txt`;
require('fs').writeFileSync(listPath, addresses.join('\n'));
try {
const out = execSync(`node scripts/generate-merkle.mjs ${listPath}`, { encoding: 'utf8' });
console.log(chalk.green('✔'), 'Merkle root generated:');
console.log(out);
} catch (err) {
console.error(chalk.red('Merkle generation failed:'), err.message);
process.exit(1);
}
});

return cmd;
}
129 changes: 129 additions & 0 deletions cli/src/commands/campaign.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* trivela campaign create|activate|close — manage campaigns via the REST API.
*/

import { Command } from 'commander';
import chalk from 'chalk';
import { ApiClient } from '../lib/api.js';
import { getConfig } from '../lib/config.js';
import { requireMainnetConfirmation } from '../lib/prompt.js';

function makeClient(cfg) {
return new ApiClient({ apiUrl: cfg.apiUrl, apiKey: cfg.apiKey });
}

export function makeCampaignCommand() {
const cmd = new Command('campaign');
cmd.description('Create, activate, or close campaigns');

cmd
.command('create')
.description('Create a new campaign')
.requiredOption('--name <name>', 'Campaign name')
.requiredOption('--reward <amount>', 'Reward per action (number)', parseFloat)
.option('--description <desc>', 'Campaign description')
.option('--slug <slug>', 'URL-friendly slug (auto-generated if omitted)')
.option('--start <date>', 'Start date (ISO 8601)')
.option('--end <date>', 'End date (ISO 8601)')
.option('--category <cat>', 'Category')
.option('--tags <tags>', 'Comma-separated tags')
.option('--dry-run', 'Print request body without sending')
.action(async (opts) => {
const cfg = getConfig();
const body = {
name: opts.name,
rewardPerAction: opts.reward,
active: true,
};
if (opts.description) body.description = opts.description;
if (opts.slug) body.slug = opts.slug;
if (opts.start) body.startDate = opts.start;
if (opts.end) body.endDate = opts.end;
if (opts.category) body.category = opts.category;
if (opts.tags) body.tags = opts.tags.split(',').map((t) => t.trim());

if (opts.dryRun) {
console.log(chalk.cyan('[dry-run] POST /api/v1/campaigns'));
console.log(JSON.stringify(body, null, 2));
return;
}

const proceed = await requireMainnetConfirmation('create campaign', cfg, false);
if (!proceed) return;

const api = makeClient(cfg);
const campaign = await api.post('/api/v1/campaigns', body);
console.log(chalk.green('✔'), `Campaign created: ${campaign.id}`);
console.log(` name : ${campaign.name}`);
console.log(` slug : ${campaign.slug}`);
console.log(` status: ${campaign.status}`);
});

cmd
.command('activate <id>')
.description('Activate a campaign by ID')
.option('--dry-run', 'Print without sending')
.action(async (id, opts) => {
const cfg = getConfig();
if (opts.dryRun) {
console.log(chalk.cyan('[dry-run]'), `Would PATCH /api/v1/campaigns/${id} {active: true}`);
return;
}
const api = makeClient(cfg);
const campaign = await api.put(`/api/v1/campaigns/${id}`, { active: true });
console.log(chalk.green('✔'), `Campaign ${campaign.id} activated (status: ${campaign.status})`);
});

cmd
.command('close <id>')
.description('Close (deactivate) a campaign by ID')
.option('--dry-run', 'Print without sending')
.action(async (id, opts) => {
const cfg = getConfig();
if (opts.dryRun) {
console.log(chalk.cyan('[dry-run]'), `Would PATCH /api/v1/campaigns/${id} {active: false}`);
return;
}
const proceed = await requireMainnetConfirmation(`close campaign ${id}`, cfg, false);
if (!proceed) return;
const api = makeClient(cfg);
const campaign = await api.put(`/api/v1/campaigns/${id}`, { active: false });
console.log(chalk.green('✔'), `Campaign ${campaign.id} closed (status: ${campaign.status})`);
});

cmd
.command('list')
.description('List campaigns')
.option('--page <n>', 'Page number', '1')
.option('--limit <n>', 'Items per page', '20')
.option('--search <q>', 'Search query')
.action(async (opts) => {
const cfg = getConfig();
const api = makeClient(cfg);
const params = new URLSearchParams({ page: opts.page, limit: opts.limit });
if (opts.search) params.append('search', opts.search);
const data = await api.get(`/api/v1/campaigns?${params}`);
const campaigns = data.data ?? [];
if (campaigns.length === 0) {
console.log('No campaigns found.');
return;
}
for (const c of campaigns) {
console.log(`${chalk.bold(c.id)} ${c.name} [${c.status}]`);
}
const p = data.pagination;
if (p) console.log(`\nPage ${p.page}/${p.totalPages} — ${p.total} total`);
});

cmd
.command('get <id>')
.description('Get campaign details')
.action(async (id) => {
const cfg = getConfig();
const api = makeClient(cfg);
const c = await api.get(`/api/v1/campaigns/${id}`);
console.log(JSON.stringify(c, null, 2));
});

return cmd;
}
45 changes: 45 additions & 0 deletions cli/src/commands/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* trivela config — view and set CLI configuration.
*/

import { Command } from 'commander';
import chalk from 'chalk';
import { getConfig, setConfig, store } from '../lib/config.js';

export function makeConfigCommand() {
const cmd = new Command('config');
cmd.description('View or set CLI configuration (network, API key, contracts, etc.)');

cmd
.command('get [key]')
.description('Print current config (or a specific key)')
.action((key) => {
const cfg = getConfig();
if (key) {
console.log(cfg[key] ?? store.get(key) ?? '(not set)');
} else {
for (const [k, v] of Object.entries(cfg)) {
const display = k.toLowerCase().includes('key') ? (v ? '***' : '(not set)') : (v || '(not set)');
console.log(`${chalk.bold(k)}: ${display}`);
}
}
});

cmd
.command('set <key> <value>')
.description('Persist a config value')
.action((key, value) => {
setConfig(key, value);
console.log(chalk.green('✔'), `${key} saved.`);
});

cmd
.command('clear')
.description('Clear all persisted config')
.action(() => {
store.clear();
console.log(chalk.green('✔'), 'Config cleared.');
});

return cmd;
}
Loading
Loading