mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
+3
-6
@@ -13,12 +13,9 @@ dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# tsc -p tsconfig.plugins.json emits plugin backend .js next to its .ts
|
||||
# source. It also stray-emits sibling .js next to every core .ts the plugin
|
||||
# imports for type-checking (src/types, src/backend/utils, repositories...);
|
||||
# scripts/copy-bundled-plugins.cjs removes those after every compile, this
|
||||
# is a safety net for running tsc -p tsconfig.plugins.json directly.
|
||||
/plugins/*/backend/**/*.js
|
||||
# Plugin build output. termix-plugin build writes it, the packaged server
|
||||
# gets its copy under dist/plugins.
|
||||
/plugins/*/dist/
|
||||
/src/**/*.js
|
||||
|
||||
.vscode/*
|
||||
|
||||
@@ -4,3 +4,5 @@ api_token: "env:CROWDIN_API_TOKEN"
|
||||
files:
|
||||
- source: /src/ui/locales/en.json
|
||||
translation: /src/ui/locales/translated/%locale_with_underscore%.json
|
||||
- source: /plugins/*/locales/en.json
|
||||
translation: /plugins/**/locales/translated/%locale_with_underscore%.json
|
||||
+23
-3
@@ -119,7 +119,7 @@ export default tseslint.config([
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ["**/plugins/*/backend/**"],
|
||||
group: ["**/plugins/*/src/backend/**"],
|
||||
message:
|
||||
"Core must not import a plugin backend. Register a dispatcher or an SDK service instead, so disabling the plugin degrades cleanly.",
|
||||
},
|
||||
@@ -141,7 +141,7 @@ export default tseslint.config([
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ["**/plugins/*/frontend/**"],
|
||||
group: ["**/plugins/*/src/frontend/**"],
|
||||
message:
|
||||
"The shell should not import plugin components directly. A7 replaces these with registrations through the app object.",
|
||||
},
|
||||
@@ -164,7 +164,7 @@ export default tseslint.config([
|
||||
//
|
||||
// What it already catches: a plugin backend importing the frontend tree,
|
||||
// which is not debt in any direction, just a mistake.
|
||||
files: ["plugins/**/backend/**/*.{ts,mjs}"],
|
||||
files: ["plugins/*/src/backend/**/*.{ts,mjs}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
@@ -178,6 +178,26 @@ export default tseslint.config([
|
||||
],
|
||||
},
|
||||
],
|
||||
// A second rule name rather than a second pattern, because flat config
|
||||
// replaces a rule's options rather than merging them: one rule cannot
|
||||
// carry both severities, and folding this into the error above would
|
||||
// fail the build on debt that has nowhere to go yet.
|
||||
//
|
||||
// scripts/plugin-boundary-allowlist.json holds today's offenders and
|
||||
// scripts/check-plugin-boundaries.cjs fails on a new one. D1 empties
|
||||
// the list and this becomes an error.
|
||||
"@typescript-eslint/no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ["**/src/backend/**", "**/src/types/**"],
|
||||
message:
|
||||
"A plugin should reach core through @termix/plugin-sdk. This relative import is legacy debt tracked in scripts/plugin-boundary-allowlist.json.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
Generated
+140
-35
@@ -9,7 +9,8 @@
|
||||
"version": "2.9.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
"packages/*",
|
||||
"plugins/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.120.0",
|
||||
@@ -1907,7 +1908,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1924,7 +1924,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1941,7 +1940,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1958,7 +1956,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1975,7 +1972,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1992,7 +1988,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2009,7 +2004,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2026,7 +2020,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2043,7 +2036,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2060,7 +2052,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2077,7 +2068,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2094,7 +2084,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2111,7 +2100,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2128,7 +2116,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2145,7 +2132,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2162,7 +2148,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2179,7 +2164,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2196,7 +2180,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2213,7 +2196,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2230,7 +2212,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2247,7 +2228,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2264,7 +2244,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2281,7 +2260,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2298,7 +2276,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2315,7 +2292,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2332,7 +2308,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3166,7 +3141,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
@@ -7005,6 +6979,54 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@termix-plugin/ai": {
|
||||
"resolved": "plugins/ai",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/automations": {
|
||||
"resolved": "plugins/automations",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/docker": {
|
||||
"resolved": "plugins/docker",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/fleets": {
|
||||
"resolved": "plugins/fleets",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/host-metrics": {
|
||||
"resolved": "plugins/host-metrics",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/network-topology": {
|
||||
"resolved": "plugins/network-topology",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/proxmox": {
|
||||
"resolved": "plugins/proxmox",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/remote-desktop": {
|
||||
"resolved": "plugins/remote-desktop",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/ssh-terminal": {
|
||||
"resolved": "plugins/ssh-terminal",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/tailscale": {
|
||||
"resolved": "plugins/tailscale",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/web-endpoint": {
|
||||
"resolved": "plugins/web-endpoint",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/workspaces": {
|
||||
"resolved": "plugins/workspaces",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix/plugin-sdk": {
|
||||
"resolved": "packages/plugin-sdk",
|
||||
"link": true
|
||||
@@ -9148,7 +9170,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -10618,7 +10639,6 @@
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -14155,7 +14175,6 @@
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -14165,7 +14184,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
@@ -16998,7 +17016,6 @@
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
@@ -17015,7 +17032,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -18489,7 +18505,96 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3"
|
||||
"esbuild": "^0.28.1",
|
||||
"semver": "^7.6.3",
|
||||
"tar": "^7.5.22"
|
||||
},
|
||||
"bin": {
|
||||
"termix-plugin": "cli/index.mjs"
|
||||
}
|
||||
},
|
||||
"plugins/ai": {
|
||||
"name": "@termix-plugin/ai",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/automations": {
|
||||
"name": "@termix-plugin/automations",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/docker": {
|
||||
"name": "@termix-plugin/docker",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/fleets": {
|
||||
"name": "@termix-plugin/fleets",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/host-metrics": {
|
||||
"name": "@termix-plugin/host-metrics",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/network-topology": {
|
||||
"name": "@termix-plugin/network-topology",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/proxmox": {
|
||||
"name": "@termix-plugin/proxmox",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/remote-desktop": {
|
||||
"name": "@termix-plugin/remote-desktop",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/ssh-terminal": {
|
||||
"name": "@termix-plugin/ssh-terminal",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/tailscale": {
|
||||
"name": "@termix-plugin/tailscale",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/web-endpoint": {
|
||||
"name": "@termix-plugin/web-endpoint",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
},
|
||||
"plugins/workspaces": {
|
||||
"name": "@termix-plugin/workspaces",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -2,7 +2,8 @@
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
"packages/*",
|
||||
"plugins/*"
|
||||
],
|
||||
"version": "2.9.0",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
@@ -18,7 +19,7 @@
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-xterm-android-ime.cjs && node scripts/patch-ssh2-agent.cjs",
|
||||
"prepare": "husky || true",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint .",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint . && node scripts/check-plugin-boundaries.cjs",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "tsc -b --force",
|
||||
"test": "vitest run",
|
||||
@@ -27,9 +28,9 @@
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"dev": "vite",
|
||||
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs",
|
||||
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs",
|
||||
"dev:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs && node ./dist/backend/backend/starter.js",
|
||||
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"dev:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins && node ./dist/backend/backend/starter.js",
|
||||
"dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
|
||||
"dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
|
||||
"generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js",
|
||||
@@ -48,7 +49,10 @@
|
||||
"schema:check": "node scripts/generate-dialect-schema.cjs --check",
|
||||
"schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts",
|
||||
"plugin:validate-manifest": "node scripts/validate-plugin-manifest.cjs",
|
||||
"build:sdk": "npm run build --workspace @termix/plugin-sdk"
|
||||
"build:sdk": "npm run build --workspace @termix/plugin-sdk",
|
||||
"build:plugins": "node scripts/build-plugins.cjs",
|
||||
"test:plugins": "node scripts/run-plugin-tests.cjs",
|
||||
"test:all": "npm run test && npm run test:plugins"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.120.0",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -0,0 +1,22 @@
|
||||
# AI Assistant
|
||||
|
||||
An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and propose changes for you to approve.
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
src/backend/ activate(ctx) / deactivate(), plus the routes
|
||||
src/frontend/ the UI it registers
|
||||
locales/en.json its strings (English only; the rest are translated)
|
||||
tests/backend/ vitest, node
|
||||
tests/frontend/ vitest, jsdom
|
||||
|
||||
## Commands
|
||||
|
||||
npm run build bundle into dist/
|
||||
npm run test run this plugin's tests
|
||||
npm run typecheck
|
||||
|
||||
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* AI Assistant - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker for the same
|
||||
* reason ssh-terminal, docker and host-metrics do (see
|
||||
* src/backend/plugins/first-party.ts): it writes directly across hosts,
|
||||
* snippets, fleets and automations through their repositories,
|
||||
* runs approved commands over the shared SSH pool via resolveHostById and
|
||||
* withConnection, and streams its chat replies as server-sent events. None
|
||||
* of that fits the worker ctx -- ctx.hosts is a 13-field read-only view with
|
||||
* no write methods and no snippets/fleets/automations surface at all,
|
||||
* and ctx.http.route replies with a single postMessage value rather than a
|
||||
* stream.
|
||||
*
|
||||
* Unlike ssh-terminal/docker/host-metrics, this plugin does not own a live
|
||||
* transport (no WebSocket server, no long-lived connection that must outlive
|
||||
* the main server's route table), so it does not get its own port. Instead
|
||||
* it registers its Express router with the always-mounted /ai dispatcher in
|
||||
* src/backend/database/routes/ai-dispatch.ts, the same "register a router,
|
||||
* a small dispatcher forwards to it" shape /plugin-api uses for worker
|
||||
* plugins, just scoped to this one path so /ai/* keeps its existing routes
|
||||
* instead of moving under /plugin-api/ai/.
|
||||
*
|
||||
* routes.ts is what used to be src/backend/ai/index.ts, moved here with its
|
||||
* sibling modules (context.ts, egress.ts, engine.ts, gating.ts, redaction.ts,
|
||||
* providers/, tools/) and rewritten from `export default router` into
|
||||
* startAiService()/stopAiService(), called from activate()/deactivate()
|
||||
* below.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Loads a module by its path relative to this file's own directory.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||
* .js for the moved AI files (routes.js, ...) sits right next to this .mjs,
|
||||
* and core modules resolve via ../../../backend/backend/... from there
|
||||
* (dist/plugins/ai/backend -> dist/backend/backend). Under vitest/dev nothing
|
||||
* imports this plugin directly (the loader loads in-process plugins with a
|
||||
* plain dynamic import, no tsx loader involved), so the only layout that
|
||||
* needs to resolve here is the built one.
|
||||
*/
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let routesModule = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
routesModule = await loadRelative("routes");
|
||||
routesModule.startAiService();
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
// stays ours. Rotating or deleting the provider takes effect on the next
|
||||
// read by any borrower, and deactivate() below withdraws the offer entirely.
|
||||
// The resolve is per-user because the keys are stored per-user and encrypted
|
||||
// under that user's data key, so a borrowed read returns the acting user's
|
||||
// own default key, never someone else's.
|
||||
ctx.secrets.offer("api-key", async (userId) => {
|
||||
const { createCurrentAiRepository } = await loadRelative(
|
||||
"../../../backend/backend/database/repositories/factory",
|
||||
);
|
||||
const repository = createCurrentAiRepository();
|
||||
const providers = await repository.listProviders(userId);
|
||||
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
||||
if (!preferred) return null;
|
||||
|
||||
const withSecret = await repository.findProviderWithSecret(
|
||||
preferred.id,
|
||||
userId,
|
||||
);
|
||||
return withSecret?.apiKey ?? null;
|
||||
});
|
||||
|
||||
ctx.log.info("AI assistant router registered at /ai");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (routesModule) {
|
||||
routesModule.stopAiService();
|
||||
routesModule = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -69,8 +69,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"backend": "backend/index.mjs",
|
||||
"frontend": "frontend/index.mjs",
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@termix-plugin/ai",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
import { isIP } from "net";
|
||||
import { isBlockedAddress } from "../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { isBlockedAddress } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Where the assistant is allowed to send requests.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import { getAdapter } from "./providers/registry.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { NextFunction, Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Three gates, all checked on the server.
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { startAiService, stopAiService } from "./routes.js";
|
||||
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
startAiService();
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
// stays ours. Rotating or deleting the provider takes effect on the next
|
||||
// read by any borrower, and deactivate() below withdraws the offer entirely.
|
||||
// The resolve is per-user because the keys are stored per-user and encrypted
|
||||
// under that user's data key, so a borrowed read returns the acting user's
|
||||
// own default key, never someone else's.
|
||||
ctx.secrets.offer("api-key", async (userId: string) => {
|
||||
const repository = createCurrentAiRepository();
|
||||
const providers = await repository.listProviders(userId);
|
||||
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
||||
if (!preferred) return null;
|
||||
|
||||
const withSecret = await repository.findProviderWithSecret(
|
||||
preferred.id,
|
||||
userId,
|
||||
);
|
||||
return withSecret?.apiKey ?? null;
|
||||
});
|
||||
|
||||
ctx.log.info("AI assistant router registered at /ai");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopAiService();
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { fetchWithProxy } from "../../../../../src/backend/utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,25 +1,25 @@
|
||||
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import express from "express";
|
||||
import {
|
||||
registerAiRouter,
|
||||
unregisterAiRouter,
|
||||
} from "../../../src/backend/database/routes/ai-dispatch.js";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../../src/backend/utils/logger.js";
|
||||
} from "../../../../src/backend/database/routes/ai-dispatch.js";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../../../src/backend/utils/audit-logger.js";
|
||||
} from "../../../../src/backend/utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentAiRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../../../src/backend/database/repositories/factory.js";
|
||||
import type { UserRecord } from "../../../src/backend/database/repositories/user-repository.js";
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { UserRecord } from "../../../../src/backend/database/repositories/user-repository.js";
|
||||
import { buildSystemPrompt } from "./context.js";
|
||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "./egress.js";
|
||||
import { runAgent } from "./engine.js";
|
||||
File renamed without changes.
File renamed without changes.
@@ -3,15 +3,15 @@ import {
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../automations/backend/routes.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { getTool } from "./catalog.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
File renamed without changes.
@@ -8,7 +8,7 @@ import {
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentWorkspaceRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
|
||||
/**
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSystemPrompt } from "../../../../../plugins/ai/backend/context.js";
|
||||
import { buildSystemPrompt } from "../../src/backend/context.js";
|
||||
|
||||
const BASE = { hostCount: 3, allowReadOnlyCommands: false };
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
evaluateEgress,
|
||||
isPrivateDestination,
|
||||
parseAllowlist,
|
||||
} from "../../../../../plugins/ai/backend/egress.js";
|
||||
} from "../../src/backend/egress.js";
|
||||
|
||||
/**
|
||||
* The rule that lets a self-hosted Ollama work without turning the backend
|
||||
+4
-5
@@ -1,14 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../../../../plugins/ai/backend/providers/types.js";
|
||||
import type { ChatChunk } from "../../src/backend/providers/types.js";
|
||||
|
||||
const streamChat = vi.fn();
|
||||
const handler = vi.fn();
|
||||
|
||||
vi.mock("../../../../../plugins/ai/backend/providers/registry.js", () => ({
|
||||
vi.mock("../../src/backend/providers/registry.js", () => ({
|
||||
getAdapter: () => ({ streamChat, listModels: async () => [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../../plugins/ai/backend/tools/catalog.js", () => ({
|
||||
vi.mock("../../src/backend/tools/catalog.js", () => ({
|
||||
getTool: (name: string) =>
|
||||
name === "list_hosts"
|
||||
? {
|
||||
@@ -24,8 +24,7 @@ vi.mock("../../../../../plugins/ai/backend/tools/catalog.js", () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
const { runAgent } =
|
||||
await import("../../../../../plugins/ai/backend/engine.js");
|
||||
const { runAgent } = await import("../../src/backend/engine.js");
|
||||
|
||||
function chunks(...values: ChatChunk[]) {
|
||||
return (async function* () {
|
||||
+2
-2
@@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const settingsRepository = { getBoolean: vi.fn() };
|
||||
const userPreferenceRepository = { findByUserId: vi.fn() };
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => settingsRepository,
|
||||
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
|
||||
}));
|
||||
|
||||
const { isAiGloballyEnabled, resolveAiAccess } =
|
||||
await import("../../../../../plugins/ai/backend/gating.js");
|
||||
await import("../../src/backend/gating.js");
|
||||
|
||||
describe("AI gating", () => {
|
||||
beforeEach(() => {
|
||||
+4
-4
@@ -7,20 +7,20 @@ const readPrivateAllowlist = vi.fn();
|
||||
const evaluateEgress = vi.fn();
|
||||
const globalFetch = vi.fn();
|
||||
|
||||
vi.mock("../../../../utils/proxy-agent.js", () => ({
|
||||
vi.mock("../../../../../src/backend/utils/proxy-agent.js", () => ({
|
||||
fetchWithProxy,
|
||||
getFetchDispatcher,
|
||||
}));
|
||||
vi.mock("../../../../utils/safe-outbound-fetch.js", () => ({
|
||||
vi.mock("../../../../../src/backend/utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch,
|
||||
}));
|
||||
vi.mock("../../../../../../plugins/ai/backend/egress.js", () => ({
|
||||
vi.mock("../../../src/backend/egress.js", () => ({
|
||||
readPrivateAllowlist,
|
||||
evaluateEgress,
|
||||
}));
|
||||
|
||||
const { providerFetch } =
|
||||
await import("../../../../../../plugins/ai/backend/providers/http.js");
|
||||
await import("../../../src/backend/providers/http.js");
|
||||
|
||||
describe("providerFetch", () => {
|
||||
beforeEach(() => {
|
||||
+7
-7
@@ -1,21 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../../../../../plugins/ai/backend/providers/types.js";
|
||||
import type { ChatChunk } from "../../../src/backend/providers/types.js";
|
||||
|
||||
const providerFetch = vi.fn();
|
||||
|
||||
vi.mock("../../../../../../plugins/ai/backend/providers/http.js", async () => {
|
||||
vi.mock("../../../src/backend/providers/http.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../../../../../plugins/ai/backend/providers/http.js")
|
||||
>("../../../../../../plugins/ai/backend/providers/http.js");
|
||||
typeof import("../../../src/backend/providers/http.js")
|
||||
>("../../../src/backend/providers/http.js");
|
||||
return { ...actual, providerFetch };
|
||||
});
|
||||
|
||||
const { openAiAdapter } =
|
||||
await import("../../../../../../plugins/ai/backend/providers/openai.js");
|
||||
await import("../../../src/backend/providers/openai.js");
|
||||
const { ollamaAdapter } =
|
||||
await import("../../../../../../plugins/ai/backend/providers/ollama.js");
|
||||
await import("../../../src/backend/providers/ollama.js");
|
||||
const { geminiAdapter } =
|
||||
await import("../../../../../../plugins/ai/backend/providers/gemini.js");
|
||||
await import("../../../src/backend/providers/gemini.js");
|
||||
|
||||
/** Builds a Response whose body streams the given text chunks. */
|
||||
function streamingResponse(lines: string[]): Response {
|
||||
+1
-5
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
REDACTED,
|
||||
redact,
|
||||
redactString,
|
||||
} from "../../../../../plugins/ai/backend/redaction.js";
|
||||
import { REDACTED, redact, redactString } from "../../src/backend/redaction.js";
|
||||
|
||||
describe("redact", () => {
|
||||
it("drops secret-named fields at any depth", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isReadOnlyCommand } from "../../../../../../plugins/ai/backend/tools/command-allowlist.js";
|
||||
import { isReadOnlyCommand } from "../../../src/backend/tools/command-allowlist.js";
|
||||
|
||||
/**
|
||||
* These commands can run without a per-command approval click, so the parser
|
||||
+11
-8
@@ -15,7 +15,7 @@ const snippetRepository = {
|
||||
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
|
||||
const automationRepository = { create: vi.fn() };
|
||||
|
||||
vi.mock("../../../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentFleetRepository: () => fleetRepository,
|
||||
@@ -23,19 +23,22 @@ vi.mock("../../../../database/repositories/factory.js", () => ({
|
||||
}));
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
vi.mock("../../../../hosts/host-resolver.js", () => ({
|
||||
vi.mock("../../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
vi.mock("../../../../hosts/ssh-client-factory.js", () => ({
|
||||
vi.mock(
|
||||
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
|
||||
() => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}),
|
||||
);
|
||||
vi.mock("../../../../../src/backend/hosts/ssh-client-factory.js", () => ({
|
||||
createFleetSshFactory: () => () => ({}),
|
||||
getFleetPoolKey: () => "pool",
|
||||
}));
|
||||
vi.mock("../../../../hosts/ssh-connection-pool.js", () => ({
|
||||
vi.mock("../../../../../src/backend/hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
_factory: unknown,
|
||||
@@ -44,7 +47,7 @@ vi.mock("../../../../hosts/ssh-connection-pool.js", () => ({
|
||||
}));
|
||||
|
||||
const { applyProposal } =
|
||||
await import("../../../../../../plugins/ai/backend/tools/executor.js");
|
||||
await import("../../../src/backend/tools/executor.js");
|
||||
|
||||
describe("applyProposal", () => {
|
||||
beforeEach(() => {
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
getTool,
|
||||
listToolNames,
|
||||
toolDefinitions,
|
||||
} from "../../../../../../plugins/ai/backend/tools/catalog.js";
|
||||
} from "../../../src/backend/tools/catalog.js";
|
||||
|
||||
/**
|
||||
* The security regression test for the whole feature.
|
||||
File renamed without changes.
@@ -5,7 +5,7 @@ import {
|
||||
mentionLabel,
|
||||
toolLabel,
|
||||
} from "@/features/ai/labels";
|
||||
import { AI_TOOLS } from "../../../../../plugins/ai/backend/tools/catalog";
|
||||
import { AI_TOOLS } from "../../src/backend/tools/catalog.js";
|
||||
|
||||
describe("humanize", () => {
|
||||
it("splits camelCase into words", () => {
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Automations
|
||||
|
||||
Trigger-and-action automation engine: schedules, metric/status/docker-event triggers, and multi-step actions across managed hosts.
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
src/backend/ activate(ctx) / deactivate(), plus the routes
|
||||
src/frontend/ the UI it registers
|
||||
locales/en.json its strings (English only; the rest are translated)
|
||||
tests/backend/ vitest, node
|
||||
tests/frontend/ vitest, jsdom
|
||||
|
||||
## Commands
|
||||
|
||||
npm run build bundle into dist/
|
||||
npm run test run this plugin's tests
|
||||
npm run typecheck
|
||||
|
||||
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Automations - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker for the same
|
||||
* reason ai and fleets do (see src/backend/plugins/first-party.ts): its
|
||||
* engine and scheduler write across hosts, snippets, fleets, and
|
||||
* notification-channel repositories through the shared SSH pool via
|
||||
* resolveHostById and withConnection, and its scheduler needs direct
|
||||
* repository access plus event-bus subscription that the worker ctx's
|
||||
* read-only ctx.hosts and single-postMessage ctx.http.route cannot support.
|
||||
*
|
||||
* Like fleets, this plugin does not own a live transport (no WebSocket
|
||||
* server, no dedicated port), so it registers its Express router with the
|
||||
* always-mounted /automations dispatcher in
|
||||
* src/backend/database/routes/automation-dispatch.ts instead of getting its
|
||||
* own port.
|
||||
*
|
||||
* routes.ts is what used to be src/backend/database/routes/automations.ts,
|
||||
* moved here and rewritten from `export default router` into
|
||||
* startAutomationsService()/stopAutomationsService(), called from
|
||||
* activate()/deactivate() below. scheduler.ts owns the one setInterval the
|
||||
* feature has always used for due schedules, dwell rechecks, docker-event
|
||||
* polling and history pruning -- there is no ctx.schedule API for an
|
||||
* in-process plugin, so it starts and stops the same way it always has. The
|
||||
* automations/automationTriggerState/automationSchedules/automationRuns/
|
||||
* automationRunSteps/automationChannels tables and their repository stay in
|
||||
* core (src/backend/database/db/schema.ts,
|
||||
* src/backend/database/repositories/automation-repository.ts) -- this plugin
|
||||
* system has no owned-table migration mechanism of its own, and these tables
|
||||
* carry FKs into core's hosts/users tables.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Loads a module by its path relative to this file's own directory.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||
* .js for routes.js/scheduler.js sits right next to this .mjs, and core
|
||||
* modules resolve via ../../../src/backend/... from there. Under
|
||||
* vitest/dev nothing imports this plugin directly (the loader loads
|
||||
* in-process plugins with a plain dynamic import, no tsx loader involved),
|
||||
* so the only layout that needs to resolve here is the built one.
|
||||
*/
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let routesModule = null;
|
||||
let schedulerModule = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
routesModule = await loadRelative("routes");
|
||||
routesModule.startAutomationsService();
|
||||
|
||||
schedulerModule = await loadRelative("scheduler");
|
||||
schedulerModule.startAutomationScheduler();
|
||||
|
||||
ctx.log.info(
|
||||
"Automations router registered at /automations; scheduler started",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (schedulerModule) {
|
||||
schedulerModule.stopAutomationScheduler();
|
||||
schedulerModule = null;
|
||||
}
|
||||
if (routesModule) {
|
||||
routesModule.stopAutomationsService();
|
||||
routesModule = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -25,8 +25,6 @@
|
||||
"network:outbound",
|
||||
"ui:surface"
|
||||
],
|
||||
"backend": "backend/index.mjs",
|
||||
"frontend": "frontend/index.mjs",
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@termix-plugin/automations",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
import type { HostSelector } from "../../../../src/types/automations.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { createCurrentFleetRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||
import type { HostSelector } from "../../../../../src/types/automations.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { createCurrentFleetRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js";
|
||||
import type { StepExecutionContext } from "./types.js";
|
||||
|
||||
/** A host the caller is allowed to act on. `host` is always resolved. */
|
||||
@@ -75,7 +75,7 @@ async function fleetHostIds(
|
||||
async function allAccessibleHostIds(userId: string): Promise<number[]> {
|
||||
try {
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../../../src/backend/database/repositories/factory.js");
|
||||
await import("../../../../../src/backend/database/repositories/factory.js");
|
||||
const hosts = await createCurrentHostRepository().listByUserId(userId);
|
||||
const ids = hosts.map((host) => host.id);
|
||||
const allowed =
|
||||
+9
-9
@@ -1,22 +1,22 @@
|
||||
import {
|
||||
DEFAULT_STEP_TIMEOUT_MS,
|
||||
type Step,
|
||||
} from "../../../../src/types/automations.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "../../../../../src/types/automations.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js";
|
||||
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js";
|
||||
import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { sendAutomationNotification } from "../notify.js";
|
||||
import { automationFetch } from "../http.js";
|
||||
import { renderRecord, renderTemplate } from "../template.js";
|
||||
@@ -248,7 +248,7 @@ async function runTunnel(
|
||||
|
||||
try {
|
||||
const manager =
|
||||
await import("../../../../src/backend/hosts/tunnel/manager.js");
|
||||
await import("../../../../../src/backend/hosts/tunnel/manager.js");
|
||||
const config = manager.tunnelConfigs?.get(name);
|
||||
if (!config) return fail(`Tunnel "${name}" is not configured`);
|
||||
|
||||
@@ -284,7 +284,7 @@ async function runWol(
|
||||
|
||||
try {
|
||||
const { sendWakeOnLan, isValidMac } =
|
||||
await import("../../../../src/backend/utils/wake-on-lan.js");
|
||||
await import("../../../../../src/backend/utils/wake-on-lan.js");
|
||||
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
|
||||
await sendWakeOnLan(mac);
|
||||
return ok(`Sent magic packet to ${target.name}`);
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { Step } from "../../../../src/types/automations.js";
|
||||
import type { Step } from "../../../../../src/types/automations.js";
|
||||
import type { TemplateContext } from "../template.js";
|
||||
|
||||
/** What every executor returns. `output` is what later steps can read. */
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
import type { MetricPath, Operator } from "../../../src/types/automations.js";
|
||||
import type {
|
||||
MetricPath,
|
||||
Operator,
|
||||
} from "../../../../src/types/automations.js";
|
||||
|
||||
/**
|
||||
* Operator evaluation and metric extraction.
|
||||
File renamed without changes.
+8
-8
@@ -1,14 +1,14 @@
|
||||
import type { AutomationDefinition } from "../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
|
||||
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
|
||||
import { execCommand } from "../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import type { AutomationDefinition } from "../../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||
} from "../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { onDockerEvent } from "./triggers.js";
|
||||
|
||||
/**
|
||||
@@ -2,16 +2,16 @@ import type {
|
||||
AutomationDefinition,
|
||||
RunStatus,
|
||||
Step,
|
||||
} from "../../../src/types/automations.js";
|
||||
} from "../../../../src/types/automations.js";
|
||||
import {
|
||||
DEFAULT_MAX_RUN_SECONDS,
|
||||
MAX_AUTOMATION_DEPTH,
|
||||
MAX_STEP_OUTPUT_BYTES,
|
||||
} from "../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
|
||||
import { notifyAutomationInternalEvent } from "../../../src/backend/hosts/automation-events.js";
|
||||
} from "../../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js";
|
||||
import { executeStep } from "./actions/index.js";
|
||||
import type { StepExecutionContext, StepResult } from "./actions/types.js";
|
||||
import { compare } from "./conditions.js";
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { listAutomationWatchedHosts } from "./triggers.js";
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,5 @@
|
||||
import { safeOutboundFetch } from "../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { readNotificationPrivateAllowlist } from "../../../src/backend/utils/notification-egress.js";
|
||||
import { safeOutboundFetch } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { readNotificationPrivateAllowlist } from "../../../../src/backend/utils/notification-egress.js";
|
||||
|
||||
/**
|
||||
* Outbound HTTP for automation steps and notification channels.
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { startAutomationsService, stopAutomationsService } from "./routes.js";
|
||||
import {
|
||||
startAutomationScheduler,
|
||||
stopAutomationScheduler,
|
||||
} from "./scheduler.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
startAutomationsService();
|
||||
startAutomationScheduler();
|
||||
ctx.log.info(
|
||||
"Automations router registered at /automations; scheduler started",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopAutomationScheduler();
|
||||
stopAutomationsService();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { automationFetch } from "./http.js";
|
||||
import type { TemplateContext } from "./template.js";
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import crypto from "node:crypto";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
Trigger,
|
||||
} from "../../../src/types/automations.js";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "../../../src/types/automations.js";
|
||||
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../../src/backend/utils/logger.js";
|
||||
} from "../../../../src/types/automations.js";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "../../../../src/types/automations.js";
|
||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../../../src/backend/utils/audit-logger.js";
|
||||
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import type { AutomationRow } from "../../../src/backend/database/repositories/automation-repository.js";
|
||||
} from "../../../../src/backend/utils/audit-logger.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { AutomationRow } from "../../../../src/backend/database/repositories/automation-repository.js";
|
||||
import { AutomationEngine } from "./engine.js";
|
||||
import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js";
|
||||
import {
|
||||
registerAutomationsRouter,
|
||||
unregisterAutomationsRouter,
|
||||
} from "../../../src/backend/database/routes/automation-dispatch.js";
|
||||
} from "../../../../src/backend/database/routes/automation-dispatch.js";
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import type { AutomationDefinition } from "../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
|
||||
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||
import type { AutomationDefinition } from "../../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { computeNextDueAt } from "./cron.js";
|
||||
import { hasDwelled, isCoolingDown } from "./conditions.js";
|
||||
import { pollDockerEvents } from "./docker-watcher.js";
|
||||
File renamed without changes.
+8
-5
@@ -2,10 +2,10 @@ import type {
|
||||
AutomationDefinition,
|
||||
HostSelector,
|
||||
Trigger,
|
||||
} from "../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import type { AutomationEngineRow } from "../../../src/backend/database/repositories/automation-repository.js";
|
||||
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||
} from "../../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { AutomationEngineRow } from "../../../../src/backend/database/repositories/automation-repository.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
compare,
|
||||
extractMetricValue,
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
type MetricsSnapshot,
|
||||
} from "./conditions.js";
|
||||
import { AutomationEngine } from "./engine.js";
|
||||
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
|
||||
import {
|
||||
pluginEvents,
|
||||
TOPICS,
|
||||
} from "../../../../src/backend/plugins/events.js";
|
||||
|
||||
/**
|
||||
* Matches events against automation triggers and decides what fires.
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
metricStateKey,
|
||||
severityForValue,
|
||||
type MetricsSnapshot,
|
||||
} from "../../../../../plugins/automations/backend/conditions.js";
|
||||
} from "../../src/backend/conditions.js";
|
||||
|
||||
const metrics: MetricsSnapshot = {
|
||||
cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] },
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
isValidTimezone,
|
||||
nextCronRun,
|
||||
parseCron,
|
||||
} from "../../../../../plugins/automations/backend/cron.js";
|
||||
} from "../../src/backend/cron.js";
|
||||
|
||||
describe("parseCron", () => {
|
||||
it("rejects anything that is not five fields", () => {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
} from "../../../../../plugins/automations/backend/docker-watcher.js";
|
||||
} from "../../src/backend/docker-watcher.js";
|
||||
|
||||
/**
|
||||
* The polling side needs SSH, so what is tested here is the pure part: turning
|
||||
+5
-6
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
} from "../../../../types/automations.js";
|
||||
} from "../../../../src/types/automations.js";
|
||||
|
||||
/**
|
||||
* The engine reaches the database through the repository factory and the
|
||||
@@ -52,21 +52,20 @@ const repository = {
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
|
||||
vi.mock("../../../hosts/host-resolver.js", () => ({
|
||||
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const executeStep = vi.fn();
|
||||
vi.mock("../../../../../plugins/automations/backend/actions/index.js", () => ({
|
||||
vi.mock("../../src/backend/actions/index.js", () => ({
|
||||
executeStep: (...args: unknown[]) => executeStep(...args),
|
||||
}));
|
||||
|
||||
const { AutomationEngine } =
|
||||
await import("../../../../../plugins/automations/backend/engine.js");
|
||||
const { AutomationEngine } = await import("../../src/backend/engine.js");
|
||||
|
||||
function defineAutomation(
|
||||
steps: Step[],
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listAutomationWatchedHosts = vi.fn();
|
||||
vi.mock("../../../../../plugins/automations/backend/triggers.js", () => ({
|
||||
vi.mock("../../src/backend/triggers.js", () => ({
|
||||
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
|
||||
}));
|
||||
|
||||
@@ -10,8 +10,7 @@ const {
|
||||
reconcileHeadlessViewers,
|
||||
releaseHeadlessViewers,
|
||||
setViewerRegistry,
|
||||
} =
|
||||
await import("../../../../../plugins/automations/backend/headless-viewer.js");
|
||||
} = await import("../../src/backend/headless-viewer.js");
|
||||
|
||||
const registry = {
|
||||
registerViewer: vi.fn(),
|
||||
+2
-2
@@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const automationFetch = vi.fn();
|
||||
|
||||
vi.mock("../../../../../plugins/automations/backend/http.js", () => ({
|
||||
vi.mock("../../src/backend/http.js", () => ({
|
||||
automationFetch: (...args: unknown[]) => automationFetch(...args),
|
||||
}));
|
||||
|
||||
const { sendAutomationNotification } =
|
||||
await import("../../../../../plugins/automations/backend/notify.js");
|
||||
await import("../../src/backend/notify.js");
|
||||
|
||||
beforeEach(() => {
|
||||
automationFetch.mockReset();
|
||||
+16
-14
@@ -1,7 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { AutomationDefinition } from "../../../../types/automations.js";
|
||||
import type { AutomationDefinition } from "../../../../src/types/automations.js";
|
||||
|
||||
/**
|
||||
* Route-level behaviour: validation, ownership and webhook token handling. The repository and engine are mocked; what matters here is what
|
||||
@@ -14,9 +14,9 @@ const state = vi.hoisted(() => ({
|
||||
nextId: 1,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
||||
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
vi.mock("../../../../src/backend/utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
@@ -39,21 +39,24 @@ const repository = vi.hoisted(() => ({
|
||||
deleteSchedule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const run = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({
|
||||
vi.mock("../../src/backend/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/automation-dispatch.js", () => ({
|
||||
registerAutomationsRouter: vi.fn(),
|
||||
unregisterAutomationsRouter: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"../../../../src/backend/database/routes/automation-dispatch.js",
|
||||
() => ({
|
||||
registerAutomationsRouter: vi.fn(),
|
||||
unregisterAutomationsRouter: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
requirePermission:
|
||||
@@ -63,7 +66,7 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
@@ -79,14 +82,13 @@ vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
vi.mock("../../../../src/backend/utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => undefined),
|
||||
getAuditUsername: vi.fn(async () => "alice"),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const { router } =
|
||||
await import("../../../../../plugins/automations/backend/routes.js");
|
||||
const { router } = await import("../../src/backend/routes.js");
|
||||
|
||||
/** Runs the whole middleware chain for the route, not just its handler. */
|
||||
async function invoke(
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
redactSecrets,
|
||||
renderRecord,
|
||||
renderTemplate,
|
||||
} from "../../../../../plugins/automations/backend/template.js";
|
||||
} from "../../src/backend/template.js";
|
||||
|
||||
const context = {
|
||||
host: { id: 7, name: "Zeus", ip: "10.0.0.5", username: "root", port: 22 },
|
||||
+4
-5
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Trigger,
|
||||
} from "../../../../types/automations.js";
|
||||
} from "../../../../src/types/automations.js";
|
||||
|
||||
/**
|
||||
* Trigger matching, dwell windows and cooldowns. The repository and the engine
|
||||
@@ -42,17 +42,16 @@ const repository = {
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
|
||||
vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({
|
||||
vi.mock("../../src/backend/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
const triggers =
|
||||
await import("../../../../../plugins/automations/backend/triggers.js");
|
||||
const triggers = await import("../../src/backend/triggers.js");
|
||||
|
||||
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
|
||||
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultTrigger } from "../../../../../plugins/automations/frontend/automations/TriggerCard";
|
||||
import { emptyDraft } from "../../../../../plugins/automations/frontend/automations/AutomationEditor";
|
||||
import { newStepId } from "../../../../../plugins/automations/frontend/automations/editor-types";
|
||||
import { defaultTrigger } from "../../src/frontend/automations/TriggerCard";
|
||||
import { emptyDraft } from "../../src/frontend/automations/AutomationEditor";
|
||||
import { newStepId } from "../../src/frontend/automations/editor-types";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
|
||||
|
||||
describe("emptyDraft", () => {
|
||||
Loaded 100 of 448 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user