@skillcdn/console 0.1.16 → 0.1.18

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 (143) hide show
  1. package/README.md +4 -3
  2. package/dist/api.d.ts +4 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +12 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +226 -21
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +8 -3
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +31 -5
  13. package/dist/cli/format.js.map +1 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +34 -8
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/install.d.ts +21 -0
  18. package/dist/cli/install.d.ts.map +1 -0
  19. package/dist/cli/install.js +87 -0
  20. package/dist/cli/install.js.map +1 -0
  21. package/dist/cli/main.js +24 -4
  22. package/dist/cli/main.js.map +1 -1
  23. package/dist/cli/notices.d.ts +13 -0
  24. package/dist/cli/notices.d.ts.map +1 -0
  25. package/dist/cli/notices.js +31 -0
  26. package/dist/cli/notices.js.map +1 -0
  27. package/dist/cli/terminal.d.ts +5 -0
  28. package/dist/cli/terminal.d.ts.map +1 -1
  29. package/dist/cli/terminal.js +11 -1
  30. package/dist/cli/terminal.js.map +1 -1
  31. package/dist/client.d.ts +43 -5
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +69 -4
  34. package/dist/client.js.map +1 -1
  35. package/dist/compat.d.ts +66 -0
  36. package/dist/compat.d.ts.map +1 -0
  37. package/dist/compat.js +115 -0
  38. package/dist/compat.js.map +1 -0
  39. package/dist/components/calendar.d.ts +3 -1
  40. package/dist/components/calendar.d.ts.map +1 -1
  41. package/dist/components/calendar.js +21 -2
  42. package/dist/components/calendar.js.map +1 -1
  43. package/dist/components/comments.d.ts +59 -0
  44. package/dist/components/comments.d.ts.map +1 -0
  45. package/dist/components/comments.js +138 -0
  46. package/dist/components/comments.js.map +1 -0
  47. package/dist/components/event-feed.d.ts.map +1 -1
  48. package/dist/components/event-feed.js +2 -0
  49. package/dist/components/event-feed.js.map +1 -1
  50. package/dist/components/goals.d.ts +3 -1
  51. package/dist/components/goals.d.ts.map +1 -1
  52. package/dist/components/goals.js +23 -11
  53. package/dist/components/goals.js.map +1 -1
  54. package/dist/components/markdown.d.ts +6 -0
  55. package/dist/components/markdown.d.ts.map +1 -1
  56. package/dist/components/markdown.js +2 -1
  57. package/dist/components/markdown.js.map +1 -1
  58. package/dist/components/projects.d.ts.map +1 -1
  59. package/dist/components/projects.js +6 -1
  60. package/dist/components/projects.js.map +1 -1
  61. package/dist/components/roadmap.d.ts +37 -0
  62. package/dist/components/roadmap.d.ts.map +1 -0
  63. package/dist/components/roadmap.js +76 -0
  64. package/dist/components/roadmap.js.map +1 -0
  65. package/dist/components/task-view.d.ts +8 -3
  66. package/dist/components/task-view.d.ts.map +1 -1
  67. package/dist/components/task-view.js +6 -4
  68. package/dist/components/task-view.js.map +1 -1
  69. package/dist/components/tokens.d.ts +2 -0
  70. package/dist/components/tokens.d.ts.map +1 -1
  71. package/dist/components/tokens.js +13 -3
  72. package/dist/components/tokens.js.map +1 -1
  73. package/dist/console.css +314 -2
  74. package/dist/console.d.ts.map +1 -1
  75. package/dist/console.js +30 -11
  76. package/dist/console.js.map +1 -1
  77. package/dist/data.d.ts +5 -1
  78. package/dist/data.d.ts.map +1 -1
  79. package/dist/data.js +3 -1
  80. package/dist/data.js.map +1 -1
  81. package/dist/i18n/en.d.ts +38 -2
  82. package/dist/i18n/en.d.ts.map +1 -1
  83. package/dist/i18n/en.js +38 -2
  84. package/dist/i18n/en.js.map +1 -1
  85. package/dist/i18n/ko.d.ts.map +1 -1
  86. package/dist/i18n/ko.js +38 -2
  87. package/dist/i18n/ko.js.map +1 -1
  88. package/dist/index.d.ts +3 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +3 -1
  91. package/dist/index.js.map +1 -1
  92. package/dist/limits.d.ts +3 -0
  93. package/dist/limits.d.ts.map +1 -1
  94. package/dist/limits.js +3 -0
  95. package/dist/limits.js.map +1 -1
  96. package/dist/router.d.ts +6 -1
  97. package/dist/router.d.ts.map +1 -1
  98. package/dist/router.js +10 -1
  99. package/dist/router.js.map +1 -1
  100. package/dist/rules.js +2 -2
  101. package/dist/rules.js.map +1 -1
  102. package/dist/schemas.d.ts +147 -0
  103. package/dist/schemas.d.ts.map +1 -1
  104. package/dist/schemas.js +50 -1
  105. package/dist/schemas.js.map +1 -1
  106. package/dist/vocabulary.d.ts +1 -1
  107. package/dist/vocabulary.d.ts.map +1 -1
  108. package/dist/vocabulary.js +1 -0
  109. package/dist/vocabulary.js.map +1 -1
  110. package/docs/specs/cli.md +17 -10
  111. package/docs/specs/rest.md +17 -6
  112. package/package.json +3 -2
  113. package/skills/working-the-board/SKILL.md +8 -5
  114. package/src/api.ts +35 -0
  115. package/src/cli/cli.ts +273 -22
  116. package/src/cli/format.ts +36 -4
  117. package/src/cli/help.ts +38 -7
  118. package/src/cli/install.ts +111 -0
  119. package/src/cli/main.ts +25 -4
  120. package/src/cli/notices.ts +48 -0
  121. package/src/cli/terminal.ts +12 -1
  122. package/src/client.ts +142 -8
  123. package/src/compat.ts +173 -0
  124. package/src/components/calendar.tsx +41 -1
  125. package/src/components/comments.tsx +342 -0
  126. package/src/components/event-feed.tsx +2 -0
  127. package/src/components/goals.tsx +86 -7
  128. package/src/components/markdown.tsx +9 -1
  129. package/src/components/projects.tsx +16 -3
  130. package/src/components/roadmap.tsx +242 -0
  131. package/src/components/task-view.tsx +32 -33
  132. package/src/components/tokens.tsx +38 -1
  133. package/src/console.tsx +45 -3
  134. package/src/data.ts +9 -1
  135. package/src/i18n/en.ts +40 -2
  136. package/src/i18n/ko.ts +40 -2
  137. package/src/index.ts +20 -0
  138. package/src/limits.ts +3 -0
  139. package/src/router.ts +13 -2
  140. package/src/rules.ts +2 -2
  141. package/src/schemas.ts +60 -1
  142. package/src/styles/console.css +314 -2
  143. package/src/vocabulary.ts +1 -0
package/src/cli/cli.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  type FetchLike,
8
8
  type ProjectClient,
9
9
  } from "../client.js";
10
+ import { CLIENT_RANGE_HEADER, judgeClient, parseVersion, VERSION_HEADER } from "../compat.js";
10
11
  import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
11
12
  import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
12
13
  import { fillRules, RULES_PATH, RULES_TITLE } from "../rules.js";
@@ -41,6 +42,7 @@ import type { DirectoryStore } from "./directory.js";
41
42
  import {
42
43
  formatAnswer,
43
44
  formatCategoryLine,
45
+ formatComment,
44
46
  formatDecision,
45
47
  formatDecisionLine,
46
48
  formatDocument,
@@ -66,6 +68,8 @@ import {
66
68
  formatWork,
67
69
  } from "./format.js";
68
70
  import { COMMAND_HELP, helpFor, usage } from "./help.js";
71
+ import { describeInstall, type Install, updateCommandFor } from "./install.js";
72
+ import type { NoticeStore } from "./notices.js";
69
73
  import { createServeHandler, DEFAULT_SERVE_PORT, type Listen } from "./serve.js";
70
74
 
71
75
  // The `console` command (docs/specs/cli.md): the agent's side of the console. A thin client of
@@ -83,8 +87,13 @@ export const EXIT = {
83
87
  usage: 2,
84
88
  /** The decision still waits for a person. */
85
89
  waiting: 3,
90
+ /** The command and the console do not go together (ADR-0025): stderr says which to update, and how. */
91
+ incompatible: 4,
86
92
  } as const;
87
93
 
94
+ /** A day, for how often the command repeats what it noticed of the versions. */
95
+ const DAY_MS = 86_400_000;
96
+
88
97
  /** How long one request for a decision's answer asks the console to hold it: what the server allows. */
89
98
  const WAIT_STEP_SECONDS = 50;
90
99
  /** A breath between two such requests, should the console answer at once. */
@@ -107,6 +116,8 @@ export interface CliIo {
107
116
  readonly readFile: (path: string) => Promise<string>;
108
117
  /** A file handed in, as bytes. */
109
118
  readonly readBytes: (path: string) => Promise<Uint8Array>;
119
+ /** A file read from the console, written at a path, or into a directory under its name; says where. */
120
+ readonly writeBytes: (path: string, bytes: Uint8Array, name: string) => Promise<string>;
110
121
  readonly store: CredentialStore;
111
122
  /** What the working directory says of the project, and the way to write it. */
112
123
  readonly directory: DirectoryStore;
@@ -114,6 +125,12 @@ export interface CliIo {
114
125
  readonly now: () => number;
115
126
  /** The version of the package the command runs from, when the entry point knows it. */
116
127
  readonly version?: string | undefined;
128
+ /** What the command calls itself on every request (ADR-0025); nothing when it does not know its version. */
129
+ readonly userAgent?: string | undefined;
130
+ /** Where the command is installed, so that an update is told the way it takes effect; nothing when unknown. */
131
+ readonly install?: Install | undefined;
132
+ /** What the command last said of the versions; left out, it says it every time. */
133
+ readonly notices?: NoticeStore | undefined;
117
134
  /** Where the console's own skills are on this machine, shipped with the package; nothing when they are not there. */
118
135
  readonly skillsDir?: string | undefined;
119
136
  /** How `serve` listens on the loopback (ADR-0014); left out, the command cannot serve. */
@@ -197,7 +214,7 @@ async function open(io: CliIo, values: Values): Promise<Session> {
197
214
  "Not signed in. A person signs the command in: console login --url <the console's origin>",
198
215
  );
199
216
  }
200
- const client = createClient({ baseUrl: url, token, fetch: io.fetch });
217
+ const client = clientOf(io, url, token);
201
218
  const projectKey = async () =>
202
219
  text(values, "project") ?? io.env.CONSOLE_PROJECT ?? (await io.directory.load())?.project;
203
220
  return {
@@ -502,7 +519,7 @@ type Command = (args: readonly string[], io: CliIo) => Promise<number>;
502
519
 
503
520
  /** Checks a token against the console, keeps it with the address, and says who it is. */
504
521
  async function keep(io: CliIo, url: string, token: string, agent?: string): Promise<number> {
505
- const me = await createClient({ baseUrl: url, token, fetch: io.fetch }).me();
522
+ const me = await clientOf(io, url, token).me();
506
523
  const person = me.person;
507
524
  if (person === null) {
508
525
  throw failed(
@@ -537,7 +554,7 @@ const login: Command = async (args, io) => {
537
554
  }
538
555
  // A connection (ADR-0011): a code a person approves on the console's own pages, and the token
539
556
  // handed over here once, never shown.
540
- const client = createClient({ baseUrl: url, fetch: io.fetch });
557
+ const client = clientOf(io, url);
541
558
  // "<agent> on <machine>": the pages say the agent and keep the machine for a hover.
542
559
  const agent = text(values, "agent") ?? agentOf(io.env) ?? "An agent";
543
560
  const begun = await client.connect({
@@ -579,7 +596,7 @@ const logout: Command = async (args, io) => {
579
596
  // Agents page, and the person is told so.
580
597
  let gone = true;
581
598
  try {
582
- await createClient({ baseUrl: url, token, fetch: io.fetch }).giveUpToken();
599
+ await clientOf(io, url, token).giveUpToken();
583
600
  } catch (error) {
584
601
  if (!(error instanceof ApiError && error.code === "auth.required")) {
585
602
  gone = false;
@@ -617,12 +634,38 @@ const whoami: Command = async (args, io) => {
617
634
  throw failed("The console does not know this token. Sign in again: console login");
618
635
  }
619
636
  const key = await session.projectKey();
637
+ const command = parseVersion(io.version);
638
+ const server = parseVersion(me.console?.version);
639
+ const verdict =
640
+ command === undefined || server === undefined
641
+ ? undefined
642
+ : judgeClient(command, server).verdict;
643
+ // A console from before the rule says nothing of itself; the command says so instead.
644
+ const consoleWords =
645
+ me.console === undefined
646
+ ? "the console does not say its version, so it is older than 0.1.18"
647
+ : `the console is ${me.console.version} and takes ${me.console.clientRange}`;
620
648
  answer(
621
649
  io,
622
650
  session,
623
- { ...me, project: key ?? null },
651
+ {
652
+ ...me,
653
+ console: me.console ?? null,
654
+ project: key ?? null,
655
+ command: { version: io.version ?? null, install: io.install ?? null },
656
+ },
624
657
  () =>
625
- `${person.login} (${person.role}) at ${me.workspace.name}, ${session.url}${key === undefined ? "; no project named here" : `; project ${key}`}`,
658
+ [
659
+ `${person.login} (${person.role}) at ${me.workspace.name}, ${session.url}${key === undefined ? "; no project named here" : `; project ${key}`}`,
660
+ `This command is ${io.version ?? "of a version it does not know"}${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}; ${consoleWords}.`,
661
+ ...(verdict === "behind"
662
+ ? [`A newer command exists. Update it: ${updateWords(io, me.console?.version)}`]
663
+ : verdict === "ahead"
664
+ ? [
665
+ `This command is newer than the console: what the console has not got yet is refused as not found. To match the console: ${updateWords(io, me.console?.version)}`,
666
+ ]
667
+ : []),
668
+ ].join("\n"),
626
669
  );
627
670
  return EXIT.ok;
628
671
  };
@@ -952,12 +995,94 @@ const task: Command = async (args, io) => {
952
995
  const session = await open(io, values);
953
996
  const project = await session.project();
954
997
  const found = await project.task(ref);
955
- const [decisions, runs] = await Promise.all([
998
+ const [decisions, runs, comments] = await Promise.all([
956
999
  project.decisions({ task: found.id }),
957
1000
  project.runs({ task: found.id }),
1001
+ project.comments(found.id),
958
1002
  ]);
959
- answer(io, session, { task: found, decisions: decisions.items, runs: runs.items }, () =>
960
- formatTask(found, decisions.items, runs.items),
1003
+ answer(
1004
+ io,
1005
+ session,
1006
+ { task: found, decisions: decisions.items, runs: runs.items, comments: comments.items },
1007
+ () => formatTask(found, decisions.items, runs.items, comments.items),
1008
+ );
1009
+ return EXIT.ok;
1010
+ };
1011
+
1012
+ /**
1013
+ * A word in the discussion on a task (ADR-0024): what is said, as given, from a file or from
1014
+ * standard input, with the files named by --attach put in it; one request, one comment.
1015
+ */
1016
+ const comment: Command = async (args, io) => {
1017
+ const { values, positionals } = parse(args, {
1018
+ file: { type: "string" },
1019
+ attach: { type: "string", multiple: true },
1020
+ });
1021
+ const ask =
1022
+ 'Say which task, and what: console comment <number|id> "<markdown>" [--file <path> | -] [--attach <path>]...';
1023
+ if (positionals.length === 0 || positionals.length > 2) {
1024
+ throw misuse(ask);
1025
+ }
1026
+ const ref = positionals[0] ?? "";
1027
+ const body = (await bodyOf(io, values, positionals[1])) ?? "";
1028
+ const paths = texts(values, "attach");
1029
+ if (body.trim().length === 0 && paths.length === 0) {
1030
+ throw misuse(`Say something, or attach a file. ${ask}`);
1031
+ }
1032
+ const files = [];
1033
+ for (const path of paths) {
1034
+ let bytes: Uint8Array;
1035
+ try {
1036
+ bytes = await io.readBytes(path);
1037
+ } catch {
1038
+ throw failed(`No file could be read at ${path}. Name the path of a file to attach.`);
1039
+ }
1040
+ const name = basename(path);
1041
+ const contentType = CONTENT_TYPES[extname(name).toLowerCase()];
1042
+ files.push({ name, bytes, ...(contentType === undefined ? {} : { contentType }) });
1043
+ }
1044
+ const session = await open(io, values);
1045
+ const project = await session.project();
1046
+ const task = await project.task(ref);
1047
+ const written = await project.comment(task.id, { body, files });
1048
+ answer(io, session, written, () =>
1049
+ [
1050
+ `Commented on #${task.number} ${task.title}${written.files.length === 0 ? "" : `, with ${written.files.length} file${written.files.length === 1 ? "" : "s"}`}.`,
1051
+ formatComment(written),
1052
+ ].join("\n"),
1053
+ );
1054
+ return EXIT.ok;
1055
+ };
1056
+
1057
+ /**
1058
+ * A file of the project, read from the console and written on this machine: one a run handed
1059
+ * in, one put in a comment, or one attached to a page, by its id, so that an agent can look at
1060
+ * a picture a person put on the board.
1061
+ */
1062
+ const file: Command = async (args, io) => {
1063
+ const { values, positionals } = parse(args, { out: { type: "string" } });
1064
+ const id = theOne(positionals, "Say which file: console file <id> [--out <path>]");
1065
+ const session = await open(io, values);
1066
+ const project = await session.project();
1067
+ const read = await project.readFile(id);
1068
+ const target = text(values, "out") ?? read.name;
1069
+ let saved: string;
1070
+ try {
1071
+ saved = await io.writeBytes(target, read.bytes, read.name);
1072
+ } catch {
1073
+ throw failed(`The file could not be written at ${target}. Name a path with --out <path>.`);
1074
+ }
1075
+ answer(
1076
+ io,
1077
+ session,
1078
+ {
1079
+ id,
1080
+ name: read.name,
1081
+ contentType: read.contentType,
1082
+ size: read.bytes.byteLength,
1083
+ path: saved,
1084
+ },
1085
+ () => `Saved ${read.name} (${read.bytes.byteLength} bytes, ${read.contentType}) at ${saved}`,
961
1086
  );
962
1087
  return EXIT.ok;
963
1088
  };
@@ -975,10 +1100,11 @@ const take: Command = async (args, io) => {
975
1100
  });
976
1101
  // The rules come with the run, as they read for the person: everything from here acts
977
1102
  // within them (ADR-0023); the task is read again, as it stands now that it is taken.
978
- const [ruling, me, taken] = await Promise.all([
1103
+ const [ruling, me, taken, comments] = await Promise.all([
979
1104
  project.rules(),
980
1105
  session.client.me(),
981
1106
  project.task(ref),
1107
+ project.comments(found.id),
982
1108
  ]);
983
1109
  // Taking a task with a run open on it ends that run (ADR-0020): said when it happened.
984
1110
  const tookOver =
@@ -989,16 +1115,16 @@ const take: Command = async (args, io) => {
989
1115
  workspace: forPerson(ruling.workspace, me.language),
990
1116
  project: forPerson(ruling.project, me.language),
991
1117
  };
992
- answer(io, session, { run, task: taken, rules }, () =>
1118
+ answer(io, session, { run, task: taken, comments: comments.items, rules }, () =>
993
1119
  [
994
1120
  `Run ${run.id} began on #${found.number} ${found.title}, as "${run.agent}" for ${run.person.login}. The task is in progress and theirs.${tookOver}`,
995
1121
  "",
996
- formatTask(taken),
1122
+ formatTask(taken, undefined, undefined, comments.items),
997
1123
  "",
998
1124
  formatRules(rules),
999
1125
  "",
1000
1126
  'Say how it goes: console report "<what you found or did>". Hand in what you make: console hand-in <https url | file path>.',
1001
- 'Ask when a person must decide: console ask "<question>" --option "..." --option "...". End with: console finish --summary "<what was done, what is left>".',
1127
+ 'Ask when a person must decide: console ask "<question>" --option "..." --option "...". A word for the task\'s people that is not a report: console comment <number> "<markdown>". End with: console finish --summary "<what was done, what is left>".',
1002
1128
  ].join("\n"),
1003
1129
  );
1004
1130
  return EXIT.ok;
@@ -1331,13 +1457,24 @@ const restore: Command = async (args, io) => {
1331
1457
  };
1332
1458
 
1333
1459
  const goals: Command = async (args, io) => {
1334
- const { values } = parse(args, {}, false);
1460
+ const { values } = parse(
1461
+ args,
1462
+ { category: { type: "string" }, state: { type: "string" } },
1463
+ false,
1464
+ );
1465
+ const state = goalStateOf(text(values, "state"));
1466
+ const category = text(values, "category");
1335
1467
  const session = await open(io, values);
1336
1468
  const project = await session.project();
1337
- const found = await project.goals();
1469
+ const found = await project.goals({
1470
+ ...(state === undefined ? {} : { state }),
1471
+ ...(category === undefined ? {} : { category }),
1472
+ });
1338
1473
  answer(io, session, found, () =>
1339
1474
  found.items.length === 0
1340
- ? "No goals. An owner sets one: console goal new <title> --due <day>"
1475
+ ? state === undefined && category === undefined
1476
+ ? "No goals. An owner sets one: console goal new <title> --due <day>"
1477
+ : "No goal matches."
1341
1478
  : found.items.map(formatGoalLine).join("\n"),
1342
1479
  );
1343
1480
  return EXIT.ok;
@@ -1345,15 +1482,24 @@ const goals: Command = async (args, io) => {
1345
1482
 
1346
1483
  const GOAL_FIELDS: OptionSpec = {
1347
1484
  state: { type: "string" },
1485
+ category: { type: "string" },
1486
+ starts: { type: "string" },
1348
1487
  due: { type: "string" },
1349
1488
  body: { type: "string" },
1350
1489
  file: { type: "string" },
1351
1490
  };
1352
1491
 
1492
+ /** A category's key as a flag names one, or `null` for `none` where the field may be cleared. */
1493
+ function categoryOf(value: string | undefined, clearable = false): string | null | undefined {
1494
+ return clearable && value === "none" ? null : value;
1495
+ }
1496
+
1353
1497
  const newGoal: Command = async (args, io) => {
1354
1498
  const { values, positionals } = parse(args, GOAL_FIELDS);
1355
1499
  const title = theOne(positionals, 'Say the title: console goal new "<title>" [--due <day>]');
1356
1500
  const state = goalStateOf(text(values, "state"));
1501
+ const category = categoryOf(text(values, "category"));
1502
+ const startsOn = dayOf(io, text(values, "starts"));
1357
1503
  const dueOn = dayOf(io, text(values, "due"));
1358
1504
  const body = await bodyOf(io, values, text(values, "body"));
1359
1505
  const session = await open(io, values);
@@ -1362,6 +1508,8 @@ const newGoal: Command = async (args, io) => {
1362
1508
  title,
1363
1509
  ...(body === undefined ? {} : { body }),
1364
1510
  ...(state === undefined ? {} : { state }),
1511
+ ...(category === undefined ? {} : { category }),
1512
+ ...(startsOn === undefined ? {} : { startsOn }),
1365
1513
  ...(dueOn === undefined ? {} : { dueOn }),
1366
1514
  });
1367
1515
  answer(io, session, made, () => `Goal set: ${made.title}\nid: ${made.id}`);
@@ -1373,17 +1521,21 @@ const editGoal: Command = async (args, io) => {
1373
1521
  const id = theOne(positionals, "Say which goal: console goal edit <id> --state <state> ...");
1374
1522
  const title = text(values, "title");
1375
1523
  const state = goalStateOf(text(values, "state"));
1524
+ const category = categoryOf(text(values, "category"), true);
1525
+ const startsOn = dayOf(io, text(values, "starts"), true);
1376
1526
  const dueOn = dayOf(io, text(values, "due"), true);
1377
1527
  const body = await bodyOf(io, values, text(values, "body"));
1378
1528
  const patch = {
1379
1529
  ...(title === undefined ? {} : { title }),
1380
1530
  ...(body === undefined ? {} : { body }),
1381
1531
  ...(state === undefined ? {} : { state }),
1532
+ ...(category === undefined ? {} : { category }),
1533
+ ...(startsOn === undefined ? {} : { startsOn }),
1382
1534
  ...(dueOn === undefined ? {} : { dueOn }),
1383
1535
  };
1384
1536
  if (Object.keys(patch).length === 0) {
1385
1537
  throw misuse(
1386
- "Say what to change: --title, --body, --state or --due; --due none clears the day.",
1538
+ "Say what to change: --title, --body, --state, --category, --starts or --due; none clears a category or a day.",
1387
1539
  );
1388
1540
  }
1389
1541
  const session = await open(io, values);
@@ -1904,6 +2056,8 @@ const COMMANDS: Readonly<Record<string, Command>> = {
1904
2056
  take,
1905
2057
  report,
1906
2058
  "hand-in": handIn,
2059
+ comment,
2060
+ file,
1907
2061
  ask,
1908
2062
  decision,
1909
2063
  decisions,
@@ -1933,9 +2087,82 @@ function help(io: CliIo, name: string | undefined): number {
1933
2087
  return EXIT.ok;
1934
2088
  }
1935
2089
 
2090
+ /** A client of the console at `url`, naming the command on every request (ADR-0025). */
2091
+ function clientOf(io: CliIo, url: string, token?: string): ConsoleClient {
2092
+ return createClient({
2093
+ baseUrl: url,
2094
+ fetch: io.fetch,
2095
+ ...(token === undefined ? {} : { token }),
2096
+ ...(io.userAgent === undefined ? {} : { userAgent: io.userAgent }),
2097
+ });
2098
+ }
2099
+
2100
+ /** What the console said of itself on its last answer (ADR-0025): its version, and the range of command versions it takes. */
2101
+ interface ConsoleSeen {
2102
+ version?: string | undefined;
2103
+ range?: string | undefined;
2104
+ }
2105
+
2106
+ /** The command that puts the console's version where this command is installed. */
2107
+ function updateWords(io: CliIo, version: string | undefined): string {
2108
+ const wanted = version ?? "latest";
2109
+ if (io.install === undefined) {
2110
+ return `npm install -g @skillcdn/console@${wanted}`;
2111
+ }
2112
+ const command = updateCommandFor(io.install, wanted);
2113
+ return io.install.kind === "project" ? `${command} (in ${io.install.root})` : command;
2114
+ }
2115
+
2116
+ /**
2117
+ * What the command says of the versions once the console answered: that a newer command exists,
2118
+ * or that this one is newer than the console; once a day for one pair of versions, on stderr,
2119
+ * so that the console's JSON stays what it is. What is out of the range never gets here: the
2120
+ * console refused it, and the refusal says what to do.
2121
+ */
2122
+ async function noticeVersions(io: CliIo, seen: ConsoleSeen): Promise<void> {
2123
+ const command = parseVersion(io.version);
2124
+ const server = parseVersion(seen.version);
2125
+ if (
2126
+ io.version === undefined ||
2127
+ seen.version === undefined ||
2128
+ command === undefined ||
2129
+ server === undefined
2130
+ ) {
2131
+ return;
2132
+ }
2133
+ const { verdict } = judgeClient(command, server);
2134
+ if (verdict !== "behind" && verdict !== "ahead") {
2135
+ return;
2136
+ }
2137
+ const previous = await io.notices?.load();
2138
+ const now = io.now();
2139
+ if (
2140
+ previous !== undefined &&
2141
+ previous.command === io.version &&
2142
+ previous.console === seen.version &&
2143
+ now - Date.parse(previous.shownAt) < DAY_MS
2144
+ ) {
2145
+ return;
2146
+ }
2147
+ io.stderr(
2148
+ verdict === "behind"
2149
+ ? `A newer command exists: the console runs ${seen.version}, this command is ${io.version}. Update it: ${updateWords(io, seen.version)}\n`
2150
+ : `This command (${io.version}) is newer than the console (${seen.version}, which takes ${seen.range ?? "an older line"}): what the console has not got yet is refused as not found. The console's operator updates it; to match the console meanwhile: ${updateWords(io, seen.version)}\n`,
2151
+ );
2152
+ await io.notices?.save({
2153
+ command: io.version,
2154
+ console: seen.version,
2155
+ shownAt: new Date(now).toISOString(),
2156
+ });
2157
+ }
2158
+
1936
2159
  /** What a refusal of the console is followed by, when there is something to do about it. */
1937
- function hintFor(error: ApiError): string {
2160
+ function hintFor(error: ApiError, io: CliIo, seen: ConsoleSeen): string {
1938
2161
  switch (error.code) {
2162
+ case "client.outdated":
2163
+ return `Update the command: ${updateWords(io, seen.version)}\n`;
2164
+ case "client.too_new":
2165
+ return `Install the console's version: ${updateWords(io, seen.version)}; or ask the console's operator to update the console.\n`;
1939
2166
  case "auth.required":
1940
2167
  return "The token is not one the console knows, any more or at all. A person signs the command in again: console login\n";
1941
2168
  case "project.not_found":
@@ -1954,6 +2181,10 @@ function hintFor(error: ApiError): string {
1954
2181
  return "The due day is before the start. Say a due day on or after it.\n";
1955
2182
  case "goal.not_found":
1956
2183
  return "No goal of this project has that id. console goals lists them.\n";
2184
+ case "goal.invalid_category":
2185
+ return "No category has that key. console categories lists them.\n";
2186
+ case "goal.invalid_dates":
2187
+ return "The target day is before the start. Say a target day on or after it.\n";
1957
2188
  case "auth.forbidden":
1958
2189
  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";
1959
2190
  case "project.key_taken":
@@ -1994,9 +2225,23 @@ function hintFor(error: ApiError): string {
1994
2225
  /** Runs the command and answers with its exit code. What it prints goes through `io`. */
1995
2226
  export async function runCli(argv: readonly string[], io: CliIo): Promise<number> {
1996
2227
  const [name, ...rest] = argv;
2228
+ // Every answer of the console says its version and the range it takes; the command reads
2229
+ // them off the answers it gets anyway, and asks nothing more (ADR-0025).
2230
+ const seen: ConsoleSeen = {};
2231
+ const watched: CliIo = {
2232
+ ...io,
2233
+ fetch: async (input, init) => {
2234
+ const response = await io.fetch(input, init);
2235
+ seen.version = response.headers.get(VERSION_HEADER) ?? seen.version;
2236
+ seen.range = response.headers.get(CLIENT_RANGE_HEADER) ?? seen.range;
2237
+ return response;
2238
+ },
2239
+ };
1997
2240
  try {
1998
2241
  if (name === "--version" || name === "-v" || name === "version") {
1999
- io.stdout(`console ${io.version ?? "(version unknown)"} (@skillcdn/console)\n`);
2242
+ io.stdout(
2243
+ `console ${io.version ?? "(version unknown)"} (@skillcdn/console)${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}\n`,
2244
+ );
2000
2245
  return EXIT.ok;
2001
2246
  }
2002
2247
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
@@ -2009,15 +2254,21 @@ export async function runCli(argv: readonly string[], io: CliIo): Promise<number
2009
2254
  if (command === undefined) {
2010
2255
  throw misuse(`Not a command: ${name}. Run: console help`);
2011
2256
  }
2012
- return await command(rest, io);
2257
+ const exit = await command(rest, watched);
2258
+ if (name !== "whoami") {
2259
+ await noticeVersions(io, seen);
2260
+ }
2261
+ return exit;
2013
2262
  } catch (error) {
2014
2263
  if (error instanceof CliExit) {
2015
2264
  io.stderr(`${error.message}\n`);
2016
2265
  return error.exit;
2017
2266
  }
2018
2267
  if (error instanceof ApiError) {
2019
- io.stderr(`${error.code}: ${error.message}\n${hintFor(error)}`);
2020
- return EXIT.failed;
2268
+ io.stderr(`${error.code}: ${error.message}\n${hintFor(error, io, seen)}`);
2269
+ return error.code === "client.outdated" || error.code === "client.too_new"
2270
+ ? EXIT.incompatible
2271
+ : EXIT.failed;
2021
2272
  }
2022
2273
  throw error;
2023
2274
  }
package/src/cli/format.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  RestArtifact,
3
3
  RestCategory,
4
+ RestComment,
4
5
  RestDecision,
5
6
  RestDocument,
6
7
  RestDocumentSummary,
@@ -131,6 +132,7 @@ export function formatTask(
131
132
  task: RestTask,
132
133
  decisions?: readonly RestDecision[],
133
134
  runs?: readonly RestRun[],
135
+ comments?: readonly RestComment[],
134
136
  ): string {
135
137
  const lines = [
136
138
  `#${task.number} ${task.title}`,
@@ -159,6 +161,31 @@ export function formatTask(
159
161
  lines.push(` ${formatRunLine(run)}`);
160
162
  }
161
163
  }
164
+ if (comments !== undefined) {
165
+ lines.push("", comments.length === 0 ? "comments: none" : "comments:");
166
+ for (const comment of comments) {
167
+ lines.push(formatComment(comment));
168
+ }
169
+ }
170
+ return lines.join("\n");
171
+ }
172
+
173
+ /**
174
+ * One comment of the discussion, indented under the list: who said it, through which agent,
175
+ * when, the whole word, and each file put in it with the command that reads it.
176
+ */
177
+ export function formatComment(comment: RestComment): string {
178
+ const who =
179
+ comment.agent === null ? comment.author.login : `${comment.author.login} as ${comment.agent}`;
180
+ const lines = [` ${who}, ${comment.createdAt}, comment ${comment.id}:`];
181
+ if (comment.body.trim().length > 0) {
182
+ lines.push(indent(comment.body));
183
+ }
184
+ for (const attached of comment.files) {
185
+ lines.push(
186
+ ` file: ${attached.file.name} (${attached.file.size} bytes, ${attached.file.contentType}): console file ${attached.id}`,
187
+ );
188
+ }
162
189
  return lines.join("\n");
163
190
  }
164
191
 
@@ -205,7 +232,7 @@ export function formatArtifact(
205
232
  const what =
206
233
  artifact.file === null
207
234
  ? (artifact.url ?? "")
208
- : `${artifact.file.name} (${artifact.file.size} bytes, ${artifact.file.contentType}${fileUrl === undefined ? "" : `; read at ${fileUrl(artifact.id)}`})`;
235
+ : `${artifact.file.name} (${artifact.file.size} bytes, ${artifact.file.contentType}${fileUrl === undefined ? "" : `; read at ${fileUrl(artifact.id)}`}; console file ${artifact.id})`;
209
236
  return artifact.label === null ? what : `${artifact.label}: ${what}`;
210
237
  }
211
238
 
@@ -287,9 +314,14 @@ function progressOf(goal: RestGoal): string {
287
314
  return `${counts.done}/${total} done${working === 0 ? "" : `, ${working} in progress`}${counts.dropped === 0 ? "" : `, ${counts.dropped} dropped`}`;
288
315
  }
289
316
 
290
- /** One goal: its id, its state, its title, when it is due, and how far it is. */
317
+ /** One goal: its id, its state, its title, its category, its days, and how far it is. */
291
318
  export function formatGoalLine(goal: RestGoal): string {
292
- const notes = [goal.dueOn === null ? "no target day" : `due ${goal.dueOn}`, progressOf(goal)];
319
+ const notes = [
320
+ ...(goal.category === null ? [] : [goal.category.key]),
321
+ ...(goal.startsOn === null ? [] : [`starts ${goal.startsOn}`]),
322
+ goal.dueOn === null ? "no target day" : `due ${goal.dueOn}`,
323
+ progressOf(goal),
324
+ ];
293
325
  return `${goal.id} ${goal.state} ${goal.title} (${notes.join("; ")})`;
294
326
  }
295
327
 
@@ -297,7 +329,7 @@ export function formatGoalLine(goal: RestGoal): string {
297
329
  export function formatGoal(goal: RestGoal, tasks?: readonly RestTask[]): string {
298
330
  const lines = [
299
331
  `goal ${goal.id}: ${goal.title}`,
300
- `state: ${goal.state} due: ${goal.dueOn ?? "none"} set by: ${goal.createdBy.login}`,
332
+ `state: ${goal.state} category: ${goal.category?.key ?? "none"} starts: ${goal.startsOn ?? "none"} due: ${goal.dueOn ?? "none"} set by: ${goal.createdBy.login}`,
301
333
  `progress: ${progressOf(goal)}`,
302
334
  ];
303
335
  if (goal.body.trim().length > 0) {
package/src/cli/help.ts CHANGED
@@ -6,9 +6,11 @@ import {
6
6
  MAX_CATEGORIES,
7
7
  MAX_CATEGORY_KEY_LENGTH,
8
8
  MAX_CATEGORY_NAME_LENGTH,
9
+ MAX_COMMENTS_PER_TASK,
9
10
  MAX_DOCUMENT_LENGTH,
10
11
  MAX_DOCUMENT_PATH_LENGTH,
11
12
  MAX_FILE_BYTES,
13
+ MAX_FILES_PER_COMMENT,
12
14
  MAX_FILES_PER_DOCUMENT,
13
15
  MAX_LINK_LABEL_LENGTH,
14
16
  MAX_OPTION_LABEL_LENGTH,
@@ -124,7 +126,7 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
124
126
  task: {
125
127
  usage: `task <number|id> | task new <title> [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS} | task edit <number|id> [--title <title>] [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS}`,
126
128
  about:
127
- "One task in full, with its plan (category, goal, start and due days), what happened to it (when it was started and done, which the board keeps itself), the decisions about it and the runs on it; or a new task written down; or a task changed, only in what the flags say, where none clears a field (priorities: low, normal, high, urgent).",
129
+ "One task in full, with its plan (category, goal, start and due days), what happened to it (when it was started and done, which the board keeps itself), the decisions about it, the runs on it and the discussion on it, each comment whole with its files; or a new task written down; or a task changed, only in what the flags say, where none clears a field (priorities: low, normal, high, urgent).",
128
130
  limits: [
129
131
  `title: one line, up to ${MAX_TITLE_LENGTH} characters, saying what is to be done on its own; what it refers to goes in the body`,
130
132
  `body: ${MARKDOWN}`,
@@ -133,22 +135,24 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
133
135
  refusals: ["task.not_found: no task has that number or id", ...TASK_FIELD_REFUSALS],
134
136
  },
135
137
  goals: {
136
- usage: "goals",
138
+ usage: "goals [--category <key>] [--state <state>]",
137
139
  about:
138
- "The roadmap of the project: its goals, the active first, each with its id, its state (planned, active, done, dropped), its target day, and how far it is, counted from the tasks that work towards it.",
140
+ "The roadmap of the project: its goals, the active first, each with its id, its state (planned, active, done, dropped), its category, the days it starts and is meant to be reached by, and how far it is, counted from the tasks that work towards it. --category keeps those sorted into one, --state those in one state.",
139
141
  },
140
142
  goal: {
141
143
  usage:
142
- 'goal <id> | goal new "<title>" [--body <markdown>|--file <path>] [--state <state>] [--due <day>] | goal edit <id> [--title <title>] [--body <markdown>|--file <path>] [--state <state>] [--due <day|none>]',
144
+ 'goal <id> | goal new "<title>" [--body <markdown>|--file <path>] [--state <state>] [--category <key>] [--starts <day>] [--due <day>] | goal edit <id> [--title <title>] [--body <markdown>|--file <path>] [--state <state>] [--category <key|none>] [--starts <day|none>] [--due <day|none>]',
143
145
  about:
144
- "One goal in full, with the tasks that work towards it; or a goal set on the roadmap, for an owner of the project; or one changed, only in what the flags say. A task works towards a goal through its --goal.",
146
+ "One goal in full, with the tasks that work towards it; or a goal set on the roadmap, for an owner of the project; or one changed, only in what the flags say, where none clears a category or a day. A goal is planned in days as a task is, a start and a target, and sorted into a category of the workspace (console categories). A task works towards a goal through its --goal.",
145
147
  limits: [
146
148
  `title: one line, up to ${MAX_TITLE_LENGTH} characters`,
147
149
  `body: ${MARKDOWN}`,
148
- `due: ${DAY}`,
150
+ `starts, due: ${DAY}`,
149
151
  ],
150
152
  refusals: [
151
153
  "goal.not_found: no goal of this project has that id",
154
+ "goal.invalid_category: no category of the workspace has that key",
155
+ "goal.invalid_dates: the target day is before the start",
152
156
  "auth.forbidden: only an owner of the project sets its roadmap",
153
157
  ],
154
158
  },
@@ -336,6 +340,30 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
336
340
  about: "One run in full: its reports, what it handed in, what it waits for, how it ended.",
337
341
  refusals: ["run.not_found: no run has that id"],
338
342
  },
343
+ comment: {
344
+ usage:
345
+ 'comment <number|id> "<markdown>" [--attach <path>]... | comment <number|id> --file <path> | comment <number|id> -',
346
+ about:
347
+ "Says something in the discussion on a task, for its people: an answer to what they asked there, a question that is not a decision, a remark; with files from this machine put in it, which the console keeps and shows on the task, pictures in place. Needs no run. A report is the run's record; a comment is a word to people. Never rewritten: a mistake is corrected in the next one.",
348
+ limits: [
349
+ `the comment: ${MARKDOWN}; blank only when a file comes with it`,
350
+ `a file: up to ${MAX_FILE_BYTES} bytes, not empty; called by its name, without the path; a picture is placed in the text as ![words](its name)`,
351
+ `at most ${MAX_FILES_PER_COMMENT} files on one comment, together up to ${MAX_FILE_BYTES} bytes`,
352
+ `at most ${MAX_COMMENTS_PER_TASK} comments on one task`,
353
+ ],
354
+ refusals: [
355
+ "task.not_found: no task has that number or id",
356
+ "comment.too_many: the task carries as many comments as one may",
357
+ "comment.too_many_files: more files than one comment may carry",
358
+ "request.too_large: the files are over the limit together",
359
+ ],
360
+ },
361
+ file: {
362
+ usage: "file <id> [--out <path>]",
363
+ about:
364
+ "Reads a file of the project from the console and writes it on this machine: one a run handed in, one put in a comment, or one attached to a page, by the id the task, the run or the page shows. Written under its own name in the working directory, at --out <path>, or into --out <directory>. Then look at it with your own tools.",
365
+ refusals: ["file.not_found: no file of this project has that id"],
366
+ },
339
367
  docs: {
340
368
  usage: "docs [<folder>] [--search <words>] [--archived]",
341
369
  about:
@@ -447,7 +475,10 @@ const SECTIONS: readonly { readonly title: string; readonly commands: readonly s
447
475
  "rules",
448
476
  ],
449
477
  },
450
- { title: "Working", commands: ["take", "report", "hand-in", "ask", "finish", "fail", "abandon"] },
478
+ {
479
+ title: "Working",
480
+ commands: ["take", "report", "hand-in", "comment", "file", "ask", "finish", "fail", "abandon"],
481
+ },
451
482
  {
452
483
  title: "The documents: the project's pages of Markdown, in folders, by path",
453
484
  commands: ["docs", "doc", "write", "attach", "archive", "restore"],