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.
@@ -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"),
@@ -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 };