@skillcdn/console 0.1.18 → 0.1.19

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 (121) hide show
  1. package/README.md +1 -1
  2. package/SKILLCDN.md +1 -1
  3. package/dist/api.d.ts +1 -1
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +1 -1
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts +1 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +353 -39
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/credentials.d.ts +102 -3
  12. package/dist/cli/credentials.d.ts.map +1 -1
  13. package/dist/cli/credentials.js +177 -8
  14. package/dist/cli/credentials.js.map +1 -1
  15. package/dist/cli/directory.d.ts +6 -0
  16. package/dist/cli/directory.d.ts.map +1 -1
  17. package/dist/cli/directory.js +37 -6
  18. package/dist/cli/directory.js.map +1 -1
  19. package/dist/cli/format.d.ts +10 -1
  20. package/dist/cli/format.d.ts.map +1 -1
  21. package/dist/cli/format.js +106 -2
  22. package/dist/cli/format.js.map +1 -1
  23. package/dist/cli/help.d.ts.map +1 -1
  24. package/dist/cli/help.js +42 -20
  25. package/dist/cli/help.js.map +1 -1
  26. package/dist/client.d.ts +21 -1
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +14 -1
  29. package/dist/client.js.map +1 -1
  30. package/dist/components/board.d.ts +9 -1
  31. package/dist/components/board.d.ts.map +1 -1
  32. package/dist/components/board.js +82 -5
  33. package/dist/components/board.js.map +1 -1
  34. package/dist/components/comments.d.ts.map +1 -1
  35. package/dist/components/comments.js +12 -4
  36. package/dist/components/comments.js.map +1 -1
  37. package/dist/components/event-feed.d.ts.map +1 -1
  38. package/dist/components/event-feed.js +4 -0
  39. package/dist/components/event-feed.js.map +1 -1
  40. package/dist/components/goals.d.ts +17 -1
  41. package/dist/components/goals.d.ts.map +1 -1
  42. package/dist/components/goals.js +34 -9
  43. package/dist/components/goals.js.map +1 -1
  44. package/dist/components/markdown.d.ts +6 -0
  45. package/dist/components/markdown.d.ts.map +1 -1
  46. package/dist/components/markdown.js +7 -0
  47. package/dist/components/markdown.js.map +1 -1
  48. package/dist/components/pictures.d.ts +68 -0
  49. package/dist/components/pictures.d.ts.map +1 -0
  50. package/dist/components/pictures.js +213 -0
  51. package/dist/components/pictures.js.map +1 -0
  52. package/dist/components/projects.d.ts +12 -0
  53. package/dist/components/projects.d.ts.map +1 -1
  54. package/dist/components/projects.js +15 -0
  55. package/dist/components/projects.js.map +1 -1
  56. package/dist/components/task-view.d.ts +3 -0
  57. package/dist/components/task-view.d.ts.map +1 -1
  58. package/dist/components/task-view.js +6 -4
  59. package/dist/components/task-view.js.map +1 -1
  60. package/dist/console.css +282 -3
  61. package/dist/console.d.ts.map +1 -1
  62. package/dist/console.js +124 -10
  63. package/dist/console.js.map +1 -1
  64. package/dist/data.d.ts +16 -1
  65. package/dist/data.d.ts.map +1 -1
  66. package/dist/data.js +47 -2
  67. package/dist/data.js.map +1 -1
  68. package/dist/i18n/en.d.ts +52 -0
  69. package/dist/i18n/en.d.ts.map +1 -1
  70. package/dist/i18n/en.js +50 -2
  71. package/dist/i18n/en.js.map +1 -1
  72. package/dist/i18n/ko.d.ts.map +1 -1
  73. package/dist/i18n/ko.js +50 -2
  74. package/dist/i18n/ko.js.map +1 -1
  75. package/dist/index.d.ts +5 -4
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +5 -4
  78. package/dist/index.js.map +1 -1
  79. package/dist/router.d.ts +25 -6
  80. package/dist/router.d.ts.map +1 -1
  81. package/dist/router.js +38 -7
  82. package/dist/router.js.map +1 -1
  83. package/dist/rules.js +5 -5
  84. package/dist/rules.js.map +1 -1
  85. package/dist/schemas.d.ts +158 -0
  86. package/dist/schemas.d.ts.map +1 -1
  87. package/dist/schemas.js +78 -0
  88. package/dist/schemas.js.map +1 -1
  89. package/dist/vocabulary.d.ts +2 -2
  90. package/dist/vocabulary.d.ts.map +1 -1
  91. package/dist/vocabulary.js +3 -0
  92. package/dist/vocabulary.js.map +1 -1
  93. package/docs/specs/cli.md +17 -9
  94. package/docs/specs/rest.md +22 -4
  95. package/package.json +1 -1
  96. package/skills/working-the-board/SKILL.md +10 -4
  97. package/src/api.ts +13 -0
  98. package/src/cli/cli.ts +441 -42
  99. package/src/cli/credentials.ts +276 -14
  100. package/src/cli/directory.ts +43 -6
  101. package/src/cli/format.ts +138 -2
  102. package/src/cli/help.ts +44 -20
  103. package/src/client.ts +60 -0
  104. package/src/components/board.tsx +154 -8
  105. package/src/components/comments.tsx +20 -10
  106. package/src/components/event-feed.tsx +4 -0
  107. package/src/components/goals.tsx +173 -106
  108. package/src/components/markdown.tsx +19 -0
  109. package/src/components/pictures.tsx +389 -0
  110. package/src/components/projects.tsx +68 -0
  111. package/src/components/task-view.tsx +43 -2
  112. package/src/console.tsx +259 -4
  113. package/src/data.ts +72 -1
  114. package/src/i18n/en.ts +53 -2
  115. package/src/i18n/ko.ts +53 -2
  116. package/src/index.ts +27 -0
  117. package/src/router.ts +57 -9
  118. package/src/rules.ts +5 -5
  119. package/src/schemas.ts +97 -0
  120. package/src/styles/console.css +282 -3
  121. package/src/vocabulary.ts +3 -0
package/src/cli/cli.ts CHANGED
@@ -15,7 +15,11 @@ import {
15
15
  hostOfLogin,
16
16
  type RestClaim,
17
17
  type RestDecision,
18
+ type RestMember,
18
19
  type RestPerson,
20
+ type RestSkillFile,
21
+ type RestSkillLoad,
22
+ type RestSkillPage,
19
23
  type RestTaskPatch,
20
24
  } from "../schemas.js";
21
25
  import {
@@ -37,8 +41,18 @@ import {
37
41
  type TaskState,
38
42
  } from "../vocabulary.js";
39
43
  import { loadWebRoot, type WebRoot, WebRootError } from "../web.js";
40
- import type { CredentialStore } from "./credentials.js";
41
- import type { DirectoryStore } from "./directory.js";
44
+ import {
45
+ type CredentialStore,
46
+ type Credentials,
47
+ CredentialsError,
48
+ EMPTY_CREDENTIALS,
49
+ loginsAt,
50
+ type Resolution,
51
+ resolveLogin,
52
+ withLogin,
53
+ withoutLogin,
54
+ } from "./credentials.js";
55
+ import type { DirectorySettings, DirectoryStore } from "./directory.js";
42
56
  import {
43
57
  formatAnswer,
44
58
  formatCategoryLine,
@@ -60,6 +74,8 @@ import {
60
74
  formatRules,
61
75
  formatRun,
62
76
  formatRunLine,
77
+ formatSkillFile,
78
+ formatSkillLoad,
63
79
  formatSkills,
64
80
  formatTask,
65
81
  formatTaskLine,
@@ -158,6 +174,7 @@ type OptionSpec = Readonly<
158
174
  const GLOBAL_OPTIONS: OptionSpec = {
159
175
  json: { type: "boolean" },
160
176
  url: { type: "string" },
177
+ account: { type: "string" },
161
178
  project: { type: "string" },
162
179
  };
163
180
 
@@ -194,6 +211,12 @@ interface Session {
194
211
  readonly url: string;
195
212
  /** The token the client holds: what `serve` carries the page's requests with. */
196
213
  readonly token: string;
214
+ /** The person's login at the console, when the login kept knows it. */
215
+ readonly account: string | undefined;
216
+ /** Where the token came from: the environment's pair, or a login kept here. */
217
+ readonly via: "environment" | "login";
218
+ /** What the command keeps, as it was read for this command. */
219
+ readonly credentials: Credentials | undefined;
197
220
  readonly json: boolean;
198
221
  /** The project the command works in: named by `--project`, the environment, or the working directory. */
199
222
  project(): Promise<ProjectClient>;
@@ -204,23 +227,85 @@ interface Session {
204
227
  const NO_PROJECT =
205
228
  "Say which project: --project <key>, CONSOLE_PROJECT in the environment, or console use <key> once in this directory. console projects lists the projects you may work in.";
206
229
 
207
- /** The console and the token to talk to it with: the flags, the environment, then what login kept. */
230
+ /** What the command keeps, or nothing; a file that cannot be read stops the command, and is never written over. */
231
+ async function loadCredentials(io: CliIo): Promise<Credentials | undefined> {
232
+ try {
233
+ return await io.store.load();
234
+ } catch (error) {
235
+ if (error instanceof CredentialsError) {
236
+ throw failed(
237
+ `${error.message} Move it away, then sign in again: console login --url <the console's origin>`,
238
+ );
239
+ }
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ /** The console a directory is bound to, as an origin, when it names one. */
245
+ function directoryOrigin(settings: DirectorySettings | undefined): string | undefined {
246
+ if (settings?.url === undefined) {
247
+ return undefined;
248
+ }
249
+ try {
250
+ return originOf(settings.url);
251
+ } catch {
252
+ throw failed(`.skillcdn-console.json names a console that is not an origin: ${settings.url}`);
253
+ }
254
+ }
255
+
256
+ /** Why no console could be talked to, for the person who reads it. */
257
+ function wordsOf(failure: Exclude<Resolution, { ok: true }>): string {
258
+ switch (failure.reason) {
259
+ case "nothing":
260
+ return "Not signed in. A person signs the command in: console login --url <the console's origin>";
261
+ case "environment_half":
262
+ return `Both CONSOLE_URL and CONSOLE_TOKEN are needed in the environment, and only ${failure.have} is set: set both, or neither.`;
263
+ case "no_login": {
264
+ const whom = failure.account === undefined ? "" : ` as ${failure.account}`;
265
+ const where =
266
+ failure.source === "directory"
267
+ ? `This directory works at ${failure.url}${whom}, where nobody is signed in here`
268
+ : `Nobody is signed in here at ${failure.url}${whom}`;
269
+ return `${where}: console login --url ${failure.url}. console logins lists the consoles signed in.`;
270
+ }
271
+ case "ambiguous":
272
+ return `Several accounts are signed in at ${failure.url} (${failure.accounts.join(", ")}): say which with --account <login>, or bind this directory to one with console use <key> --account <login>.`;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * The console a command talks to, and with what (ADR-0027): the address from the flag, the
278
+ * environment, the directory or the default login, in that order, and then the token kept for
279
+ * that address and no other. A token never travels to a console it was not made at.
280
+ */
208
281
  async function open(io: CliIo, values: Values): Promise<Session> {
209
- const stored = await io.store.load();
210
- const url = text(values, "url") ?? io.env.CONSOLE_URL ?? stored?.url;
211
- const token = io.env.CONSOLE_TOKEN ?? stored?.token;
212
- if (url === undefined || token === undefined) {
213
- throw failed(
214
- "Not signed in. A person signs the command in: console login --url <the console's origin>",
215
- );
282
+ const directory = await io.directory.load();
283
+ const flag = text(values, "url");
284
+ const envUrl = io.env.CONSOLE_URL;
285
+ const credentials = await loadCredentials(io);
286
+ const resolved = resolveLogin({
287
+ flagUrl: flag === undefined ? undefined : originOf(flag),
288
+ flagAccount: text(values, "account"),
289
+ envUrl: envUrl === undefined ? undefined : originOf(envUrl),
290
+ envToken: io.env.CONSOLE_TOKEN,
291
+ directoryUrl: directoryOrigin(directory),
292
+ directoryAccount: directory?.account,
293
+ credentials,
294
+ });
295
+ if (!resolved.ok) {
296
+ throw failed(wordsOf(resolved));
216
297
  }
298
+ const { url, token } = resolved;
217
299
  const client = clientOf(io, url, token);
218
300
  const projectKey = async () =>
219
- text(values, "project") ?? io.env.CONSOLE_PROJECT ?? (await io.directory.load())?.project;
301
+ text(values, "project") ?? io.env.CONSOLE_PROJECT ?? directory?.project;
220
302
  return {
221
303
  client,
222
304
  url,
223
305
  token,
306
+ account: resolved.account,
307
+ via: resolved.via,
308
+ credentials,
224
309
  json: on(values, "json"),
225
310
  projectKey,
226
311
  async project() {
@@ -272,6 +357,9 @@ async function bodyOf(
272
357
  return given === "-" ? io.readStdin() : given;
273
358
  }
274
359
 
360
+ /** The hosts a token may travel to in the clear: this machine, and no other. */
361
+ const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "[::1]"]);
362
+
275
363
  function originOf(value: string): string {
276
364
  const url = URL.canParse(value) ? new URL(value) : undefined;
277
365
  if (
@@ -285,6 +373,12 @@ function originOf(value: string): string {
285
373
  `Not an origin: ${value}. Say the console's address without a path, such as https://console.example.com`,
286
374
  );
287
375
  }
376
+ // A token travels over https; in the clear only to this machine, where a console is run for development.
377
+ if (url.protocol === "http:" && !LOOPBACK_HOSTS.has(url.hostname)) {
378
+ throw misuse(
379
+ `Not a safe origin: ${value}. A token travels over https only; http is for a console on this machine (localhost).`,
380
+ );
381
+ }
288
382
  return url.origin;
289
383
  }
290
384
 
@@ -517,8 +611,17 @@ async function settle(
517
611
 
518
612
  type Command = (args: readonly string[], io: CliIo) => Promise<number>;
519
613
 
520
- /** Checks a token against the console, keeps it with the address, and says who it is. */
521
- async function keep(io: CliIo, url: string, token: string, agent?: string): Promise<number> {
614
+ /**
615
+ * Checks a token against the console, keeps it as the login at that console for that account,
616
+ * and says who it is. A token the login replaces is given up at the console on the way.
617
+ */
618
+ async function keep(
619
+ io: CliIo,
620
+ url: string,
621
+ token: string,
622
+ agent: string | undefined,
623
+ asDefault: boolean,
624
+ ): Promise<number> {
522
625
  const me = await clientOf(io, url, token).me();
523
626
  const person = me.person;
524
627
  if (person === null) {
@@ -526,9 +629,22 @@ async function keep(io: CliIo, url: string, token: string, agent?: string): Prom
526
629
  "The console does not know this token. Make one on your Agents page and try again.",
527
630
  );
528
631
  }
529
- const kept = await io.store.save({ url, token });
632
+ const credentials = (await loadCredentials(io)) ?? EMPTY_CREDENTIALS;
633
+ const replaced = loginsAt(credentials, url).find((login) => login.account === person.login);
634
+ if (replaced !== undefined && replaced.token !== token) {
635
+ await clientOf(io, url, replaced.token)
636
+ .giveUpToken()
637
+ .catch(() => undefined);
638
+ }
639
+ const next = withLogin(
640
+ credentials,
641
+ { url, account: person.login, token, agent, since: new Date(io.now()).toISOString() },
642
+ { default: asDefault },
643
+ );
644
+ const kept = await io.store.save(next);
645
+ const standing = next.default === url ? ", the default console here" : "";
530
646
  io.stdout(
531
- `${agent === undefined ? "Signed in" : `Connected as "${agent}"`} to ${me.workspace.name} at ${url} as ${person.login} (${person.role}). The token is kept in ${kept}.\nNext, say which project this directory works in: console use <key> (console projects lists them).\n`,
647
+ `${agent === undefined ? "Signed in" : `Connected as "${agent}"`} to ${me.workspace.name} at ${url} as ${person.login} (${person.role})${standing}. The token is kept in ${kept}.\nNext, say which project this directory works in: console use <key> (console projects lists them).\n`,
532
648
  );
533
649
  return EXIT.ok;
534
650
  }
@@ -536,21 +652,22 @@ async function keep(io: CliIo, url: string, token: string, agent?: string): Prom
536
652
  const login: Command = async (args, io) => {
537
653
  const { values } = parse(
538
654
  args,
539
- { "token-stdin": { type: "boolean" }, agent: { type: "string" } },
655
+ { "token-stdin": { type: "boolean" }, agent: { type: "string" }, default: { type: "boolean" } },
540
656
  false,
541
657
  );
542
- const stored = await io.store.load();
543
- const wanted = text(values, "url") ?? io.env.CONSOLE_URL ?? stored?.url;
658
+ const credentials = await loadCredentials(io);
659
+ const wanted = text(values, "url") ?? io.env.CONSOLE_URL ?? credentials?.default;
544
660
  if (wanted === undefined) {
545
661
  throw misuse("Say where the console is: console login --url https://console.example.com");
546
662
  }
547
663
  const url = originOf(wanted);
664
+ const asDefault = on(values, "default");
548
665
  if (on(values, "token-stdin")) {
549
666
  const token = (await io.readStdin()).trim();
550
667
  if (token.length === 0) {
551
668
  throw failed("No token was given.");
552
669
  }
553
- return keep(io, url, token);
670
+ return keep(io, url, token, undefined, asDefault);
554
671
  }
555
672
  // A connection (ADR-0011): a code a person approves on the console's own pages, and the token
556
673
  // handed over here once, never shown.
@@ -577,40 +694,110 @@ const login: Command = async (args, io) => {
577
694
  throw error;
578
695
  }
579
696
  if (claim.status === "connected") {
580
- return keep(io, url, claim.secret, claim.token.name);
697
+ return keep(io, url, claim.secret, claim.token.name, asDefault);
581
698
  }
582
699
  }
583
700
  };
584
701
 
585
702
  const logout: Command = async (args, io) => {
586
- parse(args, {}, false);
587
- const stored = await io.store.load();
588
- const url = io.env.CONSOLE_URL ?? stored?.url;
589
- const token = io.env.CONSOLE_TOKEN ?? stored?.token;
590
- if (url === undefined || token === undefined) {
703
+ const { values } = parse(args, {}, false);
704
+ const credentials = await loadCredentials(io);
705
+ const directory = await io.directory.load();
706
+ const envNote = () => {
707
+ if (io.env.CONSOLE_TOKEN !== undefined) {
708
+ io.stdout("CONSOLE_TOKEN in the environment still holds a token: unset it.\n");
709
+ }
710
+ };
711
+ if (credentials === undefined || credentials.logins.length === 0) {
591
712
  io.stdout("Not signed in here: nothing to forget.\n");
713
+ envNote();
592
714
  return EXIT.ok;
593
715
  }
716
+ // Which login: the console the flag names, else the directory's, else the default, else the
717
+ // one console signed in to; and the account the same way, where the console has several.
718
+ const consoles = [...new Set(credentials.logins.map((login) => login.url))];
719
+ const flag = text(values, "url");
720
+ const url =
721
+ flag === undefined
722
+ ? (directoryOrigin(directory) ??
723
+ credentials.default ??
724
+ (consoles.length === 1 ? consoles[0] : undefined))
725
+ : originOf(flag);
726
+ if (url === undefined) {
727
+ throw failed(
728
+ `Several consoles are signed in here; say which: console logout --url <origin>. console logins lists them.`,
729
+ );
730
+ }
731
+ const account = text(values, "account") ?? directory?.account;
732
+ const at = loginsAt(credentials, url);
733
+ const chosen = account === undefined ? at : at.filter((login) => login.account === account);
734
+ const [login] = chosen;
735
+ if (login === undefined) {
736
+ throw failed(
737
+ `Nobody is signed in here at ${url}${account === undefined ? "" : ` as ${account}`}: nothing to forget. console logins lists the consoles signed in.`,
738
+ );
739
+ }
740
+ if (chosen.length > 1) {
741
+ throw failed(
742
+ `Several accounts are signed in at ${url} (${chosen.map((kept) => kept.account ?? "?").join(", ")}); say which: console logout --url ${url} --account <login>`,
743
+ );
744
+ }
594
745
  // The token is given up at the console first, so that it is good for nothing from now on;
595
746
  // one the console no longer knows is gone already. A console out of reach leaves it to the
596
747
  // Agents page, and the person is told so.
597
748
  let gone = true;
598
749
  try {
599
- await clientOf(io, url, token).giveUpToken();
750
+ await clientOf(io, url, login.token).giveUpToken();
600
751
  } catch (error) {
601
752
  if (!(error instanceof ApiError && error.code === "auth.required")) {
602
753
  gone = false;
603
754
  }
604
755
  }
605
- await io.store.clear();
756
+ const next = withoutLogin(credentials, url, login.account);
757
+ if (next.logins.length === 0) {
758
+ await io.store.clear();
759
+ } else {
760
+ await io.store.save(next);
761
+ }
762
+ const whom = login.account === undefined ? "" : ` as ${login.account}`;
606
763
  io.stdout(
607
764
  gone
608
- ? "Signed out: the token is given up at the console and forgotten here.\n"
609
- : `Signed out here, but the console at ${url} could not be reached to give the token up: a person removes it on the Agents page.\n`,
765
+ ? `Signed out of ${url}${whom}: the token is given up at the console and forgotten here.\n`
766
+ : `Signed out of ${url}${whom} here, but the console could not be reached to give the token up: a person removes it on the Agents page.\n`,
610
767
  );
611
- if (io.env.CONSOLE_TOKEN !== undefined) {
612
- io.stdout("CONSOLE_TOKEN in the environment still holds it: unset it.\n");
768
+ envNote();
769
+ return EXIT.ok;
770
+ };
771
+
772
+ /** The consoles this machine is signed in to, with the account at each; never a token. */
773
+ const logins: Command = async (args, io) => {
774
+ const { values } = parse(args, {}, false);
775
+ const credentials = await loadCredentials(io);
776
+ const kept = (credentials?.logins ?? []).map((login) => ({
777
+ url: login.url,
778
+ account: login.account ?? null,
779
+ agent: login.agent ?? null,
780
+ since: login.since ?? null,
781
+ default: credentials?.default === login.url,
782
+ }));
783
+ if (on(values, "json")) {
784
+ io.stdout(
785
+ `${JSON.stringify({ default: credentials?.default ?? null, logins: kept }, null, 2)}\n`,
786
+ );
787
+ return EXIT.ok;
613
788
  }
789
+ if (kept.length === 0) {
790
+ io.stdout("Not signed in anywhere here: console login --url <the console's origin>\n");
791
+ return EXIT.ok;
792
+ }
793
+ io.stdout(
794
+ `${kept
795
+ .map(
796
+ (login) =>
797
+ `${login.url} ${login.account ?? "(account unknown)"}${login.agent === null ? "" : ` ${login.agent}`}${login.since === null ? "" : ` since ${login.since.slice(0, 10)}`}${login.default ? " (default)" : ""}`,
798
+ )
799
+ .join("\n")}\n`,
800
+ );
614
801
  return EXIT.ok;
615
802
  };
616
803
 
@@ -684,16 +871,35 @@ const projects: Command = async (args, io) => {
684
871
 
685
872
  const use: Command = async (args, io) => {
686
873
  const { values, positionals } = parse(args, {});
687
- const key = theOne(positionals, "Say which project: console use <key>");
874
+ const key = theOne(
875
+ positionals,
876
+ "Say which project: console use <key> [--url <origin>] [--account <login>]",
877
+ );
688
878
  const session = await open(io, values);
689
879
  const project = await session.client.project(key).get();
690
- const kept = await io.directory.save({ project: project.key });
880
+ // The directory names the console where that is not the default login here, or when asked,
881
+ // and the account where the console has several, or when asked; never a token.
882
+ const credentials = session.credentials ?? EMPTY_CREDENTIALS;
883
+ const pinUrl =
884
+ text(values, "url") !== undefined ||
885
+ (session.via === "login" && credentials.default !== session.url);
886
+ const pinAccount =
887
+ text(values, "account") !== undefined ||
888
+ (session.via === "login" && loginsAt(credentials, session.url).length > 1);
889
+ const kept = await io.directory.save({
890
+ project: project.key,
891
+ ...(pinUrl ? { url: session.url } : {}),
892
+ ...(pinAccount && session.account !== undefined ? { account: session.account } : {}),
893
+ });
894
+ const bound = pinUrl
895
+ ? ` at ${session.url}${pinAccount && session.account !== undefined ? ` as ${session.account}` : ""}`
896
+ : "";
691
897
  answer(
692
898
  io,
693
899
  session,
694
900
  project,
695
901
  () =>
696
- `Working in ${project.name} (${project.key}) from this directory on: kept in ${kept}, which is meant to be committed. What its people ask of agents: console rules.`,
902
+ `Working in ${project.name} (${project.key})${bound} from this directory on: kept in ${kept}, which is meant to be committed in a private repository and left out of a public one. What its people ask of agents: console rules.`,
697
903
  );
698
904
  return EXIT.ok;
699
905
  };
@@ -705,14 +911,21 @@ const tasks: Command = async (args, io) => {
705
911
  state: { type: "string" },
706
912
  open: { type: "boolean" },
707
913
  mine: { type: "boolean" },
914
+ unassigned: { type: "boolean" },
708
915
  category: { type: "string" },
709
916
  goal: { type: "string" },
710
917
  due: { type: "string" },
918
+ archived: { type: "boolean" },
711
919
  ...PAGE_OPTIONS,
712
920
  },
713
921
  false,
714
922
  );
715
923
  const state = stateOf(text(values, "state"));
924
+ if (on(values, "mine") && on(values, "unassigned")) {
925
+ throw misuse(
926
+ "Say one: --mine for the tasks assigned to you, or --unassigned for those assigned to nobody",
927
+ );
928
+ }
716
929
  const page = pageOf(values);
717
930
  const session = await open(io, values);
718
931
  const project = await session.project();
@@ -734,9 +947,11 @@ const tasks: Command = async (args, io) => {
734
947
  ...(state === undefined ? {} : { state }),
735
948
  ...(on(values, "open") || due === "overdue" ? { open: true } : {}),
736
949
  ...(assignee === undefined ? {} : { assignee }),
950
+ ...(on(values, "unassigned") ? { unassigned: true } : {}),
737
951
  ...(text(values, "category") === undefined ? {} : { category: text(values, "category") }),
738
952
  ...(text(values, "goal") === undefined ? {} : { goal: text(values, "goal") }),
739
953
  ...(dueBy === undefined ? {} : { dueBy }),
954
+ ...(on(values, "archived") ? { archived: true } : {}),
740
955
  ...page,
741
956
  });
742
957
  answer(io, session, found, () =>
@@ -783,6 +998,65 @@ const skills: Command = async (args, io) => {
783
998
  return EXIT.ok;
784
999
  };
785
1000
 
1001
+ /** How many pages of a skill, or of a file, the command follows before it stops: far above what one holds. */
1002
+ const MAX_SKILL_PAGES = 64;
1003
+
1004
+ const skill: Command = async (args, io) => {
1005
+ const { values, positionals } = parse(args, { file: { type: "string" } });
1006
+ const path = theOne(
1007
+ positionals,
1008
+ "Say which skill: console skill <path of its SKILL.md> [--file <path>]",
1009
+ );
1010
+ const session = await open(io, values);
1011
+ const project = await session.project();
1012
+ const file = text(values, "file");
1013
+ if (file !== undefined) {
1014
+ // The file whole, page after page, as the console reads it from the deployment.
1015
+ const parts: string[] = [];
1016
+ let offset: number | undefined;
1017
+ let last: RestSkillFile | undefined;
1018
+ for (let pages = 0; pages < MAX_SKILL_PAGES; pages += 1) {
1019
+ last = await project.skillFile(file, offset === undefined ? {} : { offset });
1020
+ if (last.status !== "ready" || last.file === null) {
1021
+ break;
1022
+ }
1023
+ parts.push(last.file.content);
1024
+ if (last.file.nextOffset === null) {
1025
+ break;
1026
+ }
1027
+ offset = last.file.nextOffset;
1028
+ }
1029
+ if (last === undefined) {
1030
+ throw failed("The file could not be read.");
1031
+ }
1032
+ const read = last;
1033
+ const content = parts.join("");
1034
+ answer(io, session, { ...read, content }, () => formatSkillFile(read, content));
1035
+ return EXIT.ok;
1036
+ }
1037
+ // The skill whole: its pages until complete, the rules first, as load_skill lays them out.
1038
+ const pages: RestSkillPage[] = [];
1039
+ let cursor: string | undefined;
1040
+ let last: RestSkillLoad | undefined;
1041
+ for (let n = 0; n < MAX_SKILL_PAGES; n += 1) {
1042
+ last = await project.skill(path, cursor);
1043
+ if (last.status !== "ready" || last.skill === null) {
1044
+ break;
1045
+ }
1046
+ pages.push(last.skill);
1047
+ if (last.skill.complete || last.skill.nextCursor === null) {
1048
+ break;
1049
+ }
1050
+ cursor = last.skill.nextCursor;
1051
+ }
1052
+ if (last === undefined) {
1053
+ throw failed("The skill could not be read.");
1054
+ }
1055
+ const loaded = last;
1056
+ answer(io, session, { ...loaded, pages }, () => formatSkillLoad(loaded, pages));
1057
+ return EXIT.ok;
1058
+ };
1059
+
786
1060
  /** A version number given as a flag, or nothing; anything else is not understood. */
787
1061
  function versionOf(given: string | undefined): number | undefined {
788
1062
  if (given === undefined) {
@@ -983,6 +1257,45 @@ const editTask: Command = async (args, io) => {
983
1257
  return EXIT.ok;
984
1258
  };
985
1259
 
1260
+ /** Puts a task away, or every done or dropped task of a goal (ADR-0029); the task by its number or its id. */
1261
+ const taskArchive: Command = async (args, io) => {
1262
+ const { values, positionals } = parse(args, { goal: { type: "string" } });
1263
+ const goal = text(values, "goal");
1264
+ if (positionals.length > 1 || (positionals.length === 0) === (goal === undefined)) {
1265
+ throw misuse(
1266
+ "Say which: console task archive <number|id>, or console task archive --goal <id> for every done or dropped task of a goal",
1267
+ );
1268
+ }
1269
+ const session = await open(io, values);
1270
+ const project = await session.project();
1271
+ if (goal !== undefined) {
1272
+ const { archived } = await project.archiveTasksOfGoal(goal);
1273
+ answer(
1274
+ io,
1275
+ session,
1276
+ { archived },
1277
+ () => `${archived} ${archived === 1 ? "task" : "tasks"} of the goal put away.`,
1278
+ );
1279
+ return EXIT.ok;
1280
+ }
1281
+ const found = await project.task(positionals[0] ?? "");
1282
+ const archived = await project.archiveTask(found.id);
1283
+ answer(io, session, archived, () => formatTaskLine(archived));
1284
+ return EXIT.ok;
1285
+ };
1286
+
1287
+ /** Brings a task back from the archive. */
1288
+ const taskRestore: Command = async (args, io) => {
1289
+ const { values, positionals } = parse(args, {});
1290
+ const ref = theOne(positionals, "Say which task: console task restore <number|id>");
1291
+ const session = await open(io, values);
1292
+ const project = await session.project();
1293
+ const found = await project.task(ref);
1294
+ const restored = await project.restoreTask(found.id);
1295
+ answer(io, session, restored, () => formatTaskLine(restored));
1296
+ return EXIT.ok;
1297
+ };
1298
+
986
1299
  const task: Command = async (args, io) => {
987
1300
  if (args[0] === "new") {
988
1301
  return newTask(args.slice(1), io);
@@ -990,6 +1303,12 @@ const task: Command = async (args, io) => {
990
1303
  if (args[0] === "edit") {
991
1304
  return editTask(args.slice(1), io);
992
1305
  }
1306
+ if (args[0] === "archive") {
1307
+ return taskArchive(args.slice(1), io);
1308
+ }
1309
+ if (args[0] === "restore") {
1310
+ return taskRestore(args.slice(1), io);
1311
+ }
993
1312
  const { values, positionals } = parse(args, {});
994
1313
  const ref = theOne(positionals, "Say which task: console task <number|id>");
995
1314
  const session = await open(io, values);
@@ -1871,6 +2190,37 @@ const editProject: Command = async (args, io) => {
1871
2190
  return EXIT.ok;
1872
2191
  };
1873
2192
 
2193
+ /** The pack token (ADR-0028): read from standard input and never from an argument, so that no shell keeps it. */
2194
+ const projectPackToken: Command = async (args, io) => {
2195
+ const { values, positionals } = parse(args, {
2196
+ stdin: { type: "boolean" },
2197
+ remove: { type: "boolean" },
2198
+ });
2199
+ const key = theOne(
2200
+ positionals,
2201
+ "Say which project: console project pack-token <key> --stdin, or console project pack-token <key> --remove",
2202
+ );
2203
+ if (on(values, "stdin") === on(values, "remove")) {
2204
+ throw misuse(
2205
+ "Say one of --stdin, which reads the token from standard input, and --remove, which takes it away.",
2206
+ );
2207
+ }
2208
+ const session = await open(io, values);
2209
+ const project = session.client.project(key);
2210
+ if (on(values, "remove")) {
2211
+ const changed = await project.removePackToken();
2212
+ answer(io, session, changed, () => formatProject(changed));
2213
+ return EXIT.ok;
2214
+ }
2215
+ const token = (await io.readStdin()).trim();
2216
+ if (token.length === 0) {
2217
+ throw failed("No token was given on standard input.");
2218
+ }
2219
+ const changed = await project.setPackToken({ token });
2220
+ answer(io, session, changed, () => formatProject(changed));
2221
+ return EXIT.ok;
2222
+ };
2223
+
1874
2224
  const projectIcon: Command = async (args, io) => {
1875
2225
  const { values, positionals } = parse(args, { remove: { type: "boolean" } });
1876
2226
  const [key, file] = positionals;
@@ -1904,10 +2254,13 @@ const project: Command = async (args, io) => {
1904
2254
  if (args[0] === "icon") {
1905
2255
  return projectIcon(args.slice(1), io);
1906
2256
  }
2257
+ if (args[0] === "pack-token") {
2258
+ return projectPackToken(args.slice(1), io);
2259
+ }
1907
2260
  const { values, positionals } = parse(args, {});
1908
2261
  const key = theOne(
1909
2262
  positionals,
1910
- "Say which project: console project <key>; or console project new, edit or icon",
2263
+ "Say which project: console project <key>; or console project new, edit, icon or pack-token",
1911
2264
  );
1912
2265
  const session = await open(io, values);
1913
2266
  const found = await session.client.project(key).get();
@@ -1928,18 +2281,59 @@ const members: Command = async (args, io) => {
1928
2281
  return EXIT.ok;
1929
2282
  };
1930
2283
 
2284
+ /** The keys --project names with commas, each once, or nothing when it names one project or none. */
2285
+ function projectKeysOf(value: string | undefined): readonly string[] | undefined {
2286
+ if (value === undefined || !value.includes(",")) {
2287
+ return undefined;
2288
+ }
2289
+ const keys = [...new Set(value.split(",").map((key) => key.trim()))].filter((key) => key !== "");
2290
+ if (keys.length === 0) {
2291
+ throw misuse("Say which projects: --project <key>[,<key>...]");
2292
+ }
2293
+ return keys;
2294
+ }
2295
+
1931
2296
  const member: Command = async (args, io) => {
1932
2297
  const [verb, ...rest] = args;
1933
2298
  if (verb === "add") {
1934
2299
  const { values, positionals } = parse(rest, { role: { type: "string" } });
1935
- const login = theOne(positionals, "Say whom: console member add <login> [--role owner|member]");
2300
+ const login = theOne(
2301
+ positionals,
2302
+ "Say whom: console member add <login> [--role owner|member] [--project <key>[,<key>...]]",
2303
+ );
1936
2304
  const role = text(values, "role");
1937
2305
  const session = await open(io, values);
1938
- const project = await session.project();
1939
- const added = await project.addMember({
2306
+ const input = {
1940
2307
  personId: (await personOf(session, login)).id,
1941
2308
  ...(role === undefined ? {} : { role: projectRoleOf(role) }),
1942
- });
2309
+ };
2310
+ // Several projects at once, where --project names them with commas: the person is listed
2311
+ // in each in turn, one listed already is said so, and the rest go on.
2312
+ const keys = projectKeysOf(text(values, "project"));
2313
+ if (keys !== undefined) {
2314
+ const added: { readonly project: string; readonly member: RestMember | null }[] = [];
2315
+ for (const key of keys) {
2316
+ try {
2317
+ added.push({ project: key, member: await session.client.project(key).addMember(input) });
2318
+ } catch (error) {
2319
+ if (!(error instanceof ApiError && error.code === "member.exists")) {
2320
+ throw error;
2321
+ }
2322
+ added.push({ project: key, member: null });
2323
+ }
2324
+ }
2325
+ answer(io, session, added, () =>
2326
+ added
2327
+ .map(
2328
+ ({ project, member }) =>
2329
+ `${project} ${member === null ? `${login} is listed already` : formatMemberLine(member)}`,
2330
+ )
2331
+ .join("\n"),
2332
+ );
2333
+ return EXIT.ok;
2334
+ }
2335
+ const project = await session.project();
2336
+ const added = await project.addMember(input);
1943
2337
  answer(io, session, added, () => formatMemberLine(added));
1944
2338
  return EXIT.ok;
1945
2339
  }
@@ -1974,7 +2368,7 @@ const member: Command = async (args, io) => {
1974
2368
  return EXIT.ok;
1975
2369
  }
1976
2370
  throw misuse(
1977
- "Say what to do: console member add <login> [--role owner|member], console member role <login> <owner|member>, or console member remove <login>",
2371
+ "Say what to do: console member add <login> [--role owner|member] [--project <key>[,<key>...]], console member role <login> <owner|member>, or console member remove <login>",
1978
2372
  );
1979
2373
  };
1980
2374
 
@@ -2034,6 +2428,7 @@ const category: Command = async (args, io) => {
2034
2428
 
2035
2429
  const COMMANDS: Readonly<Record<string, Command>> = {
2036
2430
  login,
2431
+ logins,
2037
2432
  logout,
2038
2433
  whoami,
2039
2434
  serve,
@@ -2046,6 +2441,7 @@ const COMMANDS: Readonly<Record<string, Command>> = {
2046
2441
  goal,
2047
2442
  categories,
2048
2443
  skills,
2444
+ skill,
2049
2445
  rules,
2050
2446
  people,
2051
2447
  person,
@@ -2087,11 +2483,14 @@ function help(io: CliIo, name: string | undefined): number {
2087
2483
  return EXIT.ok;
2088
2484
  }
2089
2485
 
2090
- /** A client of the console at `url`, naming the command on every request (ADR-0025). */
2486
+ /**
2487
+ * A client of the console at `url`, naming the command on every request (ADR-0025). A redirect
2488
+ * is not followed: the token goes to the address the command resolved, and nowhere it is sent on to.
2489
+ */
2091
2490
  function clientOf(io: CliIo, url: string, token?: string): ConsoleClient {
2092
2491
  return createClient({
2093
2492
  baseUrl: url,
2094
- fetch: io.fetch,
2493
+ fetch: (input, init) => io.fetch(input, { ...init, redirect: "manual" }),
2095
2494
  ...(token === undefined ? {} : { token }),
2096
2495
  ...(io.userAgent === undefined ? {} : { userAgent: io.userAgent }),
2097
2496
  });