moshcode 0.70.0 → 0.72.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/.claude-plugin/marketplace.json +24 -0
- package/README.md +120 -0
- package/bin/moshcode.mjs +46 -0
- package/package.json +1 -1
- package/plugins/billing/.claude-plugin/plugin.json +13 -0
- package/plugins/billing/README.md +42 -0
- package/plugins/billing/commands/hours.md +37 -0
- package/plugins/billing/commands/invoice.md +39 -0
- package/plugins/billing/commands/rate.md +40 -0
- package/plugins/billing/commands/report.md +28 -0
- package/plugins/timer/.claude-plugin/plugin.json +13 -0
- package/plugins/timer/README.md +36 -0
- package/plugins/timer/commands/report.md +32 -0
- package/plugins/timer/commands/start.md +33 -0
- package/plugins/timer/commands/status.md +27 -0
- package/plugins/timer/commands/stop.md +30 -0
- package/prd/0012-billing-baked-into-the-agent-cli.md +129 -0
- package/src/billing.mjs +363 -0
- package/src/business-delegate.mjs +105 -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/plugins.mjs +39 -7
- package/src/rates.mjs +368 -0
- package/src/teams.mjs +459 -0
- package/src/timer.mjs +354 -0
- package/src/tools.mjs +18 -0
- package/src/tui.mjs +77 -0
package/src/teams.mjs
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// Who is allowed to do what, on a box you handed to somebody else.
|
|
2
|
+
//
|
|
3
|
+
// A devops shop runs moshcode on machines its own people and its clients sit
|
|
4
|
+
// at. "Preshy can use the CoinPay tool, the client can read invoices and
|
|
5
|
+
// nothing else" is a real sentence somebody needs to be able to write down, and
|
|
6
|
+
// until now the only place to write it was a wiki nobody reads.
|
|
7
|
+
//
|
|
8
|
+
// A permission is `surface:target` — `tools:coinpay`, `agents:*`,
|
|
9
|
+
// `billing:read`. Grants are additive and wildcards widen: `*` is everything,
|
|
10
|
+
// `tools:*` is every tool, and a bare `tools` means the same as `tools:*`
|
|
11
|
+
// because that is what somebody means when they type it.
|
|
12
|
+
//
|
|
13
|
+
// What this is NOT: security. moshcode runs as the person at the keyboard, with
|
|
14
|
+
// their files and their shell, and anyone who can type `/team` can also type
|
|
15
|
+
// `vim ~/.moshcode/business.json`. This is a guardrail — it stops the wrong
|
|
16
|
+
// command being run by accident and makes the intended split explicit and
|
|
17
|
+
// reviewable. A boundary that has to *hold* against someone is an OS account,
|
|
18
|
+
// a container, or a scoped credential, and this file will not pretend otherwise.
|
|
19
|
+
import { loadBusiness, slugify, updateBusiness } from "./business-store.mjs";
|
|
20
|
+
import { parseFields } from "./clients.mjs";
|
|
21
|
+
import { formatRate, parseRate } from "./rates.mjs";
|
|
22
|
+
import { acid, ash, bone, err, info, ok, table, warn } from "./ui.mjs";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What each role can do before anybody grants it anything.
|
|
26
|
+
*
|
|
27
|
+
* Roles exist so the common case is one word instead of six permissions. They
|
|
28
|
+
* are a starting set, not a ceiling: `/team grant` adds to whatever the role
|
|
29
|
+
* already carries, and nothing here can be taken away except by changing the
|
|
30
|
+
* role.
|
|
31
|
+
*/
|
|
32
|
+
export const ROLES = {
|
|
33
|
+
owner: ["*"],
|
|
34
|
+
admin: ["agents:*", "tools:*", "timer:*", "client:*", "rate:*", "billing:*", "team:read", "payments:read"],
|
|
35
|
+
member: ["agents:*", "timer:*", "client:read", "rate:read", "billing:read"],
|
|
36
|
+
// The client is on the outside of the relationship looking in: they can see
|
|
37
|
+
// what they are being billed and the time behind it, and touch nothing.
|
|
38
|
+
client: ["billing:read", "timer:read", "client:read"],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const DEFAULT_ROLE = "member";
|
|
42
|
+
|
|
43
|
+
/** Surfaces a permission can name. Used to spot a typo'd grant at write time. */
|
|
44
|
+
export const SURFACES = ["agents", "tools", "timer", "client", "team", "rate", "billing", "payments", "herd", "shell", "*"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalise the several ways people write a permission down.
|
|
48
|
+
*
|
|
49
|
+
* `tools:coinpay`, `tools/coinpay` and `allow(tools/coinpay)` are the same
|
|
50
|
+
* grant. The bracket form is how it gets said in conversation ("allow them
|
|
51
|
+
* plugins/tools"), and a command that only accepts the canonical spelling
|
|
52
|
+
* makes somebody translate their own sentence before they can use it.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizePermission(raw) {
|
|
55
|
+
let text = String(raw ?? "").trim().toLowerCase();
|
|
56
|
+
const call = text.match(/^(?:allow|grant|deny)\(([^)]*)\)$/);
|
|
57
|
+
if (call) text = call[1].trim();
|
|
58
|
+
text = text.replace(/[/\s]+/g, ":").replace(/:+/g, ":").replace(/^:|:$/g, "");
|
|
59
|
+
if (!text) return null;
|
|
60
|
+
const [surface, ...rest] = text.split(":");
|
|
61
|
+
const target = rest.join(":") || "*";
|
|
62
|
+
return `${surface}:${target}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Does this grant list allow `permission`? */
|
|
66
|
+
export function can(grants, permission) {
|
|
67
|
+
const want = normalizePermission(permission);
|
|
68
|
+
if (!want) return false;
|
|
69
|
+
const [surface, target] = want.split(":");
|
|
70
|
+
return (grants || []).some((raw) => {
|
|
71
|
+
const grant = normalizePermission(raw);
|
|
72
|
+
if (!grant) return false;
|
|
73
|
+
const [gSurface, gTarget] = grant.split(":");
|
|
74
|
+
if (gSurface !== "*" && gSurface !== surface) return false;
|
|
75
|
+
if (gTarget === "*" || gTarget === target) return true;
|
|
76
|
+
// `billing:*` covers `billing:read`; `billing:read` does not cover
|
|
77
|
+
// `billing:write`. Read is not a prefix of write on purpose.
|
|
78
|
+
return false;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Everything a member may do: their role's defaults plus their own grants. */
|
|
83
|
+
export function grantsFor(member) {
|
|
84
|
+
const role = ROLES[member?.role] || ROLES[DEFAULT_ROLE];
|
|
85
|
+
return [...new Set([...role, ...(member?.grants || [])])];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The permission a pit line needs, or null when it needs none.
|
|
90
|
+
*
|
|
91
|
+
* Only the surfaces worth gating are listed. `/help`, `/pwd` and the arcade are
|
|
92
|
+
* not access control problems, and pretending they are would make the gate
|
|
93
|
+
* something people turn off.
|
|
94
|
+
*/
|
|
95
|
+
export function permissionFor(cmd, rest = []) {
|
|
96
|
+
const verb = String(cmd || "").toLowerCase();
|
|
97
|
+
const arg = String(rest[0] || "").toLowerCase();
|
|
98
|
+
const map = {
|
|
99
|
+
tools: () => `tools:${arg || "*"}`,
|
|
100
|
+
agents: () => `agents:${arg || "*"}`,
|
|
101
|
+
agent: () => `agents:${arg || "*"}`,
|
|
102
|
+
start: () => `agents:${arg || "*"}`,
|
|
103
|
+
herd: () => "herd:*",
|
|
104
|
+
shell: () => "shell:*",
|
|
105
|
+
sh: () => "shell:*",
|
|
106
|
+
install: () => `tools:${arg || "*"}`,
|
|
107
|
+
client: () => (isReadVerb(arg) ? "client:read" : "client:write"),
|
|
108
|
+
business: () => (isReadVerb(arg) ? "client:read" : "client:write"),
|
|
109
|
+
merchant: () => (isReadVerb(arg) ? "client:read" : "client:write"),
|
|
110
|
+
customer: () => (isReadVerb(arg) ? "client:read" : "client:write"),
|
|
111
|
+
team: () => (isReadVerb(arg) ? "team:read" : "team:write"),
|
|
112
|
+
teams: () => (isReadVerb(arg) ? "team:read" : "team:write"),
|
|
113
|
+
rate: () => (isReadVerb(arg) ? "rate:read" : "rate:write"),
|
|
114
|
+
rates: () => (isReadVerb(arg) ? "rate:read" : "rate:write"),
|
|
115
|
+
billing: () => (isBillingWrite(rest) ? "billing:write" : "billing:read"),
|
|
116
|
+
invoice: () => (isBillingWrite(rest) ? "billing:write" : "billing:read"),
|
|
117
|
+
payments: () => (isReadVerb(arg) ? "payments:read" : "payments:write"),
|
|
118
|
+
timer: () => (["log", "status", "ls", "list", ""].includes(arg) ? "timer:read" : "timer:write"),
|
|
119
|
+
};
|
|
120
|
+
return map[verb] ? map[verb]() : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isReadVerb(arg) {
|
|
124
|
+
return ["", "list", "ls", "show", "get", "info", "can", "whoami", "status"].includes(arg);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isBillingWrite(rest) {
|
|
128
|
+
// Drafting an invoice reads; marking time billed or sending it to a gateway
|
|
129
|
+
// writes. The flags are the difference, not the verb.
|
|
130
|
+
return rest.some((a) => ["--mark", "--send", "--void"].includes(String(a).toLowerCase()))
|
|
131
|
+
|| ["mark", "send", "void"].includes(String(rest[0] || "").toLowerCase());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Who this pit is acting as: `MOSHCODE_MEMBER=acme/preshy`, or nobody.
|
|
136
|
+
*
|
|
137
|
+
* Env rather than a stored setting, deliberately. The owner's own pit has no
|
|
138
|
+
* member set and is never gated; a machine handed to somebody else gets the
|
|
139
|
+
* variable in its profile, which is a place an operator already knows how to
|
|
140
|
+
* manage and a place the person at the keyboard can be seen to have changed.
|
|
141
|
+
*/
|
|
142
|
+
export function currentMember(business = loadBusiness(), env = process.env) {
|
|
143
|
+
const raw = String(env.MOSHCODE_MEMBER || "").trim();
|
|
144
|
+
if (!raw) return null;
|
|
145
|
+
const [teamPart, handlePart] = raw.includes("/") ? raw.split("/") : [null, raw];
|
|
146
|
+
const teams = business.teams || {};
|
|
147
|
+
const teamId = teamPart ? slugify(teamPart) : Object.keys(teams).find((id) => teams[id]?.members?.[handlePart]);
|
|
148
|
+
const team = teamId ? teams[teamId] : null;
|
|
149
|
+
const member = team?.members?.[String(handlePart || "").toLowerCase()];
|
|
150
|
+
if (!team || !member) return { teamId: teamId || teamPart, handle: handlePart, member: null, grants: [], unknown: true };
|
|
151
|
+
return { teamId, team, handle: String(handlePart).toLowerCase(), member, grants: grantsFor(member) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The gate the pit calls before dispatching: `{ allowed, permission, reason }`.
|
|
156
|
+
*
|
|
157
|
+
* Allowed by default. No member set means the owner is at the keyboard, and an
|
|
158
|
+
* unrecognised command needs no permission — a gate that fails closed on
|
|
159
|
+
* everything it does not know about would break every command added after it.
|
|
160
|
+
*/
|
|
161
|
+
export function checkAccess(cmd, rest = [], { business = loadBusiness(), env = process.env } = {}) {
|
|
162
|
+
const permission = permissionFor(cmd, rest);
|
|
163
|
+
if (!permission) return { allowed: true, permission: null };
|
|
164
|
+
const acting = currentMember(business, env);
|
|
165
|
+
if (!acting) return { allowed: true, permission };
|
|
166
|
+
if (acting.unknown) {
|
|
167
|
+
return {
|
|
168
|
+
allowed: false,
|
|
169
|
+
permission,
|
|
170
|
+
acting,
|
|
171
|
+
reason: `MOSHCODE_MEMBER is set to ${env.MOSHCODE_MEMBER}, and no team here has that member`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (can(acting.grants, permission)) return { allowed: true, permission, acting };
|
|
175
|
+
return {
|
|
176
|
+
allowed: false,
|
|
177
|
+
permission,
|
|
178
|
+
acting,
|
|
179
|
+
reason: `${acting.handle} (${acting.member.role || DEFAULT_ROLE} on ${acting.teamId}) has no ${permission}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const USAGE = [
|
|
184
|
+
"usage: /team create <name> [--client <id>]",
|
|
185
|
+
" /team add <team> <handle> [--role owner|admin|member|client] [--email …] [--rate $80/hour]",
|
|
186
|
+
" /team grant <team> <handle> <permission…> · /team revoke <team> <handle> <permission…>",
|
|
187
|
+
" /team [list] · /team show <team> · /team can <team>/<handle> <permission> · /team whoami",
|
|
188
|
+
" permissions: surface:target — tools:coinpay, agents:*, billing:read, timer:write",
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
export function teamCommand(argv = [], { write = console.log, env = process.env } = {}) {
|
|
192
|
+
const verb = String(argv[0] ?? "list").toLowerCase();
|
|
193
|
+
const args = argv.slice(1);
|
|
194
|
+
|
|
195
|
+
if (["create", "new"].includes(verb)) return createTeam(args, write);
|
|
196
|
+
if (["add", "invite", "hire"].includes(verb)) return addMember(args, write);
|
|
197
|
+
if (["grant", "allow"].includes(verb)) return changeGrants(args, write, "grant");
|
|
198
|
+
if (["revoke", "deny"].includes(verb)) return changeGrants(args, write, "revoke");
|
|
199
|
+
if (["rm", "remove", "delete", "fire"].includes(verb)) return removeFromTeam(args, write);
|
|
200
|
+
if (["show", "info"].includes(verb)) return showTeam(args, write);
|
|
201
|
+
if (verb === "can") return checkCan(args, write);
|
|
202
|
+
if (verb === "whoami") return whoAmI(write, env);
|
|
203
|
+
if (["list", "ls"].includes(verb)) return listTeams(argv.includes("--json"), write);
|
|
204
|
+
|
|
205
|
+
const found = resolveTeam(loadBusiness(), verb);
|
|
206
|
+
if (found.ok) return showTeam([verb], write);
|
|
207
|
+
|
|
208
|
+
write(err(`unknown /team verb ${JSON.stringify(verb)}`));
|
|
209
|
+
USAGE.forEach(write);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Find a team by id, name, or unambiguous prefix — same contract as clients. */
|
|
214
|
+
export function resolveTeam(business, token) {
|
|
215
|
+
const teams = business?.teams || {};
|
|
216
|
+
const want = String(token ?? "").trim().toLowerCase();
|
|
217
|
+
if (!want) {
|
|
218
|
+
const ids = Object.keys(teams);
|
|
219
|
+
// One team is the overwhelmingly common shape, and making somebody name it
|
|
220
|
+
// every time is ceremony for its own sake.
|
|
221
|
+
if (ids.length === 1) return { ok: true, id: ids[0], team: teams[ids[0]], inferred: true };
|
|
222
|
+
return { ok: false, reason: "no team named", matches: ids };
|
|
223
|
+
}
|
|
224
|
+
if (Object.hasOwn(teams, want)) return { ok: true, id: want, team: teams[want] };
|
|
225
|
+
const slug = slugify(want);
|
|
226
|
+
if (Object.hasOwn(teams, slug)) return { ok: true, id: slug, team: teams[slug] };
|
|
227
|
+
const matches = Object.entries(teams).filter(([id, t]) =>
|
|
228
|
+
id.startsWith(slug) || String(t.name || "").toLowerCase().includes(want));
|
|
229
|
+
if (matches.length === 1) return { ok: true, id: matches[0][0], team: matches[0][1] };
|
|
230
|
+
return { ok: false, reason: matches.length ? "ambiguous" : "unknown", matches: matches.map(([id]) => id) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function createTeam(args, write) {
|
|
234
|
+
const { fields, rest } = parseFields(args);
|
|
235
|
+
const name = fields.name || rest.join(" ").trim();
|
|
236
|
+
if (!name) { write(err("a team needs a name")); USAGE.forEach(write); return 1; }
|
|
237
|
+
const id = String(fields.id || slugify(name));
|
|
238
|
+
const business = loadBusiness();
|
|
239
|
+
if (business.teams[id]) { write(err(`${bone(id)} already exists`)); return 1; }
|
|
240
|
+
const team = {
|
|
241
|
+
id,
|
|
242
|
+
name,
|
|
243
|
+
clients: fields.client ? [String(fields.client).toLowerCase()] : [],
|
|
244
|
+
members: {},
|
|
245
|
+
createdAt: new Date().toISOString(),
|
|
246
|
+
};
|
|
247
|
+
updateBusiness((data) => { data.teams[id] = team; });
|
|
248
|
+
write(ok(`team ${bone(id)} — ${name}`));
|
|
249
|
+
write(` ${acid(`/team add ${id} <handle> --role member`)} ${ash("to put somebody on it")}`);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function addMember(args, write) {
|
|
254
|
+
const { fields, rest } = parseFields(args);
|
|
255
|
+
const business = loadBusiness();
|
|
256
|
+
// `/team add preshy` with one team means that team; with two it must be said.
|
|
257
|
+
const teamToken = rest.length > 1 ? rest[0] : null;
|
|
258
|
+
const handleToken = rest.length > 1 ? rest[1] : rest[0];
|
|
259
|
+
const found = resolveTeam(business, teamToken);
|
|
260
|
+
if (!found.ok) return reportTeamMiss(found, teamToken, write);
|
|
261
|
+
const handle = slugify(handleToken || fields.handle || "");
|
|
262
|
+
if (!handle) { write(err("who? /team add <team> <handle>")); return 1; }
|
|
263
|
+
if (found.team.members?.[handle]) { write(err(`${bone(handle)} is already on ${bone(found.id)}`)); return 1; }
|
|
264
|
+
|
|
265
|
+
const role = String(fields.role || DEFAULT_ROLE).toLowerCase();
|
|
266
|
+
if (!ROLES[role]) { write(err(`unknown role ${JSON.stringify(role)} — one of ${Object.keys(ROLES).join(", ")}`)); return 1; }
|
|
267
|
+
|
|
268
|
+
let rate;
|
|
269
|
+
if (fields.rate && fields.rate !== true) {
|
|
270
|
+
try { rate = parseRate(fields.rate); }
|
|
271
|
+
catch (e) { write(err(String(e.message || e))); return 1; }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const grants = [];
|
|
275
|
+
for (const raw of String(fields.grant || "").split(",")) {
|
|
276
|
+
const permission = normalizePermission(raw);
|
|
277
|
+
if (permission) grants.push(permission);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const member = {
|
|
281
|
+
handle,
|
|
282
|
+
name: fields.name && fields.name !== true ? fields.name : handleToken,
|
|
283
|
+
email: fields.email && fields.email !== true ? fields.email : undefined,
|
|
284
|
+
role,
|
|
285
|
+
grants,
|
|
286
|
+
rate,
|
|
287
|
+
addedAt: new Date().toISOString(),
|
|
288
|
+
};
|
|
289
|
+
updateBusiness((data) => {
|
|
290
|
+
data.teams[found.id].members ||= {};
|
|
291
|
+
data.teams[found.id].members[handle] = member;
|
|
292
|
+
});
|
|
293
|
+
write(ok(`${bone(handle)} joined ${bone(found.id)} as ${acid(role)}`));
|
|
294
|
+
write(` ${ash("can:")} ${grantsFor(member).join(" ")}`);
|
|
295
|
+
if (rate) write(` ${ash("rate:")} ${acid(formatRate(rate))}`);
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function changeGrants(args, write, mode) {
|
|
300
|
+
const business = loadBusiness();
|
|
301
|
+
const words = args.filter((a) => !a.startsWith("--"));
|
|
302
|
+
if (!words.length) { write(err(`usage: /team ${mode} <team> <handle> <permission…>`)); return 1; }
|
|
303
|
+
|
|
304
|
+
// The team may be named or inferred, so work out which word is the handle by
|
|
305
|
+
// asking the team that answers: a permission always contains a separator, a
|
|
306
|
+
// handle does not.
|
|
307
|
+
const permissionsStart = words.findIndex((w) => /[:/(]/.test(w));
|
|
308
|
+
const head = permissionsStart === -1 ? words : words.slice(0, permissionsStart);
|
|
309
|
+
const rawPermissions = permissionsStart === -1 ? [] : words.slice(permissionsStart);
|
|
310
|
+
if (!rawPermissions.length) { write(err(`nothing to ${mode} — permissions look like tools:coinpay or agents:*`)); return 1; }
|
|
311
|
+
|
|
312
|
+
const found = resolveTeam(business, head.length > 1 ? head[0] : null);
|
|
313
|
+
if (!found.ok) return reportTeamMiss(found, head[0], write);
|
|
314
|
+
const handle = slugify(head.length > 1 ? head[1] : head[0]);
|
|
315
|
+
const member = found.team.members?.[handle];
|
|
316
|
+
if (!member) { write(err(`no ${JSON.stringify(handle)} on ${bone(found.id)} — ${acid(`/team show ${found.id}`)}`)); return 1; }
|
|
317
|
+
|
|
318
|
+
const permissions = [];
|
|
319
|
+
for (const raw of rawPermissions) {
|
|
320
|
+
const permission = normalizePermission(raw);
|
|
321
|
+
if (!permission) { write(err(`can't read ${JSON.stringify(raw)} as a permission`)); return 1; }
|
|
322
|
+
permissions.push(permission);
|
|
323
|
+
const surface = permission.split(":")[0];
|
|
324
|
+
if (!SURFACES.includes(surface)) {
|
|
325
|
+
write(warn(`${bone(surface)} is not a surface moshcode gates — known: ${SURFACES.join(", ")}`));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const after = updateBusiness((data) => {
|
|
330
|
+
const record = data.teams[found.id].members[handle];
|
|
331
|
+
const set = new Set(record.grants || []);
|
|
332
|
+
for (const permission of permissions) {
|
|
333
|
+
if (mode === "grant") set.add(permission);
|
|
334
|
+
else set.delete(permission);
|
|
335
|
+
}
|
|
336
|
+
record.grants = [...set].sort();
|
|
337
|
+
return record;
|
|
338
|
+
});
|
|
339
|
+
write(ok(`${bone(handle)} ${mode === "grant" ? "+" : "−"} ${acid(permissions.join(" "))}`));
|
|
340
|
+
write(` ${ash("can now:")} ${grantsFor(after).join(" ")}`);
|
|
341
|
+
if (mode === "revoke" && permissions.some((p) => can(ROLES[after.role] || [], p))) {
|
|
342
|
+
// Revoking something the role hands back is a no-op, and silently doing
|
|
343
|
+
// nothing is the worst possible answer to "take that away from them".
|
|
344
|
+
write(warn(`their role (${after.role}) still grants it — change the role to actually remove it`));
|
|
345
|
+
}
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function removeFromTeam(args, write) {
|
|
350
|
+
const business = loadBusiness();
|
|
351
|
+
const words = args.filter((a) => !a.startsWith("--"));
|
|
352
|
+
const found = resolveTeam(business, words.length > 1 ? words[0] : (words[0] && business.teams[slugify(words[0])] ? words[0] : null));
|
|
353
|
+
if (!found.ok) return reportTeamMiss(found, words[0], write);
|
|
354
|
+
const handleToken = words.length > 1 ? words[1] : (found.inferred ? words[0] : null);
|
|
355
|
+
if (!handleToken) {
|
|
356
|
+
updateBusiness((data) => { delete data.teams[found.id]; });
|
|
357
|
+
write(ok(`dropped team ${bone(found.id)}`));
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
const handle = slugify(handleToken);
|
|
361
|
+
if (!found.team.members?.[handle]) { write(err(`no ${JSON.stringify(handle)} on ${bone(found.id)}`)); return 1; }
|
|
362
|
+
updateBusiness((data) => { delete data.teams[found.id].members[handle]; });
|
|
363
|
+
write(ok(`${bone(handle)} left ${bone(found.id)}`));
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function listTeams(json, write) {
|
|
368
|
+
const { teams } = loadBusiness();
|
|
369
|
+
const ids = Object.keys(teams).sort();
|
|
370
|
+
if (json) { write(JSON.stringify(teams, null, 2)); return 0; }
|
|
371
|
+
if (!ids.length) {
|
|
372
|
+
write(info("no teams yet."));
|
|
373
|
+
write(` ${acid("/team create Profullstack")} ${ash("then")} ${acid("/team add profullstack preshy --role member")}`);
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
write(table(
|
|
377
|
+
ids.map((id) => [
|
|
378
|
+
bone(id),
|
|
379
|
+
teams[id].name || "",
|
|
380
|
+
String(Object.keys(teams[id].members || {}).length),
|
|
381
|
+
ash((teams[id].clients || []).join(", ")),
|
|
382
|
+
]),
|
|
383
|
+
{ columns: ["team", "name", "people", "clients"], indent: 2 },
|
|
384
|
+
));
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function showTeam(args, write) {
|
|
389
|
+
const business = loadBusiness();
|
|
390
|
+
const found = resolveTeam(business, args[0]);
|
|
391
|
+
if (!found.ok) return reportTeamMiss(found, args[0], write);
|
|
392
|
+
if (args.includes("--json")) { write(JSON.stringify(found.team, null, 2)); return 0; }
|
|
393
|
+
write(` ${bone(found.id)} ${ash(found.team.name || "")}`);
|
|
394
|
+
const members = Object.values(found.team.members || {});
|
|
395
|
+
if (!members.length) {
|
|
396
|
+
write(` ${info("nobody on it yet")} ${acid(`/team add ${found.id} <handle>`)}`);
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
write(table(
|
|
400
|
+
members.map((m) => [
|
|
401
|
+
bone(m.handle),
|
|
402
|
+
acid(m.role || DEFAULT_ROLE),
|
|
403
|
+
m.rate ? formatRate(m.rate) : ash("—"),
|
|
404
|
+
ash(grantsFor(m).join(" ")),
|
|
405
|
+
]),
|
|
406
|
+
{ columns: ["handle", "role", "rate", "can"], indent: 2 },
|
|
407
|
+
));
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function checkCan(args, write) {
|
|
412
|
+
const business = loadBusiness();
|
|
413
|
+
const [who, ...rest] = args.filter((a) => !a.startsWith("--"));
|
|
414
|
+
const permission = normalizePermission(rest.join(" "));
|
|
415
|
+
if (!who || !permission) { write(err("usage: /team can <team>/<handle> <permission>")); return 1; }
|
|
416
|
+
const [teamToken, handleToken] = who.includes("/") ? who.split("/") : [null, who];
|
|
417
|
+
const found = resolveTeam(business, teamToken);
|
|
418
|
+
if (!found.ok) return reportTeamMiss(found, teamToken, write);
|
|
419
|
+
const member = found.team.members?.[slugify(handleToken)];
|
|
420
|
+
if (!member) { write(err(`no ${JSON.stringify(handleToken)} on ${bone(found.id)}`)); return 1; }
|
|
421
|
+
const allowed = can(grantsFor(member), permission);
|
|
422
|
+
write(allowed
|
|
423
|
+
? ok(`${bone(member.handle)} may ${acid(permission)}`)
|
|
424
|
+
: err(`${bone(member.handle)} may not ${acid(permission)}`));
|
|
425
|
+
if (!allowed) write(` ${acid(`/team grant ${found.id} ${member.handle} ${permission}`)}`);
|
|
426
|
+
return allowed ? 0 : 1;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function whoAmI(write, env) {
|
|
430
|
+
const business = loadBusiness();
|
|
431
|
+
const acting = currentMember(business, env);
|
|
432
|
+
if (!acting) {
|
|
433
|
+
write(info("this pit is not acting as a team member — nothing is gated."));
|
|
434
|
+
write(` ${ash("set")} ${acid("MOSHCODE_MEMBER=<team>/<handle>")} ${ash("to run it as one")}`);
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
if (acting.unknown) {
|
|
438
|
+
write(err(`MOSHCODE_MEMBER=${env.MOSHCODE_MEMBER} names nobody on any team here`));
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
write(` ${bone(acting.handle)} ${ash("on")} ${bone(acting.teamId)} ${ash(`(${acting.member.role || DEFAULT_ROLE})`)}`);
|
|
442
|
+
write(` ${ash("can:")} ${acting.grants.join(" ")}`);
|
|
443
|
+
return 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function reportTeamMiss(found, token, write) {
|
|
447
|
+
if (found.reason === "ambiguous") {
|
|
448
|
+
write(err(`${JSON.stringify(token)} matches ${found.matches.join(", ")} — say which`));
|
|
449
|
+
return 1;
|
|
450
|
+
}
|
|
451
|
+
if (!token && found.matches?.length > 1) {
|
|
452
|
+
write(err(`say which team — ${found.matches.join(", ")}`));
|
|
453
|
+
return 1;
|
|
454
|
+
}
|
|
455
|
+
write(err(`no team ${JSON.stringify(token ?? "")} — ${acid("/team create <name>")}`));
|
|
456
|
+
return 1;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export { USAGE as TEAM_USAGE };
|