@skillcdn/console 0.1.6 → 0.1.8

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 (97) hide show
  1. package/README.md +50 -2
  2. package/SKILLCDN.md +35 -0
  3. package/dist/cli/cli.d.ts +3 -0
  4. package/dist/cli/cli.d.ts.map +1 -1
  5. package/dist/cli/cli.js +61 -0
  6. package/dist/cli/cli.js.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +5 -0
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/main.js +2 -0
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/cli/serve.d.ts +37 -0
  13. package/dist/cli/serve.d.ts.map +1 -0
  14. package/dist/cli/serve.js +218 -0
  15. package/dist/cli/serve.js.map +1 -0
  16. package/dist/console.d.ts +6 -0
  17. package/dist/console.d.ts.map +1 -1
  18. package/dist/console.js +78 -13
  19. package/dist/console.js.map +1 -1
  20. package/dist/data.d.ts.map +1 -1
  21. package/dist/data.js +5 -1
  22. package/dist/data.js.map +1 -1
  23. package/dist/i18n/en.d.ts +4 -0
  24. package/dist/i18n/en.d.ts.map +1 -1
  25. package/dist/i18n/en.js +4 -0
  26. package/dist/i18n/en.js.map +1 -1
  27. package/dist/i18n/ko.d.ts.map +1 -1
  28. package/dist/i18n/ko.js +4 -0
  29. package/dist/i18n/ko.js.map +1 -1
  30. package/dist/schemas.d.ts +1 -0
  31. package/dist/schemas.d.ts.map +1 -1
  32. package/dist/schemas.js +5 -0
  33. package/dist/schemas.js.map +1 -1
  34. package/dist/web.d.ts +16 -0
  35. package/dist/web.d.ts.map +1 -0
  36. package/dist/web.js +149 -0
  37. package/dist/web.js.map +1 -0
  38. package/docs/specs/cli.md +92 -0
  39. package/docs/specs/rest.md +221 -0
  40. package/package.json +14 -3
  41. package/skills/building-a-console/SKILL.md +102 -0
  42. package/skills/building-a-console/references/the-default-console.md +140 -0
  43. package/skills/deploying-a-console/SKILL.md +130 -0
  44. package/skills/deploying-a-console/assets/deployer-policy.json +118 -0
  45. package/skills/deploying-a-console/assets/secrets.example.json +11 -0
  46. package/skills/deploying-a-console/references/access.md +78 -0
  47. package/skills/deploying-a-console/references/resources.md +516 -0
  48. package/skills/deploying-a-console/references/sizing.md +90 -0
  49. package/skills/deploying-a-console/scripts/import-aws-key.mjs +35 -0
  50. package/skills/deploying-a-console/scripts/store-secrets.mjs +114 -0
  51. package/skills/working-the-board/SKILL.md +86 -0
  52. package/src/api.ts +235 -0
  53. package/src/cli/cli.ts +1114 -0
  54. package/src/cli/credentials.ts +68 -0
  55. package/src/cli/directory.ts +59 -0
  56. package/src/cli/format.ts +284 -0
  57. package/src/cli/help.ts +326 -0
  58. package/src/cli/main.ts +39 -0
  59. package/src/cli/serve.ts +276 -0
  60. package/src/cli/terminal.ts +28 -0
  61. package/src/client.ts +533 -0
  62. package/src/components/board.tsx +112 -0
  63. package/src/components/connect.tsx +126 -0
  64. package/src/components/decision-form.tsx +136 -0
  65. package/src/components/decision-list.tsx +278 -0
  66. package/src/components/documents.tsx +569 -0
  67. package/src/components/event-feed.tsx +129 -0
  68. package/src/components/markdown.tsx +72 -0
  69. package/src/components/people.tsx +59 -0
  70. package/src/components/projects.tsx +364 -0
  71. package/src/components/runs.tsx +175 -0
  72. package/src/components/shell.tsx +284 -0
  73. package/src/components/sign-in.tsx +61 -0
  74. package/src/components/skills.tsx +78 -0
  75. package/src/components/task-form.tsx +229 -0
  76. package/src/components/task-view.tsx +272 -0
  77. package/src/components/tokens.tsx +223 -0
  78. package/src/components/ui.tsx +219 -0
  79. package/src/connect-page.tsx +148 -0
  80. package/src/connect.ts +27 -0
  81. package/src/console.tsx +1166 -0
  82. package/src/data.ts +493 -0
  83. package/src/docs-page.tsx +334 -0
  84. package/src/documents.ts +76 -0
  85. package/src/i18n/en.ts +537 -0
  86. package/src/i18n/index.ts +76 -0
  87. package/src/i18n/ko.ts +511 -0
  88. package/src/index.ts +142 -0
  89. package/src/limits.ts +61 -0
  90. package/src/router.ts +295 -0
  91. package/src/routes.ts +106 -0
  92. package/src/schemas.ts +776 -0
  93. package/src/styles/console.css +2432 -0
  94. package/src/text.ts +33 -0
  95. package/src/use-action.ts +37 -0
  96. package/src/vocabulary.ts +109 -0
  97. package/src/web.ts +170 -0
@@ -0,0 +1,334 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ ApiError,
4
+ folderOf,
5
+ type RestBacklink,
6
+ type RestDocument,
7
+ type RestDocumentFile,
8
+ type RestDocuments,
9
+ type RestProject,
10
+ type RestVersion,
11
+ type RestVersionSummary,
12
+ } from "./api.js";
13
+ import {
14
+ DocumentCrumbs,
15
+ DocumentForm,
16
+ DocumentList,
17
+ DocumentSearch,
18
+ DocumentView,
19
+ FolderView,
20
+ } from "./components/documents.js";
21
+ import { Callout, EmptyState, Spinner } from "./components/ui.js";
22
+ import type { ConsoleData } from "./data.js";
23
+ import { useWords } from "./i18n/index.js";
24
+ import {
25
+ type DocsView,
26
+ decisionHref,
27
+ docHref,
28
+ type Navigation,
29
+ projectHref,
30
+ taskHref,
31
+ } from "./router.js";
32
+ import { projectPath } from "./routes.js";
33
+ import { useAction } from "./use-action.js";
34
+
35
+ // The Docs page of a project (ADR-0009): the folder or the page the path names, read on its
36
+ // own since the tree may be large, and read again whenever the project's feed grows; and the
37
+ // view the address asks for (ADR-0010): a search, a form, an earlier version. Part of the
38
+ // default composition; a custom console composes the same components its own way.
39
+
40
+ export interface DocsPageProps {
41
+ readonly project: RestProject;
42
+ /** The path the page is on: a folder, a page, or `""` for the root. */
43
+ readonly path: string;
44
+ /** How the address says the path is shown. */
45
+ readonly view: DocsView;
46
+ readonly data: ConsoleData;
47
+ readonly navigation: Navigation;
48
+ }
49
+
50
+ /** A document the server has no page for, as the page remembers it. */
51
+ type Found = RestDocument | null | undefined;
52
+
53
+ export function DocsPage(props: DocsPageProps) {
54
+ const words = useWords().docs;
55
+ const { project, path, view, data, navigation } = props;
56
+ const [listing, setListing] = useState<RestDocuments | undefined>(undefined);
57
+ const [document, setDocument] = useState<Found>(undefined);
58
+ const [versions, setVersions] = useState<readonly RestVersionSummary[] | undefined>(undefined);
59
+ const [version, setVersion] = useState<RestVersion | undefined>(undefined);
60
+ const [results, setResults] = useState<RestDocuments | undefined>(undefined);
61
+ const [problem, setProblem] = useState<string | undefined>(undefined);
62
+ const { busy, error, act } = useAction();
63
+ const { actions } = data;
64
+ const key = project.key;
65
+ const feedLength = data.events.length;
66
+ const wanted = view.kind === "read" ? view.version : undefined;
67
+ const query = view.kind === "search" ? view.q : undefined;
68
+ const latest = document?.version;
69
+
70
+ // The folder and the page at the path, asked for again whenever the project's feed grows.
71
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the feed's length is the trigger
72
+ useEffect(() => {
73
+ const controller = new AbortController();
74
+ const { signal } = controller;
75
+ setProblem(undefined);
76
+ const load = async () => {
77
+ const [folder, found] = await Promise.all([
78
+ actions.documents({ folder: path }, signal),
79
+ path === ""
80
+ ? Promise.resolve<Found>(null)
81
+ : actions.document(path, signal).catch((failure: unknown): Found => {
82
+ if (failure instanceof ApiError && failure.code === "document.not_found") {
83
+ return null;
84
+ }
85
+ throw failure;
86
+ }),
87
+ ]);
88
+ if (signal.aborted) {
89
+ return;
90
+ }
91
+ setListing(folder);
92
+ setDocument(found);
93
+ if (found !== null && found !== undefined) {
94
+ actions
95
+ .documentVersions(path, signal)
96
+ .then((answer) => {
97
+ if (!signal.aborted) {
98
+ setVersions(answer.items);
99
+ }
100
+ })
101
+ .catch(() => undefined);
102
+ } else {
103
+ setVersions(undefined);
104
+ }
105
+ };
106
+ load().catch((failure: unknown) => {
107
+ if (!signal.aborted) {
108
+ setProblem(failure instanceof ApiError ? failure.message : words.couldNotLoad);
109
+ }
110
+ });
111
+ return () => controller.abort();
112
+ }, [actions, path, feedLength]);
113
+
114
+ // The earlier version the address asks for, once the page is known.
115
+ useEffect(() => {
116
+ if (wanted === undefined || latest === undefined) {
117
+ setVersion(undefined);
118
+ return;
119
+ }
120
+ const controller = new AbortController();
121
+ actions
122
+ .documentVersion(path, wanted, controller.signal)
123
+ .then((answer) => {
124
+ if (!controller.signal.aborted) {
125
+ setVersion(answer);
126
+ }
127
+ })
128
+ .catch(() => undefined);
129
+ return () => controller.abort();
130
+ }, [actions, path, wanted, latest]);
131
+
132
+ // The search the address asks for, over all the project's pages.
133
+ useEffect(() => {
134
+ if (query === undefined) {
135
+ setResults(undefined);
136
+ return;
137
+ }
138
+ const controller = new AbortController();
139
+ actions
140
+ .documents({ q: query }, controller.signal)
141
+ .then((answer) => {
142
+ if (!controller.signal.aborted) {
143
+ setResults(answer);
144
+ }
145
+ })
146
+ .catch((failure: unknown) => {
147
+ if (!controller.signal.aborted) {
148
+ setProblem(failure instanceof ApiError ? failure.message : words.searchFailed);
149
+ }
150
+ });
151
+ return () => controller.abort();
152
+ }, [actions, query, words]);
153
+
154
+ const toDoc = (target: string) => docHref(key, target);
155
+ const toFolder = (folder: string) => docHref(key, folder);
156
+ const toFile = (file: RestDocumentFile) => `${projectPath(key, "docs", path)}/files/${file.id}`;
157
+ const toTask = (backlink: RestBacklink) =>
158
+ backlink.number === null ? projectHref(key) : taskHref(key, backlink.number);
159
+ const toDecision = (decisionId: string) => decisionHref(key, decisionId);
160
+ /** The plain address of what the page is on. */
161
+ const here = toDoc(path);
162
+ const page = document !== null && document !== undefined ? document : undefined;
163
+ /** The folder a new page goes in: this one, or the one the page shown is in. */
164
+ const folder = page === undefined ? path : folderOf(path);
165
+
166
+ const search = (typed: string) =>
167
+ navigation.go(typed === "" ? here : docHref(key, path, { kind: "search", q: typed }));
168
+
169
+ const head = (
170
+ <>
171
+ <div className="sc-page-head">
172
+ <h1 className="sc-page-title">{words.title}</h1>
173
+ {view.kind !== "new" && (
174
+ <a className="sc-button sc-button-primary" href={docHref(key, folder, { kind: "new" })}>
175
+ {words.newPage}
176
+ </a>
177
+ )}
178
+ </div>
179
+ <DocumentSearch key={query ?? ""} query={query} busy={busy} onSearch={search} />
180
+ {problem !== undefined && <Callout tone="danger">{problem}</Callout>}
181
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
182
+ </>
183
+ );
184
+
185
+ if (view.kind === "search") {
186
+ return (
187
+ <>
188
+ {head}
189
+ <section aria-label={words.found}>
190
+ <h2 className="sc-section-title">{words.pagesWith(view.q)}</h2>
191
+ {results === undefined ? (
192
+ <div className="sc-loading">
193
+ <Spinner label={words.searching} />
194
+ </div>
195
+ ) : (
196
+ <DocumentList
197
+ items={results.items}
198
+ docHref={toDoc}
199
+ empty={<EmptyState title={words.noPageHasWords} />}
200
+ />
201
+ )}
202
+ </section>
203
+ </>
204
+ );
205
+ }
206
+
207
+ if (view.kind === "new") {
208
+ return (
209
+ <>
210
+ {head}
211
+ <section className="sc-panel" aria-label={words.newPageAria}>
212
+ <DocumentForm
213
+ folder={folder}
214
+ busy={busy}
215
+ onSubmit={(input) =>
216
+ void act(async () => {
217
+ const written = await actions.writeDocument(input.path, {
218
+ title: input.title,
219
+ body: input.body,
220
+ });
221
+ navigation.replace(toDoc(written.path));
222
+ })
223
+ }
224
+ onCancel={() => navigation.back(here)}
225
+ />
226
+ </section>
227
+ </>
228
+ );
229
+ }
230
+
231
+ if (document === undefined && listing === undefined) {
232
+ return (
233
+ <>
234
+ {head}
235
+ <div className="sc-loading">
236
+ <Spinner label={words.loading} />
237
+ </div>
238
+ </>
239
+ );
240
+ }
241
+
242
+ if (page !== undefined) {
243
+ if (view.kind === "edit") {
244
+ return (
245
+ <>
246
+ {head}
247
+ <section className="sc-panel" aria-label={words.editPageAria}>
248
+ <DocumentForm
249
+ document={page}
250
+ busy={busy}
251
+ onSubmit={(input) =>
252
+ void act(async () => {
253
+ const written = await actions.writeDocument(page.path, {
254
+ title: input.title,
255
+ body: input.body,
256
+ ...(input.baseVersion === undefined ? {} : { baseVersion: input.baseVersion }),
257
+ });
258
+ setDocument(written);
259
+ navigation.replace(here);
260
+ })
261
+ }
262
+ onCancel={() => navigation.back(here)}
263
+ />
264
+ </section>
265
+ </>
266
+ );
267
+ }
268
+ const archived = page.archivedAt !== null;
269
+ return (
270
+ <>
271
+ {head}
272
+ <DocumentView
273
+ document={page}
274
+ version={wanted !== undefined && version?.number === wanted ? version : undefined}
275
+ versions={versions}
276
+ docHref={toDoc}
277
+ folderHref={toFolder}
278
+ fileHref={toFile}
279
+ taskHref={toTask}
280
+ decisionHref={toDecision}
281
+ busy={busy}
282
+ editHref={archived ? undefined : docHref(key, path, { kind: "edit" })}
283
+ versionHref={(number) =>
284
+ number === undefined ? here : docHref(key, path, { kind: "read", version: number })
285
+ }
286
+ onArchive={() =>
287
+ void act(async () => {
288
+ setDocument(await actions.archiveDocument(page.path));
289
+ })
290
+ }
291
+ onRestore={() =>
292
+ void act(async () => {
293
+ setDocument(await actions.restoreDocument(page.path));
294
+ })
295
+ }
296
+ onAttach={(file, label) =>
297
+ void act(async () => {
298
+ const bytes = new Uint8Array(await file.arrayBuffer());
299
+ setDocument(
300
+ await actions.attachDocumentFile(page.path, {
301
+ name: file.name,
302
+ bytes,
303
+ ...(file.type === "" ? {} : { contentType: file.type }),
304
+ ...(label === undefined ? {} : { label }),
305
+ }),
306
+ );
307
+ })
308
+ }
309
+ />
310
+ </>
311
+ );
312
+ }
313
+
314
+ const something =
315
+ listing !== undefined && (listing.folders.length > 0 || listing.items.length > 0);
316
+ const write = (
317
+ <a className="sc-button sc-button-secondary" href={docHref(key, path, { kind: "new" })}>
318
+ {path === "" ? words.newPage : words.writeIt}
319
+ </a>
320
+ );
321
+ return (
322
+ <>
323
+ {head}
324
+ {path !== "" && <DocumentCrumbs path={path} folderHref={toFolder} />}
325
+ {something && listing !== undefined ? (
326
+ <FolderView listing={listing} docHref={toDoc} folderHref={toFolder} />
327
+ ) : path === "" ? (
328
+ <EmptyState title={words.emptyRoot.title} body={words.emptyRoot.body} action={write} />
329
+ ) : (
330
+ <EmptyState title={words.emptyPath.title} body={words.emptyPath.body} action={write} />
331
+ )}
332
+ </>
333
+ );
334
+ }
@@ -0,0 +1,76 @@
1
+ import { MAX_DOCUMENT_PATH_LENGTH, MAX_DOCUMENT_SEGMENT_LENGTH } from "./limits.js";
2
+
3
+ // A document's address (ADR-0009): a path of lowercase segments, as a repository keeps files,
4
+ // and the way a link in Markdown names one. The server records the links a text makes with
5
+ // these, and the pages turn them into links to the document's page; both read the same rule.
6
+
7
+ /** One segment of a path: lowercase letters, digits and hyphens, beginning and ending with a letter or a digit. */
8
+ const SEGMENT = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
9
+
10
+ /** A document's path: segments separated by `/`, with no slash at either end. */
11
+ export const DOCUMENT_PATH = new RegExp(`^${SEGMENT}(?:/${SEGMENT})*$`);
12
+
13
+ /** True when `value` is a document's path, within the bounds. */
14
+ export function isDocumentPath(value: string): boolean {
15
+ return (
16
+ value.length > 0 &&
17
+ value.length <= MAX_DOCUMENT_PATH_LENGTH &&
18
+ DOCUMENT_PATH.test(value) &&
19
+ value.split("/").every((segment) => segment.length <= MAX_DOCUMENT_SEGMENT_LENGTH)
20
+ );
21
+ }
22
+
23
+ /** True when `value` names a folder: the root (`""`), or a path by the same rule. */
24
+ export function isFolderPath(value: string): boolean {
25
+ return value === "" || isDocumentPath(value);
26
+ }
27
+
28
+ /** The folder a path is in: `guides` for `guides/onboarding`, `""` for a page at the root. */
29
+ export function folderOf(path: string): string {
30
+ const at = path.lastIndexOf("/");
31
+ return at < 0 ? "" : path.slice(0, at);
32
+ }
33
+
34
+ /** The last segment of a path: what the page is called in its folder. */
35
+ export function nameOf(path: string): string {
36
+ const at = path.lastIndexOf("/");
37
+ return at < 0 ? path : path.slice(at + 1);
38
+ }
39
+
40
+ /** The folders above a path, from the root down: `["guides", "guides/setup"]` for `guides/setup/git`. */
41
+ export function foldersAbove(path: string): string[] {
42
+ const segments = path.split("/").slice(0, -1);
43
+ return segments.map((_, index) => segments.slice(0, index + 1).join("/"));
44
+ }
45
+
46
+ /**
47
+ * The destination of a link in Markdown: `[words](destination "title")`, and a reference
48
+ * definition, `[label]: destination`. A picture (`![alt](source)`) is not a link to a page.
49
+ */
50
+ const INLINE_LINK =
51
+ /(!?)\[[^\]]*\]\(\s*<?([^\s()<>]+)>?(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
52
+ const REFERENCE_DEFINITION = /^[ \t]{0,3}\[[^\]]+\]:[ \t]*<?([^\s<>]+)>?/gm;
53
+
54
+ /**
55
+ * The documents a Markdown text links to, by path, each once and in order: every link whose
56
+ * destination is a document path, read from the project's root wherever the text is. A
57
+ * fragment (`#heading`) names a place in the page and is left off. Linear in the text.
58
+ */
59
+ export function documentPathsIn(markdown: string): string[] {
60
+ const found = new Set<string>();
61
+ const consider = (destination: string | undefined) => {
62
+ const path = (destination ?? "").split("#", 1)[0] ?? "";
63
+ if (isDocumentPath(path)) {
64
+ found.add(path);
65
+ }
66
+ };
67
+ for (const match of markdown.matchAll(INLINE_LINK)) {
68
+ if (match[1] !== "!") {
69
+ consider(match[2]);
70
+ }
71
+ }
72
+ for (const match of markdown.matchAll(REFERENCE_DEFINITION)) {
73
+ consider(match[1]);
74
+ }
75
+ return [...found].sort();
76
+ }