@skillcdn/console 0.1.11 → 0.1.13

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.
Files changed (71) hide show
  1. package/README.md +8 -5
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +260 -6
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/format.d.ts +7 -1
  6. package/dist/cli/format.d.ts.map +1 -1
  7. package/dist/cli/format.js +22 -0
  8. package/dist/cli/format.js.map +1 -1
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +69 -2
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/components/board.d.ts.map +1 -1
  13. package/dist/components/board.js +2 -2
  14. package/dist/components/board.js.map +1 -1
  15. package/dist/components/calendar.js +2 -2
  16. package/dist/components/calendar.js.map +1 -1
  17. package/dist/components/people.js +2 -2
  18. package/dist/components/people.js.map +1 -1
  19. package/dist/components/projects.d.ts.map +1 -1
  20. package/dist/components/projects.js +3 -3
  21. package/dist/components/projects.js.map +1 -1
  22. package/dist/components/runs.js +2 -2
  23. package/dist/components/runs.js.map +1 -1
  24. package/dist/components/shell.js +2 -2
  25. package/dist/components/shell.js.map +1 -1
  26. package/dist/components/task-form.js +2 -2
  27. package/dist/components/task-form.js.map +1 -1
  28. package/dist/components/ui.d.ts +7 -2
  29. package/dist/components/ui.d.ts.map +1 -1
  30. package/dist/components/ui.js +9 -4
  31. package/dist/components/ui.js.map +1 -1
  32. package/dist/console.d.ts +41 -6
  33. package/dist/console.d.ts.map +1 -1
  34. package/dist/console.js +61 -50
  35. package/dist/console.js.map +1 -1
  36. package/dist/i18n/en.d.ts +5 -5
  37. package/dist/i18n/en.d.ts.map +1 -1
  38. package/dist/i18n/en.js +8 -8
  39. package/dist/i18n/en.js.map +1 -1
  40. package/dist/i18n/ko.d.ts.map +1 -1
  41. package/dist/i18n/ko.js +8 -8
  42. package/dist/i18n/ko.js.map +1 -1
  43. package/dist/index.d.ts +3 -3
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +3 -3
  46. package/dist/index.js.map +1 -1
  47. package/dist/router.d.ts +15 -0
  48. package/dist/router.d.ts.map +1 -1
  49. package/dist/router.js +17 -0
  50. package/dist/router.js.map +1 -1
  51. package/docs/specs/cli.md +15 -2
  52. package/docs/specs/rest.md +13 -13
  53. package/package.json +1 -1
  54. package/skills/building-a-console/SKILL.md +4 -4
  55. package/skills/working-the-board/SKILL.md +5 -1
  56. package/src/cli/cli.ts +333 -5
  57. package/src/cli/format.ts +27 -0
  58. package/src/cli/help.ts +86 -1
  59. package/src/components/board.tsx +11 -2
  60. package/src/components/calendar.tsx +2 -2
  61. package/src/components/people.tsx +5 -5
  62. package/src/components/projects.tsx +5 -3
  63. package/src/components/runs.tsx +2 -2
  64. package/src/components/shell.tsx +3 -3
  65. package/src/components/task-form.tsx +2 -2
  66. package/src/components/ui.tsx +14 -5
  67. package/src/console.tsx +115 -54
  68. package/src/i18n/en.ts +9 -8
  69. package/src/i18n/ko.ts +9 -8
  70. package/src/index.ts +5 -0
  71. package/src/router.ts +26 -0
package/src/cli/cli.ts CHANGED
@@ -9,10 +9,18 @@ import {
9
9
  } from "../client.js";
10
10
  import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
11
11
  import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
12
- import type { RestClaim, RestDecision, RestTaskPatch } from "../schemas.js";
12
+ import type { RestClaim, RestDecision, RestPerson, RestTaskPatch } from "../schemas.js";
13
13
  import {
14
+ COLOR_NAMES,
15
+ type ColorName,
14
16
  GOAL_STATES,
15
17
  type GoalState,
18
+ PERSON_ROLES,
19
+ type PersonRole,
20
+ PROJECT_ROLES,
21
+ PROJECT_VISIBILITIES,
22
+ type ProjectRole,
23
+ type ProjectVisibility,
16
24
  TASK_PRIORITIES,
17
25
  TASK_STATES,
18
26
  type TaskPriority,
@@ -30,8 +38,11 @@ import {
30
38
  formatDocuments,
31
39
  formatGoal,
32
40
  formatGoalLine,
41
+ formatMemberLine,
33
42
  formatMore,
34
43
  formatOptions,
44
+ formatPersonLine,
45
+ formatProject,
35
46
  formatProjectLine,
36
47
  formatRun,
37
48
  formatRunLine,
@@ -331,16 +342,41 @@ async function personIdOf(
331
342
  }
332
343
  return me.person.id;
333
344
  }
345
+ return (await personOf(session, value)).id;
346
+ }
347
+
348
+ /** The person a login names, among the workspace's; the logins are as people sign in. */
349
+ async function personOf(session: Session, login: string): Promise<RestPerson> {
334
350
  const { items } = await session.client.people();
335
- const found = items.find((person) => person.login.toLowerCase() === value.toLowerCase());
351
+ const found = items.find((person) => person.login.toLowerCase() === login.toLowerCase());
336
352
  if (found === undefined) {
337
353
  throw misuse(
338
- `Nobody of the workspace is called ${value}. console people is not a command; the logins are as people sign in.`,
354
+ `Nobody of the workspace is called ${login}. console people lists everyone, by login.`,
339
355
  );
340
356
  }
341
- return found.id;
357
+ return found;
358
+ }
359
+
360
+ /** One word of a vocabulary, or the refusal to understand. */
361
+ function wordOf<T extends string>(value: string, words: readonly T[], what: string): T {
362
+ const found = words.find((word) => word === value);
363
+ if (found === undefined) {
364
+ throw misuse(`Not ${what}: ${value}. One of ${words.join(", ")}`);
365
+ }
366
+ return found;
342
367
  }
343
368
 
369
+ const personRoleOf = (value: string): PersonRole =>
370
+ wordOf(value, PERSON_ROLES, "a role in the workspace");
371
+ const projectRoleOf = (value: string): ProjectRole =>
372
+ wordOf(value, PROJECT_ROLES, "a role in a project");
373
+ const visibilityOf = (value: string): ProjectVisibility =>
374
+ wordOf(value, PROJECT_VISIBILITIES, "a visibility");
375
+ const colorOf = (value: string): ColorName => wordOf(value, COLOR_NAMES, "a colour");
376
+ /** A colour, or `none` for the one the pages choose, which is `null` on the wire. */
377
+ const colorOrNoneOf = (value: string | undefined): ColorName | null | undefined =>
378
+ value === undefined ? undefined : value === "none" ? null : colorOf(value);
379
+
344
380
  function secondsOf(value: string | undefined, fallback: number): number {
345
381
  if (value === undefined) {
346
382
  return fallback;
@@ -381,6 +417,15 @@ function theOne(positionals: readonly string[], ask: string): string {
381
417
  return first;
382
418
  }
383
419
 
420
+ /** Exactly two words, for a command on a thing and a value. */
421
+ function theTwo(positionals: readonly string[], ask: string): readonly [string, string] {
422
+ const [first, second] = positionals;
423
+ if (first === undefined || second === undefined || positionals.length > 2) {
424
+ throw misuse(ask);
425
+ }
426
+ return [first, second];
427
+ }
428
+
384
429
  /**
385
430
  * The decision once answered, or as it stands when the wait is over. Each request asks the
386
431
  * console to hold it for a while, so the answer arrives as soon as a person gives it.
@@ -535,7 +580,7 @@ const projects: Command = async (args, io) => {
535
580
  const found = await session.client.projects();
536
581
  answer(io, session, found, () =>
537
582
  found.items.length === 0
538
- ? "No projects you may work in. A person makes one on the console's Projects page."
583
+ ? 'No projects you may work in. Make one: console project new <key> "<name>"'
539
584
  : found.items.map(formatProjectLine).join("\n"),
540
585
  );
541
586
  return EXIT.ok;
@@ -1349,6 +1394,263 @@ const serve: Command = async (args, io) => {
1349
1394
  return EXIT.ok;
1350
1395
  };
1351
1396
 
1397
+ // Configuring, as the person (ADR-0021): what an administrator or an owner does on the pages,
1398
+ // their agent does here, attributed to them as the agent. The console decides on every request
1399
+ // with the same roles; what the person may not, the agent may not (auth.forbidden). The tokens
1400
+ // themselves have no command, by design.
1401
+
1402
+ const people: Command = async (args, io) => {
1403
+ const { values } = parse(args, {}, false);
1404
+ const session = await open(io, values);
1405
+ const found = await session.client.people();
1406
+ answer(io, session, found, () => found.items.map(formatPersonLine).join("\n"));
1407
+ return EXIT.ok;
1408
+ };
1409
+
1410
+ const person: Command = async (args, io) => {
1411
+ if (args[0] !== "role") {
1412
+ throw misuse("Say what to do: console person role <login> <admin|member>");
1413
+ }
1414
+ const { values, positionals } = parse(args.slice(1), {});
1415
+ const [login, role] = theTwo(
1416
+ positionals,
1417
+ "Say whom and what: console person role <login> <admin|member>",
1418
+ );
1419
+ const wanted = personRoleOf(role);
1420
+ const session = await open(io, values);
1421
+ const found = await personOf(session, login);
1422
+ const changed = await session.client.updatePerson(found.id, { role: wanted });
1423
+ answer(io, session, changed, () => formatPersonLine(changed));
1424
+ return EXIT.ok;
1425
+ };
1426
+
1427
+ /** The flags that set what a project is, shared by making one and changing one. */
1428
+ const PROJECT_FIELDS: OptionSpec = {
1429
+ description: { type: "string" },
1430
+ file: { type: "string" },
1431
+ visibility: { type: "string" },
1432
+ skills: { type: "string" },
1433
+ color: { type: "string" },
1434
+ symbol: { type: "string" },
1435
+ };
1436
+
1437
+ /** What the flags say of a project; `none` clears what may be cleared. */
1438
+ async function projectFieldsOf(io: CliIo, values: Values) {
1439
+ const description = await bodyOf(io, values, text(values, "description"));
1440
+ const visibility = text(values, "visibility");
1441
+ const skills = orNone(text(values, "skills"));
1442
+ const color = colorOrNoneOf(text(values, "color"));
1443
+ const symbol = orNone(text(values, "symbol"));
1444
+ return {
1445
+ ...(description === undefined
1446
+ ? {}
1447
+ : { description: description === "none" ? "" : description }),
1448
+ ...(visibility === undefined ? {} : { visibility: visibilityOf(visibility) }),
1449
+ ...(skills === undefined ? {} : { skillsAddress: skills }),
1450
+ ...(color === undefined ? {} : { color }),
1451
+ ...(symbol === undefined ? {} : { symbol }),
1452
+ };
1453
+ }
1454
+
1455
+ const newProject: Command = async (args, io) => {
1456
+ const { values, positionals } = parse(args, PROJECT_FIELDS);
1457
+ const [key, name] = theTwo(
1458
+ positionals,
1459
+ 'Say the key and the name: console project new <key> "<name>" [--visibility workspace|private]',
1460
+ );
1461
+ const fields = await projectFieldsOf(io, values);
1462
+ const session = await open(io, values);
1463
+ const made = await session.client.createProject({ key, name, ...fields });
1464
+ answer(
1465
+ io,
1466
+ session,
1467
+ made,
1468
+ () =>
1469
+ `Project made: ${made.name} (${made.key}), with you as its owner.\nTo work in it from this directory: console use ${made.key}`,
1470
+ );
1471
+ return EXIT.ok;
1472
+ };
1473
+
1474
+ const editProject: Command = async (args, io) => {
1475
+ const { values, positionals } = parse(args, { ...PROJECT_FIELDS, name: { type: "string" } });
1476
+ const key = theOne(
1477
+ positionals,
1478
+ "Say which project: console project edit <key> --name <name> ...",
1479
+ );
1480
+ const name = text(values, "name");
1481
+ const patch = { ...(name === undefined ? {} : { name }), ...(await projectFieldsOf(io, values)) };
1482
+ if (Object.keys(patch).length === 0) {
1483
+ throw misuse(
1484
+ "Say what to change: --name, --description, --visibility, --skills, --color or --symbol; none clears the description, the skills address, the colour or the symbol.",
1485
+ );
1486
+ }
1487
+ const session = await open(io, values);
1488
+ const changed = await session.client.project(key).update(patch);
1489
+ answer(io, session, changed, () => formatProject(changed));
1490
+ return EXIT.ok;
1491
+ };
1492
+
1493
+ const projectIcon: Command = async (args, io) => {
1494
+ const { values, positionals } = parse(args, { remove: { type: "boolean" } });
1495
+ const [key, file] = positionals;
1496
+ const removing = on(values, "remove");
1497
+ if (key === undefined || positionals.length > 2 || (file === undefined) !== removing) {
1498
+ throw misuse(
1499
+ "Say which project and which image: console project icon <key> <file>, or console project icon <key> --remove",
1500
+ );
1501
+ }
1502
+ const session = await open(io, values);
1503
+ const project = session.client.project(key);
1504
+ const changed =
1505
+ file === undefined
1506
+ ? await project.removeIcon()
1507
+ : await project.setIcon({
1508
+ name: basename(file),
1509
+ bytes: await io.readBytes(file),
1510
+ contentType: CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream",
1511
+ });
1512
+ answer(io, session, changed, () => formatProject(changed));
1513
+ return EXIT.ok;
1514
+ };
1515
+
1516
+ const project: Command = async (args, io) => {
1517
+ if (args[0] === "new") {
1518
+ return newProject(args.slice(1), io);
1519
+ }
1520
+ if (args[0] === "edit") {
1521
+ return editProject(args.slice(1), io);
1522
+ }
1523
+ if (args[0] === "icon") {
1524
+ return projectIcon(args.slice(1), io);
1525
+ }
1526
+ const { values, positionals } = parse(args, {});
1527
+ const key = theOne(
1528
+ positionals,
1529
+ "Say which project: console project <key>; or console project new, edit or icon",
1530
+ );
1531
+ const session = await open(io, values);
1532
+ const found = await session.client.project(key).get();
1533
+ answer(io, session, found, () => formatProject(found));
1534
+ return EXIT.ok;
1535
+ };
1536
+
1537
+ const members: Command = async (args, io) => {
1538
+ const { values } = parse(args, {}, false);
1539
+ const session = await open(io, values);
1540
+ const project = await session.project();
1541
+ const found = await project.members();
1542
+ answer(io, session, found, () =>
1543
+ found.items.length === 0
1544
+ ? "Nobody is listed in the project."
1545
+ : found.items.map(formatMemberLine).join("\n"),
1546
+ );
1547
+ return EXIT.ok;
1548
+ };
1549
+
1550
+ const member: Command = async (args, io) => {
1551
+ const [verb, ...rest] = args;
1552
+ if (verb === "add") {
1553
+ const { values, positionals } = parse(rest, { role: { type: "string" } });
1554
+ const login = theOne(positionals, "Say whom: console member add <login> [--role owner|member]");
1555
+ const role = text(values, "role");
1556
+ const session = await open(io, values);
1557
+ const project = await session.project();
1558
+ const added = await project.addMember({
1559
+ personId: (await personOf(session, login)).id,
1560
+ ...(role === undefined ? {} : { role: projectRoleOf(role) }),
1561
+ });
1562
+ answer(io, session, added, () => formatMemberLine(added));
1563
+ return EXIT.ok;
1564
+ }
1565
+ if (verb === "role") {
1566
+ const { values, positionals } = parse(rest, {});
1567
+ const [login, role] = theTwo(
1568
+ positionals,
1569
+ "Say whom and what: console member role <login> <owner|member>",
1570
+ );
1571
+ const wanted = projectRoleOf(role);
1572
+ const session = await open(io, values);
1573
+ const project = await session.project();
1574
+ const changed = await project.updateMember((await personOf(session, login)).id, {
1575
+ role: wanted,
1576
+ });
1577
+ answer(io, session, changed, () => formatMemberLine(changed));
1578
+ return EXIT.ok;
1579
+ }
1580
+ if (verb === "remove") {
1581
+ const { values, positionals } = parse(rest, {});
1582
+ const login = theOne(positionals, "Say whom: console member remove <login>");
1583
+ const session = await open(io, values);
1584
+ const project = await session.project();
1585
+ const found = await personOf(session, login);
1586
+ await project.removeMember(found.id);
1587
+ answer(
1588
+ io,
1589
+ session,
1590
+ { removed: found.login },
1591
+ () => `${found.login} is no longer listed in the project.`,
1592
+ );
1593
+ return EXIT.ok;
1594
+ }
1595
+ throw misuse(
1596
+ "Say what to do: console member add <login> [--role owner|member], console member role <login> <owner|member>, or console member remove <login>",
1597
+ );
1598
+ };
1599
+
1600
+ const category: Command = async (args, io) => {
1601
+ const [verb, ...rest] = args;
1602
+ if (verb === "new") {
1603
+ const { values, positionals } = parse(rest, { color: { type: "string" } });
1604
+ const [key, name] = theTwo(
1605
+ positionals,
1606
+ 'Say the key and the name: console category new <key> "<name>" [--color <name>]',
1607
+ );
1608
+ const color = text(values, "color");
1609
+ const session = await open(io, values);
1610
+ const made = await session.client.createCategory({
1611
+ key,
1612
+ name,
1613
+ ...(color === undefined ? {} : { color: colorOf(color) }),
1614
+ });
1615
+ answer(io, session, made, () => formatCategoryLine(made));
1616
+ return EXIT.ok;
1617
+ }
1618
+ if (verb === "edit") {
1619
+ const { values, positionals } = parse(rest, {
1620
+ name: { type: "string" },
1621
+ color: { type: "string" },
1622
+ });
1623
+ const key = theOne(
1624
+ positionals,
1625
+ "Say which category: console category edit <key> [--name <name>] [--color <name>]",
1626
+ );
1627
+ const name = text(values, "name");
1628
+ const color = text(values, "color");
1629
+ const patch = {
1630
+ ...(name === undefined ? {} : { name }),
1631
+ ...(color === undefined ? {} : { color: colorOf(color) }),
1632
+ };
1633
+ if (Object.keys(patch).length === 0) {
1634
+ throw misuse("Say what to change: --name or --color.");
1635
+ }
1636
+ const session = await open(io, values);
1637
+ const changed = await session.client.updateCategory(key, patch);
1638
+ answer(io, session, changed, () => formatCategoryLine(changed));
1639
+ return EXIT.ok;
1640
+ }
1641
+ if (verb === "remove") {
1642
+ const { values, positionals } = parse(rest, {});
1643
+ const key = theOne(positionals, "Say which category: console category remove <key>");
1644
+ const session = await open(io, values);
1645
+ await session.client.removeCategory(key);
1646
+ answer(io, session, { removed: key }, () => `Category ${key} removed.`);
1647
+ return EXIT.ok;
1648
+ }
1649
+ throw misuse(
1650
+ 'Say what to do: console category new <key> "<name>" [--color <name>], console category edit <key> [--name <name>] [--color <name>], or console category remove <key>',
1651
+ );
1652
+ };
1653
+
1352
1654
  const COMMANDS: Readonly<Record<string, Command>> = {
1353
1655
  login,
1354
1656
  logout,
@@ -1363,6 +1665,12 @@ const COMMANDS: Readonly<Record<string, Command>> = {
1363
1665
  goal,
1364
1666
  categories,
1365
1667
  skills,
1668
+ people,
1669
+ person,
1670
+ project,
1671
+ members,
1672
+ member,
1673
+ category,
1366
1674
  take,
1367
1675
  report,
1368
1676
  "hand-in": handIn,
@@ -1416,6 +1724,26 @@ function hintFor(error: ApiError): string {
1416
1724
  return "The due day is before the start. Say a due day on or after it.\n";
1417
1725
  case "goal.not_found":
1418
1726
  return "No goal of this project has that id. console goals lists them.\n";
1727
+ case "auth.forbidden":
1728
+ return "What was asked is an administrator's, or an owner's of the project, and the person this token acts as is not one here. Hand the step to them.\n";
1729
+ case "project.key_taken":
1730
+ return "A project has that key already. console projects lists them.\n";
1731
+ case "project.invalid_address":
1732
+ return "The skills address is not one as SkillCDN spells it; none makes the organization's skills the project's.\n";
1733
+ case "project.invalid_icon":
1734
+ return "The image must be a PNG, a JPEG, a GIF or a WebP, known by its bytes.\n";
1735
+ case "member.exists":
1736
+ return "The person is listed already. console member role <login> <owner|member> changes what they are.\n";
1737
+ case "member.not_found":
1738
+ return "The person is not listed in the project. console members lists those who are.\n";
1739
+ case "category.key_taken":
1740
+ return "A category has that key already. console categories lists them.\n";
1741
+ case "category.not_found":
1742
+ return "No category has that key. console categories lists them.\n";
1743
+ case "category.in_use":
1744
+ return "Tasks are sorted into the category. Move them out first: console task edit <number> --category <other|none>\n";
1745
+ case "person.last_admin":
1746
+ return "The board keeps at least one administrator. Make another one first.\n";
1419
1747
  case "connect.too_many":
1420
1748
  return "The console holds as many connections as it may at once. Try again in a few minutes.\n";
1421
1749
  case "network":
package/src/cli/format.ts CHANGED
@@ -6,6 +6,8 @@ import type {
6
6
  RestDocumentSummary,
7
7
  RestDocuments,
8
8
  RestGoal,
9
+ RestMember,
10
+ RestPerson,
9
11
  RestProject,
10
12
  RestRun,
11
13
  RestSkills,
@@ -42,6 +44,31 @@ export function formatProjectLine(project: RestProject): string {
42
44
  return `${project.key} ${project.name} (${notes.join("; ")})`;
43
45
  }
44
46
 
47
+ /** One project in full: what it is, how it is marked, and what the person is in it. */
48
+ export function formatProject(project: RestProject): string {
49
+ const lines = [
50
+ `project ${project.key}: ${project.name}`,
51
+ `visibility: ${project.visibility} role: ${project.role} skills: ${project.skillsAddress ?? "the organization's"}`,
52
+ `color: ${project.color ?? "none"} symbol: ${project.symbol ?? "none"} image: ${project.icon === null ? "none" : project.icon.contentType}`,
53
+ ];
54
+ if (project.description.trim().length > 0) {
55
+ lines.push("", project.description.trimEnd());
56
+ }
57
+ return lines.join("\n");
58
+ }
59
+
60
+ /** One person: their login, their name where the host gives one, and what they are in the workspace. */
61
+ export function formatPersonLine(person: RestPerson): string {
62
+ return person.name === null
63
+ ? `${person.login} (${person.role})`
64
+ : `${person.login} ${person.name} (${person.role})`;
65
+ }
66
+
67
+ /** One member of a project: their login, what they are in it, and since when. */
68
+ export function formatMemberLine(member: RestMember): string {
69
+ return `${member.person.login} ${member.role} (since ${member.addedAt.slice(0, 10)})`;
70
+ }
71
+
45
72
  /** The line under a page of a listing that has more after it: how to ask for the next page. */
46
73
  export function formatMore(more: boolean, limit: number, offset: number): string {
47
74
  return more ? `\n... more: --offset ${offset + limit}` : "";
package/src/cli/help.ts CHANGED
@@ -3,6 +3,9 @@ import {
3
3
  MAX_AGENT_LENGTH,
4
4
  MAX_ARTIFACTS_PER_RUN,
5
5
  MAX_BODY_LENGTH,
6
+ MAX_CATEGORIES,
7
+ MAX_CATEGORY_KEY_LENGTH,
8
+ MAX_CATEGORY_NAME_LENGTH,
6
9
  MAX_DOCUMENT_LENGTH,
7
10
  MAX_DOCUMENT_PATH_LENGTH,
8
11
  MAX_FILE_BYTES,
@@ -10,6 +13,11 @@ import {
10
13
  MAX_LINK_LABEL_LENGTH,
11
14
  MAX_OPTION_LABEL_LENGTH,
12
15
  MAX_OPTIONS,
16
+ MAX_PROJECT_DESCRIPTION_LENGTH,
17
+ MAX_PROJECT_ICON_BYTES,
18
+ MAX_PROJECT_KEY_LENGTH,
19
+ MAX_PROJECT_NAME_LENGTH,
20
+ MAX_PROJECT_SYMBOL_LENGTH,
13
21
  MAX_QUESTION_LENGTH,
14
22
  MAX_REPORTS_PER_RUN,
15
23
  MAX_SUMMARY_LENGTH,
@@ -17,6 +25,7 @@ import {
17
25
  MAX_VERSIONS_PER_DOCUMENT,
18
26
  MIN_OPTIONS,
19
27
  } from "../limits.js";
28
+ import { COLOR_NAMES } from "../vocabulary.js";
20
29
 
21
30
  // What the command says about itself: for an agent that meets it for the first time, and for a
22
31
  // person. One table for every command, from which the usage and each command's help are made:
@@ -53,6 +62,8 @@ const DOCUMENT_PATH = `a path of lowercase letters, digits and hyphens, with sla
53
62
  const DOCUMENT_REFUSALS = ["document.not_found: no page has that path in this project"];
54
63
 
55
64
  const DAY = "a day as 2026-10-24, today, tomorrow, or +7 for seven days from today";
65
+ const COLORS = `one of ${COLOR_NAMES.join(", ")}`;
66
+ const KEY = "lowercase letters, digits and hyphens";
56
67
  const TASK_FIELD_FLAGS =
57
68
  "[--state <state>] [--priority <priority>] [--category <key>] [--goal <id>] [--starts <day>] [--due <day>] [--assignee <login|me>] [--parent <number|id>]";
58
69
  const TASK_FIELD_LIMITS = [
@@ -143,7 +154,76 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
143
154
  categories: {
144
155
  usage: "categories",
145
156
  about:
146
- "The categories tasks are sorted into, the same in every project: each with its key, which --category takes, its name and its colour. An administrator keeps them on the console's Categories page.",
157
+ "The categories tasks are sorted into, the same in every project: each with its key, which --category takes, its name and its colour. An administrator keeps them: console category, or the Categories page.",
158
+ },
159
+ people: {
160
+ usage: "people",
161
+ about:
162
+ "Everyone of the workspace, by login, with their name where the host gives one and what they are in it: admin, who configures the workspace, or member, who works in the projects they are in. The logins are what --assignee, member and person role take.",
163
+ },
164
+ person: {
165
+ usage: "person role <login> <admin|member>",
166
+ about:
167
+ "Says what a person is in the workspace, for an administrator: the person themselves on the People page, or their agent here, named on it. The board keeps at least one administrator.",
168
+ refusals: [
169
+ "auth.forbidden: only an administrator says what a person is",
170
+ "person.last_admin: the board keeps at least one administrator; make another one first",
171
+ ],
172
+ },
173
+ project: {
174
+ usage:
175
+ 'project <key> | project new <key> "<name>" [--description <markdown>|--file <path>] [--visibility workspace|private] [--skills <address>|none] [--color <name>] [--symbol <text>] | project edit <key> [--name "<name>"] [the same flags; none clears] | project icon <key> <file> | project icon <key> --remove',
176
+ about:
177
+ "One project in full; or a project made, with you as its owner, private unless --visibility workspace says everyone of the workspace works in it; or one changed, only in what the flags say, for an owner; or its image for its mark, set from a file of this machine or taken away. The organization's skills are the project's unless --skills names an address of its own.",
178
+ limits: [
179
+ `key: ${KEY}, up to ${MAX_PROJECT_KEY_LENGTH} characters; it does not change once made`,
180
+ `name: one line, up to ${MAX_PROJECT_NAME_LENGTH} characters; description: up to ${MAX_PROJECT_DESCRIPTION_LENGTH}`,
181
+ `color: ${COLORS}; symbol: up to ${MAX_PROJECT_SYMBOL_LENGTH} characters without spaces, shown on the colour; left out, the pages choose both from the key and the name`,
182
+ `image: a PNG, a JPEG, a GIF or a WebP of at most ${MAX_PROJECT_ICON_BYTES} bytes`,
183
+ ],
184
+ refusals: [
185
+ "project.not_found: no project has that key, or it is not yours to see",
186
+ "auth.forbidden: only an owner of the project changes it",
187
+ "project.key_taken: a project has that key already",
188
+ "project.invalid_address: the skills address is not one as SkillCDN spells it",
189
+ "project.invalid_icon: the file is not an image of those kinds, by its bytes",
190
+ "request.too_large: the image is over the limit",
191
+ ],
192
+ },
193
+ members: {
194
+ usage: "members",
195
+ about:
196
+ "Those listed in the project, by login, with what each is in it (owner, who configures it; member, who works on it) and since when. A project open to the workspace has everyone of it as a member besides, and an administrator owns every project.",
197
+ },
198
+ member: {
199
+ usage:
200
+ "member add <login> [--role owner|member] | member role <login> <owner|member> | member remove <login>",
201
+ about:
202
+ "Lists a person of the workspace in the project, as a member unless --role says owner; changes what they are in it; or takes them off the list. For an owner of the project.",
203
+ refusals: [
204
+ "auth.forbidden: only an owner of the project lists its members",
205
+ "member.invalid_person: the person is not of the workspace",
206
+ "member.exists: the person is listed already; member role changes what they are",
207
+ "member.not_found: the person is not listed in the project",
208
+ ],
209
+ },
210
+ category: {
211
+ usage:
212
+ 'category new <key> "<name>" [--color <name>] | category edit <key> [--name "<name>"] [--color <name>] | category remove <key>',
213
+ about:
214
+ "Makes a category tasks are sorted into, the same in every project; changes its name or its colour; or removes one no task is sorted into. For an administrator.",
215
+ limits: [
216
+ `key: ${KEY}, up to ${MAX_CATEGORY_KEY_LENGTH} characters; it does not change once made`,
217
+ `name: one line, up to ${MAX_CATEGORY_NAME_LENGTH} characters; color: ${COLORS}, gray when left out`,
218
+ `at most ${MAX_CATEGORIES} categories in the workspace`,
219
+ ],
220
+ refusals: [
221
+ "auth.forbidden: only an administrator keeps the categories",
222
+ "category.key_taken: a category has that key already",
223
+ "category.not_found: no category has that key",
224
+ "category.in_use: tasks are sorted into it; move them out first (console task edit <number> --category <other|none>)",
225
+ "category.too_many: the workspace holds as many categories as one may",
226
+ ],
147
227
  },
148
228
  skills: {
149
229
  usage: "skills",
@@ -352,6 +432,11 @@ const SECTIONS: readonly { readonly title: string; readonly commands: readonly s
352
432
  title: "The documents: the project's pages of Markdown, in folders, by path",
353
433
  commands: ["docs", "doc", "write", "attach", "archive", "restore"],
354
434
  },
435
+ {
436
+ title:
437
+ "Configuring, as the person: what an administrator or an owner does on the pages, their agent does here, named on it",
438
+ commands: ["people", "person", "project", "members", "member", "category"],
439
+ },
355
440
  ];
356
441
 
357
442
  /** The whole usage, as `console help` prints it, naming the version when it is known. */
@@ -9,7 +9,16 @@ import {
9
9
  } from "../api.js";
10
10
  import { useWords } from "../i18n/index.js";
11
11
  import type { BoardFilter } from "../router.js";
12
- import { Avatar, Badge, Button, CategoryBadge, cx, DueBadge, PriorityBadge } from "./ui.js";
12
+ import {
13
+ Avatar,
14
+ Badge,
15
+ Button,
16
+ CategoryBadge,
17
+ cx,
18
+ DueBadge,
19
+ PriorityBadge,
20
+ personName,
21
+ } from "./ui.js";
13
22
 
14
23
  // The board: one column per state, the tasks in it, the sooner due first and the newest after.
15
24
  // A card says what a person needs at a glance and nothing more: the number and the title, the
@@ -256,7 +265,7 @@ export function BoardFilters(props: BoardFiltersProps) {
256
265
  .filter((person) => person.id !== props.me?.id)
257
266
  .map((person) => (
258
267
  <option key={person.id} value={person.id}>
259
- {person.login}
268
+ {personName(person)}
260
269
  </option>
261
270
  ))}
262
271
  </select>
@@ -12,7 +12,7 @@ import {
12
12
  weekdays,
13
13
  } from "../dates.js";
14
14
  import { useLanguage, useWords } from "../i18n/index.js";
15
- import { cx, PersonChip } from "./ui.js";
15
+ import { cx, PersonChip, personName } from "./ui.js";
16
16
 
17
17
  // The calendar of a project (ADR-0019): the month as a grid. A task planned from a start to a
18
18
  // due day lies across its days as a bar, in a lane of its own within each week so that the bars
@@ -313,7 +313,7 @@ export function workloadOf(tasks: readonly RestTask[], today: string): WorkloadR
313
313
  if (b.person === undefined) {
314
314
  return -1;
315
315
  }
316
- return a.person.login.localeCompare(b.person.login);
316
+ return personName(a.person).localeCompare(personName(b.person));
317
317
  });
318
318
  }
319
319
 
@@ -1,7 +1,7 @@
1
1
  import type { PersonRole, RestPerson } from "../api.js";
2
2
  import { useWords } from "../i18n/index.js";
3
3
  import { PERSON_ROLES } from "../vocabulary.js";
4
- import { PersonChip, RoleBadge } from "./ui.js";
4
+ import { PersonChip, personName, RoleBadge } from "./ui.js";
5
5
 
6
6
  // The people of the board, with what each may do. An administrator changes roles here; a
7
7
  // member reads. Takes its data as props and nothing from the network.
@@ -24,15 +24,15 @@ export function PeopleList(props: PeopleListProps) {
24
24
  {props.people.map((person) => (
25
25
  <li key={person.id} className="sc-person-row">
26
26
  <PersonChip person={person} size="md" />
27
- <span className="sc-person-name">
28
- {person.name ?? ""}
29
- {props.me?.id === person.id && <span className="sc-muted"> {words.common.you}</span>}
27
+ <span className="sc-person-name sc-muted">
28
+ {person.name === null ? "" : person.login}
29
+ {props.me?.id === person.id && <span> {words.common.you}</span>}
30
30
  </span>
31
31
  {props.onChangeRole === undefined ? (
32
32
  <RoleBadge role={person.role} />
33
33
  ) : (
34
34
  <label className="sc-field sc-field-inline">
35
- <span className="sc-visually-hidden">{words.people.roleOf(person.login)}</span>
35
+ <span className="sc-visually-hidden">{words.people.roleOf(personName(person))}</span>
36
36
  <select
37
37
  className="sc-input"
38
38
  value={person.role}
@@ -23,7 +23,7 @@ import {
23
23
  type RestProjectPatch,
24
24
  } from "../api.js";
25
25
  import { useWords } from "../i18n/index.js";
26
- import { Badge, Button, cx, PersonChip, Time } from "./ui.js";
26
+ import { Badge, Button, cx, PersonChip, personName, Time } from "./ui.js";
27
27
 
28
28
  /** A project's mark: its symbol on its colour, so that projects tell apart at a glance. */
29
29
  export function ProjectMark(props: {
@@ -392,7 +392,9 @@ export function MemberList(props: MemberListProps) {
392
392
  <ProjectRoleBadge role={member.role} />
393
393
  ) : (
394
394
  <label className="sc-field sc-field-inline">
395
- <span className="sc-visually-hidden">{members.roleOf(member.person.login)}</span>
395
+ <span className="sc-visually-hidden">
396
+ {members.roleOf(personName(member.person))}
397
+ </span>
396
398
  <select
397
399
  className="sc-input"
398
400
  value={member.role}
@@ -446,7 +448,7 @@ export function MemberList(props: MemberListProps) {
446
448
  <option value="">{members.chooseSomeone}</option>
447
449
  {candidates.map((person) => (
448
450
  <option key={person.id} value={person.id}>
449
- {person.login}
451
+ {personName(person)}
450
452
  </option>
451
453
  ))}
452
454
  </select>