@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.
Files changed (143) hide show
  1. package/README.md +4 -3
  2. package/dist/api.d.ts +4 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +12 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +226 -21
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +8 -3
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +31 -5
  13. package/dist/cli/format.js.map +1 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +34 -8
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/install.d.ts +21 -0
  18. package/dist/cli/install.d.ts.map +1 -0
  19. package/dist/cli/install.js +87 -0
  20. package/dist/cli/install.js.map +1 -0
  21. package/dist/cli/main.js +24 -4
  22. package/dist/cli/main.js.map +1 -1
  23. package/dist/cli/notices.d.ts +13 -0
  24. package/dist/cli/notices.d.ts.map +1 -0
  25. package/dist/cli/notices.js +31 -0
  26. package/dist/cli/notices.js.map +1 -0
  27. package/dist/cli/terminal.d.ts +5 -0
  28. package/dist/cli/terminal.d.ts.map +1 -1
  29. package/dist/cli/terminal.js +11 -1
  30. package/dist/cli/terminal.js.map +1 -1
  31. package/dist/client.d.ts +43 -5
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +69 -4
  34. package/dist/client.js.map +1 -1
  35. package/dist/compat.d.ts +66 -0
  36. package/dist/compat.d.ts.map +1 -0
  37. package/dist/compat.js +115 -0
  38. package/dist/compat.js.map +1 -0
  39. package/dist/components/calendar.d.ts +3 -1
  40. package/dist/components/calendar.d.ts.map +1 -1
  41. package/dist/components/calendar.js +21 -2
  42. package/dist/components/calendar.js.map +1 -1
  43. package/dist/components/comments.d.ts +59 -0
  44. package/dist/components/comments.d.ts.map +1 -0
  45. package/dist/components/comments.js +138 -0
  46. package/dist/components/comments.js.map +1 -0
  47. package/dist/components/event-feed.d.ts.map +1 -1
  48. package/dist/components/event-feed.js +2 -0
  49. package/dist/components/event-feed.js.map +1 -1
  50. package/dist/components/goals.d.ts +3 -1
  51. package/dist/components/goals.d.ts.map +1 -1
  52. package/dist/components/goals.js +23 -11
  53. package/dist/components/goals.js.map +1 -1
  54. package/dist/components/markdown.d.ts +6 -0
  55. package/dist/components/markdown.d.ts.map +1 -1
  56. package/dist/components/markdown.js +2 -1
  57. package/dist/components/markdown.js.map +1 -1
  58. package/dist/components/projects.d.ts.map +1 -1
  59. package/dist/components/projects.js +6 -1
  60. package/dist/components/projects.js.map +1 -1
  61. package/dist/components/roadmap.d.ts +37 -0
  62. package/dist/components/roadmap.d.ts.map +1 -0
  63. package/dist/components/roadmap.js +76 -0
  64. package/dist/components/roadmap.js.map +1 -0
  65. package/dist/components/task-view.d.ts +8 -3
  66. package/dist/components/task-view.d.ts.map +1 -1
  67. package/dist/components/task-view.js +6 -4
  68. package/dist/components/task-view.js.map +1 -1
  69. package/dist/components/tokens.d.ts +2 -0
  70. package/dist/components/tokens.d.ts.map +1 -1
  71. package/dist/components/tokens.js +13 -3
  72. package/dist/components/tokens.js.map +1 -1
  73. package/dist/console.css +314 -2
  74. package/dist/console.d.ts.map +1 -1
  75. package/dist/console.js +30 -11
  76. package/dist/console.js.map +1 -1
  77. package/dist/data.d.ts +5 -1
  78. package/dist/data.d.ts.map +1 -1
  79. package/dist/data.js +3 -1
  80. package/dist/data.js.map +1 -1
  81. package/dist/i18n/en.d.ts +38 -2
  82. package/dist/i18n/en.d.ts.map +1 -1
  83. package/dist/i18n/en.js +38 -2
  84. package/dist/i18n/en.js.map +1 -1
  85. package/dist/i18n/ko.d.ts.map +1 -1
  86. package/dist/i18n/ko.js +38 -2
  87. package/dist/i18n/ko.js.map +1 -1
  88. package/dist/index.d.ts +3 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +3 -1
  91. package/dist/index.js.map +1 -1
  92. package/dist/limits.d.ts +3 -0
  93. package/dist/limits.d.ts.map +1 -1
  94. package/dist/limits.js +3 -0
  95. package/dist/limits.js.map +1 -1
  96. package/dist/router.d.ts +6 -1
  97. package/dist/router.d.ts.map +1 -1
  98. package/dist/router.js +10 -1
  99. package/dist/router.js.map +1 -1
  100. package/dist/rules.js +2 -2
  101. package/dist/rules.js.map +1 -1
  102. package/dist/schemas.d.ts +147 -0
  103. package/dist/schemas.d.ts.map +1 -1
  104. package/dist/schemas.js +50 -1
  105. package/dist/schemas.js.map +1 -1
  106. package/dist/vocabulary.d.ts +1 -1
  107. package/dist/vocabulary.d.ts.map +1 -1
  108. package/dist/vocabulary.js +1 -0
  109. package/dist/vocabulary.js.map +1 -1
  110. package/docs/specs/cli.md +17 -10
  111. package/docs/specs/rest.md +17 -6
  112. package/package.json +3 -2
  113. package/skills/working-the-board/SKILL.md +8 -5
  114. package/src/api.ts +35 -0
  115. package/src/cli/cli.ts +273 -22
  116. package/src/cli/format.ts +36 -4
  117. package/src/cli/help.ts +38 -7
  118. package/src/cli/install.ts +111 -0
  119. package/src/cli/main.ts +25 -4
  120. package/src/cli/notices.ts +48 -0
  121. package/src/cli/terminal.ts +12 -1
  122. package/src/client.ts +142 -8
  123. package/src/compat.ts +173 -0
  124. package/src/components/calendar.tsx +41 -1
  125. package/src/components/comments.tsx +342 -0
  126. package/src/components/event-feed.tsx +2 -0
  127. package/src/components/goals.tsx +86 -7
  128. package/src/components/markdown.tsx +9 -1
  129. package/src/components/projects.tsx +16 -3
  130. package/src/components/roadmap.tsx +242 -0
  131. package/src/components/task-view.tsx +32 -33
  132. package/src/components/tokens.tsx +38 -1
  133. package/src/console.tsx +45 -3
  134. package/src/data.ts +9 -1
  135. package/src/i18n/en.ts +40 -2
  136. package/src/i18n/ko.ts +40 -2
  137. package/src/index.ts +20 -0
  138. package/src/limits.ts +3 -0
  139. package/src/router.ts +13 -2
  140. package/src/rules.ts +2 -2
  141. package/src/schemas.ts +60 -1
  142. package/src/styles/console.css +314 -2
  143. 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 version of the package this file runs from: its own manifest, two levels above dist/cli. */
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
- store: createFileStore(defaultConfigDir(process.env)),
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: manifest.success ? manifest.data.version : undefined,
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
+ }
@@ -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 handed in are read, by the artifact's id. */
249
- fileUrl(artifactId: string): string;
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
- goals(signal?: AbortSignal): Promise<RestGoals>;
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: (artifactId) => `${base}${at("files", artifactId)}`,
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("GET", `${base}${at("goals")}`, undefined, restGoalsSchema, signal),
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
+ }