acrylblends 1.2.0 → 1.3.1

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 (52) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +44 -10
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/guides/create-acrylblends.md +4 -2
  5. package/docs/framework/workshop/README.md +4 -2
  6. package/docs/framework/workshop/facilitator.md +1 -1
  7. package/lib/cordis/door.d.ts +54 -2
  8. package/lib/cordis/door.js +131 -4
  9. package/package.json +1 -1
  10. package/plugins/acryl-door/plugin.json +3 -0
  11. package/plugins/acryl-door/src/cli/acryl.ts +63 -6
  12. package/plugins/acryl-door/src/host/door.ts +91 -5
  13. package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
  14. package/plugins/acryl-door/src/host/verbs.ts +11 -1
  15. package/plugins/acryl-door/src/index.ts +2 -1
  16. package/plugins/builder/index.ts +13 -35
  17. package/plugins/builder/plugin.json +4 -2
  18. package/plugins/extensions/index.ts +78 -19
  19. package/plugins/extensions/plugin-store.ts +27 -0
  20. package/plugins/extensions/plugin.json +1 -1
  21. package/plugins/extensions/routes.ts +92 -0
  22. package/plugins/extensions/stage.ts +15 -5
  23. package/plugins/home/index.ts +3 -1
  24. package/plugins/manifest/plugin.json +1 -1
  25. package/plugins/plugin-share/README.md +66 -0
  26. package/plugins/plugin-share/index.ts +581 -0
  27. package/plugins/plugin-share/package.ts +186 -0
  28. package/plugins/plugin-share/plugin.json +15 -0
  29. package/plugins/plugin-share/publish.ts +65 -0
  30. package/plugins/plugin-share/pull-check.ts +129 -0
  31. package/plugins/plugin-share/pulled-records.ts +70 -0
  32. package/templates/agent/.gitattributes +2 -0
  33. package/templates/agent/blend.yaml +2 -0
  34. package/templates/agent/src/app.ts +6 -0
  35. package/templates/agent/template.json +1 -0
  36. package/templates/blank/.gitattributes +2 -0
  37. package/templates/blank/blend.yaml +3 -0
  38. package/templates/blank/src/app.ts +8 -0
  39. package/templates/blank/template.json +1 -0
  40. package/templates/chat/.gitattributes +2 -0
  41. package/templates/chat/blend.yaml +3 -0
  42. package/templates/chat/src/app.ts +8 -0
  43. package/templates/chat/template.json +1 -0
  44. package/templates/nodejs/.gitattributes +2 -0
  45. package/templates/nodejs/blend.yaml +3 -0
  46. package/templates/nodejs/src/app.ts +8 -0
  47. package/templates/nodejs/template.json +1 -0
  48. package/templates/notes/.gitattributes +2 -0
  49. package/templates/notes/blend.yaml +3 -0
  50. package/templates/notes/src/app.ts +8 -0
  51. package/templates/notes/template.json +1 -0
  52. package/templates/todo/.gitattributes +2 -0
@@ -55,7 +55,7 @@ bun create acrylblends@latest my-app --template nodejs
55
55
  | `[source]` | A template from outside the package: `github:owner/repo[#ref]`, `@scope/name[@version]` or a packed `.tgz` file. See [Templates from a repository or a package](#templates-from-a-repository-or-a-package). | `github:acme/starter` |
56
56
  | `--template <template>` | One of `blank`, `nodejs`, `notes`, `chat`, `todo`, `agent` (the same list as `acrylblends init --template`; `acryl-app` is accepted as `agent`). Skips the template question; a bare name after the directory means the same. | `--template notes` |
57
57
  | `--from <id \| source>` | A starter from the registry by its id (`acrylblends.notes-desktop`), or any source above. Only starters that declare `runtime: acrylblends` are made; an ACRYL app starter is refused. See [Starters from the registry](#starters-from-the-registry). | `-- --from acrylblends.notes-desktop` |
58
- | `--registry <source>` | Read the ids from this registry instead of the public one: `github:owner/repo` or a folder, with an `index.json` (at its root or in `registry/`). | `-- --from login.thing --registry github:acryldev/workshop-2026-11-20` |
58
+ | `--registry <source>` | Read the ids from this registry instead of the public one: `github:owner/repo`, an `https://` address, or a folder, with an `index.json` (at its root or in `registry/`). | `-- --from login.thing --registry github:acryldev/workshop-2026-11-20` |
59
59
  | `--install` | Install the dependencies with the package manager after creating the project. For a template from a source, the install scripts it will run are listed first. | `--install` |
60
60
  | `--pm <packageManager>` | The package manager for `--install`: `npm`, `pnpm`, `yarn` or `bun`. Defaults to the one that started the initializer. | `--pm pnpm` |
61
61
  | `--yes`, `-y` | Ask nothing: use `nodejs` unless `--template` is given, and the directory `acryl-app`. | `-y` |
@@ -78,7 +78,9 @@ npm run dev
78
78
  - A starter with `runtime: acrylblends` is an `acrylblends` app: this command downloads its folder, gives the manifest to the new app (`metadata.id: acrylblends.my-notes`, `visibility: private`; the starter's id and licence stay the starter's), checks it for anything that looks like a secret, and writes it. Nothing in it is installed or run: `npm install` is yours to run, and the dependencies of the starter (a desktop starter brings `electron`) are in its `package.json`.
79
79
  - A starter for the ACRYL app (`runtime: acryl`, the older `acryl.*` entries) is refused with one line that says so, because its plugins run only inside the ACRYL app. Nothing is written.
80
80
  - An unknown id lists the starters this command can make.
81
- - The ids come from `https://acrylblends.github.io/registry/index.json`. `--registry github:owner/repo` reads another registry with the same layout (`index.json` and `blends/<id>/`), a team's own or a workshop's; `--registry ./folder` reads one on disk.
81
+ - The ids come from `https://acrylblends.github.io/registry/index.json`, and only the starter you asked for is downloaded, as one archive checked against the integrity in the index; an archive that does not match is refused. When the site cannot give the starter, the registry's repository is read instead. `--registry github:owner/repo` reads another registry with the same layout (`index.json` and `blends/<id>/`), a team's own or a workshop's; `--registry ./folder` reads one on disk.
82
+
83
+ A registry on any static host works with `--registry https://...`, if its `index.json` names an `archive` and an `integrity` for each entry: `workshop-registry/scripts/registry-archives.mjs` in this repository writes both when you deploy, on the copy you publish.
82
84
 
83
85
  To publish a starter of your own, see [the registry README](https://github.com/acrylblends/acrylblends.github.io/tree/main/registry): the folder of your app under `blends/<id>/` with an `acryl.template.json`, in a pull request.
84
86
 
@@ -8,7 +8,7 @@ app around it: a folder of Cordis plugins with a `blend.yaml`, running on your m
8
8
  The frame is the workshop's point. You do not edit a folder and alt-tab to a browser: your app is a page inside the workspace, the agents work in
9
9
  the same folder the frame shows, and every change you or an agent makes is one restart away from the page.
10
10
 
11
- This guide is for **acrylblends 1.2.0 or newer**; `npm create acrylblends@latest` gives you that. An app you made with an older release gets the
11
+ This guide is for **acrylblends 1.3.0 or newer**; `npm create acrylblends@latest` gives you that. An app you made with an older release gets the
12
12
  frame with the upgrade in block 6.
13
13
 
14
14
  ## 0. Before you start (10 minutes)
@@ -81,12 +81,14 @@ between watching and building.
81
81
  ## 1. Create the app; the frame opens on it (10 minutes)
82
82
 
83
83
  ```sh
84
- npm create acrylblends@latest my-app agent
84
+ npm create acrylblends@latest my-app agent -- --desktop
85
85
  cd my-app
86
86
  npm install
87
87
  npm run dev
88
88
  ```
89
89
 
90
+ npm may warn that 5 packages have install scripts not covered by allowScripts; that is a notice, not an error - nothing to do.
91
+
90
92
  `npm install` is bigger than a framework-only app: it brings the frame and the chat engine (about 500 MB together, much less on a warm package
91
93
  cache). `npm run dev` starts the app inside the frame and prints one line:
92
94
 
@@ -5,7 +5,7 @@ app is created, then **opened inside ACRYL's IDE** (projects on the left, the ap
5
5
  chat model works on the app from inside the frame, and coding agents run in terminal tabs in the same folder. The data layer, the desktop window and
6
6
  the panel-first flow of the previous handout are gone.
7
7
 
8
- The handout is written for **acrylblends 1.2.0 or newer** (the release that carries the frame). A room on an older release is on the previous handout;
8
+ The handout is written for **acrylblends 1.3.0 or newer** (the release whose `create --desktop` makes the framed app). A room on an older release is on the previous handout;
9
9
  check with `npx acrylblends@latest --version` before the day.
10
10
 
11
11
  ## Before the room: put this on every machine
@@ -84,7 +84,7 @@ declare class ShellChannel {
84
84
  }
85
85
  //#endregion
86
86
  //#region ../../plugins/acryl-door/src/host/door.d.ts
87
- type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | ShellErrorCode;
87
+ type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | 'refused' | ShellErrorCode;
88
88
  declare class DoorError extends Error {
89
89
  readonly code: DoorErrorCode;
90
90
  constructor(code: DoorErrorCode, message: string);
@@ -96,6 +96,45 @@ interface ExtensionsPort {
96
96
  reload(): Promise<unknown>;
97
97
  list(): readonly unknown[];
98
98
  }
99
+ /** The slice of acrylblends' `pluginShare` service the pull, list and remove verbs use. */
100
+ interface PluginSharePort {
101
+ /** The plugins installed while the app runs, a pulled one with its source and author. */
102
+ list(): readonly unknown[];
103
+ /** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
104
+ remove(name: string): Promise<unknown>;
105
+ pull(source: string): Promise<{
106
+ readonly ok: boolean;
107
+ readonly action?: string;
108
+ readonly reason?: string;
109
+ readonly next?: string;
110
+ }>;
111
+ planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | {
112
+ readonly ok: false;
113
+ readonly reason: string;
114
+ readonly next: string;
115
+ }>;
116
+ publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{
117
+ readonly ok: boolean;
118
+ readonly reason?: string;
119
+ }>;
120
+ }
121
+ /** Who a plan publishes as, and under which license; the service's defaults when absent. */
122
+ interface PublishOptionsView {
123
+ readonly owner?: string;
124
+ readonly license?: string;
125
+ }
126
+ /** A plan as the door shows it. The service owns it; the door passes it back unchanged. */
127
+ interface PublishPlanView {
128
+ readonly owner: string;
129
+ readonly repo: string;
130
+ readonly version: string;
131
+ readonly visibility: string;
132
+ readonly license: string;
133
+ readonly createsRepository: boolean;
134
+ readonly files: readonly string[];
135
+ readonly digest: string;
136
+ readonly pullLine: string;
137
+ }
99
138
  /** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
100
139
  interface ToolsPort {
101
140
  /** @returns the function that removes the tool. @throws when the name is taken. */
@@ -128,6 +167,8 @@ interface DoorDeps {
128
167
  readonly appBase?: string | undefined;
129
168
  /** Read at each call: the services may come and go while the door runs. */
130
169
  readonly extensions: () => ExtensionsPort | undefined;
170
+ /** Read at each call, as `extensions` is; absent where the app has no plugin-share row. */
171
+ readonly pluginShare?: () => PluginSharePort | undefined;
131
172
  readonly tools: () => ToolsPort | undefined;
132
173
  /** The app tools the door offers; anything else is refused before it reaches the registry. */
133
174
  readonly expose: readonly string[];
@@ -170,7 +211,18 @@ declare class Door {
170
211
  private parse;
171
212
  /** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
172
213
  private callPage;
214
+ /**
215
+ * Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
216
+ * the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
217
+ */
218
+ private installed;
173
219
  private plugin;
220
+ private share;
221
+ /** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
222
+ private planPublish;
223
+ /** The person's yes, from their own terminal. Not a verb: MCP and the chat do not have it. */
224
+ publishConfirmed(name: string, confirmed: PublishPlanView, options: PublishOptionsView): Promise<unknown>;
225
+ private pull;
174
226
  /** A folder named by an agent, resolved from the app folder; it must be inside the app. */
175
227
  private insideApp;
176
228
  private offeredTools;
@@ -185,7 +237,7 @@ declare class Door {
185
237
  /** Every verb, in the order `brief` lists them. */
186
238
  /** The verbs that only use the page: what a page-only door (a production assistant) offers. */
187
239
  declare const PAGE_VERBS: readonly ["look", "act"];
188
- declare const VERBS: readonly ["brief", "look", "act", "plugin-list", "plugin-add", "plugin-remove", "plugin-reload", "tool-list", "tool-call", "window-list", "window-open", "window-focus", "window-close", "window-resize", "menu-list", "menu-click"];
240
+ declare const VERBS: readonly ["brief", "look", "act", "plugin-list", "plugin-add", "plugin-pull", "plugin-publish", "plugin-remove", "plugin-reload", "tool-list", "tool-call", "window-list", "window-open", "window-focus", "window-close", "window-resize", "menu-list", "menu-click"];
189
241
  type Verb = (typeof VERBS)[number];
190
242
  /** The JSON Schema of a verb's arguments, as MCP lists it. */
191
243
  interface ArgumentsSchema {
@@ -306,6 +306,8 @@ const VERBS = [
306
306
  "act",
307
307
  "plugin-list",
308
308
  "plugin-add",
309
+ "plugin-pull",
310
+ "plugin-publish",
309
311
  "plugin-remove",
310
312
  "plugin-reload",
311
313
  "tool-list",
@@ -461,6 +463,42 @@ const VERB_SPECS = {
461
463
  additionalProperties: false
462
464
  }
463
465
  },
466
+ "plugin-pull": {
467
+ usage: "plugin pull <source>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), check it and start it, live",
468
+ description: "Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing.",
469
+ arguments: {
470
+ type: "object",
471
+ properties: { source: {
472
+ type: "string",
473
+ description: "github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address"
474
+ } },
475
+ required: ["source"],
476
+ additionalProperties: false
477
+ }
478
+ },
479
+ "plugin-publish": {
480
+ usage: "plugin publish <name> [--owner <account>] [--license <id>]: show what publishing this plugin would send; in a person's own terminal, ask them",
481
+ description: "Show what publishing a plugin would send: the account, repository, version, license, files and the line a friend pulls it with. Sends nothing. Only a person can say yes, in their own terminal or with Share in the frame; tell them the plan and how.",
482
+ arguments: {
483
+ type: "object",
484
+ properties: {
485
+ name: {
486
+ type: "string",
487
+ description: "a plugin of plugins/"
488
+ },
489
+ owner: {
490
+ type: "string",
491
+ description: "the GitHub account or organization; the signed-in one when absent"
492
+ },
493
+ license: {
494
+ type: "string",
495
+ description: "an SPDX license id; MIT when absent"
496
+ }
497
+ },
498
+ required: ["name"],
499
+ additionalProperties: false
500
+ }
501
+ },
464
502
  "plugin-remove": {
465
503
  usage: "plugin remove <name>: stop and remove an installed plugin",
466
504
  description: "Stop and remove an installed plugin.",
@@ -615,6 +653,14 @@ var DoorError = class extends Error {
615
653
  this.name = "DoorError";
616
654
  }
617
655
  };
656
+ /** What a plan says, for a person deciding. */
657
+ function renderPublishPlan(plan) {
658
+ return [
659
+ `Publish to ${plan.owner}/${plan.repo}${plan.createsRepository ? " (a new repository)" : ""}, ${plan.visibility}, version ${plan.version}, license ${plan.license}.`,
660
+ `Files: ${plan.files.join(", ")}.`,
661
+ `A friend gets exactly these bytes with: ${plan.pullLine}`
662
+ ].join("\n");
663
+ }
618
664
  /** A shell answer as text an agent reads: windows and menu items one to a line. */
619
665
  function renderShell(result) {
620
666
  if ("windows" in result) return result.windows.length === 0 ? "no windows are open" : result.windows.map((window) => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.x === void 0 || window.y === void 0 ? "" : `@${String(window.x)},${String(window.y)}`}${window.focused ? " focused" : ""}`).join("\n");
@@ -660,12 +706,14 @@ var Door = class {
660
706
  text: `done${result.target === void 0 ? "" : `: ${result.target.role} "${result.target.name}"`}${result.detail === void 0 ? "" : ` (${result.detail})`}; look again before the next act`
661
707
  };
662
708
  }
663
- case "plugin-list": return this.plugin((extensions) => extensions.list(), false);
709
+ case "plugin-list": return this.plugin((plugins) => plugins.list(), false, this.installed());
664
710
  case "plugin-add": return this.plugin((extensions) => extensions.install(this.insideApp(input.folder)));
711
+ case "plugin-pull": return this.pull(input.source);
712
+ case "plugin-publish": return this.planPublish(input);
665
713
  case "plugin-remove": {
666
714
  const name = input.name;
667
715
  if (typeof name !== "string" || name === "") throw new DoorError("invalid", "plugin remove needs a plugin name");
668
- return this.plugin((extensions) => extensions.remove(name));
716
+ return this.plugin((plugins) => plugins.remove(name), true, this.installed());
669
717
  }
670
718
  case "plugin-reload": return this.plugin((extensions) => extensions.reload());
671
719
  case "tool-list": {
@@ -894,8 +942,15 @@ var Door = class {
894
942
  throw cause;
895
943
  }
896
944
  }
897
- async plugin(run, mutates = true) {
898
- const extensions = this.deps.extensions();
945
+ /**
946
+ * Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
947
+ * the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
948
+ */
949
+ installed() {
950
+ return this.deps.pluginShare?.() ?? this.deps.extensions();
951
+ }
952
+ async plugin(run, mutates = true, port = this.deps.extensions()) {
953
+ const extensions = port;
899
954
  if (extensions === void 0) throw new DoorError("unavailable", "this door has no extensions plugin to drive. In ACRYL's frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs");
900
955
  const value = await run(extensions);
901
956
  const unchanged = typeof value === "object" && value !== null && value.action === "unchanged";
@@ -905,6 +960,50 @@ var Door = class {
905
960
  text: JSON.stringify(value, null, 2)
906
961
  };
907
962
  }
963
+ share() {
964
+ const share = this.deps.pluginShare?.();
965
+ if (share === void 0) throw new DoorError("unavailable", "plugin-share is not running in this app. If the app just changed it is restarting, so try again in a moment. An app made by an older version does not have it, and upgrade does not add it: run `npx acrylblends add plugin-share` and follow its README");
966
+ return share;
967
+ }
968
+ /** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
969
+ async planPublish(input) {
970
+ const { name, owner, license } = input;
971
+ if (typeof name !== "string" || name === "") throw new DoorError("invalid", "plugin publish needs a plugin name, e.g. plugin publish pomodoro");
972
+ const value = await this.share().planPublish(name, {
973
+ ...typeof owner === "string" && owner !== "" ? { owner } : {},
974
+ ...typeof license === "string" && license !== "" ? { license } : {}
975
+ });
976
+ if ("ok" in value) throw new DoorError("refused", `${value.reason}. ${value.next}.`);
977
+ return {
978
+ value,
979
+ text: `${renderPublishPlan(value)}\nNothing was sent. Only a person can say yes: they run \`npm run acryl -- plugin publish ${name}\` in their own terminal, or press Share in the frame. \`--license <id>\` changes the license before the yes.`
980
+ };
981
+ }
982
+ /** The person's yes, from their own terminal. Not a verb: MCP and the chat do not have it. */
983
+ async publishConfirmed(name, confirmed, options) {
984
+ const value = await this.share().publish(name, confirmed, options);
985
+ this.deps.audit.record({
986
+ at: (/* @__PURE__ */ new Date()).toISOString(),
987
+ tool: "plugin-publish",
988
+ target: {
989
+ role: "plugin",
990
+ name: `${confirmed.owner}/${confirmed.repo}@${confirmed.version}`
991
+ },
992
+ outcome: value.ok ? "ok" : "refused",
993
+ approval: "token"
994
+ });
995
+ return value;
996
+ }
997
+ async pull(source) {
998
+ if (typeof source !== "string" || source === "") throw new DoorError("invalid", "plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0");
999
+ const value = await this.share().pull(source);
1000
+ if (!value.ok) throw new DoorError("refused", `${value.reason ?? "the pull was refused"}. ${value.next ?? "nothing changed"}.`);
1001
+ if (value.action !== "unchanged" && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll();
1002
+ return {
1003
+ value,
1004
+ text: JSON.stringify(value, null, 2)
1005
+ };
1006
+ }
908
1007
  /** A folder named by an agent, resolved from the app folder; it must be inside the app. */
909
1008
  insideApp(folder) {
910
1009
  if (typeof folder !== "string" || folder === "") throw new DoorError("invalid", "plugin add needs a folder, e.g. plugins/pomodoro");
@@ -1196,6 +1295,33 @@ function operatorRoutes(prefix, secret, door, reportError) {
1196
1295
  reportError(`run the door verb ${verb}`, cause);
1197
1296
  return c.json(error("the verb could not be run"), 500);
1198
1297
  }
1298
+ }).post(`${prefix}/publish-confirmed`, operatorOnly(secret), jsonBody, async (c) => {
1299
+ const body = await readJson(c);
1300
+ const { name, confirm, owner, license } = body === INVALID_JSON || typeof body !== "object" || body === null ? {} : body;
1301
+ if (typeof name !== "string" || typeof confirm !== "object" || confirm === null) return c.json({
1302
+ ok: false,
1303
+ code: "invalid",
1304
+ message: "the body is { name, confirm }"
1305
+ }, 400);
1306
+ try {
1307
+ const value = await door.publishConfirmed(name, confirm, {
1308
+ ...typeof owner === "string" ? { owner } : {},
1309
+ ...typeof license === "string" ? { license } : {}
1310
+ });
1311
+ return c.json({
1312
+ ok: true,
1313
+ value,
1314
+ text: JSON.stringify(value, null, 2)
1315
+ });
1316
+ } catch (cause) {
1317
+ if (cause instanceof DoorError) return c.json({
1318
+ ok: false,
1319
+ code: cause.code,
1320
+ message: cause.message
1321
+ }, 409);
1322
+ reportError("publish a plugin", cause);
1323
+ return c.json(error("the plugin could not be published"), 500);
1324
+ }
1199
1325
  });
1200
1326
  }
1201
1327
  /** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
@@ -1473,6 +1599,7 @@ function apply(ctx, rawConfig = {}) {
1473
1599
  },
1474
1600
  ...appBase === "" ? {} : { appBase },
1475
1601
  extensions: () => ctx.get("extensions"),
1602
+ pluginShare: () => ctx.get("pluginShare"),
1476
1603
  tools: () => ctx.get("tools"),
1477
1604
  expose: config.tools?.expose ?? [],
1478
1605
  reloadOnChange: config.reloadOnChange !== false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,6 +11,9 @@
11
11
  "devDependencies": {
12
12
  "ws": "8.21.3"
13
13
  },
14
+ "scripts": {
15
+ "acryl": "node plugins/acryl-door/src/cli/acryl.ts"
16
+ },
14
17
  "mount": "mount it after acryl-node-server and extensions in development builds: root.plugin(door); add <script type=\"module\" src=\"/api/acryl-door/page.js\"></script> to the pages; the command is .acryl/bin/acryl",
15
18
  "gitignore": [".acryl/"],
16
19
  "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The door's operator client: what `<app>/.acryl/bin/acryl` runs. It finds the app from the folder it is started in (the nearest
3
+ * The door's operator client: what `npm run acryl -- <verb>` and `<app>/.acryl/bin/acryl` run. It finds the app from the folder it is started in (the nearest
4
4
  * `.acryl/door.json` upward, or `--dir`), never through PATH or a machine-wide registry, so it reaches only the app running from that
5
5
  * folder. The secret is read from that file and sent as a `Bearer` header; it is never printed.
6
6
  *
@@ -10,13 +10,14 @@
10
10
  * Exit codes: 0 done, 1 the verb failed (its code and message are printed), 2 a usage error, 3 no running app in this folder.
11
11
  */
12
12
 
13
+ import { createInterface } from 'node:readline/promises'
13
14
  import { existsSync, realpathSync } from 'node:fs'
14
15
  import { dirname, join, resolve } from 'node:path'
15
16
  import { fileURLToPath } from 'node:url'
16
17
  import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
17
18
 
18
19
  const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'])
19
- const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
20
+ const VALUED = new Set(['--dir', '--owner', '--license', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
20
21
  const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--force', '--help'])
21
22
 
22
23
  export class UsageError extends Error {}
@@ -42,7 +43,7 @@ const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
42
43
  act navigate --path <path of the app, e.g. /demo>
43
44
  act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
44
45
  (look and every act take --window <n>: the desktop window whose page it is for; window list prints the numbers)
45
- plugin list | plugin add <folder> | plugin remove <name> | plugin reload
46
+ plugin list | plugin add <folder> | plugin pull <source> | plugin publish <name> [--owner <account>] [--license <id>] | plugin remove <name> | plugin reload
46
47
  window list | window open [--path <p>] | window focus --id <n> | window close --id <n> [--force]
47
48
  window resize --id <n> --width <px> --height <px>
48
49
  menu list | menu click --path "File>New Note" [--force]
@@ -138,8 +139,15 @@ export function parseInvocation(argv: readonly string[], env: NodeJS.ProcessEnv
138
139
  case 'plugin': {
139
140
  if (second === 'list' || second === 'reload') return make(`plugin-${second}`)
140
141
  if (second === 'add') { if (third === undefined) throw new UsageError('plugin add needs a folder, e.g. plugin add plugins/pomodoro'); return make('plugin-add', { folder: third }) }
142
+ if (second === 'pull') { if (third === undefined) throw new UsageError('plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0'); return make('plugin-pull', { source: third }) }
143
+ if (second === 'publish') {
144
+ if (third === undefined) throw new UsageError('plugin publish needs a plugin name, e.g. plugin publish pomodoro')
145
+ const owner = values.get('--owner')
146
+ const license = values.get('--license')
147
+ return make('plugin-publish', { name: third, ...(owner === undefined ? {} : { owner }), ...(license === undefined ? {} : { license }) })
148
+ }
141
149
  if (second === 'remove') { if (third === undefined) throw new UsageError('plugin remove needs a plugin name'); return make('plugin-remove', { name: third }) }
142
- throw new UsageError(`plugin needs list, add, remove or reload\n${USAGE}`)
150
+ throw new UsageError(`plugin needs list, add, pull, publish, remove or reload\n${USAGE}`)
143
151
  }
144
152
  case 'window': {
145
153
  const id = integer(values, '--id')
@@ -199,6 +207,11 @@ export interface Io {
199
207
  readonly out: (text: string) => void
200
208
  readonly err: (text: string) => void
201
209
  readonly fetch?: typeof fetch
210
+ /**
211
+ * Ask the person at this terminal; absent when nobody is there (a pipe, an agent's shell), and then nothing that needs a yes can happen.
212
+ * @returns whether they said yes.
213
+ */
214
+ readonly ask?: (question: string) => Promise<boolean>
202
215
  }
203
216
 
204
217
  /** Run one invocation. @returns the exit code. */
@@ -214,7 +227,7 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
214
227
  const app = findApp(io.cwd, invocation.dir)
215
228
  const file = app === undefined ? undefined : readDoorFile(app)
216
229
  if (app === undefined || file === undefined) {
217
- io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: start it (npm run dev) in its folder, then try again\n`)
230
+ io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: these commands reach the running app, so start it (npm run dev) in its folder in another terminal, then run the same line again\n`)
218
231
  return 3
219
232
  }
220
233
  const request = io.fetch ?? fetch
@@ -225,7 +238,7 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
225
238
  ? await request(`${file.url}/api/acryl-door/verbs`, { headers })
226
239
  : await request(`${file.url}/api/acryl-door/call`, { method: 'POST', headers, body: JSON.stringify({ verb: invocation.verb, args: invocation.args }) })
227
240
  } catch {
228
- io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it again\n`)
241
+ io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it (npm run dev), then run the same line again\n`)
229
242
  return 3
230
243
  }
231
244
  if (response.status === 403) {
@@ -243,9 +256,52 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
243
256
  return 1
244
257
  }
245
258
  io.out(invocation.json ? `${JSON.stringify(body.value)}\n` : `${body.text ?? ''}\n`)
259
+ if (invocation.verb === 'plugin-publish' && io.ask !== undefined && isPlan(body.value)) return confirmPublish(invocation, body.value, io, request, `${file.url}/api/acryl-door/publish-confirmed`, headers)
246
260
  return 0
247
261
  }
248
262
 
263
+ const isPlan = (value: unknown): value is PlanAsked =>
264
+ typeof value === 'object' && value !== null && 'owner' in value
265
+
266
+ /** What the prompt names. */
267
+ interface PlanAsked {
268
+ readonly owner: string
269
+ readonly repo: string
270
+ readonly version: string
271
+ readonly license: string
272
+ }
273
+
274
+ /** The person's yes: only what they just read is published, and anything but a plain yes sends nothing. */
275
+ async function confirmPublish(
276
+ invocation: Invocation,
277
+ plan: PlanAsked,
278
+ io: Io,
279
+ request: typeof fetch,
280
+ url: string,
281
+ headers: Record<string, string>,
282
+ ): Promise<number> {
283
+ const yes = await io.ask?.(`Publish ${plan.owner}/${plan.repo} v${plan.version} under ${plan.license}? Type yes to send it: `)
284
+ if (yes !== true) { io.out('Nothing was sent.\n'); return 0 }
285
+ const response = await request(url, { method: 'POST', headers, body: JSON.stringify({ name: invocation.args.name, owner: invocation.args.owner, license: invocation.args.license, confirm: plan }) })
286
+ const body = await response.json().catch(() => undefined) as { ok?: boolean; message?: string; value?: { ok?: boolean; releaseUrl?: string; pullLine?: string; reason?: string; next?: string } } | undefined
287
+ const result = body?.value
288
+ if (body?.ok !== true || result?.ok !== true) {
289
+ io.err(`Not published: ${result?.reason ?? body?.message ?? 'the app did not answer'}${result?.next === undefined ? '' : `. ${result.next}`}\n`)
290
+ return 1
291
+ }
292
+ io.out(`Published: ${result.releaseUrl ?? ''}\nA friend gets exactly these bytes with:\n ${result.pullLine ?? ''}\n`)
293
+ return 0
294
+ }
295
+
296
+ async function askAtTerminal(question: string): Promise<boolean> {
297
+ const prompt = createInterface({ input: process.stdin, output: process.stdout })
298
+ try {
299
+ return (await prompt.question(question)).trim().toLowerCase() === 'yes'
300
+ } finally {
301
+ prompt.close()
302
+ }
303
+ }
304
+
249
305
  /** True when this file is the program Node was started with (not imported by a test). */
250
306
  function isProgram(): boolean {
251
307
  const entry = process.argv[1]
@@ -262,6 +318,7 @@ if (isProgram()) {
262
318
  cwd: process.cwd(),
263
319
  out: (text) => { process.stdout.write(text) },
264
320
  err: (text) => { process.stderr.write(text) },
321
+ ...(process.stdin.isTTY && process.stdout.isTTY ? { ask: askAtTerminal } : {}),
265
322
  }).then((code) => { process.exitCode = code }, (cause: unknown) => {
266
323
  process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
267
324
  process.exitCode = 1
@@ -15,7 +15,7 @@ import type { UiChannel } from './channel.ts'
15
15
  import { NO_DESKTOP, type ShellChannel } from './shell.ts'
16
16
  import { VERB_SPECS, VERBS } from './verbs.ts'
17
17
 
18
- export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | ShellErrorCode
18
+ export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | 'refused' | ShellErrorCode
19
19
 
20
20
  export class DoorError extends Error {
21
21
  readonly code: DoorErrorCode
@@ -35,6 +35,45 @@ export interface ExtensionsPort {
35
35
  list(): readonly unknown[]
36
36
  }
37
37
 
38
+ /** The slice of acrylblends' `pluginShare` service the pull, list and remove verbs use. */
39
+ export interface PluginSharePort {
40
+ /** The plugins installed while the app runs, a pulled one with its source and author. */
41
+ list(): readonly unknown[]
42
+ /** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
43
+ remove(name: string): Promise<unknown>
44
+ pull(source: string): Promise<{ readonly ok: boolean; readonly action?: string; readonly reason?: string; readonly next?: string }>
45
+ planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | { readonly ok: false; readonly reason: string; readonly next: string }>
46
+ publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{ readonly ok: boolean; readonly reason?: string }>
47
+ }
48
+
49
+ /** Who a plan publishes as, and under which license; the service's defaults when absent. */
50
+ export interface PublishOptionsView {
51
+ readonly owner?: string
52
+ readonly license?: string
53
+ }
54
+
55
+ /** A plan as the door shows it. The service owns it; the door passes it back unchanged. */
56
+ export interface PublishPlanView {
57
+ readonly owner: string
58
+ readonly repo: string
59
+ readonly version: string
60
+ readonly visibility: string
61
+ readonly license: string
62
+ readonly createsRepository: boolean
63
+ readonly files: readonly string[]
64
+ readonly digest: string
65
+ readonly pullLine: string
66
+ }
67
+
68
+ /** What a plan says, for a person deciding. */
69
+ export function renderPublishPlan(plan: PublishPlanView): string {
70
+ return [
71
+ `Publish to ${plan.owner}/${plan.repo}${plan.createsRepository ? ' (a new repository)' : ''}, ${plan.visibility}, version ${plan.version}, license ${plan.license}.`,
72
+ `Files: ${plan.files.join(', ')}.`,
73
+ `A friend gets exactly these bytes with: ${plan.pullLine}`,
74
+ ].join('\n')
75
+ }
76
+
38
77
  /** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
39
78
  export interface ToolsPort {
40
79
  /** @returns the function that removes the tool. @throws when the name is taken. */
@@ -54,6 +93,8 @@ export interface DoorDeps {
54
93
  readonly appBase?: string | undefined
55
94
  /** Read at each call: the services may come and go while the door runs. */
56
95
  readonly extensions: () => ExtensionsPort | undefined
96
+ /** Read at each call, as `extensions` is; absent where the app has no plugin-share row. */
97
+ readonly pluginShare?: () => PluginSharePort | undefined
57
98
  readonly tools: () => ToolsPort | undefined
58
99
  /** The app tools the door offers; anything else is refused before it reaches the registry. */
59
100
  readonly expose: readonly string[]
@@ -116,12 +157,14 @@ export class Door {
116
157
  const result = await this.act(input, signal, scope)
117
158
  return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
118
159
  }
119
- case 'plugin-list': return this.plugin(extensions => extensions.list(), false)
160
+ case 'plugin-list': return this.plugin(plugins => plugins.list(), false, this.installed())
120
161
  case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
162
+ case 'plugin-pull': return this.pull(input.source)
163
+ case 'plugin-publish': return this.planPublish(input)
121
164
  case 'plugin-remove': {
122
165
  const name = input.name
123
166
  if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin remove needs a plugin name')
124
- return this.plugin(extensions => extensions.remove(name))
167
+ return this.plugin(plugins => plugins.remove(name), true, this.installed())
125
168
  }
126
169
  case 'plugin-reload': return this.plugin(extensions => extensions.reload())
127
170
  case 'tool-list': {
@@ -298,8 +341,16 @@ export class Door {
298
341
  }
299
342
  }
300
343
 
301
- private async plugin(run: (extensions: ExtensionsPort) => unknown, mutates = true): Promise<VerbAnswer> {
302
- const extensions = this.deps.extensions()
344
+ /**
345
+ * Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
346
+ * the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
347
+ */
348
+ private installed(): Pick<ExtensionsPort, 'list' | 'remove'> | undefined {
349
+ return this.deps.pluginShare?.() ?? this.deps.extensions()
350
+ }
351
+
352
+ private async plugin<Port = ExtensionsPort>(run: (plugins: Port) => unknown, mutates = true, port: Port | undefined = this.deps.extensions() as Port | undefined): Promise<VerbAnswer> {
353
+ const extensions = port
303
354
  if (extensions === undefined) throw new DoorError('unavailable', 'this door has no extensions plugin to drive. In ACRYL\'s frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs')
304
355
  const value = await run(extensions)
305
356
  // The app changed under the open pages: reload them (not when the plugin was already as it should be), and answer once they are back.
@@ -308,6 +359,41 @@ export class Door {
308
359
  return { value, text: JSON.stringify(value, null, 2) }
309
360
  }
310
361
 
362
+ private share(): PluginSharePort {
363
+ const share = this.deps.pluginShare?.()
364
+ if (share === undefined) throw new DoorError('unavailable', 'plugin-share is not running in this app. If the app just changed it is restarting, so try again in a moment. An app made by an older version does not have it, and upgrade does not add it: run `npx acrylblends add plugin-share` and follow its README')
365
+ return share
366
+ }
367
+
368
+ /** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
369
+ private async planPublish(input: Record<string, unknown>): Promise<VerbAnswer> {
370
+ const { name, owner, license } = input
371
+ if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin publish needs a plugin name, e.g. plugin publish pomodoro')
372
+ const value = await this.share().planPublish(name, {
373
+ ...(typeof owner === 'string' && owner !== '' ? { owner } : {}),
374
+ ...(typeof license === 'string' && license !== '' ? { license } : {}),
375
+ })
376
+ if ('ok' in value) throw new DoorError('refused', `${value.reason}. ${value.next}.`)
377
+ return { value, text: `${renderPublishPlan(value)}\nNothing was sent. Only a person can say yes: they run \`npm run acryl -- plugin publish ${name}\` in their own terminal, or press Share in the frame. \`--license <id>\` changes the license before the yes.` }
378
+ }
379
+
380
+ /** The person's yes, from their own terminal. Not a verb: MCP and the chat do not have it. */
381
+ async publishConfirmed(name: string, confirmed: PublishPlanView, options: PublishOptionsView): Promise<unknown> {
382
+ const value = await this.share().publish(name, confirmed, options)
383
+ this.deps.audit.record({ at: new Date().toISOString(), tool: 'plugin-publish', target: { role: 'plugin', name: `${confirmed.owner}/${confirmed.repo}@${confirmed.version}` }, outcome: value.ok ? 'ok' : 'refused', approval: 'token' })
384
+ return value
385
+ }
386
+
387
+ private async pull(source: unknown): Promise<VerbAnswer> {
388
+ if (typeof source !== 'string' || source === '') throw new DoorError('invalid', 'plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0')
389
+ const value = await this.share().pull(source)
390
+ // A refusal is a failed command, so a script does not read it as done; it changed nothing, and the open pages stay.
391
+ if (!value.ok) throw new DoorError('refused', `${value.reason ?? 'the pull was refused'}. ${value.next ?? 'nothing changed'}.`)
392
+ // Neither did pulling what is already there change anything.
393
+ if (value.action !== 'unchanged' && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll()
394
+ return { value, text: JSON.stringify(value, null, 2) }
395
+ }
396
+
311
397
  /** A folder named by an agent, resolved from the app folder; it must be inside the app. */
312
398
  private insideApp(folder: unknown): string {
313
399
  if (typeof folder !== 'string' || folder === '') throw new DoorError('invalid', 'plugin add needs a folder, e.g. plugins/pomodoro')