acrylblends 1.0.1 → 1.0.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 (38) hide show
  1. package/README.md +19 -15
  2. package/bin/acrylblends.js +9 -1
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +136 -5
  5. package/dist/index.js.map +1 -1
  6. package/docs/framework/README.md +1 -0
  7. package/docs/framework/agents/index.md +18 -0
  8. package/docs/framework/cordis/migration-to-1-0.md +4 -3
  9. package/docs/framework/getting-started/desktop.md +20 -0
  10. package/docs/framework/guides/create-acrylblends.md +21 -0
  11. package/docs/framework/guides/data.md +12 -0
  12. package/docs/framework/guides/production.md +11 -1
  13. package/docs/framework/workshop/README.md +381 -0
  14. package/docs/framework/workshop/facilitator.md +107 -0
  15. package/lib/cordis/db-sqlite.js +10 -2
  16. package/lib/cordis/kv-json.js +10 -2
  17. package/lib/cordis/node-server.js +1 -1
  18. package/lib/cordis/router-http.js +1 -1
  19. package/lib/node-server.js +1 -1
  20. package/lib/{test-websocket-ZXHNHQSU.js → test-websocket-D8WWqE5G.js} +1 -2
  21. package/package.json +1 -1
  22. package/plugins/acryl-db-sqlite/plugin.json +1 -1
  23. package/plugins/acryl-db-sqlite/src/index.ts +8 -1
  24. package/plugins/acryl-kv-json/plugin.json +1 -1
  25. package/plugins/acryl-kv-json/src/index.ts +8 -1
  26. package/plugins/acryl-node-server/src/adapter/request.ts +3 -2
  27. package/plugins/agent-panel/headless.ts +24 -2
  28. package/plugins/desktop/launch.ts +48 -0
  29. package/plugins/desktop/plugin.json +1 -1
  30. package/plugins/desktop-web/launch.ts +48 -0
  31. package/plugins/desktop-web/plugin.json +1 -1
  32. package/templates/chat/AGENTS.md +1 -1
  33. package/templates/chat/src/access.ts +110 -0
  34. package/templates/chat/src/chat-app.ts +7 -1
  35. package/templates/chat/src/routes.ts +5 -1
  36. package/templates/chat/test/chat.test.ts +65 -0
  37. package/templates/notes/test/notes.test.ts +1 -1
  38. package/templates/todo/src/main.ts +2 -0
package/README.md CHANGED
@@ -103,31 +103,33 @@ Every Electron call lives in `plugins/desktop`, so the app's own code never impo
103
103
  ## Start from the registry
104
104
 
105
105
  The registry is a set of starting points people published, browsable at [acrylblends.github.io/blends](https://acrylblends.github.io/blends)
106
- and readable by any tool as plain JSON at <https://acrylblends.github.io/registry/index.json>. ACRYL's CLI pulls one by id, or from any git
107
- repository:
106
+ and readable by any tool as plain JSON at <https://acrylblends.github.io/registry/index.json>. Each entry says which runtime it is for. An
107
+ `acrylblends` starter is a whole app, and one command makes it from npm alone:
108
108
 
109
109
  ```sh
110
- npm install -g acryl
111
- acryl new my-app --from acryl.gtd # a starter from the registry
112
- acryl new my-app --from github:you/starter # a repository with an acryl.template.json
110
+ npm create acrylblends@latest my-notes -- --from acrylblends.notes-desktop # npm needs the -- before the options
111
+ cd my-notes
112
+ npm install
113
+ npm run dev
113
114
  ```
114
115
 
115
- Without `--from`, `acryl new` writes the blank app; `--blueprint` picks another built-in Blueprint. A team's own registry is the same layout in
116
- its own repository: `--registry <git URL or folder>` reads starters from there instead of the public one.
116
+ The other starters in the registry (`acryl.gtd`, `acryl.blank`, ...) are for the ACRYL app: `create-acrylblends` refuses them with a line that
117
+ says so. A repository with an `acryl.template.json` works the same way (`-- --from github:you/starter`), and a team's own registry is the same
118
+ layout in its own repository (`-- --from you.thing --registry github:you/registry`).
117
119
 
118
120
  ## Publish your app, or your own starter
119
121
 
120
122
  **Your app** belongs to you and lives in your repository: `npm run save` commits it and pushes to its remote. It refuses a file that looks like
121
123
  a secret, and refuses to push a private app to a public remote.
122
124
 
123
- Before an app goes on a server: with `NODE_ENV=production` the door, the agent panel and the PTY driver start nothing, but the `agent` template
124
- still mounts its own agent, builder and extensions rows. Start from one of the other five templates, or remove those rows from its `blend.yaml`
125
+ Before an app goes on a server: with `NODE_ENV=production` the door, the agent panel and the PTY driver start nothing, the `agent` template leaves its agent, builder and extensions rows out
126
+ (`ACRYL_PRODUCTION_KEEP` names the ones to keep, with a warning), and the `chat` template is closed until it has a `CHAT_ACCESS_TOKEN`
125
127
  - see [What runs in production](https://github.com/acrylblends/acrylblends/blob/main/docs/framework/guides/production.md).
126
128
 
127
- **A starter** other people can pull is a pull request to the registry repository: add `registry/blends/<id>/` with a `blend.yaml` (`kind: Blueprint`,
128
- an id you own, the license, `visibility`, a category and a one-line description), the plugins it adds under `plugins/`, and a `README.md`. CI
129
- validates every entry and fails when `index.json` is stale, so what the site lists is what passed. The layout, and how to keep a starter private
130
- without a registry at all, is in
129
+ **A starter** other people can pull is a pull request to the registry repository: add `registry/blends/<id>/` with your app as it stands (without `node_modules/`
130
+ and `dist/`), its `blend.yaml` made public (`id`, a licence, a category and a one-line description) and an `acryl.template.json`, which is what marks it as an
131
+ app for acrylblends. CI validates every entry, makes the app with `create-acrylblends`, and fails when `index.json` is stale, so what the site lists is what
132
+ passed. The layout, and how to keep a starter private without a registry at all, is in
131
133
  [the registry README](https://github.com/acrylblends/acrylblends.github.io/tree/main/registry).
132
134
 
133
135
  ## Keep the framework current
@@ -135,10 +137,12 @@ without a registry at all, is in
135
137
  An app carries its framework plugins as source, so a new release moves them in place and leaves your own code alone:
136
138
 
137
139
  ```sh
138
- npx acrylblends upgrade --dry-run # the plan: what would be replaced
139
- npx acrylblends upgrade # an edited file stops it with the diff, unless --force
140
+ npx acrylblends@latest upgrade --dry-run # the plan: what would be replaced
141
+ npx acrylblends@latest upgrade # an edited file stops it with the diff, unless --force
140
142
  ```
141
143
 
144
+ Write `@latest`: a bare `npx acrylblends upgrade` runs the copy the app has installed, and an app made before 1.0 has an old one with no `upgrade` verb (it prints its help and exits 0, so it looks as if it worked, or answers "unknown command" and exits 2).
145
+
142
146
  ## Use it as a library
143
147
 
144
148
  An Acryl app is also a plain function from `Request` to `Response`, which needs no scaffold. Create a folder and install the package:
@@ -1,4 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from '../dist/index.js'
2
+ // A Node too old to run the package is told so in plain words, before anything is loaded that would fail on it.
3
+ const [major, minor] = process.versions.node.split('.').map(Number)
4
+ if (major < 22 || (major === 22 && minor < 19)) {
5
+ console.error(
6
+ `Node 22.19+ needed, you have ${process.versions.node}: install it from https://nodejs.org or with nvm (nvm install 22).`,
7
+ )
8
+ process.exit(1)
9
+ }
10
+ const { runCli } = await import('../dist/index.js')
3
11
 
4
12
  process.exitCode = await runCli(process.argv.slice(2))
package/dist/index.d.ts CHANGED
@@ -74,6 +74,8 @@ interface InitOptions {
74
74
  install: boolean | undefined;
75
75
  /** Also add the desktop shell (`add desktop`): an Electron window on the app's own server. */
76
76
  desktop: boolean;
77
+ /** Print no "Next:" block: `create-acrylblends` runs `init` and prints its own, for the package manager it knows. */
78
+ quietNext: boolean;
77
79
  yes: boolean;
78
80
  }
79
81
  /** Parse `init [dir] [--template t] [--agent|--no-agent] [--name n] [--install] [-y]`. `--agent` means `--template agent`, `--no-agent` means `--template blank`. */
package/dist/index.js CHANGED
@@ -472,7 +472,33 @@ function mergeTooling(appDir, tooling) {
472
472
  scripts: addedScripts
473
473
  };
474
474
  }
475
- /** Add the lines a plugin needs to the app's `.gitignore` when it has one and they are missing. Returns the lines added. */
475
+ /** What an app runs before `dev`, `start`, `test` and `check`: refuses a Node too old to run it, in plain words, before anything that would fail confusingly (a plain CommonJS file, so any Node can read it). */
476
+ const CHECK_NODE = `// Refuses a Node that is too old to run this app, before the command that would fail on it. Plain CommonJS so that any Node can read it.
477
+ const [major, minor] = process.versions.node.split('.').map(Number)
478
+ if (major < 22 || (major === 22 && minor < 19)) {
479
+ console.error(
480
+ \`Node 22.19+ needed, you have \${process.versions.node}: install it from https://nodejs.org or with nvm (nvm install 22), then run this again.\`,
481
+ )
482
+ process.exit(1)
483
+ }
484
+ `;
485
+ /** Write \`scripts/check-node.cjs\` and run it before the app's \`dev\`, \`start\`, \`test\` and \`check\` scripts (npm and pnpm run \`pre<script>\` first). Existing \`pre*\` scripts are kept. */
486
+ function addNodePreflight(appDir) {
487
+ mkdirSync(join(appDir, "scripts"), { recursive: true });
488
+ writeFileSync(join(appDir, "scripts", "check-node.cjs"), CHECK_NODE);
489
+ const file = join(appDir, "package.json");
490
+ const manifest = JSON.parse(readFileSync(file, "utf8"));
491
+ const scripts = manifest.scripts ?? {};
492
+ for (const name of [
493
+ "dev",
494
+ "start",
495
+ "test",
496
+ "check"
497
+ ]) if (name in scripts && !(`pre${name}` in scripts)) scripts[`pre${name}`] = "node scripts/check-node.cjs";
498
+ manifest.scripts = scripts;
499
+ writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
500
+ }
501
+ /** Add the lines a plugin needs to the app's \`.gitignore\` when it has one and they are missing. Returns the lines added. */
476
502
  function appendGitignore(appDir, lines) {
477
503
  const file = join(appDir, ".gitignore");
478
504
  if (!lines || lines.length === 0 || !existsSync(file)) return [];
@@ -551,6 +577,7 @@ function parseInitOptions(argv) {
551
577
  template: named ?? alias,
552
578
  install: values.install,
553
579
  desktop: values.desktop === true,
580
+ quietNext: values["no-next"] === true,
554
581
  yes: values.yes === true
555
582
  };
556
583
  }
@@ -565,6 +592,7 @@ const parse$3 = (argv) => parseArgs({
565
592
  "no-agent": { type: "boolean" },
566
593
  install: { type: "boolean" },
567
594
  desktop: { type: "boolean" },
595
+ "no-next": { type: "boolean" },
568
596
  yes: {
569
597
  type: "boolean",
570
598
  short: "y"
@@ -599,16 +627,19 @@ async function init(options, io) {
599
627
  io.stderr.write(`error: ${error.message}\n`);
600
628
  return 1;
601
629
  }
602
- const plugins = closure([...readTemplatePlugins(templateDir(template)), ...options.desktop ? ["desktop"] : []], readLibrary());
630
+ const own = closure(readTemplatePlugins(templateDir(template)), readLibrary());
631
+ const plugins = options.desktop ? closure([...own.map((plugin) => plugin.name), "desktop"], readLibrary()) : own;
632
+ const forDesktop = plugins.filter((plugin) => !own.some((owned) => owned.name === plugin.name));
603
633
  for (const plugin of plugins) {
604
634
  copyPlugin(plugin, directory);
605
635
  if (plugin.imports) mergeImports(directory, plugin.imports, "plugins");
606
636
  mergeDependencies(directory, plugin.dependencies);
607
637
  mergeTooling(directory, plugin);
608
638
  }
639
+ addNodePreflight(directory);
609
640
  const desktopPath = options.desktop ? readTemplateDesktopPath(templateDir(template)) : void 0;
610
641
  if (desktopPath !== void 0) writeDesktopPath(directory, desktopPath);
611
- io.stdout.write(`created ${name} (${template}) in ${directory}: ${files.length} files, plugins ${plugins.map((plugin) => plugin.name).join(", ")}\n`);
642
+ io.stdout.write(`created ${name} (${template}) in ${directory}: ${files.length} files, plugins ${own.map((plugin) => plugin.name).join(", ")}${forDesktop.length > 0 ? `; the desktop shell adds ${forDesktop.map((plugin) => plugin.name).join(", ")}, which it needs` : ""}\n`);
612
643
  if (install && await io.install(directory) !== 0) {
613
644
  io.stderr.write("error: npm install failed; run it yourself in the new directory\n");
614
645
  return 1;
@@ -618,10 +649,89 @@ async function init(options, io) {
618
649
  install ? "" : " npm install\n",
619
650
  " npm run dev\n npm test\n"
620
651
  ];
621
- io.stdout.write(`\nNext:\n${steps.join("")}`);
652
+ if (!options.quietNext) io.stdout.write(`\nNext:\n${steps.join("")}`);
622
653
  return 0;
623
654
  }
624
655
  //#endregion
656
+ //#region src/mount.ts
657
+ const MOUNTED = [
658
+ {
659
+ name: "agent-panel",
660
+ local: "panel",
661
+ needs: ["agent", "extensions"]
662
+ },
663
+ {
664
+ name: "driver-pty",
665
+ local: "driverPty",
666
+ needs: ["agent-panel"],
667
+ after: "agent-panel"
668
+ },
669
+ {
670
+ name: "driver-acp",
671
+ local: "driverAcp",
672
+ needs: ["agent-panel"],
673
+ after: "agent-panel"
674
+ }
675
+ ];
676
+ const ROW = /^( *)- id: ?(\S+)\n\1 {2}name: ?(\S+)\n/gmu;
677
+ /** The `name:` of every row of the app's `blend.yaml`, or `undefined` when it has none. */
678
+ function rowNames(appDir) {
679
+ const file = join(appDir, "blend.yaml");
680
+ if (!existsSync(file)) return void 0;
681
+ return [...readFileSync(file, "utf8").matchAll(ROW)].map((match) => match[3]);
682
+ }
683
+ /** What a plugin to be mounted is missing: the rows it needs that neither the app nor this same command brings. */
684
+ function missingRows(appDir, plugin, alsoAdded) {
685
+ const have = rowNames(appDir) ?? [];
686
+ return plugin.needs.filter((need) => !have.includes(need) && !alsoAdded.includes(need));
687
+ }
688
+ /** Add the row to `blend.yaml` and the entry to `src/app.ts`; each only when it is not there yet and its place is where the template puts it. */
689
+ function mount(appDir, plugin) {
690
+ const done = [];
691
+ const failed = [];
692
+ const manifestFile = join(appDir, "blend.yaml");
693
+ if (existsSync(manifestFile)) {
694
+ let text = readFileSync(manifestFile, "utf8");
695
+ if ([...text.matchAll(ROW)].some((match) => match[3] === plugin.name)) done.push("blend.yaml (already has the row)");
696
+ else {
697
+ const found = (plugin.after ? new RegExp(`^( *)- id: ?\\S+\\n\\1 {2}name: ?${plugin.after}\\n(?:\\1 {2}config:\\n(?:\\1 {4}.*\\n|\\1 {2}\\s*\\n)*)?`, "mu") : /^( *)- id: ?routes\n/mu).exec(text);
698
+ if (found) {
699
+ const indent = found[1];
700
+ const row = `${indent}- id: ${plugin.row ?? plugin.name}\n${indent} name: ${plugin.name}\n`;
701
+ const at = plugin.after ? found.index + found[0].length : found.index;
702
+ text = text.slice(0, at) + row + text.slice(at);
703
+ writeFileSync(manifestFile, text);
704
+ done.push(`blend.yaml (row ${plugin.name})`);
705
+ } else failed.push(`blend.yaml: add a row \`- id: ${plugin.name}\` / \`name: ${plugin.name}\` ${plugin.after ? `after the ${plugin.after} row` : "before the `routes` row"}`);
706
+ }
707
+ }
708
+ const appFile = join(appDir, "src", "app.ts");
709
+ if (existsSync(appFile)) {
710
+ let text = readFileSync(appFile, "utf8");
711
+ const entry = `'${plugin.name}': ${plugin.local}`;
712
+ if (text.includes(entry)) done.push("src/app.ts (already registered)");
713
+ else {
714
+ const last = [...text.matchAll(/^import .* from '\.\.\/plugins\/[^']+'\n/gmu)].at(-1);
715
+ const table = /^export const registry = \{(\s)/mu.exec(text);
716
+ if (last && table) {
717
+ const file = plugin.entry ?? "index.ts";
718
+ const line = `import * as ${plugin.local} from '../plugins/${plugin.name}/${file}'\n`;
719
+ const at = table.index + table[0].length;
720
+ text = text.slice(0, at) + (table[1] === "\n" ? ` ${entry},\n` : `${entry}, `) + text.slice(at);
721
+ const lastEnd = last.index + last[0].length;
722
+ text = text.slice(0, lastEnd) + line + text.slice(lastEnd);
723
+ writeFileSync(appFile, text);
724
+ done.push(`src/app.ts (registered as ${plugin.local})`);
725
+ } else failed.push(`src/app.ts: import it as \`${plugin.local}\` from '../plugins/${plugin.name}/${plugin.entry ?? "index.ts"}' and add \`${entry}\` to the registry`);
726
+ }
727
+ } else failed.push(`src/app.ts: not found; register ${entry(plugin)} in the app's registry`);
728
+ return {
729
+ done: failed.length === 0,
730
+ note: [done.length > 0 ? `mounted in ${done.join(" and ")}` : "", ...failed.map((line) => `could not edit ${line}`)].filter(Boolean).join("\n")
731
+ };
732
+ }
733
+ const entry = (plugin) => `'${plugin.name}': ${plugin.local}`;
734
+ //#endregion
625
735
  //#region src/add.ts
626
736
  /** Parse `add <plugin...> [--dir <app>] [--suite]`. */
627
737
  function parseAddOptions(argv) {
@@ -680,6 +790,14 @@ async function add(options, io) {
680
790
  io.stderr.write(`error: the app already has ${already.map((name) => `${relative(appDir, join(pluginsFolder(appDir).dir, name)).split(sep).join("/")}`).join(", ")}\n`);
681
791
  return 1;
682
792
  }
793
+ const mounting = MOUNTED.filter((plugin) => names.includes(plugin.name));
794
+ for (const plugin of mounting) {
795
+ const missing = missingRows(appDir, plugin, names);
796
+ if (missing.length > 0) {
797
+ io.stderr.write(`error: ${plugin.name} needs the ${missing.map((row) => `\`${row}\``).join(" and ")} ${missing.length === 1 ? "row" : "rows"} in blend.yaml, and this app has ${describeRows(appDir)}. ${plugin.name === "driver-pty" ? "Add agent-panel first (acrylblends add agent-panel driver-pty), which needs the agent template. " : ""}The rows come with the agent template: start from it (npm create acrylblends@latest my-app agent) and add ${plugin.name} there. Nothing was changed.\n`);
798
+ return 1;
799
+ }
800
+ }
683
801
  const toCopy = closure(names, readLibrary()).filter((plugin) => !present(appDir, plugin.name));
684
802
  const addedDependencies = [];
685
803
  for (const plugin of toCopy) {
@@ -698,9 +816,22 @@ async function add(options, io) {
698
816
  }
699
817
  io.stdout.write(`added ${toCopy.map((plugin) => plugin.name).join(", ")} to ${appDir}\n`);
700
818
  if (addedDependencies.length > 0) io.stdout.write(`new dependencies: ${addedDependencies.join(", ")} (run npm install)\n`);
701
- for (const plugin of toCopy.filter((plugin) => names.includes(plugin.name))) io.stdout.write(`\n${plugin.name}: ${plugin.mount ?? `import it from '../plugins/${plugin.name}/index.ts' and mount it in src/app.ts`}\n`);
819
+ for (const plugin of toCopy.filter((plugin) => names.includes(plugin.name))) {
820
+ const mounted = MOUNTED.find((candidate) => candidate.name === plugin.name);
821
+ const result = mounted ? mount(appDir, mounted) : void 0;
822
+ io.stdout.write(`\n${plugin.name}: ${result?.done ? `${result.note}${extraNote(mounted)}` : `${result ? `${result.note}\n` : ""}${plugin.mount ?? `import it from '../plugins/${plugin.name}/index.ts' and mount it in src/app.ts`}`}\n`);
823
+ }
702
824
  return 0;
703
825
  }
826
+ /** What an app has to say about its rows, for an error: the `name:` of each, or that it has no blend.yaml. */
827
+ function describeRows(appDir) {
828
+ const rows = rowNames(appDir);
829
+ return rows === void 0 ? "no blend.yaml" : `the rows ${rows.join(", ") || "(none)"}`;
830
+ }
831
+ /** What to do after `npm install` for a plugin that needs more than the install: node-pty compiles a native module in an install script, which npm 11 and pnpm skip until approved. */
832
+ function extraNote(plugin) {
833
+ return plugin?.name === "driver-pty" ? "\nnpm 11 and pnpm warn that node-pty's install script was not run, because they ask you to approve dependency install scripts. node-pty ships prebuilt binaries for macOS, Windows and Linux (x64, arm64), so you can usually ignore the warning. If the panel lists no `pty:` terminal (the app prints why at start), approve it and rebuild: `npm approve-scripts node-pty`, then `npm rebuild node-pty` (pnpm: `pnpm approve-builds`)." : "";
834
+ }
704
835
  /**
705
836
  * The vitest the suites run on. 3.2.x, not the newest: npm 10 (the npm of Node 22) crashes on the dependency tree of vitest 4 and 5 ("Cannot read properties of null (reading
706
837
  * 'edgesOut')"), and the suites use only `describe`, `it` and `expect`, which are the same in all of them. An app on a newer npm may raise it.