@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/dist/cli/cli.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { basename, extname } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { ApiError, createClient, } from "../client.js";
4
+ import { CLIENT_RANGE_HEADER, judgeClient, parseVersion, VERSION_HEADER } from "../compat.js";
4
5
  import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
5
6
  import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
6
7
  import { fillRules, RULES_PATH, RULES_TITLE } from "../rules.js";
7
8
  import { hostOfLogin, } from "../schemas.js";
8
9
  import { COLOR_NAMES, GOAL_STATES, PERSON_ROLES, PROJECT_ROLES, PROJECT_VISIBILITIES, PROVIDER_KEYS, TASK_PRIORITIES, TASK_STATES, } from "../vocabulary.js";
9
10
  import { loadWebRoot, WebRootError } from "../web.js";
10
- import { formatAnswer, formatCategoryLine, formatDecision, formatDecisionLine, formatDocument, formatDocuments, formatGoal, formatGoalLine, formatListingLine, formatMemberLine, formatMore, formatOptions, formatPersonLine, formatPersonWork, formatProject, formatProjectLine, formatRules, formatRun, formatRunLine, formatSkills, formatTask, formatTaskLine, formatVersion, formatVersionLine, formatWork, } from "./format.js";
11
+ import { formatAnswer, formatCategoryLine, formatComment, formatDecision, formatDecisionLine, formatDocument, formatDocuments, formatGoal, formatGoalLine, formatListingLine, formatMemberLine, formatMore, formatOptions, formatPersonLine, formatPersonWork, formatProject, formatProjectLine, formatRules, formatRun, formatRunLine, formatSkills, formatTask, formatTaskLine, formatVersion, formatVersionLine, formatWork, } from "./format.js";
11
12
  import { COMMAND_HELP, helpFor, usage } from "./help.js";
13
+ import { describeInstall, updateCommandFor } from "./install.js";
12
14
  import { createServeHandler, DEFAULT_SERVE_PORT } from "./serve.js";
13
15
  // The `console` command (docs/specs/cli.md): the agent's side of the console. A thin client of
14
16
  // the REST API with no logic of its own: every command is one or two requests and the words to
@@ -24,7 +26,11 @@ export const EXIT = {
24
26
  usage: 2,
25
27
  /** The decision still waits for a person. */
26
28
  waiting: 3,
29
+ /** The command and the console do not go together (ADR-0025): stderr says which to update, and how. */
30
+ incompatible: 4,
27
31
  };
32
+ /** A day, for how often the command repeats what it noticed of the versions. */
33
+ const DAY_MS = 86_400_000;
28
34
  /** How long one request for a decision's answer asks the console to hold it: what the server allows. */
29
35
  const WAIT_STEP_SECONDS = 50;
30
36
  /** A breath between two such requests, should the console answer at once. */
@@ -83,7 +89,7 @@ async function open(io, values) {
83
89
  if (url === undefined || token === undefined) {
84
90
  throw failed("Not signed in. A person signs the command in: console login --url <the console's origin>");
85
91
  }
86
- const client = createClient({ baseUrl: url, token, fetch: io.fetch });
92
+ const client = clientOf(io, url, token);
87
93
  const projectKey = async () => text(values, "project") ?? io.env.CONSOLE_PROJECT ?? (await io.directory.load())?.project;
88
94
  return {
89
95
  client,
@@ -323,7 +329,7 @@ async function settle(io, session, project, decision, seconds, shown = false) {
323
329
  }
324
330
  /** Checks a token against the console, keeps it with the address, and says who it is. */
325
331
  async function keep(io, url, token, agent) {
326
- const me = await createClient({ baseUrl: url, token, fetch: io.fetch }).me();
332
+ const me = await clientOf(io, url, token).me();
327
333
  const person = me.person;
328
334
  if (person === null) {
329
335
  throw failed("The console does not know this token. Make one on your Agents page and try again.");
@@ -349,7 +355,7 @@ const login = async (args, io) => {
349
355
  }
350
356
  // A connection (ADR-0011): a code a person approves on the console's own pages, and the token
351
357
  // handed over here once, never shown.
352
- const client = createClient({ baseUrl: url, fetch: io.fetch });
358
+ const client = clientOf(io, url);
353
359
  // "<agent> on <machine>": the pages say the agent and keep the machine for a hover.
354
360
  const agent = text(values, "agent") ?? agentOf(io.env) ?? "An agent";
355
361
  const begun = await client.connect({
@@ -387,7 +393,7 @@ const logout = async (args, io) => {
387
393
  // Agents page, and the person is told so.
388
394
  let gone = true;
389
395
  try {
390
- await createClient({ baseUrl: url, token, fetch: io.fetch }).giveUpToken();
396
+ await clientOf(io, url, token).giveUpToken();
391
397
  }
392
398
  catch (error) {
393
399
  if (!(error instanceof ApiError && error.code === "auth.required")) {
@@ -422,7 +428,31 @@ const whoami = async (args, io) => {
422
428
  throw failed("The console does not know this token. Sign in again: console login");
423
429
  }
424
430
  const key = await session.projectKey();
425
- answer(io, session, { ...me, project: key ?? null }, () => `${person.login} (${person.role}) at ${me.workspace.name}, ${session.url}${key === undefined ? "; no project named here" : `; project ${key}`}`);
431
+ const command = parseVersion(io.version);
432
+ const server = parseVersion(me.console?.version);
433
+ const verdict = command === undefined || server === undefined
434
+ ? undefined
435
+ : judgeClient(command, server).verdict;
436
+ // A console from before the rule says nothing of itself; the command says so instead.
437
+ const consoleWords = me.console === undefined
438
+ ? "the console does not say its version, so it is older than 0.1.18"
439
+ : `the console is ${me.console.version} and takes ${me.console.clientRange}`;
440
+ answer(io, session, {
441
+ ...me,
442
+ console: me.console ?? null,
443
+ project: key ?? null,
444
+ command: { version: io.version ?? null, install: io.install ?? null },
445
+ }, () => [
446
+ `${person.login} (${person.role}) at ${me.workspace.name}, ${session.url}${key === undefined ? "; no project named here" : `; project ${key}`}`,
447
+ `This command is ${io.version ?? "of a version it does not know"}${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}; ${consoleWords}.`,
448
+ ...(verdict === "behind"
449
+ ? [`A newer command exists. Update it: ${updateWords(io, me.console?.version)}`]
450
+ : verdict === "ahead"
451
+ ? [
452
+ `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)}`,
453
+ ]
454
+ : []),
455
+ ].join("\n"));
426
456
  return EXIT.ok;
427
457
  };
428
458
  const projects = async (args, io) => {
@@ -697,11 +727,82 @@ const task = async (args, io) => {
697
727
  const session = await open(io, values);
698
728
  const project = await session.project();
699
729
  const found = await project.task(ref);
700
- const [decisions, runs] = await Promise.all([
730
+ const [decisions, runs, comments] = await Promise.all([
701
731
  project.decisions({ task: found.id }),
702
732
  project.runs({ task: found.id }),
733
+ project.comments(found.id),
703
734
  ]);
704
- answer(io, session, { task: found, decisions: decisions.items, runs: runs.items }, () => formatTask(found, decisions.items, runs.items));
735
+ answer(io, session, { task: found, decisions: decisions.items, runs: runs.items, comments: comments.items }, () => formatTask(found, decisions.items, runs.items, comments.items));
736
+ return EXIT.ok;
737
+ };
738
+ /**
739
+ * A word in the discussion on a task (ADR-0024): what is said, as given, from a file or from
740
+ * standard input, with the files named by --attach put in it; one request, one comment.
741
+ */
742
+ const comment = async (args, io) => {
743
+ const { values, positionals } = parse(args, {
744
+ file: { type: "string" },
745
+ attach: { type: "string", multiple: true },
746
+ });
747
+ const ask = 'Say which task, and what: console comment <number|id> "<markdown>" [--file <path> | -] [--attach <path>]...';
748
+ if (positionals.length === 0 || positionals.length > 2) {
749
+ throw misuse(ask);
750
+ }
751
+ const ref = positionals[0] ?? "";
752
+ const body = (await bodyOf(io, values, positionals[1])) ?? "";
753
+ const paths = texts(values, "attach");
754
+ if (body.trim().length === 0 && paths.length === 0) {
755
+ throw misuse(`Say something, or attach a file. ${ask}`);
756
+ }
757
+ const files = [];
758
+ for (const path of paths) {
759
+ let bytes;
760
+ try {
761
+ bytes = await io.readBytes(path);
762
+ }
763
+ catch {
764
+ throw failed(`No file could be read at ${path}. Name the path of a file to attach.`);
765
+ }
766
+ const name = basename(path);
767
+ const contentType = CONTENT_TYPES[extname(name).toLowerCase()];
768
+ files.push({ name, bytes, ...(contentType === undefined ? {} : { contentType }) });
769
+ }
770
+ const session = await open(io, values);
771
+ const project = await session.project();
772
+ const task = await project.task(ref);
773
+ const written = await project.comment(task.id, { body, files });
774
+ answer(io, session, written, () => [
775
+ `Commented on #${task.number} ${task.title}${written.files.length === 0 ? "" : `, with ${written.files.length} file${written.files.length === 1 ? "" : "s"}`}.`,
776
+ formatComment(written),
777
+ ].join("\n"));
778
+ return EXIT.ok;
779
+ };
780
+ /**
781
+ * A file of the project, read from the console and written on this machine: one a run handed
782
+ * in, one put in a comment, or one attached to a page, by its id, so that an agent can look at
783
+ * a picture a person put on the board.
784
+ */
785
+ const file = async (args, io) => {
786
+ const { values, positionals } = parse(args, { out: { type: "string" } });
787
+ const id = theOne(positionals, "Say which file: console file <id> [--out <path>]");
788
+ const session = await open(io, values);
789
+ const project = await session.project();
790
+ const read = await project.readFile(id);
791
+ const target = text(values, "out") ?? read.name;
792
+ let saved;
793
+ try {
794
+ saved = await io.writeBytes(target, read.bytes, read.name);
795
+ }
796
+ catch {
797
+ throw failed(`The file could not be written at ${target}. Name a path with --out <path>.`);
798
+ }
799
+ answer(io, session, {
800
+ id,
801
+ name: read.name,
802
+ contentType: read.contentType,
803
+ size: read.bytes.byteLength,
804
+ path: saved,
805
+ }, () => `Saved ${read.name} (${read.bytes.byteLength} bytes, ${read.contentType}) at ${saved}`);
705
806
  return EXIT.ok;
706
807
  };
707
808
  const take = async (args, io) => {
@@ -717,10 +818,11 @@ const take = async (args, io) => {
717
818
  });
718
819
  // The rules come with the run, as they read for the person: everything from here acts
719
820
  // within them (ADR-0023); the task is read again, as it stands now that it is taken.
720
- const [ruling, me, taken] = await Promise.all([
821
+ const [ruling, me, taken, comments] = await Promise.all([
721
822
  project.rules(),
722
823
  session.client.me(),
723
824
  project.task(ref),
825
+ project.comments(found.id),
724
826
  ]);
725
827
  // Taking a task with a run open on it ends that run (ADR-0020): said when it happened.
726
828
  const tookOver = found.openRuns > 0
@@ -730,15 +832,15 @@ const take = async (args, io) => {
730
832
  workspace: forPerson(ruling.workspace, me.language),
731
833
  project: forPerson(ruling.project, me.language),
732
834
  };
733
- answer(io, session, { run, task: taken, rules }, () => [
835
+ answer(io, session, { run, task: taken, comments: comments.items, rules }, () => [
734
836
  `Run ${run.id} began on #${found.number} ${found.title}, as "${run.agent}" for ${run.person.login}. The task is in progress and theirs.${tookOver}`,
735
837
  "",
736
- formatTask(taken),
838
+ formatTask(taken, undefined, undefined, comments.items),
737
839
  "",
738
840
  formatRules(rules),
739
841
  "",
740
842
  'Say how it goes: console report "<what you found or did>". Hand in what you make: console hand-in <https url | file path>.',
741
- 'Ask when a person must decide: console ask "<question>" --option "..." --option "...". End with: console finish --summary "<what was done, what is left>".',
843
+ '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>".',
742
844
  ].join("\n"));
743
845
  return EXIT.ok;
744
846
  };
@@ -1005,25 +1107,40 @@ const restore = async (args, io) => {
1005
1107
  return EXIT.ok;
1006
1108
  };
1007
1109
  const goals = async (args, io) => {
1008
- const { values } = parse(args, {}, false);
1110
+ const { values } = parse(args, { category: { type: "string" }, state: { type: "string" } }, false);
1111
+ const state = goalStateOf(text(values, "state"));
1112
+ const category = text(values, "category");
1009
1113
  const session = await open(io, values);
1010
1114
  const project = await session.project();
1011
- const found = await project.goals();
1115
+ const found = await project.goals({
1116
+ ...(state === undefined ? {} : { state }),
1117
+ ...(category === undefined ? {} : { category }),
1118
+ });
1012
1119
  answer(io, session, found, () => found.items.length === 0
1013
- ? "No goals. An owner sets one: console goal new <title> --due <day>"
1120
+ ? state === undefined && category === undefined
1121
+ ? "No goals. An owner sets one: console goal new <title> --due <day>"
1122
+ : "No goal matches."
1014
1123
  : found.items.map(formatGoalLine).join("\n"));
1015
1124
  return EXIT.ok;
1016
1125
  };
1017
1126
  const GOAL_FIELDS = {
1018
1127
  state: { type: "string" },
1128
+ category: { type: "string" },
1129
+ starts: { type: "string" },
1019
1130
  due: { type: "string" },
1020
1131
  body: { type: "string" },
1021
1132
  file: { type: "string" },
1022
1133
  };
1134
+ /** A category's key as a flag names one, or `null` for `none` where the field may be cleared. */
1135
+ function categoryOf(value, clearable = false) {
1136
+ return clearable && value === "none" ? null : value;
1137
+ }
1023
1138
  const newGoal = async (args, io) => {
1024
1139
  const { values, positionals } = parse(args, GOAL_FIELDS);
1025
1140
  const title = theOne(positionals, 'Say the title: console goal new "<title>" [--due <day>]');
1026
1141
  const state = goalStateOf(text(values, "state"));
1142
+ const category = categoryOf(text(values, "category"));
1143
+ const startsOn = dayOf(io, text(values, "starts"));
1027
1144
  const dueOn = dayOf(io, text(values, "due"));
1028
1145
  const body = await bodyOf(io, values, text(values, "body"));
1029
1146
  const session = await open(io, values);
@@ -1032,6 +1149,8 @@ const newGoal = async (args, io) => {
1032
1149
  title,
1033
1150
  ...(body === undefined ? {} : { body }),
1034
1151
  ...(state === undefined ? {} : { state }),
1152
+ ...(category === undefined ? {} : { category }),
1153
+ ...(startsOn === undefined ? {} : { startsOn }),
1035
1154
  ...(dueOn === undefined ? {} : { dueOn }),
1036
1155
  });
1037
1156
  answer(io, session, made, () => `Goal set: ${made.title}\nid: ${made.id}`);
@@ -1042,16 +1161,20 @@ const editGoal = async (args, io) => {
1042
1161
  const id = theOne(positionals, "Say which goal: console goal edit <id> --state <state> ...");
1043
1162
  const title = text(values, "title");
1044
1163
  const state = goalStateOf(text(values, "state"));
1164
+ const category = categoryOf(text(values, "category"), true);
1165
+ const startsOn = dayOf(io, text(values, "starts"), true);
1045
1166
  const dueOn = dayOf(io, text(values, "due"), true);
1046
1167
  const body = await bodyOf(io, values, text(values, "body"));
1047
1168
  const patch = {
1048
1169
  ...(title === undefined ? {} : { title }),
1049
1170
  ...(body === undefined ? {} : { body }),
1050
1171
  ...(state === undefined ? {} : { state }),
1172
+ ...(category === undefined ? {} : { category }),
1173
+ ...(startsOn === undefined ? {} : { startsOn }),
1051
1174
  ...(dueOn === undefined ? {} : { dueOn }),
1052
1175
  };
1053
1176
  if (Object.keys(patch).length === 0) {
1054
- throw misuse("Say what to change: --title, --body, --state or --due; --due none clears the day.");
1177
+ throw misuse("Say what to change: --title, --body, --state, --category, --starts or --due; none clears a category or a day.");
1055
1178
  }
1056
1179
  const session = await open(io, values);
1057
1180
  const project = await session.project();
@@ -1476,6 +1599,8 @@ const COMMANDS = {
1476
1599
  take,
1477
1600
  report,
1478
1601
  "hand-in": handIn,
1602
+ comment,
1603
+ file,
1479
1604
  ask,
1480
1605
  decision,
1481
1606
  decisions,
@@ -1503,9 +1628,67 @@ function help(io, name) {
1503
1628
  io.stdout(found);
1504
1629
  return EXIT.ok;
1505
1630
  }
1631
+ /** A client of the console at `url`, naming the command on every request (ADR-0025). */
1632
+ function clientOf(io, url, token) {
1633
+ return createClient({
1634
+ baseUrl: url,
1635
+ fetch: io.fetch,
1636
+ ...(token === undefined ? {} : { token }),
1637
+ ...(io.userAgent === undefined ? {} : { userAgent: io.userAgent }),
1638
+ });
1639
+ }
1640
+ /** The command that puts the console's version where this command is installed. */
1641
+ function updateWords(io, version) {
1642
+ const wanted = version ?? "latest";
1643
+ if (io.install === undefined) {
1644
+ return `npm install -g @skillcdn/console@${wanted}`;
1645
+ }
1646
+ const command = updateCommandFor(io.install, wanted);
1647
+ return io.install.kind === "project" ? `${command} (in ${io.install.root})` : command;
1648
+ }
1649
+ /**
1650
+ * What the command says of the versions once the console answered: that a newer command exists,
1651
+ * or that this one is newer than the console; once a day for one pair of versions, on stderr,
1652
+ * so that the console's JSON stays what it is. What is out of the range never gets here: the
1653
+ * console refused it, and the refusal says what to do.
1654
+ */
1655
+ async function noticeVersions(io, seen) {
1656
+ const command = parseVersion(io.version);
1657
+ const server = parseVersion(seen.version);
1658
+ if (io.version === undefined ||
1659
+ seen.version === undefined ||
1660
+ command === undefined ||
1661
+ server === undefined) {
1662
+ return;
1663
+ }
1664
+ const { verdict } = judgeClient(command, server);
1665
+ if (verdict !== "behind" && verdict !== "ahead") {
1666
+ return;
1667
+ }
1668
+ const previous = await io.notices?.load();
1669
+ const now = io.now();
1670
+ if (previous !== undefined &&
1671
+ previous.command === io.version &&
1672
+ previous.console === seen.version &&
1673
+ now - Date.parse(previous.shownAt) < DAY_MS) {
1674
+ return;
1675
+ }
1676
+ io.stderr(verdict === "behind"
1677
+ ? `A newer command exists: the console runs ${seen.version}, this command is ${io.version}. Update it: ${updateWords(io, seen.version)}\n`
1678
+ : `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`);
1679
+ await io.notices?.save({
1680
+ command: io.version,
1681
+ console: seen.version,
1682
+ shownAt: new Date(now).toISOString(),
1683
+ });
1684
+ }
1506
1685
  /** What a refusal of the console is followed by, when there is something to do about it. */
1507
- function hintFor(error) {
1686
+ function hintFor(error, io, seen) {
1508
1687
  switch (error.code) {
1688
+ case "client.outdated":
1689
+ return `Update the command: ${updateWords(io, seen.version)}\n`;
1690
+ case "client.too_new":
1691
+ return `Install the console's version: ${updateWords(io, seen.version)}; or ask the console's operator to update the console.\n`;
1509
1692
  case "auth.required":
1510
1693
  return "The token is not one the console knows, any more or at all. A person signs the command in again: console login\n";
1511
1694
  case "project.not_found":
@@ -1524,6 +1707,10 @@ function hintFor(error) {
1524
1707
  return "The due day is before the start. Say a due day on or after it.\n";
1525
1708
  case "goal.not_found":
1526
1709
  return "No goal of this project has that id. console goals lists them.\n";
1710
+ case "goal.invalid_category":
1711
+ return "No category has that key. console categories lists them.\n";
1712
+ case "goal.invalid_dates":
1713
+ return "The target day is before the start. Say a target day on or after it.\n";
1527
1714
  case "auth.forbidden":
1528
1715
  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";
1529
1716
  case "project.key_taken":
@@ -1563,9 +1750,21 @@ function hintFor(error) {
1563
1750
  /** Runs the command and answers with its exit code. What it prints goes through `io`. */
1564
1751
  export async function runCli(argv, io) {
1565
1752
  const [name, ...rest] = argv;
1753
+ // Every answer of the console says its version and the range it takes; the command reads
1754
+ // them off the answers it gets anyway, and asks nothing more (ADR-0025).
1755
+ const seen = {};
1756
+ const watched = {
1757
+ ...io,
1758
+ fetch: async (input, init) => {
1759
+ const response = await io.fetch(input, init);
1760
+ seen.version = response.headers.get(VERSION_HEADER) ?? seen.version;
1761
+ seen.range = response.headers.get(CLIENT_RANGE_HEADER) ?? seen.range;
1762
+ return response;
1763
+ },
1764
+ };
1566
1765
  try {
1567
1766
  if (name === "--version" || name === "-v" || name === "version") {
1568
- io.stdout(`console ${io.version ?? "(version unknown)"} (@skillcdn/console)\n`);
1767
+ io.stdout(`console ${io.version ?? "(version unknown)"} (@skillcdn/console)${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}\n`);
1569
1768
  return EXIT.ok;
1570
1769
  }
1571
1770
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
@@ -1578,7 +1777,11 @@ export async function runCli(argv, io) {
1578
1777
  if (command === undefined) {
1579
1778
  throw misuse(`Not a command: ${name}. Run: console help`);
1580
1779
  }
1581
- return await command(rest, io);
1780
+ const exit = await command(rest, watched);
1781
+ if (name !== "whoami") {
1782
+ await noticeVersions(io, seen);
1783
+ }
1784
+ return exit;
1582
1785
  }
1583
1786
  catch (error) {
1584
1787
  if (error instanceof CliExit) {
@@ -1586,8 +1789,10 @@ export async function runCli(argv, io) {
1586
1789
  return error.exit;
1587
1790
  }
1588
1791
  if (error instanceof ApiError) {
1589
- io.stderr(`${error.code}: ${error.message}\n${hintFor(error)}`);
1590
- return EXIT.failed;
1792
+ io.stderr(`${error.code}: ${error.message}\n${hintFor(error, io, seen)}`);
1793
+ return error.code === "client.outdated" || error.code === "client.too_new"
1794
+ ? EXIT.incompatible
1795
+ : EXIT.failed;
1591
1796
  }
1592
1797
  throw error;
1593
1798
  }