@skillcdn/console 0.4.2 → 0.4.3
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/dist/api.d.ts +2 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2 -2
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +26 -14
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +6 -1
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +9 -5
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/plugin-check.d.ts +5 -0
- package/dist/cli/plugin-check.d.ts.map +1 -1
- package/dist/cli/plugin-check.js +22 -0
- package/dist/cli/plugin-check.js.map +1 -1
- package/dist/cli/plugin-cli.d.ts +1 -1
- package/dist/cli/plugin-cli.d.ts.map +1 -1
- package/dist/cli/plugin-cli.js +70 -4
- package/dist/cli/plugin-cli.js.map +1 -1
- package/dist/cli/plugin-init.d.ts +17 -0
- package/dist/cli/plugin-init.d.ts.map +1 -1
- package/dist/cli/plugin-init.js +135 -3
- package/dist/cli/plugin-init.js.map +1 -1
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +16 -5
- package/dist/client.js.map +1 -1
- package/dist/components/comments.d.ts.map +1 -1
- package/dist/components/comments.js +1 -1
- package/dist/components/comments.js.map +1 -1
- package/dist/components/decision-form.d.ts +2 -1
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +36 -4
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/decision-list.d.ts +4 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +29 -3
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/my-decisions.d.ts +2 -0
- package/dist/components/my-decisions.d.ts.map +1 -1
- package/dist/components/my-decisions.js +2 -2
- package/dist/components/my-decisions.js.map +1 -1
- package/dist/components/task-view.d.ts +2 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js.map +1 -1
- package/dist/console.css +3 -1
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +16 -15
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +2 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +26 -7
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +6 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +6 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +6 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/limits.d.ts +2 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +2 -0
- package/dist/limits.js.map +1 -1
- package/dist/plugin/context.d.ts +6 -0
- package/dist/plugin/context.d.ts.map +1 -1
- package/dist/schemas.d.ts +77 -9
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +58 -16
- package/dist/schemas.js.map +1 -1
- package/docs/specs/cli.md +1 -1
- package/docs/specs/plugins.md +7 -6
- package/docs/specs/rest.md +8 -7
- package/package.json +7 -7
- package/skills/building-a-plugin/SKILL.md +4 -4
- package/skills/working-the-board/SKILL.md +1 -1
- package/src/api.ts +4 -0
- package/src/cli/cli.ts +40 -22
- package/src/cli/format.ts +12 -1
- package/src/cli/help.ts +9 -4
- package/src/cli/plugin-check.ts +27 -0
- package/src/cli/plugin-cli.ts +94 -5
- package/src/cli/plugin-init.ts +170 -3
- package/src/client.ts +28 -9
- package/src/components/comments.tsx +1 -0
- package/src/components/decision-form.tsx +92 -11
- package/src/components/decision-list.tsx +71 -2
- package/src/components/my-decisions.tsx +4 -1
- package/src/components/task-view.tsx +2 -1
- package/src/console.tsx +18 -12
- package/src/data.ts +32 -9
- package/src/i18n/en.ts +7 -0
- package/src/i18n/ko.ts +7 -0
- package/src/limits.ts +2 -0
- package/src/plugin/context.ts +6 -1
- package/src/schemas.ts +72 -18
- package/src/styles/console.css +3 -1
- package/template/plugin/package.json +3 -3
package/src/cli/cli.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type ConsoleClient,
|
|
6
6
|
createClient,
|
|
7
7
|
type FetchLike,
|
|
8
|
+
type FileUpload,
|
|
8
9
|
type PluginClient,
|
|
9
10
|
type ProjectClient,
|
|
10
11
|
} from "../client.js";
|
|
@@ -2298,18 +2299,7 @@ const comment: Command = async (args, io) => {
|
|
|
2298
2299
|
if (body.trim().length === 0 && paths.length === 0) {
|
|
2299
2300
|
throw misuse(`Say something, or attach a file. ${ask}`);
|
|
2300
2301
|
}
|
|
2301
|
-
const files =
|
|
2302
|
-
for (const path of paths) {
|
|
2303
|
-
let bytes: Uint8Array;
|
|
2304
|
-
try {
|
|
2305
|
-
bytes = await io.readBytes(path);
|
|
2306
|
-
} catch {
|
|
2307
|
-
throw failed(`No file could be read at ${path}. Name the path of a file to attach.`);
|
|
2308
|
-
}
|
|
2309
|
-
const name = basename(path);
|
|
2310
|
-
const contentType = CONTENT_TYPES[extname(name).toLowerCase()];
|
|
2311
|
-
files.push({ name, bytes, ...(contentType === undefined ? {} : { contentType }) });
|
|
2312
|
-
}
|
|
2302
|
+
const files = await attachmentsOf(io, paths);
|
|
2313
2303
|
const session = await open(io, values);
|
|
2314
2304
|
const project = await session.project();
|
|
2315
2305
|
const task = await project.task(ref);
|
|
@@ -2323,10 +2313,30 @@ const comment: Command = async (args, io) => {
|
|
|
2323
2313
|
return EXIT.ok;
|
|
2324
2314
|
};
|
|
2325
2315
|
|
|
2316
|
+
/**
|
|
2317
|
+
* The files named by --attach, read from this machine: each called by its name, without the
|
|
2318
|
+
* path, and typed by its extension where the extension says.
|
|
2319
|
+
*/
|
|
2320
|
+
async function attachmentsOf(io: CliIo, paths: readonly string[]): Promise<FileUpload[]> {
|
|
2321
|
+
const files: FileUpload[] = [];
|
|
2322
|
+
for (const path of paths) {
|
|
2323
|
+
let bytes: Uint8Array;
|
|
2324
|
+
try {
|
|
2325
|
+
bytes = await io.readBytes(path);
|
|
2326
|
+
} catch {
|
|
2327
|
+
throw failed(`No file could be read at ${path}. Name the path of a file to attach.`);
|
|
2328
|
+
}
|
|
2329
|
+
const name = basename(path);
|
|
2330
|
+
const contentType = CONTENT_TYPES[extname(name).toLowerCase()];
|
|
2331
|
+
files.push({ name, bytes, ...(contentType === undefined ? {} : { contentType }) });
|
|
2332
|
+
}
|
|
2333
|
+
return files;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2326
2336
|
/**
|
|
2327
2337
|
* A file of the project, read from the console and written on this machine: one a run handed
|
|
2328
|
-
* in, one put in a comment, or one attached to a page, by its id, so that an
|
|
2329
|
-
* a picture a person put on the board.
|
|
2338
|
+
* in, one put in a comment or on a decision, or one attached to a page, by its id, so that an
|
|
2339
|
+
* agent can look at a picture a person put on the board.
|
|
2330
2340
|
*/
|
|
2331
2341
|
const file: Command = async (args, io) => {
|
|
2332
2342
|
const { values, positionals } = parse(args, { out: { type: "string" } });
|
|
@@ -2521,6 +2531,7 @@ const ask: Command = async (args, io) => {
|
|
|
2521
2531
|
run: { type: "string" },
|
|
2522
2532
|
wait: { type: "string" },
|
|
2523
2533
|
to: { type: "string" },
|
|
2534
|
+
attach: { type: "string", multiple: true },
|
|
2524
2535
|
});
|
|
2525
2536
|
const question = theOne(
|
|
2526
2537
|
positionals,
|
|
@@ -2536,23 +2547,30 @@ const ask: Command = async (args, io) => {
|
|
|
2536
2547
|
}
|
|
2537
2548
|
const seconds = secondsOf(text(values, "wait"), DEFAULT_ASK_WAIT_SECONDS);
|
|
2538
2549
|
const body = await bodyOf(io, values, text(values, "body"));
|
|
2550
|
+
// The files put on the decision (ADR-0055): a picture of what is to be chosen between, for one.
|
|
2551
|
+
const files = await attachmentsOf(io, texts(values, "attach"));
|
|
2539
2552
|
const session = await open(io, values);
|
|
2540
2553
|
const project = await session.project();
|
|
2541
2554
|
const runId = await currentRun(project, values);
|
|
2542
2555
|
// Whom the decision is for (ADR-0042): the person the agent works for, unless --to names another.
|
|
2543
2556
|
const deciderId = await personIdOf(session, to);
|
|
2544
|
-
const raised = await project.raiseDecision(
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2557
|
+
const raised = await project.raiseDecision(
|
|
2558
|
+
{
|
|
2559
|
+
question,
|
|
2560
|
+
options: options.map((label) => ({ label })),
|
|
2561
|
+
runId,
|
|
2562
|
+
...(body === undefined ? {} : { body }),
|
|
2563
|
+
...(typeof deciderId === "string" ? { deciderId } : {}),
|
|
2564
|
+
},
|
|
2565
|
+
files,
|
|
2566
|
+
);
|
|
2567
|
+
const withFiles =
|
|
2568
|
+
files.length === 0 ? "" : `, with ${files.length} file${files.length === 1 ? "" : "s"}`;
|
|
2551
2569
|
// The id is printed before any waiting, so that a wait cut short loses nothing.
|
|
2552
2570
|
io.stdout(
|
|
2553
2571
|
session.json
|
|
2554
2572
|
? `${JSON.stringify(raised, null, 2)}\n`
|
|
2555
|
-
: `Decision ${raised.id} raised on run ${runId}; the run waits for ${raised.decider.login}.\ndecision id: ${raised.id}\n${formatOptions(raised)}\n`,
|
|
2573
|
+
: `Decision ${raised.id} raised on run ${runId}${withFiles}; the run waits for ${raised.decider.login}.\ndecision id: ${raised.id}\n${formatOptions(raised)}\n`,
|
|
2556
2574
|
);
|
|
2557
2575
|
return settle(io, session, project, raised, seconds, true);
|
|
2558
2576
|
};
|
package/src/cli/format.ts
CHANGED
|
@@ -445,7 +445,7 @@ export function formatOptions(decision: RestDecision): string {
|
|
|
445
445
|
return decision.options
|
|
446
446
|
.map(
|
|
447
447
|
(option) =>
|
|
448
|
-
` ${option.id}) ${option.label}${option.own === true ? " (the person's own answer, not one given)" : ""}`,
|
|
448
|
+
` ${option.id}) ${option.label}${option.key === undefined ? "" : ` [${option.key}]`}${option.own === true ? " (the person's own answer, not one given)" : ""}`,
|
|
449
449
|
)
|
|
450
450
|
.join("\n");
|
|
451
451
|
}
|
|
@@ -474,6 +474,17 @@ export function formatDecision(decision: RestDecision): string {
|
|
|
474
474
|
if (decision.body.trim().length > 0) {
|
|
475
475
|
lines.push("", decision.body.trimEnd());
|
|
476
476
|
}
|
|
477
|
+
// The files put on it (ADR-0055), each with the command that reads it.
|
|
478
|
+
const files = decision.files ?? [];
|
|
479
|
+
if (files.length > 0) {
|
|
480
|
+
lines.push(
|
|
481
|
+
"",
|
|
482
|
+
...files.map(
|
|
483
|
+
(attached) =>
|
|
484
|
+
`file: ${attached.file.name} (${attached.file.size} bytes, ${attached.file.contentType}): console file ${attached.id}`,
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
477
488
|
lines.push("", "options:", formatOptions(decision), "", formatAnswer(decision));
|
|
478
489
|
if (decision.outcome !== null && decision.outcome.trim().length > 0) {
|
|
479
490
|
lines.push("", "what followed:", indent(decision.outcome));
|
package/src/cli/help.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
MAX_DOCUMENTS_PER_PROJECT,
|
|
14
14
|
MAX_FILE_BYTES,
|
|
15
15
|
MAX_FILES_PER_COMMENT,
|
|
16
|
+
MAX_FILES_PER_DECISION,
|
|
16
17
|
MAX_FILES_PER_DOCUMENT,
|
|
17
18
|
MAX_GOALS_PER_PROJECT,
|
|
18
19
|
MAX_LINK_LABEL_LENGTH,
|
|
@@ -450,16 +451,18 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
|
|
450
451
|
},
|
|
451
452
|
ask: {
|
|
452
453
|
usage:
|
|
453
|
-
'ask "<question>" --option "<label>" --option "<label>" [...] [--body <markdown>|--file <path>] [--to <login|me>] [--wait <seconds>] [--run <id>]',
|
|
454
|
+
'ask "<question>" --option "<label>" --option "<label>" [...] [--body <markdown>|--file <path>] [--attach <path>]... [--to <login|me>] [--wait <seconds>] [--run <id>]',
|
|
454
455
|
overview:
|
|
455
|
-
'ask "<question>" --option "<label>" --option "<label>" [...] [--body <markdown>|--file <path>] [--to <login|me>] [--wait <seconds>]',
|
|
456
|
+
'ask "<question>" --option "<label>" --option "<label>" [...] [--body <markdown>|--file <path>] [--attach <path>]... [--to <login|me>] [--wait <seconds>]',
|
|
456
457
|
synopsis:
|
|
457
458
|
"Raises a decision a person must make, with options, and waits a while for the answer.",
|
|
458
|
-
about: `Raises a decision a person must make: the question, the options to choose from, and what they need to know. It is for the person you work for unless --to names another of the project, who then finds it among their decisions (ADR-0042); anyone of the project may still answer. 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. The decision's id is printed as soon as it is raised. The person chooses one of the options, or answers in their own words: the answer then names one more option, their words, marked own; act on it as given. ${ON_THE_RUN}`,
|
|
459
|
+
about: `Raises a decision a person must make: the question, the options to choose from, and what they need to know. --attach puts a file on the decision, in the one request: a picture of what is to be chosen between, a screenshot of what was made; a picture the body names as  is shown there, the rest after the body, and the person reads each back with \`console file <id>\` (ADR-0055). It is for the person you work for unless --to names another of the project, who then finds it among their decisions (ADR-0042); anyone of the project may still answer. 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. The decision's id is printed as soon as it is raised. The person chooses one of the options, or answers in their own words: the answer then names one more option, their words, marked own; act on it as given. ${ON_THE_RUN}`,
|
|
459
460
|
limits: [
|
|
460
461
|
`question: one line, up to ${MAX_QUESTION_LENGTH} characters`,
|
|
461
462
|
`options: ${MIN_OPTIONS} to ${MAX_OPTIONS}, each one line up to ${MAX_OPTION_LABEL_LENGTH} characters`,
|
|
462
463
|
`body: ${MARKDOWN}`,
|
|
464
|
+
`attach: a file: up to ${MAX_FILE_BYTES} bytes, not empty; called by its name, without the path; a picture is placed in the body as `,
|
|
465
|
+
`at most ${MAX_FILES_PER_DECISION} files on one decision, together up to ${MAX_FILE_BYTES} bytes`,
|
|
463
466
|
"to: a login of the project, or me",
|
|
464
467
|
`at most ${MAX_DECISIONS_PER_PROJECT} decisions in one project`,
|
|
465
468
|
],
|
|
@@ -468,6 +471,8 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
|
|
468
471
|
"decision.invalid_run: the run is not yours, or is over",
|
|
469
472
|
"decision.invalid_decider: the person named may not work in the project",
|
|
470
473
|
"decision.too_many: the project holds as many decisions as one may",
|
|
474
|
+
"decision.too_many_files: more files than one decision carries",
|
|
475
|
+
"request.too_large: a file, or the files together, over the bound",
|
|
471
476
|
],
|
|
472
477
|
},
|
|
473
478
|
answer: {
|
|
@@ -558,7 +563,7 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
|
|
558
563
|
usage: "file <id> [--out <path>]",
|
|
559
564
|
synopsis: "Reads a file of the project to this machine, by its id.",
|
|
560
565
|
about:
|
|
561
|
-
"Reads a file of the project from the console and writes it on this machine: one a run handed in, one put in a comment, or one attached to a page, by the id the task, the run or the page shows. Written under its own name in the working directory, at --out <path>, or into --out <directory>. Then look at it with your own tools.",
|
|
566
|
+
"Reads a file of the project from the console and writes it on this machine: one a run handed in, one put in a comment or on a decision, or one attached to a page, by the id the task, the run, the decision or the page shows. Written under its own name in the working directory, at --out <path>, or into --out <directory>. Then look at it with your own tools.",
|
|
562
567
|
refusals: ["file.not_found: no file of this project has that id"],
|
|
563
568
|
},
|
|
564
569
|
docs: {
|
package/src/cli/plugin-check.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
parsePluginManifest,
|
|
14
14
|
} from "../plugin/manifest.js";
|
|
15
15
|
import { forbiddenImportsIn, removedGlobalsIn } from "../plugin/sandbox.js";
|
|
16
|
+
import { isLanguageMap } from "./plugin-init.js";
|
|
16
17
|
|
|
17
18
|
export interface CheckReport {
|
|
18
19
|
readonly ok: boolean;
|
|
@@ -308,6 +309,27 @@ function describeCounts(manifest: PluginManifest, description: PluginDescription
|
|
|
308
309
|
return counts.join(", ");
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The words of the manifest, as JSON, whose translation repeats the English words (`name.ko`,
|
|
314
|
+
* for one): what `init --languages` copies for the author to translate, and what was left.
|
|
315
|
+
*/
|
|
316
|
+
export function sameAsEnglish(value: unknown, at = ""): string[] {
|
|
317
|
+
if (Array.isArray(value)) {
|
|
318
|
+
return value.flatMap((item, index) => sameAsEnglish(item, `${at}[${index}]`));
|
|
319
|
+
}
|
|
320
|
+
if (typeof value !== "object" || value === null) {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
if (isLanguageMap(value)) {
|
|
324
|
+
return Object.entries(value)
|
|
325
|
+
.filter(([language, words]) => language !== "en" && words === value.en)
|
|
326
|
+
.map(([language]) => `${at}.${language}`.replace(/^\./, ""));
|
|
327
|
+
}
|
|
328
|
+
return Object.entries(value).flatMap(([key, inner]) =>
|
|
329
|
+
sameAsEnglish(inner, at.length === 0 ? key : `${at}.${key}`),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
311
333
|
/** Reads the plugin at `root` as a console would, and says what it would refuse. */
|
|
312
334
|
export async function checkPlugin(
|
|
313
335
|
root: string,
|
|
@@ -338,6 +360,11 @@ export async function checkPlugin(
|
|
|
338
360
|
`console-plugin.json: pluginApi ${manifest.pluginApi} is not one this command knows (${PLUGIN_API_MAJORS.join(", ")}); write against ${CURRENT_PLUGIN_API}`,
|
|
339
361
|
);
|
|
340
362
|
}
|
|
363
|
+
for (const path of sameAsEnglish(manifestRead.value)) {
|
|
364
|
+
warnings.push(
|
|
365
|
+
`console-plugin.json: ${path} is the same as en: translate it, or leave the language out`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
341
368
|
|
|
342
369
|
const packageRead = await readJson(join(root, "package.json"));
|
|
343
370
|
let version = "0.0.0";
|
package/src/cli/plugin-cli.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { parseArgs } from "node:util";
|
|
|
6
6
|
import type { ServerLoad } from "../plugin/load.js";
|
|
7
7
|
import { isPluginKey, RESERVED_PLUGIN_KEYS } from "../plugin/manifest.js";
|
|
8
8
|
import { checkPlugin } from "./plugin-check.js";
|
|
9
|
-
import { nameFromKey, writePluginFromTemplate } from "./plugin-init.js";
|
|
9
|
+
import { LANGUAGE_TAG, nameFromKey, PACKAGE_NAME, writePluginFromTemplate } from "./plugin-init.js";
|
|
10
10
|
|
|
11
11
|
export interface PluginCliIo {
|
|
12
12
|
stdout(text: string): void;
|
|
@@ -25,12 +25,20 @@ export const PLUGIN_EXIT = { ok: 0, failed: 1, usage: 2 } as const;
|
|
|
25
25
|
export const PLUGIN_USAGE = `console-plugin: writes and checks a plugin of the SkillCDN Console.
|
|
26
26
|
|
|
27
27
|
console-plugin init <dir> --key <key> [--name <name>] [--console <dependency>]
|
|
28
|
+
[--package <name>] [--repository <url> [--directory <path>]] [--languages <tags>]
|
|
28
29
|
Writes a plugin into <dir>, which must not exist or be empty, from the template this
|
|
29
30
|
package ships: the manifest, a server module with one collection, one command and one
|
|
30
31
|
query, the source of a web bundle with one page and one widget, one skill with its
|
|
31
32
|
manifest, tests, and the build. <key> is the plugin's key, lowercase letters, digits and
|
|
32
33
|
hyphens; --name its name for people, from the key unless said; --console what the
|
|
33
|
-
plugin's devDependency on @skillcdn/console says, ^<this version> unless said
|
|
34
|
+
plugin's devDependency on @skillcdn/console says, ^<this version> unless said;
|
|
35
|
+
--package the package's name, console-plugin-<key> unless said, a scoped one
|
|
36
|
+
(@scope/console-plugin-<key>) published as public; --repository the https address of
|
|
37
|
+
the repository it lives in, with --directory its path there in a repository of several,
|
|
38
|
+
which fill repository, homepage and bugs and the manifest's store; --languages the
|
|
39
|
+
languages of its words with commas between, en first (en,ko), each language map and
|
|
40
|
+
both manifests' translations given the others as English words to translate, of which
|
|
41
|
+
check warns while they stay English.
|
|
34
42
|
|
|
35
43
|
console-plugin check [<dir>]
|
|
36
44
|
Reads the plugin in <dir>, the working directory unless said, as a console would, built:
|
|
@@ -68,13 +76,32 @@ export async function runPluginCli(argv: readonly string[], io: PluginCliIo): Pr
|
|
|
68
76
|
return PLUGIN_EXIT.usage;
|
|
69
77
|
}
|
|
70
78
|
|
|
79
|
+
const INIT_USAGE =
|
|
80
|
+
"usage: console-plugin init <dir> --key <key> [--name <name>] [--console <dependency>] [--package <name>] [--repository <url> [--directory <path>]] [--languages <tags>]\n";
|
|
81
|
+
|
|
71
82
|
async function init(args: readonly string[], io: PluginCliIo): Promise<number> {
|
|
72
|
-
let values: {
|
|
83
|
+
let values: {
|
|
84
|
+
key?: string;
|
|
85
|
+
name?: string;
|
|
86
|
+
console?: string;
|
|
87
|
+
package?: string;
|
|
88
|
+
repository?: string;
|
|
89
|
+
directory?: string;
|
|
90
|
+
languages?: string;
|
|
91
|
+
};
|
|
73
92
|
let positionals: string[];
|
|
74
93
|
try {
|
|
75
94
|
({ values, positionals } = parseArgs({
|
|
76
95
|
args: [...args],
|
|
77
|
-
options: {
|
|
96
|
+
options: {
|
|
97
|
+
key: { type: "string" },
|
|
98
|
+
name: { type: "string" },
|
|
99
|
+
console: { type: "string" },
|
|
100
|
+
package: { type: "string" },
|
|
101
|
+
repository: { type: "string" },
|
|
102
|
+
directory: { type: "string" },
|
|
103
|
+
languages: { type: "string" },
|
|
104
|
+
},
|
|
78
105
|
allowPositionals: true,
|
|
79
106
|
strict: true,
|
|
80
107
|
}));
|
|
@@ -85,8 +112,51 @@ async function init(args: readonly string[], io: PluginCliIo): Promise<number> {
|
|
|
85
112
|
const [dir, ...extra] = positionals;
|
|
86
113
|
const key = values.key;
|
|
87
114
|
if (dir === undefined || extra.length > 0 || key === undefined) {
|
|
115
|
+
io.stderr(INIT_USAGE);
|
|
116
|
+
return PLUGIN_EXIT.usage;
|
|
117
|
+
}
|
|
118
|
+
const packageName = values.package;
|
|
119
|
+
if (packageName !== undefined && (packageName.length > 214 || !PACKAGE_NAME.test(packageName))) {
|
|
120
|
+
io.stderr(
|
|
121
|
+
`console-plugin init: ${packageName} is not a package name: lowercase letters, digits, hyphens, dots and underscores, with a scope as @scope/name\n`,
|
|
122
|
+
);
|
|
123
|
+
return PLUGIN_EXIT.usage;
|
|
124
|
+
}
|
|
125
|
+
if (values.directory !== undefined && values.repository === undefined) {
|
|
126
|
+
io.stderr("console-plugin init: --directory goes with --repository\n");
|
|
127
|
+
return PLUGIN_EXIT.usage;
|
|
128
|
+
}
|
|
129
|
+
if (values.repository !== undefined && !isRepositoryAddress(values.repository)) {
|
|
130
|
+
io.stderr(
|
|
131
|
+
`console-plugin init: ${values.repository} is not the https address of a repository\n`,
|
|
132
|
+
);
|
|
133
|
+
return PLUGIN_EXIT.usage;
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
values.directory !== undefined &&
|
|
137
|
+
!/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(values.directory)
|
|
138
|
+
) {
|
|
88
139
|
io.stderr(
|
|
89
|
-
"
|
|
140
|
+
"console-plugin init: --directory is a path within the repository, with forward slashes\n",
|
|
141
|
+
);
|
|
142
|
+
return PLUGIN_EXIT.usage;
|
|
143
|
+
}
|
|
144
|
+
const languages = values.languages
|
|
145
|
+
?.split(",")
|
|
146
|
+
.map((language) => language.trim())
|
|
147
|
+
.filter((language) => language.length > 0);
|
|
148
|
+
if (
|
|
149
|
+
languages !== undefined &&
|
|
150
|
+
(languages.length === 0 || languages.some((language) => !LANGUAGE_TAG.test(language)))
|
|
151
|
+
) {
|
|
152
|
+
io.stderr(
|
|
153
|
+
"console-plugin init: --languages is a list of language tags with commas between, en first: en,ko\n",
|
|
154
|
+
);
|
|
155
|
+
return PLUGIN_EXIT.usage;
|
|
156
|
+
}
|
|
157
|
+
if (languages !== undefined && languages[0] !== "en") {
|
|
158
|
+
io.stderr(
|
|
159
|
+
"console-plugin init: --languages begins with en, the language the template is written in\n",
|
|
90
160
|
);
|
|
91
161
|
return PLUGIN_EXIT.usage;
|
|
92
162
|
}
|
|
@@ -112,6 +182,12 @@ async function init(args: readonly string[], io: PluginCliIo): Promise<number> {
|
|
|
112
182
|
key,
|
|
113
183
|
name,
|
|
114
184
|
consoleDependency,
|
|
185
|
+
packageName,
|
|
186
|
+
repository:
|
|
187
|
+
values.repository === undefined
|
|
188
|
+
? undefined
|
|
189
|
+
: { url: values.repository, directory: values.directory },
|
|
190
|
+
languages,
|
|
115
191
|
});
|
|
116
192
|
io.stdout(`Wrote the plugin ${key} into ${target}: ${written.length} files.\n`);
|
|
117
193
|
io.stdout(
|
|
@@ -124,6 +200,19 @@ async function init(args: readonly string[], io: PluginCliIo): Promise<number> {
|
|
|
124
200
|
}
|
|
125
201
|
}
|
|
126
202
|
|
|
203
|
+
/** An https address of a repository, as git names it or as a browser opens it. */
|
|
204
|
+
function isRepositoryAddress(value: string): boolean {
|
|
205
|
+
const address = value.replace(/^git\+/, "");
|
|
206
|
+
if (!/^https:\/\/\S+$/.test(address)) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
return new URL(address).hostname.length > 0;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
127
216
|
async function check(args: readonly string[], io: PluginCliIo): Promise<number> {
|
|
128
217
|
const [dir, ...extra] = args;
|
|
129
218
|
if (extra.length > 0 || dir?.startsWith("-")) {
|
package/src/cli/plugin-init.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// `console-plugin init` (docs/specs/plugins.md, "Authoring"): writes a plugin from the template
|
|
2
|
-
// the package ships, the words `__key__`, `__name__` and `__console__` in the
|
|
3
|
-
// their names replaced
|
|
4
|
-
// the
|
|
2
|
+
// the package ships, the words `__package__`, `__key__`, `__name__` and `__console__` in the
|
|
3
|
+
// template's files and their names replaced; then what the template cannot say in a word, where
|
|
4
|
+
// it was asked for: the fields a published or a repository-bound package carries, and the
|
|
5
|
+
// languages beyond English. `examples/plugin-hello` in the console's repository is what it writes
|
|
6
|
+
// for the key `hello` with nothing else said, and a test holds the two equal.
|
|
5
7
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
8
|
import { join, relative, sep } from "node:path";
|
|
7
9
|
|
|
@@ -11,11 +13,31 @@ export interface InitOptions {
|
|
|
11
13
|
readonly name: string;
|
|
12
14
|
/** What the plugin's devDependency on `@skillcdn/console` says: `^<version>` of the command, unless said. */
|
|
13
15
|
readonly consoleDependency: string;
|
|
16
|
+
/** The package's name: `console-plugin-<key>` unless said. A scoped name is published with `publishConfig.access` `public`. */
|
|
17
|
+
readonly packageName?: string | undefined;
|
|
18
|
+
/** The repository the plugin lives in and, in a repository of several, its directory: `repository`, `homepage`, `bugs` and the manifest's `store`. */
|
|
19
|
+
readonly repository?:
|
|
20
|
+
| { readonly url: string; readonly directory?: string | undefined }
|
|
21
|
+
| undefined;
|
|
22
|
+
/** The languages the plugin's words are in, `en` first; `en` alone unless said. Every language map and both manifests' translations get the others, copied from English for the author to translate. */
|
|
23
|
+
readonly languages?: readonly string[] | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A language tag as the manifest's language maps are keyed: `en`, `ko`, `pt-BR`. */
|
|
27
|
+
export const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/;
|
|
28
|
+
|
|
29
|
+
/** A package's name as the registry takes one: lowercase, with a scope as `@scope/name`. */
|
|
30
|
+
export const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
31
|
+
|
|
32
|
+
/** The package's name from the options: what was said, else `console-plugin-<key>`. */
|
|
33
|
+
export function packageNameOf(options: InitOptions): string {
|
|
34
|
+
return options.packageName ?? `console-plugin-${options.key}`;
|
|
14
35
|
}
|
|
15
36
|
|
|
16
37
|
/** The words the template uses, and what each becomes. */
|
|
17
38
|
export function substitutions(options: InitOptions): readonly [string, string][] {
|
|
18
39
|
return [
|
|
40
|
+
["__package__", packageNameOf(options)],
|
|
19
41
|
["__key__", options.key],
|
|
20
42
|
["__name__", options.name],
|
|
21
43
|
["__console__", options.consoleDependency],
|
|
@@ -86,5 +108,150 @@ export async function writePluginFromTemplate(
|
|
|
86
108
|
await writeFile(path, substitute(text, options), "utf8");
|
|
87
109
|
written.push(target);
|
|
88
110
|
}
|
|
111
|
+
await adaptPlugin(targetDir, options);
|
|
89
112
|
return written;
|
|
90
113
|
}
|
|
114
|
+
|
|
115
|
+
/** Whether a value of the manifest is a language map: words per language, `en` among them. */
|
|
116
|
+
export function isLanguageMap(value: unknown): value is Record<string, string> {
|
|
117
|
+
return (
|
|
118
|
+
typeof value === "object" &&
|
|
119
|
+
value !== null &&
|
|
120
|
+
!Array.isArray(value) &&
|
|
121
|
+
"en" in value &&
|
|
122
|
+
Object.entries(value).every(
|
|
123
|
+
([language, words]) => LANGUAGE_TAG.test(language) && typeof words === "string",
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every language map of the manifest, wherever it is, gets `languages`, each copied from English where it is missing. */
|
|
129
|
+
function addLanguages(value: unknown, languages: readonly string[]): void {
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
for (const item of value) {
|
|
132
|
+
addLanguages(item, languages);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (typeof value !== "object" || value === null) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (isLanguageMap(value)) {
|
|
140
|
+
const english = value.en ?? "";
|
|
141
|
+
for (const language of languages) {
|
|
142
|
+
if (!(language in value)) {
|
|
143
|
+
value[language] = english;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const inner of Object.values(value)) {
|
|
149
|
+
addLanguages(inner, languages);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The page of a repository at its host, from the address git names it by. */
|
|
154
|
+
function pageOf(url: string): string {
|
|
155
|
+
return url
|
|
156
|
+
.replace(/^git\+/, "")
|
|
157
|
+
.replace(/\.git$/, "")
|
|
158
|
+
.replace(/\/$/, "");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The manifest's front-matter with `translations` for `languages`, each the English words
|
|
163
|
+
* again, inserted before the front-matter's end; under `parent` where the format nests them
|
|
164
|
+
* (`skillcdn.translations` in a skill). The description is the one the front-matter has, as
|
|
165
|
+
* written; the title is the plugin's name.
|
|
166
|
+
*/
|
|
167
|
+
function withTranslations(
|
|
168
|
+
text: string,
|
|
169
|
+
languages: readonly string[],
|
|
170
|
+
titleKey: "name" | "title",
|
|
171
|
+
title: string,
|
|
172
|
+
parent?: "skillcdn",
|
|
173
|
+
): string {
|
|
174
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
175
|
+
const lines = text.split(/\r?\n/);
|
|
176
|
+
const end = lines.indexOf("---", 1);
|
|
177
|
+
if (lines[0] !== "---" || end === -1) {
|
|
178
|
+
return text;
|
|
179
|
+
}
|
|
180
|
+
const described = lines
|
|
181
|
+
.slice(1, end)
|
|
182
|
+
.find((line) => line.startsWith("description:"))
|
|
183
|
+
?.slice("description:".length)
|
|
184
|
+
.trim();
|
|
185
|
+
const indent = parent === undefined ? "" : " ";
|
|
186
|
+
const block = [
|
|
187
|
+
...(parent === undefined ? [] : [`${parent}:`]),
|
|
188
|
+
`${indent}translations:`,
|
|
189
|
+
...languages.flatMap((language) => [
|
|
190
|
+
`${indent} ${language}:`,
|
|
191
|
+
`${indent} ${titleKey}: ${JSON.stringify(title)}`,
|
|
192
|
+
...(described === undefined ? [] : [`${indent} description: ${described}`]),
|
|
193
|
+
]),
|
|
194
|
+
];
|
|
195
|
+
return [...lines.slice(0, end), ...block, ...lines.slice(end)].join(newline);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* What the template cannot say in a word, where it was asked for: `publishConfig` for a scoped
|
|
200
|
+
* name, `repository`, `homepage` and `bugs` with the manifest's `store` for a repository, and
|
|
201
|
+
* the languages beyond English in every language map and both manifests' translations, each
|
|
202
|
+
* copied from the English words for the author to translate (`console-plugin check` warns of
|
|
203
|
+
* one left as it was). Nothing is rewritten when nothing was asked, so that the example stays
|
|
204
|
+
* what the template says.
|
|
205
|
+
*/
|
|
206
|
+
async function adaptPlugin(targetDir: string, options: InitOptions): Promise<void> {
|
|
207
|
+
const name = packageNameOf(options);
|
|
208
|
+
const repository = options.repository;
|
|
209
|
+
const languages = (options.languages ?? []).filter((language) => language !== "en");
|
|
210
|
+
if (!name.startsWith("@") && repository === undefined && languages.length === 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const packagePath = join(targetDir, "package.json");
|
|
214
|
+
const pkg = JSON.parse(await readFile(packagePath, "utf8")) as Record<string, unknown>;
|
|
215
|
+
const manifestPath = join(targetDir, "console-plugin.json");
|
|
216
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record<string, unknown>;
|
|
217
|
+
if (name.startsWith("@")) {
|
|
218
|
+
pkg.publishConfig = { access: "public" };
|
|
219
|
+
}
|
|
220
|
+
if (repository !== undefined) {
|
|
221
|
+
const page = pageOf(repository.url);
|
|
222
|
+
// The paths a git host has for a directory and for issues are the host's; only the one
|
|
223
|
+
// whose paths are known gets them.
|
|
224
|
+
const atGitHub = new URL(page).hostname === "github.com";
|
|
225
|
+
const homepage =
|
|
226
|
+
repository.directory !== undefined && atGitHub
|
|
227
|
+
? `${page}/tree/HEAD/${repository.directory}`
|
|
228
|
+
: page;
|
|
229
|
+
pkg.repository = {
|
|
230
|
+
type: "git",
|
|
231
|
+
url: repository.url,
|
|
232
|
+
...(repository.directory === undefined ? {} : { directory: repository.directory }),
|
|
233
|
+
};
|
|
234
|
+
pkg.homepage = homepage;
|
|
235
|
+
if (atGitHub) {
|
|
236
|
+
pkg.bugs = { url: `${page}/issues` };
|
|
237
|
+
}
|
|
238
|
+
manifest.store = { homepage, repository: page };
|
|
239
|
+
}
|
|
240
|
+
if (languages.length > 0) {
|
|
241
|
+
addLanguages(manifest, languages);
|
|
242
|
+
const skillcdn = join(targetDir, "SKILLCDN.md");
|
|
243
|
+
await writeFile(
|
|
244
|
+
skillcdn,
|
|
245
|
+
withTranslations(await readFile(skillcdn, "utf8"), languages, "name", options.name),
|
|
246
|
+
"utf8",
|
|
247
|
+
);
|
|
248
|
+
const skill = join(targetDir, "skills", options.key, "SKILL.md");
|
|
249
|
+
await writeFile(
|
|
250
|
+
skill,
|
|
251
|
+
withTranslations(await readFile(skill, "utf8"), languages, "title", options.name, "skillcdn"),
|
|
252
|
+
"utf8",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
await writeFile(packagePath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
|
256
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
257
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -325,7 +325,8 @@ export interface ProjectClient {
|
|
|
325
325
|
* while the decision waits, for as long as it allows, and answers the decision as it stands.
|
|
326
326
|
*/
|
|
327
327
|
awaitDecision(id: string, waitSeconds: number, signal?: AbortSignal): Promise<RestDecision>;
|
|
328
|
-
|
|
328
|
+
/** Raises a decision; with `files`, the files put on it (ADR-0055) go in the one request. */
|
|
329
|
+
raiseDecision(input: RestDecisionInput, files?: readonly FileUpload[]): Promise<RestDecision>;
|
|
329
330
|
answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
|
|
330
331
|
/** Grows a decision's record: its context, or what followed. */
|
|
331
332
|
updateDecision(id: string, patch: RestDecisionPatch): Promise<RestDecision>;
|
|
@@ -343,7 +344,7 @@ export interface ProjectClient {
|
|
|
343
344
|
handIn(runId: string, input: RestArtifactInput): Promise<RestRun>;
|
|
344
345
|
/** Hands in a file, on a run of the asker's: the console keeps the bytes. */
|
|
345
346
|
handInFile(runId: string, input: FileUpload): Promise<RestRun>;
|
|
346
|
-
/** 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. */
|
|
347
|
+
/** Where the bytes of a file of the project are read, by its id: handed in by a run, put in a comment or on a decision, or attached to a page. */
|
|
347
348
|
fileUrl(fileId: string): string;
|
|
348
349
|
/** The bytes of a file of the project, by its id, with the name and the type the console says. */
|
|
349
350
|
readFile(fileId: string, signal?: AbortSignal): Promise<FileRead>;
|
|
@@ -774,11 +775,9 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
774
775
|
return form;
|
|
775
776
|
};
|
|
776
777
|
|
|
777
|
-
/**
|
|
778
|
-
const
|
|
779
|
-
const
|
|
780
|
-
form.set("body", input.body);
|
|
781
|
-
for (const file of input.files ?? []) {
|
|
778
|
+
/** The files appended to a form, a part `file` each, with its name and its type. */
|
|
779
|
+
const appendFiles = (form: FormData, files: readonly FileUpload[]): void => {
|
|
780
|
+
for (const file of files) {
|
|
782
781
|
const bytes =
|
|
783
782
|
file.bytes instanceof Blob ? file.bytes : new Blob([Uint8Array.from(file.bytes)]);
|
|
784
783
|
form.append(
|
|
@@ -787,6 +786,21 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
787
786
|
file.name,
|
|
788
787
|
);
|
|
789
788
|
}
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
/** A comment with files as the parts of a form: the part `body`, and a part `file` for each file. */
|
|
792
|
+
const commentFormOf = (input: CommentInput): FormData => {
|
|
793
|
+
const form = new FormData();
|
|
794
|
+
form.set("body", input.body);
|
|
795
|
+
appendFiles(form, input.files ?? []);
|
|
796
|
+
return form;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
/** A decision with files as the parts of a form (ADR-0055): the part `decision`, what the JSON would say, and a part `file` for each file. */
|
|
800
|
+
const decisionFormOf = (input: RestDecisionInput, files: readonly FileUpload[]): FormData => {
|
|
801
|
+
const form = new FormData();
|
|
802
|
+
form.set("decision", JSON.stringify(input));
|
|
803
|
+
appendFiles(form, files);
|
|
790
804
|
return form;
|
|
791
805
|
};
|
|
792
806
|
|
|
@@ -860,8 +874,13 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
|
|
|
860
874
|
restDecisionSchema,
|
|
861
875
|
signal,
|
|
862
876
|
),
|
|
863
|
-
raiseDecision: (input) =>
|
|
864
|
-
request(
|
|
877
|
+
raiseDecision: (input, files = []) =>
|
|
878
|
+
request(
|
|
879
|
+
"POST",
|
|
880
|
+
`${base}${at("decisions")}`,
|
|
881
|
+
files.length > 0 ? decisionFormOf(input, files) : input,
|
|
882
|
+
restDecisionSchema,
|
|
883
|
+
),
|
|
865
884
|
answerDecision: (id, input) =>
|
|
866
885
|
request("POST", `${base}${at("decisions", id)}/answer`, input, restDecisionSchema),
|
|
867
886
|
updateDecision: (id, patch) =>
|