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/timer.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// `/timer on` … `/timer off`. The whole feature, in two words.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately its own thing. Time tracking is useful to somebody who bills
|
|
4
|
+
// nobody — a freelancer proving an estimate, an employee filling a timesheet,
|
|
5
|
+
// a person who just wants to know where Tuesday went — and coupling it to a
|
|
6
|
+
// payment gateway would mean nobody could use it until they had one. So the
|
|
7
|
+
// timer writes to a local ledger and knows nothing about money. `/billing`
|
|
8
|
+
// reads that ledger later and applies a rate to it; `/payments` is a third
|
|
9
|
+
// thing again. Each of the three is worth having without the other two.
|
|
10
|
+
//
|
|
11
|
+
// What the timer does know about is agents, because that is what makes this
|
|
12
|
+
// different from every other stopwatch. An hour of moshcode is not an hour of
|
|
13
|
+
// work — it is an hour times however many engines were running in it — and
|
|
14
|
+
// `--agents auto` reads that off the herd instead of asking you to remember.
|
|
15
|
+
import { loadBusiness, loadTimers, newId, saveTimers, updateTimers } from "./business-store.mjs";
|
|
16
|
+
import { clientLabel, parseFields, resolveClient } from "./clients.mjs";
|
|
17
|
+
import { chargeFor, describeRate, formatMoney, rateFor } from "./rates.mjs";
|
|
18
|
+
import { acid, amber, ash, bone, err, info, ok, table, warn } from "./ui.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse "2h", "90m", "1h30m", "1:30", "45", "0.5h" into seconds.
|
|
22
|
+
*
|
|
23
|
+
* A bare number is minutes. That is the unit people say out loud when they are
|
|
24
|
+
* logging time after the fact ("put 45 on that"), and hours are always written
|
|
25
|
+
* with the h.
|
|
26
|
+
*/
|
|
27
|
+
export function parseDuration(text) {
|
|
28
|
+
const raw = String(text ?? "").trim().toLowerCase();
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
const clock = raw.match(/^(\d+):([0-5]\d)$/);
|
|
31
|
+
if (clock) return Number(clock[1]) * 3600 + Number(clock[2]) * 60;
|
|
32
|
+
if (/^\d+(\.\d+)?$/.test(raw)) return Math.round(Number(raw) * 60);
|
|
33
|
+
const parts = raw.match(/(\d+(?:\.\d+)?)\s*([hms])/g);
|
|
34
|
+
if (!parts) return null;
|
|
35
|
+
const scale = { h: 3600, m: 60, s: 1 };
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
const [, n, unit] = part.match(/(\d+(?:\.\d+)?)\s*([hms])/);
|
|
39
|
+
total += Number(n) * scale[unit];
|
|
40
|
+
}
|
|
41
|
+
return Math.round(total);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** "2h 15m", "45m", "12s" — the shortest thing that is still exact enough. */
|
|
45
|
+
export function humanDuration(seconds) {
|
|
46
|
+
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
|
47
|
+
if (total < 60) return `${total}s`;
|
|
48
|
+
const h = Math.floor(total / 3600);
|
|
49
|
+
const m = Math.round((total % 3600) / 60);
|
|
50
|
+
if (!h) return `${m}m`;
|
|
51
|
+
return m ? `${h}h ${m}m` : `${h}h`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Local date, as YYYY-MM-DD — the grouping every timesheet uses. */
|
|
55
|
+
export function dayOf(iso) {
|
|
56
|
+
const d = new Date(iso);
|
|
57
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
58
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How many engines are running right now, for `--agents auto`.
|
|
63
|
+
*
|
|
64
|
+
* Imported lazily and wrapped: the timer must work on a box with no herd, no
|
|
65
|
+
* state directory and no sessions, and a stopwatch that throws because the
|
|
66
|
+
* roster could not be read would be a worse tool than one that says 1.
|
|
67
|
+
*/
|
|
68
|
+
export async function countRunningAgents() {
|
|
69
|
+
try {
|
|
70
|
+
const { roster } = await import("./herd-cli.mjs");
|
|
71
|
+
const live = roster().filter((r) => r.kind !== "remote" && !["done", "exited", "dead"].includes(String(r.state || "").toLowerCase()));
|
|
72
|
+
return Math.max(1, live.length);
|
|
73
|
+
} catch {
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Seconds an open timer has run for. */
|
|
79
|
+
export function elapsed(active, now = Date.now()) {
|
|
80
|
+
const started = new Date(active?.startedAt ?? 0).getTime();
|
|
81
|
+
if (!Number.isFinite(started) || !started) return 0;
|
|
82
|
+
return Math.max(0, Math.round((now - started) / 1000));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const USAGE = [
|
|
86
|
+
"usage: /timer on [client] [--task \"…\"] [--agents N|auto] [--note …]",
|
|
87
|
+
" /timer off [--note …] · /timer status · /timer switch <client>",
|
|
88
|
+
" /timer log [--client <id>] [--today|--week|--since <date>] [--unbilled] [--json]",
|
|
89
|
+
" /timer add <client> <2h30m> [--task …] [--agents N] · /timer rm <id>",
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export async function timerCommand(argv = [], { write = console.log, countAgents = countRunningAgents, now = () => Date.now() } = {}) {
|
|
93
|
+
const verb = String(argv[0] ?? "status").toLowerCase();
|
|
94
|
+
const args = argv.slice(1);
|
|
95
|
+
|
|
96
|
+
if (["on", "start", "begin", "go"].includes(verb)) return startTimer(args, write, countAgents, now);
|
|
97
|
+
if (["off", "stop", "end", "done"].includes(verb)) return stopTimer(args, write, now);
|
|
98
|
+
if (["switch", "swap"].includes(verb)) return switchTimer(args, write, countAgents, now);
|
|
99
|
+
if (["status", "", "show"].includes(verb)) return timerStatus(argv.includes("--json"), write, now);
|
|
100
|
+
if (["log", "list", "ls", "entries"].includes(verb)) return timerLog(args, write, now);
|
|
101
|
+
if (verb === "add") return addEntry(args, write, now);
|
|
102
|
+
if (["rm", "remove", "delete"].includes(verb)) return removeEntry(args, write);
|
|
103
|
+
|
|
104
|
+
write(err(`unknown /timer verb ${JSON.stringify(verb)} — it is on, off, status, log, add or rm`));
|
|
105
|
+
USAGE.forEach(write);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function startTimer(args, write, countAgents, now) {
|
|
110
|
+
const { fields, rest } = parseFields(args);
|
|
111
|
+
const state = loadTimers();
|
|
112
|
+
if (state.active) {
|
|
113
|
+
// Refuse rather than stack. Two open timers is not a state anyone means to
|
|
114
|
+
// be in, and silently closing the first would rewrite history nobody asked
|
|
115
|
+
// to have rewritten.
|
|
116
|
+
write(err(`already running for ${bone(state.active.client || "no client")} — ${humanDuration(elapsed(state.active, now()))} so far`));
|
|
117
|
+
write(` ${acid("/timer off")} ${ash("to close it, or")} ${acid("/timer switch <client>")} ${ash("to do both at once")}`);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const business = loadBusiness();
|
|
122
|
+
const token = rest[0] || fields.client;
|
|
123
|
+
let clientId = null;
|
|
124
|
+
if (token && token !== true) {
|
|
125
|
+
const found = resolveClient(business, token);
|
|
126
|
+
if (!found.ok) {
|
|
127
|
+
if (found.reason === "ambiguous") { write(err(`${JSON.stringify(token)} matches ${found.matches.join(", ")} — say which`)); return 1; }
|
|
128
|
+
// An unknown client is a typo far more often than a new client, and the
|
|
129
|
+
// fix is one command away. Starting a timer against a name that does not
|
|
130
|
+
// exist is how time ends up on an invoice nobody can send.
|
|
131
|
+
write(err(`no client ${JSON.stringify(token)} — ${acid(`/client create ${token}`)} first, or ${acid("/timer on")} with no client`));
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
clientId = found.id;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const agents = fields.agents === "auto" || fields.agents === true
|
|
138
|
+
? await countAgents()
|
|
139
|
+
: Math.max(1, Number(fields.agents) || 1);
|
|
140
|
+
|
|
141
|
+
const active = {
|
|
142
|
+
id: newId("t", now()),
|
|
143
|
+
client: clientId,
|
|
144
|
+
task: fields.task && fields.task !== true ? String(fields.task) : (rest.slice(1).join(" ") || null),
|
|
145
|
+
agents,
|
|
146
|
+
note: fields.note && fields.note !== true ? String(fields.note) : null,
|
|
147
|
+
startedAt: new Date(now()).toISOString(),
|
|
148
|
+
};
|
|
149
|
+
updateTimers((data) => { data.active = active; });
|
|
150
|
+
|
|
151
|
+
const who = clientId ? clientLabel(clientId, business.clients[clientId]) : ash("no client");
|
|
152
|
+
write(ok(`timer on — ${who}${active.task ? ` ${ash("·")} ${active.task}` : ""}`));
|
|
153
|
+
const rate = rateFor(business, clientId);
|
|
154
|
+
if (rate) write(` ${ash(describeRate(rate))}${agents > 1 ? ash(` · ${agents} agents`) : ""}`);
|
|
155
|
+
else if (agents > 1) write(` ${ash(`${agents} agents`)}`);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function stopTimer(args, write, now) {
|
|
160
|
+
const { fields } = parseFields(args);
|
|
161
|
+
const state = loadTimers();
|
|
162
|
+
if (!state.active) {
|
|
163
|
+
write(info("no timer running."));
|
|
164
|
+
write(` ${acid("/timer on <client>")}`);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
const seconds = elapsed(state.active, now());
|
|
168
|
+
const entry = {
|
|
169
|
+
...state.active,
|
|
170
|
+
endedAt: new Date(now()).toISOString(),
|
|
171
|
+
seconds,
|
|
172
|
+
billed: false,
|
|
173
|
+
invoice: null,
|
|
174
|
+
note: fields.note && fields.note !== true ? String(fields.note) : state.active.note,
|
|
175
|
+
};
|
|
176
|
+
saveTimers({ ...state, active: null, entries: [...state.entries, entry] });
|
|
177
|
+
|
|
178
|
+
const business = loadBusiness();
|
|
179
|
+
const who = entry.client ? clientLabel(entry.client, business.clients[entry.client]) : ash("no client");
|
|
180
|
+
write(ok(`timer off — ${bone(humanDuration(seconds))} on ${who}${entry.task ? ` ${ash("·")} ${entry.task}` : ""}`));
|
|
181
|
+
|
|
182
|
+
const rate = rateFor(business, entry.client);
|
|
183
|
+
const charge = chargeFor(entry, rate);
|
|
184
|
+
if (charge?.amount != null) {
|
|
185
|
+
const capped = rate.cap && entry.agents > rate.cap;
|
|
186
|
+
write(` ${acid(formatMoney(charge.amount, charge.currency))} ${ash(`at ${describeRate(rate)}`)}`);
|
|
187
|
+
if (capped) write(` ${ash(`${entry.agents} agents ran; billed ${rate.cap} — the cap you promised them`)}`);
|
|
188
|
+
} else if (charge?.flat) {
|
|
189
|
+
write(` ${ash("flat project fee — /billing adds it once, not per entry")}`);
|
|
190
|
+
} else if (!rate) {
|
|
191
|
+
write(` ${ash("no rate for this one —")} ${acid(`/rate set ${entry.client || "default"} $100/hour/agent`)}`);
|
|
192
|
+
}
|
|
193
|
+
write(` ${ash(`entry ${entry.id}`)}`);
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function switchTimer(args, write, countAgents, now) {
|
|
198
|
+
const state = loadTimers();
|
|
199
|
+
if (state.active) {
|
|
200
|
+
const code = stopTimer([], write, now);
|
|
201
|
+
if (code) return code;
|
|
202
|
+
}
|
|
203
|
+
return startTimer(args, write, countAgents, now);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function timerStatus(json, write, now) {
|
|
207
|
+
const state = loadTimers();
|
|
208
|
+
const business = loadBusiness();
|
|
209
|
+
if (json) {
|
|
210
|
+
write(JSON.stringify({
|
|
211
|
+
active: state.active ? { ...state.active, seconds: elapsed(state.active, now()) } : null,
|
|
212
|
+
entries: state.entries.length,
|
|
213
|
+
}, null, 2));
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
if (!state.active) {
|
|
217
|
+
const today = state.entries.filter((e) => dayOf(e.endedAt) === dayOf(new Date(now()).toISOString()));
|
|
218
|
+
const seconds = today.reduce((sum, e) => sum + (e.seconds || 0), 0);
|
|
219
|
+
write(info(seconds ? `no timer running — ${humanDuration(seconds)} logged today` : "no timer running."));
|
|
220
|
+
write(` ${acid("/timer on <client>")}`);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
const seconds = elapsed(state.active, now());
|
|
224
|
+
const who = state.active.client ? clientLabel(state.active.client, business.clients[state.active.client]) : ash("no client");
|
|
225
|
+
write(` ${amber("●")} ${bone(humanDuration(seconds))} ${ash("on")} ${who}${state.active.task ? ` ${ash("·")} ${state.active.task}` : ""}`);
|
|
226
|
+
const rate = rateFor(business, state.active.client);
|
|
227
|
+
const charge = chargeFor({ seconds, agents: state.active.agents }, rate);
|
|
228
|
+
if (charge?.amount != null) write(` ${acid(formatMoney(charge.amount, charge.currency))} ${ash(`so far · ${describeRate(rate)}`)}`);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Which entries a filter selects, oldest first.
|
|
234
|
+
*
|
|
235
|
+
* Exported because `/billing` selects the same way, and two filters that drift
|
|
236
|
+
* apart would mean the invoice and the timesheet behind it disagree.
|
|
237
|
+
*/
|
|
238
|
+
export function selectEntries(entries, { client = null, since = null, until = null, unbilled = false } = {}) {
|
|
239
|
+
const from = since ? new Date(since).getTime() : null;
|
|
240
|
+
const to = until ? new Date(until).getTime() : null;
|
|
241
|
+
return entries
|
|
242
|
+
.filter((e) => (client ? e.client === client : true))
|
|
243
|
+
.filter((e) => (unbilled ? !e.billed : true))
|
|
244
|
+
.filter((e) => {
|
|
245
|
+
if (from == null && to == null) return true;
|
|
246
|
+
const at = new Date(e.endedAt || e.startedAt).getTime();
|
|
247
|
+
if (from != null && at < from) return false;
|
|
248
|
+
if (to != null && at > to) return false;
|
|
249
|
+
return true;
|
|
250
|
+
})
|
|
251
|
+
.sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `--today`, `--week`, `--month`, `--since <date>` → an ISO lower bound. */
|
|
255
|
+
export function windowFrom(fields, now = Date.now()) {
|
|
256
|
+
if (fields.since && fields.since !== true) return new Date(fields.since).toISOString();
|
|
257
|
+
const d = new Date(now);
|
|
258
|
+
if (fields.today) return new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
|
|
259
|
+
if (fields.week) return new Date(now - 7 * 86400_000).toISOString();
|
|
260
|
+
if (fields.month) return new Date(d.getFullYear(), d.getMonth(), 1).toISOString();
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function timerLog(args, write, now) {
|
|
265
|
+
const { fields } = parseFields(args);
|
|
266
|
+
const state = loadTimers();
|
|
267
|
+
const business = loadBusiness();
|
|
268
|
+
let clientId = null;
|
|
269
|
+
if (fields.client && fields.client !== true) {
|
|
270
|
+
const found = resolveClient(business, fields.client);
|
|
271
|
+
if (!found.ok) { write(err(`no client ${JSON.stringify(fields.client)}`)); return 1; }
|
|
272
|
+
clientId = found.id;
|
|
273
|
+
}
|
|
274
|
+
const rows = selectEntries(state.entries, {
|
|
275
|
+
client: clientId,
|
|
276
|
+
since: windowFrom(fields, now()),
|
|
277
|
+
unbilled: Boolean(fields.unbilled),
|
|
278
|
+
});
|
|
279
|
+
if (fields.json) { write(JSON.stringify(rows, null, 2)); return 0; }
|
|
280
|
+
if (!rows.length) { write(info("nothing tracked in that window.")); return 0; }
|
|
281
|
+
|
|
282
|
+
const limit = Number(fields.limit) > 0 ? Number(fields.limit) : 50;
|
|
283
|
+
const shown = rows.slice(-limit);
|
|
284
|
+
write(table(
|
|
285
|
+
shown.map((e) => [
|
|
286
|
+
ash(e.id),
|
|
287
|
+
ash(dayOf(e.startedAt)),
|
|
288
|
+
bone(e.client || "—"),
|
|
289
|
+
humanDuration(e.seconds),
|
|
290
|
+
e.agents > 1 ? `${e.agents}×` : "",
|
|
291
|
+
e.task || ash(e.note || ""),
|
|
292
|
+
e.billed ? acid("billed") : "",
|
|
293
|
+
]),
|
|
294
|
+
{ columns: ["id", "day", "client", "time", "agents", "what", ""], indent: 2 },
|
|
295
|
+
));
|
|
296
|
+
const seconds = rows.reduce((sum, e) => sum + (e.seconds || 0), 0);
|
|
297
|
+
write(` ${ash("total")} ${bone(humanDuration(seconds))} ${ash(`over ${rows.length} ${rows.length === 1 ? "entry" : "entries"}`)}`);
|
|
298
|
+
if (shown.length < rows.length) write(` ${ash(`showing the last ${shown.length} — --limit ${rows.length} for all of them`)}`);
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function addEntry(args, write, now) {
|
|
303
|
+
const { fields, rest } = parseFields(args);
|
|
304
|
+
const business = loadBusiness();
|
|
305
|
+
// `/timer add 2h` with no client is a legitimate thing to want, so the client
|
|
306
|
+
// is whichever word is not a duration.
|
|
307
|
+
const durationToken = rest.find((word) => parseDuration(word) != null);
|
|
308
|
+
const clientToken = rest.find((word) => word !== durationToken);
|
|
309
|
+
const seconds = parseDuration(durationToken);
|
|
310
|
+
if (!seconds) { write(err("how long? /timer add acme 2h30m")); return 1; }
|
|
311
|
+
|
|
312
|
+
let clientId = null;
|
|
313
|
+
if (clientToken) {
|
|
314
|
+
const found = resolveClient(business, clientToken);
|
|
315
|
+
if (!found.ok) { write(err(`no client ${JSON.stringify(clientToken)} — ${acid(`/client create ${clientToken}`)}`)); return 1; }
|
|
316
|
+
clientId = found.id;
|
|
317
|
+
}
|
|
318
|
+
const endedAt = fields.at && fields.at !== true ? new Date(fields.at) : new Date(now());
|
|
319
|
+
if (Number.isNaN(endedAt.getTime())) { write(err(`can't read ${JSON.stringify(fields.at)} as a date`)); return 1; }
|
|
320
|
+
|
|
321
|
+
const entry = {
|
|
322
|
+
id: newId("t", now()),
|
|
323
|
+
client: clientId,
|
|
324
|
+
task: fields.task && fields.task !== true ? String(fields.task) : null,
|
|
325
|
+
agents: Math.max(1, Number(fields.agents) || 1),
|
|
326
|
+
note: fields.note && fields.note !== true ? String(fields.note) : null,
|
|
327
|
+
startedAt: new Date(endedAt.getTime() - seconds * 1000).toISOString(),
|
|
328
|
+
endedAt: endedAt.toISOString(),
|
|
329
|
+
seconds,
|
|
330
|
+
billed: false,
|
|
331
|
+
invoice: null,
|
|
332
|
+
manual: true,
|
|
333
|
+
};
|
|
334
|
+
updateTimers((data) => { data.entries.push(entry); });
|
|
335
|
+
write(ok(`logged ${bone(humanDuration(seconds))} on ${entry.client ? bone(entry.client) : ash("no client")} ${ash(`(${entry.id})`)}`));
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function removeEntry(args, write) {
|
|
340
|
+
const id = String(args[0] ?? "").trim();
|
|
341
|
+
if (!id) { write(err("usage: /timer rm <id>")); return 1; }
|
|
342
|
+
const removed = updateTimers((data) => {
|
|
343
|
+
const index = data.entries.findIndex((e) => e.id === id);
|
|
344
|
+
if (index === -1) return null;
|
|
345
|
+
const [entry] = data.entries.splice(index, 1);
|
|
346
|
+
return entry;
|
|
347
|
+
});
|
|
348
|
+
if (!removed) { write(err(`no entry ${JSON.stringify(id)} — ${acid("/timer log")}`)); return 1; }
|
|
349
|
+
if (removed.billed) write(warn(`that one was already billed (invoice ${removed.invoice || "?"}) — the invoice still says it happened`));
|
|
350
|
+
write(ok(`dropped ${ash(id)} ${ash(`(${humanDuration(removed.seconds)})`)}`));
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export { USAGE as TIMER_USAGE };
|
package/src/tui.mjs
CHANGED
|
@@ -877,6 +877,19 @@ export async function tui() {
|
|
|
877
877
|
printHelp(cmd);
|
|
878
878
|
continue;
|
|
879
879
|
}
|
|
880
|
+
// The team gate (src/teams.mjs). After --help, because asking what a
|
|
881
|
+
// command does is not doing it, and before everything else, because a
|
|
882
|
+
// check that some commands skip is a check nobody can reason about. Costs
|
|
883
|
+
// one small JSON read, and only when MOSHCODE_MEMBER is set.
|
|
884
|
+
if (process.env.MOSHCODE_MEMBER) {
|
|
885
|
+
const { checkAccess } = await import("./teams.mjs");
|
|
886
|
+
const gate = checkAccess(cmd, rest);
|
|
887
|
+
if (!gate.allowed) {
|
|
888
|
+
console.log(err(gate.reason));
|
|
889
|
+
console.log(` ${ash("ask an owner for")} ${acid(`/team grant ${gate.acting?.teamId || "<team>"} ${gate.acting?.handle || "<you>"} ${gate.permission}`)}`);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
880
893
|
if (cmd === "new") {
|
|
881
894
|
if (rest.length) { console.log(err("usage: /new")); continue; }
|
|
882
895
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
@@ -1095,6 +1108,47 @@ export async function tui() {
|
|
|
1095
1108
|
rl = mkrl();
|
|
1096
1109
|
continue;
|
|
1097
1110
|
}
|
|
1111
|
+
// The business layer. Lazily imported for the same reason the CLI does it,
|
|
1112
|
+
// and none of these close the readline interface: they print and return,
|
|
1113
|
+
// like /ps and /cost, so the prompt never moves.
|
|
1114
|
+
if (cmd === "timer") {
|
|
1115
|
+
const { timerCommand } = await import("./timer.mjs");
|
|
1116
|
+
await timerCommand(rest, { write: (l) => console.log(l) });
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (cmd === "client" || cmd === "business" || cmd === "merchant" || cmd === "customer") {
|
|
1120
|
+
const { clientCommand } = await import("./clients.mjs");
|
|
1121
|
+
clientCommand(rest, { write: (l) => console.log(l) });
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (cmd === "team" || cmd === "teams") {
|
|
1125
|
+
const { teamCommand } = await import("./teams.mjs");
|
|
1126
|
+
teamCommand(rest, { write: (l) => console.log(l) });
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
if (cmd === "rate" || cmd === "rates") {
|
|
1130
|
+
const { rateCommand } = await import("./rates.mjs");
|
|
1131
|
+
rateCommand(rest, { write: (l) => console.log(l) });
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
if (cmd === "billing" || cmd === "invoice") {
|
|
1135
|
+
const { billingCommand } = await import("./billing.mjs");
|
|
1136
|
+
// Closed and reopened around the call: `--send --yes` hands the terminal
|
|
1137
|
+
// to the gateway's own CLI, which may prompt.
|
|
1138
|
+
rl.close();
|
|
1139
|
+
billingCommand(rest, { write: (l) => console.log(l) });
|
|
1140
|
+
rl = mkrl();
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
if (cmd === "payments") {
|
|
1144
|
+
const { paymentsCommand } = await import("./payments.mjs");
|
|
1145
|
+
// Same reason: `/payments connect coinpay` runs `coinpay login`, which is
|
|
1146
|
+
// an interactive session of somebody else's.
|
|
1147
|
+
rl.close();
|
|
1148
|
+
paymentsCommand(rest, { write: (l) => console.log(l) });
|
|
1149
|
+
rl = mkrl();
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1098
1152
|
if (cmd === "plugin" || cmd === "plugins") {
|
|
1099
1153
|
await pluginCommand(rest);
|
|
1100
1154
|
continue;
|