@skillcdn/console 0.1.6 → 0.1.7
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 +50 -2
- package/SKILLCDN.md +29 -0
- package/dist/cli/cli.d.ts +3 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +61 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +5 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +2 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/serve.d.ts +37 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +218 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/console.d.ts +6 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +78 -13
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +5 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +4 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +4 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +4 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/schemas.d.ts +1 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +5 -0
- package/dist/schemas.js.map +1 -1
- package/dist/web.d.ts +16 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +149 -0
- package/dist/web.js.map +1 -0
- package/docs/specs/cli.md +92 -0
- package/docs/specs/rest.md +220 -0
- package/package.json +14 -3
- package/skills/building-a-console/SKILL.md +79 -0
- package/skills/working-the-board/SKILL.md +78 -0
- package/src/api.ts +235 -0
- package/src/cli/cli.ts +1114 -0
- package/src/cli/credentials.ts +68 -0
- package/src/cli/directory.ts +59 -0
- package/src/cli/format.ts +284 -0
- package/src/cli/help.ts +326 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/serve.ts +276 -0
- package/src/cli/terminal.ts +28 -0
- package/src/client.ts +533 -0
- package/src/components/board.tsx +112 -0
- package/src/components/connect.tsx +126 -0
- package/src/components/decision-form.tsx +136 -0
- package/src/components/decision-list.tsx +278 -0
- package/src/components/documents.tsx +569 -0
- package/src/components/event-feed.tsx +129 -0
- package/src/components/markdown.tsx +72 -0
- package/src/components/people.tsx +59 -0
- package/src/components/projects.tsx +364 -0
- package/src/components/runs.tsx +175 -0
- package/src/components/shell.tsx +284 -0
- package/src/components/sign-in.tsx +61 -0
- package/src/components/skills.tsx +78 -0
- package/src/components/task-form.tsx +229 -0
- package/src/components/task-view.tsx +272 -0
- package/src/components/tokens.tsx +223 -0
- package/src/components/ui.tsx +219 -0
- package/src/connect-page.tsx +148 -0
- package/src/connect.ts +27 -0
- package/src/console.tsx +1166 -0
- package/src/data.ts +493 -0
- package/src/docs-page.tsx +334 -0
- package/src/documents.ts +76 -0
- package/src/i18n/en.ts +537 -0
- package/src/i18n/index.ts +76 -0
- package/src/i18n/ko.ts +511 -0
- package/src/index.ts +142 -0
- package/src/limits.ts +61 -0
- package/src/router.ts +295 -0
- package/src/routes.ts +106 -0
- package/src/schemas.ts +776 -0
- package/src/styles/console.css +2432 -0
- package/src/text.ts +33 -0
- package/src/use-action.ts +37 -0
- package/src/vocabulary.ts +109 -0
- package/src/web.ts +170 -0
package/src/text.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code points that never belong in text the console accepts, stores or shows: control
|
|
3
|
+
* characters, and the invisible or direction-changing ones that make one string look like
|
|
4
|
+
* another. A body keeps its line breaks and tabs; a title has no use for them.
|
|
5
|
+
*/
|
|
6
|
+
function isForbidden(codePoint: number, multiline: boolean): boolean {
|
|
7
|
+
if (codePoint <= 0x1f) {
|
|
8
|
+
return !(multiline && (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d));
|
|
9
|
+
}
|
|
10
|
+
return (
|
|
11
|
+
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
12
|
+
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
|
13
|
+
(codePoint >= 0x2028 && codePoint <= 0x202e) ||
|
|
14
|
+
(codePoint >= 0x2060 && codePoint <= 0x2069) ||
|
|
15
|
+
codePoint === 0xfeff
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* True when the string is malformed UTF-16 or holds a forbidden code point. Linear in the input.
|
|
21
|
+
* With `multiline`, line breaks and tabs are allowed, as a body needs them.
|
|
22
|
+
*/
|
|
23
|
+
export function hasForbiddenCodePoint(value: string, multiline = false): boolean {
|
|
24
|
+
if (!value.isWellFormed()) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
for (const character of value) {
|
|
28
|
+
if (isForbidden(character.codePointAt(0) ?? 0, multiline)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ApiError } from "./api.js";
|
|
3
|
+
import { en } from "./i18n/en.js";
|
|
4
|
+
import { useWords } from "./i18n/index.js";
|
|
5
|
+
|
|
6
|
+
// A change a page makes: run it, keep what went wrong for the page, and say while it is busy.
|
|
7
|
+
// Shared by the compositions, so that every page says the same thing the same way.
|
|
8
|
+
|
|
9
|
+
/** What a failure says to a person: the server's words, or the language's own for the rest. */
|
|
10
|
+
export function errorWords(
|
|
11
|
+
error: unknown,
|
|
12
|
+
fallback: string = en.common.somethingWentWrong,
|
|
13
|
+
): string {
|
|
14
|
+
if (error instanceof ApiError) {
|
|
15
|
+
return error.message;
|
|
16
|
+
}
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Runs a change, keeps what went wrong for the page, and says when it is busy. */
|
|
21
|
+
export function useAction() {
|
|
22
|
+
const words = useWords();
|
|
23
|
+
const [busy, setBusy] = useState(false);
|
|
24
|
+
const [error, setError] = useState<string | undefined>(undefined);
|
|
25
|
+
const act = async (work: () => Promise<unknown>) => {
|
|
26
|
+
setBusy(true);
|
|
27
|
+
setError(undefined);
|
|
28
|
+
try {
|
|
29
|
+
await work();
|
|
30
|
+
} catch (failure) {
|
|
31
|
+
setError(errorWords(failure, words.common.somethingWentWrong));
|
|
32
|
+
} finally {
|
|
33
|
+
setBusy(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return { busy, error, act };
|
|
37
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The concepts the schema, the API and the UI are named after (docs/architecture.md,
|
|
2
|
+
// "Vocabulary"). A list here is a check constraint in the database, an enum in the schemas, and
|
|
3
|
+
// a column or a badge on the board: one source for all three.
|
|
4
|
+
|
|
5
|
+
/** The states a task moves through, in the order it usually does. */
|
|
6
|
+
export const TASK_STATES = [
|
|
7
|
+
"idea",
|
|
8
|
+
"ready",
|
|
9
|
+
"in_progress",
|
|
10
|
+
"in_review",
|
|
11
|
+
"done",
|
|
12
|
+
"dropped",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type TaskState = (typeof TASK_STATES)[number];
|
|
16
|
+
|
|
17
|
+
/** How much a task matters next to the others, in rising order. */
|
|
18
|
+
export const TASK_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
|
|
19
|
+
|
|
20
|
+
export type TaskPriority = (typeof TASK_PRIORITIES)[number];
|
|
21
|
+
|
|
22
|
+
/** The identity providers people may sign in through: the git host, and Google (Workspace). */
|
|
23
|
+
export const PROVIDER_KEYS = ["gh", "google"] as const;
|
|
24
|
+
|
|
25
|
+
export type ProviderKey = (typeof PROVIDER_KEYS)[number];
|
|
26
|
+
|
|
27
|
+
export function isProviderKey(value: string | null | undefined): value is ProviderKey {
|
|
28
|
+
return (PROVIDER_KEYS as readonly string[]).includes(value ?? "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a person may do: an administrator configures the board, a member works on it. */
|
|
32
|
+
export const PERSON_ROLES = ["admin", "member"] as const;
|
|
33
|
+
|
|
34
|
+
export type PersonRole = (typeof PERSON_ROLES)[number];
|
|
35
|
+
|
|
36
|
+
/** What a person is within a project: an owner configures it, a member works on it. */
|
|
37
|
+
export const PROJECT_ROLES = ["owner", "member"] as const;
|
|
38
|
+
|
|
39
|
+
export type ProjectRole = (typeof PROJECT_ROLES)[number];
|
|
40
|
+
|
|
41
|
+
/** Who is a member of a project: every member of the workspace, or only those listed. */
|
|
42
|
+
export const PROJECT_VISIBILITIES = ["workspace", "private"] as const;
|
|
43
|
+
|
|
44
|
+
export type ProjectVisibility = (typeof PROJECT_VISIBILITIES)[number];
|
|
45
|
+
|
|
46
|
+
/** What a run is doing: at work, waiting for a person's decision, or over in one of three ways. */
|
|
47
|
+
export const RUN_STATUSES = ["running", "waiting", "finished", "failed", "abandoned"] as const;
|
|
48
|
+
|
|
49
|
+
export type RunStatus = (typeof RUN_STATUSES)[number];
|
|
50
|
+
|
|
51
|
+
/** How a run is over, as the agent says: the work is done, it could not be, or it was left. */
|
|
52
|
+
export const RUN_ENDINGS = ["finished", "failed", "abandoned"] as const;
|
|
53
|
+
|
|
54
|
+
export type RunEnding = (typeof RUN_ENDINGS)[number];
|
|
55
|
+
|
|
56
|
+
/** What links to a document by its path: another document, a task, or a decision. */
|
|
57
|
+
export const LINK_SOURCES = ["document", "task", "decision"] as const;
|
|
58
|
+
|
|
59
|
+
export type LinkSource = (typeof LINK_SOURCES)[number];
|
|
60
|
+
|
|
61
|
+
/** What a run hands in: a link to the web, or a file the console keeps. */
|
|
62
|
+
export const ARTIFACT_KINDS = ["link", "file"] as const;
|
|
63
|
+
|
|
64
|
+
export type ArtifactKind = (typeof ARTIFACT_KINDS)[number];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What the console says of the organization's skills: that it has no address for them, or what
|
|
68
|
+
* the SkillCDN deployment answered for the address.
|
|
69
|
+
*/
|
|
70
|
+
export const SKILLS_STATUSES = [
|
|
71
|
+
"none",
|
|
72
|
+
"ready",
|
|
73
|
+
"indexing",
|
|
74
|
+
"failed",
|
|
75
|
+
"not_found",
|
|
76
|
+
"unavailable",
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
export type SkillsStatus = (typeof SKILLS_STATUSES)[number];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What can happen to the board. Every change of state is one of these, written down once with
|
|
83
|
+
* who did it: the live feed shows them as they happen, and the audit trail keeps them.
|
|
84
|
+
*/
|
|
85
|
+
export const EVENT_KINDS = [
|
|
86
|
+
"person.joined",
|
|
87
|
+
"person.role_changed",
|
|
88
|
+
"project.created",
|
|
89
|
+
"project.updated",
|
|
90
|
+
"project.member_added",
|
|
91
|
+
"project.member_changed",
|
|
92
|
+
"project.member_removed",
|
|
93
|
+
"task.created",
|
|
94
|
+
"task.updated",
|
|
95
|
+
"task.moved",
|
|
96
|
+
"decision.raised",
|
|
97
|
+
"decision.answered",
|
|
98
|
+
"decision.updated",
|
|
99
|
+
"run.started",
|
|
100
|
+
"run.reported",
|
|
101
|
+
"run.handed_in",
|
|
102
|
+
"run.ended",
|
|
103
|
+
"document.written",
|
|
104
|
+
"document.archived",
|
|
105
|
+
"document.restored",
|
|
106
|
+
"document.file_attached",
|
|
107
|
+
] as const;
|
|
108
|
+
|
|
109
|
+
export type EventKind = (typeof EVENT_KINDS)[number];
|
package/src/web.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { extname, join, relative, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Serves a build of a console: static files, and the one page for every path that is not a
|
|
6
|
+
// file, since the pages route in the browser. What the image serves the default UI with, from
|
|
7
|
+
// WEB_ROOT, and what `console serve` serves a person's own build with (ADR-0014): one
|
|
8
|
+
// policy for both. Nothing here knows the UI but the directory; a deployment without one is
|
|
9
|
+
// API only. Node only: `@skillcdn/console/web`.
|
|
10
|
+
|
|
11
|
+
const MAX_FILES = 5000;
|
|
12
|
+
const HTML = "text/html; charset=utf-8";
|
|
13
|
+
|
|
14
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
15
|
+
".css": "text/css; charset=utf-8",
|
|
16
|
+
".js": "text/javascript; charset=utf-8",
|
|
17
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
18
|
+
".json": "application/json; charset=utf-8",
|
|
19
|
+
".map": "application/json; charset=utf-8",
|
|
20
|
+
".svg": "image/svg+xml",
|
|
21
|
+
".png": "image/png",
|
|
22
|
+
".jpg": "image/jpeg",
|
|
23
|
+
".jpeg": "image/jpeg",
|
|
24
|
+
".webp": "image/webp",
|
|
25
|
+
".gif": "image/gif",
|
|
26
|
+
".ico": "image/x-icon",
|
|
27
|
+
".txt": "text/plain; charset=utf-8",
|
|
28
|
+
".webmanifest": "application/manifest+json",
|
|
29
|
+
".woff2": "font/woff2",
|
|
30
|
+
".woff": "font/woff",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pages show what people and agents wrote, which is untrusted. Nothing inline runs, nothing
|
|
35
|
+
* loads from elsewhere but pictures over https, and nobody frames the page.
|
|
36
|
+
*/
|
|
37
|
+
const PAGE_HEADERS: Readonly<Record<string, string>> = {
|
|
38
|
+
"content-security-policy": [
|
|
39
|
+
"default-src 'self'",
|
|
40
|
+
"script-src 'self'",
|
|
41
|
+
"style-src 'self'",
|
|
42
|
+
"img-src 'self' data: https:",
|
|
43
|
+
"font-src 'self'",
|
|
44
|
+
"connect-src 'self'",
|
|
45
|
+
"object-src 'none'",
|
|
46
|
+
"base-uri 'none'",
|
|
47
|
+
"form-action 'self'",
|
|
48
|
+
"frame-ancestors 'none'",
|
|
49
|
+
].join("; "),
|
|
50
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
51
|
+
"x-content-type-options": "nosniff",
|
|
52
|
+
"x-frame-options": "DENY",
|
|
53
|
+
"cross-origin-opener-policy": "same-origin",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Paths that belong to the API and never to the pages: they answer their own not-found. */
|
|
57
|
+
const NOT_PAGES = ["/api/", "/auth/", "/healthz", "/readyz"];
|
|
58
|
+
|
|
59
|
+
export class WebRootError extends Error {
|
|
60
|
+
constructor(message: string, options?: { readonly cause?: unknown }) {
|
|
61
|
+
super(message, options);
|
|
62
|
+
this.name = "WebRootError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface StaticFile {
|
|
67
|
+
readonly file: string;
|
|
68
|
+
readonly contentType: string;
|
|
69
|
+
readonly etag: string;
|
|
70
|
+
readonly size: number;
|
|
71
|
+
readonly cacheControl: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface WebRoot {
|
|
75
|
+
/** The file at the path, the page for a path of the app, or nothing for the API's paths. */
|
|
76
|
+
respond(request: Request): Promise<Response | undefined>;
|
|
77
|
+
readonly files: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function listFiles(directory: string): Promise<string[]> {
|
|
81
|
+
const found: string[] = [];
|
|
82
|
+
const walk = async (current: string): Promise<void> => {
|
|
83
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
84
|
+
if (found.length >= MAX_FILES) {
|
|
85
|
+
throw new WebRootError(`more than ${MAX_FILES} files`);
|
|
86
|
+
}
|
|
87
|
+
const path = join(current, entry.name);
|
|
88
|
+
if (entry.isDirectory()) {
|
|
89
|
+
await walk(path);
|
|
90
|
+
} else if (entry.isFile()) {
|
|
91
|
+
found.push(path);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
await walk(directory);
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reads the build once: the files it has, and the page. Only what was listed is ever served,
|
|
101
|
+
* so a path can name nothing outside the directory, whatever it spells.
|
|
102
|
+
*/
|
|
103
|
+
export async function loadWebRoot(directory: string): Promise<WebRoot> {
|
|
104
|
+
let page: Buffer;
|
|
105
|
+
try {
|
|
106
|
+
page = await readFile(join(directory, "index.html"));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new WebRootError(`${directory} holds no index.html`, { cause: error });
|
|
109
|
+
}
|
|
110
|
+
const pageEtag = `"${createHash("sha256").update(page).digest("base64url").slice(0, 27)}"`;
|
|
111
|
+
const files = new Map<string, StaticFile>();
|
|
112
|
+
for (const file of await listFiles(directory)) {
|
|
113
|
+
const path = `/${relative(directory, file).split(sep).join("/")}`;
|
|
114
|
+
if (path === "/index.html") {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const info = await stat(file);
|
|
118
|
+
// A build names its bundles by their content: those never change, and may be kept forever.
|
|
119
|
+
const immutable = path.startsWith("/assets/");
|
|
120
|
+
files.set(path, {
|
|
121
|
+
file,
|
|
122
|
+
contentType: CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream",
|
|
123
|
+
etag: `W/"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`,
|
|
124
|
+
size: info.size,
|
|
125
|
+
cacheControl: immutable ? "public, max-age=31536000, immutable" : "no-cache",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const respond = async (request: Request): Promise<Response | undefined> => {
|
|
130
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const { pathname } = new URL(request.url);
|
|
134
|
+
if (NOT_PAGES.some((prefix) => pathname === prefix || pathname.startsWith(prefix))) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const found = files.get(pathname);
|
|
138
|
+
const head = request.method === "HEAD";
|
|
139
|
+
if (found !== undefined) {
|
|
140
|
+
const headers = {
|
|
141
|
+
"content-type": found.contentType,
|
|
142
|
+
"cache-control": found.cacheControl,
|
|
143
|
+
etag: found.etag,
|
|
144
|
+
"x-content-type-options": "nosniff",
|
|
145
|
+
};
|
|
146
|
+
if (request.headers.get("if-none-match") === found.etag) {
|
|
147
|
+
return new Response(null, { status: 304, headers });
|
|
148
|
+
}
|
|
149
|
+
return new Response(head ? null : new Uint8Array(await readFile(found.file)), {
|
|
150
|
+
status: 200,
|
|
151
|
+
headers: { ...headers, "content-length": String(found.size) },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// Every other path is a page of the app, which routes in the browser; what is not one is
|
|
155
|
+
// the app's own not-found page, and a crawler reads the shell either way.
|
|
156
|
+
const headers = {
|
|
157
|
+
...PAGE_HEADERS,
|
|
158
|
+
"content-type": HTML,
|
|
159
|
+
"cache-control": "no-cache",
|
|
160
|
+
etag: pageEtag,
|
|
161
|
+
"content-length": String(page.byteLength),
|
|
162
|
+
};
|
|
163
|
+
if (request.headers.get("if-none-match") === pageEtag) {
|
|
164
|
+
return new Response(null, { status: 304, headers });
|
|
165
|
+
}
|
|
166
|
+
return new Response(head ? null : new Uint8Array(page), { status: 200, headers });
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return { respond, files: files.size };
|
|
170
|
+
}
|