@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.
Files changed (100) hide show
  1. package/dist/api.d.ts +2 -2
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +2 -2
  4. package/dist/api.js.map +1 -1
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +26 -14
  7. package/dist/cli/cli.js.map +1 -1
  8. package/dist/cli/format.d.ts.map +1 -1
  9. package/dist/cli/format.js +6 -1
  10. package/dist/cli/format.js.map +1 -1
  11. package/dist/cli/help.d.ts.map +1 -1
  12. package/dist/cli/help.js +9 -5
  13. package/dist/cli/help.js.map +1 -1
  14. package/dist/cli/plugin-check.d.ts +5 -0
  15. package/dist/cli/plugin-check.d.ts.map +1 -1
  16. package/dist/cli/plugin-check.js +22 -0
  17. package/dist/cli/plugin-check.js.map +1 -1
  18. package/dist/cli/plugin-cli.d.ts +1 -1
  19. package/dist/cli/plugin-cli.d.ts.map +1 -1
  20. package/dist/cli/plugin-cli.js +70 -4
  21. package/dist/cli/plugin-cli.js.map +1 -1
  22. package/dist/cli/plugin-init.d.ts +17 -0
  23. package/dist/cli/plugin-init.d.ts.map +1 -1
  24. package/dist/cli/plugin-init.js +135 -3
  25. package/dist/cli/plugin-init.js.map +1 -1
  26. package/dist/client.d.ts +3 -2
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +16 -5
  29. package/dist/client.js.map +1 -1
  30. package/dist/components/comments.d.ts.map +1 -1
  31. package/dist/components/comments.js +1 -1
  32. package/dist/components/comments.js.map +1 -1
  33. package/dist/components/decision-form.d.ts +2 -1
  34. package/dist/components/decision-form.d.ts.map +1 -1
  35. package/dist/components/decision-form.js +36 -4
  36. package/dist/components/decision-form.js.map +1 -1
  37. package/dist/components/decision-list.d.ts +4 -0
  38. package/dist/components/decision-list.d.ts.map +1 -1
  39. package/dist/components/decision-list.js +29 -3
  40. package/dist/components/decision-list.js.map +1 -1
  41. package/dist/components/my-decisions.d.ts +2 -0
  42. package/dist/components/my-decisions.d.ts.map +1 -1
  43. package/dist/components/my-decisions.js +2 -2
  44. package/dist/components/my-decisions.js.map +1 -1
  45. package/dist/components/task-view.d.ts +2 -1
  46. package/dist/components/task-view.d.ts.map +1 -1
  47. package/dist/components/task-view.js.map +1 -1
  48. package/dist/console.css +3 -1
  49. package/dist/console.d.ts.map +1 -1
  50. package/dist/console.js +16 -15
  51. package/dist/console.js.map +1 -1
  52. package/dist/data.d.ts +2 -1
  53. package/dist/data.d.ts.map +1 -1
  54. package/dist/data.js +26 -7
  55. package/dist/data.js.map +1 -1
  56. package/dist/i18n/en.d.ts +6 -0
  57. package/dist/i18n/en.d.ts.map +1 -1
  58. package/dist/i18n/en.js +6 -0
  59. package/dist/i18n/en.js.map +1 -1
  60. package/dist/i18n/ko.d.ts.map +1 -1
  61. package/dist/i18n/ko.js +6 -0
  62. package/dist/i18n/ko.js.map +1 -1
  63. package/dist/limits.d.ts +2 -0
  64. package/dist/limits.d.ts.map +1 -1
  65. package/dist/limits.js +2 -0
  66. package/dist/limits.js.map +1 -1
  67. package/dist/plugin/context.d.ts +6 -0
  68. package/dist/plugin/context.d.ts.map +1 -1
  69. package/dist/schemas.d.ts +77 -9
  70. package/dist/schemas.d.ts.map +1 -1
  71. package/dist/schemas.js +58 -16
  72. package/dist/schemas.js.map +1 -1
  73. package/docs/specs/cli.md +1 -1
  74. package/docs/specs/plugins.md +7 -6
  75. package/docs/specs/rest.md +8 -7
  76. package/package.json +7 -7
  77. package/skills/building-a-plugin/SKILL.md +4 -4
  78. package/skills/working-the-board/SKILL.md +1 -1
  79. package/src/api.ts +4 -0
  80. package/src/cli/cli.ts +40 -22
  81. package/src/cli/format.ts +12 -1
  82. package/src/cli/help.ts +9 -4
  83. package/src/cli/plugin-check.ts +27 -0
  84. package/src/cli/plugin-cli.ts +94 -5
  85. package/src/cli/plugin-init.ts +170 -3
  86. package/src/client.ts +28 -9
  87. package/src/components/comments.tsx +1 -0
  88. package/src/components/decision-form.tsx +92 -11
  89. package/src/components/decision-list.tsx +71 -2
  90. package/src/components/my-decisions.tsx +4 -1
  91. package/src/components/task-view.tsx +2 -1
  92. package/src/console.tsx +18 -12
  93. package/src/data.ts +32 -9
  94. package/src/i18n/en.ts +7 -0
  95. package/src/i18n/ko.ts +7 -0
  96. package/src/limits.ts +2 -0
  97. package/src/plugin/context.ts +6 -1
  98. package/src/schemas.ts +72 -18
  99. package/src/styles/console.css +3 -1
  100. 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 agent can look at
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
- question,
2546
- options: options.map((label) => ({ label })),
2547
- runId,
2548
- ...(body === undefined ? {} : { body }),
2549
- ...(typeof deciderId === "string" ? { deciderId } : {}),
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 ![words](its name) 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 ![words](its name)`,
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: {
@@ -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";
@@ -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: { key?: string; name?: string; console?: string };
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: { key: { type: "string" }, name: { type: "string" }, console: { type: "string" } },
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
- "usage: console-plugin init <dir> --key <key> [--name <name>] [--console <dependency>]\n",
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("-")) {
@@ -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 template's files and
3
- // their names replaced. `examples/plugin-hello` in the console's repository is what it writes for
4
- // the key `hello`, and a test holds the two equal.
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
- raiseDecision(input: RestDecisionInput): Promise<RestDecision>;
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
- /** A comment with files as the parts of a form: the part `body`, and a part `file` for each file. */
778
- const commentFormOf = (input: CommentInput): FormData => {
779
- const form = new FormData();
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("POST", `${base}${at("decisions")}`, input, restDecisionSchema),
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) =>
@@ -305,6 +305,7 @@ export function TaskThread(props: TaskThreadProps) {
305
305
  decision={item.decision}
306
306
  task={props.task}
307
307
  docHref={props.docHref}
308
+ fileHref={fileHref}
308
309
  onAnswer={props.onAnswer}
309
310
  onUpdate={props.onUpdateDecision}
310
311
  busy={props.busy}