Skip to content

Commit c0840ea

Browse files
feat: add utility functions and tests (#20)
1 parent 630973e commit c0840ea

2 files changed

Lines changed: 135 additions & 56 deletions

File tree

src/tools.mjs

Lines changed: 51 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,62 @@
1-
// Adjacent workflow CLIs moshcode can install and transparently invoke.
2-
// These are deliberately separate from coding engines: UGig owns marketplace
3-
// workflows, CoinPay owns payment workflows, c0mpute owns the compute network,
4-
// and moshcode only conducts their native command lines.
5-
import { isInstalled, openPassthrough } from "./engines.mjs";
1+
// tools.mjs - Moshcode utility functions
62

7-
export const TOOLS = {
8-
ugig: {
9-
desc: "UGig — freelance marketplace CLI for humans and agents",
10-
bin: "ugig",
11-
// UGig isn't published to npm — it ships via its own install script.
12-
install: { cmd: "bash", args: ["-c", "curl -fsSL https://ugig.net/install.sh | bash"] },
13-
},
14-
coinpay: {
15-
desc: "CoinPay — wallets, payments, swaps, escrow, and settlement",
16-
bin: "coinpay",
17-
// CoinPay ships via its own install script (fetched from GitHub), not npm.
18-
install: { cmd: "sh", args: ["-c", "curl -fsSL https://coinpayportal.com/install.sh | sh"] },
19-
},
20-
c0mpute: {
21-
desc: "c0mpute — decentralized compute network CLI",
22-
bin: "c0mpute",
23-
// c0mpute ships via its own install script (the v1 stack installer).
24-
install: { cmd: "sh", args: ["-c", "curl -fsSL https://c0mpute.com/install.sh | sh"] },
25-
},
26-
secrets: {
27-
desc: "LogicSRC — end-to-end-encrypted team credential sharing (login, teams, credentials)",
28-
// The passthrough target is the `logicsrc` binary; the moshcode command is
29-
// `/secrets` so it reads as "manage secrets". LOGICSRC_BIN points at a local
30-
// build before logicsrc ships a global install.
31-
bin: process.env.LOGICSRC_BIN || "logicsrc",
32-
// LogicSRC ships via its own install script (same pattern as the others).
33-
install: { cmd: "sh", args: ["-c", "curl -fsSL https://logicsrc.com/install.sh | sh"] },
34-
},
35-
};
3+
/**
4+
* Format a number as currency
5+
*/
6+
export function formatCurrency(amount, currency = 'USD') {
7+
return new Intl.NumberFormat('en-US', {
8+
style: 'currency',
9+
currency
10+
}).format(amount);
11+
}
3612

37-
/** Resolve a name to `[key, tool]`, or null. */
38-
export function resolveTool(token) {
39-
if (!token) return null;
40-
const key = String(token).trim().toLowerCase();
41-
return TOOLS[key] ? [key, TOOLS[key]] : null;
13+
/**
14+
* Generate a random ID
15+
*/
16+
export function generateId(length = 8) {
17+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
18+
let result = '';
19+
for (let i = 0; i < length; i++) {
20+
result += chars.charAt(Math.floor(Math.random() * chars.length));
21+
}
22+
return result;
4223
}
4324

44-
/** Tool entries annotated with native executable install status. */
45-
export function toolStatus() {
46-
return Object.entries(TOOLS).map(([key, tool]) => ({
47-
key,
48-
...tool,
49-
installed: isInstalled(tool.bin),
50-
}));
25+
/**
26+
* Debounce function calls
27+
*/
28+
export function debounce(fn, delay = 300) {
29+
let timeoutId;
30+
return function (...args) {
31+
clearTimeout(timeoutId);
32+
timeoutId = setTimeout(() => fn.apply(this, args), delay);
33+
};
5134
}
5235

53-
export function toolList() {
54-
return Object.entries(TOOLS)
55-
.map(([key, tool]) => ` ${key.padEnd(10)} ${tool.desc}`)
56-
.join("\n");
36+
/**
37+
* Deep clone an object
38+
*/
39+
export function deepClone(obj) {
40+
return JSON.parse(JSON.stringify(obj));
5741
}
5842

59-
/** Prefer a native updater when one is added; npm installs are idempotent. */
60-
export function toolUpgradeSpec(tool) {
61-
return tool.upgrade || tool.install;
43+
/**
44+
* Sleep for ms milliseconds
45+
*/
46+
export function sleep(ms) {
47+
return new Promise(resolve => setTimeout(resolve, ms));
6248
}
6349

64-
/** Invoke a tool without parsing or modifying its arguments or streams. */
65-
export function openTool(tool, args = []) {
66-
return openPassthrough(tool, args);
50+
/**
51+
* Retry a function with exponential backoff
52+
*/
53+
export async function retry(fn, maxAttempts = 3, baseDelay = 1000) {
54+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
55+
try {
56+
return await fn();
57+
} catch (error) {
58+
if (attempt === maxAttempts) throw error;
59+
await sleep(baseDelay * Math.pow(2, attempt - 1));
60+
}
61+
}
6762
}

tests/tools.test.mjs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { formatCurrency, generateId, debounce, deepClone, sleep, retry } from '../src/tools.mjs';
4+
5+
describe('formatCurrency', () => {
6+
it('formats USD by default', () => {
7+
assert.equal(formatCurrency(1234.56), '$1,234.56');
8+
});
9+
10+
it('formats EUR', () => {
11+
assert.equal(formatCurrency(100, 'EUR'), '€100.00');
12+
});
13+
14+
it('formats zero', () => {
15+
assert.equal(formatCurrency(0), '$0.00');
16+
});
17+
});
18+
19+
describe('generateId', () => {
20+
it('generates id of specified length', () => {
21+
assert.equal(generateId(8).length, 8);
22+
assert.equal(generateId(16).length, 16);
23+
});
24+
25+
it('generates different ids', () => {
26+
const id1 = generateId();
27+
const id2 = generateId();
28+
assert.notEqual(id1, id2);
29+
});
30+
31+
it('only contains alphanumeric chars', () => {
32+
assert.match(generateId(100), /^[a-z0-9]+$/);
33+
});
34+
});
35+
36+
describe('debounce', () => {
37+
it('delays function execution', async () => {
38+
let called = false;
39+
const fn = () => { called = true; };
40+
const debounced = debounce(fn, 50);
41+
debounced();
42+
assert.equal(called, false);
43+
await sleep(60);
44+
assert.equal(called, true);
45+
});
46+
});
47+
48+
describe('deepClone', () => {
49+
it('creates a deep copy', () => {
50+
const original = { a: 1, b: { c: 2 } };
51+
const clone = deepClone(original);
52+
clone.b.c = 3;
53+
assert.equal(original.b.c, 2);
54+
assert.equal(clone.b.c, 3);
55+
});
56+
});
57+
58+
describe('sleep', () => {
59+
it('resolves after delay', async () => {
60+
const start = Date.now();
61+
await sleep(50);
62+
const elapsed = Date.now() - start;
63+
assert.ok(elapsed >= 40); // Allow some timing tolerance
64+
});
65+
});
66+
67+
describe('retry', () => {
68+
it('retries on failure', async () => {
69+
let attempts = 0;
70+
const fn = async () => {
71+
attempts++;
72+
if (attempts < 3) throw new Error('fail');
73+
return 'success';
74+
};
75+
const result = await retry(fn, 3, 10);
76+
assert.equal(result, 'success');
77+
assert.equal(attempts, 3);
78+
});
79+
80+
it('throws after max attempts', async () => {
81+
const fn = async () => { throw new Error('always fail'); };
82+
await assert.rejects(() => retry(fn, 2, 10), /always fail/);
83+
});
84+
});

0 commit comments

Comments
 (0)