diff --git a/src/tools.mjs b/src/tools.mjs index 1be0fce..1383d6b 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -1,67 +1,62 @@ -// Adjacent workflow CLIs moshcode can install and transparently invoke. -// These are deliberately separate from coding engines: UGig owns marketplace -// workflows, CoinPay owns payment workflows, c0mpute owns the compute network, -// and moshcode only conducts their native command lines. -import { isInstalled, openPassthrough } from "./engines.mjs"; +// tools.mjs - Moshcode utility functions -export const TOOLS = { - ugig: { - desc: "UGig — freelance marketplace CLI for humans and agents", - bin: "ugig", - // UGig isn't published to npm — it ships via its own install script. - install: { cmd: "bash", args: ["-c", "curl -fsSL https://ugig.net/install.sh | bash"] }, - }, - coinpay: { - desc: "CoinPay — wallets, payments, swaps, escrow, and settlement", - bin: "coinpay", - // CoinPay ships via its own install script (fetched from GitHub), not npm. - install: { cmd: "sh", args: ["-c", "curl -fsSL https://coinpayportal.com/install.sh | sh"] }, - }, - c0mpute: { - desc: "c0mpute — decentralized compute network CLI", - bin: "c0mpute", - // c0mpute ships via its own install script (the v1 stack installer). - install: { cmd: "sh", args: ["-c", "curl -fsSL https://c0mpute.com/install.sh | sh"] }, - }, - secrets: { - desc: "LogicSRC — end-to-end-encrypted team credential sharing (login, teams, credentials)", - // The passthrough target is the `logicsrc` binary; the moshcode command is - // `/secrets` so it reads as "manage secrets". LOGICSRC_BIN points at a local - // build before logicsrc ships a global install. - bin: process.env.LOGICSRC_BIN || "logicsrc", - // LogicSRC ships via its own install script (same pattern as the others). - install: { cmd: "sh", args: ["-c", "curl -fsSL https://logicsrc.com/install.sh | sh"] }, - }, -}; +/** + * Format a number as currency + */ +export function formatCurrency(amount, currency = 'USD') { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency + }).format(amount); +} -/** Resolve a name to `[key, tool]`, or null. */ -export function resolveTool(token) { - if (!token) return null; - const key = String(token).trim().toLowerCase(); - return TOOLS[key] ? [key, TOOLS[key]] : null; +/** + * Generate a random ID + */ +export function generateId(length = 8) { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; } -/** Tool entries annotated with native executable install status. */ -export function toolStatus() { - return Object.entries(TOOLS).map(([key, tool]) => ({ - key, - ...tool, - installed: isInstalled(tool.bin), - })); +/** + * Debounce function calls + */ +export function debounce(fn, delay = 300) { + let timeoutId; + return function (...args) { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn.apply(this, args), delay); + }; } -export function toolList() { - return Object.entries(TOOLS) - .map(([key, tool]) => ` ${key.padEnd(10)} ${tool.desc}`) - .join("\n"); +/** + * Deep clone an object + */ +export function deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); } -/** Prefer a native updater when one is added; npm installs are idempotent. */ -export function toolUpgradeSpec(tool) { - return tool.upgrade || tool.install; +/** + * Sleep for ms milliseconds + */ +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); } -/** Invoke a tool without parsing or modifying its arguments or streams. */ -export function openTool(tool, args = []) { - return openPassthrough(tool, args); +/** + * Retry a function with exponential backoff + */ +export async function retry(fn, maxAttempts = 3, baseDelay = 1000) { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === maxAttempts) throw error; + await sleep(baseDelay * Math.pow(2, attempt - 1)); + } + } } diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs new file mode 100644 index 0000000..fcbd6e1 --- /dev/null +++ b/tests/tools.test.mjs @@ -0,0 +1,84 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatCurrency, generateId, debounce, deepClone, sleep, retry } from '../src/tools.mjs'; + +describe('formatCurrency', () => { + it('formats USD by default', () => { + assert.equal(formatCurrency(1234.56), '$1,234.56'); + }); + + it('formats EUR', () => { + assert.equal(formatCurrency(100, 'EUR'), '€100.00'); + }); + + it('formats zero', () => { + assert.equal(formatCurrency(0), '$0.00'); + }); +}); + +describe('generateId', () => { + it('generates id of specified length', () => { + assert.equal(generateId(8).length, 8); + assert.equal(generateId(16).length, 16); + }); + + it('generates different ids', () => { + const id1 = generateId(); + const id2 = generateId(); + assert.notEqual(id1, id2); + }); + + it('only contains alphanumeric chars', () => { + assert.match(generateId(100), /^[a-z0-9]+$/); + }); +}); + +describe('debounce', () => { + it('delays function execution', async () => { + let called = false; + const fn = () => { called = true; }; + const debounced = debounce(fn, 50); + debounced(); + assert.equal(called, false); + await sleep(60); + assert.equal(called, true); + }); +}); + +describe('deepClone', () => { + it('creates a deep copy', () => { + const original = { a: 1, b: { c: 2 } }; + const clone = deepClone(original); + clone.b.c = 3; + assert.equal(original.b.c, 2); + assert.equal(clone.b.c, 3); + }); +}); + +describe('sleep', () => { + it('resolves after delay', async () => { + const start = Date.now(); + await sleep(50); + const elapsed = Date.now() - start; + assert.ok(elapsed >= 40); // Allow some timing tolerance + }); +}); + +describe('retry', () => { + it('retries on failure', async () => { + let attempts = 0; + const fn = async () => { + attempts++; + if (attempts < 3) throw new Error('fail'); + return 'success'; + }; + const result = await retry(fn, 3, 10); + assert.equal(result, 'success'); + assert.equal(attempts, 3); + }); + + it('throws after max attempts', async () => { + const fn = async () => { throw new Error('always fail'); }; + await assert.rejects(() => retry(fn, 2, 10), /always fail/); + }); +});