@skillcdn/console 0.1.17 → 0.1.18
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 +3 -3
- package/dist/api.d.ts +2 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -0
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +10 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +145 -14
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +8 -3
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +7 -5
- 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 +15 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +8 -3
- 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/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/goals.d.ts +3 -1
- package/dist/components/goals.d.ts.map +1 -1
- package/dist/components/goals.js +23 -11
- package/dist/components/goals.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/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 +204 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +8 -7
- package/dist/console.js.map +1 -1
- package/dist/data.js +1 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +24 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +24 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +24 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +6 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +10 -1
- package/dist/router.js.map +1 -1
- package/dist/schemas.d.ts +42 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +18 -0
- package/dist/schemas.js.map +1 -1
- package/docs/specs/cli.md +9 -6
- package/docs/specs/rest.md +9 -5
- package/package.json +3 -2
- package/skills/working-the-board/SKILL.md +4 -3
- package/src/api.ts +23 -0
- package/src/cli/cli.ts +178 -15
- package/src/cli/format.ts +8 -3
- package/src/cli/help.ts +7 -5
- 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 +31 -6
- package/src/compat.ts +173 -0
- package/src/components/calendar.tsx +41 -1
- package/src/components/goals.tsx +86 -7
- package/src/components/roadmap.tsx +242 -0
- package/src/components/tokens.tsx +38 -1
- package/src/console.tsx +19 -0
- package/src/data.ts +1 -1
- package/src/i18n/en.ts +24 -0
- package/src/i18n/ko.ts +24 -0
- package/src/index.ts +8 -0
- package/src/router.ts +13 -2
- package/src/schemas.ts +20 -0
- package/src/styles/console.css +204 -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";
|
|
@@ -67,6 +68,8 @@ import {
|
|
|
67
68
|
formatWork,
|
|
68
69
|
} from "./format.js";
|
|
69
70
|
import { COMMAND_HELP, helpFor, usage } from "./help.js";
|
|
71
|
+
import { describeInstall, type Install, updateCommandFor } from "./install.js";
|
|
72
|
+
import type { NoticeStore } from "./notices.js";
|
|
70
73
|
import { createServeHandler, DEFAULT_SERVE_PORT, type Listen } from "./serve.js";
|
|
71
74
|
|
|
72
75
|
// The `console` command (docs/specs/cli.md): the agent's side of the console. A thin client of
|
|
@@ -84,8 +87,13 @@ export const EXIT = {
|
|
|
84
87
|
usage: 2,
|
|
85
88
|
/** The decision still waits for a person. */
|
|
86
89
|
waiting: 3,
|
|
90
|
+
/** The command and the console do not go together (ADR-0025): stderr says which to update, and how. */
|
|
91
|
+
incompatible: 4,
|
|
87
92
|
} as const;
|
|
88
93
|
|
|
94
|
+
/** A day, for how often the command repeats what it noticed of the versions. */
|
|
95
|
+
const DAY_MS = 86_400_000;
|
|
96
|
+
|
|
89
97
|
/** How long one request for a decision's answer asks the console to hold it: what the server allows. */
|
|
90
98
|
const WAIT_STEP_SECONDS = 50;
|
|
91
99
|
/** A breath between two such requests, should the console answer at once. */
|
|
@@ -117,6 +125,12 @@ export interface CliIo {
|
|
|
117
125
|
readonly now: () => number;
|
|
118
126
|
/** The version of the package the command runs from, when the entry point knows it. */
|
|
119
127
|
readonly version?: string | undefined;
|
|
128
|
+
/** What the command calls itself on every request (ADR-0025); nothing when it does not know its version. */
|
|
129
|
+
readonly userAgent?: string | undefined;
|
|
130
|
+
/** Where the command is installed, so that an update is told the way it takes effect; nothing when unknown. */
|
|
131
|
+
readonly install?: Install | undefined;
|
|
132
|
+
/** What the command last said of the versions; left out, it says it every time. */
|
|
133
|
+
readonly notices?: NoticeStore | undefined;
|
|
120
134
|
/** Where the console's own skills are on this machine, shipped with the package; nothing when they are not there. */
|
|
121
135
|
readonly skillsDir?: string | undefined;
|
|
122
136
|
/** How `serve` listens on the loopback (ADR-0014); left out, the command cannot serve. */
|
|
@@ -200,7 +214,7 @@ async function open(io: CliIo, values: Values): Promise<Session> {
|
|
|
200
214
|
"Not signed in. A person signs the command in: console login --url <the console's origin>",
|
|
201
215
|
);
|
|
202
216
|
}
|
|
203
|
-
const client =
|
|
217
|
+
const client = clientOf(io, url, token);
|
|
204
218
|
const projectKey = async () =>
|
|
205
219
|
text(values, "project") ?? io.env.CONSOLE_PROJECT ?? (await io.directory.load())?.project;
|
|
206
220
|
return {
|
|
@@ -505,7 +519,7 @@ type Command = (args: readonly string[], io: CliIo) => Promise<number>;
|
|
|
505
519
|
|
|
506
520
|
/** Checks a token against the console, keeps it with the address, and says who it is. */
|
|
507
521
|
async function keep(io: CliIo, url: string, token: string, agent?: string): Promise<number> {
|
|
508
|
-
const me = await
|
|
522
|
+
const me = await clientOf(io, url, token).me();
|
|
509
523
|
const person = me.person;
|
|
510
524
|
if (person === null) {
|
|
511
525
|
throw failed(
|
|
@@ -540,7 +554,7 @@ const login: Command = async (args, io) => {
|
|
|
540
554
|
}
|
|
541
555
|
// A connection (ADR-0011): a code a person approves on the console's own pages, and the token
|
|
542
556
|
// handed over here once, never shown.
|
|
543
|
-
const client =
|
|
557
|
+
const client = clientOf(io, url);
|
|
544
558
|
// "<agent> on <machine>": the pages say the agent and keep the machine for a hover.
|
|
545
559
|
const agent = text(values, "agent") ?? agentOf(io.env) ?? "An agent";
|
|
546
560
|
const begun = await client.connect({
|
|
@@ -582,7 +596,7 @@ const logout: Command = async (args, io) => {
|
|
|
582
596
|
// Agents page, and the person is told so.
|
|
583
597
|
let gone = true;
|
|
584
598
|
try {
|
|
585
|
-
await
|
|
599
|
+
await clientOf(io, url, token).giveUpToken();
|
|
586
600
|
} catch (error) {
|
|
587
601
|
if (!(error instanceof ApiError && error.code === "auth.required")) {
|
|
588
602
|
gone = false;
|
|
@@ -620,12 +634,38 @@ const whoami: Command = async (args, io) => {
|
|
|
620
634
|
throw failed("The console does not know this token. Sign in again: console login");
|
|
621
635
|
}
|
|
622
636
|
const key = await session.projectKey();
|
|
637
|
+
const command = parseVersion(io.version);
|
|
638
|
+
const server = parseVersion(me.console?.version);
|
|
639
|
+
const verdict =
|
|
640
|
+
command === undefined || server === undefined
|
|
641
|
+
? undefined
|
|
642
|
+
: judgeClient(command, server).verdict;
|
|
643
|
+
// A console from before the rule says nothing of itself; the command says so instead.
|
|
644
|
+
const consoleWords =
|
|
645
|
+
me.console === undefined
|
|
646
|
+
? "the console does not say its version, so it is older than 0.1.18"
|
|
647
|
+
: `the console is ${me.console.version} and takes ${me.console.clientRange}`;
|
|
623
648
|
answer(
|
|
624
649
|
io,
|
|
625
650
|
session,
|
|
626
|
-
{
|
|
651
|
+
{
|
|
652
|
+
...me,
|
|
653
|
+
console: me.console ?? null,
|
|
654
|
+
project: key ?? null,
|
|
655
|
+
command: { version: io.version ?? null, install: io.install ?? null },
|
|
656
|
+
},
|
|
627
657
|
() =>
|
|
628
|
-
|
|
658
|
+
[
|
|
659
|
+
`${person.login} (${person.role}) at ${me.workspace.name}, ${session.url}${key === undefined ? "; no project named here" : `; project ${key}`}`,
|
|
660
|
+
`This command is ${io.version ?? "of a version it does not know"}${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}; ${consoleWords}.`,
|
|
661
|
+
...(verdict === "behind"
|
|
662
|
+
? [`A newer command exists. Update it: ${updateWords(io, me.console?.version)}`]
|
|
663
|
+
: verdict === "ahead"
|
|
664
|
+
? [
|
|
665
|
+
`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)}`,
|
|
666
|
+
]
|
|
667
|
+
: []),
|
|
668
|
+
].join("\n"),
|
|
629
669
|
);
|
|
630
670
|
return EXIT.ok;
|
|
631
671
|
};
|
|
@@ -1417,13 +1457,24 @@ const restore: Command = async (args, io) => {
|
|
|
1417
1457
|
};
|
|
1418
1458
|
|
|
1419
1459
|
const goals: Command = async (args, io) => {
|
|
1420
|
-
const { values } = parse(
|
|
1460
|
+
const { values } = parse(
|
|
1461
|
+
args,
|
|
1462
|
+
{ category: { type: "string" }, state: { type: "string" } },
|
|
1463
|
+
false,
|
|
1464
|
+
);
|
|
1465
|
+
const state = goalStateOf(text(values, "state"));
|
|
1466
|
+
const category = text(values, "category");
|
|
1421
1467
|
const session = await open(io, values);
|
|
1422
1468
|
const project = await session.project();
|
|
1423
|
-
const found = await project.goals(
|
|
1469
|
+
const found = await project.goals({
|
|
1470
|
+
...(state === undefined ? {} : { state }),
|
|
1471
|
+
...(category === undefined ? {} : { category }),
|
|
1472
|
+
});
|
|
1424
1473
|
answer(io, session, found, () =>
|
|
1425
1474
|
found.items.length === 0
|
|
1426
|
-
?
|
|
1475
|
+
? state === undefined && category === undefined
|
|
1476
|
+
? "No goals. An owner sets one: console goal new <title> --due <day>"
|
|
1477
|
+
: "No goal matches."
|
|
1427
1478
|
: found.items.map(formatGoalLine).join("\n"),
|
|
1428
1479
|
);
|
|
1429
1480
|
return EXIT.ok;
|
|
@@ -1431,15 +1482,24 @@ const goals: Command = async (args, io) => {
|
|
|
1431
1482
|
|
|
1432
1483
|
const GOAL_FIELDS: OptionSpec = {
|
|
1433
1484
|
state: { type: "string" },
|
|
1485
|
+
category: { type: "string" },
|
|
1486
|
+
starts: { type: "string" },
|
|
1434
1487
|
due: { type: "string" },
|
|
1435
1488
|
body: { type: "string" },
|
|
1436
1489
|
file: { type: "string" },
|
|
1437
1490
|
};
|
|
1438
1491
|
|
|
1492
|
+
/** A category's key as a flag names one, or `null` for `none` where the field may be cleared. */
|
|
1493
|
+
function categoryOf(value: string | undefined, clearable = false): string | null | undefined {
|
|
1494
|
+
return clearable && value === "none" ? null : value;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1439
1497
|
const newGoal: Command = async (args, io) => {
|
|
1440
1498
|
const { values, positionals } = parse(args, GOAL_FIELDS);
|
|
1441
1499
|
const title = theOne(positionals, 'Say the title: console goal new "<title>" [--due <day>]');
|
|
1442
1500
|
const state = goalStateOf(text(values, "state"));
|
|
1501
|
+
const category = categoryOf(text(values, "category"));
|
|
1502
|
+
const startsOn = dayOf(io, text(values, "starts"));
|
|
1443
1503
|
const dueOn = dayOf(io, text(values, "due"));
|
|
1444
1504
|
const body = await bodyOf(io, values, text(values, "body"));
|
|
1445
1505
|
const session = await open(io, values);
|
|
@@ -1448,6 +1508,8 @@ const newGoal: Command = async (args, io) => {
|
|
|
1448
1508
|
title,
|
|
1449
1509
|
...(body === undefined ? {} : { body }),
|
|
1450
1510
|
...(state === undefined ? {} : { state }),
|
|
1511
|
+
...(category === undefined ? {} : { category }),
|
|
1512
|
+
...(startsOn === undefined ? {} : { startsOn }),
|
|
1451
1513
|
...(dueOn === undefined ? {} : { dueOn }),
|
|
1452
1514
|
});
|
|
1453
1515
|
answer(io, session, made, () => `Goal set: ${made.title}\nid: ${made.id}`);
|
|
@@ -1459,17 +1521,21 @@ const editGoal: Command = async (args, io) => {
|
|
|
1459
1521
|
const id = theOne(positionals, "Say which goal: console goal edit <id> --state <state> ...");
|
|
1460
1522
|
const title = text(values, "title");
|
|
1461
1523
|
const state = goalStateOf(text(values, "state"));
|
|
1524
|
+
const category = categoryOf(text(values, "category"), true);
|
|
1525
|
+
const startsOn = dayOf(io, text(values, "starts"), true);
|
|
1462
1526
|
const dueOn = dayOf(io, text(values, "due"), true);
|
|
1463
1527
|
const body = await bodyOf(io, values, text(values, "body"));
|
|
1464
1528
|
const patch = {
|
|
1465
1529
|
...(title === undefined ? {} : { title }),
|
|
1466
1530
|
...(body === undefined ? {} : { body }),
|
|
1467
1531
|
...(state === undefined ? {} : { state }),
|
|
1532
|
+
...(category === undefined ? {} : { category }),
|
|
1533
|
+
...(startsOn === undefined ? {} : { startsOn }),
|
|
1468
1534
|
...(dueOn === undefined ? {} : { dueOn }),
|
|
1469
1535
|
};
|
|
1470
1536
|
if (Object.keys(patch).length === 0) {
|
|
1471
1537
|
throw misuse(
|
|
1472
|
-
"Say what to change: --title, --body, --state or --due;
|
|
1538
|
+
"Say what to change: --title, --body, --state, --category, --starts or --due; none clears a category or a day.",
|
|
1473
1539
|
);
|
|
1474
1540
|
}
|
|
1475
1541
|
const session = await open(io, values);
|
|
@@ -2021,9 +2087,82 @@ function help(io: CliIo, name: string | undefined): number {
|
|
|
2021
2087
|
return EXIT.ok;
|
|
2022
2088
|
}
|
|
2023
2089
|
|
|
2090
|
+
/** A client of the console at `url`, naming the command on every request (ADR-0025). */
|
|
2091
|
+
function clientOf(io: CliIo, url: string, token?: string): ConsoleClient {
|
|
2092
|
+
return createClient({
|
|
2093
|
+
baseUrl: url,
|
|
2094
|
+
fetch: io.fetch,
|
|
2095
|
+
...(token === undefined ? {} : { token }),
|
|
2096
|
+
...(io.userAgent === undefined ? {} : { userAgent: io.userAgent }),
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/** What the console said of itself on its last answer (ADR-0025): its version, and the range of command versions it takes. */
|
|
2101
|
+
interface ConsoleSeen {
|
|
2102
|
+
version?: string | undefined;
|
|
2103
|
+
range?: string | undefined;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/** The command that puts the console's version where this command is installed. */
|
|
2107
|
+
function updateWords(io: CliIo, version: string | undefined): string {
|
|
2108
|
+
const wanted = version ?? "latest";
|
|
2109
|
+
if (io.install === undefined) {
|
|
2110
|
+
return `npm install -g @skillcdn/console@${wanted}`;
|
|
2111
|
+
}
|
|
2112
|
+
const command = updateCommandFor(io.install, wanted);
|
|
2113
|
+
return io.install.kind === "project" ? `${command} (in ${io.install.root})` : command;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* What the command says of the versions once the console answered: that a newer command exists,
|
|
2118
|
+
* or that this one is newer than the console; once a day for one pair of versions, on stderr,
|
|
2119
|
+
* so that the console's JSON stays what it is. What is out of the range never gets here: the
|
|
2120
|
+
* console refused it, and the refusal says what to do.
|
|
2121
|
+
*/
|
|
2122
|
+
async function noticeVersions(io: CliIo, seen: ConsoleSeen): Promise<void> {
|
|
2123
|
+
const command = parseVersion(io.version);
|
|
2124
|
+
const server = parseVersion(seen.version);
|
|
2125
|
+
if (
|
|
2126
|
+
io.version === undefined ||
|
|
2127
|
+
seen.version === undefined ||
|
|
2128
|
+
command === undefined ||
|
|
2129
|
+
server === undefined
|
|
2130
|
+
) {
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
const { verdict } = judgeClient(command, server);
|
|
2134
|
+
if (verdict !== "behind" && verdict !== "ahead") {
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
const previous = await io.notices?.load();
|
|
2138
|
+
const now = io.now();
|
|
2139
|
+
if (
|
|
2140
|
+
previous !== undefined &&
|
|
2141
|
+
previous.command === io.version &&
|
|
2142
|
+
previous.console === seen.version &&
|
|
2143
|
+
now - Date.parse(previous.shownAt) < DAY_MS
|
|
2144
|
+
) {
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
io.stderr(
|
|
2148
|
+
verdict === "behind"
|
|
2149
|
+
? `A newer command exists: the console runs ${seen.version}, this command is ${io.version}. Update it: ${updateWords(io, seen.version)}\n`
|
|
2150
|
+
: `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`,
|
|
2151
|
+
);
|
|
2152
|
+
await io.notices?.save({
|
|
2153
|
+
command: io.version,
|
|
2154
|
+
console: seen.version,
|
|
2155
|
+
shownAt: new Date(now).toISOString(),
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2024
2159
|
/** What a refusal of the console is followed by, when there is something to do about it. */
|
|
2025
|
-
function hintFor(error: ApiError): string {
|
|
2160
|
+
function hintFor(error: ApiError, io: CliIo, seen: ConsoleSeen): string {
|
|
2026
2161
|
switch (error.code) {
|
|
2162
|
+
case "client.outdated":
|
|
2163
|
+
return `Update the command: ${updateWords(io, seen.version)}\n`;
|
|
2164
|
+
case "client.too_new":
|
|
2165
|
+
return `Install the console's version: ${updateWords(io, seen.version)}; or ask the console's operator to update the console.\n`;
|
|
2027
2166
|
case "auth.required":
|
|
2028
2167
|
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
2168
|
case "project.not_found":
|
|
@@ -2042,6 +2181,10 @@ function hintFor(error: ApiError): string {
|
|
|
2042
2181
|
return "The due day is before the start. Say a due day on or after it.\n";
|
|
2043
2182
|
case "goal.not_found":
|
|
2044
2183
|
return "No goal of this project has that id. console goals lists them.\n";
|
|
2184
|
+
case "goal.invalid_category":
|
|
2185
|
+
return "No category has that key. console categories lists them.\n";
|
|
2186
|
+
case "goal.invalid_dates":
|
|
2187
|
+
return "The target day is before the start. Say a target day on or after it.\n";
|
|
2045
2188
|
case "auth.forbidden":
|
|
2046
2189
|
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
2190
|
case "project.key_taken":
|
|
@@ -2082,9 +2225,23 @@ function hintFor(error: ApiError): string {
|
|
|
2082
2225
|
/** Runs the command and answers with its exit code. What it prints goes through `io`. */
|
|
2083
2226
|
export async function runCli(argv: readonly string[], io: CliIo): Promise<number> {
|
|
2084
2227
|
const [name, ...rest] = argv;
|
|
2228
|
+
// Every answer of the console says its version and the range it takes; the command reads
|
|
2229
|
+
// them off the answers it gets anyway, and asks nothing more (ADR-0025).
|
|
2230
|
+
const seen: ConsoleSeen = {};
|
|
2231
|
+
const watched: CliIo = {
|
|
2232
|
+
...io,
|
|
2233
|
+
fetch: async (input, init) => {
|
|
2234
|
+
const response = await io.fetch(input, init);
|
|
2235
|
+
seen.version = response.headers.get(VERSION_HEADER) ?? seen.version;
|
|
2236
|
+
seen.range = response.headers.get(CLIENT_RANGE_HEADER) ?? seen.range;
|
|
2237
|
+
return response;
|
|
2238
|
+
},
|
|
2239
|
+
};
|
|
2085
2240
|
try {
|
|
2086
2241
|
if (name === "--version" || name === "-v" || name === "version") {
|
|
2087
|
-
io.stdout(
|
|
2242
|
+
io.stdout(
|
|
2243
|
+
`console ${io.version ?? "(version unknown)"} (@skillcdn/console)${io.install === undefined ? "" : `, ${describeInstall(io.install)}`}\n`,
|
|
2244
|
+
);
|
|
2088
2245
|
return EXIT.ok;
|
|
2089
2246
|
}
|
|
2090
2247
|
if (name === undefined || name === "help" || name === "--help" || name === "-h") {
|
|
@@ -2097,15 +2254,21 @@ export async function runCli(argv: readonly string[], io: CliIo): Promise<number
|
|
|
2097
2254
|
if (command === undefined) {
|
|
2098
2255
|
throw misuse(`Not a command: ${name}. Run: console help`);
|
|
2099
2256
|
}
|
|
2100
|
-
|
|
2257
|
+
const exit = await command(rest, watched);
|
|
2258
|
+
if (name !== "whoami") {
|
|
2259
|
+
await noticeVersions(io, seen);
|
|
2260
|
+
}
|
|
2261
|
+
return exit;
|
|
2101
2262
|
} catch (error) {
|
|
2102
2263
|
if (error instanceof CliExit) {
|
|
2103
2264
|
io.stderr(`${error.message}\n`);
|
|
2104
2265
|
return error.exit;
|
|
2105
2266
|
}
|
|
2106
2267
|
if (error instanceof ApiError) {
|
|
2107
|
-
io.stderr(`${error.code}: ${error.message}\n${hintFor(error)}`);
|
|
2108
|
-
return
|
|
2268
|
+
io.stderr(`${error.code}: ${error.message}\n${hintFor(error, io, seen)}`);
|
|
2269
|
+
return error.code === "client.outdated" || error.code === "client.too_new"
|
|
2270
|
+
? EXIT.incompatible
|
|
2271
|
+
: EXIT.failed;
|
|
2109
2272
|
}
|
|
2110
2273
|
throw error;
|
|
2111
2274
|
}
|
package/src/cli/format.ts
CHANGED
|
@@ -314,9 +314,14 @@ function progressOf(goal: RestGoal): string {
|
|
|
314
314
|
return `${counts.done}/${total} done${working === 0 ? "" : `, ${working} in progress`}${counts.dropped === 0 ? "" : `, ${counts.dropped} dropped`}`;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
/** One goal: its id, its state, its title,
|
|
317
|
+
/** One goal: its id, its state, its title, its category, its days, and how far it is. */
|
|
318
318
|
export function formatGoalLine(goal: RestGoal): string {
|
|
319
|
-
const notes = [
|
|
319
|
+
const notes = [
|
|
320
|
+
...(goal.category === null ? [] : [goal.category.key]),
|
|
321
|
+
...(goal.startsOn === null ? [] : [`starts ${goal.startsOn}`]),
|
|
322
|
+
goal.dueOn === null ? "no target day" : `due ${goal.dueOn}`,
|
|
323
|
+
progressOf(goal),
|
|
324
|
+
];
|
|
320
325
|
return `${goal.id} ${goal.state} ${goal.title} (${notes.join("; ")})`;
|
|
321
326
|
}
|
|
322
327
|
|
|
@@ -324,7 +329,7 @@ export function formatGoalLine(goal: RestGoal): string {
|
|
|
324
329
|
export function formatGoal(goal: RestGoal, tasks?: readonly RestTask[]): string {
|
|
325
330
|
const lines = [
|
|
326
331
|
`goal ${goal.id}: ${goal.title}`,
|
|
327
|
-
`state: ${goal.state} due: ${goal.dueOn ?? "none"} set by: ${goal.createdBy.login}`,
|
|
332
|
+
`state: ${goal.state} category: ${goal.category?.key ?? "none"} starts: ${goal.startsOn ?? "none"} due: ${goal.dueOn ?? "none"} set by: ${goal.createdBy.login}`,
|
|
328
333
|
`progress: ${progressOf(goal)}`,
|
|
329
334
|
];
|
|
330
335
|
if (goal.body.trim().length > 0) {
|
package/src/cli/help.ts
CHANGED
|
@@ -135,22 +135,24 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
|
|
135
135
|
refusals: ["task.not_found: no task has that number or id", ...TASK_FIELD_REFUSALS],
|
|
136
136
|
},
|
|
137
137
|
goals: {
|
|
138
|
-
usage: "goals",
|
|
138
|
+
usage: "goals [--category <key>] [--state <state>]",
|
|
139
139
|
about:
|
|
140
|
-
"The roadmap of the project: its goals, the active first, each with its id, its state (planned, active, done, dropped), its
|
|
140
|
+
"The roadmap of the project: its goals, the active first, each with its id, its state (planned, active, done, dropped), its category, the days it starts and is meant to be reached by, and how far it is, counted from the tasks that work towards it. --category keeps those sorted into one, --state those in one state.",
|
|
141
141
|
},
|
|
142
142
|
goal: {
|
|
143
143
|
usage:
|
|
144
|
-
'goal <id> | goal new "<title>" [--body <markdown>|--file <path>] [--state <state>] [--due <day>] | goal edit <id> [--title <title>] [--body <markdown>|--file <path>] [--state <state>] [--due <day|none>]',
|
|
144
|
+
'goal <id> | goal new "<title>" [--body <markdown>|--file <path>] [--state <state>] [--category <key>] [--starts <day>] [--due <day>] | goal edit <id> [--title <title>] [--body <markdown>|--file <path>] [--state <state>] [--category <key|none>] [--starts <day|none>] [--due <day|none>]',
|
|
145
145
|
about:
|
|
146
|
-
"One goal in full, with the tasks that work towards it; or a goal set on the roadmap, for an owner of the project; or one changed, only in what the flags say. A task works towards a goal through its --goal.",
|
|
146
|
+
"One goal in full, with the tasks that work towards it; or a goal set on the roadmap, for an owner of the project; or one changed, only in what the flags say, where none clears a category or a day. A goal is planned in days as a task is, a start and a target, and sorted into a category of the workspace (console categories). A task works towards a goal through its --goal.",
|
|
147
147
|
limits: [
|
|
148
148
|
`title: one line, up to ${MAX_TITLE_LENGTH} characters`,
|
|
149
149
|
`body: ${MARKDOWN}`,
|
|
150
|
-
`due: ${DAY}`,
|
|
150
|
+
`starts, due: ${DAY}`,
|
|
151
151
|
],
|
|
152
152
|
refusals: [
|
|
153
153
|
"goal.not_found: no goal of this project has that id",
|
|
154
|
+
"goal.invalid_category: no category of the workspace has that key",
|
|
155
|
+
"goal.invalid_dates: the target day is before the start",
|
|
154
156
|
"auth.forbidden: only an owner of the project sets its roadmap",
|
|
155
157
|
],
|
|
156
158
|
},
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Where the command runs from, so that the update it is told to make is the one that takes
|
|
2
|
+
// effect: installed for the machine, in a project, run once from a cache, or a checkout of the
|
|
3
|
+
// repository. Read from the package's own path and the working directory; nothing is asked of
|
|
4
|
+
// the system, so that a notice about versions costs a command nothing.
|
|
5
|
+
|
|
6
|
+
export type InstallKind = "global" | "project" | "temporary" | "checkout";
|
|
7
|
+
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
|
|
8
|
+
|
|
9
|
+
export interface Install {
|
|
10
|
+
readonly kind: InstallKind;
|
|
11
|
+
readonly manager: PackageManager;
|
|
12
|
+
/** The directory the package is installed in: a project's root, the machine's global one, a cache's; the checkout itself. */
|
|
13
|
+
readonly root: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface InstallClues {
|
|
17
|
+
/** The package's own directory, where its manifest is. */
|
|
18
|
+
readonly packageDir: string;
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
21
|
+
readonly exists: (path: string) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PACKAGE = "@skillcdn/console";
|
|
25
|
+
|
|
26
|
+
const slashes = (path: string): string => path.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
27
|
+
|
|
28
|
+
/** The package manager that ran this command, when one did: `pnpm exec`, `npx` and their kind say so. */
|
|
29
|
+
function managerRunning(env: InstallClues["env"]): PackageManager | undefined {
|
|
30
|
+
const agent = env.npm_config_user_agent ?? "";
|
|
31
|
+
for (const manager of ["pnpm", "yarn", "bun", "npm"] as const) {
|
|
32
|
+
if (agent.startsWith(`${manager}/`)) {
|
|
33
|
+
return manager;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function detectInstall(clues: InstallClues): Install {
|
|
40
|
+
const dir = slashes(clues.packageDir);
|
|
41
|
+
const cwd = slashes(clues.cwd);
|
|
42
|
+
const at = dir.indexOf("/node_modules/");
|
|
43
|
+
if (at < 0) {
|
|
44
|
+
return { kind: "checkout", manager: "pnpm", root: dir };
|
|
45
|
+
}
|
|
46
|
+
const root = dir.slice(0, at);
|
|
47
|
+
const lower = dir.toLowerCase();
|
|
48
|
+
const running = managerRunning(clues.env);
|
|
49
|
+
// What `npx`, `pnpm dlx`, `yarn dlx` and `bunx` fetch into a cache lives only for the run.
|
|
50
|
+
if (/\/(_npx|dlx|dlx-[^/]*)\//.test(lower) || lower.includes("/.bun/install/cache/")) {
|
|
51
|
+
const manager =
|
|
52
|
+
running ??
|
|
53
|
+
(lower.includes("/_npx/") ? "npm" : lower.includes("/.bun/install/cache/") ? "bun" : "pnpm");
|
|
54
|
+
return { kind: "temporary", manager, root };
|
|
55
|
+
}
|
|
56
|
+
if (cwd === root || cwd.startsWith(`${root}/`)) {
|
|
57
|
+
const manager: PackageManager = clues.exists(`${root}/pnpm-lock.yaml`)
|
|
58
|
+
? "pnpm"
|
|
59
|
+
: clues.exists(`${root}/yarn.lock`)
|
|
60
|
+
? "yarn"
|
|
61
|
+
: clues.exists(`${root}/bun.lock`) || clues.exists(`${root}/bun.lockb`)
|
|
62
|
+
? "bun"
|
|
63
|
+
: (running ?? "npm");
|
|
64
|
+
return { kind: "project", manager, root };
|
|
65
|
+
}
|
|
66
|
+
const manager: PackageManager = /\/pnpm\/global\/|\/\.pnpm-global\//.test(lower)
|
|
67
|
+
? "pnpm"
|
|
68
|
+
: lower.includes("/yarn/global/")
|
|
69
|
+
? "yarn"
|
|
70
|
+
: lower.includes("/.bun/install/global/")
|
|
71
|
+
? "bun"
|
|
72
|
+
: "npm";
|
|
73
|
+
return { kind: "global", manager, root };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The command that puts the version named where this command is installed. */
|
|
77
|
+
export function updateCommandFor(install: Install, version: string): string {
|
|
78
|
+
const spec = `${PACKAGE}@${version}`;
|
|
79
|
+
switch (install.kind) {
|
|
80
|
+
case "global":
|
|
81
|
+
return install.manager === "npm"
|
|
82
|
+
? `npm install -g ${spec}`
|
|
83
|
+
: install.manager === "yarn"
|
|
84
|
+
? `yarn global add ${spec}`
|
|
85
|
+
: `${install.manager} add -g ${spec}`;
|
|
86
|
+
case "project":
|
|
87
|
+
return `${install.manager === "npm" ? "npm install" : `${install.manager} add`} ${spec}`;
|
|
88
|
+
case "temporary":
|
|
89
|
+
return install.manager === "npm"
|
|
90
|
+
? `npx ${spec}`
|
|
91
|
+
: install.manager === "bun"
|
|
92
|
+
? `bunx ${spec}`
|
|
93
|
+
: `${install.manager} dlx ${spec}`;
|
|
94
|
+
case "checkout":
|
|
95
|
+
return "git pull && pnpm install && pnpm build";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where the command is installed, in words: what `whoami` and `--version` say. */
|
|
100
|
+
export function describeInstall(install: Install): string {
|
|
101
|
+
switch (install.kind) {
|
|
102
|
+
case "global":
|
|
103
|
+
return `installed for the machine with ${install.manager} in ${install.root}`;
|
|
104
|
+
case "project":
|
|
105
|
+
return `installed in the project at ${install.root} with ${install.manager}`;
|
|
106
|
+
case "temporary":
|
|
107
|
+
return `fetched for this run by ${install.manager}`;
|
|
108
|
+
case "checkout":
|
|
109
|
+
return `built from the checkout at ${install.root}`;
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -5,21 +5,28 @@ import { hostname } from "node:os";
|
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import * as z from "zod/mini";
|
|
8
|
+
import { userAgentOf } from "../compat.js";
|
|
8
9
|
import { runCli } from "./cli.js";
|
|
9
10
|
import { createFileStore, defaultConfigDir } from "./credentials.js";
|
|
10
11
|
import { createDirectoryStore } from "./directory.js";
|
|
12
|
+
import { detectInstall } from "./install.js";
|
|
13
|
+
import { createNoticeStore } from "./notices.js";
|
|
11
14
|
import { listenOn } from "./serve.js";
|
|
12
15
|
import { readBytesAt, readFileAt, readStdin, writeBytesAt } from "./terminal.js";
|
|
13
16
|
|
|
14
17
|
// The `console` command: the only place the command reads the process. Everything it does is in
|
|
15
18
|
// cli.ts, which takes what it needs and is what the tests run.
|
|
16
19
|
|
|
17
|
-
/** The
|
|
20
|
+
/** The package's own directory, two levels above dist/cli: where its manifest and its skills are. */
|
|
21
|
+
const packageDir = fileURLToPath(new URL("../../", import.meta.url));
|
|
22
|
+
/** The version of the package this file runs from: its own manifest. */
|
|
18
23
|
const manifest = z
|
|
19
24
|
.object({ version: z.string() })
|
|
20
25
|
.safeParse(createRequire(import.meta.url)("../../package.json"));
|
|
26
|
+
const version = manifest.success ? manifest.data.version : undefined;
|
|
21
27
|
/** The console's own skills, shipped with the package beside dist/ (ADR-0015); not there in a checkout before packing. */
|
|
22
28
|
const skillsDir = fileURLToPath(new URL("../../skills/", import.meta.url));
|
|
29
|
+
const configDir = defaultConfigDir(process.env);
|
|
23
30
|
|
|
24
31
|
process.exitCode = await runCli(process.argv.slice(2), {
|
|
25
32
|
env: process.env,
|
|
@@ -35,11 +42,24 @@ process.exitCode = await runCli(process.argv.slice(2), {
|
|
|
35
42
|
readFile: readFileAt,
|
|
36
43
|
readBytes: readBytesAt,
|
|
37
44
|
writeBytes: writeBytesAt,
|
|
38
|
-
store: createFileStore(
|
|
45
|
+
store: createFileStore(configDir),
|
|
39
46
|
directory: createDirectoryStore(process.cwd()),
|
|
40
47
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
41
48
|
now: () => Date.now(),
|
|
42
|
-
version
|
|
49
|
+
version,
|
|
50
|
+
// The command names itself on every request, and where it runs, so that the console holds it
|
|
51
|
+
// to the versions it takes and the access log says which commands are about (ADR-0025).
|
|
52
|
+
userAgent:
|
|
53
|
+
version === undefined
|
|
54
|
+
? undefined
|
|
55
|
+
: userAgentOf(version, `cli; node ${process.versions.node}; ${process.platform}`),
|
|
56
|
+
install: detectInstall({
|
|
57
|
+
packageDir,
|
|
58
|
+
cwd: process.cwd(),
|
|
59
|
+
env: process.env,
|
|
60
|
+
exists: existsSync,
|
|
61
|
+
}),
|
|
62
|
+
notices: createNoticeStore(configDir),
|
|
43
63
|
skillsDir: existsSync(skillsDir) ? skillsDir : undefined,
|
|
44
64
|
listen: listenOn,
|
|
45
65
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import * as z from "zod/mini";
|
|
4
|
+
|
|
5
|
+
// What the command last said about the versions, kept beside the credentials, so that it says
|
|
6
|
+
// it once a day for one pair of versions and not on every command an agent runs. A notice is
|
|
7
|
+
// not worth failing a command for: a file that cannot be read or written is as good as none.
|
|
8
|
+
|
|
9
|
+
export interface VersionNotice {
|
|
10
|
+
/** The version of the command that noticed. */
|
|
11
|
+
readonly command: string;
|
|
12
|
+
/** The version of the console it noticed. */
|
|
13
|
+
readonly console: string;
|
|
14
|
+
readonly shownAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NoticeStore {
|
|
18
|
+
load(): Promise<VersionNotice | undefined>;
|
|
19
|
+
save(notice: VersionNotice): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const noticesSchema = z.object({
|
|
23
|
+
versions: z.optional(z.object({ command: z.string(), console: z.string(), shownAt: z.string() })),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const FILE_NAME = "notices.json";
|
|
27
|
+
|
|
28
|
+
export function createNoticeStore(directory: string): NoticeStore {
|
|
29
|
+
const path = join(directory, FILE_NAME);
|
|
30
|
+
return {
|
|
31
|
+
async load() {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = noticesSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
|
|
34
|
+
return parsed.success ? parsed.data.versions : undefined;
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
async save(notice) {
|
|
40
|
+
try {
|
|
41
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
42
|
+
await writeFile(path, `${JSON.stringify({ versions: notice }, null, 2)}\n`);
|
|
43
|
+
} catch {
|
|
44
|
+
// Said this time; said again next time.
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|