@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.
@@ -11,7 +11,7 @@ The API is the board as its members see it: the people, the projects, and in eac
11
11
  - Every answer is `cache-control: no-store`: it is one person's, or it changes.
12
12
  - A request that changes something (`POST`, `PATCH`, `DELETE`) on a session must carry the session cookie **and** come from the console's own pages: the browser's `origin` header must be `PUBLIC_URL`. The cookie is `SameSite=Lax`, so another site's request does not carry it in the first place; the origin is the second fence.
13
13
  - A token ([`POST /api/v1/tokens`](#get-apiv1tokens-and-post-apiv1tokens)) is presented as `Authorization: Bearer <secret>` on any route, by an agent, a script or a console of a person's own. It is then the credential: a cookie beside it is not looked at, and no origin is needed, since nothing attaches a token but whoever holds it. A token that is nothing is refused, whatever cookie travels with it. What a token does is attributed to its person, and the event names the agent: the token's name, or what the agent called itself on its run.
14
- - Configuring is a person's own doing, signed in on the console's own pages, never with a token: the tokens themselves, roles, projects and their members.
14
+ - A token configures as its person does ([ADR-0021](../adr/0021-a-token-configures-as-its-person-does.md)): every request a session may make, a token may make, within the same roles, except the tokens themselves and the approval of a connection, which stay a person's own doing, signed in on the console's own pages.
15
15
  - An agent connects without anyone copying a token ([ADR-0011](../adr/0011-an-agent-connects-with-a-short-code-a-person-approves-and-the-token-is-never-shown.md)): [`POST /api/v1/connect`](#connecting-an-agent) is nobody's, the approval is a person's own doing on the console's own pages, and the claim is the command's, with the secret it was given.
16
16
  - Request bodies are JSON, at most 256 KiB, and parsed once with the package's input schema. Text is trimmed where it is a line, bounded everywhere (`MAX_TITLE_LENGTH` and the rest of `@skillcdn/console/api`), and refused when it carries control or invisible characters. Links are `https` URLs only.
17
17
  - Errors have `{ "error": { "code": "...", "message": "..." } }`. The code is stable; the message is for a person, in English.
@@ -37,7 +37,7 @@ The API is the board as its members see it: the people, the projects, and in eac
37
37
  | 401 | `auth.required` | Nobody is signed in, or the session has ended, or the token is nothing. The cookie is taken away when the operator no longer lists the login. |
38
38
  | 403 | `auth.forbidden_origin` | A request that changes something on a session did not come from the console's own pages. |
39
39
  | 403 | `auth.forbidden` | A member asked for what only an administrator, or only an owner of the project, may do. |
40
- | 403 | `auth.session_required` | A token asked for what only a person signed in may do: the tokens themselves, and configuring. |
40
+ | 403 | `auth.session_required` | A token asked for what only a person signed in may do: the tokens themselves, and the approval of an agent's connection, which makes one. |
41
41
  | 403 | `run.not_yours` | The run is another person's. |
42
42
  | 404 | `project.not_found` | No project has that key, or it is not the asker's to see: the two are one answer. The key is checked before anything is asked. |
43
43
  | 404 | `task.not_found`, `decision.not_found`, `goal.not_found`, `token.not_found`, `person.not_found`, `run.not_found`, `run.task_not_found`, `member.not_found` | No such id in the project (or in the workspace, for a token, a person); for a token, none of the asker's. An id that is not a UUID is not found either, except a task's number. |
@@ -69,11 +69,11 @@ The API is the board as its members see it: the people, the projects, and in eac
69
69
 
70
70
  ### `GET /api/v1/people`
71
71
 
72
- `{ "items": [person, ...] }`: everyone who has signed in and is still listed, by login. What a picker of assignees and of members is made of. A person carries their `role` in the workspace: `admin`, who configures it, or `member`, who works in the projects they are in.
72
+ `{ "items": [person, ...] }`: everyone who has signed in and is still listed, by login. What a picker of assignees and of members is made of. A person carries their `role` in the workspace: `admin`, who configures it, or `member`, who works in the projects they are in. A token reads it as its person does: what the command's `--assignee`, `member` and `person role` take a login from.
73
73
 
74
74
  ### `PATCH /api/v1/people/<id>`
75
75
 
76
- Takes `role` and answers the person. For an administrator signed in on the console's own pages, never with a token: an agent works as its person does, and configuring is a person's own doing. The board keeps at least one administrator (`person.last_admin`). A change writes a `person.role_changed` event with `login` and `role`; a change to what is already so writes nothing.
76
+ Takes `role` and answers the person. For an administrator, signed in or through their agent's token ([ADR-0021](../adr/0021-a-token-configures-as-its-person-does.md)). The board keeps at least one administrator (`person.last_admin`). A change writes a `person.role_changed` event with `login` and `role`; a change to what is already so writes nothing.
77
77
 
78
78
  ### `GET /api/v1/projects`
79
79
 
@@ -81,7 +81,7 @@ Takes `role` and answers the person. For an administrator signed in on the conso
81
81
 
82
82
  ### `POST /api/v1/projects`
83
83
 
84
- Takes `key` and `name`, and optionally `description`, `visibility` (default `private`), `skillsAddress` (checked as the standard spells one, kept canonical; `project.invalid_address`), `color` and `symbol`. For whoever is signed in on the console's own pages, never with a token; they become the project's owner. Answers `201` with the project and writes a `project.created` event in it, with `key` and `name`. A key taken is `project.key_taken`.
84
+ Takes `key` and `name`, and optionally `description`, `visibility` (default `private`), `skillsAddress` (checked as the standard spells one, kept canonical; `project.invalid_address`), `color` and `symbol`. For whoever is signed in, or their agent with their token; they become the project's owner. Answers `201` with the project and writes a `project.created` event in it, with `key` and `name`. A key taken is `project.key_taken`.
85
85
 
86
86
  ### `GET /api/v1/events?after=&limit=`
87
87
 
@@ -89,7 +89,7 @@ The workspace's own events, the ones about no project: who joined (`person.joine
89
89
 
90
90
  ### `PATCH /api/v1/me`
91
91
 
92
- Keeps the person's own choice of language for the pages ([ADR-0012](../adr/0012-the-pages-speak-the-persons-language-from-packs-that-ship-with-the-package.md)): `{ "language": "ko" }`, a language tag as a browser sends one, or `null` to choose none and let the browser's languages decide; answers what `GET` answers. A person's own doing, from the console's own pages: never with a token.
92
+ Keeps the person's own choice of language for the pages ([ADR-0012](../adr/0012-the-pages-speak-the-persons-language-from-packs-that-ship-with-the-package.md)): `{ "language": "ko" }`, a language tag as a browser sends one, or `null` to choose none and let the browser's languages decide; answers what `GET` answers. From the console's own pages, or with a token: a console of a person's own keeps the choice on the person too.
93
93
 
94
94
  ### `GET /api/v1/categories`
95
95
 
@@ -97,7 +97,7 @@ Keeps the person's own choice of language for the pages ([ADR-0012](../adr/0012-
97
97
 
98
98
  ### `POST /api/v1/categories`, `PATCH /api/v1/categories/<key>` and `DELETE /api/v1/categories/<key>`
99
99
 
100
- Kept by an administrator signed in on the console's own pages, never with a token, as the rest of configuring is. `POST` takes `key`, `name` and optionally `color` (default `gray`), answers `201` with the category and writes a `category.created` event in the workspace's own feed with `key` and `name`; a key taken is `category.key_taken`, and the workspace holds at most `MAX_CATEGORIES` (`category.too_many`). `PATCH` takes any of `name` and `color`, answers the category and writes `category.updated` naming the `fields`; what is already so writes nothing; the key does not change. `DELETE` answers `204` and writes `category.removed`; a category tasks are sorted into is `category.in_use` until they are moved out of it.
100
+ Kept by an administrator, signed in or through their agent's token. `POST` takes `key`, `name` and optionally `color` (default `gray`), answers `201` with the category and writes a `category.created` event in the workspace's own feed with `key` and `name`; a key taken is `category.key_taken`, and the workspace holds at most `MAX_CATEGORIES` (`category.too_many`). `PATCH` takes any of `name` and `color`, answers the category and writes `category.updated` naming the `fields`; what is already so writes nothing; the key does not change. `DELETE` answers `204` and writes `category.removed`; a category tasks are sorted into is `category.in_use` until they are moved out of it.
101
101
 
102
102
  ### `GET /api/v1/work`
103
103
 
@@ -119,7 +119,7 @@ An administrator's sight of everyone's agents ([ADR-0011](../adr/0011-an-agent-c
119
119
 
120
120
  `POST /api/v1/connect`, `GET /api/v1/connect/<code>`, `POST /api/v1/connect/<code>/approve`, `POST /api/v1/connect/<code>/deny` and `POST /api/v1/connect/claim`: how an agent gets its token without anyone copying one ([ADR-0011](../adr/0011-an-agent-connects-with-a-short-code-a-person-approves-and-the-token-is-never-shown.md)).
121
121
 
122
- `POST /api/v1/connect` is nobody's: the command sends `{ "agent" }` (what the agent calls itself and where it runs, a line up to `MAX_TOKEN_NAME_LENGTH`) and is answered `201` with `{ "code", "url", "secret", "expiresAt", "interval" }`: a code of eight characters in two groups (`ABCD-EFGH`, from an alphabet without look-alikes) for a person to approve, the console's own page for it, a secret (`cns_c_...`) the command claims with and shows nowhere, when the request expires (ten minutes after it began), and how many seconds the command waits between claims. A deployment holds a bounded number of requests at once (`connect.too_many`).
122
+ `POST /api/v1/connect` is nobody's: the command sends `{ "agent" }` (what the agent calls itself and where it runs, a line up to `MAX_TOKEN_NAME_LENGTH`) and is answered `201` with `{ "code", "url", "secret", "expiresAt", "interval" }`: a code of eight characters in two groups (`ABCD-EFGH`, from an alphabet without look-alikes) for a person to approve, the console's own page for it, a secret (`cns_c_...`) the command claims with and shows nowhere, when the request expires (thirty minutes after it began), and how many seconds the command waits between claims. A deployment holds a bounded number of requests at once (`connect.too_many`).
123
123
 
124
124
  `GET /api/v1/connect/<code>`, for a person signed in, answers what asks: `{ "code", "agent", "createdAt", "expiresAt", "approved" }`. A code is read as a person types it: any case, with or without the hyphen. `POST .../approve`, from the console's own pages, takes what `POST /api/v1/tokens` takes (`name`, `expiresInDays`) and answers the request, approved; `POST .../deny` takes the request away and answers `204`.
125
125
 
@@ -131,19 +131,19 @@ Everything below is under `/api/v1/projects/<key>`, for whoever may see the proj
131
131
 
132
132
  ### `GET /api/v1/projects/<key>` and `PATCH /api/v1/projects/<key>`
133
133
 
134
- The project, as the asker sees it. A patch, for an owner signed in, takes any of `name`, `description`, `visibility`, `skillsAddress` (`null` for the organization's), `color` and `symbol` (`null` for none), and writes a `project.updated` event naming the `fields`; what is already so writes nothing. The key does not change.
134
+ The project, as the asker sees it. A patch, for an owner, signed in or with a token, takes any of `name`, `description`, `visibility`, `skillsAddress` (`null` for the organization's), `color` and `symbol` (`null` for none), and writes a `project.updated` event naming the `fields`; what is already so writes nothing. The key does not change.
135
135
 
136
136
  ### `POST /api/v1/projects/<key>/icon`, `DELETE .../icon` and `GET .../icon`
137
137
 
138
- The project's own image for its mark, in place of the symbol on the colour. `POST`, for an owner signed in on the console's own pages, takes a form with a part called `file`: a PNG, a JPEG, a GIF or a WebP of at most `MAX_PROJECT_ICON_BYTES`, known by its first bytes and never by what the form says of it (`project.invalid_icon`; `request.too_large`). The bytes are kept by their hash, the project is answered with its `icon`, and the board hears `project.updated` with `fields: ["icon"]`; the same image again writes nothing. `DELETE` takes the image away and answers the project. `GET`, for whoever may see the project, answers the bytes with their type, the hash as `ETag` and `Cache-Control: private, max-age=86400`, or `304` to a matching `If-None-Match`; a project without an image is `project.no_icon`. The pages read it at `projectIconPath(key, icon)`, which carries the hash as a version.
138
+ The project's own image for its mark, in place of the symbol on the colour. `POST`, for an owner, takes a form with a part called `file`: a PNG, a JPEG, a GIF or a WebP of at most `MAX_PROJECT_ICON_BYTES`, known by its first bytes and never by what the form says of it (`project.invalid_icon`; `request.too_large`). The bytes are kept by their hash, the project is answered with its `icon`, and the board hears `project.updated` with `fields: ["icon"]`; the same image again writes nothing. `DELETE` takes the image away and answers the project. `GET`, for whoever may see the project, answers the bytes with their type, the hash as `ETag` and `Cache-Control: private, max-age=86400`, or `304` to a matching `If-None-Match`; a project without an image is `project.no_icon`. The pages read it at `projectIconPath(key, icon)`, which carries the hash as a version.
139
139
 
140
140
  ### `GET /api/v1/projects/<key>/members` and `POST /api/v1/projects/<key>/members`
141
141
 
142
- `{ "items": [member, ...] }`: those listed in the project, by login, each a `person`, their `role` in it (`owner`, `member`) and `addedAt`. A project open to the workspace has everyone of it as a member besides; an administrator is an owner of every project, listed or not. `POST`, for an owner signed in, takes `personId` (a person of the workspace; `member.invalid_person`) and optionally `role` (default `member`), answers `201` with the member and writes `project.member_added` with `login` and `role`. A person listed already is `member.exists`.
142
+ `{ "items": [member, ...] }`: those listed in the project, by login, each a `person`, their `role` in it (`owner`, `member`) and `addedAt`. A project open to the workspace has everyone of it as a member besides; an administrator is an owner of every project, listed or not. `POST`, for an owner, takes `personId` (a person of the workspace; `member.invalid_person`) and optionally `role` (default `member`), answers `201` with the member and writes `project.member_added` with `login` and `role`. A person listed already is `member.exists`.
143
143
 
144
144
  ### `PATCH /api/v1/projects/<key>/members/<person id>` and `DELETE .../members/<person id>`
145
145
 
146
- For an owner signed in. The patch takes `role` and writes `project.member_changed`; the removal answers `204` and writes `project.member_removed`. A person not listed is `member.not_found`. A project may be left without an owner listed; the administrators own it then.
146
+ For an owner, signed in or with a token. The patch takes `role` and writes `project.member_changed`; the removal answers `204` and writes `project.member_removed`. A person not listed is `member.not_found`. A project may be left without an owner listed; the administrators own it then.
147
147
 
148
148
  ### `GET /api/v1/projects/<key>/tasks?state=&open=&category=&goal=&assignee=&dueBy=&limit=&offset=`
149
149
 
@@ -203,7 +203,7 @@ The bytes are read back at `/api/v1/projects/<key>/files/<artifact id>` by whoev
203
203
 
204
204
  ### `POST .../runs/<id>/end`
205
205
 
206
- Takes `status` (`finished`, `failed` or `abandoned`) and optionally a `summary` (Markdown, `MAX_SUMMARY_LENGTH`: what was done and what is left), ends the run (`run.ended`), and answers it. A finished run puts a task that was `in_progress` up for review (`in_review`); the rest leave the task as it is. The run's person ends it, as `finished` or `failed` from their agent; `abandoned` is a person giving up on a run that will not come back: their own, or anyone's as an owner of the project signed in on the console's own pages, never with a token (`run.not_yours`). A run that is over is `run.over`.
206
+ Takes `status` (`finished`, `failed` or `abandoned`) and optionally a `summary` (Markdown, `MAX_SUMMARY_LENGTH`: what was done and what is left), ends the run (`run.ended`), and answers it. A finished run puts a task that was `in_progress` up for review (`in_review`); the rest leave the task as it is. The run's person ends it, as `finished` or `failed` from their agent; `abandoned` is a person giving up on a run that will not come back: their own, or anyone's as an owner of the project, signed in or through their agent (`run.not_yours` otherwise). A run that is over is `run.over`.
207
207
 
208
208
  ### `GET /api/v1/projects/<key>/skills`
209
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillcdn/console",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "What a custom SkillCDN console is built from: the API client and schemas, the components, the composition of the default console, and the console command agents work the board with.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/skillcdn/console#readme",
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: A shell with Node.js 24 or newer and npm or pnpm, and an agent that reads and writes files. For looking at the pages against a board, the console command signed in by a person. A browser the agent can drive is optional. No key of the agent's own is needed, and it never sees a token.
6
6
  metadata:
7
7
  author: skillcdn
8
- version: "1.2"
8
+ version: "1.3"
9
9
  skillcdn:
10
10
  translations:
11
11
  ko:
@@ -74,7 +74,7 @@ Produces the **pages**. For a page the default console lacks: `useConsoleData(cl
74
74
 
75
75
  ### Phase 4: Run it against the board
76
76
 
77
- Produces the **running console**. `console serve` serves the API at `http://127.0.0.1:11197/api/` while `npx vite` serves the pages, and `console serve dist` serves the build whole at `http://127.0.0.1:11197/`. The pages hold no token; the command carries it, on the loopback only. The pages know they hold a token (`GET /api/v1/me` answers `agent`) and offer nothing a token cannot do: the tokens themselves and configuring stay on the organization's console. The organization's console is looked at the same way, on the builder's token: it shows every page a token may see, and the pages a token may not (the agents, the settings) are the package's own and unchanged, so the look checkpoint covers everything the console changed. Where the organization's build then goes is the plan's answer: `console-ui/` of the deployment repository, whose workflow builds it into the image on every push, handed over to deploying-a-console, the skill beside this one at the same address (in the package under `node_modules/@skillcdn/console/skills/deploying-a-console/`), with the source and the repository, and with the package pinned at the version of the release the deployment pins as `CONSOLE_REF` (the tag `@skillcdn/console@<version>`), so that the pages and the server are one release and move together; the build beside the Dockerfile of [deploy/README.md](/deploy/README.md) "A custom console in the image", for an image the organization builds itself; or the mounted directory. A script: `createClient({ baseUrl, token })`, every answer parsed with the package's schemas.
77
+ Produces the **running console**. `console serve` serves the API at `http://127.0.0.1:11197/api/` while `npx vite` serves the pages, and `console serve dist` serves the build whole at `http://127.0.0.1:11197/`. The pages hold no token; the command carries it, on the loopback only. The pages know they hold a token (`GET /api/v1/me` answers `agent`) and offer nothing a token cannot do: the tokens themselves stay on the organization's console, and everything else, configuring included, the pages do on the token as the person ([ADR-0021](/docs/adr/0021-a-token-configures-as-its-person-does.md)). The organization's console is looked at the same way, on the builder's token: it shows every page a token may see, and the pages a token may not (the agents) are the package's own and unchanged, so the look checkpoint covers everything the console changed. Where the organization's build then goes is the plan's answer: `console-ui/` of the deployment repository, whose workflow builds it into the image on every push, handed over to deploying-a-console, the skill beside this one at the same address (in the package under `node_modules/@skillcdn/console/skills/deploying-a-console/`), with the source and the repository, and with the package pinned at the version of the release the deployment pins as `CONSOLE_REF` (the tag `@skillcdn/console@<version>`), so that the pages and the server are one release and move together; the build beside the Dockerfile of [deploy/README.md](/deploy/README.md) "A custom console in the image", for an image the organization builds itself; or the mounted directory. A script: `createClient({ baseUrl, token })`, every answer parsed with the package's schemas; it configures as its person does too, with `createProject`, `updatePerson`, a project's `update`, `setIcon`, `addMember`, `updateMember` and `removeMember`, and `createCategory`, `updateCategory` and `removeCategory`, refused with `auth.forbidden` where the person may not, and never the tokens.
78
78
 
79
79
  ### Phase 5: Check and show
80
80
 
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: Needs a shell with the console command on the PATH (npm install -g @skillcdn/console, Node.js 24 or newer), signed in by a person. Nothing else; the command needs no key of its own and the agent never sees a token.
6
6
  metadata:
7
7
  author: skillcdn
8
- version: "1.2"
8
+ version: "1.3"
9
9
  skillcdn:
10
10
  translations:
11
11
  ko:
@@ -73,6 +73,10 @@ What was learned and will be needed again (how a thing is set up, why a choice w
73
73
 
74
74
  After the last report, `console finish --summary "<what was done, and what is left>"`: the task goes up for review. `console fail --summary "<why, and what is left>"` when it could not be done. A run is never left open.
75
75
 
76
+ ## Setting up, when told to
77
+
78
+ A task or a person may ask for the project itself rather than work in it: a project made or changed (`console project new <key> "<name>"`, `console project edit <key>`), its mark (`console project icon <key> <file>`), its members (`console member add <login>`, `console member role`, `console member remove`), the workspace's categories (`console category new <key> "<name>"`, `console category edit`, `console category remove`), what a person is (`console person role <login> <admin|member>`), and its goals (`console goal new`). The agent does these as its person may, an owner of the project or an administrator of the workspace ([ADR-0021](/docs/adr/0021-a-token-configures-as-its-person-does.md)); the feed names the agent beside the person on each. What the person may not, the console refuses (`auth.forbidden`): that step goes back to the person in the report, never worked around. `console people` and `console members` say who is who, by login.
79
+
76
80
  ## Hard rules
77
81
 
78
82
  - Never look for, read, print or send a token. The rules of the repository say the rest.
package/src/cli/cli.ts CHANGED
@@ -9,10 +9,18 @@ import {
9
9
  } from "../client.js";
10
10
  import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
11
11
  import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
12
- import type { RestClaim, RestDecision, RestTaskPatch } from "../schemas.js";
12
+ import type { RestClaim, RestDecision, RestPerson, RestTaskPatch } from "../schemas.js";
13
13
  import {
14
+ COLOR_NAMES,
15
+ type ColorName,
14
16
  GOAL_STATES,
15
17
  type GoalState,
18
+ PERSON_ROLES,
19
+ type PersonRole,
20
+ PROJECT_ROLES,
21
+ PROJECT_VISIBILITIES,
22
+ type ProjectRole,
23
+ type ProjectVisibility,
16
24
  TASK_PRIORITIES,
17
25
  TASK_STATES,
18
26
  type TaskPriority,
@@ -30,8 +38,11 @@ import {
30
38
  formatDocuments,
31
39
  formatGoal,
32
40
  formatGoalLine,
41
+ formatMemberLine,
33
42
  formatMore,
34
43
  formatOptions,
44
+ formatPersonLine,
45
+ formatProject,
35
46
  formatProjectLine,
36
47
  formatRun,
37
48
  formatRunLine,
@@ -331,16 +342,41 @@ async function personIdOf(
331
342
  }
332
343
  return me.person.id;
333
344
  }
345
+ return (await personOf(session, value)).id;
346
+ }
347
+
348
+ /** The person a login names, among the workspace's; the logins are as people sign in. */
349
+ async function personOf(session: Session, login: string): Promise<RestPerson> {
334
350
  const { items } = await session.client.people();
335
- const found = items.find((person) => person.login.toLowerCase() === value.toLowerCase());
351
+ const found = items.find((person) => person.login.toLowerCase() === login.toLowerCase());
336
352
  if (found === undefined) {
337
353
  throw misuse(
338
- `Nobody of the workspace is called ${value}. console people is not a command; the logins are as people sign in.`,
354
+ `Nobody of the workspace is called ${login}. console people lists everyone, by login.`,
339
355
  );
340
356
  }
341
- return found.id;
357
+ return found;
358
+ }
359
+
360
+ /** One word of a vocabulary, or the refusal to understand. */
361
+ function wordOf<T extends string>(value: string, words: readonly T[], what: string): T {
362
+ const found = words.find((word) => word === value);
363
+ if (found === undefined) {
364
+ throw misuse(`Not ${what}: ${value}. One of ${words.join(", ")}`);
365
+ }
366
+ return found;
342
367
  }
343
368
 
369
+ const personRoleOf = (value: string): PersonRole =>
370
+ wordOf(value, PERSON_ROLES, "a role in the workspace");
371
+ const projectRoleOf = (value: string): ProjectRole =>
372
+ wordOf(value, PROJECT_ROLES, "a role in a project");
373
+ const visibilityOf = (value: string): ProjectVisibility =>
374
+ wordOf(value, PROJECT_VISIBILITIES, "a visibility");
375
+ const colorOf = (value: string): ColorName => wordOf(value, COLOR_NAMES, "a colour");
376
+ /** A colour, or `none` for the one the pages choose, which is `null` on the wire. */
377
+ const colorOrNoneOf = (value: string | undefined): ColorName | null | undefined =>
378
+ value === undefined ? undefined : value === "none" ? null : colorOf(value);
379
+
344
380
  function secondsOf(value: string | undefined, fallback: number): number {
345
381
  if (value === undefined) {
346
382
  return fallback;
@@ -381,6 +417,15 @@ function theOne(positionals: readonly string[], ask: string): string {
381
417
  return first;
382
418
  }
383
419
 
420
+ /** Exactly two words, for a command on a thing and a value. */
421
+ function theTwo(positionals: readonly string[], ask: string): readonly [string, string] {
422
+ const [first, second] = positionals;
423
+ if (first === undefined || second === undefined || positionals.length > 2) {
424
+ throw misuse(ask);
425
+ }
426
+ return [first, second];
427
+ }
428
+
384
429
  /**
385
430
  * The decision once answered, or as it stands when the wait is over. Each request asks the
386
431
  * console to hold it for a while, so the answer arrives as soon as a person gives it.
@@ -535,7 +580,7 @@ const projects: Command = async (args, io) => {
535
580
  const found = await session.client.projects();
536
581
  answer(io, session, found, () =>
537
582
  found.items.length === 0
538
- ? "No projects you may work in. A person makes one on the console's Projects page."
583
+ ? 'No projects you may work in. Make one: console project new <key> "<name>"'
539
584
  : found.items.map(formatProjectLine).join("\n"),
540
585
  );
541
586
  return EXIT.ok;
@@ -1349,6 +1394,263 @@ const serve: Command = async (args, io) => {
1349
1394
  return EXIT.ok;
1350
1395
  };
1351
1396
 
1397
+ // Configuring, as the person (ADR-0021): what an administrator or an owner does on the pages,
1398
+ // their agent does here, attributed to them as the agent. The console decides on every request
1399
+ // with the same roles; what the person may not, the agent may not (auth.forbidden). The tokens
1400
+ // themselves have no command, by design.
1401
+
1402
+ const people: Command = async (args, io) => {
1403
+ const { values } = parse(args, {}, false);
1404
+ const session = await open(io, values);
1405
+ const found = await session.client.people();
1406
+ answer(io, session, found, () => found.items.map(formatPersonLine).join("\n"));
1407
+ return EXIT.ok;
1408
+ };
1409
+
1410
+ const person: Command = async (args, io) => {
1411
+ if (args[0] !== "role") {
1412
+ throw misuse("Say what to do: console person role <login> <admin|member>");
1413
+ }
1414
+ const { values, positionals } = parse(args.slice(1), {});
1415
+ const [login, role] = theTwo(
1416
+ positionals,
1417
+ "Say whom and what: console person role <login> <admin|member>",
1418
+ );
1419
+ const wanted = personRoleOf(role);
1420
+ const session = await open(io, values);
1421
+ const found = await personOf(session, login);
1422
+ const changed = await session.client.updatePerson(found.id, { role: wanted });
1423
+ answer(io, session, changed, () => formatPersonLine(changed));
1424
+ return EXIT.ok;
1425
+ };
1426
+
1427
+ /** The flags that set what a project is, shared by making one and changing one. */
1428
+ const PROJECT_FIELDS: OptionSpec = {
1429
+ description: { type: "string" },
1430
+ file: { type: "string" },
1431
+ visibility: { type: "string" },
1432
+ skills: { type: "string" },
1433
+ color: { type: "string" },
1434
+ symbol: { type: "string" },
1435
+ };
1436
+
1437
+ /** What the flags say of a project; `none` clears what may be cleared. */
1438
+ async function projectFieldsOf(io: CliIo, values: Values) {
1439
+ const description = await bodyOf(io, values, text(values, "description"));
1440
+ const visibility = text(values, "visibility");
1441
+ const skills = orNone(text(values, "skills"));
1442
+ const color = colorOrNoneOf(text(values, "color"));
1443
+ const symbol = orNone(text(values, "symbol"));
1444
+ return {
1445
+ ...(description === undefined
1446
+ ? {}
1447
+ : { description: description === "none" ? "" : description }),
1448
+ ...(visibility === undefined ? {} : { visibility: visibilityOf(visibility) }),
1449
+ ...(skills === undefined ? {} : { skillsAddress: skills }),
1450
+ ...(color === undefined ? {} : { color }),
1451
+ ...(symbol === undefined ? {} : { symbol }),
1452
+ };
1453
+ }
1454
+
1455
+ const newProject: Command = async (args, io) => {
1456
+ const { values, positionals } = parse(args, PROJECT_FIELDS);
1457
+ const [key, name] = theTwo(
1458
+ positionals,
1459
+ 'Say the key and the name: console project new <key> "<name>" [--visibility workspace|private]',
1460
+ );
1461
+ const fields = await projectFieldsOf(io, values);
1462
+ const session = await open(io, values);
1463
+ const made = await session.client.createProject({ key, name, ...fields });
1464
+ answer(
1465
+ io,
1466
+ session,
1467
+ made,
1468
+ () =>
1469
+ `Project made: ${made.name} (${made.key}), with you as its owner.\nTo work in it from this directory: console use ${made.key}`,
1470
+ );
1471
+ return EXIT.ok;
1472
+ };
1473
+
1474
+ const editProject: Command = async (args, io) => {
1475
+ const { values, positionals } = parse(args, { ...PROJECT_FIELDS, name: { type: "string" } });
1476
+ const key = theOne(
1477
+ positionals,
1478
+ "Say which project: console project edit <key> --name <name> ...",
1479
+ );
1480
+ const name = text(values, "name");
1481
+ const patch = { ...(name === undefined ? {} : { name }), ...(await projectFieldsOf(io, values)) };
1482
+ if (Object.keys(patch).length === 0) {
1483
+ throw misuse(
1484
+ "Say what to change: --name, --description, --visibility, --skills, --color or --symbol; none clears the description, the skills address, the colour or the symbol.",
1485
+ );
1486
+ }
1487
+ const session = await open(io, values);
1488
+ const changed = await session.client.project(key).update(patch);
1489
+ answer(io, session, changed, () => formatProject(changed));
1490
+ return EXIT.ok;
1491
+ };
1492
+
1493
+ const projectIcon: Command = async (args, io) => {
1494
+ const { values, positionals } = parse(args, { remove: { type: "boolean" } });
1495
+ const [key, file] = positionals;
1496
+ const removing = on(values, "remove");
1497
+ if (key === undefined || positionals.length > 2 || (file === undefined) !== removing) {
1498
+ throw misuse(
1499
+ "Say which project and which image: console project icon <key> <file>, or console project icon <key> --remove",
1500
+ );
1501
+ }
1502
+ const session = await open(io, values);
1503
+ const project = session.client.project(key);
1504
+ const changed =
1505
+ file === undefined
1506
+ ? await project.removeIcon()
1507
+ : await project.setIcon({
1508
+ name: basename(file),
1509
+ bytes: await io.readBytes(file),
1510
+ contentType: CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream",
1511
+ });
1512
+ answer(io, session, changed, () => formatProject(changed));
1513
+ return EXIT.ok;
1514
+ };
1515
+
1516
+ const project: Command = async (args, io) => {
1517
+ if (args[0] === "new") {
1518
+ return newProject(args.slice(1), io);
1519
+ }
1520
+ if (args[0] === "edit") {
1521
+ return editProject(args.slice(1), io);
1522
+ }
1523
+ if (args[0] === "icon") {
1524
+ return projectIcon(args.slice(1), io);
1525
+ }
1526
+ const { values, positionals } = parse(args, {});
1527
+ const key = theOne(
1528
+ positionals,
1529
+ "Say which project: console project <key>; or console project new, edit or icon",
1530
+ );
1531
+ const session = await open(io, values);
1532
+ const found = await session.client.project(key).get();
1533
+ answer(io, session, found, () => formatProject(found));
1534
+ return EXIT.ok;
1535
+ };
1536
+
1537
+ const members: Command = async (args, io) => {
1538
+ const { values } = parse(args, {}, false);
1539
+ const session = await open(io, values);
1540
+ const project = await session.project();
1541
+ const found = await project.members();
1542
+ answer(io, session, found, () =>
1543
+ found.items.length === 0
1544
+ ? "Nobody is listed in the project."
1545
+ : found.items.map(formatMemberLine).join("\n"),
1546
+ );
1547
+ return EXIT.ok;
1548
+ };
1549
+
1550
+ const member: Command = async (args, io) => {
1551
+ const [verb, ...rest] = args;
1552
+ if (verb === "add") {
1553
+ const { values, positionals } = parse(rest, { role: { type: "string" } });
1554
+ const login = theOne(positionals, "Say whom: console member add <login> [--role owner|member]");
1555
+ const role = text(values, "role");
1556
+ const session = await open(io, values);
1557
+ const project = await session.project();
1558
+ const added = await project.addMember({
1559
+ personId: (await personOf(session, login)).id,
1560
+ ...(role === undefined ? {} : { role: projectRoleOf(role) }),
1561
+ });
1562
+ answer(io, session, added, () => formatMemberLine(added));
1563
+ return EXIT.ok;
1564
+ }
1565
+ if (verb === "role") {
1566
+ const { values, positionals } = parse(rest, {});
1567
+ const [login, role] = theTwo(
1568
+ positionals,
1569
+ "Say whom and what: console member role <login> <owner|member>",
1570
+ );
1571
+ const wanted = projectRoleOf(role);
1572
+ const session = await open(io, values);
1573
+ const project = await session.project();
1574
+ const changed = await project.updateMember((await personOf(session, login)).id, {
1575
+ role: wanted,
1576
+ });
1577
+ answer(io, session, changed, () => formatMemberLine(changed));
1578
+ return EXIT.ok;
1579
+ }
1580
+ if (verb === "remove") {
1581
+ const { values, positionals } = parse(rest, {});
1582
+ const login = theOne(positionals, "Say whom: console member remove <login>");
1583
+ const session = await open(io, values);
1584
+ const project = await session.project();
1585
+ const found = await personOf(session, login);
1586
+ await project.removeMember(found.id);
1587
+ answer(
1588
+ io,
1589
+ session,
1590
+ { removed: found.login },
1591
+ () => `${found.login} is no longer listed in the project.`,
1592
+ );
1593
+ return EXIT.ok;
1594
+ }
1595
+ throw misuse(
1596
+ "Say what to do: console member add <login> [--role owner|member], console member role <login> <owner|member>, or console member remove <login>",
1597
+ );
1598
+ };
1599
+
1600
+ const category: Command = async (args, io) => {
1601
+ const [verb, ...rest] = args;
1602
+ if (verb === "new") {
1603
+ const { values, positionals } = parse(rest, { color: { type: "string" } });
1604
+ const [key, name] = theTwo(
1605
+ positionals,
1606
+ 'Say the key and the name: console category new <key> "<name>" [--color <name>]',
1607
+ );
1608
+ const color = text(values, "color");
1609
+ const session = await open(io, values);
1610
+ const made = await session.client.createCategory({
1611
+ key,
1612
+ name,
1613
+ ...(color === undefined ? {} : { color: colorOf(color) }),
1614
+ });
1615
+ answer(io, session, made, () => formatCategoryLine(made));
1616
+ return EXIT.ok;
1617
+ }
1618
+ if (verb === "edit") {
1619
+ const { values, positionals } = parse(rest, {
1620
+ name: { type: "string" },
1621
+ color: { type: "string" },
1622
+ });
1623
+ const key = theOne(
1624
+ positionals,
1625
+ "Say which category: console category edit <key> [--name <name>] [--color <name>]",
1626
+ );
1627
+ const name = text(values, "name");
1628
+ const color = text(values, "color");
1629
+ const patch = {
1630
+ ...(name === undefined ? {} : { name }),
1631
+ ...(color === undefined ? {} : { color: colorOf(color) }),
1632
+ };
1633
+ if (Object.keys(patch).length === 0) {
1634
+ throw misuse("Say what to change: --name or --color.");
1635
+ }
1636
+ const session = await open(io, values);
1637
+ const changed = await session.client.updateCategory(key, patch);
1638
+ answer(io, session, changed, () => formatCategoryLine(changed));
1639
+ return EXIT.ok;
1640
+ }
1641
+ if (verb === "remove") {
1642
+ const { values, positionals } = parse(rest, {});
1643
+ const key = theOne(positionals, "Say which category: console category remove <key>");
1644
+ const session = await open(io, values);
1645
+ await session.client.removeCategory(key);
1646
+ answer(io, session, { removed: key }, () => `Category ${key} removed.`);
1647
+ return EXIT.ok;
1648
+ }
1649
+ throw misuse(
1650
+ 'Say what to do: console category new <key> "<name>" [--color <name>], console category edit <key> [--name <name>] [--color <name>], or console category remove <key>',
1651
+ );
1652
+ };
1653
+
1352
1654
  const COMMANDS: Readonly<Record<string, Command>> = {
1353
1655
  login,
1354
1656
  logout,
@@ -1363,6 +1665,12 @@ const COMMANDS: Readonly<Record<string, Command>> = {
1363
1665
  goal,
1364
1666
  categories,
1365
1667
  skills,
1668
+ people,
1669
+ person,
1670
+ project,
1671
+ members,
1672
+ member,
1673
+ category,
1366
1674
  take,
1367
1675
  report,
1368
1676
  "hand-in": handIn,
@@ -1416,6 +1724,26 @@ function hintFor(error: ApiError): string {
1416
1724
  return "The due day is before the start. Say a due day on or after it.\n";
1417
1725
  case "goal.not_found":
1418
1726
  return "No goal of this project has that id. console goals lists them.\n";
1727
+ case "auth.forbidden":
1728
+ 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";
1729
+ case "project.key_taken":
1730
+ return "A project has that key already. console projects lists them.\n";
1731
+ case "project.invalid_address":
1732
+ return "The skills address is not one as SkillCDN spells it; none makes the organization's skills the project's.\n";
1733
+ case "project.invalid_icon":
1734
+ return "The image must be a PNG, a JPEG, a GIF or a WebP, known by its bytes.\n";
1735
+ case "member.exists":
1736
+ return "The person is listed already. console member role <login> <owner|member> changes what they are.\n";
1737
+ case "member.not_found":
1738
+ return "The person is not listed in the project. console members lists those who are.\n";
1739
+ case "category.key_taken":
1740
+ return "A category has that key already. console categories lists them.\n";
1741
+ case "category.not_found":
1742
+ return "No category has that key. console categories lists them.\n";
1743
+ case "category.in_use":
1744
+ return "Tasks are sorted into the category. Move them out first: console task edit <number> --category <other|none>\n";
1745
+ case "person.last_admin":
1746
+ return "The board keeps at least one administrator. Make another one first.\n";
1419
1747
  case "connect.too_many":
1420
1748
  return "The console holds as many connections as it may at once. Try again in a few minutes.\n";
1421
1749
  case "network":
package/src/cli/format.ts CHANGED
@@ -6,6 +6,8 @@ import type {
6
6
  RestDocumentSummary,
7
7
  RestDocuments,
8
8
  RestGoal,
9
+ RestMember,
10
+ RestPerson,
9
11
  RestProject,
10
12
  RestRun,
11
13
  RestSkills,
@@ -42,6 +44,31 @@ export function formatProjectLine(project: RestProject): string {
42
44
  return `${project.key} ${project.name} (${notes.join("; ")})`;
43
45
  }
44
46
 
47
+ /** One project in full: what it is, how it is marked, and what the person is in it. */
48
+ export function formatProject(project: RestProject): string {
49
+ const lines = [
50
+ `project ${project.key}: ${project.name}`,
51
+ `visibility: ${project.visibility} role: ${project.role} skills: ${project.skillsAddress ?? "the organization's"}`,
52
+ `color: ${project.color ?? "none"} symbol: ${project.symbol ?? "none"} image: ${project.icon === null ? "none" : project.icon.contentType}`,
53
+ ];
54
+ if (project.description.trim().length > 0) {
55
+ lines.push("", project.description.trimEnd());
56
+ }
57
+ return lines.join("\n");
58
+ }
59
+
60
+ /** One person: their login, their name where the host gives one, and what they are in the workspace. */
61
+ export function formatPersonLine(person: RestPerson): string {
62
+ return person.name === null
63
+ ? `${person.login} (${person.role})`
64
+ : `${person.login} ${person.name} (${person.role})`;
65
+ }
66
+
67
+ /** One member of a project: their login, what they are in it, and since when. */
68
+ export function formatMemberLine(member: RestMember): string {
69
+ return `${member.person.login} ${member.role} (since ${member.addedAt.slice(0, 10)})`;
70
+ }
71
+
45
72
  /** The line under a page of a listing that has more after it: how to ask for the next page. */
46
73
  export function formatMore(more: boolean, limit: number, offset: number): string {
47
74
  return more ? `\n... more: --offset ${offset + limit}` : "";