@skillcdn/console 0.4.3 → 0.4.4

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 (231) hide show
  1. package/README.md +4 -4
  2. package/dist/api.d.ts +3 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -3
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +95 -20
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +4 -2
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +32 -9
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +17 -14
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/plugin-check.d.ts +5 -1
  17. package/dist/cli/plugin-check.d.ts.map +1 -1
  18. package/dist/cli/plugin-check.js +23 -4
  19. package/dist/cli/plugin-check.js.map +1 -1
  20. package/dist/cli/plugin-cli.d.ts.map +1 -1
  21. package/dist/cli/plugin-cli.js +10 -1
  22. package/dist/cli/plugin-cli.js.map +1 -1
  23. package/dist/cli/plugin-init.d.ts +2 -0
  24. package/dist/cli/plugin-init.d.ts.map +1 -1
  25. package/dist/cli/plugin-init.js +6 -0
  26. package/dist/cli/plugin-init.js.map +1 -1
  27. package/dist/client.d.ts +4 -0
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +2 -0
  30. package/dist/client.js.map +1 -1
  31. package/dist/compat.js +1 -1
  32. package/dist/components/board.d.ts +8 -0
  33. package/dist/components/board.d.ts.map +1 -1
  34. package/dist/components/board.js +3 -3
  35. package/dist/components/board.js.map +1 -1
  36. package/dist/components/decision-form.d.ts +6 -0
  37. package/dist/components/decision-form.d.ts.map +1 -1
  38. package/dist/components/decision-form.js +23 -5
  39. package/dist/components/decision-form.js.map +1 -1
  40. package/dist/components/decision-list.d.ts +5 -1
  41. package/dist/components/decision-list.d.ts.map +1 -1
  42. package/dist/components/decision-list.js +25 -5
  43. package/dist/components/decision-list.js.map +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +7 -1
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/goals.d.ts.map +1 -1
  48. package/dist/components/goals.js +2 -2
  49. package/dist/components/goals.js.map +1 -1
  50. package/dist/components/home.d.ts +35 -0
  51. package/dist/components/home.d.ts.map +1 -0
  52. package/dist/components/home.js +326 -0
  53. package/dist/components/home.js.map +1 -0
  54. package/dist/components/pack-connect.d.ts.map +1 -1
  55. package/dist/components/pack-connect.js +5 -4
  56. package/dist/components/pack-connect.js.map +1 -1
  57. package/dist/components/people.d.ts +2 -0
  58. package/dist/components/people.d.ts.map +1 -1
  59. package/dist/components/people.js +15 -2
  60. package/dist/components/people.js.map +1 -1
  61. package/dist/components/plugin-frame.d.ts +3 -12
  62. package/dist/components/plugin-frame.d.ts.map +1 -1
  63. package/dist/components/plugin-frame.js +53 -20
  64. package/dist/components/plugin-frame.js.map +1 -1
  65. package/dist/components/plugin-settings.d.ts.map +1 -1
  66. package/dist/components/plugin-settings.js +151 -26
  67. package/dist/components/plugin-settings.js.map +1 -1
  68. package/dist/components/projects.d.ts +0 -8
  69. package/dist/components/projects.d.ts.map +1 -1
  70. package/dist/components/projects.js +1 -13
  71. package/dist/components/projects.js.map +1 -1
  72. package/dist/components/task-view.d.ts.map +1 -1
  73. package/dist/components/task-view.js +2 -1
  74. package/dist/components/task-view.js.map +1 -1
  75. package/dist/components/ui.d.ts.map +1 -1
  76. package/dist/components/ui.js +5 -2
  77. package/dist/components/ui.js.map +1 -1
  78. package/dist/connect-page.d.ts.map +1 -1
  79. package/dist/connect-page.js +5 -6
  80. package/dist/connect-page.js.map +1 -1
  81. package/dist/console.css +761 -191
  82. package/dist/console.d.ts +10 -3
  83. package/dist/console.d.ts.map +1 -1
  84. package/dist/console.js +33 -13
  85. package/dist/console.js.map +1 -1
  86. package/dist/data.d.ts +4 -2
  87. package/dist/data.d.ts.map +1 -1
  88. package/dist/data.js +5 -0
  89. package/dist/data.js.map +1 -1
  90. package/dist/docs-page.d.ts.map +1 -1
  91. package/dist/docs-page.js +6 -5
  92. package/dist/docs-page.js.map +1 -1
  93. package/dist/home-tiles.d.ts +92 -0
  94. package/dist/home-tiles.d.ts.map +1 -0
  95. package/dist/home-tiles.js +294 -0
  96. package/dist/home-tiles.js.map +1 -0
  97. package/dist/i18n/en.d.ts +174 -0
  98. package/dist/i18n/en.d.ts.map +1 -1
  99. package/dist/i18n/en.js +175 -1
  100. package/dist/i18n/en.js.map +1 -1
  101. package/dist/i18n/index.d.ts +4 -2
  102. package/dist/i18n/index.d.ts.map +1 -1
  103. package/dist/i18n/index.js +10 -11
  104. package/dist/i18n/index.js.map +1 -1
  105. package/dist/i18n/ko.d.ts.map +1 -1
  106. package/dist/i18n/ko.js +170 -3
  107. package/dist/i18n/ko.js.map +1 -1
  108. package/dist/index.d.ts +4 -3
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +4 -3
  111. package/dist/index.js.map +1 -1
  112. package/dist/limits.d.ts +6 -0
  113. package/dist/limits.d.ts.map +1 -1
  114. package/dist/limits.js +6 -0
  115. package/dist/limits.js.map +1 -1
  116. package/dist/plugin/bridge.d.ts +44 -3
  117. package/dist/plugin/bridge.d.ts.map +1 -1
  118. package/dist/plugin/bridge.js +26 -0
  119. package/dist/plugin/bridge.js.map +1 -1
  120. package/dist/plugin/capabilities.d.ts +7 -1
  121. package/dist/plugin/capabilities.d.ts.map +1 -1
  122. package/dist/plugin/capabilities.js +14 -1
  123. package/dist/plugin/capabilities.js.map +1 -1
  124. package/dist/plugin/context.d.ts +18 -1
  125. package/dist/plugin/context.d.ts.map +1 -1
  126. package/dist/plugin/define.d.ts +1 -0
  127. package/dist/plugin/define.d.ts.map +1 -1
  128. package/dist/plugin/define.js +1 -1
  129. package/dist/plugin/define.js.map +1 -1
  130. package/dist/plugin/limits.d.ts +5 -2
  131. package/dist/plugin/limits.d.ts.map +1 -1
  132. package/dist/plugin/limits.js +5 -2
  133. package/dist/plugin/limits.js.map +1 -1
  134. package/dist/plugin/manifest.d.ts +83 -6
  135. package/dist/plugin/manifest.d.ts.map +1 -1
  136. package/dist/plugin/manifest.js +111 -7
  137. package/dist/plugin/manifest.js.map +1 -1
  138. package/dist/plugin/rest-schemas.d.ts +76 -0
  139. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  140. package/dist/plugin/rest-schemas.js +23 -2
  141. package/dist/plugin/rest-schemas.js.map +1 -1
  142. package/dist/plugin/sandbox.d.ts +4 -3
  143. package/dist/plugin/sandbox.d.ts.map +1 -1
  144. package/dist/plugin/sandbox.js +33 -6
  145. package/dist/plugin/sandbox.js.map +1 -1
  146. package/dist/plugin/semver.d.ts +26 -0
  147. package/dist/plugin/semver.d.ts.map +1 -0
  148. package/dist/plugin/semver.js +76 -0
  149. package/dist/plugin/semver.js.map +1 -0
  150. package/dist/plugin/testing.d.ts.map +1 -1
  151. package/dist/plugin/testing.js +2 -1
  152. package/dist/plugin/testing.js.map +1 -1
  153. package/dist/plugin/web.d.ts +10 -1
  154. package/dist/plugin/web.d.ts.map +1 -1
  155. package/dist/plugin/web.js +34 -0
  156. package/dist/plugin/web.js.map +1 -1
  157. package/dist/plugin.d.ts +3 -2
  158. package/dist/plugin.d.ts.map +1 -1
  159. package/dist/plugin.js +3 -2
  160. package/dist/plugin.js.map +1 -1
  161. package/dist/rules.d.ts +2 -2
  162. package/dist/rules.js +3 -3
  163. package/dist/schemas.d.ts +497 -2
  164. package/dist/schemas.d.ts.map +1 -1
  165. package/dist/schemas.js +96 -9
  166. package/dist/schemas.js.map +1 -1
  167. package/dist/use-action.d.ts +8 -2
  168. package/dist/use-action.d.ts.map +1 -1
  169. package/dist/use-action.js +13 -6
  170. package/dist/use-action.js.map +1 -1
  171. package/dist/vocabulary.d.ts +3 -1
  172. package/dist/vocabulary.d.ts.map +1 -1
  173. package/dist/vocabulary.js +3 -0
  174. package/dist/vocabulary.js.map +1 -1
  175. package/docs/specs/cli.md +4 -3
  176. package/docs/specs/plugins.md +46 -33
  177. package/docs/specs/rest.md +16 -15
  178. package/package.json +1 -1
  179. package/skills/building-a-plugin/SKILL.md +8 -7
  180. package/skills/planning-the-board/references/setting-up.md +2 -1
  181. package/skills/working-the-board/SKILL.md +3 -3
  182. package/src/api.ts +6 -0
  183. package/src/cli/cli.ts +124 -19
  184. package/src/cli/format.ts +42 -9
  185. package/src/cli/help.ts +17 -13
  186. package/src/cli/plugin-check.ts +41 -2
  187. package/src/cli/plugin-cli.ts +10 -1
  188. package/src/cli/plugin-init.ts +8 -0
  189. package/src/client.ts +8 -0
  190. package/src/compat.ts +1 -1
  191. package/src/components/board.tsx +3 -3
  192. package/src/components/decision-form.tsx +75 -19
  193. package/src/components/decision-list.tsx +45 -13
  194. package/src/components/event-feed.tsx +7 -1
  195. package/src/components/goals.tsx +4 -1
  196. package/src/components/home.tsx +615 -0
  197. package/src/components/pack-connect.tsx +5 -4
  198. package/src/components/people.tsx +136 -54
  199. package/src/components/plugin-frame.tsx +62 -63
  200. package/src/components/plugin-settings.tsx +428 -87
  201. package/src/components/projects.tsx +0 -55
  202. package/src/components/task-view.tsx +7 -0
  203. package/src/components/ui.tsx +6 -2
  204. package/src/connect-page.tsx +5 -6
  205. package/src/console.tsx +80 -30
  206. package/src/data.ts +9 -2
  207. package/src/docs-page.tsx +6 -5
  208. package/src/home-tiles.ts +438 -0
  209. package/src/i18n/en.ts +178 -0
  210. package/src/i18n/index.ts +17 -14
  211. package/src/i18n/ko.ts +174 -3
  212. package/src/index.ts +7 -5
  213. package/src/limits.ts +6 -0
  214. package/src/plugin/bridge.ts +77 -1
  215. package/src/plugin/capabilities.ts +17 -1
  216. package/src/plugin/context.ts +18 -1
  217. package/src/plugin/define.ts +4 -1
  218. package/src/plugin/limits.ts +5 -2
  219. package/src/plugin/manifest.ts +154 -10
  220. package/src/plugin/rest-schemas.ts +27 -1
  221. package/src/plugin/sandbox.ts +36 -8
  222. package/src/plugin/semver.ts +89 -0
  223. package/src/plugin/testing.ts +2 -1
  224. package/src/plugin/web.ts +49 -0
  225. package/src/plugin.ts +18 -0
  226. package/src/rules.ts +3 -3
  227. package/src/schemas.ts +118 -10
  228. package/src/styles/console.css +761 -191
  229. package/src/use-action.ts +16 -7
  230. package/src/vocabulary.ts +4 -0
  231. package/template/plugin/console-plugin.json +3 -1
package/src/cli/cli.ts CHANGED
@@ -21,7 +21,13 @@ import {
21
21
  formatVersion as versionText,
22
22
  } from "../compat.js";
23
23
  import { addDays, dayOf as dayOfDate, firstDayOfWeek, isDay, weekOf } from "../dates.js";
24
- import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
24
+ import {
25
+ DEFAULT_PAGE_LIMIT,
26
+ LIST_LIMIT,
27
+ MAX_DECIDERS,
28
+ MAX_OPTIONS,
29
+ MIN_OPTIONS,
30
+ } from "../limits.js";
25
31
  import { capabilityWords } from "../plugin/capabilities.js";
26
32
  import { PLUGIN_LIST_LIMIT } from "../plugin/limits.js";
27
33
  import { fillRules, RULES_PATH, RULES_TITLE } from "../rules.js";
@@ -77,6 +83,7 @@ import {
77
83
  } from "./credentials.js";
78
84
  import { type DirectorySettings, type DirectoryStore, THE_SENTENCE } from "./directory.js";
79
85
  import {
86
+ deciderLogins,
80
87
  formatAnswer,
81
88
  formatCategoryLine,
82
89
  formatComment,
@@ -1526,6 +1533,8 @@ const plugin: Command = async (args, io) => {
1526
1533
  );
1527
1534
  return EXIT.ok;
1528
1535
  }
1536
+ case "capabilities":
1537
+ return pluginCapabilities(io, session, client, rest);
1529
1538
  case "settings":
1530
1539
  return pluginSettings(io, session, client, rest);
1531
1540
  case "secret":
@@ -1787,7 +1796,60 @@ function capabilityLines(capabilities: readonly string[]): string[] {
1787
1796
  });
1788
1797
  }
1789
1798
 
1790
- /** Enables a plugin, with consent to every capability it asks, named again; an administrator's. */
1799
+ /** The capabilities `--consent` names, given again or with commas between. */
1800
+ function capabilitiesNamed(values: Values): string[] {
1801
+ return texts(values, "consent")
1802
+ .flatMap((value) => value.split(","))
1803
+ .map((capability) => capability.trim())
1804
+ .filter((capability) => capability.length > 0);
1805
+ }
1806
+
1807
+ /** What a plugin requires, and which of what it can go without it holds, as `capabilities` prints it. */
1808
+ function capabilitiesHeld(detail: RestPluginDetail): string {
1809
+ const held = detail.consent ?? [];
1810
+ const optional = detail.optionalCapabilities ?? [];
1811
+ const named = (capabilities: readonly string[]) =>
1812
+ capabilities.length === 0 ? "none" : capabilities.join(", ");
1813
+ return [
1814
+ `Capabilities of ${detail.key}:`,
1815
+ ` required: ${named(detail.capabilities)}`,
1816
+ ` optional, on: ${named(optional.filter((capability) => held.includes(capability)))}`,
1817
+ ` optional, off: ${named(optional.filter((capability) => !held.includes(capability)))}`,
1818
+ ].join("\n");
1819
+ }
1820
+
1821
+ /**
1822
+ * What a plugin holds; with `--consent`, which of the optional capabilities it holds from now on,
1823
+ * `none` for none of them. An administrator's.
1824
+ */
1825
+ async function pluginCapabilities(
1826
+ io: CliIo,
1827
+ session: Session,
1828
+ client: PluginClient,
1829
+ rest: readonly string[],
1830
+ ): Promise<number> {
1831
+ const { values, positionals } = parse(rest, { consent: { type: "string", multiple: true } });
1832
+ if (positionals.length > 0) {
1833
+ throw misuse(
1834
+ `Nothing follows capabilities but --consent: console plugin ${client.key} capabilities [--consent <capability>,...|none]`,
1835
+ );
1836
+ }
1837
+ const detail = await client.get();
1838
+ const named = capabilitiesNamed(values);
1839
+ if (named.length === 0) {
1840
+ answer(io, session, detail, () => capabilitiesHeld(detail));
1841
+ return EXIT.ok;
1842
+ }
1843
+ const chosen = named.filter((capability) => capability !== "none");
1844
+ const changed = await client.updateConsent([
1845
+ ...detail.capabilities,
1846
+ ...chosen.filter((capability) => !detail.capabilities.includes(capability)),
1847
+ ]);
1848
+ answer(io, session, changed, () => capabilitiesHeld(changed));
1849
+ return EXIT.ok;
1850
+ }
1851
+
1852
+ /** Enables a plugin, with consent to every capability it requires, named again; an administrator's. */
1791
1853
  async function pluginEnable(
1792
1854
  io: CliIo,
1793
1855
  session: Session,
@@ -1807,19 +1869,22 @@ async function pluginEnable(
1807
1869
  }
1808
1870
  const [detail, me] = await Promise.all([client.get(), session.client.me()]);
1809
1871
  const asked = detail.capabilities;
1810
- const consent = texts(values, "consent")
1811
- .flatMap((value) => value.split(","))
1812
- .map((capability) => capability.trim())
1813
- .filter((capability) => capability.length > 0);
1872
+ const optional = detail.optionalCapabilities ?? [];
1873
+ const consent = capabilitiesNamed(values);
1814
1874
  if (asked.length > 0 && !session.json) {
1815
1875
  io.stdout(
1816
- `${detail.key} asks ${asked.length === 1 ? "one capability" : `${asked.length} capabilities`}:\n${capabilityLines(asked).join("\n")}\n`,
1876
+ `${detail.key} requires ${asked.length === 1 ? "one capability" : `${asked.length} capabilities`}:\n${capabilityLines(asked).join("\n")}\n`,
1877
+ );
1878
+ }
1879
+ if (optional.length > 0 && !session.json) {
1880
+ io.stdout(
1881
+ `It can go without these, which it holds if --consent names them too:\n${capabilityLines(optional).join("\n")}\n`,
1817
1882
  );
1818
1883
  }
1819
1884
  const missing = asked.filter((capability) => !consent.includes(capability));
1820
1885
  if (missing.length > 0) {
1821
1886
  throw misuse(
1822
- `Enabling ${detail.key} needs consent to every capability it asks, named again: --consent ${asked.join(",")}. Not named: ${missing.join(", ")}.`,
1887
+ `Enabling ${detail.key} needs consent to every capability it requires, named again: --consent ${asked.join(",")}. Not named: ${missing.join(", ")}.`,
1823
1888
  );
1824
1889
  }
1825
1890
  const boardFlag = text(values, "board");
@@ -2070,7 +2135,7 @@ const rules: Command = async (args, io) => {
2070
2135
 
2071
2136
  /**
2072
2137
  * The rules as they read for the person whose token the command holds: `{language}` filled
2073
- * with the language they chose for the pages, or with the task's when they chose none.
2138
+ * with the language they read in, or with the task's while they have none.
2074
2139
  */
2075
2140
  function forPerson<T extends { readonly body: string | null }>(
2076
2141
  half: T,
@@ -2530,16 +2595,19 @@ const ask: Command = async (args, io) => {
2530
2595
  file: { type: "string" },
2531
2596
  run: { type: "string" },
2532
2597
  wait: { type: "string" },
2533
- to: { type: "string" },
2598
+ to: { type: "string", multiple: true },
2534
2599
  attach: { type: "string", multiple: true },
2535
2600
  });
2536
2601
  const question = theOne(
2537
2602
  positionals,
2538
2603
  'Say the question: console ask "<question>" --option "<label>" --option "<label>"',
2539
2604
  );
2540
- const to = text(values, "to");
2541
- if (to === "none") {
2542
- throw misuse("Say whom the decision is for: --to <login|me>.");
2605
+ const tos = texts(values, "to");
2606
+ if (tos.includes("none")) {
2607
+ throw misuse("Say whom the decision is for: --to <login|me>, given again for several.");
2608
+ }
2609
+ if (tos.length > MAX_DECIDERS) {
2610
+ throw misuse(`Name up to ${MAX_DECIDERS} people with --to.`);
2543
2611
  }
2544
2612
  const options = texts(values, "option");
2545
2613
  if (options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) {
@@ -2552,15 +2620,26 @@ const ask: Command = async (args, io) => {
2552
2620
  const session = await open(io, values);
2553
2621
  const project = await session.project();
2554
2622
  const runId = await currentRun(project, values);
2555
- // Whom the decision is for (ADR-0042): the person the agent works for, unless --to names another.
2556
- const deciderId = await personIdOf(session, to);
2623
+ // Whom the decision is for (ADR-0042, ADR-0057): the person the agent works for, unless --to
2624
+ // names others of the project, each once; several, and any of them may answer.
2625
+ const deciderIds: string[] = [];
2626
+ for (const to of tos) {
2627
+ const id = await personIdOf(session, to);
2628
+ if (typeof id === "string" && !deciderIds.includes(id)) {
2629
+ deciderIds.push(id);
2630
+ }
2631
+ }
2557
2632
  const raised = await project.raiseDecision(
2558
2633
  {
2559
2634
  question,
2560
2635
  options: options.map((label) => ({ label })),
2561
2636
  runId,
2562
2637
  ...(body === undefined ? {} : { body }),
2563
- ...(typeof deciderId === "string" ? { deciderId } : {}),
2638
+ ...(deciderIds.length === 0
2639
+ ? {}
2640
+ : deciderIds.length === 1
2641
+ ? { deciderId: deciderIds[0] }
2642
+ : { deciderId: deciderIds[0], deciderIds }),
2564
2643
  },
2565
2644
  files,
2566
2645
  );
@@ -2570,7 +2649,7 @@ const ask: Command = async (args, io) => {
2570
2649
  io.stdout(
2571
2650
  session.json
2572
2651
  ? `${JSON.stringify(raised, null, 2)}\n`
2573
- : `Decision ${raised.id} raised on run ${runId}${withFiles}; the run waits for ${raised.decider.login}.\ndecision id: ${raised.id}\n${formatOptions(raised)}\n`,
2652
+ : `Decision ${raised.id} raised on run ${runId}${withFiles}; the run waits for ${deciderLogins(raised)}.\ndecision id: ${raised.id}\n${formatOptions(raised)}\n`,
2574
2653
  );
2575
2654
  return settle(io, session, project, raised, seconds, true);
2576
2655
  };
@@ -3119,8 +3198,19 @@ const people: Command = async (args, io) => {
3119
3198
  const { values } = parse(args, {}, false);
3120
3199
  const session = await open(io, values);
3121
3200
  const found = await session.client.people();
3201
+ const loginOf = new Map(found.items.map((person) => [person.id, person.login]));
3122
3202
  answer(io, session, found, () =>
3123
- [...found.items.map(formatPersonLine), ...found.listed.map(formatListingLine)].join("\n"),
3203
+ [
3204
+ ...found.items.map((person) =>
3205
+ formatPersonLine(
3206
+ person,
3207
+ person.managerId === null || person.managerId === undefined
3208
+ ? undefined
3209
+ : (loginOf.get(person.managerId) ?? person.managerId),
3210
+ ),
3211
+ ),
3212
+ ...found.listed.map(formatListingLine),
3213
+ ].join("\n"),
3124
3214
  );
3125
3215
  return EXIT.ok;
3126
3216
  };
@@ -3201,7 +3291,7 @@ const workspace: Command = async (args, io) => {
3201
3291
  };
3202
3292
 
3203
3293
  const PERSON_USAGE =
3204
- "Say what to do: console person role <login> <admin|member> | person add <login> [--host gh|google] | person remove <login> | person deactivate <login> | person reactivate <login>";
3294
+ "Say what to do: console person role <login> <admin|member> | person manager <login> <manager login|none> | person add <login> [--host gh|google] | person remove <login> | person deactivate <login> | person reactivate <login>";
3205
3295
 
3206
3296
  const person: Command = async (args, io) => {
3207
3297
  const what = args[0];
@@ -3218,6 +3308,21 @@ const person: Command = async (args, io) => {
3218
3308
  answer(io, session, changed, () => formatPersonLine(changed));
3219
3309
  return EXIT.ok;
3220
3310
  }
3311
+ if (what === "manager") {
3312
+ const { values, positionals } = parse(args.slice(1), {});
3313
+ const [login, manager] = theTwo(
3314
+ positionals,
3315
+ "Say whom and whom they report to: console person manager <login> <manager login|none>",
3316
+ );
3317
+ const session = await open(io, values);
3318
+ const found = await personOf(session, login);
3319
+ const managerId = manager === "none" ? null : (await personOf(session, manager)).id;
3320
+ const changed = await session.client.updatePerson(found.id, { managerId });
3321
+ answer(io, session, changed, () =>
3322
+ formatPersonLine(changed, managerId === null ? undefined : manager),
3323
+ );
3324
+ return EXIT.ok;
3325
+ }
3221
3326
  if (what === "add") {
3222
3327
  const { values, positionals } = parse(args.slice(1), { host: { type: "string" } });
3223
3328
  const login = theOne(positionals, "Say whom: console person add <login> [--host gh|google]");
package/src/cli/format.ts CHANGED
@@ -119,12 +119,13 @@ export function formatProject(project: RestProject): string {
119
119
  return lines.join("\n");
120
120
  }
121
121
 
122
- /** One person: their login, their name where the host gives one, what they are in the workspace, and that they are out when they are. */
123
- export function formatPersonLine(person: RestPerson): string {
122
+ /** One person: their login, their name where the host gives one, what they are in the workspace, that they are out when they are, and whom they report to where known. */
123
+ export function formatPersonLine(person: RestPerson, reportsTo?: string | undefined): string {
124
124
  const standing = person.status === "active" ? person.role : `${person.role}, deactivated`;
125
+ const line = reportsTo === undefined ? "" : `, reports to ${reportsTo}`;
125
126
  return person.name === null
126
- ? `${person.login} (${standing})`
127
- : `${person.login} ${person.name} (${standing})`;
127
+ ? `${person.login} (${standing}${line})`
128
+ : `${person.login} ${person.name} (${standing}${line})`;
128
129
  }
129
130
 
130
131
  /** The provider a listing names, as people call it. */
@@ -219,6 +220,7 @@ export function formatTask(
219
220
  `#${task.number} ${task.title}`,
220
221
  `state: ${task.state} priority: ${task.priority} owner: ${task.owner.login} workers: ${task.workers.length === 0 ? "nobody" : logins(task.workers)}`,
221
222
  `id: ${task.id}${task.parentId === null ? "" : ` part of: ${task.parentId}`}`,
223
+ ...(task.agent == null ? [] : [`written through: ${task.agent}`]),
222
224
  ...scheduleLines(task),
223
225
  ];
224
226
  if (task.links.length > 0) {
@@ -379,7 +381,7 @@ export function formatGoalLine(goal: RestGoal): string {
379
381
  export function formatGoal(goal: RestGoal, tasks?: readonly RestTask[]): string {
380
382
  const lines = [
381
383
  `goal ${goal.id}: ${goal.title}`,
382
- `state: ${goal.state} category: ${goal.category?.key ?? "none"} starts: ${goal.startsOn ?? "none"} due: ${goal.dueOn ?? "none"} set by: ${goal.createdBy.login}`,
384
+ `state: ${goal.state} category: ${goal.category?.key ?? "none"} starts: ${goal.startsOn ?? "none"} due: ${goal.dueOn ?? "none"} set by: ${goal.createdBy.login}${through(goal.agent)}`,
383
385
  `progress: ${progressOf(goal)}`,
384
386
  ];
385
387
  if (goal.body.trim().length > 0) {
@@ -431,9 +433,27 @@ export function formatPersonWork(person: RestPerson, work: RestPersonWork): stri
431
433
  ].join("\n");
432
434
  }
433
435
 
434
- /** Whom a decision is for, where that is not the one who asked (ADR-0042). */
436
+ /** The agent a thing came through, beside the person (ADR-0056); nothing when they did it themselves. */
437
+ const through = (agent: string | null | undefined): string =>
438
+ agent === null || agent === undefined ? "" : ` through ${agent}`;
439
+
440
+ /** Everyone a decision is addressed to (ADR-0057): `deciders` where the console answers it, else the one decider. */
441
+ const decidersOf = (decision: RestDecision): readonly RestPerson[] =>
442
+ decision.deciders !== undefined && decision.deciders.length > 0
443
+ ? decision.deciders
444
+ : [decision.decider];
445
+
446
+ /** The logins of everyone a decision is addressed to, with commas between. */
447
+ export const deciderLogins = (decision: RestDecision): string =>
448
+ decidersOf(decision)
449
+ .map((person) => person.login)
450
+ .join(", ");
451
+
452
+ /** Whom a decision is for, where that is not the one who asked alone (ADR-0042, ADR-0057). */
435
453
  const addressed = (decision: RestDecision): string =>
436
- decision.decider.id === decision.raisedBy.id ? "" : `, for ${decision.decider.login}`;
454
+ decidersOf(decision).every((person) => person.id === decision.raisedBy.id)
455
+ ? ""
456
+ : `, for ${deciderLogins(decision)}`;
437
457
 
438
458
  export function formatDecisionLine(decision: RestDecision): string {
439
459
  const state = decision.answer === null ? "waiting" : `answered ${decision.answer.option}`;
@@ -454,11 +474,11 @@ export function formatOptions(decision: RestDecision): string {
454
474
  export function formatAnswer(decision: RestDecision): string {
455
475
  const { answer } = decision;
456
476
  if (answer === null) {
457
- return `waits for ${decision.decider.login}.`;
477
+ return `waits for ${deciderLogins(decision)}.`;
458
478
  }
459
479
  const chosen = decision.options.find((option) => option.id === answer.option);
460
480
  const lines = [
461
- `Answered: ${answer.option}) ${chosen?.label ?? answer.option}${chosen?.own === true ? ", in their own words" : ""}, by ${answer.by.login} at ${answer.at}`,
481
+ `Answered: ${answer.option}) ${chosen?.label ?? answer.option}${chosen?.own === true ? ", in their own words" : ""}, by ${answer.by.login}${through(answer.agent)} at ${answer.at}`,
462
482
  ];
463
483
  if (answer.note !== null && answer.note.length > 0) {
464
484
  lines.push(`Note: ${answer.note}`);
@@ -488,6 +508,13 @@ export function formatDecision(decision: RestDecision): string {
488
508
  lines.push("", "options:", formatOptions(decision), "", formatAnswer(decision));
489
509
  if (decision.outcome !== null && decision.outcome.trim().length > 0) {
490
510
  lines.push("", "what followed:", indent(decision.outcome));
511
+ if (decision.outcomeBy != null && decision.outcomeAt != null) {
512
+ lines.push(
513
+ indent(
514
+ `written by ${decision.outcomeBy.login}${through(decision.outcomeAgent)} at ${decision.outcomeAt}`,
515
+ ),
516
+ );
517
+ }
491
518
  }
492
519
  return lines.join("\n");
493
520
  }
@@ -590,6 +617,9 @@ export function localized(
590
617
  export function formatPluginLine(plugin: RestPlugin, language: string | null | undefined): string {
591
618
  const notes = [
592
619
  plugin.roles.length === 0 ? undefined : `roles: ${plugin.roles.join(", ")}`,
620
+ plugin.console === null || plugin.console === undefined
621
+ ? undefined
622
+ : `console: ${plugin.console}`,
593
623
  plugin.problems.length === 0 ? undefined : `problems: ${plugin.problems.join("; ")}`,
594
624
  ].filter((note): note is string => note !== undefined);
595
625
  return `${plugin.key} ${localized(plugin.name, language)} ${plugin.version} ${plugin.state}${notes.length === 0 ? "" : ` ${notes.join(" ")}`}`;
@@ -761,6 +791,9 @@ export function formatPlugin(
761
791
  if (detail.problems.length > 0) {
762
792
  lines.push(`problems: ${detail.problems.join("; ")}`);
763
793
  }
794
+ if (detail.console !== null && detail.console !== undefined) {
795
+ lines.push(`Runs on consoles ${detail.console}.`);
796
+ }
764
797
  lines.push(
765
798
  detail.roles.length === 0
766
799
  ? "You hold no role in it."
package/src/cli/help.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  MAX_CATEGORY_KEY_LENGTH,
8
8
  MAX_CATEGORY_NAME_LENGTH,
9
9
  MAX_COMMENTS_PER_TASK,
10
+ MAX_DECIDERS,
10
11
  MAX_DECISIONS_PER_PROJECT,
11
12
  MAX_DOCUMENT_LENGTH,
12
13
  MAX_DOCUMENT_PATH_LENGTH,
@@ -247,14 +248,15 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
247
248
  },
248
249
  person: {
249
250
  usage:
250
- "person role <login> <admin|member> | person add <login> [--host gh|google] | person remove <login> | person deactivate <login> | person reactivate <login>",
251
+ "person role <login> <admin|member> | person manager <login> <manager login|none> | person add <login> [--host gh|google] | person remove <login> | person deactivate <login> | person reactivate <login>",
251
252
  synopsis:
252
- "Says what a person is in the workspace, lets a login in or takes one out; for an administrator.",
253
+ "Says what a person is in the workspace and whom they report to, lets a login in or takes one out; for an administrator.",
253
254
  about:
254
- "For an administrator: the person themselves on the People page, or their agent here, named on it. role says what a person is in the workspace; the board keeps at least one administrator in. add lets a login in: whoever signs in with that account at that host is a person from then on; the host is read off the login (an address is Google's, anything else GitHub's) unless --host says. remove takes a login away that nobody has signed in with yet. deactivate takes a person out: refused on every request at once, their agents with them, kept with everything they did, and listed nowhere; reactivate takes them back in. A person the organization let in comes and goes with it, and is neither.",
255
+ "For an administrator: the person themselves on the People page, or their agent here, named on it. role says what a person is in the workspace; the board keeps at least one administrator in. manager says whom a person reports to, the reporting line of the directory, which the plugins read as a default (the approver of their leave, say) in place of a line of their own: another active person of the workspace who does not report to them, directly or through others, or none; console people shows it. add lets a login in: whoever signs in with that account at that host is a person from then on; the host is read off the login (an address is Google's, anything else GitHub's) unless --host says. remove takes a login away that nobody has signed in with yet. deactivate takes a person out: refused on every request at once, their agents with them, kept with everything they did, and listed nowhere; reactivate takes them back in. A person the organization let in comes and goes with it, and is neither.",
255
256
  refusals: [
256
257
  "auth.forbidden: only an administrator says who is in and what they are",
257
258
  "person.last_admin: the board keeps at least one administrator in; make another one first",
259
+ "person.invalid_manager: the manager is not another active person of the workspace, or reports to this person already",
258
260
  "person.of_organization: the organization says whether that person is in",
259
261
  "listing.exists: the login is let in already, or is a person's who is in",
260
262
  "listing.unknown_host: nobody signs in through that provider here",
@@ -378,9 +380,9 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
378
380
  },
379
381
  plugin: {
380
382
  usage:
381
- "plugin <key> | plugin <key> help [<command>] | plugin <key> <command> [<args>] [--<name> <value>]... [--file <path> | -] [--run <id>] | plugin <key> records <collection> [<id>] [--where <field>=<value>]... [--search <words>] [--project <key>] [--order <field>[:desc]] [--limit <n>] [--after <cursor>] [--archived] [--versions] | plugin <key> skills [<folder>|<file>] [--search <words> [--limit <n>] [--cursor <cursor>]] | plugin <key> skill <path> | plugin <key> skill --file <path> [--offset <n>] [--limit <n>] | plugin <key> enable --consent <capability>,... [--board new|<key>] [--projects all|<key>,...] [--set <key>=<value>]... | plugin <key> disable | plugin <key> settings [--set <key>=<value>]... | plugin <key> secret <name> [--remove] | plugin <key> grant <role> --person <login>|--project <key>|--everyone | plugin <key> revoke <grant id>",
383
+ "plugin <key> | plugin <key> help [<command>] | plugin <key> <command> [<args>] [--<name> <value>]... [--file <path> | -] [--run <id>] | plugin <key> records <collection> [<id>] [--where <field>=<value>]... [--search <words>] [--project <key>] [--order <field>[:desc]] [--limit <n>] [--after <cursor>] [--archived] [--versions] | plugin <key> skills [<folder>|<file>] [--search <words> [--limit <n>] [--cursor <cursor>]] | plugin <key> skill <path> | plugin <key> skill --file <path> [--offset <n>] [--limit <n>] | plugin <key> enable --consent <capability>,... [--board new|<key>] [--projects all|<key>,...] [--set <key>=<value>]... | plugin <key> disable | plugin <key> capabilities [--consent <capability>,...|none] | plugin <key> settings [--set <key>=<value>]... | plugin <key> secret <name> [--remove] | plugin <key> grant <role> --person <login>|--project <key>|--everyone | plugin <key> revoke <grant id>",
382
384
  overview:
383
- "plugin <key> [help [<command>] | <command> [<args>] | records <collection> | skills | skill <path> | enable | disable | settings | secret | grant | revoke]",
385
+ "plugin <key> [help [<command>] | <command> [<args>] | records <collection> | skills | skill <path> | enable | disable | capabilities | settings | secret | grant | revoke]",
384
386
  synopsis:
385
387
  "One plugin: what it is for and its commands; a command run as you; its records, its skills; its settings, for an administrator.",
386
388
  about:
@@ -388,7 +390,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
388
390
  limits: [
389
391
  "a command line: up to 200 words of up to 50,000 characters each; one file, up to the file bound (console help hand-in), with the plugin's files capability",
390
392
  "records: a page of up to 200; where: a field the collection indexes",
391
- "enable: consent names every capability the manifest asks; settings and secrets as the manifest bounds them",
393
+ "enable: consent names every capability the manifest requires, and the optional ones to turn on; settings and secrets as the manifest bounds them",
394
+ "capabilities: --consent names the optional capabilities the plugin holds from now on, none for none of them; those it requires stay",
392
395
  ],
393
396
  refusals: [
394
397
  "plugin.not_found: no plugin has that key, or you may not know of it; or the plugin has no such command or query",
@@ -451,25 +454,26 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
451
454
  },
452
455
  ask: {
453
456
  usage:
454
- 'ask "<question>" --option "<label>" --option "<label>" [...] [--body <markdown>|--file <path>] [--attach <path>]... [--to <login|me>] [--wait <seconds>] [--run <id>]',
457
+ 'ask "<question>" --option "<label>" --option "<label>" [...] [--body <markdown>|--file <path>] [--attach <path>]... [--to <login|me>]... [--wait <seconds>] [--run <id>]',
455
458
  overview:
456
- 'ask "<question>" --option "<label>" --option "<label>" [...] [--body <markdown>|--file <path>] [--attach <path>]... [--to <login|me>] [--wait <seconds>]',
459
+ 'ask "<question>" --option "<label>" --option "<label>" [...] [--body <markdown>|--file <path>] [--attach <path>]... [--to <login|me>]... [--wait <seconds>]',
457
460
  synopsis:
458
461
  "Raises a decision a person must make, with options, and waits a while for the answer.",
459
- about: `Raises a decision a person must make: the question, the options to choose from, and what they need to know. --attach puts a file on the decision, in the one request: a picture of what is to be chosen between, a screenshot of what was made; a picture the body names as ![words](its name) is shown there, the rest after the body, and the person reads each back with \`console file <id>\` (ADR-0055). It is for the person you work for unless --to names another of the project, who then finds it among their decisions (ADR-0042); anyone of the project may still answer. The run waits for the answer. The command waits 60 seconds for it unless --wait says otherwise, and exits with 3 if it is still to come: keep waiting with \`console decision <id> --wait 100\`, within what your shell gives one command. The decision's id is printed as soon as it is raised. The person chooses one of the options, or answers in their own words: the answer then names one more option, their words, marked own; act on it as given. ${ON_THE_RUN}`,
462
+ about: `Raises a decision a person must make: the question, the options to choose from, and what they need to know. --attach puts a file on the decision, in the one request: a picture of what is to be chosen between, a screenshot of what was made; a picture the body names as ![words](its name) is shown there, the rest after the body, and the person reads each back with \`console file <id>\` (ADR-0055). It is for the person you work for unless --to names another of the project, who then finds it among their decisions (ADR-0042); --to given again names several, each of whom finds it among theirs, and the first answer ends it (ADR-0057); anyone of the project may still answer. The run waits for the answer. The command waits 60 seconds for it unless --wait says otherwise, and exits with 3 if it is still to come: keep waiting with \`console decision <id> --wait 100\`, within what your shell gives one command. The decision's id is printed as soon as it is raised. The person chooses one of the options, or answers in their own words: the answer then names one more option, their words, marked own; act on it as given. ${ON_THE_RUN}`,
460
463
  limits: [
461
464
  `question: one line, up to ${MAX_QUESTION_LENGTH} characters`,
462
465
  `options: ${MIN_OPTIONS} to ${MAX_OPTIONS}, each one line up to ${MAX_OPTION_LABEL_LENGTH} characters`,
463
466
  `body: ${MARKDOWN}`,
464
467
  `attach: a file: up to ${MAX_FILE_BYTES} bytes, not empty; called by its name, without the path; a picture is placed in the body as ![words](its name)`,
465
468
  `at most ${MAX_FILES_PER_DECISION} files on one decision, together up to ${MAX_FILE_BYTES} bytes`,
466
- "to: a login of the project, or me",
469
+ `to: a login of the project, or me; up to ${MAX_DECIDERS} of them`,
467
470
  `at most ${MAX_DECISIONS_PER_PROJECT} decisions in one project`,
468
471
  ],
469
472
  refusals: [
470
473
  ...RUN_CHOICE,
471
474
  "decision.invalid_run: the run is not yours, or is over",
472
- "decision.invalid_decider: the person named may not work in the project",
475
+ "decision.invalid_decider: a person named may not work in the project",
476
+ "decision.too_many_deciders: more people named than one decision takes",
473
477
  "decision.too_many: the project holds as many decisions as one may",
474
478
  "decision.too_many_files: more files than one decision carries",
475
479
  "request.too_large: a file, or the files together, over the bound",
@@ -479,7 +483,7 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
479
483
  usage: 'answer <id> --option <n>|--own "<answer>" [--note <markdown>|--file <path>]',
480
484
  synopsis: "Writes a person's answer on a decision: the option they chose, or their own words.",
481
485
  about:
482
- "Writes on the board the answer a person gave: one of the decision's options by its number, or their own words, which become one more option of the decision marked as theirs; and a note about it. The answer is the person's, written in their name through you (ADR-0021): a choice they made in chat, in a meeting or by changing the task is written here, with where and when they made it in the note, so that the record is whole and a run that waited goes on. A decision is answered once; what followed is written with console decision <id> --outcome.",
486
+ "Writes on the board the answer a person gave: one of the decision's options by its number, or their own words, which become one more option of the decision marked as theirs; and a note about it. The answer is the person's, written in their name through you (ADR-0021), and the record says so: the decision names you beside them as the agent the answer came through, where an answer given on the pages names the person alone (ADR-0056). A choice they made in chat, in a meeting or by changing the task is written here, with where and when they made it in the note, so that the record is whole and a run that waited goes on. A decision is answered once; what followed is written with console decision <id> --outcome.",
483
487
  limits: [
484
488
  `own: one line, up to ${MAX_OPTION_LABEL_LENGTH} characters`,
485
489
  `note: Markdown, up to ${MAX_NOTE_LENGTH} characters`,
@@ -495,7 +499,7 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
495
499
  synopsis:
496
500
  "One decision in full; waits for its answer with --wait; writes what followed with --outcome.",
497
501
  about:
498
- "One decision, with its context, its options, its answer (one of the options, or the person's own words as one more, marked own) and what followed. With --wait, holds on for the answer that long, and exits with 3 if it is still to come. With --outcome, writes down what followed the decision, so that it reads as a record.",
502
+ "One decision, with its context, its options, its answer (one of the options, or the person's own words as one more, marked own) and what followed. With --wait, holds on for the answer that long, and exits with 3 if it is still to come. With --outcome, writes down what followed the decision, so that it reads as a record; the record names who wrote it, you beside them, and when (ADR-0056).",
499
503
  limits: [`outcome: ${MARKDOWN}`],
500
504
  refusals: ["decision.not_found: no decision has that id"],
501
505
  },
@@ -8,11 +8,13 @@ import { descriptionProblems, type PluginDescription } from "../plugin/define.js
8
8
  import type { ServerLoad } from "../plugin/load.js";
9
9
  import {
10
10
  CURRENT_PLUGIN_API,
11
+ capabilitiesAsked,
11
12
  PLUGIN_API_MAJORS,
12
13
  type PluginManifest,
13
14
  parsePluginManifest,
14
15
  } from "../plugin/manifest.js";
15
16
  import { forbiddenImportsIn, removedGlobalsIn } from "../plugin/sandbox.js";
17
+ import { formatTriple, satisfiesRange, versionOfDependency } from "../plugin/semver.js";
16
18
  import { isLanguageMap } from "./plugin-init.js";
17
19
 
18
20
  export interface CheckReport {
@@ -330,10 +332,16 @@ export function sameAsEnglish(value: unknown, at = ""): string[] {
330
332
  );
331
333
  }
332
334
 
335
+ export interface CheckOptions {
336
+ /** The version of the console this command belongs to, held against the manifest's `console`; left out, not compared. */
337
+ readonly consoleVersion?: string | undefined;
338
+ }
339
+
333
340
  /** Reads the plugin at `root` as a console would, and says what it would refuse. */
334
341
  export async function checkPlugin(
335
342
  root: string,
336
343
  loadServer: (file: string) => Promise<ServerLoad>,
344
+ options: CheckOptions = {},
337
345
  ): Promise<CheckReport> {
338
346
  const errors: string[] = [];
339
347
  const warnings: string[] = [];
@@ -365,6 +373,15 @@ export async function checkPlugin(
365
373
  `console-plugin.json: ${path} is the same as en: translate it, or leave the language out`,
366
374
  );
367
375
  }
376
+ if (
377
+ manifest.console !== undefined &&
378
+ options.consoleVersion !== undefined &&
379
+ !satisfiesRange(options.consoleVersion, manifest.console)
380
+ ) {
381
+ warnings.push(
382
+ `console-plugin.json: console ${manifest.console} leaves out ${options.consoleVersion}, the console this command belongs to`,
383
+ );
384
+ }
368
385
 
369
386
  const packageRead = await readJson(join(root, "package.json"));
370
387
  let version = "0.0.0";
@@ -393,6 +410,25 @@ export async function checkPlugin(
393
410
  if (pkg.type !== "module") {
394
411
  errors.push('package.json: type is not "module"; the server module is ESM');
395
412
  }
413
+ // The console the plugin is built against is the one its types come from: a devDependency
414
+ // outside the consoles it declares is a plugin built for a console it says it does not run on.
415
+ const devDependencies = (
416
+ typeof pkg.devDependencies === "object" && pkg.devDependencies !== null
417
+ ? pkg.devDependencies
418
+ : {}
419
+ ) as Record<string, unknown>;
420
+ const dependency = devDependencies["@skillcdn/console"];
421
+ const builtAgainst =
422
+ typeof dependency === "string" ? versionOfDependency(dependency) : undefined;
423
+ if (
424
+ manifest.console !== undefined &&
425
+ builtAgainst !== undefined &&
426
+ !satisfiesRange(formatTriple(builtAgainst), manifest.console)
427
+ ) {
428
+ errors.push(
429
+ `package.json: devDependencies names @skillcdn/console ${String(dependency)}, outside the consoles the manifest declares (console ${manifest.console})`,
430
+ );
431
+ }
396
432
  const scripts = (
397
433
  typeof pkg.scripts === "object" && pkg.scripts !== null ? pkg.scripts : {}
398
434
  ) as Record<string, unknown>;
@@ -456,14 +492,17 @@ export async function checkPlugin(
456
492
  errors.push(`${manifest.server}: ${problem}`);
457
493
  }
458
494
  if (
459
- manifest.capabilities.includes("endpoints") &&
495
+ capabilitiesAsked(manifest).includes("endpoints") &&
460
496
  Object.keys(description.endpoints).length === 0
461
497
  ) {
462
498
  warnings.push(
463
499
  "console-plugin.json: asks the capability endpoints and the server module defines no endpoint",
464
500
  );
465
501
  }
466
- if (manifest.capabilities.includes("jobs") && Object.keys(description.jobs).length === 0) {
502
+ if (
503
+ capabilitiesAsked(manifest).includes("jobs") &&
504
+ Object.keys(description.jobs).length === 0
505
+ ) {
467
506
  warnings.push(
468
507
  "console-plugin.json: asks the capability jobs and the server module defines no job",
469
508
  );
@@ -5,6 +5,7 @@ import { resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
6
6
  import type { ServerLoad } from "../plugin/load.js";
7
7
  import { isPluginKey, RESERVED_PLUGIN_KEYS } from "../plugin/manifest.js";
8
+ import { lineRangeFrom, versionOfDependency } from "../plugin/semver.js";
8
9
  import { checkPlugin } from "./plugin-check.js";
9
10
  import { LANGUAGE_TAG, nameFromKey, PACKAGE_NAME, writePluginFromTemplate } from "./plugin-init.js";
10
11
 
@@ -176,12 +177,18 @@ async function init(args: readonly string[], io: PluginCliIo): Promise<number> {
176
177
  return PLUGIN_EXIT.usage;
177
178
  }
178
179
  const consoleDependency = values.console ?? (io.version === undefined ? "*" : `^${io.version}`);
180
+ // The consoles the plugin runs on: the line of the version it is written against, read from
181
+ // the dependency where it names one (`^0.4.3`, `0.4.3`); a dependency that names none
182
+ // (`workspace:*`) leaves the manifest naming no console.
183
+ const writtenAgainst = versionOfDependency(consoleDependency);
184
+ const consoleRange = writtenAgainst === undefined ? undefined : lineRangeFrom(writtenAgainst);
179
185
  const target = resolve(io.cwd, dir);
180
186
  try {
181
187
  const written = await writePluginFromTemplate(io.templateDir, target, {
182
188
  key,
183
189
  name,
184
190
  consoleDependency,
191
+ consoleRange,
185
192
  packageName,
186
193
  repository:
187
194
  values.repository === undefined
@@ -220,7 +227,9 @@ async function check(args: readonly string[], io: PluginCliIo): Promise<number>
220
227
  return PLUGIN_EXIT.usage;
221
228
  }
222
229
  const root = resolve(io.cwd, dir ?? ".");
223
- const report = await checkPlugin(root, (file) => io.loadServer(file));
230
+ const report = await checkPlugin(root, (file) => io.loadServer(file), {
231
+ consoleVersion: io.version,
232
+ });
224
233
  io.stdout(`console-plugin check ${root}\n`);
225
234
  io.stdout(wordsOf(report.errors, "error"));
226
235
  io.stdout(wordsOf(report.warnings, "warning"));
@@ -13,6 +13,8 @@ export interface InitOptions {
13
13
  readonly name: string;
14
14
  /** What the plugin's devDependency on `@skillcdn/console` says: `^<version>` of the command, unless said. */
15
15
  readonly consoleDependency: string;
16
+ /** The consoles the plugin runs on, the manifest's `console`: the line of the version it is written against; left out, the manifest names none. */
17
+ readonly consoleRange?: string | undefined;
16
18
  /** The package's name: `console-plugin-<key>` unless said. A scoped name is published with `publishConfig.access` `public`. */
17
19
  readonly packageName?: string | undefined;
18
20
  /** The repository the plugin lives in and, in a repository of several, its directory: `repository`, `homepage`, `bugs` and the manifest's `store`. */
@@ -40,12 +42,18 @@ export function substitutions(options: InitOptions): readonly [string, string][]
40
42
  ["__package__", packageNameOf(options)],
41
43
  ["__key__", options.key],
42
44
  ["__name__", options.name],
45
+ ["__consoleRange__", options.consoleRange ?? "*"],
43
46
  ["__console__", options.consoleDependency],
44
47
  ];
45
48
  }
46
49
 
47
50
  export function substitute(text: string, options: InitOptions): string {
48
51
  let result = text;
52
+ if (options.consoleRange === undefined) {
53
+ // No console to name: the manifest's line goes, and any console that serves the plugin API
54
+ // runs the plugin, as before the field. Taken out as text, so that the file keeps its shape.
55
+ result = result.replace(/^[ \t]*"console": "__consoleRange__",\r?\n/m, "");
56
+ }
49
57
  for (const [word, value] of substitutions(options)) {
50
58
  result = result.replaceAll(word, value);
51
59
  }