feat: add plugin packing, signing, pinned trust and tmxplug bundling

This commit is contained in:
LukeGus committed 2026-09-25 09:21:28 -05:00
1 parent c6a44b7ff3
commit 2ee9b3592c
25 files changed
+1764 -56

No files matched your search

+94 -7
View File
@@ -1639,10 +1639,44 @@ plugins/<id>/
src/backend/tables.ts defineTable() definitions
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
README.md CHANGELOG.md
README.md CHANGELOG.md icon.svg
dist/ build output, gitignored
```
#### Which plugins ship, and from where
`docker/bundled-plugins.json` lists every plugin the image ships and where
each comes from. `npm run build:plugins` (`scripts/build-plugins.cjs`) reads
it and stages each one in `dist/plugins/<id>/`:
```json
{ "id": "tunnels", "source": "workspace" }
{ "id": "tunnels", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/termix-plugin-tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
"sha256": "<64 hex>" }
```
A `workspace` entry is built from `plugins/<id>/`. A `tmxplug` entry is
downloaded (cached under `node_modules/.cache/termix-bundled-plugins/`) or
read from a local `path`, refused unless its sha256 matches the one pinned in
the file, and unpacked. The build fails on a `plugins/` folder nobody listed,
a workspace entry with no folder, or a tmxplug entry whose folder still
exists, so the two can never both ship.
Switching a plugin over once its own repo exists:
1. Tag a release in the plugin's repo. Its release workflow publishes
`<id>-<version>.tmxplug` and the `.sig`.
2. Replace the plugin's entry with `source: "tmxplug"`, the release `url` and
the `sha256` the workflow printed (or `sha256sum` of the download).
3. Delete `plugins/<id>/` and run `npm install` so the workspace goes away.
4. Run `npm run build:plugins` and check `dist/plugins/<id>/` holds the
plugin.
A tmxplug plugin is not in the Vite dev server's plugin glob, so `npm run dev`
does not hot-reload it; its built frontend still loads from
`/plugin-assets/<id>/`.
### 14. Upgrades must be lossless
Every move of data, settings or host columns into a plugin comes with a
@@ -2575,8 +2609,11 @@ None of the checks in this document stop that. They are not a sandbox.
### What the system does about it
- **Signing and review.** Official plugins live in this repo and ship in the
Termix image; they are reviewed like core. Signed community plugins and
their review process arrive with the install UI in 3.0.0.
Termix image; they are reviewed like core and trusted by shipping inside
it. A plugin outside the image is a `.tmxplug` signed by a key compiled
into Termix (see [Packaging and signing](#packaging-and-signing)), and
`TERMIX_REQUIRE_SIGNED_PLUGINS=true` refuses anything else. Community
review and the install UI arrive in 3.0.0.
- **The kill list (3.0.0).** A signed list of plugin versions known to be bad,
which the server refuses to load, so a bad plugin can be switched off on
every install without waiting for each admin.
@@ -2612,7 +2649,8 @@ None of the checks in this document stop that. They are not a sandbox.
### Before installing a community plugin
- **Know where it came from.** Install from a source you would trust with
root on the server, and check the signature once 3.0.0 has them.
root on the server. Prefer a signed `.tmxplug` and run with
`TERMIX_REQUIRE_SIGNED_PLUGINS=true`.
- **Read the capabilities.** Anything critical or high (`credentials:read`,
`system:tls`, `ssh:connect`, `process:spawn`, `users:write`,
`auth:provide`, `device:serial`) means the plugin can reach your hosts,
@@ -2642,10 +2680,13 @@ keeps working unchanged.
| `termix-plugin validate` | Runs the SDK's `parseManifest`, checks the files the manifest names exist, and checks that migrations only touch this plugin's tables and exist for every dialect. |
| `termix-plugin migrations` | Diffs `src/backend/tables.ts` against `migrations/snapshot.json` and writes one `.sql` per dialect. `--check` fails when a definition changed without a migration. A rename or a type change is refused rather than guessed at, because that is a data decision. |
| `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. |
| `termix-plugin pack` | Validates, then writes `<id>-<version>.tmxplug` (see [Packaging and signing](#packaging-and-signing)). `--out <dir>` picks the folder. |
| `termix-plugin sign` | Signs a `.tmxplug` with the key in `TERMIX_PLUGIN_SIGNING_KEY` and writes `<file>.tmxplug.sig`. |
| `termix-plugin verify` | Checks a `.tmxplug` against its `.sig` and `--key <base64>[,<base64>]`. |
| `termix-plugin keygen` | Writes a new Ed25519 private key to `termix-plugin-signing.key` and prints the public key and key id. Never prints the private key. |
`npm run build:plugins` builds every plugin and stages the result in
`dist/plugins/<id>/`, which is what `getBundledPluginsDir()` resolves to and
`npm run build:plugins` stages every plugin listed in
`docker/bundled-plugins.json` 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.
@@ -2653,6 +2694,52 @@ TypeScript only type-checks (`tsconfig.plugins.backend.json` and
`tsconfig.plugins.frontend.json`, `noEmit`); esbuild does
every emit. Nothing is written next to a source file.
### Packaging and signing
**The `.tmxplug` file.** A gzipped tar of `manifest.json`, `dist/`,
`locales/`, `migrations/`, `README.md`, `CHANGELOG.md` and `icon.svg`
(whichever exist). `pack` refuses when `validate` finds an error. The archive
is reproducible: entries sorted by path, files only, mode 0644, owner 0,
mtime 0, and a gzip header with no timestamp and a fixed OS byte. The same
tag rebuilt on any machine gives the same sha256, as long as the build output
is the same (check out with the same line endings).
**Signatures.** `termix-plugin sign` makes an Ed25519 signature over the raw
32-byte sha256 of the `.tmxplug` and writes it, base64, to
`<file>.tmxplug.sig`. The private key comes from `TERMIX_PLUGIN_SIGNING_KEY`
(base64 PKCS8 as `keygen` writes it, or PEM); in CI it is a GitHub Actions
secret and it is never committed. A registry index carries the same base64
string in each version's `signature`.
**Pinned trust.** `src/backend/plugins/trust.ts` holds the registry public
keys as compile-time constants (`TRUSTED_PLUGIN_KEYS`) and
`verifyPluginArtifact(buffer, sha256, signature)`, which checks the hash and
then the signature against each pinned key. A key read from a registry index,
a download or the data directory is never trusted: whoever serves the index
could sign anything with it. Rotation needs a Termix release: add the new
key, ship, sign with it, and keep the old key for one release cycle so late
upgraders still accept plugins signed before the switch. The steps are in the
Termix-Registry README.
**What the server loads.** Bundled plugins (`dist/plugins/`) are trusted by
shipping in the image and are not checked. In the data directory,
`<DATA_DIR>/plugins/` holds either a plugin folder (as before) or a
`<name>.tmxplug` with an optional `<name>.tmxplug.sig` next to it. A
`.tmxplug` is unpacked fresh on every load into
`<DATA_DIR>/plugins/.unpacked/<id>/` (plain files only, no path outside it).
A `.sig` that does not verify always blocks the plugin. With
`TERMIX_REQUIRE_SIGNED_PLUGINS=true`, a plugin folder is blocked (it carries
no signature) and so is a `.tmxplug` without a valid `.sig`. A blocked
plugin is logged and skipped like any other plugin that fails to load.
**Publishing the SDK.** Plugins outside this repo depend on
`@termix/plugin-sdk` from npm. It is published from this repo with
`npm run build:sdk && npm publish --workspace @termix/plugin-sdk`; the
package's `files` list ships `dist/` with its `.d.ts` files, the CLI, the
manifest schema and `tsconfig.plugin.json`. Outside a Termix checkout the
vitest preset does not alias the shell's modules, so `renderWithApp` and
`@termix/plugin-sdk/ui` are only available to plugins in this repo for now.
### Host-provided packages
These ship with the Termix server and are never bundled into a plugin, because
+23
View File
@@ -179,6 +179,29 @@ with a real install, a real device or a repo outside this one.
Plugins > Permissions lists a plugin's public routes; a disabled plugin's
`/plugin-api` URL answers 503; a share-link guest who leaves cannot type.
## After D3 (needs the repos and keys)
- Run `termix-plugin keygen`, then pin the public key in
`TRUSTED_PLUGIN_KEYS` (`src/backend/plugins/trust.ts`) and save it as
`keys/official.pub` in Termix-Registry. Until then the list is empty and
`TERMIX_REQUIRE_SIGNED_PLUGINS=true` blocks every user plugin. Steps are in
the Termix-Registry README.
- Create the Termix-Registry and Termix-Plugin-Template repos from the local
folders next to this repo, and set `TERMIX_PLUGIN_SIGNING_KEY` and
`REGISTRY_TOKEN` secrets.
- Publish the SDK (`npm run build:sdk && npm publish --workspace
@termix/plugin-sdk`), then run `npm install` in Termix-Plugin-Template and
commit its `package-lock.json`; its release workflow uses `npm ci`.
- Plugins outside this repo cannot use `renderWithApp` or
`@termix/plugin-sdk/ui` (types and the test host come from core's source).
Ship them in the SDK package before community plugins need UI components.
- `npm run test:plugins` on Windows sometimes fails with "Worker exited
unexpectedly" in a random plugin (file-manager once, workspaces once); the
same suite passes alone and a rerun of the full run passed. Find out why
the vitest fork dies (D0).
- Check by hand: drop a signed `.tmxplug` into `<DATA_DIR>/plugins/` on a
real install, with and without `TERMIX_REQUIRE_SIGNED_PLUGINS=true`.
## Left for later
- 3.0.0: drop the migrated 2.8 settings rows (`guac_url`, `step_ca_url`,
+19
View File
@@ -0,0 +1,19 @@
# @termix/plugin-sdk
Everything a [Termix](https://github.com/Termix-SSH/Termix) plugin builds
against: the types for `activate(ctx)` and `activate(app)`, table
definitions, the manifest schema, test helpers, a vitest preset and the
`termix-plugin` CLI.
```bash
npm install --save-dev @termix/plugin-sdk
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
npx termix-plugin test # run the plugin's vitest suite
npx termix-plugin validate # check manifest.json and the files it names
npx termix-plugin pack # write <id>-<version>.tmxplug
npx termix-plugin sign <file.tmxplug> # needs TERMIX_PLUGIN_SIGNING_KEY
```
Start from
[Termix-Plugin-Template](https://github.com/Termix-SSH/Termix-Plugin-Template).
The full contract is in [ARCHITECTURE.md](./ARCHITECTURE.md).
+25 -16
View File
@@ -1,19 +1,23 @@
import fs from "node:fs";
import path from "node:path";
import { create } from "tar";
import { readManifest } from "../lib/plugin-dir.mjs";
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
import { sha256 } from "../lib/signing.mjs";
import { validate } from "./validate.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",
];
function readOption(args, name) {
const index = args.indexOf(name);
if (index === -1) return null;
const value = args[index + 1];
if (!value) throw new Error(`${name} needs a value`);
return value;
}
export async function pack({ cwd }) {
/**
* Writes <id>-<version>.tmxplug. Validates first and refuses on any error,
* so a broken manifest never becomes a release.
*/
export async function pack({ cwd, args = [] }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const version = manifest.version ?? "0.0.0";
@@ -22,12 +26,17 @@ export async function pack({ cwd }) {
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 validate({ cwd });
await create({ gzip: true, cwd, file: outfile }, files);
const outDir = path.resolve(cwd, readOption(args, "--out") ?? ".");
fs.mkdirSync(outDir, { recursive: true });
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
const archive = createTmxplug(cwd, collectFiles(cwd));
fs.writeFileSync(outfile, archive);
console.log(`packed ${path.basename(outfile)}`);
console.log(` size ${archive.length}`);
console.log(` sha256 ${sha256(archive).toString("hex")}`);
return outfile;
}
+83
View File
@@ -0,0 +1,83 @@
import fs from "node:fs";
import path from "node:path";
import {
SIGNING_KEY_ENV,
generateKeyPair,
loadPrivateKey,
sha256,
signArtifact,
verifyArtifact,
} from "../lib/signing.mjs";
function readOption(args, name) {
const index = args.indexOf(name);
if (index === -1) return null;
const value = args[index + 1];
if (!value) throw new Error(`${name} needs a value`);
return value;
}
function artifactArg(cwd, args) {
const file = args.find((arg) => arg.endsWith(".tmxplug"));
if (!file) throw new Error("Pass the .tmxplug file to use");
const full = path.resolve(cwd, file);
if (!fs.existsSync(full)) throw new Error(`${file} does not exist`);
return full;
}
/** Writes <file>.sig: base64 Ed25519 over the sha256 of the .tmxplug. */
export async function sign({ cwd, args = [], env = process.env }) {
const file = artifactArg(cwd, args);
const key = loadPrivateKey(env[SIGNING_KEY_ENV]);
const buffer = fs.readFileSync(file);
const signature = signArtifact(buffer, key);
fs.writeFileSync(`${file}.sig`, `${signature}\n`);
console.log(`signed ${path.basename(file)}`);
console.log(` sha256 ${sha256(buffer).toString("hex")}`);
console.log(` signature ${signature}`);
return signature;
}
/** Checks a .tmxplug against its .sig and one or more public keys. */
export async function verify({ cwd, args = [] }) {
const file = artifactArg(cwd, args);
const keys = (readOption(args, "--key") ?? "")
.split(",")
.map((key) => key.trim())
.filter(Boolean);
if (keys.length === 0) throw new Error("Pass --key <base64 public key>");
const sigFile = readOption(args, "--sig") ?? `${file}.sig`;
if (!fs.existsSync(sigFile)) throw new Error(`${sigFile} does not exist`);
const matched = verifyArtifact(
fs.readFileSync(file),
fs.readFileSync(sigFile, "utf8"),
keys,
);
if (!matched)
throw new Error(`${path.basename(file)}: signature is not valid`);
console.log(`ok ${path.basename(file)} signed by key ${matched}`);
return matched;
}
/**
* Writes a new private key to a file and prints the public half. The
* private key never goes to stdout, so it does not end up in a CI log.
*/
export async function keygen({ cwd, args = [] }) {
const outDir = path.resolve(cwd, readOption(args, "--out") ?? ".");
const keyFile = path.join(outDir, "termix-plugin-signing.key");
if (fs.existsSync(keyFile)) {
throw new Error(`${keyFile} already exists; move it first`);
}
const pair = generateKeyPair();
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(keyFile, `${pair.privateKey}\n`, { mode: 0o600 });
console.log(`private key ${keyFile}`);
console.log(`public key ${pair.publicKey}`);
console.log(`key id ${pair.keyId}`);
console.log(
`Store the private key as ${SIGNING_KEY_ENV}, then delete the file.`,
);
return { keyFile, publicKey: pair.publicKey, keyId: pair.keyId };
}
+16 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* termix-plugin: build, validate, test and pack a Termix plugin.
* termix-plugin: build, validate, test, pack and sign a Termix plugin.
*
* Run from a plugin directory (npm run build inside plugins/<id>/ does).
*/
@@ -10,9 +10,19 @@ import { build } from "./commands/build.mjs";
import { validate } from "./commands/validate.mjs";
import { test } from "./commands/test.mjs";
import { pack } from "./commands/pack.mjs";
import { sign, verify, keygen } from "./commands/sign.mjs";
import { migrations } from "./commands/migrations.mjs";
const COMMANDS = { build, validate, test, pack, migrations };
const COMMANDS = {
build,
validate,
test,
pack,
sign,
verify,
keygen,
migrations,
};
const [command, ...args] = process.argv.slice(2);
@@ -24,7 +34,10 @@ if (!command || command === "--help" || command === "-h") {
" 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",
" pack Write <id>-<version>.tmxplug of the built plugin [--out dir]",
" sign Sign a .tmxplug with TERMIX_PLUGIN_SIGNING_KEY <file>",
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
" keygen Write a new signing key pair [--out dir]",
" migrations Generate migrations from the plugin's table definitions",
].join("\n"),
);
+86
View File
@@ -0,0 +1,86 @@
import crypto from "node:crypto";
/** The env var that holds the base64 PKCS8 private key. Never commit it. */
export const SIGNING_KEY_ENV = "TERMIX_PLUGIN_SIGNING_KEY";
// DER prefix of an Ed25519 SubjectPublicKeyInfo; the raw 32-byte key follows.
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
export function sha256(buffer) {
return crypto.createHash("sha256").update(buffer).digest();
}
export function publicKeyFromRaw(base64) {
const raw = Buffer.from(base64.trim(), "base64");
if (raw.length !== 32) throw new Error("A public key is 32 bytes, base64");
return crypto.createPublicKey({
key: Buffer.concat([SPKI_PREFIX, raw]),
format: "der",
type: "spki",
});
}
export function rawPublicKey(keyObject) {
const der = keyObject.export({ format: "der", type: "spki" });
return der.subarray(SPKI_PREFIX.length).toString("base64");
}
/** First 16 hex chars of the sha256 of the raw public key. */
export function keyId(publicKeyBase64) {
return sha256(Buffer.from(publicKeyBase64, "base64"))
.toString("hex")
.slice(0, 16);
}
export function generateKeyPair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicBase64 = rawPublicKey(publicKey);
return {
publicKey: publicBase64,
privateKey: privateKey
.export({ format: "der", type: "pkcs8" })
.toString("base64"),
keyId: keyId(publicBase64),
};
}
/** Accepts base64 PKCS8 DER (what keygen writes) or a PEM block. */
export function loadPrivateKey(value) {
if (!value || !value.trim()) {
throw new Error(`${SIGNING_KEY_ENV} is not set`);
}
const trimmed = value.trim();
const key = trimmed.startsWith("-----BEGIN")
? crypto.createPrivateKey(trimmed)
: crypto.createPrivateKey({
key: Buffer.from(trimmed, "base64"),
format: "der",
type: "pkcs8",
});
if (key.asymmetricKeyType !== "ed25519") {
throw new Error("The signing key must be an Ed25519 key");
}
return key;
}
/** Ed25519 over the raw 32-byte sha256 of the artifact, base64. */
export function signArtifact(buffer, privateKey) {
return crypto.sign(null, sha256(buffer), privateKey).toString("base64");
}
/** The id of the first key that verifies the signature, or null. */
export function verifyArtifact(buffer, signatureBase64, publicKeys) {
const digest = sha256(buffer);
const signature = Buffer.from(signatureBase64.trim(), "base64");
if (signature.length !== 64) return null;
for (const key of publicKeys) {
try {
if (crypto.verify(null, digest, publicKeyFromRaw(key), signature)) {
return keyId(key);
}
} catch {
// A malformed key is just a key that does not match.
}
}
return null;
}
+106
View File
@@ -0,0 +1,106 @@
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
/** What a .tmxplug holds, in the order the spec lists it. */
export const TMXPLUG_CONTENTS = [
"manifest.json",
"dist",
"locales",
"migrations",
"README.md",
"CHANGELOG.md",
"icon.svg",
];
const BLOCK = 512;
/**
* Every file under the given top-level entries, as sorted posix paths.
* Directories are implied by their files, so none get their own entry.
*/
export function collectFiles(cwd, entries = TMXPLUG_CONTENTS) {
const files = [];
const walk = (rel) => {
const full = path.join(cwd, rel);
const stat = fs.lstatSync(full);
if (stat.isSymbolicLink()) {
throw new Error(`${rel} is a symlink; a .tmxplug holds plain files only`);
}
if (stat.isDirectory()) {
for (const child of fs.readdirSync(full)) {
walk(path.posix.join(rel, child));
}
} else if (stat.isFile()) {
files.push(rel);
}
};
for (const entry of entries) {
if (fs.existsSync(path.join(cwd, entry))) walk(entry);
}
return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
}
function writeString(buf, offset, length, value) {
const bytes = Buffer.from(value, "utf8");
if (bytes.length > length) throw new Error(`"${value}" is too long`);
bytes.copy(buf, offset);
}
function writeOctal(buf, offset, length, value) {
writeString(buf, offset, length, value.toString(8).padStart(length - 1, "0"));
}
function splitName(name) {
if (Buffer.byteLength(name) <= 100) return { name, prefix: "" };
for (let i = name.length - 1; i > 0; i--) {
if (name[i] !== "/") continue;
const prefix = name.slice(0, i);
const rest = name.slice(i + 1);
if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(rest) <= 100) {
return { name: rest, prefix };
}
}
throw new Error(`${name} is too long for a .tmxplug entry`);
}
function header(name, size) {
const buf = Buffer.alloc(BLOCK);
const split = splitName(name);
writeString(buf, 0, 100, split.name);
writeOctal(buf, 100, 8, 0o644);
writeOctal(buf, 108, 8, 0);
writeOctal(buf, 116, 8, 0);
writeOctal(buf, 124, 12, size);
writeOctal(buf, 136, 12, 0);
buf.fill(0x20, 148, 156);
buf[156] = 0x30;
writeString(buf, 257, 6, "ustar");
writeString(buf, 263, 2, "00");
writeString(buf, 345, 155, split.prefix);
let sum = 0;
for (const byte of buf) sum += byte;
writeString(buf, 148, 8, sum.toString(8).padStart(6, "0") + "\0 ");
return buf;
}
/**
* A gzipped ustar archive that depends only on file names and contents:
* fixed mode, owner and mtime, sorted entries, and a gzip header with no
* timestamp and a fixed OS byte (Node on Windows writes a different one).
* Rebuilding from the same tag gives the same sha256 on any machine.
*/
export function createTmxplug(cwd, files) {
const parts = [];
for (const rel of files) {
const data = fs.readFileSync(path.join(cwd, rel));
parts.push(header(rel, data.length), data);
const pad = (BLOCK - (data.length % BLOCK)) % BLOCK;
if (pad) parts.push(Buffer.alloc(pad));
}
parts.push(Buffer.alloc(BLOCK * 2));
const gz = zlib.gzipSync(Buffer.concat(parts), { level: 9 });
gz.writeUInt32LE(0, 4);
gz[9] = 0x03;
return gz;
}
+29 -3
View File
@@ -3,7 +3,16 @@
"version": "1.0.0",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/Termix-SSH/Termix.git",
"directory": "packages/plugin-sdk"
},
"homepage": "https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ARCHITECTURE.md",
"type": "module",
"engines": {
"node": ">=22"
},
"bin": {
"termix-plugin": "./cli/index.mjs"
},
@@ -68,10 +77,12 @@
"cli",
"schema",
"tsconfig.plugin.json",
"ARCHITECTURE.md"
"ARCHITECTURE.md",
"README.md"
],
"scripts": {
"build": "tsc -p tsconfig.json"
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
"dependencies": {
"esbuild": "^0.28.1",
@@ -80,7 +91,10 @@
},
"peerDependencies": {
"drizzle-orm": "*",
"ssh2": "*"
"ssh2": "*",
"better-sqlite3": "*",
"react": "*",
"vitest": "*"
},
"peerDependenciesMeta": {
"drizzle-orm": {
@@ -88,6 +102,18 @@
},
"ssh2": {
"optional": true
},
"better-sqlite3": {
"optional": true
},
"react": {
"optional": true
},
"vitest": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
}
+7 -1
View File
@@ -1846,7 +1846,13 @@ export async function createTestDb(
);
const handle = drizzle(sqlite);
const pluginId = path.basename(pluginDir);
// The manifest id, not the folder name: a plugin's own repo is rarely
// named after its id.
const manifestPath = path.join(pluginDir, "manifest.json");
const pluginId: string =
(fs.existsSync(manifestPath) &&
JSON.parse(fs.readFileSync(manifestPath, "utf8")).id) ||
path.basename(pluginDir);
const applied: string[] = [];
let persisted = 0;
+5 -1
View File
@@ -42,7 +42,11 @@ export function pluginVitestConfig(
options: PluginVitestOptions = {},
) {
const pluginDir = path.dirname(fileURLToPath(configUrl));
const pluginId = path.basename(pluginDir);
const manifestPath = path.join(pluginDir, "manifest.json");
const pluginId: string =
(fs.existsSync(manifestPath) &&
JSON.parse(fs.readFileSync(manifestPath, "utf8")).id) ||
path.basename(pluginDir);
const setupFile = fileURLToPath(
new URL("./testing/setup.js", import.meta.url),
);