@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/src/cli/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type FetchLike,
|
|
8
8
|
type ProjectClient,
|
|
9
9
|
} from "../client.js";
|
|
10
|
+
import { CLIENT_RANGE_HEADER, judgeClient, parseVersion, VERSION_HEADER } from "../compat.js";
|
|
10
11
|
import { addDays, dayOf as dayOfDate, isDay } from "../dates.js";
|
|
11
12
|
import { DEFAULT_PAGE_LIMIT, LIST_LIMIT, MAX_OPTIONS, MIN_OPTIONS } from "../limits.js";
|
|
12
13
|
import { fillRules, RULES_PATH, RULES_TITLE } from "../rules.js";
|
|
@@ -14,7 +15,11 @@ import {
|
|
|
14
15
|
hostOfLogin,
|
|
15
16
|
type RestClaim,
|
|
16
17
|
type RestDecision,
|
|
18
|
+
type RestMember,
|
|
17
19
|
type RestPerson,
|
|
20
|
+
type RestSkillFile,
|
|
21
|
+
type RestSkillLoad,
|
|
22
|
+
type RestSkillPage,
|
|
18
23
|
type RestTaskPatch,
|
|
19
24
|
} from "../schemas.js";
|
|
20
25
|
import {
|
|
@@ -36,8 +41,18 @@ import {
|
|
|
36
41
|
type TaskState,
|
|
37
42
|
} from "../vocabulary.js";
|
|
38
43
|
import { loadWebRoot, type WebRoot, WebRootError } from "../web.js";
|
|
39
|
-
import
|
|
40
|
-
|
|
44
|
+
import {
|
|
45
|
+
type CredentialStore,
|
|
46
|
+
type Credentials,
|
|
47
|
+
CredentialsError,
|
|
48
|
+
EMPTY_CREDENTIALS,
|
|
49
|
+
loginsAt,
|
|
50
|
+
type Resolution,
|
|
51
|
+
resolveLogin,
|
|
52
|
+
withLogin,
|
|
53
|
+
withoutLogin,
|
|
54
|
+
} from "./credentials.js";
|
|
55
|
+
import type { DirectorySettings, DirectoryStore } from "./directory.js";
|
|
41
56
|
import {
|
|
42
57
|
formatAnswer,
|
|
43
58
|
formatCategoryLine,
|
|
@@ -59,6 +74,8 @@ import {
|
|
|
59
74
|
formatRules,
|
|
60
75
|
formatRun,
|
|
61
76
|
formatRunLine,
|
|
77
|
+
formatSkillFile,
|
|
78
|
+
formatSkillLoad,
|
|
62
79
|
formatSkills,
|
|
63
80
|
formatTask,
|
|
64
81
|
formatTaskLine,
|
|
@@ -67,6 +84,8 @@ import {
|
|
|
67
84
|
formatWork,
|
|
68
85
|
} from "./format.js";
|
|
69
86
|
import { COMMAND_HELP, helpFor, usage } from "./help.js";
|
|
87
|
+
import { describeInstall, type Install, updateCommandFor } from "./install.js";
|
|
88
|
+
import type { NoticeStore } from "./notices.js";
|
|
70
89
|
import { createServeHandler, DEFAULT_SERVE_PORT, type Listen } from "./serve.js";
|
|
71
90
|
|
|
72
91
|
// The `console` command (docs/specs/cli.md): the agent's side of the console. A thin client of
|
|
@@ -84,8 +103,13 @@ export const EXIT = {
|
|
|
84
103
|
usage: 2,
|
|
85
104
|
/** The decision still waits for a person. */
|
|
86
105
|
waiting: 3,
|
|
106
|
+
/** The command and the console do not go together (ADR-0025): stderr says which to update, and how. */
|
|
107
|
+
incompatible: 4,
|
|
87
108
|
} as const;
|
|
88
109
|
|
|
110
|
+
/** A day, for how often the command repeats what it noticed of the versions. */
|
|
111
|
+
const DAY_MS = 86_400_000;
|
|
112
|
+
|
|
89
113
|
/** How long one request for a decision's answer asks the console to hold it: what the server allows. */
|
|
90
114
|
const WAIT_STEP_SECONDS = 50;
|
|
91
115
|
/** A breath between two such requests, should the console answer at once. */
|
|
@@ -117,6 +141,12 @@ export interface CliIo {
|
|
|
117
141
|
readonly now: () => number;
|
|
118
142
|
/** The version of the package the command runs from, when the entry point knows it. */
|
|
119
143
|
readonly version?: string | undefined;
|
|
144
|
+
/** What the command calls itself on every request (ADR-0025); nothing when it does not know its version. */
|
|
145
|
+
readonly userAgent?: string | undefined;
|
|
146
|
+
/** Where the command is installed, so that an update is told the way it takes effect; nothing when unknown. */
|
|
147
|
+
readonly install?: Install | undefined;
|
|
148
|
+
/** What the command last said of the versions; left out, it says it every time. */
|
|
149
|
+
readonly notices?: NoticeStore | undefined;
|
|
120
150
|
/** Where the console's own skills are on this machine, shipped with the package; nothing when they are not there. */
|
|
121
151
|
readonly skillsDir?: string | undefined;
|
|
122
152
|
/** How `serve` listens on the loopback (ADR-0014); left out, the command cannot serve. */
|
|
@@ -144,6 +174,7 @@ type OptionSpec = Readonly<
|
|
|
144
174
|
const GLOBAL_OPTIONS: OptionSpec = {
|
|
145
175
|
json: { type: "boolean" },
|
|
146
176
|
url: { type: "string" },
|
|
177
|
+
account: { type: "string" },
|
|
147
178
|
project: { type: "string" },
|
|
148
179
|
};
|
|
149
180
|
|
|
@@ -180,6 +211,12 @@ interface Session {
|
|
|
180
211
|
readonly url: string;
|
|
181
212
|
/** The token the client holds: what `serve` carries the page's requests with. */
|
|
182
213
|
readonly token: string;
|
|
214
|
+
/** The person's login at the console, when the login kept knows it. */
|
|
215
|
+
readonly account: string | undefined;
|
|
216
|
+
/** Where the token came from: the environment's pair, or a login kept here. */
|
|
217
|
+
readonly via: "environment" | "login";
|
|
218
|
+
/** What the command keeps, as it was read for this command. */
|
|
219
|
+
readonly credentials: Credentials | undefined;
|
|
183
220
|
readonly json: boolean;
|
|
184
221
|
/** The project the command works in: named by `--project`, the environment, or the working directory. */
|
|
185
222
|
project(): Promise<ProjectClient>;
|
|
@@ -190,23 +227,85 @@ interface Session {
|
|
|
190
227
|
const NO_PROJECT =
|
|
191
228
|
"Say which project: --project <key>, CONSOLE_PROJECT in the environment, or console use <key> once in this directory. console projects lists the projects you may work in.";
|
|
192
229
|
|
|
193
|
-
/**
|
|
230
|
+
/** What the command keeps, or nothing; a file that cannot be read stops the command, and is never written over. */
|
|
231
|
+
async function loadCredentials(io: CliIo): Promise<Credentials | undefined> {
|
|
232
|
+
try {
|
|
233
|
+
return await io.store.load();
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof CredentialsError) {
|
|
236
|
+
throw failed(
|
|
237
|
+
`${error.message} Move it away, then sign in again: console login --url <the console's origin>`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The console a directory is bound to, as an origin, when it names one. */
|
|
245
|
+
function directoryOrigin(settings: DirectorySettings | undefined): string | undefined {
|
|
246
|
+
if (settings?.url === undefined) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return originOf(settings.url);
|
|
251
|
+
} catch {
|
|
252
|
+
throw failed(`.skillcdn-console.json names a console that is not an origin: ${settings.url}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Why no console could be talked to, for the person who reads it. */
|
|
257
|
+
function wordsOf(failure: Exclude<Resolution, { ok: true }>): string {
|
|
258
|
+
switch (failure.reason) {
|
|
259
|
+
case "nothing":
|
|
260
|
+
return "Not signed in. A person signs the command in: console login --url <the console's origin>";
|
|
261
|
+
case "environment_half":
|
|
262
|
+
return `Both CONSOLE_URL and CONSOLE_TOKEN are needed in the environment, and only ${failure.have} is set: set both, or neither.`;
|
|
263
|
+
case "no_login": {
|
|
264
|
+
const whom = failure.account === undefined ? "" : ` as ${failure.account}`;
|
|
265
|
+
const where =
|
|
266
|
+
failure.source === "directory"
|
|
267
|
+
? `This directory works at ${failure.url}${whom}, where nobody is signed in here`
|
|
268
|
+
: `Nobody is signed in here at ${failure.url}${whom}`;
|
|
269
|
+
return `${where}: console login --url ${failure.url}. console logins lists the consoles signed in.`;
|
|
270
|
+
}
|
|
271
|
+
case "ambiguous":
|
|
272
|
+
return `Several accounts are signed in at ${failure.url} (${failure.accounts.join(", ")}): say which with --account <login>, or bind this directory to one with console use <key> --account <login>.`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The console a command talks to, and with what (ADR-0027): the address from the flag, the
|
|
278
|
+
* environment, the directory or the default login, in that order, and then the token kept for
|
|
279
|
+
* that address and no other. A token never travels to a console it was not made at.
|
|
280
|
+
*/
|
|
194
281
|
async function open(io: CliIo, values: Values): Promise<Session> {
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
)
|
|
282
|
+
const directory = await io.directory.load();
|
|
283
|
+
const flag = text(values, "url");
|
|
284
|
+
const envUrl = io.env.CONSOLE_URL;
|
|
285
|
+
const credentials = await loadCredentials(io);
|
|
286
|
+
const resolved = resolveLogin({
|
|
287
|
+
flagUrl: flag === undefined ? undefined : originOf(flag),
|
|
288
|
+
flagAccount: text(values, "account"),
|
|
289
|
+
envUrl: envUrl === undefined ? undefined : originOf(envUrl),
|
|
290
|
+
envToken: io.env.CONSOLE_TOKEN,
|
|
291
|
+
directoryUrl: directoryOrigin(directory),
|
|
292
|
+
directoryAccount: directory?.account,
|
|
293
|
+
credentials,
|
|
294
|
+
});
|
|
295
|
+
if (!resolved.ok) {
|
|
296
|
+
throw failed(wordsOf(resolved));
|
|
202
297
|
}
|
|
203
|
-
const
|
|
298
|
+
const { url, token } = resolved;
|
|
299
|
+
const client = clientOf(io, url, token);
|
|
204
300
|
const projectKey = async () =>
|
|
205
|
-
text(values, "project") ?? io.env.CONSOLE_PROJECT ??
|
|
301
|
+
text(values, "project") ?? io.env.CONSOLE_PROJECT ?? directory?.project;
|
|
206
302
|
return {
|
|
207
303
|
client,
|
|
208
304
|
url,
|
|
209
305
|
token,
|
|
306
|
+
account: resolved.account,
|
|
307
|
+
via: resolved.via,
|
|
308
|
+
credentials,
|
|
210
309
|
json: on(values, "json"),
|
|
211
310
|
projectKey,
|
|
212
311
|
async project() {
|
|
@@ -258,6 +357,9 @@ async function bodyOf(
|
|
|
258
357
|
return given === "-" ? io.readStdin() : given;
|
|
259
358
|
}
|
|
260
359
|
|
|
360
|
+
/** The hosts a token may travel to in the clear: this machine, and no other. */
|
|
361
|
+
const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
362
|
+
|
|
261
363
|
function originOf(value: string): string {
|
|
262
364
|
const url = URL.canParse(value) ? new URL(value) : undefined;
|
|
263
365
|
if (
|
|
@@ -271,6 +373,12 @@ function originOf(value: string): string {
|
|
|
271
373
|
`Not an origin: ${value}. Say the console's address without a path, such as https://console.example.com`,
|
|
272
374
|
);
|
|
273
375
|
}
|
|
376
|
+
// A token travels over https; in the clear only to this machine, where a console is run for development.
|
|
377
|
+
if (url.protocol === "http:" && !LOOPBACK_HOSTS.has(url.hostname)) {
|
|
378
|
+
throw misuse(
|
|
379
|
+
`Not a safe origin: ${value}. A token travels over https only; http is for a console on this machine (localhost).`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
274
382
|
return url.origin;
|
|
275
383
|
}
|
|
276
384
|
|
|
@@ -503,18 +611,40 @@ async function settle(
|
|
|
503
611
|
|
|
504
612
|
type Command = (args: readonly string[], io: CliIo) => Promise<number>;
|
|
505
613
|
|
|
506
|
-
/**
|
|
507
|
-
|
|
508
|
-
|
|
614
|
+
/**
|
|
615
|
+
* Checks a token against the console, keeps it as the login at that console for that account,
|
|
616
|
+
* and says who it is. A token the login replaces is given up at the console on the way.
|
|
617
|
+
*/
|
|
618
|
+
async function keep(
|
|
619
|
+
io: CliIo,
|
|
620
|
+
url: string,
|
|
621
|
+
token: string,
|
|
622
|
+
agent: string | undefined,
|
|
623
|
+
asDefault: boolean,
|
|
624
|
+
): Promise<number> {
|
|
625
|
+
const me = await clientOf(io, url, token).me();
|
|
509
626
|
const person = me.person;
|
|
510
627
|
if (person === null) {
|
|
511
628
|
throw failed(
|
|
512
629
|
"The console does not know this token. Make one on your Agents page and try again.",
|
|
513
630
|
);
|
|
514
631
|
}
|
|
515
|
-
const
|
|
632
|
+
const credentials = (await loadCredentials(io)) ?? EMPTY_CREDENTIALS;
|
|
633
|
+
const replaced = loginsAt(credentials, url).find((login) => login.account === person.login);
|
|
634
|
+
if (replaced !== undefined && replaced.token !== token) {
|
|
635
|
+
await clientOf(io, url, replaced.token)
|
|
636
|
+
.giveUpToken()
|
|
637
|
+
.catch(() => undefined);
|
|
638
|
+
}
|
|
639
|
+
const next = withLogin(
|
|
640
|
+
credentials,
|
|
641
|
+
{ url, account: person.login, token, agent, since: new Date(io.now()).toISOString() },
|
|
642
|
+
{ default: asDefault },
|
|
643
|
+
);
|
|
644
|
+
const kept = await io.store.save(next);
|
|
645
|
+
const standing = next.default === url ? ", the default console here" : "";
|
|
516
646
|
io.stdout(
|
|
517
|
-
`${agent === undefined ? "Signed in" : `Connected as "${agent}"`} to ${me.workspace.name} at ${url} as ${person.login} (${person.role}). The token is kept in ${kept}.\nNext, say which project this directory works in: console use <key> (console projects lists them).\n`,
|
|
647
|
+
`${agent === undefined ? "Signed in" : `Connected as "${agent}"`} to ${me.workspace.name} at ${url} as ${person.login} (${person.role})${standing}. The token is kept in ${kept}.\nNext, say which project this directory works in: console use <key> (console projects lists them).\n`,
|
|
518
648
|
);
|
|
519
649
|
return EXIT.ok;
|
|
520
650
|
}
|
|
@@ -522,25 +652,26 @@ async function keep(io: CliIo, url: string, token: string, agent?: string): Prom
|
|
|
522
652
|
const login: Command = async (args, io) => {
|
|
523
653
|
const { values } = parse(
|
|
524
654
|
args,
|
|
525
|
-
{ "token-stdin": { type: "boolean" }, agent: { type: "string" } },
|
|
655
|
+
{ "token-stdin": { type: "boolean" }, agent: { type: "string" }, default: { type: "boolean" } },
|
|
526
656
|
false,
|
|
527
657
|
);
|
|
528
|
-
const
|
|
529
|
-
const wanted = text(values, "url") ?? io.env.CONSOLE_URL ??
|
|
658
|
+
const credentials = await loadCredentials(io);
|
|
659
|
+
const wanted = text(values, "url") ?? io.env.CONSOLE_URL ?? credentials?.default;
|
|
530
660
|
if (wanted === undefined) {
|
|
531
661
|
throw misuse("Say where the console is: console login --url https://console.example.com");
|
|
532
662
|
}
|
|
533
663
|
const url = originOf(wanted);
|
|
664
|
+
const asDefault = on(values, "default");
|
|
534
665
|
if (on(values, "token-stdin")) {
|
|
535
666
|
const token = (await io.readStdin()).trim();
|
|
536
667
|
if (token.length === 0) {
|
|
537
668
|
throw failed("No token was given.");
|
|
538
669
|
}
|
|
539
|
-
return keep(io, url, token);
|
|
670
|
+
return keep(io, url, token, undefined, asDefault);
|
|
540
671
|
}
|
|
541
672
|
// A connection (ADR-0011): a code a person approves on the console's own pages, and the token
|
|
542
673
|
// handed over here once, never shown.
|
|
543
|
-
const client =
|
|
674
|
+
const client = clientOf(io, url);
|
|
544
675
|
// "<agent> on <machine>": the pages say the agent and keep the machine for a hover.
|
|
545
676
|
const agent = text(values, "agent") ?? agentOf(io.env) ?? "An agent";
|
|
546
677
|
const begun = await client.connect({
|
|
@@ -563,40 +694,110 @@ const login: Command = async (args, io) => {
|
|
|
563
694
|
throw error;
|
|
564
695
|
}
|
|
565
696
|
if (claim.status === "connected") {
|
|
566
|
-
return keep(io, url, claim.secret, claim.token.name);
|
|
697
|
+
return keep(io, url, claim.secret, claim.token.name, asDefault);
|
|
567
698
|
}
|
|
568
699
|
}
|
|
569
700
|
};
|
|
570
701
|
|
|
571
702
|
const logout: Command = async (args, io) => {
|
|
572
|
-
parse(args, {}, false);
|
|
573
|
-
const
|
|
574
|
-
const
|
|
575
|
-
const
|
|
576
|
-
|
|
703
|
+
const { values } = parse(args, {}, false);
|
|
704
|
+
const credentials = await loadCredentials(io);
|
|
705
|
+
const directory = await io.directory.load();
|
|
706
|
+
const envNote = () => {
|
|
707
|
+
if (io.env.CONSOLE_TOKEN !== undefined) {
|
|
708
|
+
io.stdout("CONSOLE_TOKEN in the environment still holds a token: unset it.\n");
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
if (credentials === undefined || credentials.logins.length === 0) {
|
|
577
712
|
io.stdout("Not signed in here: nothing to forget.\n");
|
|
713
|
+
envNote();
|
|
578
714
|
return EXIT.ok;
|
|
579
715
|
}
|
|
716
|
+
// Which login: the console the flag names, else the directory's, else the default, else the
|
|
717
|
+
// one console signed in to; and the account the same way, where the console has several.
|
|
718
|
+
const consoles = [...new Set(credentials.logins.map((login) => login.url))];
|
|
719
|
+
const flag = text(values, "url");
|
|
720
|
+
const url =
|
|
721
|
+
flag === undefined
|
|
722
|
+
? (directoryOrigin(directory) ??
|
|
723
|
+
credentials.default ??
|
|
724
|
+
(consoles.length === 1 ? consoles[0] : undefined))
|
|
725
|
+
: originOf(flag);
|
|
726
|
+
if (url === undefined) {
|
|
727
|
+
throw failed(
|
|
728
|
+
`Several consoles are signed in here; say which: console logout --url <origin>. console logins lists them.`,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
const account = text(values, "account") ?? directory?.account;
|
|
732
|
+
const at = loginsAt(credentials, url);
|
|
733
|
+
const chosen = account === undefined ? at : at.filter((login) => login.account === account);
|
|
734
|
+
const [login] = chosen;
|
|
735
|
+
if (login === undefined) {
|
|
736
|
+
throw failed(
|
|
737
|
+
`Nobody is signed in here at ${url}${account === undefined ? "" : ` as ${account}`}: nothing to forget. console logins lists the consoles signed in.`,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
if (chosen.length > 1) {
|
|
741
|
+
throw failed(
|
|
742
|
+
`Several accounts are signed in at ${url} (${chosen.map((kept) => kept.account ?? "?").join(", ")}); say which: console logout --url ${url} --account <login>`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
580
745
|
// The token is given up at the console first, so that it is good for nothing from now on;
|
|
581
746
|
// one the console no longer knows is gone already. A console out of reach leaves it to the
|
|
582
747
|
// Agents page, and the person is told so.
|
|
583
748
|
let gone = true;
|
|
584
749
|
try {
|
|
585
|
-
await
|
|
750
|
+
await clientOf(io, url, login.token).giveUpToken();
|
|
586
751
|
} catch (error) {
|
|
587
752
|
if (!(error instanceof ApiError && error.code === "auth.required")) {
|
|
588
753
|
gone = false;
|
|
589
754
|
}
|
|
590
755
|
}
|
|
591
|
-
|
|
756
|
+
const next = withoutLogin(credentials, url, login.account);
|
|
757
|
+
if (next.logins.length === 0) {
|
|
758
|
+
await io.store.clear();
|
|
759
|
+
} else {
|
|
760
|
+
await io.store.save(next);
|
|
761
|
+
}
|
|
762
|
+
const whom = login.account === undefined ? "" : ` as ${login.account}`;
|
|
592
763
|
io.stdout(
|
|
593
764
|
gone
|
|
594
|
-
?
|
|
595
|
-
: `Signed out here, but the console
|
|
765
|
+
? `Signed out of ${url}${whom}: the token is given up at the console and forgotten here.\n`
|
|
766
|
+
: `Signed out of ${url}${whom} here, but the console could not be reached to give the token up: a person removes it on the Agents page.\n`,
|
|
596
767
|
);
|
|
597
|
-
|
|
598
|
-
|
|
768
|
+
envNote();
|
|
769
|
+
return EXIT.ok;
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
/** The consoles this machine is signed in to, with the account at each; never a token. */
|
|
773
|
+
const logins: Command = async (args, io) => {
|
|
774
|
+
const { values } = parse(args, {}, false);
|
|
775
|
+
const credentials = await loadCredentials(io);
|
|
776
|
+
const kept = (credentials?.logins ?? []).map((login) => ({
|
|
777
|
+
url: login.url,
|
|
778
|
+
account: login.account ?? null,
|
|
779
|
+
agent: login.agent ?? null,
|
|
780
|
+
since: login.since ?? null,
|
|
781
|
+
default: credentials?.default === login.url,
|
|
782
|
+
}));
|
|
783
|
+
if (on(values, "json")) {
|
|
784
|
+
io.stdout(
|
|
785
|
+
`${JSON.stringify({ default: credentials?.default ?? null, logins: kept }, null, 2)}\n`,
|
|
786
|
+
);
|
|
787
|
+
return EXIT.ok;
|
|
599
788
|
}
|
|
789
|
+
if (kept.length === 0) {
|
|
790
|
+
io.stdout("Not signed in anywhere here: console login --url <the console's origin>\n");
|
|
791
|
+
return EXIT.ok;
|
|
792
|
+
}
|
|
793
|
+
io.stdout(
|
|
794
|
+
`${kept
|
|
795
|
+
.map(
|
|
796
|
+
(login) =>
|
|
797
|
+
`${login.url} ${login.account ?? "(account unknown)"}${login.agent === null ? "" : ` ${login.agent}`}${login.since === null ? "" : ` since ${login.since.slice(0, 10)}`}${login.default ? " (default)" : ""}`,
|
|
798
|
+
)
|
|
799
|
+
.join("\n")}\n`,
|
|
800
|
+
);
|
|
600
801
|
return EXIT.ok;
|
|
601
802
|
};
|
|
602
803
|
|
|
@@ -620,12 +821,38 @@ const whoami: Command = async (args, io) => {
|
|
|
620
821
|
throw failed("The console does not know this token. Sign in again: console login");
|
|
621
822
|
}
|
|
622
823
|
const key = await session.projectKey();
|
|
824
|
+
const command = parseVersion(io.version);
|
|
825
|
+
const server = parseVersion(me.console?.version);
|
|
826
|
+
const verdict =
|
|
827
|
+
command === undefined || server === undefined
|
|
828
|
+
? undefined
|
|
829
|
+
: judgeClient(command, server).verdict;
|
|
830
|
+
// A console from before the rule says nothing of itself; the command says so instead.
|
|
831
|
+
const consoleWords =
|
|
832
|
+
me.console === undefined
|
|
833
|
+
? "the console does not say its version, so it is older than 0.1.18"
|
|
834
|
+
: `the console is ${me.console.version} and takes ${me.console.clientRange}`;
|
|
623
835
|
answer(
|
|
624
836
|
io,
|
|
625
837
|
session,
|
|
626
|
-
{
|
|
838
|
+
{
|
|
839
|
+
...me,
|
|
840
|
+
console: me.console ?? null,
|
|
841
|
+
project: key ?? null,
|
|
842
|
+
command: { version: io.version ?? null, install: io.install ?? null },
|
|
843
|
+
},
|
|
627
844
|
() =>
|
|
628
|
-
|
|
845
|
+
[
|
|
846
|
+
`${person.login} (${person.role}) at ${me.workspace.name}, ${session.url}${key === undefined ? "; no project named here" : `; project ${key}`}`,
|
|
847
|
+
`This command is ${io.version ?? "of a version it does not know"}${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}; ${consoleWords}.`,
|
|
848
|
+
...(verdict === "behind"
|
|
849
|
+
? [`A newer command exists. Update it: ${updateWords(io, me.console?.version)}`]
|
|
850
|
+
: verdict === "ahead"
|
|
851
|
+
? [
|
|
852
|
+
`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)}`,
|
|
853
|
+
]
|
|
854
|
+
: []),
|
|
855
|
+
].join("\n"),
|
|
629
856
|
);
|
|
630
857
|
return EXIT.ok;
|
|
631
858
|
};
|
|
@@ -644,16 +871,35 @@ const projects: Command = async (args, io) => {
|
|
|
644
871
|
|
|
645
872
|
const use: Command = async (args, io) => {
|
|
646
873
|
const { values, positionals } = parse(args, {});
|
|
647
|
-
const key = theOne(
|
|
874
|
+
const key = theOne(
|
|
875
|
+
positionals,
|
|
876
|
+
"Say which project: console use <key> [--url <origin>] [--account <login>]",
|
|
877
|
+
);
|
|
648
878
|
const session = await open(io, values);
|
|
649
879
|
const project = await session.client.project(key).get();
|
|
650
|
-
|
|
880
|
+
// The directory names the console where that is not the default login here, or when asked,
|
|
881
|
+
// and the account where the console has several, or when asked; never a token.
|
|
882
|
+
const credentials = session.credentials ?? EMPTY_CREDENTIALS;
|
|
883
|
+
const pinUrl =
|
|
884
|
+
text(values, "url") !== undefined ||
|
|
885
|
+
(session.via === "login" && credentials.default !== session.url);
|
|
886
|
+
const pinAccount =
|
|
887
|
+
text(values, "account") !== undefined ||
|
|
888
|
+
(session.via === "login" && loginsAt(credentials, session.url).length > 1);
|
|
889
|
+
const kept = await io.directory.save({
|
|
890
|
+
project: project.key,
|
|
891
|
+
...(pinUrl ? { url: session.url } : {}),
|
|
892
|
+
...(pinAccount && session.account !== undefined ? { account: session.account } : {}),
|
|
893
|
+
});
|
|
894
|
+
const bound = pinUrl
|
|
895
|
+
? ` at ${session.url}${pinAccount && session.account !== undefined ? ` as ${session.account}` : ""}`
|
|
896
|
+
: "";
|
|
651
897
|
answer(
|
|
652
898
|
io,
|
|
653
899
|
session,
|
|
654
900
|
project,
|
|
655
901
|
() =>
|
|
656
|
-
`Working in ${project.name} (${project.key}) from this directory on: kept in ${kept}, which is meant to be committed. What its people ask of agents: console rules.`,
|
|
902
|
+
`Working in ${project.name} (${project.key})${bound} from this directory on: kept in ${kept}, which is meant to be committed in a private repository and left out of a public one. What its people ask of agents: console rules.`,
|
|
657
903
|
);
|
|
658
904
|
return EXIT.ok;
|
|
659
905
|
};
|
|
@@ -665,14 +911,21 @@ const tasks: Command = async (args, io) => {
|
|
|
665
911
|
state: { type: "string" },
|
|
666
912
|
open: { type: "boolean" },
|
|
667
913
|
mine: { type: "boolean" },
|
|
914
|
+
unassigned: { type: "boolean" },
|
|
668
915
|
category: { type: "string" },
|
|
669
916
|
goal: { type: "string" },
|
|
670
917
|
due: { type: "string" },
|
|
918
|
+
archived: { type: "boolean" },
|
|
671
919
|
...PAGE_OPTIONS,
|
|
672
920
|
},
|
|
673
921
|
false,
|
|
674
922
|
);
|
|
675
923
|
const state = stateOf(text(values, "state"));
|
|
924
|
+
if (on(values, "mine") && on(values, "unassigned")) {
|
|
925
|
+
throw misuse(
|
|
926
|
+
"Say one: --mine for the tasks assigned to you, or --unassigned for those assigned to nobody",
|
|
927
|
+
);
|
|
928
|
+
}
|
|
676
929
|
const page = pageOf(values);
|
|
677
930
|
const session = await open(io, values);
|
|
678
931
|
const project = await session.project();
|
|
@@ -694,9 +947,11 @@ const tasks: Command = async (args, io) => {
|
|
|
694
947
|
...(state === undefined ? {} : { state }),
|
|
695
948
|
...(on(values, "open") || due === "overdue" ? { open: true } : {}),
|
|
696
949
|
...(assignee === undefined ? {} : { assignee }),
|
|
950
|
+
...(on(values, "unassigned") ? { unassigned: true } : {}),
|
|
697
951
|
...(text(values, "category") === undefined ? {} : { category: text(values, "category") }),
|
|
698
952
|
...(text(values, "goal") === undefined ? {} : { goal: text(values, "goal") }),
|
|
699
953
|
...(dueBy === undefined ? {} : { dueBy }),
|
|
954
|
+
...(on(values, "archived") ? { archived: true } : {}),
|
|
700
955
|
...page,
|
|
701
956
|
});
|
|
702
957
|
answer(io, session, found, () =>
|
|
@@ -743,6 +998,65 @@ const skills: Command = async (args, io) => {
|
|
|
743
998
|
return EXIT.ok;
|
|
744
999
|
};
|
|
745
1000
|
|
|
1001
|
+
/** How many pages of a skill, or of a file, the command follows before it stops: far above what one holds. */
|
|
1002
|
+
const MAX_SKILL_PAGES = 64;
|
|
1003
|
+
|
|
1004
|
+
const skill: Command = async (args, io) => {
|
|
1005
|
+
const { values, positionals } = parse(args, { file: { type: "string" } });
|
|
1006
|
+
const path = theOne(
|
|
1007
|
+
positionals,
|
|
1008
|
+
"Say which skill: console skill <path of its SKILL.md> [--file <path>]",
|
|
1009
|
+
);
|
|
1010
|
+
const session = await open(io, values);
|
|
1011
|
+
const project = await session.project();
|
|
1012
|
+
const file = text(values, "file");
|
|
1013
|
+
if (file !== undefined) {
|
|
1014
|
+
// The file whole, page after page, as the console reads it from the deployment.
|
|
1015
|
+
const parts: string[] = [];
|
|
1016
|
+
let offset: number | undefined;
|
|
1017
|
+
let last: RestSkillFile | undefined;
|
|
1018
|
+
for (let pages = 0; pages < MAX_SKILL_PAGES; pages += 1) {
|
|
1019
|
+
last = await project.skillFile(file, offset === undefined ? {} : { offset });
|
|
1020
|
+
if (last.status !== "ready" || last.file === null) {
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
parts.push(last.file.content);
|
|
1024
|
+
if (last.file.nextOffset === null) {
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
offset = last.file.nextOffset;
|
|
1028
|
+
}
|
|
1029
|
+
if (last === undefined) {
|
|
1030
|
+
throw failed("The file could not be read.");
|
|
1031
|
+
}
|
|
1032
|
+
const read = last;
|
|
1033
|
+
const content = parts.join("");
|
|
1034
|
+
answer(io, session, { ...read, content }, () => formatSkillFile(read, content));
|
|
1035
|
+
return EXIT.ok;
|
|
1036
|
+
}
|
|
1037
|
+
// The skill whole: its pages until complete, the rules first, as load_skill lays them out.
|
|
1038
|
+
const pages: RestSkillPage[] = [];
|
|
1039
|
+
let cursor: string | undefined;
|
|
1040
|
+
let last: RestSkillLoad | undefined;
|
|
1041
|
+
for (let n = 0; n < MAX_SKILL_PAGES; n += 1) {
|
|
1042
|
+
last = await project.skill(path, cursor);
|
|
1043
|
+
if (last.status !== "ready" || last.skill === null) {
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
pages.push(last.skill);
|
|
1047
|
+
if (last.skill.complete || last.skill.nextCursor === null) {
|
|
1048
|
+
break;
|
|
1049
|
+
}
|
|
1050
|
+
cursor = last.skill.nextCursor;
|
|
1051
|
+
}
|
|
1052
|
+
if (last === undefined) {
|
|
1053
|
+
throw failed("The skill could not be read.");
|
|
1054
|
+
}
|
|
1055
|
+
const loaded = last;
|
|
1056
|
+
answer(io, session, { ...loaded, pages }, () => formatSkillLoad(loaded, pages));
|
|
1057
|
+
return EXIT.ok;
|
|
1058
|
+
};
|
|
1059
|
+
|
|
746
1060
|
/** A version number given as a flag, or nothing; anything else is not understood. */
|
|
747
1061
|
function versionOf(given: string | undefined): number | undefined {
|
|
748
1062
|
if (given === undefined) {
|
|
@@ -943,6 +1257,45 @@ const editTask: Command = async (args, io) => {
|
|
|
943
1257
|
return EXIT.ok;
|
|
944
1258
|
};
|
|
945
1259
|
|
|
1260
|
+
/** Puts a task away, or every done or dropped task of a goal (ADR-0029); the task by its number or its id. */
|
|
1261
|
+
const taskArchive: Command = async (args, io) => {
|
|
1262
|
+
const { values, positionals } = parse(args, { goal: { type: "string" } });
|
|
1263
|
+
const goal = text(values, "goal");
|
|
1264
|
+
if (positionals.length > 1 || (positionals.length === 0) === (goal === undefined)) {
|
|
1265
|
+
throw misuse(
|
|
1266
|
+
"Say which: console task archive <number|id>, or console task archive --goal <id> for every done or dropped task of a goal",
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
const session = await open(io, values);
|
|
1270
|
+
const project = await session.project();
|
|
1271
|
+
if (goal !== undefined) {
|
|
1272
|
+
const { archived } = await project.archiveTasksOfGoal(goal);
|
|
1273
|
+
answer(
|
|
1274
|
+
io,
|
|
1275
|
+
session,
|
|
1276
|
+
{ archived },
|
|
1277
|
+
() => `${archived} ${archived === 1 ? "task" : "tasks"} of the goal put away.`,
|
|
1278
|
+
);
|
|
1279
|
+
return EXIT.ok;
|
|
1280
|
+
}
|
|
1281
|
+
const found = await project.task(positionals[0] ?? "");
|
|
1282
|
+
const archived = await project.archiveTask(found.id);
|
|
1283
|
+
answer(io, session, archived, () => formatTaskLine(archived));
|
|
1284
|
+
return EXIT.ok;
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
/** Brings a task back from the archive. */
|
|
1288
|
+
const taskRestore: Command = async (args, io) => {
|
|
1289
|
+
const { values, positionals } = parse(args, {});
|
|
1290
|
+
const ref = theOne(positionals, "Say which task: console task restore <number|id>");
|
|
1291
|
+
const session = await open(io, values);
|
|
1292
|
+
const project = await session.project();
|
|
1293
|
+
const found = await project.task(ref);
|
|
1294
|
+
const restored = await project.restoreTask(found.id);
|
|
1295
|
+
answer(io, session, restored, () => formatTaskLine(restored));
|
|
1296
|
+
return EXIT.ok;
|
|
1297
|
+
};
|
|
1298
|
+
|
|
946
1299
|
const task: Command = async (args, io) => {
|
|
947
1300
|
if (args[0] === "new") {
|
|
948
1301
|
return newTask(args.slice(1), io);
|
|
@@ -950,6 +1303,12 @@ const task: Command = async (args, io) => {
|
|
|
950
1303
|
if (args[0] === "edit") {
|
|
951
1304
|
return editTask(args.slice(1), io);
|
|
952
1305
|
}
|
|
1306
|
+
if (args[0] === "archive") {
|
|
1307
|
+
return taskArchive(args.slice(1), io);
|
|
1308
|
+
}
|
|
1309
|
+
if (args[0] === "restore") {
|
|
1310
|
+
return taskRestore(args.slice(1), io);
|
|
1311
|
+
}
|
|
953
1312
|
const { values, positionals } = parse(args, {});
|
|
954
1313
|
const ref = theOne(positionals, "Say which task: console task <number|id>");
|
|
955
1314
|
const session = await open(io, values);
|
|
@@ -1417,13 +1776,24 @@ const restore: Command = async (args, io) => {
|
|
|
1417
1776
|
};
|
|
1418
1777
|
|
|
1419
1778
|
const goals: Command = async (args, io) => {
|
|
1420
|
-
const { values } = parse(
|
|
1779
|
+
const { values } = parse(
|
|
1780
|
+
args,
|
|
1781
|
+
{ category: { type: "string" }, state: { type: "string" } },
|
|
1782
|
+
false,
|
|
1783
|
+
);
|
|
1784
|
+
const state = goalStateOf(text(values, "state"));
|
|
1785
|
+
const category = text(values, "category");
|
|
1421
1786
|
const session = await open(io, values);
|
|
1422
1787
|
const project = await session.project();
|
|
1423
|
-
const found = await project.goals(
|
|
1788
|
+
const found = await project.goals({
|
|
1789
|
+
...(state === undefined ? {} : { state }),
|
|
1790
|
+
...(category === undefined ? {} : { category }),
|
|
1791
|
+
});
|
|
1424
1792
|
answer(io, session, found, () =>
|
|
1425
1793
|
found.items.length === 0
|
|
1426
|
-
?
|
|
1794
|
+
? state === undefined && category === undefined
|
|
1795
|
+
? "No goals. An owner sets one: console goal new <title> --due <day>"
|
|
1796
|
+
: "No goal matches."
|
|
1427
1797
|
: found.items.map(formatGoalLine).join("\n"),
|
|
1428
1798
|
);
|
|
1429
1799
|
return EXIT.ok;
|
|
@@ -1431,15 +1801,24 @@ const goals: Command = async (args, io) => {
|
|
|
1431
1801
|
|
|
1432
1802
|
const GOAL_FIELDS: OptionSpec = {
|
|
1433
1803
|
state: { type: "string" },
|
|
1804
|
+
category: { type: "string" },
|
|
1805
|
+
starts: { type: "string" },
|
|
1434
1806
|
due: { type: "string" },
|
|
1435
1807
|
body: { type: "string" },
|
|
1436
1808
|
file: { type: "string" },
|
|
1437
1809
|
};
|
|
1438
1810
|
|
|
1811
|
+
/** A category's key as a flag names one, or `null` for `none` where the field may be cleared. */
|
|
1812
|
+
function categoryOf(value: string | undefined, clearable = false): string | null | undefined {
|
|
1813
|
+
return clearable && value === "none" ? null : value;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1439
1816
|
const newGoal: Command = async (args, io) => {
|
|
1440
1817
|
const { values, positionals } = parse(args, GOAL_FIELDS);
|
|
1441
1818
|
const title = theOne(positionals, 'Say the title: console goal new "<title>" [--due <day>]');
|
|
1442
1819
|
const state = goalStateOf(text(values, "state"));
|
|
1820
|
+
const category = categoryOf(text(values, "category"));
|
|
1821
|
+
const startsOn = dayOf(io, text(values, "starts"));
|
|
1443
1822
|
const dueOn = dayOf(io, text(values, "due"));
|
|
1444
1823
|
const body = await bodyOf(io, values, text(values, "body"));
|
|
1445
1824
|
const session = await open(io, values);
|
|
@@ -1448,6 +1827,8 @@ const newGoal: Command = async (args, io) => {
|
|
|
1448
1827
|
title,
|
|
1449
1828
|
...(body === undefined ? {} : { body }),
|
|
1450
1829
|
...(state === undefined ? {} : { state }),
|
|
1830
|
+
...(category === undefined ? {} : { category }),
|
|
1831
|
+
...(startsOn === undefined ? {} : { startsOn }),
|
|
1451
1832
|
...(dueOn === undefined ? {} : { dueOn }),
|
|
1452
1833
|
});
|
|
1453
1834
|
answer(io, session, made, () => `Goal set: ${made.title}\nid: ${made.id}`);
|
|
@@ -1459,17 +1840,21 @@ const editGoal: Command = async (args, io) => {
|
|
|
1459
1840
|
const id = theOne(positionals, "Say which goal: console goal edit <id> --state <state> ...");
|
|
1460
1841
|
const title = text(values, "title");
|
|
1461
1842
|
const state = goalStateOf(text(values, "state"));
|
|
1843
|
+
const category = categoryOf(text(values, "category"), true);
|
|
1844
|
+
const startsOn = dayOf(io, text(values, "starts"), true);
|
|
1462
1845
|
const dueOn = dayOf(io, text(values, "due"), true);
|
|
1463
1846
|
const body = await bodyOf(io, values, text(values, "body"));
|
|
1464
1847
|
const patch = {
|
|
1465
1848
|
...(title === undefined ? {} : { title }),
|
|
1466
1849
|
...(body === undefined ? {} : { body }),
|
|
1467
1850
|
...(state === undefined ? {} : { state }),
|
|
1851
|
+
...(category === undefined ? {} : { category }),
|
|
1852
|
+
...(startsOn === undefined ? {} : { startsOn }),
|
|
1468
1853
|
...(dueOn === undefined ? {} : { dueOn }),
|
|
1469
1854
|
};
|
|
1470
1855
|
if (Object.keys(patch).length === 0) {
|
|
1471
1856
|
throw misuse(
|
|
1472
|
-
"Say what to change: --title, --body, --state or --due;
|
|
1857
|
+
"Say what to change: --title, --body, --state, --category, --starts or --due; none clears a category or a day.",
|
|
1473
1858
|
);
|
|
1474
1859
|
}
|
|
1475
1860
|
const session = await open(io, values);
|
|
@@ -1805,6 +2190,37 @@ const editProject: Command = async (args, io) => {
|
|
|
1805
2190
|
return EXIT.ok;
|
|
1806
2191
|
};
|
|
1807
2192
|
|
|
2193
|
+
/** The pack token (ADR-0028): read from standard input and never from an argument, so that no shell keeps it. */
|
|
2194
|
+
const projectPackToken: Command = async (args, io) => {
|
|
2195
|
+
const { values, positionals } = parse(args, {
|
|
2196
|
+
stdin: { type: "boolean" },
|
|
2197
|
+
remove: { type: "boolean" },
|
|
2198
|
+
});
|
|
2199
|
+
const key = theOne(
|
|
2200
|
+
positionals,
|
|
2201
|
+
"Say which project: console project pack-token <key> --stdin, or console project pack-token <key> --remove",
|
|
2202
|
+
);
|
|
2203
|
+
if (on(values, "stdin") === on(values, "remove")) {
|
|
2204
|
+
throw misuse(
|
|
2205
|
+
"Say one of --stdin, which reads the token from standard input, and --remove, which takes it away.",
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
const session = await open(io, values);
|
|
2209
|
+
const project = session.client.project(key);
|
|
2210
|
+
if (on(values, "remove")) {
|
|
2211
|
+
const changed = await project.removePackToken();
|
|
2212
|
+
answer(io, session, changed, () => formatProject(changed));
|
|
2213
|
+
return EXIT.ok;
|
|
2214
|
+
}
|
|
2215
|
+
const token = (await io.readStdin()).trim();
|
|
2216
|
+
if (token.length === 0) {
|
|
2217
|
+
throw failed("No token was given on standard input.");
|
|
2218
|
+
}
|
|
2219
|
+
const changed = await project.setPackToken({ token });
|
|
2220
|
+
answer(io, session, changed, () => formatProject(changed));
|
|
2221
|
+
return EXIT.ok;
|
|
2222
|
+
};
|
|
2223
|
+
|
|
1808
2224
|
const projectIcon: Command = async (args, io) => {
|
|
1809
2225
|
const { values, positionals } = parse(args, { remove: { type: "boolean" } });
|
|
1810
2226
|
const [key, file] = positionals;
|
|
@@ -1838,10 +2254,13 @@ const project: Command = async (args, io) => {
|
|
|
1838
2254
|
if (args[0] === "icon") {
|
|
1839
2255
|
return projectIcon(args.slice(1), io);
|
|
1840
2256
|
}
|
|
2257
|
+
if (args[0] === "pack-token") {
|
|
2258
|
+
return projectPackToken(args.slice(1), io);
|
|
2259
|
+
}
|
|
1841
2260
|
const { values, positionals } = parse(args, {});
|
|
1842
2261
|
const key = theOne(
|
|
1843
2262
|
positionals,
|
|
1844
|
-
"Say which project: console project <key>; or console project new, edit or
|
|
2263
|
+
"Say which project: console project <key>; or console project new, edit, icon or pack-token",
|
|
1845
2264
|
);
|
|
1846
2265
|
const session = await open(io, values);
|
|
1847
2266
|
const found = await session.client.project(key).get();
|
|
@@ -1862,18 +2281,59 @@ const members: Command = async (args, io) => {
|
|
|
1862
2281
|
return EXIT.ok;
|
|
1863
2282
|
};
|
|
1864
2283
|
|
|
2284
|
+
/** The keys --project names with commas, each once, or nothing when it names one project or none. */
|
|
2285
|
+
function projectKeysOf(value: string | undefined): readonly string[] | undefined {
|
|
2286
|
+
if (value === undefined || !value.includes(",")) {
|
|
2287
|
+
return undefined;
|
|
2288
|
+
}
|
|
2289
|
+
const keys = [...new Set(value.split(",").map((key) => key.trim()))].filter((key) => key !== "");
|
|
2290
|
+
if (keys.length === 0) {
|
|
2291
|
+
throw misuse("Say which projects: --project <key>[,<key>...]");
|
|
2292
|
+
}
|
|
2293
|
+
return keys;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
1865
2296
|
const member: Command = async (args, io) => {
|
|
1866
2297
|
const [verb, ...rest] = args;
|
|
1867
2298
|
if (verb === "add") {
|
|
1868
2299
|
const { values, positionals } = parse(rest, { role: { type: "string" } });
|
|
1869
|
-
const login = theOne(
|
|
2300
|
+
const login = theOne(
|
|
2301
|
+
positionals,
|
|
2302
|
+
"Say whom: console member add <login> [--role owner|member] [--project <key>[,<key>...]]",
|
|
2303
|
+
);
|
|
1870
2304
|
const role = text(values, "role");
|
|
1871
2305
|
const session = await open(io, values);
|
|
1872
|
-
const
|
|
1873
|
-
const added = await project.addMember({
|
|
2306
|
+
const input = {
|
|
1874
2307
|
personId: (await personOf(session, login)).id,
|
|
1875
2308
|
...(role === undefined ? {} : { role: projectRoleOf(role) }),
|
|
1876
|
-
}
|
|
2309
|
+
};
|
|
2310
|
+
// Several projects at once, where --project names them with commas: the person is listed
|
|
2311
|
+
// in each in turn, one listed already is said so, and the rest go on.
|
|
2312
|
+
const keys = projectKeysOf(text(values, "project"));
|
|
2313
|
+
if (keys !== undefined) {
|
|
2314
|
+
const added: { readonly project: string; readonly member: RestMember | null }[] = [];
|
|
2315
|
+
for (const key of keys) {
|
|
2316
|
+
try {
|
|
2317
|
+
added.push({ project: key, member: await session.client.project(key).addMember(input) });
|
|
2318
|
+
} catch (error) {
|
|
2319
|
+
if (!(error instanceof ApiError && error.code === "member.exists")) {
|
|
2320
|
+
throw error;
|
|
2321
|
+
}
|
|
2322
|
+
added.push({ project: key, member: null });
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
answer(io, session, added, () =>
|
|
2326
|
+
added
|
|
2327
|
+
.map(
|
|
2328
|
+
({ project, member }) =>
|
|
2329
|
+
`${project} ${member === null ? `${login} is listed already` : formatMemberLine(member)}`,
|
|
2330
|
+
)
|
|
2331
|
+
.join("\n"),
|
|
2332
|
+
);
|
|
2333
|
+
return EXIT.ok;
|
|
2334
|
+
}
|
|
2335
|
+
const project = await session.project();
|
|
2336
|
+
const added = await project.addMember(input);
|
|
1877
2337
|
answer(io, session, added, () => formatMemberLine(added));
|
|
1878
2338
|
return EXIT.ok;
|
|
1879
2339
|
}
|
|
@@ -1908,7 +2368,7 @@ const member: Command = async (args, io) => {
|
|
|
1908
2368
|
return EXIT.ok;
|
|
1909
2369
|
}
|
|
1910
2370
|
throw misuse(
|
|
1911
|
-
"Say what to do: console member add <login> [--role owner|member], console member role <login> <owner|member>, or console member remove <login>",
|
|
2371
|
+
"Say what to do: console member add <login> [--role owner|member] [--project <key>[,<key>...]], console member role <login> <owner|member>, or console member remove <login>",
|
|
1912
2372
|
);
|
|
1913
2373
|
};
|
|
1914
2374
|
|
|
@@ -1968,6 +2428,7 @@ const category: Command = async (args, io) => {
|
|
|
1968
2428
|
|
|
1969
2429
|
const COMMANDS: Readonly<Record<string, Command>> = {
|
|
1970
2430
|
login,
|
|
2431
|
+
logins,
|
|
1971
2432
|
logout,
|
|
1972
2433
|
whoami,
|
|
1973
2434
|
serve,
|
|
@@ -1980,6 +2441,7 @@ const COMMANDS: Readonly<Record<string, Command>> = {
|
|
|
1980
2441
|
goal,
|
|
1981
2442
|
categories,
|
|
1982
2443
|
skills,
|
|
2444
|
+
skill,
|
|
1983
2445
|
rules,
|
|
1984
2446
|
people,
|
|
1985
2447
|
person,
|
|
@@ -2021,9 +2483,85 @@ function help(io: CliIo, name: string | undefined): number {
|
|
|
2021
2483
|
return EXIT.ok;
|
|
2022
2484
|
}
|
|
2023
2485
|
|
|
2486
|
+
/**
|
|
2487
|
+
* A client of the console at `url`, naming the command on every request (ADR-0025). A redirect
|
|
2488
|
+
* is not followed: the token goes to the address the command resolved, and nowhere it is sent on to.
|
|
2489
|
+
*/
|
|
2490
|
+
function clientOf(io: CliIo, url: string, token?: string): ConsoleClient {
|
|
2491
|
+
return createClient({
|
|
2492
|
+
baseUrl: url,
|
|
2493
|
+
fetch: (input, init) => io.fetch(input, { ...init, redirect: "manual" }),
|
|
2494
|
+
...(token === undefined ? {} : { token }),
|
|
2495
|
+
...(io.userAgent === undefined ? {} : { userAgent: io.userAgent }),
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
/** What the console said of itself on its last answer (ADR-0025): its version, and the range of command versions it takes. */
|
|
2500
|
+
interface ConsoleSeen {
|
|
2501
|
+
version?: string | undefined;
|
|
2502
|
+
range?: string | undefined;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
/** The command that puts the console's version where this command is installed. */
|
|
2506
|
+
function updateWords(io: CliIo, version: string | undefined): string {
|
|
2507
|
+
const wanted = version ?? "latest";
|
|
2508
|
+
if (io.install === undefined) {
|
|
2509
|
+
return `npm install -g @skillcdn/console@${wanted}`;
|
|
2510
|
+
}
|
|
2511
|
+
const command = updateCommandFor(io.install, wanted);
|
|
2512
|
+
return io.install.kind === "project" ? `${command} (in ${io.install.root})` : command;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/**
|
|
2516
|
+
* What the command says of the versions once the console answered: that a newer command exists,
|
|
2517
|
+
* or that this one is newer than the console; once a day for one pair of versions, on stderr,
|
|
2518
|
+
* so that the console's JSON stays what it is. What is out of the range never gets here: the
|
|
2519
|
+
* console refused it, and the refusal says what to do.
|
|
2520
|
+
*/
|
|
2521
|
+
async function noticeVersions(io: CliIo, seen: ConsoleSeen): Promise<void> {
|
|
2522
|
+
const command = parseVersion(io.version);
|
|
2523
|
+
const server = parseVersion(seen.version);
|
|
2524
|
+
if (
|
|
2525
|
+
io.version === undefined ||
|
|
2526
|
+
seen.version === undefined ||
|
|
2527
|
+
command === undefined ||
|
|
2528
|
+
server === undefined
|
|
2529
|
+
) {
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
const { verdict } = judgeClient(command, server);
|
|
2533
|
+
if (verdict !== "behind" && verdict !== "ahead") {
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
const previous = await io.notices?.load();
|
|
2537
|
+
const now = io.now();
|
|
2538
|
+
if (
|
|
2539
|
+
previous !== undefined &&
|
|
2540
|
+
previous.command === io.version &&
|
|
2541
|
+
previous.console === seen.version &&
|
|
2542
|
+
now - Date.parse(previous.shownAt) < DAY_MS
|
|
2543
|
+
) {
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
io.stderr(
|
|
2547
|
+
verdict === "behind"
|
|
2548
|
+
? `A newer command exists: the console runs ${seen.version}, this command is ${io.version}. Update it: ${updateWords(io, seen.version)}\n`
|
|
2549
|
+
: `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`,
|
|
2550
|
+
);
|
|
2551
|
+
await io.notices?.save({
|
|
2552
|
+
command: io.version,
|
|
2553
|
+
console: seen.version,
|
|
2554
|
+
shownAt: new Date(now).toISOString(),
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2024
2558
|
/** What a refusal of the console is followed by, when there is something to do about it. */
|
|
2025
|
-
function hintFor(error: ApiError): string {
|
|
2559
|
+
function hintFor(error: ApiError, io: CliIo, seen: ConsoleSeen): string {
|
|
2026
2560
|
switch (error.code) {
|
|
2561
|
+
case "client.outdated":
|
|
2562
|
+
return `Update the command: ${updateWords(io, seen.version)}\n`;
|
|
2563
|
+
case "client.too_new":
|
|
2564
|
+
return `Install the console's version: ${updateWords(io, seen.version)}; or ask the console's operator to update the console.\n`;
|
|
2027
2565
|
case "auth.required":
|
|
2028
2566
|
return "The token is not one the console knows, any more or at all. A person signs the command in again: console login\n";
|
|
2029
2567
|
case "project.not_found":
|
|
@@ -2042,6 +2580,10 @@ function hintFor(error: ApiError): string {
|
|
|
2042
2580
|
return "The due day is before the start. Say a due day on or after it.\n";
|
|
2043
2581
|
case "goal.not_found":
|
|
2044
2582
|
return "No goal of this project has that id. console goals lists them.\n";
|
|
2583
|
+
case "goal.invalid_category":
|
|
2584
|
+
return "No category has that key. console categories lists them.\n";
|
|
2585
|
+
case "goal.invalid_dates":
|
|
2586
|
+
return "The target day is before the start. Say a target day on or after it.\n";
|
|
2045
2587
|
case "auth.forbidden":
|
|
2046
2588
|
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";
|
|
2047
2589
|
case "project.key_taken":
|
|
@@ -2082,9 +2624,23 @@ function hintFor(error: ApiError): string {
|
|
|
2082
2624
|
/** Runs the command and answers with its exit code. What it prints goes through `io`. */
|
|
2083
2625
|
export async function runCli(argv: readonly string[], io: CliIo): Promise<number> {
|
|
2084
2626
|
const [name, ...rest] = argv;
|
|
2627
|
+
// Every answer of the console says its version and the range it takes; the command reads
|
|
2628
|
+
// them off the answers it gets anyway, and asks nothing more (ADR-0025).
|
|
2629
|
+
const seen: ConsoleSeen = {};
|
|
2630
|
+
const watched: CliIo = {
|
|
2631
|
+
...io,
|
|
2632
|
+
fetch: async (input, init) => {
|
|
2633
|
+
const response = await io.fetch(input, init);
|
|
2634
|
+
seen.version = response.headers.get(VERSION_HEADER) ?? seen.version;
|
|
2635
|
+
seen.range = response.headers.get(CLIENT_RANGE_HEADER) ?? seen.range;
|
|
2636
|
+
return response;
|
|
2637
|
+
},
|
|
2638
|
+
};
|
|
2085
2639
|
try {
|
|
2086
2640
|
if (name === "--version" || name === "-v" || name === "version") {
|
|
2087
|
-
io.stdout(
|
|
2641
|
+
io.stdout(
|
|
2642
|
+
`console ${io.version ?? "(version unknown)"} (@skillcdn/console)${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}\n`,
|
|
2643
|
+
);
|
|
2088
2644
|
return EXIT.ok;
|
|
2089
2645
|
}
|
|
2090
2646
|
if (name === undefined || name === "help" || name === "--help" || name === "-h") {
|
|
@@ -2097,15 +2653,21 @@ export async function runCli(argv: readonly string[], io: CliIo): Promise<number
|
|
|
2097
2653
|
if (command === undefined) {
|
|
2098
2654
|
throw misuse(`Not a command: ${name}. Run: console help`);
|
|
2099
2655
|
}
|
|
2100
|
-
|
|
2656
|
+
const exit = await command(rest, watched);
|
|
2657
|
+
if (name !== "whoami") {
|
|
2658
|
+
await noticeVersions(io, seen);
|
|
2659
|
+
}
|
|
2660
|
+
return exit;
|
|
2101
2661
|
} catch (error) {
|
|
2102
2662
|
if (error instanceof CliExit) {
|
|
2103
2663
|
io.stderr(`${error.message}\n`);
|
|
2104
2664
|
return error.exit;
|
|
2105
2665
|
}
|
|
2106
2666
|
if (error instanceof ApiError) {
|
|
2107
|
-
io.stderr(`${error.code}: ${error.message}\n${hintFor(error)}`);
|
|
2108
|
-
return
|
|
2667
|
+
io.stderr(`${error.code}: ${error.message}\n${hintFor(error, io, seen)}`);
|
|
2668
|
+
return error.code === "client.outdated" || error.code === "client.too_new"
|
|
2669
|
+
? EXIT.incompatible
|
|
2670
|
+
: EXIT.failed;
|
|
2109
2671
|
}
|
|
2110
2672
|
throw error;
|
|
2111
2673
|
}
|