@skillcdn/console 0.1.17 → 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.
- package/README.md +4 -4
- package/SKILLCDN.md +1 -1
- package/dist/api.d.ts +3 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2 -1
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +11 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +496 -51
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/credentials.d.ts +102 -3
- package/dist/cli/credentials.d.ts.map +1 -1
- package/dist/cli/credentials.js +177 -8
- package/dist/cli/credentials.js.map +1 -1
- package/dist/cli/directory.d.ts +6 -0
- package/dist/cli/directory.d.ts.map +1 -1
- package/dist/cli/directory.js +37 -6
- package/dist/cli/directory.js.map +1 -1
- package/dist/cli/format.d.ts +11 -2
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +114 -5
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +49 -25
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/install.d.ts +21 -0
- package/dist/cli/install.d.ts.map +1 -0
- package/dist/cli/install.js +87 -0
- package/dist/cli/install.js.map +1 -0
- package/dist/cli/main.js +22 -3
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/notices.d.ts +13 -0
- package/dist/cli/notices.d.ts.map +1 -0
- package/dist/cli/notices.js +31 -0
- package/dist/cli/notices.js.map +1 -0
- package/dist/client.d.ts +36 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +22 -4
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +66 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +115 -0
- package/dist/compat.js.map +1 -0
- package/dist/components/board.d.ts +9 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +82 -5
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +3 -1
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +21 -2
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/comments.d.ts.map +1 -1
- package/dist/components/comments.js +12 -4
- package/dist/components/comments.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +4 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/goals.d.ts +20 -2
- package/dist/components/goals.d.ts.map +1 -1
- package/dist/components/goals.js +50 -13
- package/dist/components/goals.js.map +1 -1
- package/dist/components/markdown.d.ts +6 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +7 -0
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/pictures.d.ts +68 -0
- package/dist/components/pictures.d.ts.map +1 -0
- package/dist/components/pictures.js +213 -0
- package/dist/components/pictures.js.map +1 -0
- package/dist/components/projects.d.ts +12 -0
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +15 -0
- package/dist/components/projects.js.map +1 -1
- package/dist/components/roadmap.d.ts +37 -0
- package/dist/components/roadmap.d.ts.map +1 -0
- package/dist/components/roadmap.js +76 -0
- package/dist/components/roadmap.js.map +1 -0
- package/dist/components/task-view.d.ts +3 -0
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/tokens.d.ts +2 -0
- package/dist/components/tokens.d.ts.map +1 -1
- package/dist/components/tokens.js +13 -3
- package/dist/components/tokens.js.map +1 -1
- package/dist/console.css +486 -3
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +132 -17
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +16 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +48 -3
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +76 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +74 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +74 -2
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +31 -7
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +48 -8
- package/dist/router.js.map +1 -1
- package/dist/rules.js +5 -5
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +200 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +96 -0
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +2 -2
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +3 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +26 -15
- package/docs/specs/rest.md +31 -9
- package/package.json +3 -2
- package/skills/working-the-board/SKILL.md +11 -4
- package/src/api.ts +36 -0
- package/src/cli/cli.ts +616 -54
- package/src/cli/credentials.ts +276 -14
- package/src/cli/directory.ts +43 -6
- package/src/cli/format.ts +146 -5
- package/src/cli/help.ts +51 -25
- package/src/cli/install.ts +111 -0
- package/src/cli/main.ts +23 -3
- package/src/cli/notices.ts +48 -0
- package/src/client.ts +91 -6
- package/src/compat.ts +173 -0
- package/src/components/board.tsx +154 -8
- package/src/components/calendar.tsx +41 -1
- package/src/components/comments.tsx +20 -10
- package/src/components/event-feed.tsx +4 -0
- package/src/components/goals.tsx +234 -88
- package/src/components/markdown.tsx +19 -0
- package/src/components/pictures.tsx +389 -0
- package/src/components/projects.tsx +68 -0
- package/src/components/roadmap.tsx +242 -0
- package/src/components/task-view.tsx +43 -2
- package/src/components/tokens.tsx +38 -1
- package/src/console.tsx +278 -4
- package/src/data.ts +73 -2
- package/src/i18n/en.ts +77 -2
- package/src/i18n/ko.ts +77 -2
- package/src/index.ts +35 -0
- package/src/router.ts +70 -11
- package/src/rules.ts +5 -5
- package/src/schemas.ts +117 -0
- package/src/styles/console.css +486 -3
- package/src/vocabulary.ts +3 -0
package/dist/cli/cli.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { basename, extname } from "node:path";
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { ApiError, createClient, } from "../client.js";
|
|
4
|
+
import { CLIENT_RANGE_HEADER, judgeClient, parseVersion, VERSION_HEADER } from "../compat.js";
|
|
4
5
|
import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
|
|
5
6
|
import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
|
|
6
7
|
import { fillRules, RULES_PATH, RULES_TITLE } from "../rules.js";
|
|
7
8
|
import { hostOfLogin, } from "../schemas.js";
|
|
8
9
|
import { COLOR_NAMES, GOAL_STATES, PERSON_ROLES, PROJECT_ROLES, PROJECT_VISIBILITIES, PROVIDER_KEYS, TASK_PRIORITIES, TASK_STATES, } from "../vocabulary.js";
|
|
9
10
|
import { loadWebRoot, WebRootError } from "../web.js";
|
|
10
|
-
import {
|
|
11
|
+
import { CredentialsError, EMPTY_CREDENTIALS, loginsAt, resolveLogin, withLogin, withoutLogin, } from "./credentials.js";
|
|
12
|
+
import { formatAnswer, formatCategoryLine, formatComment, formatDecision, formatDecisionLine, formatDocument, formatDocuments, formatGoal, formatGoalLine, formatListingLine, formatMemberLine, formatMore, formatOptions, formatPersonLine, formatPersonWork, formatProject, formatProjectLine, formatRules, formatRun, formatRunLine, formatSkillFile, formatSkillLoad, formatSkills, formatTask, formatTaskLine, formatVersion, formatVersionLine, formatWork, } from "./format.js";
|
|
11
13
|
import { COMMAND_HELP, helpFor, usage } from "./help.js";
|
|
14
|
+
import { describeInstall, updateCommandFor } from "./install.js";
|
|
12
15
|
import { createServeHandler, DEFAULT_SERVE_PORT } from "./serve.js";
|
|
13
16
|
// The `console` command (docs/specs/cli.md): the agent's side of the console. A thin client of
|
|
14
17
|
// the REST API with no logic of its own: every command is one or two requests and the words to
|
|
@@ -24,7 +27,11 @@ export const EXIT = {
|
|
|
24
27
|
usage: 2,
|
|
25
28
|
/** The decision still waits for a person. */
|
|
26
29
|
waiting: 3,
|
|
30
|
+
/** The command and the console do not go together (ADR-0025): stderr says which to update, and how. */
|
|
31
|
+
incompatible: 4,
|
|
27
32
|
};
|
|
33
|
+
/** A day, for how often the command repeats what it noticed of the versions. */
|
|
34
|
+
const DAY_MS = 86_400_000;
|
|
28
35
|
/** How long one request for a decision's answer asks the console to hold it: what the server allows. */
|
|
29
36
|
const WAIT_STEP_SECONDS = 50;
|
|
30
37
|
/** A breath between two such requests, should the console answer at once. */
|
|
@@ -47,6 +54,7 @@ const misuse = (message) => new CliExit(EXIT.usage, message);
|
|
|
47
54
|
const GLOBAL_OPTIONS = {
|
|
48
55
|
json: { type: "boolean" },
|
|
49
56
|
url: { type: "string" },
|
|
57
|
+
account: { type: "string" },
|
|
50
58
|
project: { type: "string" },
|
|
51
59
|
};
|
|
52
60
|
function parse(args, options, allowPositionals = true) {
|
|
@@ -75,20 +83,80 @@ const texts = (values, name) => {
|
|
|
75
83
|
return typeof value === "string" ? [value] : [];
|
|
76
84
|
};
|
|
77
85
|
const NO_PROJECT = "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.";
|
|
78
|
-
/**
|
|
86
|
+
/** What the command keeps, or nothing; a file that cannot be read stops the command, and is never written over. */
|
|
87
|
+
async function loadCredentials(io) {
|
|
88
|
+
try {
|
|
89
|
+
return await io.store.load();
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error instanceof CredentialsError) {
|
|
93
|
+
throw failed(`${error.message} Move it away, then sign in again: console login --url <the console's origin>`);
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** The console a directory is bound to, as an origin, when it names one. */
|
|
99
|
+
function directoryOrigin(settings) {
|
|
100
|
+
if (settings?.url === undefined) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return originOf(settings.url);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw failed(`.skillcdn-console.json names a console that is not an origin: ${settings.url}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Why no console could be talked to, for the person who reads it. */
|
|
111
|
+
function wordsOf(failure) {
|
|
112
|
+
switch (failure.reason) {
|
|
113
|
+
case "nothing":
|
|
114
|
+
return "Not signed in. A person signs the command in: console login --url <the console's origin>";
|
|
115
|
+
case "environment_half":
|
|
116
|
+
return `Both CONSOLE_URL and CONSOLE_TOKEN are needed in the environment, and only ${failure.have} is set: set both, or neither.`;
|
|
117
|
+
case "no_login": {
|
|
118
|
+
const whom = failure.account === undefined ? "" : ` as ${failure.account}`;
|
|
119
|
+
const where = failure.source === "directory"
|
|
120
|
+
? `This directory works at ${failure.url}${whom}, where nobody is signed in here`
|
|
121
|
+
: `Nobody is signed in here at ${failure.url}${whom}`;
|
|
122
|
+
return `${where}: console login --url ${failure.url}. console logins lists the consoles signed in.`;
|
|
123
|
+
}
|
|
124
|
+
case "ambiguous":
|
|
125
|
+
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>.`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The console a command talks to, and with what (ADR-0027): the address from the flag, the
|
|
130
|
+
* environment, the directory or the default login, in that order, and then the token kept for
|
|
131
|
+
* that address and no other. A token never travels to a console it was not made at.
|
|
132
|
+
*/
|
|
79
133
|
async function open(io, values) {
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
134
|
+
const directory = await io.directory.load();
|
|
135
|
+
const flag = text(values, "url");
|
|
136
|
+
const envUrl = io.env.CONSOLE_URL;
|
|
137
|
+
const credentials = await loadCredentials(io);
|
|
138
|
+
const resolved = resolveLogin({
|
|
139
|
+
flagUrl: flag === undefined ? undefined : originOf(flag),
|
|
140
|
+
flagAccount: text(values, "account"),
|
|
141
|
+
envUrl: envUrl === undefined ? undefined : originOf(envUrl),
|
|
142
|
+
envToken: io.env.CONSOLE_TOKEN,
|
|
143
|
+
directoryUrl: directoryOrigin(directory),
|
|
144
|
+
directoryAccount: directory?.account,
|
|
145
|
+
credentials,
|
|
146
|
+
});
|
|
147
|
+
if (!resolved.ok) {
|
|
148
|
+
throw failed(wordsOf(resolved));
|
|
149
|
+
}
|
|
150
|
+
const { url, token } = resolved;
|
|
151
|
+
const client = clientOf(io, url, token);
|
|
152
|
+
const projectKey = async () => text(values, "project") ?? io.env.CONSOLE_PROJECT ?? directory?.project;
|
|
88
153
|
return {
|
|
89
154
|
client,
|
|
90
155
|
url,
|
|
91
156
|
token,
|
|
157
|
+
account: resolved.account,
|
|
158
|
+
via: resolved.via,
|
|
159
|
+
credentials,
|
|
92
160
|
json: on(values, "json"),
|
|
93
161
|
projectKey,
|
|
94
162
|
async project() {
|
|
@@ -128,6 +196,8 @@ async function bodyOf(io, values, given) {
|
|
|
128
196
|
}
|
|
129
197
|
return given === "-" ? io.readStdin() : given;
|
|
130
198
|
}
|
|
199
|
+
/** The hosts a token may travel to in the clear: this machine, and no other. */
|
|
200
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
131
201
|
function originOf(value) {
|
|
132
202
|
const url = URL.canParse(value) ? new URL(value) : undefined;
|
|
133
203
|
if (url === undefined ||
|
|
@@ -137,6 +207,10 @@ function originOf(value) {
|
|
|
137
207
|
url.hash !== "") {
|
|
138
208
|
throw misuse(`Not an origin: ${value}. Say the console's address without a path, such as https://console.example.com`);
|
|
139
209
|
}
|
|
210
|
+
// A token travels over https; in the clear only to this machine, where a console is run for development.
|
|
211
|
+
if (url.protocol === "http:" && !LOOPBACK_HOSTS.has(url.hostname)) {
|
|
212
|
+
throw misuse(`Not a safe origin: ${value}. A token travels over https only; http is for a console on this machine (localhost).`);
|
|
213
|
+
}
|
|
140
214
|
return url.origin;
|
|
141
215
|
}
|
|
142
216
|
function stateOf(value) {
|
|
@@ -321,35 +395,48 @@ async function settle(io, session, project, decision, seconds, shown = false) {
|
|
|
321
395
|
}
|
|
322
396
|
return outcome.answer === null ? EXIT.waiting : EXIT.ok;
|
|
323
397
|
}
|
|
324
|
-
/**
|
|
325
|
-
|
|
326
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Checks a token against the console, keeps it as the login at that console for that account,
|
|
400
|
+
* and says who it is. A token the login replaces is given up at the console on the way.
|
|
401
|
+
*/
|
|
402
|
+
async function keep(io, url, token, agent, asDefault) {
|
|
403
|
+
const me = await clientOf(io, url, token).me();
|
|
327
404
|
const person = me.person;
|
|
328
405
|
if (person === null) {
|
|
329
406
|
throw failed("The console does not know this token. Make one on your Agents page and try again.");
|
|
330
407
|
}
|
|
331
|
-
const
|
|
332
|
-
|
|
408
|
+
const credentials = (await loadCredentials(io)) ?? EMPTY_CREDENTIALS;
|
|
409
|
+
const replaced = loginsAt(credentials, url).find((login) => login.account === person.login);
|
|
410
|
+
if (replaced !== undefined && replaced.token !== token) {
|
|
411
|
+
await clientOf(io, url, replaced.token)
|
|
412
|
+
.giveUpToken()
|
|
413
|
+
.catch(() => undefined);
|
|
414
|
+
}
|
|
415
|
+
const next = withLogin(credentials, { url, account: person.login, token, agent, since: new Date(io.now()).toISOString() }, { default: asDefault });
|
|
416
|
+
const kept = await io.store.save(next);
|
|
417
|
+
const standing = next.default === url ? ", the default console here" : "";
|
|
418
|
+
io.stdout(`${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`);
|
|
333
419
|
return EXIT.ok;
|
|
334
420
|
}
|
|
335
421
|
const login = async (args, io) => {
|
|
336
|
-
const { values } = parse(args, { "token-stdin": { type: "boolean" }, agent: { type: "string" } }, false);
|
|
337
|
-
const
|
|
338
|
-
const wanted = text(values, "url") ?? io.env.CONSOLE_URL ??
|
|
422
|
+
const { values } = parse(args, { "token-stdin": { type: "boolean" }, agent: { type: "string" }, default: { type: "boolean" } }, false);
|
|
423
|
+
const credentials = await loadCredentials(io);
|
|
424
|
+
const wanted = text(values, "url") ?? io.env.CONSOLE_URL ?? credentials?.default;
|
|
339
425
|
if (wanted === undefined) {
|
|
340
426
|
throw misuse("Say where the console is: console login --url https://console.example.com");
|
|
341
427
|
}
|
|
342
428
|
const url = originOf(wanted);
|
|
429
|
+
const asDefault = on(values, "default");
|
|
343
430
|
if (on(values, "token-stdin")) {
|
|
344
431
|
const token = (await io.readStdin()).trim();
|
|
345
432
|
if (token.length === 0) {
|
|
346
433
|
throw failed("No token was given.");
|
|
347
434
|
}
|
|
348
|
-
return keep(io, url, token);
|
|
435
|
+
return keep(io, url, token, undefined, asDefault);
|
|
349
436
|
}
|
|
350
437
|
// A connection (ADR-0011): a code a person approves on the console's own pages, and the token
|
|
351
438
|
// handed over here once, never shown.
|
|
352
|
-
const client =
|
|
439
|
+
const client = clientOf(io, url);
|
|
353
440
|
// "<agent> on <machine>": the pages say the agent and keep the machine for a hover.
|
|
354
441
|
const agent = text(values, "agent") ?? agentOf(io.env) ?? "An agent";
|
|
355
442
|
const begun = await client.connect({
|
|
@@ -369,38 +456,94 @@ const login = async (args, io) => {
|
|
|
369
456
|
throw error;
|
|
370
457
|
}
|
|
371
458
|
if (claim.status === "connected") {
|
|
372
|
-
return keep(io, url, claim.secret, claim.token.name);
|
|
459
|
+
return keep(io, url, claim.secret, claim.token.name, asDefault);
|
|
373
460
|
}
|
|
374
461
|
}
|
|
375
462
|
};
|
|
376
463
|
const logout = async (args, io) => {
|
|
377
|
-
parse(args, {}, false);
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
|
|
464
|
+
const { values } = parse(args, {}, false);
|
|
465
|
+
const credentials = await loadCredentials(io);
|
|
466
|
+
const directory = await io.directory.load();
|
|
467
|
+
const envNote = () => {
|
|
468
|
+
if (io.env.CONSOLE_TOKEN !== undefined) {
|
|
469
|
+
io.stdout("CONSOLE_TOKEN in the environment still holds a token: unset it.\n");
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
if (credentials === undefined || credentials.logins.length === 0) {
|
|
382
473
|
io.stdout("Not signed in here: nothing to forget.\n");
|
|
474
|
+
envNote();
|
|
383
475
|
return EXIT.ok;
|
|
384
476
|
}
|
|
477
|
+
// Which login: the console the flag names, else the directory's, else the default, else the
|
|
478
|
+
// one console signed in to; and the account the same way, where the console has several.
|
|
479
|
+
const consoles = [...new Set(credentials.logins.map((login) => login.url))];
|
|
480
|
+
const flag = text(values, "url");
|
|
481
|
+
const url = flag === undefined
|
|
482
|
+
? (directoryOrigin(directory) ??
|
|
483
|
+
credentials.default ??
|
|
484
|
+
(consoles.length === 1 ? consoles[0] : undefined))
|
|
485
|
+
: originOf(flag);
|
|
486
|
+
if (url === undefined) {
|
|
487
|
+
throw failed(`Several consoles are signed in here; say which: console logout --url <origin>. console logins lists them.`);
|
|
488
|
+
}
|
|
489
|
+
const account = text(values, "account") ?? directory?.account;
|
|
490
|
+
const at = loginsAt(credentials, url);
|
|
491
|
+
const chosen = account === undefined ? at : at.filter((login) => login.account === account);
|
|
492
|
+
const [login] = chosen;
|
|
493
|
+
if (login === undefined) {
|
|
494
|
+
throw failed(`Nobody is signed in here at ${url}${account === undefined ? "" : ` as ${account}`}: nothing to forget. console logins lists the consoles signed in.`);
|
|
495
|
+
}
|
|
496
|
+
if (chosen.length > 1) {
|
|
497
|
+
throw failed(`Several accounts are signed in at ${url} (${chosen.map((kept) => kept.account ?? "?").join(", ")}); say which: console logout --url ${url} --account <login>`);
|
|
498
|
+
}
|
|
385
499
|
// The token is given up at the console first, so that it is good for nothing from now on;
|
|
386
500
|
// one the console no longer knows is gone already. A console out of reach leaves it to the
|
|
387
501
|
// Agents page, and the person is told so.
|
|
388
502
|
let gone = true;
|
|
389
503
|
try {
|
|
390
|
-
await
|
|
504
|
+
await clientOf(io, url, login.token).giveUpToken();
|
|
391
505
|
}
|
|
392
506
|
catch (error) {
|
|
393
507
|
if (!(error instanceof ApiError && error.code === "auth.required")) {
|
|
394
508
|
gone = false;
|
|
395
509
|
}
|
|
396
510
|
}
|
|
397
|
-
|
|
511
|
+
const next = withoutLogin(credentials, url, login.account);
|
|
512
|
+
if (next.logins.length === 0) {
|
|
513
|
+
await io.store.clear();
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
await io.store.save(next);
|
|
517
|
+
}
|
|
518
|
+
const whom = login.account === undefined ? "" : ` as ${login.account}`;
|
|
398
519
|
io.stdout(gone
|
|
399
|
-
?
|
|
400
|
-
: `Signed out here, but the console
|
|
401
|
-
|
|
402
|
-
|
|
520
|
+
? `Signed out of ${url}${whom}: the token is given up at the console and forgotten here.\n`
|
|
521
|
+
: `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`);
|
|
522
|
+
envNote();
|
|
523
|
+
return EXIT.ok;
|
|
524
|
+
};
|
|
525
|
+
/** The consoles this machine is signed in to, with the account at each; never a token. */
|
|
526
|
+
const logins = async (args, io) => {
|
|
527
|
+
const { values } = parse(args, {}, false);
|
|
528
|
+
const credentials = await loadCredentials(io);
|
|
529
|
+
const kept = (credentials?.logins ?? []).map((login) => ({
|
|
530
|
+
url: login.url,
|
|
531
|
+
account: login.account ?? null,
|
|
532
|
+
agent: login.agent ?? null,
|
|
533
|
+
since: login.since ?? null,
|
|
534
|
+
default: credentials?.default === login.url,
|
|
535
|
+
}));
|
|
536
|
+
if (on(values, "json")) {
|
|
537
|
+
io.stdout(`${JSON.stringify({ default: credentials?.default ?? null, logins: kept }, null, 2)}\n`);
|
|
538
|
+
return EXIT.ok;
|
|
403
539
|
}
|
|
540
|
+
if (kept.length === 0) {
|
|
541
|
+
io.stdout("Not signed in anywhere here: console login --url <the console's origin>\n");
|
|
542
|
+
return EXIT.ok;
|
|
543
|
+
}
|
|
544
|
+
io.stdout(`${kept
|
|
545
|
+
.map((login) => `${login.url} ${login.account ?? "(account unknown)"}${login.agent === null ? "" : ` ${login.agent}`}${login.since === null ? "" : ` since ${login.since.slice(0, 10)}`}${login.default ? " (default)" : ""}`)
|
|
546
|
+
.join("\n")}\n`);
|
|
404
547
|
return EXIT.ok;
|
|
405
548
|
};
|
|
406
549
|
/** The agent the command runs under, where its environment says so: what a connection is named by default. */
|
|
@@ -422,7 +565,31 @@ const whoami = async (args, io) => {
|
|
|
422
565
|
throw failed("The console does not know this token. Sign in again: console login");
|
|
423
566
|
}
|
|
424
567
|
const key = await session.projectKey();
|
|
425
|
-
|
|
568
|
+
const command = parseVersion(io.version);
|
|
569
|
+
const server = parseVersion(me.console?.version);
|
|
570
|
+
const verdict = command === undefined || server === undefined
|
|
571
|
+
? undefined
|
|
572
|
+
: judgeClient(command, server).verdict;
|
|
573
|
+
// A console from before the rule says nothing of itself; the command says so instead.
|
|
574
|
+
const consoleWords = me.console === undefined
|
|
575
|
+
? "the console does not say its version, so it is older than 0.1.18"
|
|
576
|
+
: `the console is ${me.console.version} and takes ${me.console.clientRange}`;
|
|
577
|
+
answer(io, session, {
|
|
578
|
+
...me,
|
|
579
|
+
console: me.console ?? null,
|
|
580
|
+
project: key ?? null,
|
|
581
|
+
command: { version: io.version ?? null, install: io.install ?? null },
|
|
582
|
+
}, () => [
|
|
583
|
+
`${person.login} (${person.role}) at ${me.workspace.name}, ${session.url}${key === undefined ? "; no project named here" : `; project ${key}`}`,
|
|
584
|
+
`This command is ${io.version ?? "of a version it does not know"}${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}; ${consoleWords}.`,
|
|
585
|
+
...(verdict === "behind"
|
|
586
|
+
? [`A newer command exists. Update it: ${updateWords(io, me.console?.version)}`]
|
|
587
|
+
: verdict === "ahead"
|
|
588
|
+
? [
|
|
589
|
+
`This command is newer than the console: what the console has not got yet is refused as not found. To match the console: ${updateWords(io, me.console?.version)}`,
|
|
590
|
+
]
|
|
591
|
+
: []),
|
|
592
|
+
].join("\n"));
|
|
426
593
|
return EXIT.ok;
|
|
427
594
|
};
|
|
428
595
|
const projects = async (args, io) => {
|
|
@@ -436,11 +603,25 @@ const projects = async (args, io) => {
|
|
|
436
603
|
};
|
|
437
604
|
const use = async (args, io) => {
|
|
438
605
|
const { values, positionals } = parse(args, {});
|
|
439
|
-
const key = theOne(positionals, "Say which project: console use <key>");
|
|
606
|
+
const key = theOne(positionals, "Say which project: console use <key> [--url <origin>] [--account <login>]");
|
|
440
607
|
const session = await open(io, values);
|
|
441
608
|
const project = await session.client.project(key).get();
|
|
442
|
-
|
|
443
|
-
|
|
609
|
+
// The directory names the console where that is not the default login here, or when asked,
|
|
610
|
+
// and the account where the console has several, or when asked; never a token.
|
|
611
|
+
const credentials = session.credentials ?? EMPTY_CREDENTIALS;
|
|
612
|
+
const pinUrl = text(values, "url") !== undefined ||
|
|
613
|
+
(session.via === "login" && credentials.default !== session.url);
|
|
614
|
+
const pinAccount = text(values, "account") !== undefined ||
|
|
615
|
+
(session.via === "login" && loginsAt(credentials, session.url).length > 1);
|
|
616
|
+
const kept = await io.directory.save({
|
|
617
|
+
project: project.key,
|
|
618
|
+
...(pinUrl ? { url: session.url } : {}),
|
|
619
|
+
...(pinAccount && session.account !== undefined ? { account: session.account } : {}),
|
|
620
|
+
});
|
|
621
|
+
const bound = pinUrl
|
|
622
|
+
? ` at ${session.url}${pinAccount && session.account !== undefined ? ` as ${session.account}` : ""}`
|
|
623
|
+
: "";
|
|
624
|
+
answer(io, session, project, () => `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.`);
|
|
444
625
|
return EXIT.ok;
|
|
445
626
|
};
|
|
446
627
|
const tasks = async (args, io) => {
|
|
@@ -448,12 +629,17 @@ const tasks = async (args, io) => {
|
|
|
448
629
|
state: { type: "string" },
|
|
449
630
|
open: { type: "boolean" },
|
|
450
631
|
mine: { type: "boolean" },
|
|
632
|
+
unassigned: { type: "boolean" },
|
|
451
633
|
category: { type: "string" },
|
|
452
634
|
goal: { type: "string" },
|
|
453
635
|
due: { type: "string" },
|
|
636
|
+
archived: { type: "boolean" },
|
|
454
637
|
...PAGE_OPTIONS,
|
|
455
638
|
}, false);
|
|
456
639
|
const state = stateOf(text(values, "state"));
|
|
640
|
+
if (on(values, "mine") && on(values, "unassigned")) {
|
|
641
|
+
throw misuse("Say one: --mine for the tasks assigned to you, or --unassigned for those assigned to nobody");
|
|
642
|
+
}
|
|
457
643
|
const page = pageOf(values);
|
|
458
644
|
const session = await open(io, values);
|
|
459
645
|
const project = await session.project();
|
|
@@ -474,9 +660,11 @@ const tasks = async (args, io) => {
|
|
|
474
660
|
...(state === undefined ? {} : { state }),
|
|
475
661
|
...(on(values, "open") || due === "overdue" ? { open: true } : {}),
|
|
476
662
|
...(assignee === undefined ? {} : { assignee }),
|
|
663
|
+
...(on(values, "unassigned") ? { unassigned: true } : {}),
|
|
477
664
|
...(text(values, "category") === undefined ? {} : { category: text(values, "category") }),
|
|
478
665
|
...(text(values, "goal") === undefined ? {} : { goal: text(values, "goal") }),
|
|
479
666
|
...(dueBy === undefined ? {} : { dueBy }),
|
|
667
|
+
...(on(values, "archived") ? { archived: true } : {}),
|
|
480
668
|
...page,
|
|
481
669
|
});
|
|
482
670
|
answer(io, session, found, () => found.items.length === 0
|
|
@@ -515,6 +703,60 @@ const skills = async (args, io) => {
|
|
|
515
703
|
answer(io, session, found, () => formatSkills(found, io.skillsDir));
|
|
516
704
|
return EXIT.ok;
|
|
517
705
|
};
|
|
706
|
+
/** How many pages of a skill, or of a file, the command follows before it stops: far above what one holds. */
|
|
707
|
+
const MAX_SKILL_PAGES = 64;
|
|
708
|
+
const skill = async (args, io) => {
|
|
709
|
+
const { values, positionals } = parse(args, { file: { type: "string" } });
|
|
710
|
+
const path = theOne(positionals, "Say which skill: console skill <path of its SKILL.md> [--file <path>]");
|
|
711
|
+
const session = await open(io, values);
|
|
712
|
+
const project = await session.project();
|
|
713
|
+
const file = text(values, "file");
|
|
714
|
+
if (file !== undefined) {
|
|
715
|
+
// The file whole, page after page, as the console reads it from the deployment.
|
|
716
|
+
const parts = [];
|
|
717
|
+
let offset;
|
|
718
|
+
let last;
|
|
719
|
+
for (let pages = 0; pages < MAX_SKILL_PAGES; pages += 1) {
|
|
720
|
+
last = await project.skillFile(file, offset === undefined ? {} : { offset });
|
|
721
|
+
if (last.status !== "ready" || last.file === null) {
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
parts.push(last.file.content);
|
|
725
|
+
if (last.file.nextOffset === null) {
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
offset = last.file.nextOffset;
|
|
729
|
+
}
|
|
730
|
+
if (last === undefined) {
|
|
731
|
+
throw failed("The file could not be read.");
|
|
732
|
+
}
|
|
733
|
+
const read = last;
|
|
734
|
+
const content = parts.join("");
|
|
735
|
+
answer(io, session, { ...read, content }, () => formatSkillFile(read, content));
|
|
736
|
+
return EXIT.ok;
|
|
737
|
+
}
|
|
738
|
+
// The skill whole: its pages until complete, the rules first, as load_skill lays them out.
|
|
739
|
+
const pages = [];
|
|
740
|
+
let cursor;
|
|
741
|
+
let last;
|
|
742
|
+
for (let n = 0; n < MAX_SKILL_PAGES; n += 1) {
|
|
743
|
+
last = await project.skill(path, cursor);
|
|
744
|
+
if (last.status !== "ready" || last.skill === null) {
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
pages.push(last.skill);
|
|
748
|
+
if (last.skill.complete || last.skill.nextCursor === null) {
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
cursor = last.skill.nextCursor;
|
|
752
|
+
}
|
|
753
|
+
if (last === undefined) {
|
|
754
|
+
throw failed("The skill could not be read.");
|
|
755
|
+
}
|
|
756
|
+
const loaded = last;
|
|
757
|
+
answer(io, session, { ...loaded, pages }, () => formatSkillLoad(loaded, pages));
|
|
758
|
+
return EXIT.ok;
|
|
759
|
+
};
|
|
518
760
|
/** A version number given as a flag, or nothing; anything else is not understood. */
|
|
519
761
|
function versionOf(given) {
|
|
520
762
|
if (given === undefined) {
|
|
@@ -685,6 +927,36 @@ const editTask = async (args, io) => {
|
|
|
685
927
|
answer(io, session, changed, () => formatTask(changed));
|
|
686
928
|
return EXIT.ok;
|
|
687
929
|
};
|
|
930
|
+
/** Puts a task away, or every done or dropped task of a goal (ADR-0029); the task by its number or its id. */
|
|
931
|
+
const taskArchive = async (args, io) => {
|
|
932
|
+
const { values, positionals } = parse(args, { goal: { type: "string" } });
|
|
933
|
+
const goal = text(values, "goal");
|
|
934
|
+
if (positionals.length > 1 || (positionals.length === 0) === (goal === undefined)) {
|
|
935
|
+
throw misuse("Say which: console task archive <number|id>, or console task archive --goal <id> for every done or dropped task of a goal");
|
|
936
|
+
}
|
|
937
|
+
const session = await open(io, values);
|
|
938
|
+
const project = await session.project();
|
|
939
|
+
if (goal !== undefined) {
|
|
940
|
+
const { archived } = await project.archiveTasksOfGoal(goal);
|
|
941
|
+
answer(io, session, { archived }, () => `${archived} ${archived === 1 ? "task" : "tasks"} of the goal put away.`);
|
|
942
|
+
return EXIT.ok;
|
|
943
|
+
}
|
|
944
|
+
const found = await project.task(positionals[0] ?? "");
|
|
945
|
+
const archived = await project.archiveTask(found.id);
|
|
946
|
+
answer(io, session, archived, () => formatTaskLine(archived));
|
|
947
|
+
return EXIT.ok;
|
|
948
|
+
};
|
|
949
|
+
/** Brings a task back from the archive. */
|
|
950
|
+
const taskRestore = async (args, io) => {
|
|
951
|
+
const { values, positionals } = parse(args, {});
|
|
952
|
+
const ref = theOne(positionals, "Say which task: console task restore <number|id>");
|
|
953
|
+
const session = await open(io, values);
|
|
954
|
+
const project = await session.project();
|
|
955
|
+
const found = await project.task(ref);
|
|
956
|
+
const restored = await project.restoreTask(found.id);
|
|
957
|
+
answer(io, session, restored, () => formatTaskLine(restored));
|
|
958
|
+
return EXIT.ok;
|
|
959
|
+
};
|
|
688
960
|
const task = async (args, io) => {
|
|
689
961
|
if (args[0] === "new") {
|
|
690
962
|
return newTask(args.slice(1), io);
|
|
@@ -692,6 +964,12 @@ const task = async (args, io) => {
|
|
|
692
964
|
if (args[0] === "edit") {
|
|
693
965
|
return editTask(args.slice(1), io);
|
|
694
966
|
}
|
|
967
|
+
if (args[0] === "archive") {
|
|
968
|
+
return taskArchive(args.slice(1), io);
|
|
969
|
+
}
|
|
970
|
+
if (args[0] === "restore") {
|
|
971
|
+
return taskRestore(args.slice(1), io);
|
|
972
|
+
}
|
|
695
973
|
const { values, positionals } = parse(args, {});
|
|
696
974
|
const ref = theOne(positionals, "Say which task: console task <number|id>");
|
|
697
975
|
const session = await open(io, values);
|
|
@@ -1077,25 +1355,40 @@ const restore = async (args, io) => {
|
|
|
1077
1355
|
return EXIT.ok;
|
|
1078
1356
|
};
|
|
1079
1357
|
const goals = async (args, io) => {
|
|
1080
|
-
const { values } = parse(args, {}, false);
|
|
1358
|
+
const { values } = parse(args, { category: { type: "string" }, state: { type: "string" } }, false);
|
|
1359
|
+
const state = goalStateOf(text(values, "state"));
|
|
1360
|
+
const category = text(values, "category");
|
|
1081
1361
|
const session = await open(io, values);
|
|
1082
1362
|
const project = await session.project();
|
|
1083
|
-
const found = await project.goals(
|
|
1363
|
+
const found = await project.goals({
|
|
1364
|
+
...(state === undefined ? {} : { state }),
|
|
1365
|
+
...(category === undefined ? {} : { category }),
|
|
1366
|
+
});
|
|
1084
1367
|
answer(io, session, found, () => found.items.length === 0
|
|
1085
|
-
?
|
|
1368
|
+
? state === undefined && category === undefined
|
|
1369
|
+
? "No goals. An owner sets one: console goal new <title> --due <day>"
|
|
1370
|
+
: "No goal matches."
|
|
1086
1371
|
: found.items.map(formatGoalLine).join("\n"));
|
|
1087
1372
|
return EXIT.ok;
|
|
1088
1373
|
};
|
|
1089
1374
|
const GOAL_FIELDS = {
|
|
1090
1375
|
state: { type: "string" },
|
|
1376
|
+
category: { type: "string" },
|
|
1377
|
+
starts: { type: "string" },
|
|
1091
1378
|
due: { type: "string" },
|
|
1092
1379
|
body: { type: "string" },
|
|
1093
1380
|
file: { type: "string" },
|
|
1094
1381
|
};
|
|
1382
|
+
/** A category's key as a flag names one, or `null` for `none` where the field may be cleared. */
|
|
1383
|
+
function categoryOf(value, clearable = false) {
|
|
1384
|
+
return clearable && value === "none" ? null : value;
|
|
1385
|
+
}
|
|
1095
1386
|
const newGoal = async (args, io) => {
|
|
1096
1387
|
const { values, positionals } = parse(args, GOAL_FIELDS);
|
|
1097
1388
|
const title = theOne(positionals, 'Say the title: console goal new "<title>" [--due <day>]');
|
|
1098
1389
|
const state = goalStateOf(text(values, "state"));
|
|
1390
|
+
const category = categoryOf(text(values, "category"));
|
|
1391
|
+
const startsOn = dayOf(io, text(values, "starts"));
|
|
1099
1392
|
const dueOn = dayOf(io, text(values, "due"));
|
|
1100
1393
|
const body = await bodyOf(io, values, text(values, "body"));
|
|
1101
1394
|
const session = await open(io, values);
|
|
@@ -1104,6 +1397,8 @@ const newGoal = async (args, io) => {
|
|
|
1104
1397
|
title,
|
|
1105
1398
|
...(body === undefined ? {} : { body }),
|
|
1106
1399
|
...(state === undefined ? {} : { state }),
|
|
1400
|
+
...(category === undefined ? {} : { category }),
|
|
1401
|
+
...(startsOn === undefined ? {} : { startsOn }),
|
|
1107
1402
|
...(dueOn === undefined ? {} : { dueOn }),
|
|
1108
1403
|
});
|
|
1109
1404
|
answer(io, session, made, () => `Goal set: ${made.title}\nid: ${made.id}`);
|
|
@@ -1114,16 +1409,20 @@ const editGoal = async (args, io) => {
|
|
|
1114
1409
|
const id = theOne(positionals, "Say which goal: console goal edit <id> --state <state> ...");
|
|
1115
1410
|
const title = text(values, "title");
|
|
1116
1411
|
const state = goalStateOf(text(values, "state"));
|
|
1412
|
+
const category = categoryOf(text(values, "category"), true);
|
|
1413
|
+
const startsOn = dayOf(io, text(values, "starts"), true);
|
|
1117
1414
|
const dueOn = dayOf(io, text(values, "due"), true);
|
|
1118
1415
|
const body = await bodyOf(io, values, text(values, "body"));
|
|
1119
1416
|
const patch = {
|
|
1120
1417
|
...(title === undefined ? {} : { title }),
|
|
1121
1418
|
...(body === undefined ? {} : { body }),
|
|
1122
1419
|
...(state === undefined ? {} : { state }),
|
|
1420
|
+
...(category === undefined ? {} : { category }),
|
|
1421
|
+
...(startsOn === undefined ? {} : { startsOn }),
|
|
1123
1422
|
...(dueOn === undefined ? {} : { dueOn }),
|
|
1124
1423
|
};
|
|
1125
1424
|
if (Object.keys(patch).length === 0) {
|
|
1126
|
-
throw misuse("Say what to change: --title, --body, --state or --due;
|
|
1425
|
+
throw misuse("Say what to change: --title, --body, --state, --category, --starts or --due; none clears a category or a day.");
|
|
1127
1426
|
}
|
|
1128
1427
|
const session = await open(io, values);
|
|
1129
1428
|
const project = await session.project();
|
|
@@ -1394,6 +1693,31 @@ const editProject = async (args, io) => {
|
|
|
1394
1693
|
answer(io, session, changed, () => formatProject(changed));
|
|
1395
1694
|
return EXIT.ok;
|
|
1396
1695
|
};
|
|
1696
|
+
/** The pack token (ADR-0028): read from standard input and never from an argument, so that no shell keeps it. */
|
|
1697
|
+
const projectPackToken = async (args, io) => {
|
|
1698
|
+
const { values, positionals } = parse(args, {
|
|
1699
|
+
stdin: { type: "boolean" },
|
|
1700
|
+
remove: { type: "boolean" },
|
|
1701
|
+
});
|
|
1702
|
+
const key = theOne(positionals, "Say which project: console project pack-token <key> --stdin, or console project pack-token <key> --remove");
|
|
1703
|
+
if (on(values, "stdin") === on(values, "remove")) {
|
|
1704
|
+
throw misuse("Say one of --stdin, which reads the token from standard input, and --remove, which takes it away.");
|
|
1705
|
+
}
|
|
1706
|
+
const session = await open(io, values);
|
|
1707
|
+
const project = session.client.project(key);
|
|
1708
|
+
if (on(values, "remove")) {
|
|
1709
|
+
const changed = await project.removePackToken();
|
|
1710
|
+
answer(io, session, changed, () => formatProject(changed));
|
|
1711
|
+
return EXIT.ok;
|
|
1712
|
+
}
|
|
1713
|
+
const token = (await io.readStdin()).trim();
|
|
1714
|
+
if (token.length === 0) {
|
|
1715
|
+
throw failed("No token was given on standard input.");
|
|
1716
|
+
}
|
|
1717
|
+
const changed = await project.setPackToken({ token });
|
|
1718
|
+
answer(io, session, changed, () => formatProject(changed));
|
|
1719
|
+
return EXIT.ok;
|
|
1720
|
+
};
|
|
1397
1721
|
const projectIcon = async (args, io) => {
|
|
1398
1722
|
const { values, positionals } = parse(args, { remove: { type: "boolean" } });
|
|
1399
1723
|
const [key, file] = positionals;
|
|
@@ -1423,8 +1747,11 @@ const project = async (args, io) => {
|
|
|
1423
1747
|
if (args[0] === "icon") {
|
|
1424
1748
|
return projectIcon(args.slice(1), io);
|
|
1425
1749
|
}
|
|
1750
|
+
if (args[0] === "pack-token") {
|
|
1751
|
+
return projectPackToken(args.slice(1), io);
|
|
1752
|
+
}
|
|
1426
1753
|
const { values, positionals } = parse(args, {});
|
|
1427
|
-
const key = theOne(positionals, "Say which project: console project <key>; or console project new, edit or
|
|
1754
|
+
const key = theOne(positionals, "Say which project: console project <key>; or console project new, edit, icon or pack-token");
|
|
1428
1755
|
const session = await open(io, values);
|
|
1429
1756
|
const found = await session.client.project(key).get();
|
|
1430
1757
|
answer(io, session, found, () => formatProject(found));
|
|
@@ -1440,18 +1767,51 @@ const members = async (args, io) => {
|
|
|
1440
1767
|
: found.items.map(formatMemberLine).join("\n"));
|
|
1441
1768
|
return EXIT.ok;
|
|
1442
1769
|
};
|
|
1770
|
+
/** The keys --project names with commas, each once, or nothing when it names one project or none. */
|
|
1771
|
+
function projectKeysOf(value) {
|
|
1772
|
+
if (value === undefined || !value.includes(",")) {
|
|
1773
|
+
return undefined;
|
|
1774
|
+
}
|
|
1775
|
+
const keys = [...new Set(value.split(",").map((key) => key.trim()))].filter((key) => key !== "");
|
|
1776
|
+
if (keys.length === 0) {
|
|
1777
|
+
throw misuse("Say which projects: --project <key>[,<key>...]");
|
|
1778
|
+
}
|
|
1779
|
+
return keys;
|
|
1780
|
+
}
|
|
1443
1781
|
const member = async (args, io) => {
|
|
1444
1782
|
const [verb, ...rest] = args;
|
|
1445
1783
|
if (verb === "add") {
|
|
1446
1784
|
const { values, positionals } = parse(rest, { role: { type: "string" } });
|
|
1447
|
-
const login = theOne(positionals, "Say whom: console member add <login> [--role owner|member]");
|
|
1785
|
+
const login = theOne(positionals, "Say whom: console member add <login> [--role owner|member] [--project <key>[,<key>...]]");
|
|
1448
1786
|
const role = text(values, "role");
|
|
1449
1787
|
const session = await open(io, values);
|
|
1450
|
-
const
|
|
1451
|
-
const added = await project.addMember({
|
|
1788
|
+
const input = {
|
|
1452
1789
|
personId: (await personOf(session, login)).id,
|
|
1453
1790
|
...(role === undefined ? {} : { role: projectRoleOf(role) }),
|
|
1454
|
-
}
|
|
1791
|
+
};
|
|
1792
|
+
// Several projects at once, where --project names them with commas: the person is listed
|
|
1793
|
+
// in each in turn, one listed already is said so, and the rest go on.
|
|
1794
|
+
const keys = projectKeysOf(text(values, "project"));
|
|
1795
|
+
if (keys !== undefined) {
|
|
1796
|
+
const added = [];
|
|
1797
|
+
for (const key of keys) {
|
|
1798
|
+
try {
|
|
1799
|
+
added.push({ project: key, member: await session.client.project(key).addMember(input) });
|
|
1800
|
+
}
|
|
1801
|
+
catch (error) {
|
|
1802
|
+
if (!(error instanceof ApiError && error.code === "member.exists")) {
|
|
1803
|
+
throw error;
|
|
1804
|
+
}
|
|
1805
|
+
added.push({ project: key, member: null });
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
answer(io, session, added, () => added
|
|
1809
|
+
.map(({ project, member }) => `${project} ${member === null ? `${login} is listed already` : formatMemberLine(member)}`)
|
|
1810
|
+
.join("\n"));
|
|
1811
|
+
return EXIT.ok;
|
|
1812
|
+
}
|
|
1813
|
+
const project = await session.project();
|
|
1814
|
+
const added = await project.addMember(input);
|
|
1455
1815
|
answer(io, session, added, () => formatMemberLine(added));
|
|
1456
1816
|
return EXIT.ok;
|
|
1457
1817
|
}
|
|
@@ -1477,7 +1837,7 @@ const member = async (args, io) => {
|
|
|
1477
1837
|
answer(io, session, { removed: found.login }, () => `${found.login} is no longer listed in the project.`);
|
|
1478
1838
|
return EXIT.ok;
|
|
1479
1839
|
}
|
|
1480
|
-
throw misuse("Say what to do: console member add <login> [--role owner|member], console member role <login> <owner|member>, or console member remove <login>");
|
|
1840
|
+
throw misuse("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>");
|
|
1481
1841
|
};
|
|
1482
1842
|
const category = async (args, io) => {
|
|
1483
1843
|
const [verb, ...rest] = args;
|
|
@@ -1526,6 +1886,7 @@ const category = async (args, io) => {
|
|
|
1526
1886
|
};
|
|
1527
1887
|
const COMMANDS = {
|
|
1528
1888
|
login,
|
|
1889
|
+
logins,
|
|
1529
1890
|
logout,
|
|
1530
1891
|
whoami,
|
|
1531
1892
|
serve,
|
|
@@ -1538,6 +1899,7 @@ const COMMANDS = {
|
|
|
1538
1899
|
goal,
|
|
1539
1900
|
categories,
|
|
1540
1901
|
skills,
|
|
1902
|
+
skill,
|
|
1541
1903
|
rules,
|
|
1542
1904
|
people,
|
|
1543
1905
|
person,
|
|
@@ -1577,9 +1939,70 @@ function help(io, name) {
|
|
|
1577
1939
|
io.stdout(found);
|
|
1578
1940
|
return EXIT.ok;
|
|
1579
1941
|
}
|
|
1942
|
+
/**
|
|
1943
|
+
* A client of the console at `url`, naming the command on every request (ADR-0025). A redirect
|
|
1944
|
+
* is not followed: the token goes to the address the command resolved, and nowhere it is sent on to.
|
|
1945
|
+
*/
|
|
1946
|
+
function clientOf(io, url, token) {
|
|
1947
|
+
return createClient({
|
|
1948
|
+
baseUrl: url,
|
|
1949
|
+
fetch: (input, init) => io.fetch(input, { ...init, redirect: "manual" }),
|
|
1950
|
+
...(token === undefined ? {} : { token }),
|
|
1951
|
+
...(io.userAgent === undefined ? {} : { userAgent: io.userAgent }),
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
/** The command that puts the console's version where this command is installed. */
|
|
1955
|
+
function updateWords(io, version) {
|
|
1956
|
+
const wanted = version ?? "latest";
|
|
1957
|
+
if (io.install === undefined) {
|
|
1958
|
+
return `npm install -g @skillcdn/console@${wanted}`;
|
|
1959
|
+
}
|
|
1960
|
+
const command = updateCommandFor(io.install, wanted);
|
|
1961
|
+
return io.install.kind === "project" ? `${command} (in ${io.install.root})` : command;
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* What the command says of the versions once the console answered: that a newer command exists,
|
|
1965
|
+
* or that this one is newer than the console; once a day for one pair of versions, on stderr,
|
|
1966
|
+
* so that the console's JSON stays what it is. What is out of the range never gets here: the
|
|
1967
|
+
* console refused it, and the refusal says what to do.
|
|
1968
|
+
*/
|
|
1969
|
+
async function noticeVersions(io, seen) {
|
|
1970
|
+
const command = parseVersion(io.version);
|
|
1971
|
+
const server = parseVersion(seen.version);
|
|
1972
|
+
if (io.version === undefined ||
|
|
1973
|
+
seen.version === undefined ||
|
|
1974
|
+
command === undefined ||
|
|
1975
|
+
server === undefined) {
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
const { verdict } = judgeClient(command, server);
|
|
1979
|
+
if (verdict !== "behind" && verdict !== "ahead") {
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
const previous = await io.notices?.load();
|
|
1983
|
+
const now = io.now();
|
|
1984
|
+
if (previous !== undefined &&
|
|
1985
|
+
previous.command === io.version &&
|
|
1986
|
+
previous.console === seen.version &&
|
|
1987
|
+
now - Date.parse(previous.shownAt) < DAY_MS) {
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
io.stderr(verdict === "behind"
|
|
1991
|
+
? `A newer command exists: the console runs ${seen.version}, this command is ${io.version}. Update it: ${updateWords(io, seen.version)}\n`
|
|
1992
|
+
: `This command (${io.version}) is newer than the console (${seen.version}, which takes ${seen.range ?? "an older line"}): what the console has not got yet is refused as not found. The console's operator updates it; to match the console meanwhile: ${updateWords(io, seen.version)}\n`);
|
|
1993
|
+
await io.notices?.save({
|
|
1994
|
+
command: io.version,
|
|
1995
|
+
console: seen.version,
|
|
1996
|
+
shownAt: new Date(now).toISOString(),
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1580
1999
|
/** What a refusal of the console is followed by, when there is something to do about it. */
|
|
1581
|
-
function hintFor(error) {
|
|
2000
|
+
function hintFor(error, io, seen) {
|
|
1582
2001
|
switch (error.code) {
|
|
2002
|
+
case "client.outdated":
|
|
2003
|
+
return `Update the command: ${updateWords(io, seen.version)}\n`;
|
|
2004
|
+
case "client.too_new":
|
|
2005
|
+
return `Install the console's version: ${updateWords(io, seen.version)}; or ask the console's operator to update the console.\n`;
|
|
1583
2006
|
case "auth.required":
|
|
1584
2007
|
return "The token is not one the console knows, any more or at all. A person signs the command in again: console login\n";
|
|
1585
2008
|
case "project.not_found":
|
|
@@ -1598,6 +2021,10 @@ function hintFor(error) {
|
|
|
1598
2021
|
return "The due day is before the start. Say a due day on or after it.\n";
|
|
1599
2022
|
case "goal.not_found":
|
|
1600
2023
|
return "No goal of this project has that id. console goals lists them.\n";
|
|
2024
|
+
case "goal.invalid_category":
|
|
2025
|
+
return "No category has that key. console categories lists them.\n";
|
|
2026
|
+
case "goal.invalid_dates":
|
|
2027
|
+
return "The target day is before the start. Say a target day on or after it.\n";
|
|
1601
2028
|
case "auth.forbidden":
|
|
1602
2029
|
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";
|
|
1603
2030
|
case "project.key_taken":
|
|
@@ -1637,9 +2064,21 @@ function hintFor(error) {
|
|
|
1637
2064
|
/** Runs the command and answers with its exit code. What it prints goes through `io`. */
|
|
1638
2065
|
export async function runCli(argv, io) {
|
|
1639
2066
|
const [name, ...rest] = argv;
|
|
2067
|
+
// Every answer of the console says its version and the range it takes; the command reads
|
|
2068
|
+
// them off the answers it gets anyway, and asks nothing more (ADR-0025).
|
|
2069
|
+
const seen = {};
|
|
2070
|
+
const watched = {
|
|
2071
|
+
...io,
|
|
2072
|
+
fetch: async (input, init) => {
|
|
2073
|
+
const response = await io.fetch(input, init);
|
|
2074
|
+
seen.version = response.headers.get(VERSION_HEADER) ?? seen.version;
|
|
2075
|
+
seen.range = response.headers.get(CLIENT_RANGE_HEADER) ?? seen.range;
|
|
2076
|
+
return response;
|
|
2077
|
+
},
|
|
2078
|
+
};
|
|
1640
2079
|
try {
|
|
1641
2080
|
if (name === "--version" || name === "-v" || name === "version") {
|
|
1642
|
-
io.stdout(`console ${io.version ?? "(version unknown)"} (@skillcdn/console)\n`);
|
|
2081
|
+
io.stdout(`console ${io.version ?? "(version unknown)"} (@skillcdn/console)${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}\n`);
|
|
1643
2082
|
return EXIT.ok;
|
|
1644
2083
|
}
|
|
1645
2084
|
if (name === undefined || name === "help" || name === "--help" || name === "-h") {
|
|
@@ -1652,7 +2091,11 @@ export async function runCli(argv, io) {
|
|
|
1652
2091
|
if (command === undefined) {
|
|
1653
2092
|
throw misuse(`Not a command: ${name}. Run: console help`);
|
|
1654
2093
|
}
|
|
1655
|
-
|
|
2094
|
+
const exit = await command(rest, watched);
|
|
2095
|
+
if (name !== "whoami") {
|
|
2096
|
+
await noticeVersions(io, seen);
|
|
2097
|
+
}
|
|
2098
|
+
return exit;
|
|
1656
2099
|
}
|
|
1657
2100
|
catch (error) {
|
|
1658
2101
|
if (error instanceof CliExit) {
|
|
@@ -1660,8 +2103,10 @@ export async function runCli(argv, io) {
|
|
|
1660
2103
|
return error.exit;
|
|
1661
2104
|
}
|
|
1662
2105
|
if (error instanceof ApiError) {
|
|
1663
|
-
io.stderr(`${error.code}: ${error.message}\n${hintFor(error)}`);
|
|
1664
|
-
return
|
|
2106
|
+
io.stderr(`${error.code}: ${error.message}\n${hintFor(error, io, seen)}`);
|
|
2107
|
+
return error.code === "client.outdated" || error.code === "client.too_new"
|
|
2108
|
+
? EXIT.incompatible
|
|
2109
|
+
: EXIT.failed;
|
|
1665
2110
|
}
|
|
1666
2111
|
throw error;
|
|
1667
2112
|
}
|