@skillcdn/console 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,13 +33,14 @@ const { items } = await client.tasks({ state: "ready" });
33
33
  - **The bounds** every input is held to (`MAX_TITLE_LENGTH`, `MAX_BODY_LENGTH`, `MAX_OPTIONS`, ...), so that a page can say so before a request is made.
34
34
  - **The routes:** `REST_ROUTES` (the workspace's resources), `AUTH_ROUTES`, and the helpers that build a path (`projectPath(key, collection, id)` for everything under a project, `projectIconPath(key, icon)` for a project's image, `restPath`, `loginPath`, `signInPath`). The API itself is described in [docs/specs/rest.md](https://github.com/skillcdn/console/blob/main/docs/specs/rest.md).
35
35
  - **The schemas,** in the mini build of zod, which runs in a browser: what the server answers (`restProjectSchema`, `restMemberSchema`, `restTaskSchema`, `restDecisionSchema`, `restEventSchema`, `restMeSchema`, ...) and what it is sent (`restProjectInputSchema`, `restTaskInputSchema`, `restTaskPatchSchema`, `restDecisionInputSchema`, `restAnswerInputSchema`, ...). Absent values are `null` on the wire, never missing keys. The server validates with the input schemas and is tested against the others.
36
- - **The client:** `createClient({ baseUrl, fetch })` answers typed values parsed with those schemas, and throws an `ApiError` with the server's stable `code` (or `network`, `invalid_response`) for anything else. It takes a `fetch` and reads nothing else. With `token`, a token a person made on their Agents page, every request carries `Authorization: Bearer` and no cookie: how a script or a console of a person's own acts as that person. The board is a project's: `client.projects()` lists those the person may see, and `client.project(key)` is the client of one, with its tasks, decisions, goals, runs, files, events, skills, documents, settings, members and image (`setIcon`, `removeIcon`). `tasks(filter)` narrows the board (`TaskFilter`: a state, the open ones, a category's key, a goal, an assignee, a day tasks are due by) and pages it (`Page`: `limit` and `offset`; the answer carries `more`), as `decisions(filter)` and `runs(filter)` are paged; a task carries its `category`, `goalId`, its plan (`startsOn`, `dueOn`) and its moments (`startedAt`, `doneAt`). The roadmap is `goals()`, `goal(id)`, `createGoal` and `updateGoal`; the workspace's categories are `client.categories()`, `createCategory`, `updateCategory` and `removeCategory`; `client.work()` is what is on the person's plate across their projects ([ADR-0019](https://github.com/skillcdn/console/blob/main/docs/adr/0019-a-task-is-planned-in-days-the-board-keeps-its-moments-and-goals-and-categories-give-the-work-its-shape.md)). The documents go by path: `documents({ folder })` lists a folder, `documents({ q })` finds pages by their words, `document(path)` reads one with its links both ways and its files, `writeDocument(path, { title, body, baseVersion })` writes a version, `archiveDocument`, `restoreDocument`, `documentVersions`, `documentVersion(path, n)`, `attachFile(path, upload)` and `documentFileUrl(path, id)` do the rest; `updateDecision(id, { outcome })` grows a decision's record. `connect`, `connectRequest`, `approveConnection`, `denyConnection` and `claimConnection` are the two sides of an agent connecting with a code a person approves (ADR-0011), and `personTokens` and `revokePersonToken` an administrator's sight of everyone's agents. An `EventSource` cannot carry a header, so a stream read with a token is a `fetch` of `eventStreamUrl(after)`. `handInFile` sends a file as a form, and `fileUrl` says where its bytes are read back. `events(after, { task })` is everything that happened to one task.
36
+ - **The client:** `createClient({ baseUrl, fetch })` answers typed values parsed with those schemas, and throws an `ApiError` with the server's stable `code` (or `network`, `invalid_response`) for anything else. It takes a `fetch` and reads nothing else. With `token`, a token a person made on their Agents page, every request carries `Authorization: Bearer` and no cookie: how a script or a console of a person's own acts as that person, configuring included (`createProject`, `updatePerson`, a project's `update`, `setIcon`, `addMember`, `updateMember` and `removeMember`, `createCategory`, `updateCategory` and `removeCategory`), within the person's own roles; only the tokens themselves and the approval of a connection are a session's ([ADR-0021](https://github.com/skillcdn/console/blob/main/docs/adr/0021-a-token-configures-as-its-person-does.md)). The board is a project's: `client.projects()` lists those the person may see, and `client.project(key)` is the client of one, with its tasks, decisions, goals, runs, files, events, skills, documents, settings, members and image (`setIcon`, `removeIcon`). `tasks(filter)` narrows the board (`TaskFilter`: a state, the open ones, a category's key, a goal, an assignee, a day tasks are due by) and pages it (`Page`: `limit` and `offset`; the answer carries `more`), as `decisions(filter)` and `runs(filter)` are paged; a task carries its `category`, `goalId`, its plan (`startsOn`, `dueOn`) and its moments (`startedAt`, `doneAt`). The roadmap is `goals()`, `goal(id)`, `createGoal` and `updateGoal`; the workspace's categories are `client.categories()`, `createCategory`, `updateCategory` and `removeCategory`; `client.work()` is what is on the person's plate across their projects ([ADR-0019](https://github.com/skillcdn/console/blob/main/docs/adr/0019-a-task-is-planned-in-days-the-board-keeps-its-moments-and-goals-and-categories-give-the-work-its-shape.md)). The documents go by path: `documents({ folder })` lists a folder, `documents({ q })` finds pages by their words, `document(path)` reads one with its links both ways and its files, `writeDocument(path, { title, body, baseVersion })` writes a version, `archiveDocument`, `restoreDocument`, `documentVersions`, `documentVersion(path, n)`, `attachFile(path, upload)` and `documentFileUrl(path, id)` do the rest; `updateDecision(id, { outcome })` grows a decision's record. `connect`, `connectRequest`, `approveConnection`, `denyConnection` and `claimConnection` are the two sides of an agent connecting with a code a person approves (ADR-0011), and `personTokens` and `revokePersonToken` an administrator's sight of everyone's agents. An `EventSource` cannot carry a header, so a stream read with a token is a `fetch` of `eventStreamUrl(after)`. `handInFile` sends a file as a form, and `fileUrl` says where its bytes are read back. `events(after, { task })` is everything that happened to one task.
37
37
 
38
38
  ### The command line
39
39
 
40
40
  ```sh
41
41
  npm install -g @skillcdn/console # where the agent runs
42
42
  console login --url https://console.example # shows an address and a code; a person approves, and the token arrives here
43
+ console project new web "The web app" # a project of your own, as on the pages; member, category and person role do the rest
43
44
  console use web # the project this directory works in; .skillcdn-console.json, committed
44
45
  console take 7 # a run begins on task #7 of the project, for the person the token is
45
46
  console report "Found the cause: the parser trusts its input."
@@ -52,8 +53,9 @@ console finish --summary "Done: the parser refuses empty input."
52
53
  console serve dist # a console of your own, built from this package, served for you
53
54
  ```
54
55
 
55
- - **`console`** is the agent's side of the console ([docs/specs/cli.md](https://github.com/skillcdn/console/blob/main/docs/specs/cli.md)): a thin client of the REST API with no logic of its own, no dependencies beyond the package's, and no cost to an agent until it is used. `console help` says everything an agent needs and `console --version` which version this is; `--json` answers with the console's own JSON; the exit code says whether the console did it (`0`), refused or could not be reached (`1`), did not understand (`2`), or a decision still waits (`3`).
56
+ - **`console`** is the agent's side of the console ([docs/specs/cli.md](https://github.com/skillcdn/console/blob/main/docs/specs/cli.md)): a thin client of the REST API with no logic of its own, no dependencies beyond the package's, and no cost to an agent until it is used. The package states Node.js 24 as its floor and is tested on it; 22 runs the command today, checked by hand on each release. `console help` says everything an agent needs and `console --version` which version this is; `--json` answers with the console's own JSON; the exit code says whether the console did it (`0`), refused or could not be reached (`1`), did not understand (`2`), or a decision still waits (`3`).
56
57
  - **A person signs it in once:** `login` checks the token against the console and keeps it, with the console's address, in `$XDG_CONFIG_HOME/skillcdn-console/credentials.json` (`~/.config` when unset). `CONSOLE_URL` and `CONSOLE_TOKEN` in the environment win over the file, for a hook or a job. A token is never taken on the command line.
58
+ - **Configuring, as the person** ([ADR-0021](https://github.com/skillcdn/console/blob/main/docs/adr/0021-a-token-configures-as-its-person-does.md)): `people`, `person role`, `project new`, `project edit`, `project icon`, `members`, `member add`, `member role`, `member remove`, `category new`, `category edit` and `category remove` do what an administrator or an owner does on the pages, with the same refusals (`auth.forbidden` where the person may not), and the event names the agent beside the person. The tokens themselves have no command: they are made and removed on the Agents page, signed in.
57
59
  - **The project's documents** are the agent's to read and write: `docs`, `doc`, `write`, `attach`, `archive` and `restore`, by path; a link in a body to a page's path refers to the page.
58
60
  - **Every command on the board works in one project:** `--project <key>`, else `CONSOLE_PROJECT`, else what `console use <key>` wrote to `.skillcdn-console.json` in the working directory or one above it, a file meant to be committed. `console projects` lists the projects the person may work in.
59
61
  - **`console serve [<dir>] [--port <n>]`** is for a person: it serves [a console of your own](#a-console-of-your-own) from your machine, the build in `<dir>` at `http://127.0.0.1:11197/`, and carries everything under `/api/` to the console the command is signed in to, with your token, which the pages never see ([ADR-0014](https://github.com/skillcdn/console/blob/main/docs/adr/0014-a-persons-own-console-is-served-from-their-machine-by-the-command.md)). The loopback only; a request from another site's page is refused. Without a directory only the API is served, for a development server.
@@ -119,7 +121,7 @@ npx vite # your pages, reloading as you
119
121
  npx vite build && console serve dist # the build served whole, at http://127.0.0.1:11197/
120
122
  ```
121
123
 
122
- The pages run on your token: what you may do on the board, they may do, and no more. The token never reaches the browser; the command adds it on the way, on the loopback only, and refuses a request from another site's page. The tokens themselves and configuring are done on the organization's console, signed in, and the pages say so where it matters. Replace a component, pass your own language pack or your own marks, or compose pages of your own from the components and `useConsoleData`: the sections above say what each piece takes.
124
+ The pages run on your token: what you may do on the board, they may do, and no more. The token never reaches the browser; the command adds it on the way, on the loopback only, and refuses a request from another site's page. The tokens themselves are made and removed on the organization's console, signed in, and the pages say so there; everything else, configuring included, the pages do on your token as the organization's console does on your session. Replace a component, pass your own language pack or your own marks, or compose pages of your own from the components and `useConsoleData`: the sections above say what each piece takes.
123
125
 
124
126
  ### The skills, for an agent
125
127
 
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,SAAS,EAEf,MAAM,cAAc,CAAC;AAatB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAuBrD,OAAO,EAA0C,KAAK,MAAM,EAAE,MAAM,YAAY,CAAC;AAQjF,qFAAqF;AACrF,eAAO,MAAM,IAAI;aACf,EAAE,EAAE,CAAC;IACL,6EAA6E;aAC7E,MAAM,EAAE,CAAC;IACT,sCAAsC;aACtC,KAAK,EAAE,CAAC;IACR,6CAA6C;aAC7C,OAAO,EAAE,CAAC;CACF,CAAC;AAWX,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC3D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAC1C,yFAAyF;IACzF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,2CAA2C;IAC3C,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACrD,kCAAkC;IAClC,QAAQ,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IAC1D,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,+EAA+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,QAAQ,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC;IAC3B,uFAAuF;IACvF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,0FAA0F;IAC1F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACtC;AAozCD,yFAAyF;AACzF,wBAAsB,MAAM,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,EAAE,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CA6BhF"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,SAAS,EAEf,MAAM,cAAc,CAAC;AAqBtB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AA0BrD,OAAO,EAA0C,KAAK,MAAM,EAAE,MAAM,YAAY,CAAC;AAQjF,qFAAqF;AACrF,eAAO,MAAM,IAAI;aACf,EAAE,EAAE,CAAC;IACL,6EAA6E;aAC7E,MAAM,EAAE,CAAC;IACT,sCAAsC;aACtC,KAAK,EAAE,CAAC;IACR,6CAA6C;aAC7C,OAAO,EAAE,CAAC;CACF,CAAC;AAWX,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC3D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAC1C,yFAAyF;IACzF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,2CAA2C;IAC3C,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACrD,kCAAkC;IAClC,QAAQ,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IAC1D,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,+EAA+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,QAAQ,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC;IAC3B,uFAAuF;IACvF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,0FAA0F;IAC1F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACtC;AAinDD,yFAAyF;AACzF,wBAAsB,MAAM,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,EAAE,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CA6BhF"}
package/dist/cli/cli.js CHANGED
@@ -3,9 +3,9 @@ import { parseArgs } from "node:util";
3
3
  import { ApiError, createClient, } from "../client.js";
4
4
  import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
5
5
  import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
6
- import { GOAL_STATES, TASK_PRIORITIES, TASK_STATES, } from "../vocabulary.js";
6
+ import { COLOR_NAMES, GOAL_STATES, PERSON_ROLES, PROJECT_ROLES, PROJECT_VISIBILITIES, TASK_PRIORITIES, TASK_STATES, } from "../vocabulary.js";
7
7
  import { loadWebRoot, WebRootError } from "../web.js";
8
- import { formatAnswer, formatCategoryLine, formatDecision, formatDecisionLine, formatDocument, formatDocuments, formatGoal, formatGoalLine, formatMore, formatOptions, formatProjectLine, formatRun, formatRunLine, formatSkills, formatTask, formatTaskLine, formatVersion, formatVersionLine, formatWork, } from "./format.js";
8
+ import { formatAnswer, formatCategoryLine, formatDecision, formatDecisionLine, formatDocument, formatDocuments, formatGoal, formatGoalLine, formatMemberLine, formatMore, formatOptions, formatPersonLine, formatProject, formatProjectLine, formatRun, formatRunLine, formatSkills, formatTask, formatTaskLine, formatVersion, formatVersionLine, formatWork, } from "./format.js";
9
9
  import { COMMAND_HELP, helpFor, usage } from "./help.js";
10
10
  import { createServeHandler, DEFAULT_SERVE_PORT } from "./serve.js";
11
11
  // The `console` command (docs/specs/cli.md): the agent's side of the console. A thin client of
@@ -212,13 +212,31 @@ async function personIdOf(session, value) {
212
212
  }
213
213
  return me.person.id;
214
214
  }
215
+ return (await personOf(session, value)).id;
216
+ }
217
+ /** The person a login names, among the workspace's; the logins are as people sign in. */
218
+ async function personOf(session, login) {
215
219
  const { items } = await session.client.people();
216
- const found = items.find((person) => person.login.toLowerCase() === value.toLowerCase());
220
+ const found = items.find((person) => person.login.toLowerCase() === login.toLowerCase());
217
221
  if (found === undefined) {
218
- throw misuse(`Nobody of the workspace is called ${value}. console people is not a command; the logins are as people sign in.`);
222
+ throw misuse(`Nobody of the workspace is called ${login}. console people lists everyone, by login.`);
219
223
  }
220
- return found.id;
224
+ return found;
221
225
  }
226
+ /** One word of a vocabulary, or the refusal to understand. */
227
+ function wordOf(value, words, what) {
228
+ const found = words.find((word) => word === value);
229
+ if (found === undefined) {
230
+ throw misuse(`Not ${what}: ${value}. One of ${words.join(", ")}`);
231
+ }
232
+ return found;
233
+ }
234
+ const personRoleOf = (value) => wordOf(value, PERSON_ROLES, "a role in the workspace");
235
+ const projectRoleOf = (value) => wordOf(value, PROJECT_ROLES, "a role in a project");
236
+ const visibilityOf = (value) => wordOf(value, PROJECT_VISIBILITIES, "a visibility");
237
+ const colorOf = (value) => wordOf(value, COLOR_NAMES, "a colour");
238
+ /** A colour, or `none` for the one the pages choose, which is `null` on the wire. */
239
+ const colorOrNoneOf = (value) => value === undefined ? undefined : value === "none" ? null : colorOf(value);
222
240
  function secondsOf(value, fallback) {
223
241
  if (value === undefined) {
224
242
  return fallback;
@@ -255,6 +273,14 @@ function theOne(positionals, ask) {
255
273
  }
256
274
  return first;
257
275
  }
276
+ /** Exactly two words, for a command on a thing and a value. */
277
+ function theTwo(positionals, ask) {
278
+ const [first, second] = positionals;
279
+ if (first === undefined || second === undefined || positionals.length > 2) {
280
+ throw misuse(ask);
281
+ }
282
+ return [first, second];
283
+ }
258
284
  /**
259
285
  * The decision once answered, or as it stands when the wait is over. Each request asks the
260
286
  * console to hold it for a while, so the answer arrives as soon as a person gives it.
@@ -366,7 +392,7 @@ const projects = async (args, io) => {
366
392
  const session = await open(io, values);
367
393
  const found = await session.client.projects();
368
394
  answer(io, session, found, () => found.items.length === 0
369
- ? "No projects you may work in. A person makes one on the console's Projects page."
395
+ ? 'No projects you may work in. Make one: console project new <key> "<name>"'
370
396
  : found.items.map(formatProjectLine).join("\n"));
371
397
  return EXIT.ok;
372
398
  };
@@ -1032,6 +1058,208 @@ const serve = async (args, io) => {
1032
1058
  await listening.closed;
1033
1059
  return EXIT.ok;
1034
1060
  };
1061
+ // Configuring, as the person (ADR-0021): what an administrator or an owner does on the pages,
1062
+ // their agent does here, attributed to them as the agent. The console decides on every request
1063
+ // with the same roles; what the person may not, the agent may not (auth.forbidden). The tokens
1064
+ // themselves have no command, by design.
1065
+ const people = async (args, io) => {
1066
+ const { values } = parse(args, {}, false);
1067
+ const session = await open(io, values);
1068
+ const found = await session.client.people();
1069
+ answer(io, session, found, () => found.items.map(formatPersonLine).join("\n"));
1070
+ return EXIT.ok;
1071
+ };
1072
+ const person = async (args, io) => {
1073
+ if (args[0] !== "role") {
1074
+ throw misuse("Say what to do: console person role <login> <admin|member>");
1075
+ }
1076
+ const { values, positionals } = parse(args.slice(1), {});
1077
+ const [login, role] = theTwo(positionals, "Say whom and what: console person role <login> <admin|member>");
1078
+ const wanted = personRoleOf(role);
1079
+ const session = await open(io, values);
1080
+ const found = await personOf(session, login);
1081
+ const changed = await session.client.updatePerson(found.id, { role: wanted });
1082
+ answer(io, session, changed, () => formatPersonLine(changed));
1083
+ return EXIT.ok;
1084
+ };
1085
+ /** The flags that set what a project is, shared by making one and changing one. */
1086
+ const PROJECT_FIELDS = {
1087
+ description: { type: "string" },
1088
+ file: { type: "string" },
1089
+ visibility: { type: "string" },
1090
+ skills: { type: "string" },
1091
+ color: { type: "string" },
1092
+ symbol: { type: "string" },
1093
+ };
1094
+ /** What the flags say of a project; `none` clears what may be cleared. */
1095
+ async function projectFieldsOf(io, values) {
1096
+ const description = await bodyOf(io, values, text(values, "description"));
1097
+ const visibility = text(values, "visibility");
1098
+ const skills = orNone(text(values, "skills"));
1099
+ const color = colorOrNoneOf(text(values, "color"));
1100
+ const symbol = orNone(text(values, "symbol"));
1101
+ return {
1102
+ ...(description === undefined
1103
+ ? {}
1104
+ : { description: description === "none" ? "" : description }),
1105
+ ...(visibility === undefined ? {} : { visibility: visibilityOf(visibility) }),
1106
+ ...(skills === undefined ? {} : { skillsAddress: skills }),
1107
+ ...(color === undefined ? {} : { color }),
1108
+ ...(symbol === undefined ? {} : { symbol }),
1109
+ };
1110
+ }
1111
+ const newProject = async (args, io) => {
1112
+ const { values, positionals } = parse(args, PROJECT_FIELDS);
1113
+ const [key, name] = theTwo(positionals, 'Say the key and the name: console project new <key> "<name>" [--visibility workspace|private]');
1114
+ const fields = await projectFieldsOf(io, values);
1115
+ const session = await open(io, values);
1116
+ const made = await session.client.createProject({ key, name, ...fields });
1117
+ answer(io, session, made, () => `Project made: ${made.name} (${made.key}), with you as its owner.\nTo work in it from this directory: console use ${made.key}`);
1118
+ return EXIT.ok;
1119
+ };
1120
+ const editProject = async (args, io) => {
1121
+ const { values, positionals } = parse(args, { ...PROJECT_FIELDS, name: { type: "string" } });
1122
+ const key = theOne(positionals, "Say which project: console project edit <key> --name <name> ...");
1123
+ const name = text(values, "name");
1124
+ const patch = { ...(name === undefined ? {} : { name }), ...(await projectFieldsOf(io, values)) };
1125
+ if (Object.keys(patch).length === 0) {
1126
+ throw misuse("Say what to change: --name, --description, --visibility, --skills, --color or --symbol; none clears the description, the skills address, the colour or the symbol.");
1127
+ }
1128
+ const session = await open(io, values);
1129
+ const changed = await session.client.project(key).update(patch);
1130
+ answer(io, session, changed, () => formatProject(changed));
1131
+ return EXIT.ok;
1132
+ };
1133
+ const projectIcon = async (args, io) => {
1134
+ const { values, positionals } = parse(args, { remove: { type: "boolean" } });
1135
+ const [key, file] = positionals;
1136
+ const removing = on(values, "remove");
1137
+ if (key === undefined || positionals.length > 2 || (file === undefined) !== removing) {
1138
+ throw misuse("Say which project and which image: console project icon <key> <file>, or console project icon <key> --remove");
1139
+ }
1140
+ const session = await open(io, values);
1141
+ const project = session.client.project(key);
1142
+ const changed = file === undefined
1143
+ ? await project.removeIcon()
1144
+ : await project.setIcon({
1145
+ name: basename(file),
1146
+ bytes: await io.readBytes(file),
1147
+ contentType: CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream",
1148
+ });
1149
+ answer(io, session, changed, () => formatProject(changed));
1150
+ return EXIT.ok;
1151
+ };
1152
+ const project = async (args, io) => {
1153
+ if (args[0] === "new") {
1154
+ return newProject(args.slice(1), io);
1155
+ }
1156
+ if (args[0] === "edit") {
1157
+ return editProject(args.slice(1), io);
1158
+ }
1159
+ if (args[0] === "icon") {
1160
+ return projectIcon(args.slice(1), io);
1161
+ }
1162
+ const { values, positionals } = parse(args, {});
1163
+ const key = theOne(positionals, "Say which project: console project <key>; or console project new, edit or icon");
1164
+ const session = await open(io, values);
1165
+ const found = await session.client.project(key).get();
1166
+ answer(io, session, found, () => formatProject(found));
1167
+ return EXIT.ok;
1168
+ };
1169
+ const members = async (args, io) => {
1170
+ const { values } = parse(args, {}, false);
1171
+ const session = await open(io, values);
1172
+ const project = await session.project();
1173
+ const found = await project.members();
1174
+ answer(io, session, found, () => found.items.length === 0
1175
+ ? "Nobody is listed in the project."
1176
+ : found.items.map(formatMemberLine).join("\n"));
1177
+ return EXIT.ok;
1178
+ };
1179
+ const member = async (args, io) => {
1180
+ const [verb, ...rest] = args;
1181
+ if (verb === "add") {
1182
+ const { values, positionals } = parse(rest, { role: { type: "string" } });
1183
+ const login = theOne(positionals, "Say whom: console member add <login> [--role owner|member]");
1184
+ const role = text(values, "role");
1185
+ const session = await open(io, values);
1186
+ const project = await session.project();
1187
+ const added = await project.addMember({
1188
+ personId: (await personOf(session, login)).id,
1189
+ ...(role === undefined ? {} : { role: projectRoleOf(role) }),
1190
+ });
1191
+ answer(io, session, added, () => formatMemberLine(added));
1192
+ return EXIT.ok;
1193
+ }
1194
+ if (verb === "role") {
1195
+ const { values, positionals } = parse(rest, {});
1196
+ const [login, role] = theTwo(positionals, "Say whom and what: console member role <login> <owner|member>");
1197
+ const wanted = projectRoleOf(role);
1198
+ const session = await open(io, values);
1199
+ const project = await session.project();
1200
+ const changed = await project.updateMember((await personOf(session, login)).id, {
1201
+ role: wanted,
1202
+ });
1203
+ answer(io, session, changed, () => formatMemberLine(changed));
1204
+ return EXIT.ok;
1205
+ }
1206
+ if (verb === "remove") {
1207
+ const { values, positionals } = parse(rest, {});
1208
+ const login = theOne(positionals, "Say whom: console member remove <login>");
1209
+ const session = await open(io, values);
1210
+ const project = await session.project();
1211
+ const found = await personOf(session, login);
1212
+ await project.removeMember(found.id);
1213
+ answer(io, session, { removed: found.login }, () => `${found.login} is no longer listed in the project.`);
1214
+ return EXIT.ok;
1215
+ }
1216
+ throw misuse("Say what to do: console member add <login> [--role owner|member], console member role <login> <owner|member>, or console member remove <login>");
1217
+ };
1218
+ const category = async (args, io) => {
1219
+ const [verb, ...rest] = args;
1220
+ if (verb === "new") {
1221
+ const { values, positionals } = parse(rest, { color: { type: "string" } });
1222
+ const [key, name] = theTwo(positionals, 'Say the key and the name: console category new <key> "<name>" [--color <name>]');
1223
+ const color = text(values, "color");
1224
+ const session = await open(io, values);
1225
+ const made = await session.client.createCategory({
1226
+ key,
1227
+ name,
1228
+ ...(color === undefined ? {} : { color: colorOf(color) }),
1229
+ });
1230
+ answer(io, session, made, () => formatCategoryLine(made));
1231
+ return EXIT.ok;
1232
+ }
1233
+ if (verb === "edit") {
1234
+ const { values, positionals } = parse(rest, {
1235
+ name: { type: "string" },
1236
+ color: { type: "string" },
1237
+ });
1238
+ const key = theOne(positionals, "Say which category: console category edit <key> [--name <name>] [--color <name>]");
1239
+ const name = text(values, "name");
1240
+ const color = text(values, "color");
1241
+ const patch = {
1242
+ ...(name === undefined ? {} : { name }),
1243
+ ...(color === undefined ? {} : { color: colorOf(color) }),
1244
+ };
1245
+ if (Object.keys(patch).length === 0) {
1246
+ throw misuse("Say what to change: --name or --color.");
1247
+ }
1248
+ const session = await open(io, values);
1249
+ const changed = await session.client.updateCategory(key, patch);
1250
+ answer(io, session, changed, () => formatCategoryLine(changed));
1251
+ return EXIT.ok;
1252
+ }
1253
+ if (verb === "remove") {
1254
+ const { values, positionals } = parse(rest, {});
1255
+ const key = theOne(positionals, "Say which category: console category remove <key>");
1256
+ const session = await open(io, values);
1257
+ await session.client.removeCategory(key);
1258
+ answer(io, session, { removed: key }, () => `Category ${key} removed.`);
1259
+ return EXIT.ok;
1260
+ }
1261
+ throw misuse('Say what to do: console category new <key> "<name>" [--color <name>], console category edit <key> [--name <name>] [--color <name>], or console category remove <key>');
1262
+ };
1035
1263
  const COMMANDS = {
1036
1264
  login,
1037
1265
  logout,
@@ -1046,6 +1274,12 @@ const COMMANDS = {
1046
1274
  goal,
1047
1275
  categories,
1048
1276
  skills,
1277
+ people,
1278
+ person,
1279
+ project,
1280
+ members,
1281
+ member,
1282
+ category,
1049
1283
  take,
1050
1284
  report,
1051
1285
  "hand-in": handIn,
@@ -1097,6 +1331,26 @@ function hintFor(error) {
1097
1331
  return "The due day is before the start. Say a due day on or after it.\n";
1098
1332
  case "goal.not_found":
1099
1333
  return "No goal of this project has that id. console goals lists them.\n";
1334
+ case "auth.forbidden":
1335
+ 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";
1336
+ case "project.key_taken":
1337
+ return "A project has that key already. console projects lists them.\n";
1338
+ case "project.invalid_address":
1339
+ return "The skills address is not one as SkillCDN spells it; none makes the organization's skills the project's.\n";
1340
+ case "project.invalid_icon":
1341
+ return "The image must be a PNG, a JPEG, a GIF or a WebP, known by its bytes.\n";
1342
+ case "member.exists":
1343
+ return "The person is listed already. console member role <login> <owner|member> changes what they are.\n";
1344
+ case "member.not_found":
1345
+ return "The person is not listed in the project. console members lists those who are.\n";
1346
+ case "category.key_taken":
1347
+ return "A category has that key already. console categories lists them.\n";
1348
+ case "category.not_found":
1349
+ return "No category has that key. console categories lists them.\n";
1350
+ case "category.in_use":
1351
+ return "Tasks are sorted into the category. Move them out first: console task edit <number> --category <other|none>\n";
1352
+ case "person.last_admin":
1353
+ return "The board keeps at least one administrator. Make another one first.\n";
1100
1354
  case "connect.too_many":
1101
1355
  return "The console holds as many connections as it may at once. Try again in a few minutes.\n";
1102
1356
  case "network":