moshcode 0.70.0 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/bin/moshcode.mjs +34 -0
- package/package.json +1 -1
- package/prd/0012-billing-baked-into-the-agent-cli.md +129 -0
- package/src/billing.mjs +363 -0
- package/src/business-store.mjs +155 -0
- package/src/cli-schema.mjs +245 -0
- package/src/clients.mjs +328 -0
- package/src/commands.mjs +9 -0
- package/src/payments.mjs +258 -0
- package/src/rates.mjs +368 -0
- package/src/teams.mjs +459 -0
- package/src/timer.mjs +354 -0
- package/src/tui.mjs +54 -0
package/src/clients.mjs
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Who the work is for.
|
|
2
|
+
//
|
|
3
|
+
// A client is the noun the rest of the business layer hangs off: a timer is
|
|
4
|
+
// started *for* one, a rate belongs *to* one, an invoice is addressed *to* one,
|
|
5
|
+
// and a team is granted access *on behalf of* one. Everything else here is
|
|
6
|
+
// derived from that, which is why this file is mostly a record and a resolver
|
|
7
|
+
// rather than a feature.
|
|
8
|
+
//
|
|
9
|
+
// `/business` and `/merchant` are the same verb. They are not synonyms in
|
|
10
|
+
// general English, but they are the same thing in every conversation this is
|
|
11
|
+
// for — the party on the other end of an invoice — and the word somebody
|
|
12
|
+
// reaches for depends on which product taught it to them. Three doors, one room.
|
|
13
|
+
//
|
|
14
|
+
// Contact details are written the way they arrive:
|
|
15
|
+
//
|
|
16
|
+
// /client create "Acme Inc", https://acme.com, +1-555-0100
|
|
17
|
+
// /client create acme --contact.telephone +1-555-0100 --contact.name "Jane"
|
|
18
|
+
//
|
|
19
|
+
// The comma form is what a person pastes out of an email signature; the dotted
|
|
20
|
+
// form is what a script wants. Neither is a schema — `--contact.telephone` sets
|
|
21
|
+
// `contact.telephone` because that is what it says, and any other dotted flag
|
|
22
|
+
// does the same, so the record grows the fields a business actually keeps
|
|
23
|
+
// without this file having to guess them in advance.
|
|
24
|
+
import { loadBusiness, newId, slugify, updateBusiness } from "./business-store.mjs";
|
|
25
|
+
import { acid, ash, bone, err, info, ok, table, warn } from "./ui.mjs";
|
|
26
|
+
|
|
27
|
+
/** Fields the comma form recognises by shape, in the order it tries them. */
|
|
28
|
+
const LOOKS_LIKE = [
|
|
29
|
+
["url", (v) => /^https?:\/\//i.test(v) || /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(v)],
|
|
30
|
+
["email", (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)],
|
|
31
|
+
["phone", (v) => /^[+(]?[\d][\d\s().+-]{5,}$/.test(v)],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** Set `a.b.c` on an object, creating the objects in between. */
|
|
35
|
+
export function setPath(obj, dotted, value) {
|
|
36
|
+
const parts = String(dotted).split(".").filter(Boolean);
|
|
37
|
+
if (!parts.length) return obj;
|
|
38
|
+
// Own properties only, and never a prototype key: these paths come straight
|
|
39
|
+
// off a command line, and `--__proto__.x` must set a field called
|
|
40
|
+
// `__proto__`, not reach the prototype of every object in the process.
|
|
41
|
+
let node = obj;
|
|
42
|
+
for (const part of parts.slice(0, -1)) {
|
|
43
|
+
if (part === "__proto__" || part === "constructor" || part === "prototype") return obj;
|
|
44
|
+
if (!node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {};
|
|
45
|
+
node = node[part];
|
|
46
|
+
}
|
|
47
|
+
const leaf = parts[parts.length - 1];
|
|
48
|
+
if (leaf === "__proto__" || leaf === "constructor" || leaf === "prototype") return obj;
|
|
49
|
+
node[leaf] = value;
|
|
50
|
+
return obj;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read `a.b.c` back out, or undefined. */
|
|
54
|
+
export function getPath(obj, dotted) {
|
|
55
|
+
return String(dotted).split(".").filter(Boolean)
|
|
56
|
+
.reduce((node, part) => (node == null ? undefined : node[part]), obj);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Split an argv into `{ fields, rest }`.
|
|
61
|
+
*
|
|
62
|
+
* `--k v` and `--k=v` both set `k`; a `--k` with nothing after it is a flag and
|
|
63
|
+
* lands as `true`. Dotted names nest. Anything that is not a flag stays in
|
|
64
|
+
* `rest`, in order, for the caller to read positionally.
|
|
65
|
+
*/
|
|
66
|
+
export function parseFields(argv = []) {
|
|
67
|
+
const fields = {};
|
|
68
|
+
const rest = [];
|
|
69
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
70
|
+
const arg = String(argv[i]);
|
|
71
|
+
if (!arg.startsWith("--")) { rest.push(arg); continue; }
|
|
72
|
+
const body = arg.slice(2);
|
|
73
|
+
const eq = body.indexOf("=");
|
|
74
|
+
if (eq !== -1) { setPath(fields, body.slice(0, eq), body.slice(eq + 1)); continue; }
|
|
75
|
+
const next = argv[i + 1];
|
|
76
|
+
if (next === undefined || String(next).startsWith("--")) { setPath(fields, body, true); continue; }
|
|
77
|
+
setPath(fields, body, String(next));
|
|
78
|
+
i += 1;
|
|
79
|
+
}
|
|
80
|
+
return { fields, rest };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read the comma form: `"Acme Inc", https://acme.com, +1-555-0100`.
|
|
85
|
+
*
|
|
86
|
+
* The first segment is always the name — it is the only field with no shape to
|
|
87
|
+
* recognise it by — and the rest are sorted by what they look like. A segment
|
|
88
|
+
* nothing claims becomes a note rather than being dropped, because the thing
|
|
89
|
+
* somebody pasted was in the signature for a reason.
|
|
90
|
+
*/
|
|
91
|
+
export function parseCommaForm(text) {
|
|
92
|
+
const segments = String(text ?? "")
|
|
93
|
+
.split(",")
|
|
94
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
const out = { notes: [] };
|
|
97
|
+
for (const segment of segments) {
|
|
98
|
+
if (!out.name) { out.name = segment; continue; }
|
|
99
|
+
const match = LOOKS_LIKE.find(([field, test]) => !out[field] && test(segment));
|
|
100
|
+
if (match) { out[match[0]] = segment; continue; }
|
|
101
|
+
out.notes.push(segment);
|
|
102
|
+
}
|
|
103
|
+
out.notes = out.notes.join("; ");
|
|
104
|
+
if (!out.notes) delete out.notes;
|
|
105
|
+
if (out.url && !/^https?:\/\//i.test(out.url)) out.url = `https://${out.url}`;
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `solana:ADDR`, or an address with `--chain` alongside, or null. */
|
|
110
|
+
export function parsePayee(value, chain) {
|
|
111
|
+
if (!value || value === true) return null;
|
|
112
|
+
const text = String(value).trim();
|
|
113
|
+
const split = text.indexOf(":");
|
|
114
|
+
if (split > 0 && split < 12) {
|
|
115
|
+
return { chain: text.slice(0, split).toLowerCase(), address: text.slice(split + 1) };
|
|
116
|
+
}
|
|
117
|
+
return { chain: String(chain || "").toLowerCase() || "unknown", address: text };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Find a client by id, name, or unambiguous prefix.
|
|
122
|
+
*
|
|
123
|
+
* Returns `{ ok, id, client }` or `{ ok: false, reason, matches }` — never
|
|
124
|
+
* throws and never guesses between two candidates, because the callers are
|
|
125
|
+
* `/timer on` and `/billing`, and picking the wrong client silently is how
|
|
126
|
+
* hours end up on the wrong invoice.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveClient(business, token) {
|
|
129
|
+
const clients = business?.clients || {};
|
|
130
|
+
const want = String(token ?? "").trim().toLowerCase();
|
|
131
|
+
if (!want) return { ok: false, reason: "no client named" };
|
|
132
|
+
if (Object.hasOwn(clients, want)) return { ok: true, id: want, client: clients[want] };
|
|
133
|
+
const slug = slugify(want);
|
|
134
|
+
if (Object.hasOwn(clients, slug)) return { ok: true, id: slug, client: clients[slug] };
|
|
135
|
+
const matches = Object.entries(clients).filter(([id, c]) =>
|
|
136
|
+
id.startsWith(slug) || String(c.name || "").toLowerCase().includes(want));
|
|
137
|
+
if (matches.length === 1) return { ok: true, id: matches[0][0], client: matches[0][1] };
|
|
138
|
+
if (matches.length > 1) return { ok: false, reason: "ambiguous", matches: matches.map(([id]) => id) };
|
|
139
|
+
return { ok: false, reason: "unknown", matches: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The one-line "who is this" a report puts next to an id. */
|
|
143
|
+
export function clientLabel(id, client) {
|
|
144
|
+
const name = client?.name && slugify(client.name) !== id ? ` ${ash(`(${client.name})`)}` : "";
|
|
145
|
+
return `${bone(id)}${name}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const USAGE = [
|
|
149
|
+
"usage: /client create <name>[, url][, phone|email] [--field value…]",
|
|
150
|
+
" /client list [--json] · /client show <id> · /client set <id> --field value",
|
|
151
|
+
" /client rm <id> · /client payee <id> <chain:address> where their payments land",
|
|
152
|
+
" aliases: /business /merchant /customer — same command",
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
export function clientCommand(argv = [], { write = console.log } = {}) {
|
|
156
|
+
const verb = String(argv[0] ?? "list").toLowerCase();
|
|
157
|
+
const args = argv.slice(1);
|
|
158
|
+
|
|
159
|
+
if (["create", "add", "new"].includes(verb)) return createClient(args, write);
|
|
160
|
+
if (["list", "ls"].includes(verb)) return listClients(argv.includes("--json"), write);
|
|
161
|
+
if (["show", "get", "info"].includes(verb)) return showClient(args, write);
|
|
162
|
+
if (["set", "edit", "update"].includes(verb)) return setClient(args, write);
|
|
163
|
+
if (["rm", "remove", "delete"].includes(verb)) return removeClient(args, write);
|
|
164
|
+
if (verb === "payee") return setPayee(args, write);
|
|
165
|
+
|
|
166
|
+
// `/client acme` is "show me acme", which is what the word means when it is
|
|
167
|
+
// followed by something that is already a client.
|
|
168
|
+
const business = loadBusiness();
|
|
169
|
+
const found = resolveClient(business, verb);
|
|
170
|
+
if (found.ok) return showClient([verb], write);
|
|
171
|
+
if (verb === "list" || !argv.length) return listClients(false, write);
|
|
172
|
+
|
|
173
|
+
write(err(`unknown /client verb ${JSON.stringify(verb)}`));
|
|
174
|
+
USAGE.forEach(write);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function createClient(args, write) {
|
|
179
|
+
const { fields, rest } = parseFields(args);
|
|
180
|
+
const commas = parseCommaForm(rest.join(" "));
|
|
181
|
+
const name = fields.name || commas.name;
|
|
182
|
+
if (!name) { write(err("a client needs a name")); USAGE.forEach(write); return 1; }
|
|
183
|
+
|
|
184
|
+
const id = String(fields.id || slugify(name));
|
|
185
|
+
if (!id) { write(err(`can't make a handle out of ${JSON.stringify(name)} — pass --id`)); return 1; }
|
|
186
|
+
|
|
187
|
+
const business = loadBusiness();
|
|
188
|
+
if (business.clients[id]) {
|
|
189
|
+
write(err(`${bone(id)} already exists — ${acid(`/client set ${id} --field value`)} to change it`));
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const now = new Date().toISOString();
|
|
194
|
+
const record = {
|
|
195
|
+
id,
|
|
196
|
+
name,
|
|
197
|
+
...commas,
|
|
198
|
+
...fields,
|
|
199
|
+
payee: parsePayee(fields.payee, fields.chain),
|
|
200
|
+
createdAt: now,
|
|
201
|
+
updatedAt: now,
|
|
202
|
+
};
|
|
203
|
+
delete record.chain;
|
|
204
|
+
if (!record.payee) delete record.payee;
|
|
205
|
+
|
|
206
|
+
updateBusiness((data) => { data.clients[id] = record; });
|
|
207
|
+
write(ok(`${bone(id)} — ${record.name}`));
|
|
208
|
+
for (const line of describeClient(record)) write(` ${line}`);
|
|
209
|
+
if (!record.payee) {
|
|
210
|
+
// The payee is where *their* money lands — our receiving address for this
|
|
211
|
+
// relationship, not theirs. CoinPay refuses to settle to an undecided
|
|
212
|
+
// address, and finding that out at invoice time is finding it out too late.
|
|
213
|
+
write(` ${ash("no payee yet —")} ${acid(`/client payee ${id} solana:<address>`)} ${ash("says where their payments land")}`);
|
|
214
|
+
}
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function describeClient(client) {
|
|
219
|
+
const lines = [];
|
|
220
|
+
const pairs = [["url", client.url], ["email", client.email], ["phone", client.phone]];
|
|
221
|
+
for (const [key, value] of pairs) if (value) lines.push(`${ash(key.padEnd(8))} ${value}`);
|
|
222
|
+
for (const [key, value] of Object.entries(client.contact || {})) {
|
|
223
|
+
lines.push(`${ash(`contact.${key}`.padEnd(8))} ${value}`);
|
|
224
|
+
}
|
|
225
|
+
if (client.payee) lines.push(`${ash("payee".padEnd(8))} ${acid(`${client.payee.chain}:${client.payee.address}`)}`);
|
|
226
|
+
if (client.notes) lines.push(`${ash("notes".padEnd(8))} ${client.notes}`);
|
|
227
|
+
return lines;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function listClients(json, write) {
|
|
231
|
+
const { clients } = loadBusiness();
|
|
232
|
+
const ids = Object.keys(clients).sort();
|
|
233
|
+
if (json) { write(JSON.stringify(clients, null, 2)); return 0; }
|
|
234
|
+
if (!ids.length) {
|
|
235
|
+
write(info("no clients yet."));
|
|
236
|
+
write(` ${acid('/client create "Acme Inc", https://acme.com, +1-555-0100')}`);
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
write(table(
|
|
240
|
+
ids.map((id) => [
|
|
241
|
+
bone(id),
|
|
242
|
+
clients[id].name || "",
|
|
243
|
+
ash(clients[id].url || clients[id].email || clients[id].phone || ""),
|
|
244
|
+
clients[id].payee ? acid("payee ✓") : ash("no payee"),
|
|
245
|
+
]),
|
|
246
|
+
{ columns: ["id", "name", "reach", "settle"], indent: 2 },
|
|
247
|
+
));
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function showClient(args, write) {
|
|
252
|
+
const business = loadBusiness();
|
|
253
|
+
const found = resolveClient(business, args[0]);
|
|
254
|
+
if (!found.ok) return reportMiss(found, args[0], write);
|
|
255
|
+
if (args.includes("--json")) { write(JSON.stringify(found.client, null, 2)); return 0; }
|
|
256
|
+
write(` ${clientLabel(found.id, found.client)}`);
|
|
257
|
+
for (const line of describeClient(found.client)) write(` ${line}`);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function setClient(args, write) {
|
|
262
|
+
const { fields, rest } = parseFields(args);
|
|
263
|
+
const business = loadBusiness();
|
|
264
|
+
const found = resolveClient(business, rest[0]);
|
|
265
|
+
if (!found.ok) return reportMiss(found, rest[0], write);
|
|
266
|
+
if (!Object.keys(fields).length) { write(err("nothing to set — /client set <id> --url https://…")); return 1; }
|
|
267
|
+
const updated = updateBusiness((data) => {
|
|
268
|
+
const record = data.clients[found.id];
|
|
269
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
270
|
+
if (key === "payee") { record.payee = parsePayee(value, fields.chain); continue; }
|
|
271
|
+
if (key === "chain") continue;
|
|
272
|
+
if (value && typeof value === "object") {
|
|
273
|
+
for (const [sub, subValue] of Object.entries(value)) setPath(record, `${key}.${sub}`, subValue);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// `--url ""` is how you clear a field; setting it to an empty string
|
|
277
|
+
// would leave a blank line in every report that prints it.
|
|
278
|
+
if (value === "") delete record[key];
|
|
279
|
+
else record[key] = value;
|
|
280
|
+
}
|
|
281
|
+
record.updatedAt = new Date().toISOString();
|
|
282
|
+
return record;
|
|
283
|
+
});
|
|
284
|
+
write(ok(`updated ${clientLabel(found.id, updated)}`));
|
|
285
|
+
for (const line of describeClient(updated)) write(` ${line}`);
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function setPayee(args, write) {
|
|
290
|
+
const { fields, rest } = parseFields(args);
|
|
291
|
+
const business = loadBusiness();
|
|
292
|
+
const found = resolveClient(business, rest[0]);
|
|
293
|
+
if (!found.ok) return reportMiss(found, rest[0], write);
|
|
294
|
+
const payee = parsePayee(rest[1] || fields.payee, fields.chain);
|
|
295
|
+
if (!payee) { write(err("usage: /client payee <id> <chain:address>")); return 1; }
|
|
296
|
+
updateBusiness((data) => {
|
|
297
|
+
data.clients[found.id].payee = payee;
|
|
298
|
+
data.clients[found.id].updatedAt = new Date().toISOString();
|
|
299
|
+
});
|
|
300
|
+
write(ok(`${bone(found.id)} settles to ${acid(`${payee.chain}:${payee.address}`)}`));
|
|
301
|
+
if (payee.chain === "unknown") write(warn("no chain given — say which one, e.g. solana:<address>"));
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function removeClient(args, write) {
|
|
306
|
+
const business = loadBusiness();
|
|
307
|
+
const found = resolveClient(business, args[0]);
|
|
308
|
+
if (!found.ok) return reportMiss(found, args[0], write);
|
|
309
|
+
updateBusiness((data) => {
|
|
310
|
+
delete data.clients[found.id];
|
|
311
|
+
// The rate was a fact about a relationship that no longer exists; tracked
|
|
312
|
+
// time is not, so it stays in the ledger with the id it was booked against.
|
|
313
|
+
delete data.rates[found.id];
|
|
314
|
+
});
|
|
315
|
+
write(ok(`dropped ${bone(found.id)} ${ash("(tracked time is kept — /timer log to see it)")}`));
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function reportMiss(found, token, write) {
|
|
320
|
+
if (found.reason === "ambiguous") {
|
|
321
|
+
write(err(`${JSON.stringify(token)} matches ${found.matches.join(", ")} — say which`));
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
324
|
+
write(err(`no client ${JSON.stringify(token ?? "")} — ${acid("/client list")} to see them`));
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export { USAGE as CLIENT_USAGE };
|
package/src/commands.mjs
CHANGED
|
@@ -696,6 +696,15 @@ const COMMANDS = [
|
|
|
696
696
|
cliVerb("rss", "manage RSS subscriptions and reading lists"),
|
|
697
697
|
|
|
698
698
|
// Extending moshcode from a script — the same fan-out `mcp`/`skill` do.
|
|
699
|
+
// The business layer. Scriptable because that is where it earns its keep: a
|
|
700
|
+
// .mosh that starts a timer, runs the herd, stops the timer and drafts the
|
|
701
|
+
// invoice is the whole workflow in six lines.
|
|
702
|
+
cliVerb("timer", "track time (timer(\"on\", client) … timer(\"off\"))"),
|
|
703
|
+
cliVerb("client", "clients/businesses/merchants — create, list, set, payee"),
|
|
704
|
+
cliVerb("team", "who may do what (create, add, grant, revoke, can)"),
|
|
705
|
+
cliVerb("rate", "what agent time costs (rate(\"set\", \"acme\", \"$100/hour/agent/upto:4\"))"),
|
|
706
|
+
cliVerb("billing", "turn tracked time into an invoice (--mark claims it, --send hands it over)"),
|
|
707
|
+
cliVerb("payments", "the rail invoices go out on (connect, default, disconnect)"),
|
|
699
708
|
cliVerb("plugin", "install/manage moshcode plugins from the marketplace"),
|
|
700
709
|
cliVerb("engines", "list coding engines and whether they're installed"),
|
|
701
710
|
cliVerb("tools", "list the adjacent workflow CLIs and whether they're installed"),
|
package/src/payments.mjs
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// The rail an invoice goes out on.
|
|
2
|
+
//
|
|
3
|
+
// moshcode does not move money. It connects to something that does, and it is
|
|
4
|
+
// deliberately not opinionated about which: CoinPay is the one it knows best,
|
|
5
|
+
// and Stripe, PayPal, Coinbase and a bare wallet address are all first-class
|
|
6
|
+
// here because "which processor" is a decision a business already made, usually
|
|
7
|
+
// years ago, and usually not for reasons a CLI gets to relitigate.
|
|
8
|
+
//
|
|
9
|
+
// Three ways to be connected, because there are three kinds of gateway:
|
|
10
|
+
//
|
|
11
|
+
// cli the gateway ships a command line that owns its own OAuth session
|
|
12
|
+
// (`coinpay login`, `stripe login`). Nothing is stored here — the
|
|
13
|
+
// tool holds the credential, we hold the fact that you chose it.
|
|
14
|
+
// oauth the gateway wants an app registered in a dashboard. We record
|
|
15
|
+
// where the credentials live and NOT what they are.
|
|
16
|
+
// wallet no gateway at all: a chain and an address. The fallback for when
|
|
17
|
+
// the answer to "can we use CoinPay" is no.
|
|
18
|
+
//
|
|
19
|
+
// The thing this file will not do is hold a secret. There is a vault for that
|
|
20
|
+
// (`/secrets`, LogicSRC) and a rule behind it: keys belong somewhere they can
|
|
21
|
+
// be rotated and shared, not in a dotfile in one person's home directory. So
|
|
22
|
+
// `/payments connect stripe` records a *reference* — vault and key name — and
|
|
23
|
+
// says out loud where the secret should go.
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
import { loadBusiness, updateBusiness } from "./business-store.mjs";
|
|
27
|
+
import { parseFields } from "./clients.mjs";
|
|
28
|
+
import { isInstalled } from "./engines.mjs";
|
|
29
|
+
import { acid, ash, bone, err, info, ok, table, warn } from "./ui.mjs";
|
|
30
|
+
|
|
31
|
+
export const GATEWAYS = {
|
|
32
|
+
coinpay: {
|
|
33
|
+
desc: "CoinPay — crypto and fiat settlement, escrow, x402",
|
|
34
|
+
kind: "cli",
|
|
35
|
+
bin: "coinpay",
|
|
36
|
+
tool: "coinpay",
|
|
37
|
+
connect: ["login"],
|
|
38
|
+
currencies: ["USDC", "SOL", "BTC", "ETH", "USD"],
|
|
39
|
+
// The one gateway moshcode can hand a finished invoice to without the
|
|
40
|
+
// operator retyping it — see src/billing.mjs.
|
|
41
|
+
invoice: true,
|
|
42
|
+
},
|
|
43
|
+
stripe: {
|
|
44
|
+
desc: "Stripe — cards, invoices, subscriptions (fiat)",
|
|
45
|
+
kind: "cli",
|
|
46
|
+
bin: "stripe",
|
|
47
|
+
connect: ["login"],
|
|
48
|
+
currencies: ["USD", "EUR", "GBP", "CAD", "AUD"],
|
|
49
|
+
install: "https://docs.stripe.com/stripe-cli",
|
|
50
|
+
},
|
|
51
|
+
paypal: {
|
|
52
|
+
desc: "PayPal — invoices and checkout (fiat)",
|
|
53
|
+
kind: "oauth",
|
|
54
|
+
dashboard: "https://developer.paypal.com/dashboard/applications",
|
|
55
|
+
keys: ["PAYPAL_CLIENT_ID", "PAYPAL_CLIENT_SECRET"],
|
|
56
|
+
currencies: ["USD", "EUR", "GBP"],
|
|
57
|
+
},
|
|
58
|
+
coinbase: {
|
|
59
|
+
desc: "Coinbase Commerce / CDP — crypto checkout and onchain payouts",
|
|
60
|
+
kind: "oauth",
|
|
61
|
+
dashboard: "https://portal.cdp.coinbase.com/",
|
|
62
|
+
keys: ["COINBASE_API_KEY", "COINBASE_API_SECRET"],
|
|
63
|
+
currencies: ["USDC", "BTC", "ETH", "SOL"],
|
|
64
|
+
},
|
|
65
|
+
wallet: {
|
|
66
|
+
desc: "a bare wallet address — no gateway, no fees, no dispute process",
|
|
67
|
+
kind: "wallet",
|
|
68
|
+
currencies: ["USDC", "SOL", "BTC", "ETH"],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Resolve a gateway name to `[key, gateway]`, or null. */
|
|
73
|
+
export function resolveGateway(token) {
|
|
74
|
+
const key = String(token ?? "").trim().toLowerCase();
|
|
75
|
+
return Object.hasOwn(GATEWAYS, key) ? [key, GATEWAYS[key]] : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What is connected, and how, for one gateway.
|
|
80
|
+
*
|
|
81
|
+
* "Connected" means something different per kind, and saying so plainly beats a
|
|
82
|
+
* green tick that means four things. A CLI gateway is connected when the binary
|
|
83
|
+
* is here AND somebody chose it — the binary alone only means it is installed,
|
|
84
|
+
* which is not a decision.
|
|
85
|
+
*/
|
|
86
|
+
export function gatewayState(key, business = loadBusiness()) {
|
|
87
|
+
const gateway = GATEWAYS[key];
|
|
88
|
+
const record = business.payments?.gateways?.[key] || null;
|
|
89
|
+
const installed = gateway.kind === "cli" ? isInstalled(gateway.bin) : null;
|
|
90
|
+
const connected = Boolean(record) && (gateway.kind !== "cli" || installed);
|
|
91
|
+
return { key, gateway, record, installed, connected, isDefault: business.payments?.default === key };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The gateway an invoice should go out on, or null. */
|
|
95
|
+
export function defaultGateway(business = loadBusiness()) {
|
|
96
|
+
const chosen = business.payments?.default;
|
|
97
|
+
if (chosen && GATEWAYS[chosen]) return chosen;
|
|
98
|
+
const connected = Object.keys(GATEWAYS).filter((key) => gatewayState(key, business).connected);
|
|
99
|
+
return connected.length === 1 ? connected[0] : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const USAGE = [
|
|
103
|
+
"usage: /payments [list] · /payments status",
|
|
104
|
+
" /payments connect <gateway> [--vault <name>] [--chain solana --address <addr>]",
|
|
105
|
+
" /payments default <gateway> · /payments disconnect <gateway>",
|
|
106
|
+
` gateways: ${Object.keys(GATEWAYS).join(", ")}`,
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
export function paymentsCommand(argv = [], { write = console.log, run = spawnSync } = {}) {
|
|
110
|
+
const verb = String(argv[0] ?? "list").toLowerCase();
|
|
111
|
+
const args = argv.slice(1);
|
|
112
|
+
|
|
113
|
+
if (["list", "ls", "status", ""].includes(verb)) return listGateways(argv.includes("--json"), write);
|
|
114
|
+
if (["connect", "add", "login"].includes(verb)) return connectGateway(args, write, run);
|
|
115
|
+
if (["disconnect", "rm", "remove", "logout"].includes(verb)) return disconnectGateway(args, write);
|
|
116
|
+
if (["default", "use", "prefer"].includes(verb)) return setDefault(args, write);
|
|
117
|
+
|
|
118
|
+
// `/payments coinpay` is "connect coinpay" — the only thing that word can
|
|
119
|
+
// mean when it is a gateway name and nothing else was said.
|
|
120
|
+
if (resolveGateway(verb)) return connectGateway([verb, ...args], write, run);
|
|
121
|
+
|
|
122
|
+
write(err(`unknown /payments verb ${JSON.stringify(verb)}`));
|
|
123
|
+
USAGE.forEach(write);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function listGateways(json, write) {
|
|
128
|
+
const business = loadBusiness();
|
|
129
|
+
const states = Object.keys(GATEWAYS).map((key) => gatewayState(key, business));
|
|
130
|
+
if (json) {
|
|
131
|
+
write(JSON.stringify(states.map((s) => ({
|
|
132
|
+
key: s.key, kind: s.gateway.kind, connected: s.connected, installed: s.installed,
|
|
133
|
+
default: s.isDefault, currencies: s.gateway.currencies, record: s.record,
|
|
134
|
+
})), null, 2));
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
write(table(
|
|
138
|
+
states.map((s) => [
|
|
139
|
+
s.isDefault ? acid(`${s.key} ★`) : bone(s.key),
|
|
140
|
+
ash(s.gateway.kind),
|
|
141
|
+
statusWord(s),
|
|
142
|
+
ash(s.gateway.currencies.join(" ")),
|
|
143
|
+
]),
|
|
144
|
+
{ columns: ["gateway", "how", "state", "settles in"], indent: 2 },
|
|
145
|
+
));
|
|
146
|
+
const chosen = defaultGateway(business);
|
|
147
|
+
write(chosen
|
|
148
|
+
? ` ${ash("invoices go out on")} ${acid(chosen)}`
|
|
149
|
+
: ` ${ash("no default rail —")} ${acid("/payments connect coinpay")}`);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function statusWord(state) {
|
|
154
|
+
if (state.connected) return acid("connected");
|
|
155
|
+
if (state.gateway.kind === "cli" && state.record && !state.installed) return warn(`chosen, ${state.gateway.bin} missing`);
|
|
156
|
+
if (state.gateway.kind === "cli" && state.installed) return ash("installed, not chosen");
|
|
157
|
+
return ash("—");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function connectGateway(args, write, run) {
|
|
161
|
+
const { fields, rest } = parseFields(args);
|
|
162
|
+
const resolved = resolveGateway(rest[0]);
|
|
163
|
+
if (!resolved) {
|
|
164
|
+
write(err(`unknown gateway ${JSON.stringify(rest[0] ?? "")} — one of ${Object.keys(GATEWAYS).join(", ")}`));
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
const [key, gateway] = resolved;
|
|
168
|
+
|
|
169
|
+
if (gateway.kind === "wallet") return connectWallet(key, fields, write);
|
|
170
|
+
if (gateway.kind === "oauth") return connectOauth(key, gateway, fields, write);
|
|
171
|
+
|
|
172
|
+
// A CLI gateway: the binary owns the session, so the honest connect is to run
|
|
173
|
+
// its own login and record which rail was chosen.
|
|
174
|
+
if (!isInstalled(gateway.bin)) {
|
|
175
|
+
write(err(`${bone(gateway.bin)} is not on PATH`));
|
|
176
|
+
if (gateway.tool) write(` ${acid(`/install ${gateway.tool}`)}`);
|
|
177
|
+
else if (gateway.install) write(` ${ash(gateway.install)}`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
write(info(`handing you to ${bone(gateway.bin)} — it owns its own session`));
|
|
181
|
+
const result = run(gateway.bin, gateway.connect, { stdio: "inherit" });
|
|
182
|
+
if (result?.error) { write(err(String(result.error.message || result.error))); return 1; }
|
|
183
|
+
if (result?.status) {
|
|
184
|
+
write(err(`${gateway.bin} ${gateway.connect.join(" ")} exited ${result.status} — nothing recorded`));
|
|
185
|
+
return result.status;
|
|
186
|
+
}
|
|
187
|
+
record(key, { via: "cli", bin: gateway.bin });
|
|
188
|
+
write(ok(`${bone(key)} connected ${ash(`(${gateway.bin} holds the credential, not moshcode)`)}`));
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function connectOauth(key, gateway, fields, write) {
|
|
193
|
+
const vault = fields.vault && fields.vault !== true ? String(fields.vault) : null;
|
|
194
|
+
record(key, { via: "oauth", vault, keys: gateway.keys });
|
|
195
|
+
write(ok(`${bone(key)} recorded as your rail`));
|
|
196
|
+
write(` ${ash("register an app:")} ${gateway.dashboard}`);
|
|
197
|
+
write(` ${ash("then put the credentials in the vault, not in a dotfile:")}`);
|
|
198
|
+
const where = vault ? ` --vault ${vault}` : "";
|
|
199
|
+
for (const name of gateway.keys) write(` ${acid(`/secrets set ${name}${where}`)}`);
|
|
200
|
+
if (!vault) write(` ${ash("no --vault given — say which one so this record points somewhere")}`);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function connectWallet(key, fields, write) {
|
|
205
|
+
const address = fields.address && fields.address !== true ? String(fields.address) : null;
|
|
206
|
+
const chain = fields.chain && fields.chain !== true ? String(fields.chain).toLowerCase() : null;
|
|
207
|
+
if (!address || !chain) {
|
|
208
|
+
write(err("a wallet needs both: /payments connect wallet --chain solana --address <addr>"));
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
record(key, { via: "wallet", chain, address });
|
|
212
|
+
write(ok(`paid straight to ${acid(`${chain}:${address}`)}`));
|
|
213
|
+
write(` ${ash("no gateway means no chargeback, no dispute, and no invoice status — it is on you to reconcile")}`);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function record(key, details) {
|
|
218
|
+
updateBusiness((data) => {
|
|
219
|
+
data.payments.gateways ||= {};
|
|
220
|
+
data.payments.gateways[key] = { ...details, connectedAt: new Date().toISOString() };
|
|
221
|
+
// First rail wins the default, because somebody who connected exactly one
|
|
222
|
+
// gateway has already answered "which one".
|
|
223
|
+
if (!data.payments.default) data.payments.default = key;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function disconnectGateway(args, write) {
|
|
228
|
+
const resolved = resolveGateway(args[0]);
|
|
229
|
+
if (!resolved) { write(err(`unknown gateway ${JSON.stringify(args[0] ?? "")}`)); return 1; }
|
|
230
|
+
const [key, gateway] = resolved;
|
|
231
|
+
const had = updateBusiness((data) => {
|
|
232
|
+
const existed = Boolean(data.payments?.gateways?.[key]);
|
|
233
|
+
if (data.payments?.gateways) delete data.payments.gateways[key];
|
|
234
|
+
if (data.payments?.default === key) delete data.payments.default;
|
|
235
|
+
return existed;
|
|
236
|
+
});
|
|
237
|
+
if (!had) { write(info(`${key} was not connected`)); return 1; }
|
|
238
|
+
write(ok(`${bone(key)} disconnected`));
|
|
239
|
+
if (gateway.kind === "cli") {
|
|
240
|
+
// Forgetting the choice is not the same as ending the session, and saying
|
|
241
|
+
// so is the difference between a clean disconnect and a surprise later.
|
|
242
|
+
write(` ${ash(`${gateway.bin} is still logged in —`)} ${acid(`${gateway.bin} logout`)} ${ash("if you meant that too")}`);
|
|
243
|
+
}
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function setDefault(args, write) {
|
|
248
|
+
const resolved = resolveGateway(args[0]);
|
|
249
|
+
if (!resolved) { write(err(`unknown gateway ${JSON.stringify(args[0] ?? "")}`)); return 1; }
|
|
250
|
+
const [key] = resolved;
|
|
251
|
+
const state = gatewayState(key);
|
|
252
|
+
if (!state.record) { write(err(`${key} is not connected — ${acid(`/payments connect ${key}`)}`)); return 1; }
|
|
253
|
+
updateBusiness((data) => { data.payments.default = key; });
|
|
254
|
+
write(ok(`invoices go out on ${bone(key)}`));
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export { USAGE as PAYMENTS_USAGE };
|