@skillcdn/console 0.1.16 → 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 +4 -3
- package/dist/api.d.ts +4 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +3 -2
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +12 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +226 -21
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +8 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +31 -5
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +34 -8
- 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 +24 -4
- 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/cli/terminal.d.ts +5 -0
- package/dist/cli/terminal.d.ts.map +1 -1
- package/dist/cli/terminal.js +11 -1
- package/dist/cli/terminal.js.map +1 -1
- package/dist/client.d.ts +43 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +69 -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/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 +59 -0
- package/dist/components/comments.d.ts.map +1 -0
- package/dist/components/comments.js +138 -0
- package/dist/components/comments.js.map +1 -0
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +2 -0
- package/dist/components/event-feed.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/markdown.d.ts +6 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +2 -1
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +6 -1
- 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 +8 -3
- 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 +314 -2
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +30 -11
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +5 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +3 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +38 -2
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +38 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +38 -2
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +3 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +3 -0
- package/dist/limits.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/rules.js +2 -2
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +147 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +50 -1
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +1 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +17 -10
- package/docs/specs/rest.md +17 -6
- package/package.json +3 -2
- package/skills/working-the-board/SKILL.md +8 -5
- package/src/api.ts +35 -0
- package/src/cli/cli.ts +273 -22
- package/src/cli/format.ts +36 -4
- package/src/cli/help.ts +38 -7
- package/src/cli/install.ts +111 -0
- package/src/cli/main.ts +25 -4
- package/src/cli/notices.ts +48 -0
- package/src/cli/terminal.ts +12 -1
- package/src/client.ts +142 -8
- package/src/compat.ts +173 -0
- package/src/components/calendar.tsx +41 -1
- package/src/components/comments.tsx +342 -0
- package/src/components/event-feed.tsx +2 -0
- package/src/components/goals.tsx +86 -7
- package/src/components/markdown.tsx +9 -1
- package/src/components/projects.tsx +16 -3
- package/src/components/roadmap.tsx +242 -0
- package/src/components/task-view.tsx +32 -33
- package/src/components/tokens.tsx +38 -1
- package/src/console.tsx +45 -3
- package/src/data.ts +9 -1
- package/src/i18n/en.ts +40 -2
- package/src/i18n/ko.ts +40 -2
- package/src/index.ts +20 -0
- package/src/limits.ts +3 -0
- package/src/router.ts +13 -2
- package/src/rules.ts +2 -2
- package/src/schemas.ts +60 -1
- package/src/styles/console.css +314 -2
- package/src/vocabulary.ts +1 -0
|
@@ -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
|
-
import { readBytesAt, readFileAt, readStdin } from "./terminal.js";
|
|
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,
|
|
@@ -34,11 +41,25 @@ process.exitCode = await runCli(process.argv.slice(2), {
|
|
|
34
41
|
hostname: hostname(),
|
|
35
42
|
readFile: readFileAt,
|
|
36
43
|
readBytes: readBytesAt,
|
|
37
|
-
|
|
44
|
+
writeBytes: writeBytesAt,
|
|
45
|
+
store: createFileStore(configDir),
|
|
38
46
|
directory: createDirectoryStore(process.cwd()),
|
|
39
47
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
40
48
|
now: () => Date.now(),
|
|
41
|
-
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),
|
|
42
63
|
skillsDir: existsSync(skillsDir) ? skillsDir : undefined,
|
|
43
64
|
listen: listenOn,
|
|
44
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
|
+
}
|
package/src/cli/terminal.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
|
|
4
5
|
// The terminal's side of the command: what is read from it. Nothing here is reached by tests,
|
|
@@ -26,3 +27,13 @@ export function readFileAt(path: string): Promise<string> {
|
|
|
26
27
|
export function readBytesAt(path: string): Promise<Uint8Array> {
|
|
27
28
|
return readFile(path);
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A file read from the console, written at the path, or into it under the file's name when
|
|
33
|
+
* the path is a directory; says where it went, in full.
|
|
34
|
+
*/
|
|
35
|
+
export async function writeBytesAt(path: string, bytes: Uint8Array, name: string): Promise<string> {
|
|
36
|
+
const target = (await stat(path).catch(() => undefined))?.isDirectory() ? join(path, name) : path;
|
|
37
|
+
await writeFile(target, bytes);
|
|
38
|
+
return resolve(target);
|
|
39
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
type RestCategoryInput,
|
|
19
19
|
type RestCategoryPatch,
|
|
20
20
|
type RestClaim,
|
|
21
|
+
type RestComment,
|
|
22
|
+
type RestComments,
|
|
21
23
|
type RestConnectInput,
|
|
22
24
|
type RestConnection,
|
|
23
25
|
type RestConnectRequest,
|
|
@@ -71,6 +73,8 @@ import {
|
|
|
71
73
|
restCategoriesSchema,
|
|
72
74
|
restCategorySchema,
|
|
73
75
|
restClaimSchema,
|
|
76
|
+
restCommentSchema,
|
|
77
|
+
restCommentsSchema,
|
|
74
78
|
restConnectionSchema,
|
|
75
79
|
restConnectRequestSchema,
|
|
76
80
|
restDecisionSchema,
|
|
@@ -103,7 +107,7 @@ import {
|
|
|
103
107
|
restVersionsSchema,
|
|
104
108
|
restWorkSchema,
|
|
105
109
|
} from "./schemas.js";
|
|
106
|
-
import type { TaskState } from "./vocabulary.js";
|
|
110
|
+
import type { GoalState, TaskState } from "./vocabulary.js";
|
|
107
111
|
|
|
108
112
|
// The client of the console's REST API: what the default console and a custom one talk to the
|
|
109
113
|
// server with, and what the command line, a script or a console of a person's own talks to it
|
|
@@ -123,6 +127,22 @@ export interface FileUpload {
|
|
|
123
127
|
readonly label?: string | undefined;
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
/** A comment to write on a task: the word, and the files put in it, which go in the one request. */
|
|
131
|
+
export interface CommentInput {
|
|
132
|
+
/** Markdown; may be blank when a file comes with it. */
|
|
133
|
+
readonly body: string;
|
|
134
|
+
readonly files?: readonly FileUpload[] | undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The bytes of a file of the project, as the console hands them back, with what it calls them. */
|
|
138
|
+
export interface FileRead {
|
|
139
|
+
/** The file's name, as the console says it; the id when it says none. */
|
|
140
|
+
readonly name: string;
|
|
141
|
+
/** The media type the console answered with, without its parameters. */
|
|
142
|
+
readonly contentType: string;
|
|
143
|
+
readonly bytes: Uint8Array;
|
|
144
|
+
}
|
|
145
|
+
|
|
126
146
|
export interface ClientOptions {
|
|
127
147
|
/** The origin of the console, without a path. Empty: the origin of the page. */
|
|
128
148
|
readonly baseUrl?: string;
|
|
@@ -134,6 +154,12 @@ export interface ClientOptions {
|
|
|
134
154
|
* the page's own origin is what the console answers to.
|
|
135
155
|
*/
|
|
136
156
|
readonly token?: string;
|
|
157
|
+
/**
|
|
158
|
+
* What the client calls itself, sent as `user-agent` where the platform lets a client say
|
|
159
|
+
* (Node; a browser keeps its own): the command sends its product token, by which the console
|
|
160
|
+
* holds it to the versions it takes (ADR-0025).
|
|
161
|
+
*/
|
|
162
|
+
readonly userAgent?: string;
|
|
137
163
|
}
|
|
138
164
|
|
|
139
165
|
export class ApiError extends Error {
|
|
@@ -169,6 +195,13 @@ export interface Page {
|
|
|
169
195
|
}
|
|
170
196
|
|
|
171
197
|
/** What the board is narrowed to: one state, the open ones, a category, a goal, an assignee, or what is due by a day. */
|
|
198
|
+
/** What the roadmap is narrowed by: the goals in one state, or sorted into one category (ADR-0026). */
|
|
199
|
+
export interface GoalFilter {
|
|
200
|
+
readonly state?: GoalState | undefined;
|
|
201
|
+
/** The key of a category of the workspace. */
|
|
202
|
+
readonly category?: string | undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
172
205
|
export interface TaskFilter extends Page {
|
|
173
206
|
readonly state?: TaskState | undefined;
|
|
174
207
|
/** True for the tasks not done or dropped; false for the rest. */
|
|
@@ -245,8 +278,14 @@ export interface ProjectClient {
|
|
|
245
278
|
handIn(runId: string, input: RestArtifactInput): Promise<RestRun>;
|
|
246
279
|
/** Hands in a file, on a run of the asker's: the console keeps the bytes. */
|
|
247
280
|
handInFile(runId: string, input: FileUpload): Promise<RestRun>;
|
|
248
|
-
/** Where the bytes of a file
|
|
249
|
-
fileUrl(
|
|
281
|
+
/** Where the bytes of a file of the project are read, by its id: handed in by a run, put in a comment, or attached to a page. */
|
|
282
|
+
fileUrl(fileId: string): string;
|
|
283
|
+
/** The bytes of a file of the project, by its id, with the name and the type the console says. */
|
|
284
|
+
readFile(fileId: string, signal?: AbortSignal): Promise<FileRead>;
|
|
285
|
+
/** The discussion on a task: its comments, oldest first, whole. */
|
|
286
|
+
comments(taskId: string, signal?: AbortSignal): Promise<RestComments>;
|
|
287
|
+
/** Writes a comment on a task, with its files when given, in one request. */
|
|
288
|
+
comment(taskId: string, input: CommentInput): Promise<RestComment>;
|
|
250
289
|
/** Ends a run: finished or failed by the agent; abandoned by the person it is for, or an administrator. */
|
|
251
290
|
endRun(runId: string, input: RestRunEndInput): Promise<RestRun>;
|
|
252
291
|
/** What happened in the project after event number `after`; `0` for the beginning. Narrowed by `filter`. */
|
|
@@ -276,7 +315,8 @@ export interface ProjectClient {
|
|
|
276
315
|
/** Where the bytes of a file attached to a document are read, by the file's id. */
|
|
277
316
|
documentFileUrl(path: string, fileId: string): string;
|
|
278
317
|
/** The roadmap (ADR-0019): the goals, the active first, each with its tasks counted by state. */
|
|
279
|
-
|
|
318
|
+
/** The roadmap, or what the filter keeps of it: the active goals first. */
|
|
319
|
+
goals(filter?: GoalFilter, signal?: AbortSignal): Promise<RestGoals>;
|
|
280
320
|
goal(id: string, signal?: AbortSignal): Promise<RestGoal>;
|
|
281
321
|
/** Sets a goal; for an owner, with a session or a token. */
|
|
282
322
|
createGoal(input: RestGoalInput): Promise<RestGoal>;
|
|
@@ -355,7 +395,8 @@ const INVALID = "The server answered unexpectedly.";
|
|
|
355
395
|
export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
356
396
|
const base = (options.baseUrl ?? "").replace(/\/+$/, "");
|
|
357
397
|
const send = options.fetch ?? ((input, init) => fetch(input, init));
|
|
358
|
-
const { token } = options;
|
|
398
|
+
const { token, userAgent } = options;
|
|
399
|
+
const named: Record<string, string> = userAgent === undefined ? {} : { "user-agent": userAgent };
|
|
359
400
|
|
|
360
401
|
const withQuery = (path: string, params: Record<string, string | undefined>): string => {
|
|
361
402
|
const query = new URLSearchParams();
|
|
@@ -397,6 +438,7 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
397
438
|
// A form names its own type, boundary included; everything else is JSON.
|
|
398
439
|
...(body === undefined || form ? {} : { "content-type": "application/json" }),
|
|
399
440
|
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
|
|
441
|
+
...named,
|
|
400
442
|
},
|
|
401
443
|
...(body === undefined ? {} : { body: form ? body : JSON.stringify(body) }),
|
|
402
444
|
});
|
|
@@ -419,6 +461,40 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
419
461
|
return parsed.data;
|
|
420
462
|
};
|
|
421
463
|
|
|
464
|
+
/** The bytes at `url`, with the name and the type the console says, for a file of the project. */
|
|
465
|
+
const read = async (
|
|
466
|
+
url: string,
|
|
467
|
+
fallbackName: string,
|
|
468
|
+
signal?: AbortSignal,
|
|
469
|
+
): Promise<FileRead> => {
|
|
470
|
+
let response: Response;
|
|
471
|
+
try {
|
|
472
|
+
response = await send(url, {
|
|
473
|
+
method: "GET",
|
|
474
|
+
...(signal === undefined ? {} : { signal }),
|
|
475
|
+
credentials: token === undefined ? "same-origin" : "omit",
|
|
476
|
+
headers: {
|
|
477
|
+
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
|
|
478
|
+
...named,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if (signal?.aborted === true) {
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
throw new ApiError(0, "network", "The server could not be reached.");
|
|
486
|
+
}
|
|
487
|
+
if (!response.ok) {
|
|
488
|
+
throw await failure(response);
|
|
489
|
+
}
|
|
490
|
+
const type = response.headers.get("content-type") ?? "application/octet-stream";
|
|
491
|
+
return {
|
|
492
|
+
name: fileNameOf(response.headers.get("content-disposition") ?? "") ?? fallbackName,
|
|
493
|
+
contentType: type.split(";")[0]?.trim() ?? "application/octet-stream",
|
|
494
|
+
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
495
|
+
};
|
|
496
|
+
};
|
|
497
|
+
|
|
422
498
|
const flag = (value: boolean | undefined): string | undefined =>
|
|
423
499
|
value === undefined ? undefined : String(value);
|
|
424
500
|
const number = (value: number | undefined): string | undefined =>
|
|
@@ -440,6 +516,22 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
440
516
|
return form;
|
|
441
517
|
};
|
|
442
518
|
|
|
519
|
+
/** A comment with files as the parts of a form: the part `body`, and a part `file` for each file. */
|
|
520
|
+
const commentFormOf = (input: CommentInput): FormData => {
|
|
521
|
+
const form = new FormData();
|
|
522
|
+
form.set("body", input.body);
|
|
523
|
+
for (const file of input.files ?? []) {
|
|
524
|
+
const bytes =
|
|
525
|
+
file.bytes instanceof Blob ? file.bytes : new Blob([Uint8Array.from(file.bytes)]);
|
|
526
|
+
form.append(
|
|
527
|
+
"file",
|
|
528
|
+
new File([bytes], file.name, { type: file.contentType ?? "application/octet-stream" }),
|
|
529
|
+
file.name,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
return form;
|
|
533
|
+
};
|
|
534
|
+
|
|
443
535
|
const projectClient = (key: string): ProjectClient => {
|
|
444
536
|
const at = (collection?: Parameters<typeof projectPath>[1], id?: string) =>
|
|
445
537
|
projectPath(key, collection, id);
|
|
@@ -530,7 +622,23 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
530
622
|
request("POST", `${base}${at("runs", runId)}/artifacts`, input, restRunSchema),
|
|
531
623
|
handInFile: (runId, input) =>
|
|
532
624
|
request("POST", `${base}${at("runs", runId)}/files`, formOf(input), restRunSchema),
|
|
533
|
-
fileUrl: (
|
|
625
|
+
fileUrl: (fileId) => `${base}${at("files", fileId)}`,
|
|
626
|
+
readFile: (fileId, signal) => read(`${base}${at("files", fileId)}`, fileId, signal),
|
|
627
|
+
comments: (taskId, signal) =>
|
|
628
|
+
request(
|
|
629
|
+
"GET",
|
|
630
|
+
`${base}${at("tasks", taskId)}/comments`,
|
|
631
|
+
undefined,
|
|
632
|
+
restCommentsSchema,
|
|
633
|
+
signal,
|
|
634
|
+
),
|
|
635
|
+
comment: (taskId, input) =>
|
|
636
|
+
request(
|
|
637
|
+
"POST",
|
|
638
|
+
`${base}${at("tasks", taskId)}/comments`,
|
|
639
|
+
(input.files?.length ?? 0) > 0 ? commentFormOf(input) : { body: input.body },
|
|
640
|
+
restCommentSchema,
|
|
641
|
+
),
|
|
534
642
|
endRun: (runId, input) =>
|
|
535
643
|
request("POST", `${base}${at("runs", runId)}/end`, input, restRunSchema),
|
|
536
644
|
events: (after, filter = {}, signal) =>
|
|
@@ -592,8 +700,14 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
592
700
|
request("POST", `${base}${at("docs", path)}/files`, formOf(input), restDocumentSchema),
|
|
593
701
|
documentFileUrl: (path, fileId) =>
|
|
594
702
|
`${base}${at("docs", path)}/files/${encodeURIComponent(fileId)}`,
|
|
595
|
-
goals: (signal) =>
|
|
596
|
-
request(
|
|
703
|
+
goals: (filter = {}, signal) =>
|
|
704
|
+
request(
|
|
705
|
+
"GET",
|
|
706
|
+
withQuery(at("goals"), { state: filter.state, category: filter.category }),
|
|
707
|
+
undefined,
|
|
708
|
+
restGoalsSchema,
|
|
709
|
+
signal,
|
|
710
|
+
),
|
|
597
711
|
goal: (id, signal) =>
|
|
598
712
|
request("GET", `${base}${at("goals", id)}`, undefined, restGoalSchema, signal),
|
|
599
713
|
createGoal: (input) => request("POST", `${base}${at("goals")}`, input, restGoalSchema),
|
|
@@ -668,3 +782,23 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
668
782
|
signOut: () => request("POST", `${base}${AUTH_ROUTES.logout}`, undefined, undefined),
|
|
669
783
|
};
|
|
670
784
|
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* The file's name as a `content-disposition` header says it: the exact one (`filename*`, UTF-8,
|
|
788
|
+
* percent-encoded) when it is readable, else the plain one in quotes; nothing when neither is.
|
|
789
|
+
*/
|
|
790
|
+
export function fileNameOf(disposition: string): string | undefined {
|
|
791
|
+
const exact = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
|
|
792
|
+
if (exact?.[1] !== undefined) {
|
|
793
|
+
try {
|
|
794
|
+
const decoded = decodeURIComponent(exact[1].trim());
|
|
795
|
+
if (decoded.length > 0) {
|
|
796
|
+
return decoded;
|
|
797
|
+
}
|
|
798
|
+
} catch {
|
|
799
|
+
// Not percent-encoded as it should be: the plain name below is what there is.
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const plain = /filename="([^"]*)"/i.exec(disposition);
|
|
803
|
+
return plain?.[1] === undefined || plain[1].length === 0 ? undefined : plain[1];
|
|
804
|
+
}
|
package/src/compat.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Whether the command and the console go together (ADR-0025). The command names its version on
|
|
2
|
+
// every request, in the product token of its user-agent; the console answers every request of
|
|
3
|
+
// the API with its own version and the range of command versions it takes, and refuses a
|
|
4
|
+
// command outside the range with `426` and the words to update. One rule for both sides, kept
|
|
5
|
+
// here so that the command and the console read the same one, and narrowed by the console's
|
|
6
|
+
// policy below only, never widened: what the rule promises stays predictable.
|
|
7
|
+
|
|
8
|
+
/** The product token the command names itself with: `skillcdn-console/<version>`. */
|
|
9
|
+
export const CLIENT_PRODUCT = "skillcdn-console";
|
|
10
|
+
/** On every answer of the API: the console's version. */
|
|
11
|
+
export const VERSION_HEADER = "console-version";
|
|
12
|
+
/** On every answer of the API: the range of command versions the console takes, as npm spells one. */
|
|
13
|
+
export const CLIENT_RANGE_HEADER = "console-client-range";
|
|
14
|
+
|
|
15
|
+
export interface Version {
|
|
16
|
+
readonly major: number;
|
|
17
|
+
readonly minor: number;
|
|
18
|
+
readonly patch: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `x.y.z`, each a number of at most nine digits without a leading zero; no tag, no build. */
|
|
22
|
+
const VERSION = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
|
|
23
|
+
const MAX_VERSION_LENGTH = 32;
|
|
24
|
+
|
|
25
|
+
/** A version as the package spells one, or nothing for any other text. */
|
|
26
|
+
export function parseVersion(text: string | null | undefined): Version | undefined {
|
|
27
|
+
if (typeof text !== "string" || text.length > MAX_VERSION_LENGTH) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const match = VERSION.exec(text);
|
|
31
|
+
return match === null
|
|
32
|
+
? undefined
|
|
33
|
+
: { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatVersion(version: Version): string {
|
|
37
|
+
return `${version.major}.${version.minor}.${version.patch}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Negative when `a` is the older, zero when they are the same, positive when `a` is the newer. */
|
|
41
|
+
export function compareVersions(a: Version, b: Version): number {
|
|
42
|
+
return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A half-open range of versions: from `atLeast` on, and before `below`. */
|
|
46
|
+
export interface VersionRange {
|
|
47
|
+
readonly atLeast: Version;
|
|
48
|
+
readonly below: Version;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The range as npm spells one: `>=0.1.18 <0.2.0`. */
|
|
52
|
+
export function formatRange(range: VersionRange): string {
|
|
53
|
+
return `>=${formatVersion(range.atLeast)} <${formatVersion(range.below)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RANGE = /^>=(\S+) <(\S+)$/;
|
|
57
|
+
const MAX_RANGE_LENGTH = 2 * MAX_VERSION_LENGTH + 4;
|
|
58
|
+
|
|
59
|
+
/** A range as `formatRange` spells one, or nothing for any other text. */
|
|
60
|
+
export function parseRange(text: string | null | undefined): VersionRange | undefined {
|
|
61
|
+
if (typeof text !== "string" || text.length > MAX_RANGE_LENGTH) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const match = RANGE.exec(text);
|
|
65
|
+
const atLeast = parseVersion(match?.[1]);
|
|
66
|
+
const below = parseVersion(match?.[2]);
|
|
67
|
+
return atLeast !== undefined && below !== undefined && compareVersions(atLeast, below) < 0
|
|
68
|
+
? { atLeast, below }
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function inRange(version: Version, range: VersionRange): boolean {
|
|
73
|
+
return compareVersions(version, range.atLeast) >= 0 && compareVersions(version, range.below) < 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The common rule. Before 1.0, a command from the console's own version on and within the
|
|
78
|
+
* console's minor (`^0.1.18`): any release may change the contract until then (ADR-0007), so the
|
|
79
|
+
* console's own version is the floor, and the minor is the line. From 1.0, the console's major
|
|
80
|
+
* and a minor within one of the console's, either way, whatever the patch: a minor adds and a
|
|
81
|
+
* major breaks, as the version promises from then on.
|
|
82
|
+
*/
|
|
83
|
+
export function commonRange(server: Version): VersionRange {
|
|
84
|
+
if (server.major === 0) {
|
|
85
|
+
return { atLeast: server, below: { major: 0, minor: server.minor + 1, patch: 0 } };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
atLeast: { major: server.major, minor: Math.max(0, server.minor - 1), patch: 0 },
|
|
89
|
+
below: { major: server.major, minor: server.minor + 2, patch: 0 },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What the console narrows the common rule by, and nothing it widens it by: a floor above the
|
|
95
|
+
* rule's, for a contract that moved within what the rule still allows, and versions refused
|
|
96
|
+
* whatever the rule says, for a release whose command is wrong. Changed with a changeset that
|
|
97
|
+
* says why.
|
|
98
|
+
*/
|
|
99
|
+
export interface ClientPolicy {
|
|
100
|
+
/** The oldest command this console answers, when the common rule's floor is too low. */
|
|
101
|
+
readonly atLeast?: string | undefined;
|
|
102
|
+
/** Versions of the command refused outright. */
|
|
103
|
+
readonly refused?: readonly string[] | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The console's policy today: the common rule as it is. */
|
|
107
|
+
export const CLIENT_POLICY: ClientPolicy = {};
|
|
108
|
+
|
|
109
|
+
/** The range of command versions a console of `server` takes: the common rule, narrowed by the policy's floor where that floor is within it. */
|
|
110
|
+
export function clientRange(server: Version, policy: ClientPolicy = CLIENT_POLICY): VersionRange {
|
|
111
|
+
const common = commonRange(server);
|
|
112
|
+
const floor = parseVersion(policy.atLeast);
|
|
113
|
+
return floor !== undefined &&
|
|
114
|
+
compareVersions(floor, common.atLeast) > 0 &&
|
|
115
|
+
compareVersions(floor, common.below) < 0
|
|
116
|
+
? { atLeast: floor, below: common.below }
|
|
117
|
+
: common;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What a command of one version is to a console of another: within the range and the same
|
|
122
|
+
* (`current`), older (`behind`) or newer (`ahead`), which the console answers; or outside it,
|
|
123
|
+
* older (`outdated`) or newer (`too_new`), which the console refuses.
|
|
124
|
+
*/
|
|
125
|
+
export type ClientVerdict = "current" | "behind" | "ahead" | "outdated" | "too_new";
|
|
126
|
+
|
|
127
|
+
export interface ClientJudgement {
|
|
128
|
+
readonly verdict: ClientVerdict;
|
|
129
|
+
readonly range: VersionRange;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function judgeClient(
|
|
133
|
+
client: Version,
|
|
134
|
+
server: Version,
|
|
135
|
+
policy: ClientPolicy = CLIENT_POLICY,
|
|
136
|
+
): ClientJudgement {
|
|
137
|
+
const range = clientRange(server, policy);
|
|
138
|
+
const order = compareVersions(client, server);
|
|
139
|
+
const refused = (policy.refused ?? []).some((text) => {
|
|
140
|
+
const version = parseVersion(text);
|
|
141
|
+
return version !== undefined && compareVersions(version, client) === 0;
|
|
142
|
+
});
|
|
143
|
+
if (refused) {
|
|
144
|
+
return { verdict: order > 0 ? "too_new" : "outdated", range };
|
|
145
|
+
}
|
|
146
|
+
if (compareVersions(client, range.atLeast) < 0) {
|
|
147
|
+
return { verdict: "outdated", range };
|
|
148
|
+
}
|
|
149
|
+
if (compareVersions(client, range.below) >= 0) {
|
|
150
|
+
return { verdict: "too_new", range };
|
|
151
|
+
}
|
|
152
|
+
return { verdict: order === 0 ? "current" : order < 0 ? "behind" : "ahead", range };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What the command sends as its user-agent: its product token, and, in a comment, where it runs. */
|
|
156
|
+
export function userAgentOf(version: string, comment?: string): string {
|
|
157
|
+
// A comment holds printable ASCII without parentheses or backslashes; nothing else is said.
|
|
158
|
+
const safe = comment?.replaceAll(/[^\x20-\x27\x2a-\x5b\x5d-\x7e]/g, "").trim();
|
|
159
|
+
return `${CLIENT_PRODUCT}/${version}${safe === undefined || safe.length === 0 ? "" : ` (${safe})`}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const MAX_USER_AGENT_LENGTH = 512;
|
|
163
|
+
const CLIENT_TOKEN = new RegExp(
|
|
164
|
+
`(?:^|\\s)${CLIENT_PRODUCT}/(\\d{1,9}\\.\\d{1,9}\\.\\d{1,9})(?![\\w.-])`,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
/** The version of the command a request's user-agent names, or nothing for any other client. */
|
|
168
|
+
export function clientVersionOf(userAgent: string | null | undefined): Version | undefined {
|
|
169
|
+
if (typeof userAgent !== "string") {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
return parseVersion(CLIENT_TOKEN.exec(userAgent.slice(0, MAX_USER_AGENT_LENGTH))?.[1]);
|
|
173
|
+
}
|