@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.
- package/README.md +50 -2
- package/SKILLCDN.md +35 -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 +221 -0
- package/package.json +14 -3
- package/skills/building-a-console/SKILL.md +102 -0
- package/skills/building-a-console/references/the-default-console.md +140 -0
- package/skills/deploying-a-console/SKILL.md +130 -0
- package/skills/deploying-a-console/assets/deployer-policy.json +118 -0
- package/skills/deploying-a-console/assets/secrets.example.json +11 -0
- package/skills/deploying-a-console/references/access.md +78 -0
- package/skills/deploying-a-console/references/resources.md +516 -0
- package/skills/deploying-a-console/references/sizing.md +90 -0
- package/skills/deploying-a-console/scripts/import-aws-key.mjs +35 -0
- package/skills/deploying-a-console/scripts/store-secrets.mjs +114 -0
- package/skills/working-the-board/SKILL.md +86 -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/cli/help.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_AGENT_LENGTH,
|
|
3
|
+
MAX_ARTIFACTS_PER_RUN,
|
|
4
|
+
MAX_BODY_LENGTH,
|
|
5
|
+
MAX_DOCUMENT_LENGTH,
|
|
6
|
+
MAX_DOCUMENT_PATH_LENGTH,
|
|
7
|
+
MAX_FILE_BYTES,
|
|
8
|
+
MAX_FILES_PER_DOCUMENT,
|
|
9
|
+
MAX_LINK_LABEL_LENGTH,
|
|
10
|
+
MAX_OPTION_LABEL_LENGTH,
|
|
11
|
+
MAX_OPTIONS,
|
|
12
|
+
MAX_QUESTION_LENGTH,
|
|
13
|
+
MAX_REPORTS_PER_RUN,
|
|
14
|
+
MAX_SUMMARY_LENGTH,
|
|
15
|
+
MAX_TITLE_LENGTH,
|
|
16
|
+
MAX_VERSIONS_PER_DOCUMENT,
|
|
17
|
+
MIN_OPTIONS,
|
|
18
|
+
} from "../limits.js";
|
|
19
|
+
|
|
20
|
+
// What the command says about itself: for an agent that meets it for the first time, and for a
|
|
21
|
+
// person. One table for every command, from which the usage and each command's help are made:
|
|
22
|
+
// the overview says what each command does, and a command's own help adds the limits the
|
|
23
|
+
// console holds it to and the refusals it may meet, so that an agent learns them before it
|
|
24
|
+
// runs into them.
|
|
25
|
+
|
|
26
|
+
export interface CommandHelp {
|
|
27
|
+
/** The command and its arguments, after `console`. */
|
|
28
|
+
readonly usage: string;
|
|
29
|
+
/** What it does, in a sentence. */
|
|
30
|
+
readonly about: string;
|
|
31
|
+
/** What the console holds the arguments to. */
|
|
32
|
+
readonly limits?: readonly string[];
|
|
33
|
+
/** What the console may refuse, by code, and what to do then. */
|
|
34
|
+
readonly refusals?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The refusals any command may meet, said once in the overview. */
|
|
38
|
+
const COMMON_REFUSALS = [
|
|
39
|
+
"auth.required: the token is not one the console knows, any more or at all; a person signs the command in again",
|
|
40
|
+
"request.invalid: what was sent is over a limit or malformed; the message names the field",
|
|
41
|
+
"network: the console could not be reached at its address",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const RUN_REFUSALS = [
|
|
45
|
+
"run.not_yours: the run is another person's",
|
|
46
|
+
"run.over: the run has ended already",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const MARKDOWN = `Markdown, up to ${MAX_BODY_LENGTH} characters`;
|
|
50
|
+
|
|
51
|
+
const DOCUMENT_PATH = `a path of lowercase letters, digits and hyphens, with slashes between its folders (guides/onboarding), up to ${MAX_DOCUMENT_PATH_LENGTH} characters; it does not change once written`;
|
|
52
|
+
const DOCUMENT_REFUSALS = ["document.not_found: no page has that path in this project"];
|
|
53
|
+
|
|
54
|
+
export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
|
55
|
+
login: {
|
|
56
|
+
usage: "login --url <origin> [--agent <name>] [--token-stdin]",
|
|
57
|
+
about:
|
|
58
|
+
"Connects this agent to the console: shows an address and a code, which a person opens signed in, names the agent and approves; the command then receives a token of its own and keeps it, with the console's address, in your home directory, never showing it. An agent running the command relays the address and the code to its person and waits. --agent says what the agent calls itself. --token-stdin reads a token made by hand from standard input instead, for a script or a job.",
|
|
59
|
+
refusals: [
|
|
60
|
+
"connect.not_found: the code was not approved in time, or the person said it was not theirs; run login again",
|
|
61
|
+
"the console does not know the token given: make one on the Agents page and try again",
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
logout: {
|
|
65
|
+
usage: "logout",
|
|
66
|
+
about:
|
|
67
|
+
"Forgets the address and the token kept here. The token itself is removed on the Agents page.",
|
|
68
|
+
},
|
|
69
|
+
whoami: {
|
|
70
|
+
usage: "whoami",
|
|
71
|
+
about:
|
|
72
|
+
"Who the token acts as, which console it is for, and which project this directory works in.",
|
|
73
|
+
},
|
|
74
|
+
projects: {
|
|
75
|
+
usage: "projects",
|
|
76
|
+
about:
|
|
77
|
+
"The projects you may work in: each with its key, its name, what you are in it, and what waits in it. Every command on the board works in one project.",
|
|
78
|
+
},
|
|
79
|
+
use: {
|
|
80
|
+
usage: "use <key>",
|
|
81
|
+
about:
|
|
82
|
+
"Says which project this directory works in: writes .skillcdn-console.json here, which the commands read from this directory or one above it, and which is meant to be committed. --project <key> on any command, or CONSOLE_PROJECT in the environment, wins over it.",
|
|
83
|
+
refusals: ["project.not_found: no project has the key, or it is not yours to see"],
|
|
84
|
+
},
|
|
85
|
+
tasks: {
|
|
86
|
+
usage: "tasks [--state <state>]",
|
|
87
|
+
about:
|
|
88
|
+
"The tasks on the board, newest first: number, state, priority, title, who owns and works on each, and what waits on it. States: idea, ready, in_progress, in_review, done, dropped.",
|
|
89
|
+
},
|
|
90
|
+
task: {
|
|
91
|
+
usage:
|
|
92
|
+
"task <number|id> | task new <title> [--body <markdown>|--file <path>] [--state <state>] [--priority <priority>]",
|
|
93
|
+
about:
|
|
94
|
+
"One task in full, with the decisions about it and the runs on it; or a new task written down (priorities: low, normal, high, urgent).",
|
|
95
|
+
limits: [`title: one line, up to ${MAX_TITLE_LENGTH} characters`, `body: ${MARKDOWN}`],
|
|
96
|
+
refusals: ["task.not_found: no task has that number or id"],
|
|
97
|
+
},
|
|
98
|
+
skills: {
|
|
99
|
+
usage: "skills",
|
|
100
|
+
about:
|
|
101
|
+
"The organization's skills, as SkillCDN serves them at the console's address: each with its description and the URI you load it by through your own SkillCDN connection. --json adds the page of each, for a person.",
|
|
102
|
+
},
|
|
103
|
+
take: {
|
|
104
|
+
usage: "take <number|id> [--agent <name>]",
|
|
105
|
+
about:
|
|
106
|
+
"Takes a task: a run begins, and the task is in progress and the person's. One agent at a time per task. --agent is what people see at work; left out, it is what the person called the token.",
|
|
107
|
+
limits: [`agent: one line, up to ${MAX_AGENT_LENGTH} characters`],
|
|
108
|
+
refusals: [
|
|
109
|
+
"task.not_found: no task has that number or id",
|
|
110
|
+
"run.task_taken: a run is open on the task already; console runs --open --mine shows whether it is yours",
|
|
111
|
+
"run.task_closed: the task is done or dropped",
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
report: {
|
|
115
|
+
usage: "report <markdown> | report --file <path> | report -",
|
|
116
|
+
about:
|
|
117
|
+
"Says how the work goes, in Markdown: what was found, what was done, what is next. A milestone at a time, not every step. With - the report is read from standard input.",
|
|
118
|
+
limits: [
|
|
119
|
+
`the report: ${MARKDOWN}, not blank`,
|
|
120
|
+
`at most ${MAX_REPORTS_PER_RUN} reports on one run`,
|
|
121
|
+
],
|
|
122
|
+
refusals: [...RUN_REFUSALS, "run.too_many_reports: the run carries as many as one may"],
|
|
123
|
+
},
|
|
124
|
+
"hand-in": {
|
|
125
|
+
usage: "hand-in <https url | file path> [--label <words>]",
|
|
126
|
+
about:
|
|
127
|
+
"Hands in what was made: a link (a branch, a pull request, a document, a page), or a file from this machine, which the console keeps and shows on the task.",
|
|
128
|
+
limits: [
|
|
129
|
+
"a link: https only",
|
|
130
|
+
`a file: up to ${MAX_FILE_BYTES} bytes, not empty; it is called by its name, without the path`,
|
|
131
|
+
`label: one line, up to ${MAX_LINK_LABEL_LENGTH} characters`,
|
|
132
|
+
`at most ${MAX_ARTIFACTS_PER_RUN} links and files on one run`,
|
|
133
|
+
],
|
|
134
|
+
refusals: [
|
|
135
|
+
...RUN_REFUSALS,
|
|
136
|
+
"run.too_many_artifacts: the run carries as many as one may",
|
|
137
|
+
"request.too_large: the file is over the limit",
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
ask: {
|
|
141
|
+
usage:
|
|
142
|
+
'ask "<question>" --option "<label>" --option "<label>" [...] [--body <markdown>|--file <path>] [--wait <seconds>]',
|
|
143
|
+
about:
|
|
144
|
+
"Raises a decision a person must make: the question, the options to choose from, and what they need to know. The run waits for the answer. The command waits 60 seconds for it unless --wait says otherwise, and exits with 3 if it is still to come: keep waiting with `console decision <id> --wait 100`, within what your shell gives one command, and go on meanwhile only with work that does not depend on the answer. The decision's id is printed as soon as it is raised.",
|
|
145
|
+
limits: [
|
|
146
|
+
`question: one line, up to ${MAX_QUESTION_LENGTH} characters`,
|
|
147
|
+
`options: ${MIN_OPTIONS} to ${MAX_OPTIONS}, each one line up to ${MAX_OPTION_LABEL_LENGTH} characters`,
|
|
148
|
+
`body: ${MARKDOWN}`,
|
|
149
|
+
],
|
|
150
|
+
refusals: ["decision.invalid_run: the run is not yours, or is over"],
|
|
151
|
+
},
|
|
152
|
+
decision: {
|
|
153
|
+
usage: "decision <id> [--wait <seconds>] | decision <id> --outcome <markdown>|--file <path>",
|
|
154
|
+
about:
|
|
155
|
+
"One decision, with its context, its options, its answer and what followed. With --wait, holds on for the answer that long, and exits with 3 if it is still to come. With --outcome, writes down what followed the decision, so that it reads as a record.",
|
|
156
|
+
limits: [`outcome: ${MARKDOWN}`],
|
|
157
|
+
refusals: ["decision.not_found: no decision has that id"],
|
|
158
|
+
},
|
|
159
|
+
decisions: {
|
|
160
|
+
usage: "decisions [--open]",
|
|
161
|
+
about: "The decisions on the board, the waiting first; --open keeps only those.",
|
|
162
|
+
},
|
|
163
|
+
finish: {
|
|
164
|
+
usage: 'finish [--summary "<markdown>"|--file <path>]',
|
|
165
|
+
about:
|
|
166
|
+
"Ends the run: the work is done and handed in, and the task goes up for review. The summary says what was done and what is left; the result itself goes in a last report before, which may be much longer.",
|
|
167
|
+
limits: [`summary: ${`Markdown, up to ${MAX_SUMMARY_LENGTH} characters`}`],
|
|
168
|
+
refusals: RUN_REFUSALS,
|
|
169
|
+
},
|
|
170
|
+
fail: {
|
|
171
|
+
usage: 'fail [--summary "<markdown>"|--file <path>]',
|
|
172
|
+
about: "Ends the run: the work could not be done. The summary says why, and what is left.",
|
|
173
|
+
limits: [`summary: Markdown, up to ${MAX_SUMMARY_LENGTH} characters`],
|
|
174
|
+
refusals: RUN_REFUSALS,
|
|
175
|
+
},
|
|
176
|
+
abandon: {
|
|
177
|
+
usage: 'abandon [<run id>] [--summary "<markdown>"]',
|
|
178
|
+
about:
|
|
179
|
+
"Leaves a run; the task stays as it is. A person gives up this way on a run of theirs that will not come back.",
|
|
180
|
+
limits: [`summary: Markdown, up to ${MAX_SUMMARY_LENGTH} characters`],
|
|
181
|
+
refusals: RUN_REFUSALS,
|
|
182
|
+
},
|
|
183
|
+
runs: {
|
|
184
|
+
usage: "runs [--task <number|id>] [--open] [--mine]",
|
|
185
|
+
about:
|
|
186
|
+
"The runs, newest first: agents at work and what they did. --mine keeps those begun with this token.",
|
|
187
|
+
},
|
|
188
|
+
run: {
|
|
189
|
+
usage: "run <id>",
|
|
190
|
+
about: "One run in full: its reports, what it handed in, what it waits for, how it ended.",
|
|
191
|
+
refusals: ["run.not_found: no run has that id"],
|
|
192
|
+
},
|
|
193
|
+
docs: {
|
|
194
|
+
usage: "docs [<folder>] [--search <words>] [--archived]",
|
|
195
|
+
about:
|
|
196
|
+
"The pages of the project's documents: those in a folder, with the folders in it (the root when none is named), or those found by words in their title or body. Archived pages are left out unless --archived.",
|
|
197
|
+
},
|
|
198
|
+
doc: {
|
|
199
|
+
usage: "doc <path> [--version <n>|--versions]",
|
|
200
|
+
about:
|
|
201
|
+
"One page in full: its title, its body in Markdown, what it links to, what refers to it (pages, tasks, decisions), and its files. --versions lists its versions, who wrote each and when; --version <n> prints one as it was.",
|
|
202
|
+
refusals: [...DOCUMENT_REFUSALS, "document.version_not_found: the page has no such version"],
|
|
203
|
+
},
|
|
204
|
+
write: {
|
|
205
|
+
usage: 'write <path> [--title "<title>"] --body <markdown>|--file <path> [--base <version>]',
|
|
206
|
+
about:
|
|
207
|
+
"Writes a page: the first version at a new path, or a new version of the page there. A link in the body whose destination is a page's path (guides/onboarding) refers to that page, and the page says what refers to it. Without --title the page keeps its title. With --base, the write is refused if the page has moved on since that version.",
|
|
208
|
+
limits: [
|
|
209
|
+
`path: ${DOCUMENT_PATH}`,
|
|
210
|
+
`title: one line, up to ${MAX_TITLE_LENGTH} characters`,
|
|
211
|
+
`body: Markdown, up to ${MAX_DOCUMENT_LENGTH} characters`,
|
|
212
|
+
`at most ${MAX_VERSIONS_PER_DOCUMENT} versions of one page`,
|
|
213
|
+
],
|
|
214
|
+
refusals: [
|
|
215
|
+
"document.invalid_path: the path is not one",
|
|
216
|
+
"document.conflict: the page has moved on since --base; read it again",
|
|
217
|
+
"document.archived: the page is archived; restore it first",
|
|
218
|
+
"document.too_many_versions: the page carries as many versions as one may; write a new page",
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
attach: {
|
|
222
|
+
usage: "attach <path> <file> [--label <words>]",
|
|
223
|
+
about:
|
|
224
|
+
"Attaches a file from this machine to a page: the console keeps it, and the page shows it.",
|
|
225
|
+
limits: [
|
|
226
|
+
`a file: up to ${MAX_FILE_BYTES} bytes, not empty; it is called by its name, without the path`,
|
|
227
|
+
`at most ${MAX_FILES_PER_DOCUMENT} files on one page`,
|
|
228
|
+
],
|
|
229
|
+
refusals: [
|
|
230
|
+
...DOCUMENT_REFUSALS,
|
|
231
|
+
"document.archived: the page is archived; restore it first",
|
|
232
|
+
"document.too_many_files: the page carries as many files as one may",
|
|
233
|
+
"request.too_large: the file is over the limit",
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
archive: {
|
|
237
|
+
usage: "archive <path>",
|
|
238
|
+
about:
|
|
239
|
+
"Puts a page away: out of the folders and the search, still readable at its path, not written to until restored. Nothing is deleted.",
|
|
240
|
+
refusals: DOCUMENT_REFUSALS,
|
|
241
|
+
},
|
|
242
|
+
restore: {
|
|
243
|
+
usage: "restore <path>",
|
|
244
|
+
about: "Brings an archived page back.",
|
|
245
|
+
refusals: DOCUMENT_REFUSALS,
|
|
246
|
+
},
|
|
247
|
+
serve: {
|
|
248
|
+
usage: "serve [<dir>] [--port <n>]",
|
|
249
|
+
about:
|
|
250
|
+
"For a person: serves a console of your own from this machine, for you. The build in <dir> (its index.html and files) is served at http://127.0.0.1:11197/, and everything under /api/ is carried to the console you are signed in to with your token, which the pages never see. Without a directory only the API is served there, for a development server to send its /api/ requests to. The loopback only; a request from another site's page is refused. Stop it with Ctrl+C.",
|
|
251
|
+
refusals: ["not a build to serve: the directory holds no index.html"],
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** The first lines of the usage, with the version of the command when it is known. */
|
|
256
|
+
const preamble = (version: string | undefined): string =>
|
|
257
|
+
`console${version === undefined ? "" : ` ${version}`}: the board of your organization's work with AI agents, from the command line.
|
|
258
|
+
You act as the person whose token you hold; everything you send is shown to people as text.
|
|
259
|
+
|
|
260
|
+
Usage: console <command> [arguments] [--json] [--url <origin>] [--project <key>]
|
|
261
|
+
`;
|
|
262
|
+
|
|
263
|
+
const CLOSING = `Options everywhere: --json answers with the console's own JSON; --url names another console;
|
|
264
|
+
--project names the project to work in, over CONSOLE_PROJECT and over what console use kept here.
|
|
265
|
+
Commands on a run (report, hand-in, ask, finish, fail) act on your one open run in the project;
|
|
266
|
+
with several open, say which with --run <id>. A Markdown body given as - is read from standard input.
|
|
267
|
+
console help <command> says what each command takes, the limits the console holds it to, and
|
|
268
|
+
what it may refuse. console --version says which version of the command this is.
|
|
269
|
+
|
|
270
|
+
Exit codes: 0 done; 1 the console refused, or could not be reached (the reason is on stderr);
|
|
271
|
+
2 the command was not understood; 3 the decision still waits for a person.
|
|
272
|
+
What the console refuses is printed as its code and its words. Everywhere:
|
|
273
|
+
${COMMON_REFUSALS.map((refusal) => ` ${refusal}`).join("\n")}
|
|
274
|
+
|
|
275
|
+
Credentials: CONSOLE_URL and CONSOLE_TOKEN in the environment, else what login kept in
|
|
276
|
+
$XDG_CONFIG_HOME/skillcdn-console/credentials.json (~/.config when XDG_CONFIG_HOME is unset).
|
|
277
|
+
`;
|
|
278
|
+
|
|
279
|
+
const SECTIONS: readonly { readonly title: string; readonly commands: readonly string[] }[] = [
|
|
280
|
+
{
|
|
281
|
+
title: "Signing in, once, as a person, and the project to work in",
|
|
282
|
+
commands: ["login", "logout", "whoami", "projects", "use"],
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
title: "The board",
|
|
286
|
+
commands: ["tasks", "task", "decisions", "decision", "runs", "run", "skills"],
|
|
287
|
+
},
|
|
288
|
+
{ title: "Working", commands: ["take", "report", "hand-in", "ask", "finish", "fail", "abandon"] },
|
|
289
|
+
{
|
|
290
|
+
title: "The documents: the project's pages of Markdown, in folders, by path",
|
|
291
|
+
commands: ["docs", "doc", "write", "attach", "archive", "restore"],
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
/** The whole usage, as `console help` prints it, naming the version when it is known. */
|
|
296
|
+
export function usage(version?: string): string {
|
|
297
|
+
const parts = [preamble(version)];
|
|
298
|
+
for (const section of SECTIONS) {
|
|
299
|
+
parts.push(`${section.title}`);
|
|
300
|
+
for (const name of section.commands) {
|
|
301
|
+
const help = COMMAND_HELP[name];
|
|
302
|
+
if (help !== undefined) {
|
|
303
|
+
parts.push(` console ${help.usage}\n ${help.about}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
parts.push("");
|
|
307
|
+
}
|
|
308
|
+
parts.push(CLOSING);
|
|
309
|
+
return parts.join("\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** What `console help <command>` prints, or nothing for a command that is not one. */
|
|
313
|
+
export function helpFor(name: string): string | undefined {
|
|
314
|
+
const help = COMMAND_HELP[name];
|
|
315
|
+
if (help === undefined) {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
const parts = [`console ${help.usage}`, "", help.about];
|
|
319
|
+
if (help.limits !== undefined) {
|
|
320
|
+
parts.push("", "Limits:", ...help.limits.map((limit) => ` ${limit}`));
|
|
321
|
+
}
|
|
322
|
+
if (help.refusals !== undefined) {
|
|
323
|
+
parts.push("", "Refusals:", ...help.refusals.map((refusal) => ` ${refusal}`));
|
|
324
|
+
}
|
|
325
|
+
return `${parts.join("\n")}\n`;
|
|
326
|
+
}
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import * as z from "zod/mini";
|
|
6
|
+
import { runCli } from "./cli.js";
|
|
7
|
+
import { createFileStore, defaultConfigDir } from "./credentials.js";
|
|
8
|
+
import { createDirectoryStore } from "./directory.js";
|
|
9
|
+
import { listenOn } from "./serve.js";
|
|
10
|
+
import { readBytesAt, readFileAt, readStdin } from "./terminal.js";
|
|
11
|
+
|
|
12
|
+
// The `console` command: the only place the command reads the process. Everything it does is in
|
|
13
|
+
// cli.ts, which takes what it needs and is what the tests run.
|
|
14
|
+
|
|
15
|
+
/** The version of the package this file runs from: its own manifest, two levels above dist/cli. */
|
|
16
|
+
const manifest = z
|
|
17
|
+
.object({ version: z.string() })
|
|
18
|
+
.safeParse(createRequire(import.meta.url)("../../package.json"));
|
|
19
|
+
|
|
20
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
21
|
+
env: process.env,
|
|
22
|
+
stdout: (text) => {
|
|
23
|
+
process.stdout.write(text);
|
|
24
|
+
},
|
|
25
|
+
stderr: (text) => {
|
|
26
|
+
process.stderr.write(text);
|
|
27
|
+
},
|
|
28
|
+
fetch: (input, init) => fetch(input, init),
|
|
29
|
+
readStdin,
|
|
30
|
+
hostname: hostname(),
|
|
31
|
+
readFile: readFileAt,
|
|
32
|
+
readBytes: readBytesAt,
|
|
33
|
+
store: createFileStore(defaultConfigDir(process.env)),
|
|
34
|
+
directory: createDirectoryStore(process.cwd()),
|
|
35
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
36
|
+
now: () => Date.now(),
|
|
37
|
+
version: manifest.success ? manifest.data.version : undefined,
|
|
38
|
+
listen: listenOn,
|
|
39
|
+
});
|
package/src/cli/serve.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
|
5
|
+
import type { FetchLike } from "../client.js";
|
|
6
|
+
import type { WebRoot } from "../web.js";
|
|
7
|
+
|
|
8
|
+
// A console of a person's own, served from their machine (ADR-0014): the files of a build, and
|
|
9
|
+
// everything under /api/ carried to the organization's console with the token the command
|
|
10
|
+
// keeps. The page and the API share one origin on the loopback, so the page needs no token and
|
|
11
|
+
// the organization's console no allowance: it sees a token's requests, as it sees the command's.
|
|
12
|
+
|
|
13
|
+
/** Where the command listens when nothing is said: next to the api role's 11199 and the dev UI's 11198. */
|
|
14
|
+
export const DEFAULT_SERVE_PORT = 11197;
|
|
15
|
+
|
|
16
|
+
/** The one address the command listens on. */
|
|
17
|
+
export const LOOPBACK = "127.0.0.1";
|
|
18
|
+
|
|
19
|
+
export interface ServeOptions {
|
|
20
|
+
/** The origin of the organization's console, where the API is. */
|
|
21
|
+
readonly upstream: string;
|
|
22
|
+
/** The token the requests are carried with: the person's standing, never given to the page. */
|
|
23
|
+
readonly token: string;
|
|
24
|
+
readonly fetch: FetchLike;
|
|
25
|
+
/** The port the command listens on: what a request's host must name. */
|
|
26
|
+
readonly port: number;
|
|
27
|
+
/** The build to serve, or none: then only the API is here, for a development server to send it to. */
|
|
28
|
+
readonly web?: WebRoot | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ServeHandler = (request: Request) => Promise<Response>;
|
|
32
|
+
|
|
33
|
+
/** Headers that belong to one connection and never travel over the next. */
|
|
34
|
+
const HOP_BY_HOP: ReadonlySet<string> = new Set([
|
|
35
|
+
"connection",
|
|
36
|
+
"keep-alive",
|
|
37
|
+
"proxy-authenticate",
|
|
38
|
+
"proxy-authorization",
|
|
39
|
+
"proxy-connection",
|
|
40
|
+
"te",
|
|
41
|
+
"trailer",
|
|
42
|
+
"transfer-encoding",
|
|
43
|
+
"upgrade",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What of the browser's request does not go to the console: the loopback's host, anything of a
|
|
48
|
+
* session (the token is the credential), what names the page's origin (a token's request needs
|
|
49
|
+
* none), and the encodings the browser takes, since what comes back is passed on decoded.
|
|
50
|
+
*/
|
|
51
|
+
const NOT_FORWARDED: ReadonlySet<string> = new Set([
|
|
52
|
+
"host",
|
|
53
|
+
"cookie",
|
|
54
|
+
"authorization",
|
|
55
|
+
"origin",
|
|
56
|
+
"referer",
|
|
57
|
+
"accept-encoding",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** What of the console's answer does not go to the browser: a cookie would be the loopback's, and the body arrives decoded. */
|
|
61
|
+
const NOT_ANSWERED: ReadonlySet<string> = new Set([
|
|
62
|
+
"set-cookie",
|
|
63
|
+
"content-encoding",
|
|
64
|
+
"content-length",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const json = (status: number, code: string, message: string): Response =>
|
|
68
|
+
new Response(JSON.stringify({ error: { code, message } }), {
|
|
69
|
+
status,
|
|
70
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const plain = (status: number, message: string): Response =>
|
|
74
|
+
new Response(`${message}\n`, {
|
|
75
|
+
status,
|
|
76
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a request to the API comes from the console's own page, or from no page at all.
|
|
81
|
+
* Browsers say which site a request comes from, and one from another site is that site's
|
|
82
|
+
* request made with the person's standing: refused. A browser too old to say names the page's
|
|
83
|
+
* origin on a request that changes something, which is compared instead. A request that says
|
|
84
|
+
* nothing of either is not a browser's.
|
|
85
|
+
*/
|
|
86
|
+
function fromOwnPage(request: Request, origin: string): boolean {
|
|
87
|
+
const site = request.headers.get("sec-fetch-site");
|
|
88
|
+
if (site !== null) {
|
|
89
|
+
return site === "same-origin" || site === "none";
|
|
90
|
+
}
|
|
91
|
+
const from = request.headers.get("origin");
|
|
92
|
+
return from === null || from === origin;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function forward(
|
|
96
|
+
request: Request,
|
|
97
|
+
url: URL,
|
|
98
|
+
upstream: string,
|
|
99
|
+
options: ServeOptions,
|
|
100
|
+
): Promise<Response> {
|
|
101
|
+
const headers = new Headers();
|
|
102
|
+
request.headers.forEach((value, name) => {
|
|
103
|
+
if (!HOP_BY_HOP.has(name) && !NOT_FORWARDED.has(name) && !name.startsWith("sec-")) {
|
|
104
|
+
headers.set(name, value);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
108
|
+
headers.set("accept-encoding", "identity");
|
|
109
|
+
const withBody = request.method !== "GET" && request.method !== "HEAD";
|
|
110
|
+
let answer: Response;
|
|
111
|
+
try {
|
|
112
|
+
answer = await options.fetch(`${upstream}${url.pathname}${url.search}`, {
|
|
113
|
+
method: request.method,
|
|
114
|
+
headers,
|
|
115
|
+
body: withBody ? request.body : undefined,
|
|
116
|
+
redirect: "manual",
|
|
117
|
+
signal: request.signal,
|
|
118
|
+
// A streamed body has to be declared as such.
|
|
119
|
+
...(withBody ? { duplex: "half" } : {}),
|
|
120
|
+
} as RequestInit);
|
|
121
|
+
} catch {
|
|
122
|
+
return json(502, "network", "The console could not be reached.");
|
|
123
|
+
}
|
|
124
|
+
const out = new Headers();
|
|
125
|
+
answer.headers.forEach((value, name) => {
|
|
126
|
+
if (!HOP_BY_HOP.has(name) && !NOT_ANSWERED.has(name)) {
|
|
127
|
+
out.set(name, value);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return new Response(answer.body, { status: answer.status, headers: out });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What the command answers: the API carried to the console with the token, for the page's own
|
|
135
|
+
* requests; the build's files and its page for everything else, when there is a build; and
|
|
136
|
+
* nothing to a request whose host is not this loopback, which is a page elsewhere whose name
|
|
137
|
+
* was made to resolve here.
|
|
138
|
+
*/
|
|
139
|
+
export function createServeHandler(options: ServeOptions): ServeHandler {
|
|
140
|
+
const hosts = new Set([
|
|
141
|
+
`${LOOPBACK}:${options.port}`,
|
|
142
|
+
`localhost:${options.port}`,
|
|
143
|
+
`[::1]:${options.port}`,
|
|
144
|
+
]);
|
|
145
|
+
const upstream = new URL(options.upstream).origin;
|
|
146
|
+
return async (request) => {
|
|
147
|
+
const url = new URL(request.url);
|
|
148
|
+
if (!hosts.has(url.host)) {
|
|
149
|
+
return plain(421, "This address is not the console's.");
|
|
150
|
+
}
|
|
151
|
+
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
|
|
152
|
+
if (!fromOwnPage(request, `http://${url.host}`)) {
|
|
153
|
+
return json(
|
|
154
|
+
403,
|
|
155
|
+
"auth.forbidden_origin",
|
|
156
|
+
"This request must come from the console's own pages.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return forward(request, url, upstream, options);
|
|
160
|
+
}
|
|
161
|
+
if (options.web !== undefined) {
|
|
162
|
+
const answer = await options.web.respond(request);
|
|
163
|
+
if (answer !== undefined) {
|
|
164
|
+
return answer;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return plain(
|
|
168
|
+
404,
|
|
169
|
+
options.web === undefined
|
|
170
|
+
? "Nothing is served here but the API, under /api/."
|
|
171
|
+
: "Nothing here.",
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface Listening {
|
|
177
|
+
/** Where the command listens, for a person to open. */
|
|
178
|
+
readonly url: string;
|
|
179
|
+
/** Settled once the listener has closed. */
|
|
180
|
+
readonly closed: Promise<void>;
|
|
181
|
+
close(): Promise<void>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** How the command listens: given the port it was bound, the handler to answer with. */
|
|
185
|
+
export type Listen = (
|
|
186
|
+
handlerFor: (port: number) => ServeHandler,
|
|
187
|
+
port: number,
|
|
188
|
+
) => Promise<Listening>;
|
|
189
|
+
|
|
190
|
+
async function answer(
|
|
191
|
+
handler: ServeHandler,
|
|
192
|
+
req: IncomingMessage,
|
|
193
|
+
res: ServerResponse,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
// A browser that goes away takes its request with it, a stream of events included.
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
res.on("close", () => {
|
|
198
|
+
if (!res.writableFinished) {
|
|
199
|
+
controller.abort();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
const method = req.method ?? "GET";
|
|
203
|
+
const withBody = method !== "GET" && method !== "HEAD";
|
|
204
|
+
let request: Request;
|
|
205
|
+
try {
|
|
206
|
+
const headers = new Headers();
|
|
207
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
208
|
+
if (Array.isArray(value)) {
|
|
209
|
+
for (const each of value) {
|
|
210
|
+
headers.append(name, each);
|
|
211
|
+
}
|
|
212
|
+
} else if (value !== undefined) {
|
|
213
|
+
headers.set(name, value);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
request = new Request(`http://${req.headers.host ?? LOOPBACK}${req.url ?? "/"}`, {
|
|
217
|
+
method,
|
|
218
|
+
headers,
|
|
219
|
+
body: withBody ? (Readable.toWeb(req) as unknown as ReadableStream) : undefined,
|
|
220
|
+
signal: controller.signal,
|
|
221
|
+
...(withBody ? { duplex: "half" } : {}),
|
|
222
|
+
} as RequestInit);
|
|
223
|
+
} catch {
|
|
224
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
225
|
+
res.end("The request was not understood.\n");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
let response: Response;
|
|
229
|
+
try {
|
|
230
|
+
response = await handler(request);
|
|
231
|
+
} catch {
|
|
232
|
+
response = plain(500, "Something went wrong.");
|
|
233
|
+
}
|
|
234
|
+
res.statusCode = response.status;
|
|
235
|
+
response.headers.forEach((value, name) => {
|
|
236
|
+
res.setHeader(name, value);
|
|
237
|
+
});
|
|
238
|
+
if (response.body === null || method === "HEAD") {
|
|
239
|
+
res.end();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await pipeline(Readable.fromWeb(response.body as unknown as NodeReadableStream), res);
|
|
244
|
+
} catch {
|
|
245
|
+
// The browser went away before the answer ended: nothing is owed to it.
|
|
246
|
+
res.destroy();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Listens on the loopback, and only there, and answers every request through the handler. */
|
|
251
|
+
export const listenOn: Listen = (handlerFor, port) =>
|
|
252
|
+
new Promise((resolve, reject) => {
|
|
253
|
+
let handler: ServeHandler | undefined;
|
|
254
|
+
const server = createServer((req, res) => {
|
|
255
|
+
void answer(handler ?? (() => Promise.resolve(plain(503, "Not ready."))), req, res);
|
|
256
|
+
});
|
|
257
|
+
server.once("error", reject);
|
|
258
|
+
server.listen(port, LOOPBACK, () => {
|
|
259
|
+
server.off("error", reject);
|
|
260
|
+
const address = server.address();
|
|
261
|
+
const bound = typeof address === "object" && address !== null ? address.port : port;
|
|
262
|
+
handler = handlerFor(bound);
|
|
263
|
+
const closed = new Promise<void>((done) => {
|
|
264
|
+
server.once("close", () => done());
|
|
265
|
+
});
|
|
266
|
+
resolve({
|
|
267
|
+
url: `http://${LOOPBACK}:${bound}/`,
|
|
268
|
+
closed,
|
|
269
|
+
close: () =>
|
|
270
|
+
new Promise<void>((done, fail) => {
|
|
271
|
+
server.closeAllConnections();
|
|
272
|
+
server.close((error) => (error === undefined ? done() : fail(error)));
|
|
273
|
+
}),
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
// The terminal's side of the command: what is read from it. Nothing here is reached by tests,
|
|
5
|
+
// which hand the command readers of their own.
|
|
6
|
+
|
|
7
|
+
/** All of standard input, once it ends. */
|
|
8
|
+
export function readStdin(): Promise<string> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let content = "";
|
|
11
|
+
process.stdin.setEncoding("utf8");
|
|
12
|
+
process.stdin.on("data", (chunk: string) => {
|
|
13
|
+
content += chunk;
|
|
14
|
+
});
|
|
15
|
+
process.stdin.on("end", () => resolve(content));
|
|
16
|
+
process.stdin.on("error", reject);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A file named on the command line, as text. */
|
|
21
|
+
export function readFileAt(path: string): Promise<string> {
|
|
22
|
+
return readFile(path, "utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A file named on the command line, as bytes: what is handed in. */
|
|
26
|
+
export function readBytesAt(path: string): Promise<Uint8Array> {
|
|
27
|
+
return readFile(path);
|
|
28
|
+
}
|