mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -126,12 +126,30 @@ is disposed automatically on deactivate. See [Lifecycle rules](#lifecycle-rules)
|
||||
|
||||
### 11. Tests
|
||||
|
||||
Each plugin has `plugins/<id>/tests/backend` and `plugins/<id>/tests/frontend`,
|
||||
run by its own vitest config built on the SDK preset (**A2**). Core tests
|
||||
(`src/backend/tests`, `src/ui/tests`) never test plugin code; they may test the
|
||||
plugin runtime with fixture plugins.
|
||||
Each plugin has `plugins/<id>/tests/backend` (node) and
|
||||
`plugins/<id>/tests/frontend` (jsdom), run by its own vitest config:
|
||||
|
||||
Until A2, plugin tests still live under `src/*/tests/plugins/`.
|
||||
```ts
|
||||
// plugins/<id>/vitest.config.ts
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
```
|
||||
|
||||
The preset supplies both projects, the shared setup file
|
||||
(`@termix/plugin-sdk/testing/setup`, which core's `vitest.setup.ts` re-exports
|
||||
so there is one copy) and the `@/` alias the frontends still use.
|
||||
|
||||
`npm run test` runs core only, `npm run test:plugins` runs every plugin and
|
||||
`npm run test:all` runs both. Core tests (`src/backend/tests`, `src/ui/tests`)
|
||||
never test plugin code; they may test the plugin runtime with fixture plugins,
|
||||
which is why `src/backend/tests/plugins/` still holds the loader, manifest,
|
||||
capability, registry, service, secret and shutdown suites.
|
||||
|
||||
`@termix/plugin-sdk/testing` provides `createMockCtx()`, which enforces
|
||||
capabilities the way the runtime does, `createFakeContext()` for tests that do
|
||||
not care about the gates, and `renderWithApp()` for frontend tests (**A7**
|
||||
finishes it).
|
||||
|
||||
### 12. Auth
|
||||
|
||||
@@ -146,6 +164,24 @@ Official plugins live in `plugins/<id>/` as npm workspace packages in this
|
||||
monorepo for all of 2.9.0, and get split into their own repos later. Every
|
||||
plugin is enabled by default in 2.9.0; there is no install UI until 3.0.0.
|
||||
|
||||
The layout is fixed, and the directory name must equal the manifest `id`:
|
||||
|
||||
```
|
||||
plugins/<id>/
|
||||
manifest.json id, capabilities, contributions
|
||||
package.json @termix-plugin/<id>, private, scripts call the CLI
|
||||
tsconfig.json extends @termix/plugin-sdk/tsconfig.plugin.json
|
||||
vitest.config.ts one line, the SDK preset
|
||||
src/backend/index.ts exports activate(ctx) and deactivate()
|
||||
src/frontend/index.tsx exports activate(app) and deactivate() (A7)
|
||||
locales/en.json English strings; Crowdin translates the rest
|
||||
migrations/{sqlite,pg,mysql}/ owned tables (A3)
|
||||
tests/backend/ vitest, node
|
||||
tests/frontend/ vitest, jsdom
|
||||
README.md CHANGELOG.md
|
||||
dist/ build output, gitignored
|
||||
```
|
||||
|
||||
### 14. Upgrades must be lossless
|
||||
|
||||
Every move of data, settings or host columns into a plugin comes with a
|
||||
@@ -431,35 +467,99 @@ until one is, this document does not pretend otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Building a plugin
|
||||
|
||||
`termix-plugin`, the CLI the SDK ships, is the only build a plugin needs. It
|
||||
runs from the plugin's own directory, so a plugin that moves to its own repo
|
||||
keeps working unchanged.
|
||||
|
||||
| Command | What it does |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `termix-plugin build` | esbuild bundles `src/backend/index.ts` to `dist/backend.js` (ESM, node22) and `src/frontend/index.tsx` to `dist/frontend.js` (ESM, browser), then copies `locales/` and `migrations/` into `dist/`. Imported CSS lands in `dist/frontend.css`. |
|
||||
| `termix-plugin validate` | Runs the SDK's `parseManifest` and checks the files the manifest names exist. |
|
||||
| `termix-plugin test` | Runs the plugin's vitest suite. |
|
||||
| `termix-plugin pack` | Writes a `.tgz` of the manifest, `dist/`, locales, migrations and README. **D3** adds signing. |
|
||||
|
||||
`npm run build:plugins` builds every plugin and stages the result in
|
||||
`dist/plugins/<id>/`, which is what `getBundledPluginsDir()` resolves to and
|
||||
what Docker and electron-builder package. `npm run build`, `build:backend` and
|
||||
`dev:backend` all call it.
|
||||
|
||||
TypeScript only type-checks (`tsconfig.plugins.json`, `noEmit`); esbuild does
|
||||
every emit. Nothing is written next to a source file.
|
||||
|
||||
### Host-provided packages
|
||||
|
||||
These ship with the Termix server and are never bundled into a plugin, because
|
||||
a second copy of express or React is a bug, and a second `ssh2` means a second
|
||||
set of native bindings. A plugin may depend on anything outside these lists,
|
||||
and that is bundled into its output.
|
||||
|
||||
**Backend** (plus every node builtin): `@termix/plugin-sdk`, `express`, `ssh2`,
|
||||
`ws`, `multer`, `cookie-parser`, `axios`, `jszip`, `guacamole-lite`,
|
||||
`@anthropic-ai/sdk`, `drizzle-orm`.
|
||||
|
||||
**Frontend**: `react`, `react-dom`, `react/jsx-runtime`, `@termix/plugin-sdk`,
|
||||
`i18next`, `react-i18next`, and the UI libraries core provides today
|
||||
(`lucide-react`, `sonner`, `axios`, `cytoscape`, `react-cytoscapejs`,
|
||||
`guacamole-common-js`, `react-xtermjs`, `@xterm/*`). **A7** serves these
|
||||
through the SDK `ui` entry.
|
||||
|
||||
The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`.
|
||||
|
||||
---
|
||||
|
||||
## Legacy core imports: the debt D1 removes
|
||||
|
||||
The twelve bundled plugins predate the SDK. They still reach core by relative
|
||||
path (`../../../src/backend/...`), which a build-time string rewrite in
|
||||
`scripts/copy-bundled-plugins.cjs` patches to the compiled output path.
|
||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
||||
and rewrites to the compiled output path (`../../../backend/backend/...`, or
|
||||
`../../../backend/types/...` for shared types). The rewrite is output-relative,
|
||||
so a source file at any nesting collapses to the same prefix. D1 deletes that
|
||||
file.
|
||||
|
||||
This is expected and temporary. The SDK does not yet expose what they need:
|
||||
the SSH connection pool, host resolution, the repository layer, the shared
|
||||
component library. D1 removes the debt once A3 through A8 have given them
|
||||
supported APIs to move onto.
|
||||
|
||||
What the lint fence enforces today:
|
||||
What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
|
||||
- Core must not import a plugin backend. **Error**, no offenders.
|
||||
- A plugin backend must not import frontend code or use the `@/` alias.
|
||||
**Error**, no offenders.
|
||||
- The shell must not import plugin components. **Warning** with 21 offenders,
|
||||
which is exactly the work A7 does. It becomes an error then.
|
||||
- Plugin backends importing core by relative path is not yet flagged, because
|
||||
there is nowhere for them to go.
|
||||
| Direction | Severity | Offenders | Emptied by |
|
||||
| ------------------------------------------------ | --------- | --------- | ---------- |
|
||||
| Core importing a plugin backend | **Error** | 0 | - |
|
||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||
| The shell importing plugin components | Warning | 16 files | A7 |
|
||||
| A plugin importing core by relative path | Warning | 71 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 3 files | B18 |
|
||||
|
||||
A warning does not fail a build, so the counts are held by
|
||||
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
|
||||
listed in `scripts/plugin-boundary-allowlist.json`: a new one fails, and so
|
||||
does an entry that stopped being an offender, so the list shrinks as each step
|
||||
lands rather than rotting. `--write` regenerates it. D1 empties it and the two
|
||||
plugin-side warnings become errors.
|
||||
|
||||
The two plugin-backend directions use different rule names
|
||||
(`no-restricted-imports` and `@typescript-eslint/no-restricted-imports`)
|
||||
because flat config replaces a rule's options rather than merging them, so one
|
||||
rule cannot carry both severities on the same files.
|
||||
|
||||
Known specifics:
|
||||
|
||||
- **TODO(B18):** `plugins/ai/backend/tools/executor.ts` imports
|
||||
`../../../automations/backend/routes.js` directly. It is declared as a hard
|
||||
`dependencies` entry so the loader starts automations first and marks `ai`
|
||||
blocked if automations is disabled. In B18 `ai` calls automations through an
|
||||
- **TODO(B18):** `plugins/ai/src/backend/tools/executor.ts` imports
|
||||
`automations`' `routes.js` directly. It is declared as a hard `dependencies`
|
||||
entry so the loader starts automations first and marks `ai` blocked if
|
||||
automations is disabled. In B18 `ai` calls automations through an
|
||||
`automations` service as an `optionalDependency` and the direct import goes
|
||||
away. This is the only cross-plugin import in the repo.
|
||||
away.
|
||||
- **TODO(B18):** `plugins/host-metrics/src/backend/` reaches `automations`
|
||||
twice, for a `MetricsSnapshot` type and a fire-and-forget
|
||||
`import("...headless-viewer.js")` that registers a viewer bridge. Both
|
||||
degrade silently when automations is absent, so host-metrics declares it as
|
||||
an `optionalDependency` (added in A2; it previously declared nothing, and
|
||||
the loader had no reason to order the two).
|
||||
- Eight plugins keep their original core URLs through the dispatchers in
|
||||
`src/backend/database/routes/*-dispatch.ts` rather than `/plugin-api`. A4
|
||||
moves them.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { legacyCoreImports } from "../lib/legacy-core-imports.mjs";
|
||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||
|
||||
const BACKEND_ENTRIES = [
|
||||
"src/backend/index.ts",
|
||||
"src/backend/index.mjs",
|
||||
"src/backend/index.js",
|
||||
];
|
||||
const FRONTEND_ENTRIES = [
|
||||
"src/frontend/index.tsx",
|
||||
"src/frontend/index.ts",
|
||||
"src/frontend/index.mjs",
|
||||
"src/frontend/index.js",
|
||||
];
|
||||
|
||||
export async function build({ cwd }) {
|
||||
const manifest = readManifest(cwd);
|
||||
const pluginId = manifest.id ?? path.basename(cwd);
|
||||
const outDir = path.join(cwd, "dist");
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
||||
if (!backendEntry) {
|
||||
throw new Error(
|
||||
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [backendEntry],
|
||||
outfile: path.join(outDir, "backend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
external: BACKEND_EXTERNALS,
|
||||
plugins: [legacyCoreImports({ pluginId, platform: "node" })],
|
||||
});
|
||||
|
||||
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
||||
if (frontendEntry) {
|
||||
await esbuild.build({
|
||||
entryPoints: [frontendEntry],
|
||||
outfile: path.join(outDir, "frontend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
jsx: "automatic",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
external: FRONTEND_EXTERNALS,
|
||||
plugins: [legacyCoreImports({ pluginId, platform: "browser" })],
|
||||
});
|
||||
}
|
||||
|
||||
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
|
||||
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
|
||||
|
||||
console.log(`built ${pluginId}`);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { create } from "tar";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
|
||||
/** Everything a server needs to run the plugin, and nothing else. D3 signs it. */
|
||||
const CONTENTS = [
|
||||
"manifest.json",
|
||||
"dist",
|
||||
"locales",
|
||||
"migrations",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
];
|
||||
|
||||
export async function pack({ cwd }) {
|
||||
const manifest = readManifest(cwd);
|
||||
const pluginId = manifest.id ?? path.basename(cwd);
|
||||
const version = manifest.version ?? "0.0.0";
|
||||
|
||||
if (!fs.existsSync(path.join(cwd, "dist", "backend.js"))) {
|
||||
throw new Error(`${pluginId}: nothing built. Run termix-plugin build.`);
|
||||
}
|
||||
|
||||
const files = CONTENTS.filter((entry) =>
|
||||
fs.existsSync(path.join(cwd, entry)),
|
||||
);
|
||||
const outfile = path.join(cwd, `${pluginId}-${version}.tgz`);
|
||||
|
||||
await create({ gzip: true, cwd, file: outfile }, files);
|
||||
|
||||
console.log(`packed ${path.basename(outfile)}`);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
/**
|
||||
* Runs vitest through its own entry rather than the npx shim, so no shell is
|
||||
* involved and paths with spaces work the same on every platform.
|
||||
*/
|
||||
export async function test({ cwd, args }) {
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
let vitestBin;
|
||||
try {
|
||||
// vitest.mjs is the package bin, not an exported subpath, so resolve the
|
||||
// manifest and walk to it.
|
||||
const manifest = require.resolve("vitest/package.json", { paths: [cwd] });
|
||||
vitestBin = path.join(path.dirname(manifest), "vitest.mjs");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"vitest is not installed. Run npm install at the repo root.",
|
||||
);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[vitestBin, "run", "--config", "vitest.config.ts", ...args],
|
||||
{ cwd, stdio: "inherit" },
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`vitest exited with ${result.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
|
||||
/**
|
||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||
* there is one implementation rather than a copy here that drifts.
|
||||
*/
|
||||
async function loadParseManifest() {
|
||||
const entry = new URL("../../dist/manifest.js", import.meta.url);
|
||||
if (!fs.existsSync(fileURLToPath(entry))) {
|
||||
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
|
||||
}
|
||||
const mod = await import(entry.href);
|
||||
return mod.parseManifest;
|
||||
}
|
||||
|
||||
export async function validate({ cwd }) {
|
||||
const raw = readManifest(cwd);
|
||||
const parseManifest = await loadParseManifest();
|
||||
const { errors } = parseManifest(raw);
|
||||
|
||||
const problems = [...errors];
|
||||
|
||||
// The manifest names files. They have to be there.
|
||||
const referenced = [
|
||||
raw.backend ?? "dist/backend.js",
|
||||
raw.frontend ?? "dist/frontend.js",
|
||||
raw.locales ?? "locales",
|
||||
];
|
||||
for (const rel of referenced) {
|
||||
if (!fs.existsSync(path.join(cwd, rel))) {
|
||||
problems.push(`manifest references ${rel}, which does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
|
||||
}
|
||||
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* termix-plugin: build, validate, test and pack a Termix plugin.
|
||||
*
|
||||
* Run from a plugin directory (npm run build inside plugins/<id>/ does).
|
||||
*/
|
||||
|
||||
import process from "node:process";
|
||||
import { build } from "./commands/build.mjs";
|
||||
import { validate } from "./commands/validate.mjs";
|
||||
import { test } from "./commands/test.mjs";
|
||||
import { pack } from "./commands/pack.mjs";
|
||||
|
||||
const COMMANDS = { build, validate, test, pack };
|
||||
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
|
||||
if (!command || command === "--help" || command === "-h") {
|
||||
console.log(
|
||||
[
|
||||
"Usage: termix-plugin <command>",
|
||||
"",
|
||||
" build Bundle the plugin into dist/",
|
||||
" validate Check manifest.json and the files it names",
|
||||
" test Run the plugin's vitest suite",
|
||||
" pack Write a .tgz of the built plugin",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(command ? 0 : 1);
|
||||
}
|
||||
|
||||
const run = COMMANDS[command];
|
||||
if (!run) {
|
||||
console.error(`Unknown command: ${command}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await run({ cwd: process.cwd(), args });
|
||||
} catch (error) {
|
||||
console.error(error?.message ?? error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Host-provided packages.
|
||||
*
|
||||
* These ship with the Termix server and are never bundled into a plugin:
|
||||
* bundling them would give each plugin its own copy of express or React, and
|
||||
* a second ssh2 would mean a second set of native bindings. A plugin may
|
||||
* depend on anything outside these lists, and that does get bundled.
|
||||
*
|
||||
* Node builtins are external automatically through platform: "node".
|
||||
*/
|
||||
|
||||
export const BACKEND_EXTERNALS = [
|
||||
"@termix/plugin-sdk",
|
||||
"@termix/plugin-sdk/*",
|
||||
"express",
|
||||
"ssh2",
|
||||
"ws",
|
||||
"multer",
|
||||
"cookie-parser",
|
||||
"axios",
|
||||
"jszip",
|
||||
"guacamole-lite",
|
||||
"@anthropic-ai/sdk",
|
||||
"drizzle-orm",
|
||||
"drizzle-orm/*",
|
||||
];
|
||||
|
||||
export const FRONTEND_EXTERNALS = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"react-dom/client",
|
||||
"@termix/plugin-sdk",
|
||||
"@termix/plugin-sdk/*",
|
||||
"i18next",
|
||||
"react-i18next",
|
||||
// Core-provided UI libraries. A7 serves these through the SDK ui entry.
|
||||
"lucide-react",
|
||||
"sonner",
|
||||
"axios",
|
||||
"cytoscape",
|
||||
"react-cytoscapejs",
|
||||
"guacamole-common-js",
|
||||
"react-xtermjs",
|
||||
"@xterm/*",
|
||||
// Legacy: the shell's own modules, still reached by alias. A7 removes.
|
||||
"@/*",
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Legacy core imports: the debt D1 removes.
|
||||
*
|
||||
* Plugin code predates the SDK and still reaches core, and in one case a
|
||||
* sibling plugin, by relative path. Those specifiers must stay out of the
|
||||
* bundle and be rewritten to where the built server puts the target.
|
||||
*
|
||||
* The rewrite is output-relative, not source-relative. Every plugin bundles
|
||||
* to plugins/<id>/dist/backend.js, which sits at the same depth the old
|
||||
* per-file output did, so one prefix covers a source file at any nesting.
|
||||
*
|
||||
* Delete this file in D1, once every plugin reaches core through the SDK.
|
||||
*/
|
||||
|
||||
// "../../../src/backend/" or "../../../../src/backend/", any depth.
|
||||
const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
|
||||
// Shared types. Mostly erased at compile, but automations imports a real
|
||||
// value (AUTOMATION_DEFINITION_VERSION), so this has to resolve at runtime.
|
||||
const CORE_TYPES = /^(?:\.\.\/)+src\/types\//;
|
||||
const CORE_UI = /^(?:\.\.\/)+src\/ui\//;
|
||||
// "../../../<other-plugin>/backend/" -- only plugins/ai/.../executor.ts today.
|
||||
// esbuild filters are Go RE2, which has no lookahead, so "src" is excluded in
|
||||
// the callback rather than the pattern.
|
||||
const CROSS_PLUGIN = /^(?:\.\.\/)+([a-z0-9-]+)\/(?:src\/)?backend\//;
|
||||
|
||||
/** dist/plugins/<id>/dist/backend.js -> dist/backend/... */
|
||||
const COMPILED_CORE = "../../../backend/backend/";
|
||||
const COMPILED_TYPES = "../../../backend/types/";
|
||||
|
||||
export function legacyCoreImports({ pluginId, platform }) {
|
||||
return {
|
||||
name: "legacy-core-imports",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: CORE_BACKEND }, (args) => ({
|
||||
path: args.path.replace(CORE_BACKEND, COMPILED_CORE),
|
||||
external: true,
|
||||
}));
|
||||
|
||||
build.onResolve({ filter: CORE_TYPES }, (args) => ({
|
||||
path: args.path.replace(CORE_TYPES, COMPILED_TYPES),
|
||||
external: true,
|
||||
}));
|
||||
|
||||
// The shell's own modules. Only plugins/docker's frontend entry reaches
|
||||
// core this way; every other frontend uses the "@/" alias, which is
|
||||
// externalized by name. A7 removes both.
|
||||
if (platform === "browser") {
|
||||
build.onResolve({ filter: CORE_UI }, (args) => ({
|
||||
path: args.path,
|
||||
external: true,
|
||||
}));
|
||||
}
|
||||
|
||||
build.onResolve({ filter: CROSS_PLUGIN }, (args) => {
|
||||
const [, other] = CROSS_PLUGIN.exec(args.path);
|
||||
if (other === "src" || other === pluginId) return null;
|
||||
return { path: `../../${other}/dist/backend.js`, external: true };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function readManifest(cwd) {
|
||||
const manifestPath = path.join(cwd, "manifest.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`No manifest.json in ${cwd}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(`manifest.json is not valid JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The first of these that exists, or null. */
|
||||
export function resolveEntry(cwd, candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const full = path.join(cwd, candidate);
|
||||
if (fs.existsSync(full)) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function copyDir(from, to) {
|
||||
if (!fs.existsSync(from)) return false;
|
||||
fs.cpSync(from, to, { recursive: true });
|
||||
return true;
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"termix-plugin": "./cli/index.mjs"
|
||||
},
|
||||
"exports": {
|
||||
"./backend": {
|
||||
"types": "./dist/backend.d.ts",
|
||||
@@ -25,17 +28,30 @@
|
||||
"types": "./dist/testing.d.ts",
|
||||
"default": "./dist/testing.js"
|
||||
},
|
||||
"./testing/setup": {
|
||||
"types": "./dist/testing/setup.d.ts",
|
||||
"default": "./dist/testing/setup.js"
|
||||
},
|
||||
"./vitest-preset": {
|
||||
"types": "./dist/vitest-preset.d.ts",
|
||||
"default": "./dist/vitest-preset.js"
|
||||
},
|
||||
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
||||
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"cli",
|
||||
"schema",
|
||||
"tsconfig.plugin.json",
|
||||
"ARCHITECTURE.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3"
|
||||
"esbuild": "^0.28.1",
|
||||
"semver": "^7.6.3",
|
||||
"tar": "^7.5.22"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* Test helpers for plugin authors.
|
||||
*
|
||||
* A2 adds the shared vitest preset each plugin's config extends. For now this
|
||||
* exposes the one thing a plugin test needs before that lands: a context
|
||||
* double that satisfies the PluginContext contract without a running server.
|
||||
* createMockCtx is the one to reach for: it enforces capabilities the way the
|
||||
* real runtime does. createFakeContext stays for tests that only need a
|
||||
* context-shaped object and do not care about the gates.
|
||||
*
|
||||
* The vitest config these run under comes from @termix/plugin-sdk/vitest-preset.
|
||||
*/
|
||||
|
||||
import { PluginCapabilityError } from "./backend.js";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginDisposables,
|
||||
PluginLogger,
|
||||
} from "./backend.js";
|
||||
import type { TermixApp } from "./frontend.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
export interface FakeContextOptions {
|
||||
@@ -137,3 +141,148 @@ export function createFakeContext(
|
||||
|
||||
return { ctx, disposals, emitted, kv };
|
||||
}
|
||||
|
||||
export interface MockContextOptions {
|
||||
pluginId?: string;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Capabilities the plugin is granted. Anything else throws. */
|
||||
capabilities?: string[];
|
||||
/** Seed for ctx.kv. */
|
||||
kv?: Record<string, unknown>;
|
||||
/** Plugin settings, readable through settings.get. A5 wires the real one. */
|
||||
settings?: Record<string, unknown>;
|
||||
/** Acting user returned by currentActor and used by asUser. */
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
/** Settings backing store, so a test can assert what a plugin wrote. */
|
||||
settings: Map<string, unknown>;
|
||||
/** Capability ids checked during the test, in order, denied ones included. */
|
||||
checked: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A context double that gates on capabilities the way src/backend/plugins/ctx.ts
|
||||
* does, so a test can prove a plugin fails closed without a running server.
|
||||
*
|
||||
* Two gates exist on today's ctx surface: kv:own on every ctx.kv call, and
|
||||
* events:core on emitting a topic outside the plugin's own namespace. As the
|
||||
* SDK grows a member, add its gate here in the same shape.
|
||||
*/
|
||||
export function createMockCtx(
|
||||
options: MockContextOptions = {},
|
||||
): MockPluginContext {
|
||||
const granted = new Set(options.capabilities ?? []);
|
||||
const checked: string[] = [];
|
||||
const settings = new Map<string, unknown>(
|
||||
Object.entries(options.settings ?? {}),
|
||||
);
|
||||
|
||||
const base = createFakeContext({
|
||||
pluginId: options.pluginId,
|
||||
actor: options.actor,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
},
|
||||
});
|
||||
|
||||
const { ctx, kv } = base;
|
||||
const pluginId = ctx.pluginId;
|
||||
|
||||
for (const [key, value] of Object.entries(options.kv ?? {})) {
|
||||
kv.set(key, value);
|
||||
}
|
||||
|
||||
const require = (capability: string) => {
|
||||
checked.push(capability);
|
||||
if (!granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
};
|
||||
|
||||
const guardedKv = ctx.kv;
|
||||
const gatedCtx: PluginContext = {
|
||||
...ctx,
|
||||
|
||||
kv: {
|
||||
get: async (key) => {
|
||||
require("kv:own");
|
||||
return guardedKv.get(key);
|
||||
},
|
||||
set: async (key, value) => {
|
||||
require("kv:own");
|
||||
return guardedKv.set(key, value);
|
||||
},
|
||||
delete: async (key) => {
|
||||
require("kv:own");
|
||||
return guardedKv.delete(key);
|
||||
},
|
||||
list: async () => {
|
||||
require("kv:own");
|
||||
return guardedKv.list();
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
emit: (topic, payload) => {
|
||||
if (
|
||||
!topic.startsWith(`plugin.${pluginId}.`) &&
|
||||
!granted.has("events:core")
|
||||
) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} may only emit topics under "plugin.${pluginId}.". ` +
|
||||
`Declare the events:core capability to emit core topics.`,
|
||||
);
|
||||
}
|
||||
ctx.events.emit(topic, payload);
|
||||
},
|
||||
on: (topic, listener) => ctx.events.on(topic, listener),
|
||||
},
|
||||
};
|
||||
|
||||
return { ...base, ctx: gatedCtx, settings, checked };
|
||||
}
|
||||
|
||||
/**
|
||||
* A TermixApp double for frontend tests.
|
||||
*
|
||||
* A7 builds the real registration surface; until then this records what a
|
||||
* plugin's frontend entry registered, which is all a test can assert about a
|
||||
* loader that does not exist yet. Deliberately free of any render library so
|
||||
* a plugin only needs testing-library if its own tests render something.
|
||||
*/
|
||||
export interface FakeTermixApp {
|
||||
app: TermixApp;
|
||||
/** Cleanups the plugin handed to app.onDispose. */
|
||||
disposals: Array<() => void>;
|
||||
}
|
||||
|
||||
export function renderWithApp(
|
||||
options: { pluginId?: string; manifest?: Partial<PluginManifest> } = {},
|
||||
): FakeTermixApp {
|
||||
const pluginId = options.pluginId ?? "test-plugin";
|
||||
const disposals: Array<() => void> = [];
|
||||
|
||||
const manifest = {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
...options.manifest,
|
||||
} as PluginManifest;
|
||||
|
||||
const app: TermixApp = {
|
||||
pluginId,
|
||||
manifest,
|
||||
onDispose: (dispose) => disposals.push(dispose),
|
||||
};
|
||||
|
||||
return { app, disposals };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Shared vitest setup for core and for every plugin suite.
|
||||
*
|
||||
* The root vitest.setup.ts re-exports this so there is one copy of the
|
||||
* polyfills rather than two that drift apart.
|
||||
*/
|
||||
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
if (typeof window !== "undefined" && !window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom in this project ships without a `localStorage` global, and Node only
|
||||
* provides one when started with --localstorage-file (which persists to disk
|
||||
* and is shared across test files -- both wrong for tests). Suites that touch
|
||||
* storage therefore threw `Cannot read properties of undefined`, taking out
|
||||
* every test in the file rather than just the ones that used it.
|
||||
*
|
||||
* A per-process in-memory implementation is enough: it satisfies the Storage
|
||||
* interface the app uses and keeps nothing on disk.
|
||||
*/
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
const createMemoryStorage = (): Storage => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (index: number) => Array.from(store.keys())[index] ?? null,
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
} as Storage;
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: createMemoryStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "sessionStorage", {
|
||||
value: createMemoryStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The vitest config every plugin uses.
|
||||
*
|
||||
* A plugin's vitest.config.ts is one line:
|
||||
*
|
||||
* import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
* export default pluginVitestConfig(import.meta.url);
|
||||
*
|
||||
* Two projects, matching the layout in ARCHITECTURE.md: tests/backend runs in
|
||||
* node, tests/frontend in jsdom.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Walks up from a plugin directory to the repo that holds src/ui.
|
||||
*
|
||||
* Plugin frontends and their tests still reach core through the "@/" alias
|
||||
* (the legacy debt A7 and D1 remove), so the preset has to resolve it. A
|
||||
* plugin developed outside this repo has no src/ui to point at, which is
|
||||
* exactly right: the alias stops resolving and the import fails loudly.
|
||||
*/
|
||||
function findRepoRoot(startDir: string): string | null {
|
||||
let dir = startDir;
|
||||
for (;;) {
|
||||
if (fs.existsSync(path.join(dir, "src", "ui"))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginVitestOptions {
|
||||
/** Extra aliases merged over the defaults. */
|
||||
alias?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function pluginVitestConfig(
|
||||
configUrl: string,
|
||||
options: PluginVitestOptions = {},
|
||||
) {
|
||||
const pluginDir = path.dirname(fileURLToPath(configUrl));
|
||||
const pluginId = path.basename(pluginDir);
|
||||
const setupFile = fileURLToPath(
|
||||
new URL("./testing/setup.js", import.meta.url),
|
||||
);
|
||||
|
||||
const repoRoot = findRepoRoot(pluginDir);
|
||||
const alias: Record<string, string> = {
|
||||
...(repoRoot
|
||||
? {
|
||||
"@/types": path.join(repoRoot, "src", "types"),
|
||||
"@": path.join(repoRoot, "src", "ui"),
|
||||
}
|
||||
: {}),
|
||||
...options.alias,
|
||||
};
|
||||
|
||||
return {
|
||||
resolve: { alias },
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
projects: [
|
||||
{
|
||||
resolve: { alias },
|
||||
test: {
|
||||
name: `${pluginId}:backend`,
|
||||
root: pluginDir,
|
||||
environment: "node",
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
include: ["tests/backend/**/*.test.ts"],
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: { alias },
|
||||
test: {
|
||||
name: `${pluginId}:frontend`,
|
||||
root: pluginDir,
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
include: ["tests/frontend/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/tsconfig.plugin-sdk.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedSideEffectImports": false,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitThis": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"allowUnusedLabels": true,
|
||||
"allowUnreachableCode": true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user