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.
- package/README.md +19 -15
- package/bin/acrylblends.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +136 -5
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +1 -0
- package/docs/framework/agents/index.md +18 -0
- package/docs/framework/cordis/migration-to-1-0.md +4 -3
- package/docs/framework/getting-started/desktop.md +20 -0
- package/docs/framework/guides/create-acrylblends.md +21 -0
- package/docs/framework/guides/data.md +12 -0
- package/docs/framework/guides/production.md +11 -1
- package/docs/framework/workshop/README.md +381 -0
- package/docs/framework/workshop/facilitator.md +107 -0
- package/lib/cordis/db-sqlite.js +10 -2
- package/lib/cordis/kv-json.js +10 -2
- package/lib/cordis/node-server.js +1 -1
- package/lib/cordis/router-http.js +1 -1
- package/lib/node-server.js +1 -1
- package/lib/{test-websocket-ZXHNHQSU.js → test-websocket-D8WWqE5G.js} +1 -2
- package/package.json +1 -1
- package/plugins/acryl-db-sqlite/plugin.json +1 -1
- package/plugins/acryl-db-sqlite/src/index.ts +8 -1
- package/plugins/acryl-kv-json/plugin.json +1 -1
- package/plugins/acryl-kv-json/src/index.ts +8 -1
- package/plugins/acryl-node-server/src/adapter/request.ts +3 -2
- package/plugins/agent-panel/headless.ts +24 -2
- package/plugins/desktop/launch.ts +48 -0
- package/plugins/desktop/plugin.json +1 -1
- package/plugins/desktop-web/launch.ts +48 -0
- package/plugins/desktop-web/plugin.json +1 -1
- package/templates/chat/AGENTS.md +1 -1
- package/templates/chat/src/access.ts +110 -0
- package/templates/chat/src/chat-app.ts +7 -1
- package/templates/chat/src/routes.ts +5 -1
- package/templates/chat/test/chat.test.ts +65 -0
- package/templates/notes/test/notes.test.ts +1 -1
- 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>.
|
|
107
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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,
|
|
124
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
validates every entry and fails when `index.json` is stale, so what the site lists is what
|
|
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:
|
package/bin/acrylblends.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 ${
|
|
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)))
|
|
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.
|