mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f332aa5c3 |
No files matched your search
@@ -41,15 +41,9 @@ on:
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
# One image per plugin profile (docker/bundled-plugins.json). standard keeps
|
||||
# the plain tags; minimal and full add a suffix.
|
||||
build:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile: [standard, minimal, full]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
@@ -86,17 +80,12 @@ jobs:
|
||||
TAGS=()
|
||||
ALL_TAGS=()
|
||||
|
||||
SUFFIX=""
|
||||
if [ "${{ matrix.profile }}" != "standard" ]; then
|
||||
SUFFIX="-${{ matrix.profile }}"
|
||||
fi
|
||||
|
||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||
TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX")
|
||||
TAGS+=("$VERSION" "latest")
|
||||
elif [ "$BUILD_TYPE" = "Beta" ]; then
|
||||
TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX")
|
||||
TAGS+=("beta" "$VERSION")
|
||||
else
|
||||
TAGS+=("dev-$VERSION$SUFFIX")
|
||||
TAGS+=("dev-$VERSION")
|
||||
fi
|
||||
|
||||
for tag in "${TAGS[@]}"; do
|
||||
@@ -135,7 +124,6 @@ jobs:
|
||||
tags: ${{ env.ALL_TAGS }}
|
||||
build-args: |
|
||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
PLUGIN_PROFILE=${{ matrix.profile }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
|
||||
|
||||
@@ -161,10 +161,9 @@ jobs:
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Translates new or changed strings with the Termix LLM server and pins the
|
||||
# bundled plugins to their newest registry releases, then commits both to the
|
||||
# dev branch so the beta or stable build includes them. If the server is down
|
||||
# it warns and the release goes on with what is there.
|
||||
# Translates new or changed strings with the Termix LLM server and commits
|
||||
# them to the dev branch, so the beta or stable build includes them. If the
|
||||
# server is down it warns and the release goes on with what is there.
|
||||
translate:
|
||||
needs: [prep]
|
||||
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||
@@ -192,26 +191,19 @@ jobs:
|
||||
languages: src/ui/locales/languages.json
|
||||
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
# Newest stable plugin release whose engine.termix allows this version.
|
||||
- name: Pin bundled plugins
|
||||
run: node scripts/pin-bundled-plugins.cjs --all --core-version "${{ needs.prep.outputs.version }}"
|
||||
|
||||
- name: Commit translations and plugin pins
|
||||
- name: Commit translations
|
||||
id: commit
|
||||
run: |
|
||||
git add src/ui/locales docker/bundled-plugins.json
|
||||
git add src/ui/locales
|
||||
if git diff --cached --quiet; then
|
||||
echo "No translation changes."
|
||||
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
|
||||
echo "DRY RUN: would commit these translation and pin changes:"
|
||||
echo "DRY RUN: would commit these translation changes:"
|
||||
git --no-pager diff --cached --stat
|
||||
else
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git commit -m "chore: update translations and plugin pins"
|
||||
git commit -m "chore: update translations"
|
||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1,38 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 26.10.6
|
||||
|
||||
### Added
|
||||
|
||||
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
|
||||
- Plugins can say how they show in the mobile app
|
||||
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
|
||||
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
|
||||
|
||||
### Fixed
|
||||
|
||||
- The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES
|
||||
- Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind
|
||||
|
||||
## 26.10.5
|
||||
|
||||
### Added
|
||||
|
||||
- Safe mode: start the server with TERMIX_SAFE_MODE=1 to run only layout and theme plugins, so a broken plugin can be turned off
|
||||
- TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS lets plugin registries live on a private address, like a registry in your LAN
|
||||
- Plugin registry browsing and downloads use HTTP_PROXY and HTTPS_PROXY when they are set
|
||||
- Docker image sizes: the default image now ships the most used plugins, the minimal tag ships only the look plugins and the full tag ships every official plugin
|
||||
- Plugins an older image shipped and you had on are downloaded from the registry on the first start of a smaller image, so nothing goes missing
|
||||
|
||||
### Changed
|
||||
|
||||
- Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row
|
||||
|
||||
### Fixed
|
||||
|
||||
- The macOS app asks for Local Network access, so it can reach servers on your LAN
|
||||
- The desktop app can change the connection origin of a host shared with you, instead of failing to save
|
||||
|
||||
## 26.10.4
|
||||
|
||||
### Fixed
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "26.10.5"
|
||||
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
|
||||
version "26.10.3"
|
||||
sha256 "a0c9db16f144c1e2be6ce2f4ead9268a010d12e0d0608feb34976750661a2239"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -93,7 +93,6 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [Services](https://github.com/Termix-SSH/Plugin-Service-Manager) | Manage services, timers and logs on your hosts over SSH. |
|
||||
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
|
||||
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
|
||||
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
|
||||
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
||||
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
||||
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
||||
|
||||
+4
-15
@@ -34,20 +34,9 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build:backend:core
|
||||
|
||||
# Stage 3b: Stage the bundled plugins for one profile (minimal, standard or
|
||||
# full, see docker/bundled-plugins.json). Only this stage and the final one
|
||||
# differ between profiles, so the other stages are built once.
|
||||
FROM deps AS plugins
|
||||
WORKDIR /app
|
||||
ARG PLUGIN_PROFILE=standard
|
||||
COPY scripts/build-plugins.cjs ./scripts/build-plugins.cjs
|
||||
COPY scripts/lib/bundled-plugins.cjs ./scripts/lib/bundled-plugins.cjs
|
||||
COPY docker/bundled-plugins.json ./docker/bundled-plugins.json
|
||||
# Bundled plugin downloads show in the plugin store, so keep them between builds.
|
||||
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
|
||||
node scripts/build-plugins.cjs --profile "$PLUGIN_PROFILE"
|
||||
npm run build:backend
|
||||
|
||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||
FROM node:24-slim AS opkssh-downloader
|
||||
@@ -128,9 +117,9 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html
|
||||
|
||||
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||
# Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved
|
||||
# as dist/plugins by getBundledPluginsDir().
|
||||
COPY --chown=node:node --from=plugins /app/dist/plugins ./dist/plugins
|
||||
# Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by
|
||||
# getBundledPluginsDir().
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
|
||||
# Target of node_modules/@termix-ssh/plugin-sdk, which core and the plugins import.
|
||||
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
|
||||
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
|
||||
|
||||
+61
-155
@@ -3,23 +3,20 @@
|
||||
{
|
||||
"id": "acme-ssl",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug",
|
||||
"sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
|
||||
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug",
|
||||
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.2.0/ai-1.2.0.tmxplug",
|
||||
"sha256": "3b486133af78ae5168e6f5b8a4c71f3db3403cfdae537ed1ca8d6cc16035d8dd"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
|
||||
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
|
||||
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -29,22 +26,19 @@
|
||||
"id": "audit-tools",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug",
|
||||
"sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b",
|
||||
"tier": "full"
|
||||
"sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b"
|
||||
},
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug",
|
||||
"sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.3/automations-1.0.3.tmxplug",
|
||||
"sha256": "b5e5b39a053010b72ad1ba03ea6bb05afbbb2b789a86e0e98634d9ada543d02d"
|
||||
},
|
||||
{
|
||||
"id": "classic-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
|
||||
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
|
||||
"tier": "minimal",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.1/classic-layout-1.0.1.tmxplug",
|
||||
"sha256": "6b2d9acd77ef4b33fa0761c1aea7ed3c6171c325ee8febcedff94b5b2bc7be34",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
@@ -52,36 +46,19 @@
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
|
||||
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
|
||||
"tier": "minimal",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "cloud-providers",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Cloud-Providers/releases/download/v1.1.0/cloud-providers-1.1.0.tmxplug",
|
||||
"sha256": "7f61eafc3b99bdf1328fffa95791db1d27f8e1b683e5a5925603b5840057a9c7",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "credential-tools",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
|
||||
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce",
|
||||
"tier": "standard"
|
||||
},
|
||||
{
|
||||
"id": "databases",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug",
|
||||
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8",
|
||||
"tier": "full"
|
||||
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug",
|
||||
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.1.0/docker-1.1.0.tmxplug",
|
||||
"sha256": "da32dfbe9d4cffec5d80c4a1037a79e7591a3d16c50f882db6a146322a50c2b7",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -90,9 +67,8 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
|
||||
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.0/file-manager-1.1.0.tmxplug",
|
||||
"sha256": "807e9ffa80d6054a5911522721a555e0daa3e50f6edffdebe97efe7c91aea7a2",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -102,22 +78,19 @@
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59",
|
||||
"tier": "full"
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
|
||||
},
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug",
|
||||
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00",
|
||||
"tier": "full"
|
||||
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00"
|
||||
},
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
|
||||
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.1.0/host-metrics-1.1.0.tmxplug",
|
||||
"sha256": "4f1d422ec1d0565c79f94fa814ee0864d556d7367793b11035e68239e49e297f",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -127,142 +100,93 @@
|
||||
"id": "host-status",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
|
||||
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
|
||||
"tier": "standard"
|
||||
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170"
|
||||
},
|
||||
{
|
||||
"id": "hosts-config",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug",
|
||||
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad",
|
||||
"tier": "full"
|
||||
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad"
|
||||
},
|
||||
{
|
||||
"id": "import-export",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
|
||||
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58",
|
||||
"tier": "standard"
|
||||
},
|
||||
{
|
||||
"id": "kubernetes",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Kubernetes/releases/download/v1.0.1/kubernetes-1.0.1.tmxplug",
|
||||
"sha256": "31d6b200579d326d07244712713ae291ff473270d44b478a884d07c863a394f0",
|
||||
"tier": "full"
|
||||
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58"
|
||||
},
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03",
|
||||
"tier": "full"
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
|
||||
},
|
||||
{
|
||||
"id": "login-security",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug",
|
||||
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa",
|
||||
"tier": "full"
|
||||
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56",
|
||||
"tier": "full"
|
||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219",
|
||||
"tier": "full"
|
||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
|
||||
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
|
||||
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug",
|
||||
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.0/remote-desktop-1.2.0.tmxplug",
|
||||
"sha256": "0de40896bfeb4b1a702c3ce815355b7b7b0bb4bfe424f33e905bb5a0209d02f6",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "reverse-proxy",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug",
|
||||
"sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "samba",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Samba/releases/download/v1.0.0/samba-1.0.0.tmxplug",
|
||||
"sha256": "6e131ccdca998a9d0802becdd439a88305b64fcd7f07335bec9c7ed6791c0f53",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134",
|
||||
"tier": "full"
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
|
||||
},
|
||||
{
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
|
||||
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "service-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug",
|
||||
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "session-recording",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug",
|
||||
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
|
||||
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
|
||||
},
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.1.0/session-sharing-1.1.0.tmxplug",
|
||||
"sha256": "2988b39221d93b3dbc5e2a81e104e5dc01ce264afb1e66df3407492658ab9fdc",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "simple-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Simple-Layout/releases/download/v1.0.1/simple-layout-1.0.1.tmxplug",
|
||||
"sha256": "838f08050335b44b62295b87006b50d1db1029699bfe6a35fa75808d4aea2cde",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
|
||||
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
|
||||
},
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug",
|
||||
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
|
||||
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -271,9 +195,8 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
|
||||
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.0/ssh-terminal-1.1.0.tmxplug",
|
||||
"sha256": "dacdbadb3c6a0d7014b1de4f62b499f80abf358c7799a6e6326674660b055e4a",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -282,44 +205,37 @@
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug",
|
||||
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
|
||||
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
"enabledByEnv": [
|
||||
"OIDC_CLIENT_ID"
|
||||
]
|
||||
"enabledByEnv": ["OIDC_CLIENT_ID"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "step-ca",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a",
|
||||
"tier": "full"
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
|
||||
},
|
||||
{
|
||||
"id": "sync",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
|
||||
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
|
||||
"tier": "standard"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.0/sync-1.0.0.tmxplug",
|
||||
"sha256": "12ced05523f7c1a9151b5e680726bd31f53888e8bb0047d7bd12a2570ea168bf"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug",
|
||||
"sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
|
||||
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
|
||||
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
@@ -329,22 +245,19 @@
|
||||
"id": "termix-identity",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
|
||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927",
|
||||
"tier": "full"
|
||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
|
||||
},
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug",
|
||||
"sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.4/tmux-monitor-1.0.4.tmxplug",
|
||||
"sha256": "42b8a2284e68a321c1736984f5c641b1857c87c6a65ce8cf694c6bc946de2444"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
|
||||
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -353,9 +266,8 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
|
||||
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.1.0/tunnels-1.1.0.tmxplug",
|
||||
"sha256": "b1e4f1b6dd2bbd15a9a240ae1c1d381111c3609258c8946d19493a83f861ea1e",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -365,36 +277,31 @@
|
||||
"id": "vault",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
|
||||
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28",
|
||||
"tier": "full"
|
||||
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
|
||||
},
|
||||
{
|
||||
"id": "wake-on-lan",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug",
|
||||
"sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
|
||||
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
|
||||
},
|
||||
{
|
||||
"id": "warpgate",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
|
||||
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47",
|
||||
"tier": "full"
|
||||
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
|
||||
},
|
||||
{
|
||||
"id": "web-endpoint",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug",
|
||||
"sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.3/web-endpoint-1.0.3.tmxplug",
|
||||
"sha256": "014cbf23c82cab20e485bc4f4c9b8baa779aee9bb3ed644b41f22a9b1be95687"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
|
||||
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -403,9 +310,8 @@
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug",
|
||||
"sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
|
||||
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -533,13 +533,6 @@
|
||||
"when": 1792430772041,
|
||||
"tag": "0075_session_policy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 76,
|
||||
"version": "5",
|
||||
"when": 1792945361809,
|
||||
"tag": "0076_host_color_icon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -533,13 +533,6 @@
|
||||
"when": 1792430770990,
|
||||
"tag": "0075_session_policy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 76,
|
||||
"version": "7",
|
||||
"when": 1792945360507,
|
||||
"tag": "0076_host_color_icon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -498,13 +498,6 @@
|
||||
"when": 1792430769899,
|
||||
"tag": "0070_session_policy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "6",
|
||||
"when": 1792945434651,
|
||||
"tag": "0071_host_color_icon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -128,10 +128,6 @@
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"extendInfo": {
|
||||
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
|
||||
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"]
|
||||
},
|
||||
"entitlements": "packaging/build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
|
||||
"type": "distribution",
|
||||
@@ -157,8 +153,6 @@
|
||||
"artifactName": "termix_macos_${arch}_mas.${ext}",
|
||||
"extendInfo": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
|
||||
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"],
|
||||
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
||||
}
|
||||
},
|
||||
|
||||
+1
-12
@@ -298,7 +298,7 @@
|
||||
{
|
||||
"name": "HTTP_PROXY",
|
||||
"group": "proxy",
|
||||
"description": "Proxy for outbound HTTP requests, like plugin registry downloads and webhooks."
|
||||
"description": "Proxy for outbound HTTP requests, like registry downloads and webhooks."
|
||||
},
|
||||
{
|
||||
"name": "HTTPS_PROXY",
|
||||
@@ -353,11 +353,6 @@
|
||||
"group": "plugins",
|
||||
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS",
|
||||
"group": "plugins",
|
||||
"description": "Comma-separated host names or IP addresses that plugin registry downloads may reach on a private address, like a registry in your LAN. List bare hosts: entries with a scheme, port or path are ignored. Matched by name only, on any port, so only list names your own DNS answers for. With HTTPS_PROXY set, add them to NO_PROXY too."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
||||
"group": "plugins",
|
||||
@@ -370,12 +365,6 @@
|
||||
"default": "false",
|
||||
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_SAFE_MODE",
|
||||
"group": "plugins",
|
||||
"default": "false",
|
||||
"description": "Start only layout and theme plugins. Every other plugin stays off until a restart without it, so you can turn off a plugin that breaks the server or the app."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||
"group": "plugins",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.6",
|
||||
"version": "26.10.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.6",
|
||||
"version": "26.10.5",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
||||
+2
-3
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.6",
|
||||
"version": "26.10.5",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -31,8 +31,7 @@
|
||||
"dev:docker": "node scripts/dev.mjs docker",
|
||||
"dev:frontend": "vite",
|
||||
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"build:backend:core": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"",
|
||||
"build:backend": "npm run build:backend:core && 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",
|
||||
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
|
||||
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
|
||||
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.22
|
||||
|
||||
Plugin API 1.13.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
|
||||
- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
|
||||
- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
|
||||
|
||||
### Fixed
|
||||
|
||||
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
|
||||
|
||||
## 1.0.21
|
||||
|
||||
Plugin API 1.12.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `@termix-ssh/plugin-sdk/host-scripts`: Docker, Linux metrics, systemd, journal and OpenRC command builders and parsers, plus `scriptShell()` and `passwordSudoScript()` to send scripts to `bash -s` on stdin. It has no Node or ssh2 imports, so the Termix mobile app runs the same code
|
||||
|
||||
## 1.0.20
|
||||
|
||||
Plugin API 1.12.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.22 | Plugin API 1.13.0
|
||||
SDK 1.0.20 | Plugin API 1.12.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
@@ -175,49 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
||||
|
||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||
`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
|
||||
|
||||
### Mobile app
|
||||
|
||||
`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
|
||||
plugin as having nothing to show (official plugins are mapped by the app itself).
|
||||
|
||||
```json
|
||||
"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
|
||||
```
|
||||
|
||||
| Field | Notes |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
|
||||
| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
|
||||
| `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
|
||||
| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
|
||||
| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
|
||||
| `note` | A locale key or plain text the app shows next to the plugin. |
|
||||
|
||||
`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
|
||||
|
||||
How a `webview` plugin runs in the app:
|
||||
|
||||
- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
|
||||
on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
|
||||
plugin list.
|
||||
- Every request and socket goes through the app, which adds the login, proxy headers and client
|
||||
certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
|
||||
settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
|
||||
activity). Other core routes answer 403, so call your own backend instead.
|
||||
- Images load straight from the server, without proxy headers or a client certificate.
|
||||
- Nothing in `localStorage` survives closing the view. Keep preferences in settings.
|
||||
- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
|
||||
is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
|
||||
- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
|
||||
is too old for the range, it asks the user to update the app instead of opening the view. When the range
|
||||
is for an older API major, it asks to update the plugin. Ask only for the API you use.
|
||||
- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
|
||||
to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
|
||||
|
||||
Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
|
||||
`"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
|
||||
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
||||
|
||||
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||
@@ -377,9 +335,7 @@ try {
|
||||
|
||||
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
|
||||
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
|
||||
sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal
|
||||
commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from
|
||||
`ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
||||
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
||||
|
||||
### The desktop app
|
||||
|
||||
@@ -949,7 +905,6 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
||||
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
||||
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
|
||||
| `@termix-ssh/plugin-sdk/host-scripts` | Docker, metrics, systemd and journal command builders and parsers, scripts sent to `bash -s` |
|
||||
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
||||
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
|
||||
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
||||
|
||||
@@ -25,43 +25,6 @@ const FRONTEND_ENTRIES = [
|
||||
"src/frontend/index.mjs",
|
||||
"src/frontend/index.js",
|
||||
];
|
||||
const MOBILE_ENTRIES = [
|
||||
"src/mobile/index.tsx",
|
||||
"src/mobile/index.ts",
|
||||
"src/mobile/index.mjs",
|
||||
"src/mobile/index.js",
|
||||
];
|
||||
|
||||
/** Builds a bundle that runs in a browser against core's shared modules. */
|
||||
async function buildBrowserBundle(entry, outfile, outDir) {
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
jsx: "automatic",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
external: FRONTEND_EXTERNALS,
|
||||
// Vite replaces these in the dev server; a plugin bundle has to have
|
||||
// them too, or code that reads them throws when the plugin loads.
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
"import.meta.env.DEV": "false",
|
||||
"import.meta.env.PROD": "true",
|
||||
"import.meta.env.MODE": '"production"',
|
||||
"import.meta.env.SSR": "false",
|
||||
"import.meta.env":
|
||||
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
|
||||
},
|
||||
plugins: [
|
||||
externalRequireInterop(FRONTEND_EXTERNALS),
|
||||
staticUrlImports({ outDir }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
|
||||
@@ -229,12 +192,15 @@ export async function buildTailwind(cwd, outDir) {
|
||||
// build() adds to what a compiler already emitted, so each half gets its own.
|
||||
const compiler = () =>
|
||||
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
||||
// The mobile bundle uses the same stylesheet.
|
||||
const sources = ["frontend", "mobile"]
|
||||
.map((dir) => path.join(cwd, "src", dir))
|
||||
.filter((dir, index) => index === 0 || fs.existsSync(dir))
|
||||
.map((base) => ({ base, pattern: "**/*", negated: false }));
|
||||
const scanner = new Scanner({ sources });
|
||||
const scanner = new Scanner({
|
||||
sources: [
|
||||
{
|
||||
base: path.join(cwd, "src", "frontend"),
|
||||
pattern: "**/*",
|
||||
negated: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||
// on native CSS nesting.
|
||||
const emit = async (candidates, options) =>
|
||||
@@ -331,18 +297,33 @@ export async function build({ cwd }) {
|
||||
|
||||
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
||||
if (frontendEntry) {
|
||||
await buildBrowserBundle(
|
||||
frontendEntry,
|
||||
path.join(outDir, "frontend.js"),
|
||||
outDir,
|
||||
);
|
||||
}
|
||||
|
||||
// A bundle the mobile app loads in its WebView, next to the frontend one.
|
||||
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
|
||||
const mobileOut = manifest.contributes?.mobile?.entry;
|
||||
if (mobileEntry && typeof mobileOut === "string") {
|
||||
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
|
||||
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,
|
||||
// Vite replaces these in the dev server; a plugin bundle has to have
|
||||
// them too, or code that reads them throws when the plugin loads.
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
"import.meta.env.DEV": "false",
|
||||
"import.meta.env.PROD": "true",
|
||||
"import.meta.env.MODE": '"production"',
|
||||
"import.meta.env.SSR": "false",
|
||||
"import.meta.env":
|
||||
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
|
||||
},
|
||||
plugins: [
|
||||
externalRequireInterop(FRONTEND_EXTERNALS),
|
||||
staticUrlImports({ outDir }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (frontendEntry) await buildTailwind(cwd, outDir);
|
||||
|
||||
@@ -28,10 +28,6 @@ export async function validate({ cwd }) {
|
||||
// A theme only plugin has no frontend code.
|
||||
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||
raw.locales ?? "locales",
|
||||
// Built from src/mobile by termix-plugin build.
|
||||
...(typeof raw.contributes?.mobile?.entry === "string"
|
||||
? [raw.contributes.mobile.entry]
|
||||
: []),
|
||||
];
|
||||
for (const rel of referenced) {
|
||||
if (!fs.existsSync(path.join(cwd, rel))) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.22",
|
||||
"version": "1.0.20",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -81,10 +81,6 @@
|
||||
"types": "./dist/host-commands.d.ts",
|
||||
"default": "./dist/host-commands.js"
|
||||
},
|
||||
"./host-scripts": {
|
||||
"types": "./dist/host-scripts/index.d.ts",
|
||||
"default": "./dist/host-scripts/index.js"
|
||||
},
|
||||
"./ssh-certs": {
|
||||
"types": "./dist/ssh-certs.d.ts",
|
||||
"default": "./dist/ssh-certs.js"
|
||||
@@ -102,10 +98,6 @@
|
||||
"./testing-host": {
|
||||
"default": "./dist/host/testing-host.js"
|
||||
},
|
||||
"./frame-host": {
|
||||
"default": "./dist/host/frame-host.js"
|
||||
},
|
||||
"./frame-host.css": "./dist/host/frame-host.css",
|
||||
"./theme.css": "./dist/host/theme.css"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -1239,48 +1239,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"type": "object",
|
||||
"description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode"],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["webview", "native", "none"],
|
||||
"description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app."
|
||||
},
|
||||
"entry": {
|
||||
"type": "string",
|
||||
"pattern": "\\.js$",
|
||||
"description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame."
|
||||
},
|
||||
"sizes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "enum": ["phone", "tablet"] },
|
||||
"description": "webview only. Screen sizes the UI works at. Defaults to both."
|
||||
},
|
||||
"views": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab."
|
||||
},
|
||||
"feature": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||
"description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id."
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "i18n key in the plugin's namespace, or plain text, shown in the app."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
|
||||
export type { Client, ClientChannel } from "ssh2";
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
// Homebrew and OrbStack paths a non-interactive shell misses.
|
||||
import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
|
||||
export { EXTRA_PATH_DIRS };
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
@@ -117,6 +114,14 @@ export interface PlatformInfo {
|
||||
osPrettyName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
||||
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
||||
* Extend PATH with their common install locations before probing.
|
||||
*/
|
||||
export const EXTRA_PATH_DIRS =
|
||||
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
||||
|
||||
const DOCKER_PROBE =
|
||||
`command -v docker >/dev/null 2>&1` +
|
||||
` || [ -S /var/run/docker.sock ]` +
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
/**
|
||||
* Docker and Podman command builders and output parsers.
|
||||
*
|
||||
* Container ids and names are checked with CONTAINER_ID_RE before they go on
|
||||
* a command line, so they need no quoting. Anything free form is quoted.
|
||||
*/
|
||||
import { HostScriptError } from "./text.js";
|
||||
|
||||
export type ContainerRuntime = "docker" | "podman";
|
||||
|
||||
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
|
||||
return value === "podman" ? "podman" : "docker";
|
||||
}
|
||||
|
||||
export function getRuntimeLabel(runtime: ContainerRuntime): string {
|
||||
return runtime === "podman" ? "Podman" : "Docker";
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
||||
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
||||
*/
|
||||
export const EXTRA_PATH_DIRS =
|
||||
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
||||
|
||||
export function containerCommand(
|
||||
runtime: ContainerRuntime | undefined,
|
||||
args: string,
|
||||
isWindows = false,
|
||||
): string {
|
||||
const cli = `${normalizeContainerRuntime(runtime)} ${args}`;
|
||||
// cmd and PowerShell don't understand the POSIX env prefix.
|
||||
return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`;
|
||||
}
|
||||
|
||||
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
||||
|
||||
export function isContainerId(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length <= 256 &&
|
||||
CONTAINER_ID_RE.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function requireContainer(id: string): string {
|
||||
if (!isContainerId(id))
|
||||
throw new HostScriptError("That is not a valid container id");
|
||||
return id;
|
||||
}
|
||||
|
||||
export const CONTAINER_ACTIONS = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"pause",
|
||||
"unpause",
|
||||
] as const;
|
||||
export type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
|
||||
|
||||
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
|
||||
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
|
||||
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
|
||||
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
|
||||
|
||||
export function listFormat(isWindows: boolean): string {
|
||||
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
|
||||
}
|
||||
|
||||
export function statsFormat(isWindows: boolean): string {
|
||||
return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
|
||||
}
|
||||
|
||||
/** One row of `ps --format listFormat()`. */
|
||||
export interface ContainerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state: string;
|
||||
ports: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
/** CLI arguments, without the runtime, for each container command. */
|
||||
export const dockerArgs = {
|
||||
version: () => "--version",
|
||||
ping: () => "ps",
|
||||
list: (all: boolean, isWindows = false) =>
|
||||
`ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`,
|
||||
inspect: (id: string) => `inspect ${requireContainer(id)}`,
|
||||
stats: (id: string, isWindows = false) =>
|
||||
`stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`,
|
||||
action: (action: ContainerAction, id: string) => {
|
||||
if (!CONTAINER_ACTIONS.includes(action))
|
||||
throw new HostScriptError("Unknown action");
|
||||
return `${action} ${requireContainer(id)}`;
|
||||
},
|
||||
remove: (id: string, force: boolean) =>
|
||||
`rm ${force ? "-f " : ""}${requireContainer(id)}`,
|
||||
logs: (
|
||||
id: string,
|
||||
options: {
|
||||
tail?: number;
|
||||
timestamps?: boolean;
|
||||
since?: string;
|
||||
until?: string;
|
||||
} = {},
|
||||
) => {
|
||||
let args = `logs ${requireContainer(id)}`;
|
||||
if (options.tail && options.tail > 0)
|
||||
args += ` --tail ${Math.floor(options.tail)}`;
|
||||
if (options.timestamps) args += " --timestamps";
|
||||
if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) {
|
||||
args += ` --since ${options.since}`;
|
||||
}
|
||||
if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) {
|
||||
args += ` --until ${options.until}`;
|
||||
}
|
||||
return `${args} 2>&1`;
|
||||
},
|
||||
states: () => `ps -a --format ${PS_STATE_FORMAT}`,
|
||||
composeProjects: () => "compose ls -a --format json",
|
||||
};
|
||||
|
||||
/** The version from `docker --version` or `podman --version`. */
|
||||
export function parseRuntimeVersion(output: string): string {
|
||||
return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown";
|
||||
}
|
||||
|
||||
export type RuntimeProblem =
|
||||
"NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR";
|
||||
|
||||
/** Sorts a failed `docker ps` into a reason the UI can explain. */
|
||||
export function classifyRuntimeError(message: string): RuntimeProblem {
|
||||
if (
|
||||
/command not found|not found: (docker|podman)|No such file or directory/i.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return "NOT_INSTALLED";
|
||||
}
|
||||
if (message.includes("Cannot connect to the Docker daemon"))
|
||||
return "DAEMON_NOT_RUNNING";
|
||||
if (/permission denied/i.test(message)) return "PERMISSION_DENIED";
|
||||
return "DOCKER_ERROR";
|
||||
}
|
||||
|
||||
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
|
||||
export function parseContainerList(output: string): Record<string, unknown>[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as Record<string, unknown>];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface ContainerStats {
|
||||
cpu: string;
|
||||
memoryUsed: string;
|
||||
memoryLimit: string;
|
||||
memoryPercent: string;
|
||||
netInput: string;
|
||||
netOutput: string;
|
||||
blockRead: string;
|
||||
blockWrite: string;
|
||||
pids: string;
|
||||
}
|
||||
|
||||
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
|
||||
export function parseContainerStats(output: string): ContainerStats {
|
||||
const raw = JSON.parse(output.trim()) as Record<string, string>;
|
||||
const split = (value: string | undefined) => {
|
||||
const [first, second] = (value ?? "").split(" / ");
|
||||
return [first?.trim() || "0B", second?.trim() || "0B"];
|
||||
};
|
||||
const [memoryUsed, memoryLimit] = split(raw.memory);
|
||||
const [netInput, netOutput] = split(raw.netIO);
|
||||
const [blockRead, blockWrite] = split(raw.blockIO);
|
||||
return {
|
||||
cpu: raw.cpu,
|
||||
memoryUsed,
|
||||
memoryLimit,
|
||||
memoryPercent: raw.memoryPercent,
|
||||
netInput,
|
||||
netOutput,
|
||||
blockRead,
|
||||
blockWrite,
|
||||
pids: raw.pids,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContainerState {
|
||||
/** Docker's own state word: running, exited, restarting, ... */
|
||||
state: string;
|
||||
/** Health from the status text, when the image declares a healthcheck. */
|
||||
unhealthy: boolean;
|
||||
}
|
||||
|
||||
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
||||
|
||||
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
|
||||
|
||||
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
|
||||
export function parseContainerStates(
|
||||
output: string,
|
||||
): Map<string, ContainerState> {
|
||||
const states = new Map<string, ContainerState>();
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
name?: string;
|
||||
state?: string;
|
||||
status?: string;
|
||||
};
|
||||
if (!parsed.name) continue;
|
||||
states.set(parsed.name, {
|
||||
state: (parsed.state ?? "").toLowerCase(),
|
||||
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
|
||||
});
|
||||
} catch {
|
||||
// A partial line is not worth failing the whole poll over.
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* The events a pair of snapshots implies. A container missing from the
|
||||
* previous snapshot counts as newly seen rather than started, so the first
|
||||
* poll after a restart does not replay every running container.
|
||||
*/
|
||||
export function diffContainerStates(
|
||||
previous: Map<string, ContainerState>,
|
||||
current: Map<string, ContainerState>,
|
||||
): Array<{ container: string; event: DockerEventName }> {
|
||||
const events: Array<{ container: string; event: DockerEventName }> = [];
|
||||
for (const [name, now] of current) {
|
||||
const before = previous.get(name);
|
||||
if (!before) continue;
|
||||
if (before.state !== now.state) {
|
||||
if (now.state === "exited")
|
||||
events.push({ container: name, event: "exited" });
|
||||
else if (now.state === "running")
|
||||
events.push({ container: name, event: "started" });
|
||||
else if (now.state === "restarting") {
|
||||
events.push({ container: name, event: "restarting" });
|
||||
}
|
||||
}
|
||||
// A container can go unhealthy while it stays up.
|
||||
if (!before.unhealthy && now.unhealthy) {
|
||||
events.push({ container: name, event: "unhealthy" });
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export interface ContainerCounts {
|
||||
running: number;
|
||||
stopped: number;
|
||||
unhealthy: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
|
||||
export function countContainers(output: string): ContainerCounts {
|
||||
const states = [...parseContainerStates(output).values()];
|
||||
const running = states.filter((s) => s.state === "running").length;
|
||||
return {
|
||||
running,
|
||||
stopped: states.length - running,
|
||||
unhealthy: states.filter((s) => s.unhealthy).length,
|
||||
total: states.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Compose stacks
|
||||
|
||||
export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
||||
export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/;
|
||||
export const COMPOSE_FILES = [
|
||||
"compose.yaml",
|
||||
"compose.yml",
|
||||
"docker-compose.yaml",
|
||||
"docker-compose.yml",
|
||||
];
|
||||
|
||||
export const STACK_ACTIONS = [
|
||||
"deploy",
|
||||
"redeploy",
|
||||
"pull",
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"pause",
|
||||
"unpause",
|
||||
"down",
|
||||
"destroy",
|
||||
] as const;
|
||||
export type StackAction = (typeof STACK_ACTIONS)[number];
|
||||
|
||||
/** Actions that can target one service of a stack. */
|
||||
export const STACK_SERVICE_ACTIONS: StackAction[] = [
|
||||
"deploy",
|
||||
"pull",
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"pause",
|
||||
"unpause",
|
||||
];
|
||||
|
||||
export type StackState =
|
||||
"running" | "partial" | "stopped" | "paused" | "not-deployed";
|
||||
|
||||
export interface StackSummary {
|
||||
name: string;
|
||||
managed: boolean;
|
||||
state: StackState;
|
||||
running: number;
|
||||
total: number;
|
||||
composeFile: string | null;
|
||||
}
|
||||
|
||||
export interface StackService {
|
||||
service: string;
|
||||
name: string;
|
||||
id: string;
|
||||
image: string;
|
||||
state: string;
|
||||
status: string;
|
||||
health: string;
|
||||
ports: string;
|
||||
}
|
||||
|
||||
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
|
||||
export function parseJsonLines(output: string): Record<string, unknown>[] {
|
||||
const text = output.trim();
|
||||
if (!text) return [];
|
||||
if (text.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(text) as Record<string, unknown>[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return text.split("\n").flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as Record<string, unknown>];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const str = (value: unknown): string =>
|
||||
typeof value === "string" ? value : "";
|
||||
|
||||
export function parseServices(output: string): StackService[] {
|
||||
return parseJsonLines(output).map((row) => {
|
||||
const publishers = Array.isArray(row.Publishers)
|
||||
? (row.Publishers as Record<string, unknown>[])
|
||||
: [];
|
||||
const ports = publishers
|
||||
.filter((p) => Number(p.PublishedPort) > 0)
|
||||
.map((p) => `${p.PublishedPort}:${p.TargetPort}`)
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
.join(", ");
|
||||
return {
|
||||
service: str(row.Service),
|
||||
name: str(row.Name),
|
||||
id: str(row.ID),
|
||||
image: str(row.Image),
|
||||
state: str(row.State),
|
||||
status: str(row.Status),
|
||||
health: str(row.Health),
|
||||
ports,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function stateOf(running: number, paused: number, total: number): StackState {
|
||||
if (total === 0) return "not-deployed";
|
||||
if (paused === total) return "paused";
|
||||
if (running === total) return "running";
|
||||
if (running > 0) return "partial";
|
||||
return "stopped";
|
||||
}
|
||||
|
||||
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
|
||||
export function parseStackStatus(status: string): {
|
||||
state: StackState;
|
||||
running: number;
|
||||
total: number;
|
||||
} {
|
||||
let running = 0;
|
||||
let paused = 0;
|
||||
let total = 0;
|
||||
for (const part of status.split(",")) {
|
||||
const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i);
|
||||
if (!match) continue;
|
||||
const count = Number(match[2]);
|
||||
total += count;
|
||||
if (match[1] === "running") running += count;
|
||||
if (match[1] === "paused") paused += count;
|
||||
}
|
||||
return { state: stateOf(running, paused, total), running, total };
|
||||
}
|
||||
|
||||
export function stackStateFromServices(services: StackService[]) {
|
||||
const total = services.length;
|
||||
const running = services.filter((s) => s.state === "running").length;
|
||||
const paused = services.filter((s) => s.state === "paused").length;
|
||||
return { state: stateOf(running, paused, total), running, total };
|
||||
}
|
||||
|
||||
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
|
||||
export function parseComposeProjects(output: string): StackSummary[] {
|
||||
return parseJsonLines(output)
|
||||
.flatMap((project) => {
|
||||
const name = str(project.Name);
|
||||
if (!STACK_NAME_RE.test(name)) return [];
|
||||
return [
|
||||
{
|
||||
name,
|
||||
managed: false,
|
||||
...parseStackStatus(str(project.Status)),
|
||||
composeFile: str(project.ConfigFiles).split(",")[0] || null,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** The compose commands one action runs, in order. */
|
||||
export function composeArgsFor(
|
||||
action: StackAction,
|
||||
service: string | undefined,
|
||||
options: { removeVolumes?: boolean },
|
||||
): string[] {
|
||||
const target = service ? ` ${service}` : "";
|
||||
switch (action) {
|
||||
case "deploy":
|
||||
return [`up -d --remove-orphans${target}`];
|
||||
case "redeploy":
|
||||
return [`pull${target}`, `up -d --remove-orphans${target}`];
|
||||
case "pull":
|
||||
return [`pull${target}`];
|
||||
case "start":
|
||||
case "stop":
|
||||
case "restart":
|
||||
case "pause":
|
||||
case "unpause":
|
||||
return [`${action}${target}`];
|
||||
case "down":
|
||||
return ["down --remove-orphans"];
|
||||
case "destroy":
|
||||
return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`];
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Host scripts: command builders and output parsers for Docker, Linux
|
||||
* metrics, systemd, the journal and OpenRC.
|
||||
*
|
||||
* Pure TypeScript with no Node or ssh2 imports, so the same code runs in a
|
||||
* plugin backend and in the mobile app. Run a script by sending it on stdin
|
||||
* to the command from `scriptShell()`.
|
||||
*/
|
||||
export * from "./text.js";
|
||||
export * from "./shell.js";
|
||||
export * from "./docker.js";
|
||||
export * from "./metrics.js";
|
||||
export * from "./systemd.js";
|
||||
export * from "./journal.js";
|
||||
export * from "./openrc.js";
|
||||
@@ -1,242 +0,0 @@
|
||||
/** journalctl scripts and parsers. */
|
||||
import { isUnit } from "./systemd.js";
|
||||
import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js";
|
||||
|
||||
export const MAX_JOURNAL_LINES = 5000;
|
||||
const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM";
|
||||
|
||||
export interface JournalQuery {
|
||||
units: string[];
|
||||
priority?: number;
|
||||
since?: string;
|
||||
until?: string;
|
||||
boot?: string;
|
||||
grep?: string;
|
||||
lines: number;
|
||||
kernel: boolean;
|
||||
}
|
||||
|
||||
export interface JournalEntry {
|
||||
/** Unix ms. */
|
||||
time: number;
|
||||
priority: number;
|
||||
unit: string;
|
||||
ident: string;
|
||||
pid: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BootView {
|
||||
offset: number;
|
||||
id: string;
|
||||
first: string;
|
||||
last: string;
|
||||
}
|
||||
|
||||
const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/;
|
||||
const WORDS = ["today", "yesterday", "now"];
|
||||
|
||||
export function isJournalTime(value: string): boolean {
|
||||
return (
|
||||
RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/** Reads journal filters from a query string. Anything odd throws a HostScriptError. */
|
||||
export function parseJournalQuery(
|
||||
input: Record<string, unknown>,
|
||||
): JournalQuery {
|
||||
const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit];
|
||||
const units = rawUnits.map(str).filter(Boolean);
|
||||
for (const unit of units) {
|
||||
if (!isUnit(unit))
|
||||
throw new HostScriptError("That is not a valid unit name");
|
||||
}
|
||||
if (units.length > 20) throw new HostScriptError("Too many units");
|
||||
|
||||
const query: JournalQuery = {
|
||||
units,
|
||||
lines: 300,
|
||||
kernel: input.kernel === "true" || input.kernel === true,
|
||||
};
|
||||
const priority = str(input.priority);
|
||||
if (priority) {
|
||||
const value = Number(priority);
|
||||
if (!Number.isInteger(value) || value < 0 || value > 7) {
|
||||
throw new HostScriptError("priority must be 0 to 7");
|
||||
}
|
||||
query.priority = value;
|
||||
}
|
||||
for (const key of ["since", "until"] as const) {
|
||||
const value = str(input[key]);
|
||||
if (!value) continue;
|
||||
if (!isJournalTime(value))
|
||||
throw new HostScriptError(`${key} is not a valid time`);
|
||||
query[key] = value;
|
||||
}
|
||||
const boot = str(input.boot);
|
||||
if (boot) {
|
||||
if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot))
|
||||
throw new HostScriptError("boot is not valid");
|
||||
query.boot = boot;
|
||||
}
|
||||
const grep = typeof input.grep === "string" ? input.grep : "";
|
||||
if (grep) {
|
||||
if (grep.length > 200 || /[\r\n\0]/.test(grep)) {
|
||||
throw new HostScriptError("The search is too long");
|
||||
}
|
||||
query.grep = grep;
|
||||
}
|
||||
const lines = Number(str(input.lines) || 300);
|
||||
if (Number.isFinite(lines) && lines > 0) {
|
||||
query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines));
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/** journalctl arguments for a query, each one quoted. */
|
||||
export function journalArgs(
|
||||
query: Omit<JournalQuery, "lines"> & { lines?: number },
|
||||
options: { follow?: boolean; useGrep?: boolean } = {},
|
||||
): string {
|
||||
const args = ["journalctl", "--no-pager", "-q", "-o", "json"];
|
||||
args.push(`--output-fields=${FIELDS}`);
|
||||
if (options.follow) args.push("-f");
|
||||
if (query.lines) args.push("-n", String(Math.floor(query.lines)));
|
||||
for (const unit of query.units) {
|
||||
if (!isUnit(unit))
|
||||
throw new HostScriptError("That is not a valid unit name");
|
||||
args.push("-u", q(unit));
|
||||
}
|
||||
if (query.kernel) args.push("-k");
|
||||
if (query.priority !== undefined)
|
||||
args.push("-p", String(Math.floor(query.priority)));
|
||||
if (query.since) args.push("--since", q(query.since));
|
||||
if (query.until) args.push("--until", q(query.until));
|
||||
if (query.boot) args.push("-b", q(query.boot));
|
||||
if (query.grep && options.useGrep !== false) {
|
||||
args.push("--case-sensitive=false", "-g", q(query.grep));
|
||||
}
|
||||
return args.join(" ");
|
||||
}
|
||||
|
||||
export function journalScript(query: JournalQuery): string {
|
||||
// Older journalctl builds have no -g. Then the search runs on our side instead.
|
||||
const noGrep = journalArgs(
|
||||
{ ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines },
|
||||
{ useGrep: false },
|
||||
);
|
||||
return `
|
||||
command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; }
|
||||
${journalArgs(query)} 2>/tmp/termix-journal.$$
|
||||
RC=$?
|
||||
if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then
|
||||
rm -f /tmp/termix-journal.$$
|
||||
echo "@@TERMIX_NOGREP@@"
|
||||
${noGrep}
|
||||
exit 0
|
||||
fi
|
||||
cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$
|
||||
exit $RC
|
||||
`;
|
||||
}
|
||||
|
||||
function messageOf(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) {
|
||||
return utf8Decode(value.filter((n): n is number => typeof n === "number"));
|
||||
}
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
/** One `journalctl -o json` line, or null when it is not an entry. */
|
||||
export function parseEntry(line: string): JournalEntry | null {
|
||||
const text = line.trim();
|
||||
if (!text.startsWith("{")) return null;
|
||||
let raw: Record<string, unknown>;
|
||||
try {
|
||||
raw = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const micros = Number(raw.__REALTIME_TIMESTAMP);
|
||||
const priority = Number(raw.PRIORITY);
|
||||
return {
|
||||
time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0,
|
||||
priority: Number.isInteger(priority) ? priority : 6,
|
||||
unit: messageOf(raw._SYSTEMD_UNIT),
|
||||
ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM),
|
||||
pid: messageOf(raw._PID),
|
||||
message: messageOf(raw.MESSAGE),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJournal(
|
||||
output: string,
|
||||
query: JournalQuery,
|
||||
): JournalEntry[] {
|
||||
const fallback = output.includes("@@TERMIX_NOGREP@@");
|
||||
const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output;
|
||||
let entries = body
|
||||
.split("\n")
|
||||
.map(parseEntry)
|
||||
.filter((entry): entry is JournalEntry => entry !== null);
|
||||
if (fallback && query.grep) {
|
||||
const needle = query.grep.toLowerCase();
|
||||
entries = entries
|
||||
.filter((entry) => entry.message.toLowerCase().includes(needle))
|
||||
.slice(-query.lines);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`;
|
||||
|
||||
export function parseBoots(output: string): BootView[] {
|
||||
const boots: BootView[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const match =
|
||||
/^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec(
|
||||
line,
|
||||
);
|
||||
if (!match) continue;
|
||||
boots.push({
|
||||
offset: Number(match[1]),
|
||||
id: match[2],
|
||||
first: match[3],
|
||||
last: match[4] ?? "",
|
||||
});
|
||||
}
|
||||
return boots.reverse();
|
||||
}
|
||||
|
||||
export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`;
|
||||
|
||||
export function parseUsage(output: string): string {
|
||||
return /take up (\S+)/.exec(output)?.[1] ?? "";
|
||||
}
|
||||
|
||||
const SIZE_RE = /^\d{1,6}(K|M|G)$/;
|
||||
const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/;
|
||||
|
||||
export function vacuumScript(body: Record<string, unknown>): string {
|
||||
const size = str(body.size);
|
||||
const time = str(body.time);
|
||||
if (!size && !time) throw new HostScriptError("Give a size or an age");
|
||||
if (size && !SIZE_RE.test(size))
|
||||
throw new HostScriptError("Size looks like 500M or 2G");
|
||||
if (time && !AGE_RE.test(time))
|
||||
throw new HostScriptError("Age looks like 7d or 4w");
|
||||
const flags = [
|
||||
size ? `--vacuum-size=${size}` : "",
|
||||
time ? `--vacuum-time=${time}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`;
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
/**
|
||||
* Linux metrics: one awk sample of /proc for CPU, memory, load, network and
|
||||
* processes, plus df parsing for disks.
|
||||
*/
|
||||
|
||||
// One awk reads every /proc file itself, so a sample is one exec of awk instead
|
||||
// of a shell plus six tools. Output sections are split by @@; each process line
|
||||
// is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm.
|
||||
const AWK = String.raw`BEGIN{f="/proc/stat";while((getline l<f)>0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l<f)>0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l<f;print l;close(f);f="/proc/uptime";getline l<f;print l;close(f);print "@@";f="/proc/net/dev";while((getline l<f)>0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i<ARGC;i++){f=ARGV[i];if((getline s<f)>0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`;
|
||||
|
||||
/** The live sample command. The page size never changes on a host, so pass it back once known. */
|
||||
export const liveCmd = (pageBytes?: number) =>
|
||||
"awk -v pg=" +
|
||||
(pageBytes ?? '"$(getconf PAGESIZE)"') +
|
||||
" '" +
|
||||
AWK +
|
||||
"' /proc/[0-9]*/stat";
|
||||
|
||||
export interface LiveSample {
|
||||
/** The admin minimum between samples; the client never polls faster. */
|
||||
minIntervalS?: number;
|
||||
cpu: number | null;
|
||||
cpuMix?: { user: number; system: number; iowait: number; steal: number };
|
||||
cores: Array<number | null>;
|
||||
coreIds: string[];
|
||||
memTotalKb: number;
|
||||
memCachedKb: number;
|
||||
memFreeKb: number;
|
||||
memUsedKb: number;
|
||||
swapTotalKb: number;
|
||||
swapUsedKb: number;
|
||||
load: [number, number, number];
|
||||
net: Array<{ name: string; rxBps: number | null; txBps: number | null }>;
|
||||
count: number;
|
||||
running: number;
|
||||
procs: Array<{
|
||||
pid: number;
|
||||
name: string;
|
||||
cpu: number;
|
||||
memKb: number;
|
||||
state: string;
|
||||
ppid: number;
|
||||
}>;
|
||||
/** Seconds since boot. */
|
||||
uptime?: number;
|
||||
/** Total bytes received and sent on every interface but lo, since boot. */
|
||||
netTotals?: { rx: number; tx: number };
|
||||
}
|
||||
|
||||
/** What the next call needs from this one. */
|
||||
export interface LivePrev {
|
||||
at: number;
|
||||
uptime: number;
|
||||
pageBytes: number;
|
||||
cpu: Map<string, { total: number; idle: number }>;
|
||||
/** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */
|
||||
cpuRaw?: number[];
|
||||
/** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */
|
||||
ticks: Map<string, number>;
|
||||
net: Map<string, { rx: number; tx: number }>;
|
||||
}
|
||||
|
||||
/** Total and idle ticks from a /proc/stat `cpu` line. */
|
||||
export function parseCpuLine(
|
||||
cpuLine: string,
|
||||
): { total: number; idle: number } | undefined {
|
||||
const parts = cpuLine.trim().split(/\s+/);
|
||||
if (parts[0] !== "cpu") return undefined;
|
||||
const nums = parts
|
||||
.slice(1)
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (nums.length < 4) return undefined;
|
||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||
const total = nums.reduce((a, b) => a + b, 0);
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
const TOP_N = 60;
|
||||
|
||||
/** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
|
||||
export function parseLive(
|
||||
output: string,
|
||||
prev: LivePrev | undefined,
|
||||
at = Date.now(),
|
||||
): { sample: LiveSample; next: LivePrev } {
|
||||
const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] =
|
||||
output.split("@@\n");
|
||||
|
||||
// Only the first 8 fields: guest and guest_nice are already inside user and nice.
|
||||
const cpu = new Map<string, { total: number; idle: number }>();
|
||||
let cpuRaw: number[] | undefined;
|
||||
for (const l of cpuS.split("\n")) {
|
||||
const name = l.split(/\s+/)[0];
|
||||
if (!name.startsWith("cpu")) continue;
|
||||
if (name === "cpu") cpuRaw = l.split(/\s+/).slice(1, 9).map(Number);
|
||||
const c = parseCpuLine(
|
||||
l
|
||||
.replace(/^cpu\d*/, "cpu")
|
||||
.split(/\s+/)
|
||||
.slice(0, 9)
|
||||
.join(" "),
|
||||
);
|
||||
if (c) cpu.set(name, c);
|
||||
}
|
||||
// Matched by name, so an offline core does not shift the ones after it.
|
||||
const pct = (name: string) => {
|
||||
const p = prev?.cpu.get(name);
|
||||
const c = cpu.get(name);
|
||||
if (!p || !c || c.total <= p.total) return null;
|
||||
const used = c.total - p.total - (c.idle - p.idle);
|
||||
return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100));
|
||||
};
|
||||
|
||||
// Where the busy time went since the last sample, as percent of all ticks.
|
||||
let cpuMix: LiveSample["cpuMix"];
|
||||
if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) {
|
||||
const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0));
|
||||
const total = d.reduce((a, b) => a + b, 0);
|
||||
if (total > 0 && d.every((v) => v >= 0)) {
|
||||
const p = (n: number) => (n / total) * 100;
|
||||
cpuMix = {
|
||||
user: p(d[0] + d[1]),
|
||||
system: p(d[2] + d[5] + d[6]),
|
||||
iowait: p(d[4]),
|
||||
steal: p(d[7]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const mem: Record<string, number> = {};
|
||||
for (const l of memS.split("\n")) {
|
||||
const m = l.match(/^(\w+):\s+(\d+)/);
|
||||
if (m) mem[m[1]] = Number(m[2]);
|
||||
}
|
||||
|
||||
const [la = "", up = ""] = loadS.trim().split("\n");
|
||||
const laParts = la.split(/\s+/).map(Number);
|
||||
const uptime = Number(up.split(/\s+/)[0]) || 0;
|
||||
|
||||
const nets = new Map<string, { rx: number; tx: number }>();
|
||||
for (const l of netS.split("\n")) {
|
||||
const [name, rest] = l.split(":");
|
||||
if (!rest || name.trim() === "lo") continue;
|
||||
const f = rest.trim().split(/\s+/);
|
||||
nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 });
|
||||
}
|
||||
// A reboot makes uptime go backwards, so rates start again.
|
||||
const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0;
|
||||
const rate = (now: number, before: number | undefined) =>
|
||||
dt > 0 && before !== undefined && now >= before
|
||||
? (now - before) / dt
|
||||
: null;
|
||||
|
||||
const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096;
|
||||
const pageKb = pageBytes / 1024;
|
||||
const aggDelta =
|
||||
prev && cpu.has("cpu") && prev.cpu.has("cpu")
|
||||
? cpu.get("cpu")!.total - prev.cpu.get("cpu")!.total
|
||||
: 0;
|
||||
const cores = [...cpu.keys()].filter((k) => k !== "cpu");
|
||||
const ncpu = Math.max(1, cores.length);
|
||||
const ticks = new Map<string, number>();
|
||||
const procs: LiveSample["procs"] = [];
|
||||
for (const l of procS.split("\n")) {
|
||||
const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t");
|
||||
const name = rest.join("\t");
|
||||
if (!name) continue;
|
||||
const key = pid + ":" + start;
|
||||
const tk = Number(t) || 0;
|
||||
ticks.set(key, tk);
|
||||
const before = prev?.ticks.get(key);
|
||||
procs.push({
|
||||
pid: Number(pid),
|
||||
name,
|
||||
cpu:
|
||||
before !== undefined && aggDelta > 0 && tk >= before
|
||||
? ((tk - before) / aggDelta) * ncpu * 100
|
||||
: 0,
|
||||
memKb: (Number(rss) || 0) * pageKb,
|
||||
state: state || "?",
|
||||
ppid: Number(ppid) || 0,
|
||||
});
|
||||
}
|
||||
procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb);
|
||||
|
||||
// Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample.
|
||||
if (!cpu.has("cpu") || !mem.MemTotal || !uptime) {
|
||||
throw new Error("live sample unavailable: /proc data missing");
|
||||
}
|
||||
const memTotalKb = mem.MemTotal;
|
||||
const swapTotalKb = mem.SwapTotal ?? 0;
|
||||
let rxTotal = 0;
|
||||
let txTotal = 0;
|
||||
for (const c of nets.values()) {
|
||||
rxTotal += c.rx;
|
||||
txTotal += c.tx;
|
||||
}
|
||||
return {
|
||||
sample: {
|
||||
cpu: pct("cpu"),
|
||||
cores: cores.map(pct),
|
||||
coreIds: cores.map((k) => k.slice(3)),
|
||||
cpuMix,
|
||||
memTotalKb,
|
||||
memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0),
|
||||
memFreeKb: mem.MemFree ?? 0,
|
||||
memUsedKb: memTotalKb - (mem.MemAvailable ?? 0),
|
||||
swapTotalKb,
|
||||
swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0),
|
||||
load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0],
|
||||
net: [...nets].map(([name, c]) => ({
|
||||
name,
|
||||
rxBps: rate(c.rx, prev?.net.get(name)?.rx),
|
||||
txBps: rate(c.tx, prev?.net.get(name)?.tx),
|
||||
})),
|
||||
count: procs.length,
|
||||
running: procs.filter((p) => p.state === "R").length,
|
||||
procs: procs.slice(0, TOP_N),
|
||||
uptime,
|
||||
netTotals: { rx: rxTotal, tx: txTotal },
|
||||
},
|
||||
next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets },
|
||||
};
|
||||
}
|
||||
|
||||
// Disks
|
||||
|
||||
const DARWIN_DATA_MOUNT = "/System/Volumes/Data";
|
||||
const PSEUDO_FS_RE =
|
||||
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
|
||||
|
||||
/** GNU df sizes in KiB, without the header. */
|
||||
export const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
|
||||
/** GNU df sizes as short human text, without the header. */
|
||||
export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
|
||||
|
||||
export interface DfRow {
|
||||
filesystem: string;
|
||||
type: string;
|
||||
mount: string;
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
export interface DiskFilesystem {
|
||||
filesystem: string;
|
||||
type: string;
|
||||
mount: string;
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
availableHuman: string | null;
|
||||
usedBytes: number | null;
|
||||
totalBytes: number | null;
|
||||
availableBytes: number | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface MonitoredMount {
|
||||
path: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `df -T -P` style output: Filesystem, Type, then the size columns,
|
||||
* with Mounted-on last. Pseudo filesystems are left out.
|
||||
*/
|
||||
export function parseDfLines(output: string): DfRow[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/);
|
||||
return {
|
||||
filesystem: parts[0] || "",
|
||||
type: parts[1] || "",
|
||||
mount: parts[6] || "",
|
||||
parts,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
|
||||
}
|
||||
|
||||
/** The most used real filesystem in a `df -TkP` row set. */
|
||||
export function findWorstMountIndex(bytesRows: DfRow[]): {
|
||||
index: number;
|
||||
usedBytes: number;
|
||||
totalBytes: number;
|
||||
} {
|
||||
let worstIndex = -1;
|
||||
let worstUsedBytes = -1;
|
||||
let worstTotalBytes = 0;
|
||||
bytesRows.forEach((row, index) => {
|
||||
const totalBytes = Number(row.parts[2]);
|
||||
const usedBytes = Number(row.parts[3]);
|
||||
if (
|
||||
!Number.isFinite(totalBytes) ||
|
||||
!Number.isFinite(usedBytes) ||
|
||||
totalBytes <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const usedRatio = usedBytes / totalBytes;
|
||||
const worstRatio =
|
||||
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
|
||||
if (usedRatio > worstRatio) {
|
||||
worstIndex = index;
|
||||
worstUsedBytes = usedBytes;
|
||||
worstTotalBytes = totalBytes;
|
||||
}
|
||||
});
|
||||
return {
|
||||
index: worstIndex,
|
||||
usedBytes: worstUsedBytes,
|
||||
totalBytes: worstTotalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
|
||||
* `df -hT -P` human rows into one list. Human rows only supply the display
|
||||
* text, matched by mount point so a mismatched row count can't shift them.
|
||||
*/
|
||||
export function buildFilesystemList(
|
||||
bytesRows: DfRow[],
|
||||
humanRows: DfRow[],
|
||||
blockSizeBytes = 1,
|
||||
): DiskFilesystem[] {
|
||||
const aligned = humanRows.length === bytesRows.length;
|
||||
return bytesRows
|
||||
.map((row, index) => {
|
||||
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
|
||||
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
|
||||
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
|
||||
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
|
||||
const humanRow = aligned
|
||||
? humanRows[index]
|
||||
: humanRows.find((h) => h.mount === row.mount);
|
||||
const percent = Number.isFinite(usedBytes)
|
||||
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
|
||||
: null;
|
||||
return {
|
||||
filesystem: row.filesystem,
|
||||
type: row.type,
|
||||
mount: row.mount,
|
||||
percent: toFixedNum(percent, 0),
|
||||
usedHuman: humanRow?.parts[3] || null,
|
||||
totalHuman: humanRow?.parts[2] || null,
|
||||
availableHuman: humanRow?.parts[4] || null,
|
||||
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
|
||||
totalBytes,
|
||||
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
|
||||
};
|
||||
})
|
||||
.filter((fs): fs is DiskFilesystem => fs !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline disk is the root filesystem. On macOS "/" is the small sealed
|
||||
* system volume, so the data volume wins there. With no root mount the most
|
||||
* used one is shown.
|
||||
*/
|
||||
export function selectPrimaryFilesystem(
|
||||
filesystems: DiskFilesystem[],
|
||||
): DiskFilesystem | null {
|
||||
if (filesystems.length === 0) return null;
|
||||
const root =
|
||||
filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ??
|
||||
filesystems.find((fs) => fs.mount === "/");
|
||||
if (root) return root;
|
||||
let best = filesystems[0];
|
||||
for (const fs of filesystems) {
|
||||
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
|
||||
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
|
||||
if (ratio > bestRatio) best = fs;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
|
||||
* ("nfs" matches nfs and nfs4), so network shares can be hidden.
|
||||
*/
|
||||
export function filterExcludedFilesystems(
|
||||
filesystems: DiskFilesystem[],
|
||||
excludedMounts?: string[] | null,
|
||||
): DiskFilesystem[] {
|
||||
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
|
||||
const normalized = excludedMounts
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (normalized.length === 0) return filesystems;
|
||||
return filesystems.filter((fs) => {
|
||||
const mount = fs.mount.toLowerCase();
|
||||
const type = fs.type.toLowerCase();
|
||||
return !normalized.some(
|
||||
(entry) => mount === entry || (type && type.includes(entry)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeMonitoredFilesystems(
|
||||
filesystems: DiskFilesystem[],
|
||||
monitored: MonitoredMount[],
|
||||
customFilesystems: Array<DiskFilesystem | null>,
|
||||
): DiskFilesystem[] {
|
||||
const result = [...filesystems];
|
||||
monitored.forEach((entry, index) => {
|
||||
const path = entry.path.trim();
|
||||
const custom = customFilesystems[index];
|
||||
if (!path || !custom) return;
|
||||
const existing = result.find((fs) => fs.mount === path);
|
||||
if (existing) {
|
||||
existing.label = entry.label?.trim() || undefined;
|
||||
return;
|
||||
}
|
||||
result.push({
|
||||
...custom,
|
||||
mount: path,
|
||||
label: entry.label?.trim() || undefined,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Host name, OS, kernel and arch in one exec, as key=value lines. */
|
||||
export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`;
|
||||
@@ -1,94 +0,0 @@
|
||||
/** OpenRC scripts, with states mapped onto the words systemd uses. */
|
||||
import { isOpenRcName, type UnitAction, type UnitSummary } from "./systemd.js";
|
||||
import { HostScriptError, shellQuote as q } from "./text.js";
|
||||
|
||||
export const OPENRC_LIST_SCRIPT = `
|
||||
rc-status --servicelist 2>/dev/null
|
||||
echo "@@TERMIX_RUNLEVELS@@"
|
||||
rc-update show -v 2>/dev/null
|
||||
exit 0
|
||||
`;
|
||||
|
||||
function activeOf(state: string): { active: string; sub: string } {
|
||||
switch (state) {
|
||||
case "started":
|
||||
return { active: "active", sub: "running" };
|
||||
case "crashed":
|
||||
return { active: "failed", sub: "crashed" };
|
||||
case "starting":
|
||||
return { active: "activating", sub: "starting" };
|
||||
case "stopping":
|
||||
return { active: "deactivating", sub: "stopping" };
|
||||
case "inactive":
|
||||
case "hotplugged":
|
||||
return { active: "inactive", sub: state };
|
||||
default:
|
||||
return { active: "inactive", sub: state || "stopped" };
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenRcList(output: string): UnitSummary[] {
|
||||
const [list = "", levels = ""] = output.split("@@TERMIX_RUNLEVELS@@");
|
||||
const units = new Map<string, UnitSummary>();
|
||||
for (const line of list.split("\n")) {
|
||||
const match = /^\s*(\S+)\s+\[\s*([a-z ]+?)\s*\]/.exec(line);
|
||||
if (!match) continue;
|
||||
const { active, sub } = activeOf(match[2]);
|
||||
units.set(match[1], {
|
||||
name: match[1],
|
||||
type: "service",
|
||||
load: "loaded",
|
||||
active,
|
||||
sub,
|
||||
description: "",
|
||||
fileState: "disabled",
|
||||
preset: "",
|
||||
});
|
||||
}
|
||||
for (const line of levels.split("\n")) {
|
||||
const match = /^\s*(\S+)\s*\|\s*(.*)$/.exec(line);
|
||||
if (!match) continue;
|
||||
const runlevels = match[2].trim();
|
||||
const known = units.get(match[1]) ?? {
|
||||
name: match[1],
|
||||
type: "service",
|
||||
load: "loaded",
|
||||
active: "inactive",
|
||||
sub: "stopped",
|
||||
description: "",
|
||||
fileState: "disabled",
|
||||
preset: "",
|
||||
};
|
||||
known.fileState = runlevels ? "enabled" : "disabled";
|
||||
known.description = runlevels ? `Runlevels: ${runlevels}` : "";
|
||||
units.set(match[1], known);
|
||||
}
|
||||
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function requireName(name: string): string {
|
||||
if (!isOpenRcName(name))
|
||||
throw new HostScriptError("That is not a valid service name");
|
||||
return name;
|
||||
}
|
||||
|
||||
export function openRcActionScript(name: string, action: UnitAction): string {
|
||||
const s = q(requireName(name));
|
||||
switch (action) {
|
||||
case "start":
|
||||
case "stop":
|
||||
case "restart":
|
||||
return `rc-service ${s} ${action} 2>&1 || { echo "SCRIPT_ERR:rc-service ${action} failed"; exit 1; }\n`;
|
||||
case "enable":
|
||||
return `rc-update add ${s} default 2>&1 || { echo "SCRIPT_ERR:rc-update add failed"; exit 1; }\n`;
|
||||
case "disable":
|
||||
return `rc-update del ${s} 2>&1 || { echo "SCRIPT_ERR:rc-update del failed"; exit 1; }\n`;
|
||||
default:
|
||||
throw new HostScriptError("OpenRC can't do that", 400);
|
||||
}
|
||||
}
|
||||
|
||||
export function openRcStatusScript(name: string): string {
|
||||
const s = q(requireName(name));
|
||||
return `rc-service ${s} status 2>&1; rc-update show -v 2>/dev/null | grep -E '^ *'${s}' *\\|'; exit 0\n`;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* How a script reaches the host: the command line runs a shell that reads
|
||||
* the script from stdin (`bash -s`), so names and values in a script are
|
||||
* never parsed out of argv.
|
||||
*/
|
||||
import { shellQuote, toBase64 } from "./text.js";
|
||||
|
||||
// systemctl and friends often live in sbin, which a normal login skips.
|
||||
// TZ=UTC so tools print every time in UTC, which parses the same anywhere.
|
||||
const ENV = "SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC";
|
||||
|
||||
/** Puts sbin on PATH and sets a plain, UTC environment. */
|
||||
export const SCRIPT_PRELUDE = `export PATH="$PATH:/usr/sbin:/sbin:/usr/local/sbin"; export ${ENV}; `;
|
||||
const SUDO = `sudo -n env PATH="$PATH" ${ENV}`;
|
||||
|
||||
/**
|
||||
* - `plain`: the login user.
|
||||
* - `auto`: root when it can be had without a password, else the login user.
|
||||
* - `root`: root or passwordless sudo, fails on anything else.
|
||||
* - `root-only`: root logins only, for hosts where sudo is turned off.
|
||||
*/
|
||||
export type ScriptShellMode = "plain" | "auto" | "root" | "root-only";
|
||||
|
||||
const ERR = "SCRIPT_ERR:";
|
||||
|
||||
/** The command line that runs a script sent on stdin. */
|
||||
export function scriptShell(mode: ScriptShellMode): string {
|
||||
switch (mode) {
|
||||
case "plain":
|
||||
return `${SCRIPT_PRELUDE}exec bash -s`;
|
||||
case "auto":
|
||||
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -s; else exec bash -s; fi`;
|
||||
case "root":
|
||||
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else exec ${SUDO} bash -s; fi`;
|
||||
case "root-only":
|
||||
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else echo "${ERR}This needs root, or turn on Use sudo for this host"; exit 1; fi`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a script as root with a sudo password. The command is `plain`; all of
|
||||
* the returned stdin goes to the host. The password is read by the first
|
||||
* line into a shell variable and piped to `sudo -S`, so it is never in argv,
|
||||
* never in the environment and never run as a command. Root logins and
|
||||
* passwordless sudo skip the password. The script itself travels as base64.
|
||||
*/
|
||||
export function passwordSudoScript(
|
||||
script: string,
|
||||
password: string,
|
||||
): { command: string; stdin: string } {
|
||||
if (/[\r\n\0]/.test(password)) {
|
||||
throw new Error("A sudo password can't contain a line break");
|
||||
}
|
||||
const run = `bash -c "$TERMIX_S"`;
|
||||
const stdin = `IFS= read -r TERMIX_PW
|
||||
${password}
|
||||
TERMIX_S=$(printf '%s' ${shellQuote(toBase64(script))} | base64 -d)
|
||||
if [ "$(id -u)" = "0" ]; then unset TERMIX_PW; exec ${run}
|
||||
elif sudo -n true 2>/dev/null; then unset TERMIX_PW; exec ${SUDO} ${run}
|
||||
else P=$TERMIX_PW; unset TERMIX_PW; printf '%s\\n' "$P" | sudo -k -S -p '' env PATH="$PATH" ${ENV} ${run}; exit $?
|
||||
fi
|
||||
`;
|
||||
return { command: scriptShell("plain"), stdin };
|
||||
}
|
||||
|
||||
/** sudo asked for a password or a terminal it did not get. */
|
||||
export const SUDO_REFUSED_RE =
|
||||
/password is required|a terminal is required|no tty present/i;
|
||||
/** sudo got a password and it was wrong. */
|
||||
export const SUDO_WRONG_RE =
|
||||
/incorrect password|sorry, try again|no password was provided/i;
|
||||
/** The user may not use sudo at all. */
|
||||
export const NOT_SUDOER_RE =
|
||||
/is not in the sudoers file|not allowed to run sudo/i;
|
||||
|
||||
/**
|
||||
* Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a
|
||||
* non zero exit to explain a failure.
|
||||
*/
|
||||
export function scriptError(stdout: string): string {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.startsWith(ERR)) return line.slice(ERR.length);
|
||||
if (line.startsWith("SERVICES_ERR:")) return line.slice(13);
|
||||
return null;
|
||||
})
|
||||
.filter((line): line is string => line !== null)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** A line a script prints to explain why it stops. */
|
||||
export function errLine(message: string): string {
|
||||
return `echo ${shellQuote(ERR + message)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a long command in the background and stops it once stdin closes,
|
||||
* which happens when the SSH channel ends. A pty can't be trusted for this:
|
||||
* sudo keeps its own and the command would outlive the viewer.
|
||||
*/
|
||||
export function watchdog(command: string): string {
|
||||
return `${command} &
|
||||
P=$!
|
||||
trap 'kill $P 2>/dev/null' EXIT
|
||||
cat >/dev/null
|
||||
`;
|
||||
}
|
||||
|
||||
/** The wrapper a streamed command runs in, as one shell command line. */
|
||||
export function streamCommand(script: string, sudo: boolean): string {
|
||||
const quoted = shellQuote(`${SCRIPT_PRELUDE}${script}`);
|
||||
return sudo
|
||||
? `if [ "$(id -u)" = "0" ]; then exec bash -c ${quoted}; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -c ${quoted}; else exec bash -c ${quoted}; fi`
|
||||
: `exec bash -c ${quoted}`;
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
/**
|
||||
* systemd unit scripts and parsers, plus init system detection.
|
||||
* Every script is meant for `scriptShell()`, sent on stdin.
|
||||
*/
|
||||
import { HostScriptError, parseKeys, shellQuote as q } from "./text.js";
|
||||
|
||||
export const UNIT_TYPES = [
|
||||
"service",
|
||||
"socket",
|
||||
"timer",
|
||||
"target",
|
||||
"mount",
|
||||
"automount",
|
||||
"path",
|
||||
"swap",
|
||||
"slice",
|
||||
"scope",
|
||||
"device",
|
||||
] as const;
|
||||
export type UnitType = (typeof UNIT_TYPES)[number];
|
||||
|
||||
// Backslash is allowed for escaped names like dev-disk-by\x2duuid.
|
||||
const UNIT_RE = new RegExp(
|
||||
`^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`,
|
||||
);
|
||||
const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/;
|
||||
|
||||
export function isUnit(name: unknown): name is string {
|
||||
return typeof name === "string" && UNIT_RE.test(name);
|
||||
}
|
||||
|
||||
export function unitType(name: string): UnitType {
|
||||
return name.slice(name.lastIndexOf(".") + 1) as UnitType;
|
||||
}
|
||||
|
||||
/** Units a file may be written for. Templates and device style names are out. */
|
||||
export function isWritableUnitName(name: string): boolean {
|
||||
return (
|
||||
isUnit(name) &&
|
||||
!name.includes("\\") &&
|
||||
["service", "socket", "timer", "target", "path", "mount"].includes(
|
||||
unitType(name),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isOpenRcName(name: unknown): name is string {
|
||||
return typeof name === "string" && OPENRC_RE.test(name);
|
||||
}
|
||||
|
||||
export function requireUnit(name: unknown): string {
|
||||
if (!isUnit(name)) throw new HostScriptError("That is not a valid unit name");
|
||||
return name;
|
||||
}
|
||||
|
||||
export type InitSystem = "systemd" | "openrc" | "none";
|
||||
|
||||
/** A unit name or, on OpenRC, a service name. */
|
||||
export function requireService(name: unknown, init: string): string {
|
||||
if (init === "openrc") {
|
||||
if (!isOpenRcName(name))
|
||||
throw new HostScriptError("That is not a valid service name");
|
||||
return name;
|
||||
}
|
||||
return requireUnit(name);
|
||||
}
|
||||
|
||||
export interface UnitSummary {
|
||||
name: string;
|
||||
type: string;
|
||||
load: string;
|
||||
active: string;
|
||||
sub: string;
|
||||
description: string;
|
||||
/** enabled, disabled, static, masked and so on. Empty when unknown. */
|
||||
fileState: string;
|
||||
preset: string;
|
||||
}
|
||||
|
||||
export interface UnitDetail {
|
||||
name: string;
|
||||
props: Record<string, string>;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TimerView {
|
||||
name: string;
|
||||
description: string;
|
||||
active: string;
|
||||
fileState: string;
|
||||
/** Unix ms, or null when not set. */
|
||||
next: number | null;
|
||||
last: number | null;
|
||||
activates: string;
|
||||
schedule: string[];
|
||||
}
|
||||
|
||||
export const UNIT_ACTIONS = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"reload",
|
||||
"enable",
|
||||
"disable",
|
||||
"enable-now",
|
||||
"disable-now",
|
||||
"mask",
|
||||
"unmask",
|
||||
"reset-failed",
|
||||
"revert",
|
||||
] as const;
|
||||
export type UnitAction = (typeof UNIT_ACTIONS)[number];
|
||||
|
||||
/** Actions OpenRC can do. */
|
||||
export const OPENRC_ACTIONS: readonly UnitAction[] = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"enable",
|
||||
"disable",
|
||||
];
|
||||
|
||||
export const DETECT_SCRIPT = `
|
||||
INIT=none
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd
|
||||
elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi
|
||||
echo "init=$INIT"
|
||||
if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi
|
||||
if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi
|
||||
echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"
|
||||
echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"
|
||||
echo "uid=$(id -u)"
|
||||
if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi
|
||||
if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi
|
||||
if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi
|
||||
exit 0
|
||||
`;
|
||||
|
||||
export interface Detected {
|
||||
init: InitSystem;
|
||||
version: string;
|
||||
os: string;
|
||||
hostname: string;
|
||||
root: boolean;
|
||||
journal: boolean;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export function parseDetect(output: string): Detected {
|
||||
const keys = parseKeys(output);
|
||||
const init =
|
||||
keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none";
|
||||
return {
|
||||
init,
|
||||
version: keys.version ?? "",
|
||||
os: keys.os ?? "",
|
||||
hostname: keys.hostname ?? "",
|
||||
root: keys.uid === "0",
|
||||
journal: keys.journal === "1",
|
||||
failed: Number(keys.failed) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Types the list can show. */
|
||||
export const LIST_TYPES = [
|
||||
"service",
|
||||
"socket",
|
||||
"timer",
|
||||
"target",
|
||||
"path",
|
||||
"mount",
|
||||
] as const;
|
||||
export type ListType = (typeof LIST_TYPES)[number] | "all";
|
||||
|
||||
export function listUnitsScript(type: ListType): string {
|
||||
const types = type === "all" ? LIST_TYPES.join(",") : type;
|
||||
if (type !== "all" && !LIST_TYPES.includes(type))
|
||||
throw new HostScriptError("Unknown unit type");
|
||||
return `
|
||||
systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
|
||||
echo "@@TERMIX_FILES@@"
|
||||
systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
|
||||
exit 0
|
||||
`;
|
||||
}
|
||||
|
||||
/** Merges list-units and list-unit-files. Template units are left out. */
|
||||
export function parseUnitList(output: string): UnitSummary[] {
|
||||
const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@");
|
||||
const units = new Map<string, UnitSummary>();
|
||||
for (const raw of loaded.split("\n")) {
|
||||
const line = raw.replace(/^[●*\s]+/, "").trimEnd();
|
||||
if (!line) continue;
|
||||
const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
|
||||
if (!match) continue;
|
||||
const [, name, load, active, sub, description] = match;
|
||||
if (!name.includes(".") || name.includes("@.")) continue;
|
||||
units.set(name, {
|
||||
name,
|
||||
type: unitType(name),
|
||||
load,
|
||||
active,
|
||||
sub,
|
||||
description: description.trim(),
|
||||
fileState: "",
|
||||
preset: "",
|
||||
});
|
||||
}
|
||||
for (const raw of files.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const [name, state = "", preset = ""] = line.split(/\s+/);
|
||||
if (!name || !name.includes(".") || name.includes("@.")) continue;
|
||||
const known = units.get(name);
|
||||
const cleanPreset = preset === "-" ? "" : preset;
|
||||
if (known) {
|
||||
known.fileState = state;
|
||||
known.preset = cleanPreset;
|
||||
} else {
|
||||
units.set(name, {
|
||||
name,
|
||||
type: unitType(name),
|
||||
load: state === "masked" ? "masked" : "unloaded",
|
||||
active: "inactive",
|
||||
sub: "dead",
|
||||
description: "",
|
||||
fileState: state,
|
||||
preset: cleanPreset,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export const DETAIL_PROPS = [
|
||||
"Id",
|
||||
"Description",
|
||||
"LoadState",
|
||||
"ActiveState",
|
||||
"SubState",
|
||||
"UnitFileState",
|
||||
"UnitFilePreset",
|
||||
"Result",
|
||||
"MainPID",
|
||||
"ExecMainStartTimestamp",
|
||||
"ActiveEnterTimestamp",
|
||||
"InactiveEnterTimestamp",
|
||||
"MemoryCurrent",
|
||||
"MemoryPeak",
|
||||
"CPUUsageNSec",
|
||||
"TasksCurrent",
|
||||
"NRestarts",
|
||||
"FragmentPath",
|
||||
"DropInPaths",
|
||||
"Requires",
|
||||
"Wants",
|
||||
"After",
|
||||
"WantedBy",
|
||||
"TriggeredBy",
|
||||
"Triggers",
|
||||
"User",
|
||||
"Group",
|
||||
"Restart",
|
||||
"Type",
|
||||
"ExecStart",
|
||||
"CanStart",
|
||||
"CanStop",
|
||||
"CanReload",
|
||||
] as const;
|
||||
|
||||
export function detailScript(unit: string): string {
|
||||
const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" ");
|
||||
return `
|
||||
systemctl show --no-pager ${props} -- ${q(requireUnit(unit))}
|
||||
echo "@@TERMIX_STATUS@@"
|
||||
systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1
|
||||
exit 0
|
||||
`;
|
||||
}
|
||||
|
||||
export function parseDetail(unit: string, output: string): UnitDetail {
|
||||
const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@");
|
||||
return { name: unit, props: parseKeys(props), status: status.trim() };
|
||||
}
|
||||
|
||||
const VERBS: Record<UnitAction, string> = {
|
||||
start: "start",
|
||||
stop: "stop",
|
||||
restart: "restart",
|
||||
reload: "reload",
|
||||
enable: "enable",
|
||||
disable: "disable",
|
||||
"enable-now": "enable --now",
|
||||
"disable-now": "disable --now",
|
||||
mask: "mask",
|
||||
unmask: "unmask",
|
||||
"reset-failed": "reset-failed",
|
||||
revert: "revert",
|
||||
};
|
||||
|
||||
/** The systemctl call for an action. Prints `state=` after it. */
|
||||
export function actionScript(unit: string, action: UnitAction): string {
|
||||
const verb = VERBS[action];
|
||||
if (!verb) throw new HostScriptError("Unknown action");
|
||||
const u = q(requireUnit(unit));
|
||||
return `
|
||||
systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; }
|
||||
echo "state=$(systemctl is-active -- ${u} 2>/dev/null)"
|
||||
`;
|
||||
}
|
||||
|
||||
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
|
||||
export function parseSystemdTime(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value);
|
||||
if (!match) return null;
|
||||
const [, y, mo, d, h, mi, s] = match.map(Number);
|
||||
return Date.UTC(y, mo - 1, d, h, mi, s);
|
||||
}
|
||||
|
||||
export const TIMERS_SCRIPT = `
|
||||
UNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u)
|
||||
[ -n "$UNITS" ] || exit 0
|
||||
systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS
|
||||
exit 0
|
||||
`;
|
||||
|
||||
/** Reads systemctl show output for many units, split by blank lines. */
|
||||
export function parseShowBlocks(output: string): Array<Record<string, string>> {
|
||||
const blocks: Array<Record<string, string>> = [];
|
||||
for (const block of output.split(/\n\s*\n/)) {
|
||||
const lines = block.split("\n").filter((line) => line.includes("="));
|
||||
if (!lines.length) continue;
|
||||
// A unit can have several TimersCalendar lines; keep all of them.
|
||||
const keys: Record<string, string> = {};
|
||||
for (const line of lines) {
|
||||
const eq = line.indexOf("=");
|
||||
const key = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1).trim();
|
||||
keys[key] = key in keys ? `${keys[key]}\n${value}` : value;
|
||||
}
|
||||
blocks.push(keys);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function schedules(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
const out: string[] = [];
|
||||
for (const line of value.split("\n")) {
|
||||
const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line);
|
||||
if (match) out.push(`${match[1]}=${match[2]}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseTimers(output: string): TimerView[] {
|
||||
return parseShowBlocks(output)
|
||||
.filter((keys) => keys.Id?.endsWith(".timer"))
|
||||
.map((keys) => ({
|
||||
name: keys.Id,
|
||||
description: keys.Description ?? "",
|
||||
active: keys.ActiveState ?? "",
|
||||
fileState: keys.UnitFileState ?? "",
|
||||
next: parseSystemdTime(keys.NextElapseUSecRealtime),
|
||||
last: parseSystemdTime(keys.LastTriggerUSec),
|
||||
activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "",
|
||||
schedule: [
|
||||
...schedules(keys.TimersCalendar),
|
||||
...schedules(keys.TimersMonotonic),
|
||||
],
|
||||
}))
|
||||
.sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity));
|
||||
}
|
||||
|
||||
/** One line per unit from systemctl is-active, in the order asked. */
|
||||
export function isActiveScript(units: string[]): string {
|
||||
return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`;
|
||||
}
|
||||
|
||||
export function parseIsActive(
|
||||
units: string[],
|
||||
output: string,
|
||||
): Map<string, string> {
|
||||
const lines = output.split("\n").map((line) => line.trim());
|
||||
const out = new Map<string, string>();
|
||||
units.forEach((unit, i) => out.set(unit, lines[i] || "unknown"));
|
||||
return out;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* Quoting, encoding and the error type the host scripts share.
|
||||
*
|
||||
* No Node APIs here: this entry also runs in the mobile app.
|
||||
*/
|
||||
|
||||
/** A bad input to a host script builder. `status` is the HTTP status to answer with. */
|
||||
export class HostScriptError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status = 400,
|
||||
readonly extra: Record<string, unknown> = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HostScriptError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps a value in single quotes for a POSIX shell. */
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// Written by hand because some JS engines (Hermes) have no TextDecoder.
|
||||
|
||||
/** UTF-8 bytes of a string. */
|
||||
export function utf8Encode(text: string): Uint8Array {
|
||||
const out: number[] = [];
|
||||
for (const char of text) {
|
||||
const cp = char.codePointAt(0) ?? 0xfffd;
|
||||
if (cp < 0x80) out.push(cp);
|
||||
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 63));
|
||||
else if (cp < 0x10000) {
|
||||
out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63));
|
||||
} else {
|
||||
out.push(
|
||||
0xf0 | (cp >> 18),
|
||||
0x80 | ((cp >> 12) & 63),
|
||||
0x80 | ((cp >> 6) & 63),
|
||||
0x80 | (cp & 63),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
/** A string from UTF-8 bytes. Bad sequences become U+FFFD. */
|
||||
export function utf8Decode(bytes: ArrayLike<number>): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < bytes.length) {
|
||||
const b = bytes[i];
|
||||
let need = 0;
|
||||
let cp = 0;
|
||||
if (b < 0x80) cp = b;
|
||||
else if (b >= 0xc2 && b < 0xe0) [need, cp] = [1, b & 31];
|
||||
else if (b >= 0xe0 && b < 0xf0) [need, cp] = [2, b & 15];
|
||||
else if (b >= 0xf0 && b < 0xf5) [need, cp] = [3, b & 7];
|
||||
else {
|
||||
out += "\ufffd";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = 1;
|
||||
for (; j <= need; j++) {
|
||||
const next = bytes[i + j];
|
||||
if (next === undefined || (next & 0xc0) !== 0x80) break;
|
||||
cp = (cp << 6) | (next & 63);
|
||||
}
|
||||
const tooSmall = [0, 0x80, 0x800, 0x10000][need];
|
||||
if (
|
||||
j <= need ||
|
||||
cp < tooSmall ||
|
||||
cp > 0x10ffff ||
|
||||
(cp >= 0xd800 && cp < 0xe000)
|
||||
) {
|
||||
out += "\ufffd";
|
||||
i += Math.max(1, j);
|
||||
continue;
|
||||
}
|
||||
out += String.fromCodePoint(cp);
|
||||
i += need + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/** Base64 of the UTF-8 bytes of a string, for `base64 -d` on the host. */
|
||||
export function toBase64(text: string): string {
|
||||
const bytes = utf8Encode(text);
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i];
|
||||
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||
const n = (a << 16) | (b << 8) | c;
|
||||
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
|
||||
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : "=";
|
||||
out += i + 2 < bytes.length ? B64[n & 63] : "=";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reads key=value lines. Only the first = splits a line. */
|
||||
export function parseKeys(output: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of output.split("\n")) {
|
||||
const eq = line.indexOf("=");
|
||||
if (eq > 0) out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trims output and keeps it short enough to show. */
|
||||
export function scrub(text: string, max = 600): string {
|
||||
return text.trim().slice(0, max);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.13.0";
|
||||
export const PLUGIN_API_VERSION = "1.12.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -526,36 +526,6 @@ export interface PluginContributions {
|
||||
layouts?: PluginLayoutContribution[];
|
||||
/** Themes. Needs the ui:theme capability. */
|
||||
themes?: PluginThemeContribution[];
|
||||
/** How the plugin shows in the Termix mobile app. Plugin API 1.13. */
|
||||
mobile?: PluginMobileContribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* - "webview": the app shows the plugin's UI from the server in a WebView.
|
||||
* - "native": the app has its own screens for this plugin.
|
||||
* - "none": nothing to show in the app.
|
||||
*/
|
||||
export const MOBILE_MODES = ["webview", "native", "none"] as const;
|
||||
export type PluginMobileMode = (typeof MOBILE_MODES)[number];
|
||||
|
||||
export const MOBILE_SIZES = ["phone", "tablet"] as const;
|
||||
export type PluginMobileSize = (typeof MOBILE_SIZES)[number];
|
||||
|
||||
export interface PluginMobileContribution {
|
||||
mode: PluginMobileMode;
|
||||
/**
|
||||
* webview: a bundle built for the app, in the same folder as the frontend
|
||||
* bundle. Unset means the normal frontend runs in a phone sized frame.
|
||||
*/
|
||||
entry?: string;
|
||||
/** webview: screen sizes the UI works at. Defaults to both. */
|
||||
sizes?: PluginMobileSize[];
|
||||
/** webview: tab, panel or card ids the app offers. Defaults to every tab. */
|
||||
views?: string[];
|
||||
/** native: the app feature that stands in for it. Defaults to the plugin id. */
|
||||
feature?: string;
|
||||
/** i18n key in the plugin's namespace, or plain text, shown in the app. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** A field a protocol login carries besides username and password. */
|
||||
@@ -795,7 +765,6 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"protocols",
|
||||
"layouts",
|
||||
"themes",
|
||||
"mobile",
|
||||
]);
|
||||
|
||||
const ALLOWED_SETTINGS_FIELD = [
|
||||
@@ -977,135 +946,10 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
typeof m.id === "string" ? m.id : undefined,
|
||||
errors,
|
||||
);
|
||||
if (isPlainObject(m.contributes)) {
|
||||
validateMobile(
|
||||
m.contributes,
|
||||
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function folderOf(file: string): string {
|
||||
const parts = file.split("/").filter((part) => part !== "" && part !== ".");
|
||||
return parts.slice(0, -1).join("/");
|
||||
}
|
||||
|
||||
function validateMobile(
|
||||
contributes: Record<string, unknown>,
|
||||
frontend: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
const mobile = contributes.mobile;
|
||||
if (mobile === undefined) return;
|
||||
const where = "contributes.mobile";
|
||||
if (!isPlainObject(mobile)) {
|
||||
errors.push(`Field "${where}" must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
mobile,
|
||||
["mode", "entry", "sizes", "views", "feature", "note"],
|
||||
`"${where}"`,
|
||||
errors,
|
||||
);
|
||||
const mode = mobile.mode;
|
||||
if (!(MOBILE_MODES as readonly unknown[]).includes(mode)) {
|
||||
errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
for (const key of ["entry", "sizes", "views"]) {
|
||||
if (mobile[key] !== undefined && mode !== "webview") {
|
||||
errors.push(`${where}.${key} is only for mode "webview"`);
|
||||
}
|
||||
}
|
||||
if (mobile.feature !== undefined && mode !== "native") {
|
||||
errors.push(`${where}.feature is only for mode "native"`);
|
||||
}
|
||||
|
||||
if (
|
||||
mobile.entry !== undefined &&
|
||||
requireString(mobile.entry, `${where}.entry`, errors)
|
||||
) {
|
||||
const before = errors.length;
|
||||
requireRelativePath(mobile.entry, `${where}.entry`, errors);
|
||||
if (!mobile.entry.endsWith(".js")) {
|
||||
errors.push(`${where}.entry must be a .js file`);
|
||||
} else if (
|
||||
errors.length === before &&
|
||||
folderOf(mobile.entry) !== folderOf(frontend)
|
||||
) {
|
||||
// Core only serves the folder the frontend bundle sits in.
|
||||
errors.push(
|
||||
`${where}.entry must sit in the same folder as the frontend bundle (${frontend})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mobile.sizes !== undefined) {
|
||||
const sizes = mobile.sizes;
|
||||
if (!Array.isArray(sizes) || sizes.length === 0) {
|
||||
errors.push(`${where}.sizes must be a non-empty array`);
|
||||
} else {
|
||||
const seen = new Set<unknown>();
|
||||
for (const size of sizes) {
|
||||
if (!(MOBILE_SIZES as readonly unknown[]).includes(size)) {
|
||||
errors.push(
|
||||
`${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`,
|
||||
);
|
||||
} else if (seen.has(size)) {
|
||||
errors.push(`${where}.sizes duplicates "${String(size)}"`);
|
||||
}
|
||||
seen.add(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mobile.views !== undefined) {
|
||||
const views = mobile.views;
|
||||
if (!Array.isArray(views) || views.length === 0) {
|
||||
errors.push(`${where}.views must be a non-empty array`);
|
||||
} else {
|
||||
const declared = new Set<string>();
|
||||
for (const field of ["tabs", "panels", "dashboardCards"]) {
|
||||
const list = contributes[field];
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const view of list) {
|
||||
if (isPlainObject(view) && typeof view.id === "string") {
|
||||
declared.add(view.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
const seen = new Set<unknown>();
|
||||
for (const view of views) {
|
||||
if (typeof view !== "string" || !declared.has(view)) {
|
||||
errors.push(
|
||||
`${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`,
|
||||
);
|
||||
} else if (seen.has(view)) {
|
||||
errors.push(`${where}.views duplicates "${view}"`);
|
||||
}
|
||||
seen.add(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
mobile.feature !== undefined &&
|
||||
(typeof mobile.feature !== "string" ||
|
||||
!LOOK_ID_PATTERN.test(mobile.feature))
|
||||
) {
|
||||
errors.push(
|
||||
`${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
|
||||
);
|
||||
}
|
||||
if (mobile.note !== undefined) {
|
||||
requireString(mobile.note, `${where}.note`, errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFeatures(features: unknown, errors: string[]): void {
|
||||
if (features === undefined) return;
|
||||
if (!Array.isArray(features)) {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
*
|
||||
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||
* --profile minimal|standard|full (or TERMIX_PLUGIN_PROFILE, default standard)
|
||||
* picks which tiers of the file are staged.
|
||||
* Each .tmxplug is downloaded (or read from a path), checked against its
|
||||
* pinned sha256 and unpacked.
|
||||
*
|
||||
@@ -27,16 +25,13 @@ const {
|
||||
localSdkBuildCommand,
|
||||
copyLocalBuild,
|
||||
buildBundledIndex,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const destination = path.join(root, "dist", "plugins");
|
||||
|
||||
async function main() {
|
||||
const profile = resolveProfile();
|
||||
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
|
||||
const plugins = loadBundledPlugins(root);
|
||||
const flag = process.argv.indexOf("--local");
|
||||
const localArg =
|
||||
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
||||
@@ -79,7 +74,7 @@ async function main() {
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`,
|
||||
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* Builds the pieces of the plugin SDK that come from core:
|
||||
* Builds the two pieces of the plugin SDK that come from core:
|
||||
*
|
||||
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
|
||||
* dist/host/testing-host.js what renderWithApp() renders plugins into
|
||||
* dist/host/core-classes.json the classes core's CSS has rules for
|
||||
* dist/host/frame-host.js runs one plugin outside the shell (mobile app)
|
||||
* dist/host/frame-host.css core's compiled CSS for that frame
|
||||
*
|
||||
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
|
||||
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
|
||||
@@ -48,7 +46,6 @@ await esbuild.build({
|
||||
entryPoints: {
|
||||
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
||||
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
|
||||
"frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"),
|
||||
},
|
||||
outdir: outDir,
|
||||
bundle: true,
|
||||
@@ -75,9 +72,6 @@ await esbuild.build({
|
||||
// so the translated locale files it would list are simply none.
|
||||
banner: { js: "const __termixNoGlob = () => ({});" },
|
||||
define: {
|
||||
// esbuild picks "development" for an unminified build, which sends core's
|
||||
// requests to the Vite dev proxy on any server without a port.
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
"import.meta.glob": "__termixNoGlob",
|
||||
"import.meta.env.DEV": "false",
|
||||
"import.meta.env.PROD": "true",
|
||||
@@ -163,16 +157,14 @@ const rootSource =
|
||||
const scanner = new Scanner({
|
||||
sources: [...rootSource, ...compiler.sources],
|
||||
});
|
||||
const coreCss = optimize(compiler.build(scanner.scan()), {
|
||||
minify: true,
|
||||
}).code;
|
||||
const coreClasses = [...classNamesInCss(coreCss)].sort();
|
||||
const coreClasses = [
|
||||
...classNamesInCss(
|
||||
optimize(compiler.build(scanner.scan()), { minify: true }).code,
|
||||
),
|
||||
].sort();
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "core-classes.json"),
|
||||
`${JSON.stringify(coreClasses)}\n`,
|
||||
);
|
||||
|
||||
// The frame host has no Vite build to load core's CSS, so it gets it here.
|
||||
fs.writeFileSync(path.join(outDir, "frame-host.css"), coreCss);
|
||||
|
||||
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
localSdkBuildCommand,
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
} from "./lib/bundled-plugins.cjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
@@ -265,52 +263,10 @@ describe("pinsFromIndex", () => {
|
||||
source: "tmxplug",
|
||||
url: "https://example.test/beta-1.1.0.tmxplug",
|
||||
sha256: "b".repeat(64),
|
||||
tier: "full",
|
||||
});
|
||||
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the tier of an existing pin", () => {
|
||||
const pins = pinsFromIndex(index, null, [
|
||||
{ id: "alpha", tier: "standard" },
|
||||
]);
|
||||
expect(pins[0].tier).toBe("standard");
|
||||
expect(pins[1].tier).toBe("full");
|
||||
});
|
||||
|
||||
it("skips releases whose engine.termix excludes the core version", () => {
|
||||
const withEngine = {
|
||||
plugins: [
|
||||
{
|
||||
id: "gamma",
|
||||
versions: [
|
||||
{
|
||||
version: "2.0.0",
|
||||
url: "https://example.test/gamma-2.0.0.tmxplug",
|
||||
sha256: "d".repeat(64),
|
||||
engine: { api: "^1", termix: ">=26.10.6" },
|
||||
},
|
||||
{
|
||||
version: "1.0.0",
|
||||
url: "https://example.test/gamma-1.0.0.tmxplug",
|
||||
sha256: "e".repeat(64),
|
||||
engine: { api: "^1", termix: ">=26.10.0" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(pinsFromIndex(withEngine, null, [], "26.10.5")[0].url).toBe(
|
||||
"https://example.test/gamma-1.0.0.tmxplug",
|
||||
);
|
||||
expect(pinsFromIndex(withEngine, null, [], "26.10.6")[0].url).toBe(
|
||||
"https://example.test/gamma-2.0.0.tmxplug",
|
||||
);
|
||||
expect(pinsFromIndex(withEngine, null)[0].url).toBe(
|
||||
"https://example.test/gamma-2.0.0.tmxplug",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the onboarding defaults of an existing pin", () => {
|
||||
const current = [
|
||||
{ id: "beta", onboarding: { recommended: true, consent: false } },
|
||||
@@ -555,61 +511,3 @@ describe("default look", () => {
|
||||
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin profiles", () => {
|
||||
const entry = (id: string, tier?: string) => ({
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: `https://x/${id}.tmxplug`,
|
||||
sha256: SHA,
|
||||
...(tier ? { tier } : {}),
|
||||
});
|
||||
const { plugins } = parseBundledPlugins({
|
||||
plugins: [
|
||||
entry("look", "minimal"),
|
||||
entry("term", "standard"),
|
||||
entry("extra", "full"),
|
||||
entry("untiered"),
|
||||
],
|
||||
});
|
||||
const ids = (profile: string) =>
|
||||
pluginsForProfile(plugins, profile).map((p: { id: string }) => p.id);
|
||||
|
||||
it("ships each tier and every smaller one", () => {
|
||||
expect(ids("minimal")).toEqual(["look"]);
|
||||
expect(ids("standard")).toEqual(["look", "term"]);
|
||||
expect(ids("full")).toEqual(["look", "term", "extra", "untiered"]);
|
||||
});
|
||||
|
||||
it("rejects an unknown tier", () => {
|
||||
const { problems } = parseBundledPlugins({ plugins: [entry("x", "huge")] });
|
||||
expect(problems[0]).toMatch(/tier must be one of/);
|
||||
});
|
||||
|
||||
it("reads the profile from the flag, then the environment", () => {
|
||||
expect(resolveProfile(["node", "x"], {})).toBe("standard");
|
||||
expect(
|
||||
resolveProfile(["node", "x"], { TERMIX_PLUGIN_PROFILE: "full" }),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveProfile(["node", "x", "--profile", "minimal"], {
|
||||
TERMIX_PLUGIN_PROFILE: "full",
|
||||
}),
|
||||
).toBe("minimal");
|
||||
expect(() =>
|
||||
resolveProfile(["node", "x", "--profile", "tiny"], {}),
|
||||
).toThrow(/unknown plugin profile/);
|
||||
});
|
||||
|
||||
it("keeps the real file valid with a default look in the minimal tier", () => {
|
||||
const real = loadBundledPlugins(path.resolve(__dirname, ".."));
|
||||
const minimal = pluginsForProfile(real, "minimal");
|
||||
expect(minimal.length).toBeGreaterThan(0);
|
||||
expect(minimal.every((p: { defaultLook: boolean }) => p.defaultLook)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(pluginsForProfile(real, "standard").length).toBeLessThan(
|
||||
real.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,6 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
|
||||
const INTERNAL_EXPORTS = new Set([
|
||||
"./testing/setup",
|
||||
"./testing-host",
|
||||
"./frame-host",
|
||||
"./frame-host.css",
|
||||
"./tsconfig.plugin.json",
|
||||
"./schema/manifest.schema.json",
|
||||
"./theme.css",
|
||||
|
||||
@@ -16,11 +16,6 @@
|
||||
* environment variables is set, for a server configured before first boot.
|
||||
* Plugins without it start unchecked.
|
||||
*
|
||||
* "tier" says which image profile ships the plugin: "minimal" (only what the
|
||||
* app cannot start without, the default look), "standard" (the default image)
|
||||
* or "full" (everything else). A profile includes its own tier and every
|
||||
* smaller one. A plugin with no tier counts as "full".
|
||||
*
|
||||
* "defaultLook": true marks the plugin whose layout (and that layout's
|
||||
* theme) a fresh install shows. Core finds the default look this way, so it
|
||||
* never names a plugin. Such a plugin always starts enabled and cannot be
|
||||
@@ -36,8 +31,6 @@ const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
||||
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const PROFILES = ["minimal", "standard", "full"];
|
||||
const DEFAULT_PROFILE = "standard";
|
||||
|
||||
function parseOnboarding(value, id, problems) {
|
||||
if (value === undefined) return null;
|
||||
@@ -129,9 +122,6 @@ function parseBundledPlugins(raw) {
|
||||
) {
|
||||
problems.push(`${entry.id} defaultLook must be a boolean`);
|
||||
}
|
||||
if (entry.tier !== undefined && !PROFILES.includes(entry.tier)) {
|
||||
problems.push(`${entry.id} tier must be one of ${PROFILES.join(", ")}`);
|
||||
}
|
||||
if (problems.length > before) continue;
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
@@ -141,7 +131,6 @@ function parseBundledPlugins(raw) {
|
||||
sha256: entry.sha256,
|
||||
onboarding,
|
||||
defaultLook: entry.defaultLook === true,
|
||||
tier: entry.tier ?? "full",
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
@@ -252,35 +241,12 @@ function buildBundledIndex(plugins, stagedIds) {
|
||||
return { version: 1, plugins: out };
|
||||
}
|
||||
|
||||
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
|
||||
function resolveProfile(argv = process.argv, env = process.env) {
|
||||
const flag = argv.indexOf("--profile");
|
||||
const value =
|
||||
(flag !== -1 ? argv[flag + 1] : env.TERMIX_PLUGIN_PROFILE) ||
|
||||
DEFAULT_PROFILE;
|
||||
if (!PROFILES.includes(value)) {
|
||||
throw new Error(
|
||||
`unknown plugin profile "${value}", use ${PROFILES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The plugins a profile ships: its own tier and every smaller one. */
|
||||
function pluginsForProfile(plugins, profile) {
|
||||
const rank = PROFILES.indexOf(profile);
|
||||
return plugins.filter((plugin) => PROFILES.indexOf(plugin.tier) <= rank);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the newest registry version of each wanted plugin. `ids` is the list
|
||||
* to pin, or null for every plugin in the index. `current` is the existing
|
||||
* pin list, so fields the registry does not know (onboarding, tier) carry over;
|
||||
* a plugin new to the list starts in the full tier. coreVersion, when given,
|
||||
* skips releases whose engine.termix range does not include it, so the image
|
||||
* never ships a plugin its own core cannot run.
|
||||
* pin list, so fields the registry does not know (onboarding) carry over.
|
||||
*/
|
||||
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||
function pinsFromIndex(index, ids, current = []) {
|
||||
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
|
||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||
@@ -291,25 +257,15 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
|
||||
.filter((entry) => semver.valid(entry.version))
|
||||
.filter((entry) => !semver.prerelease(entry.version))
|
||||
.filter(
|
||||
(entry) =>
|
||||
!coreVersion ||
|
||||
!entry.engine?.termix ||
|
||||
semver.satisfies(coreVersion, entry.engine.termix, {
|
||||
includePrerelease: true,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
||||
const onboarding = currentById.get(id)?.onboarding;
|
||||
const defaultLook = currentById.get(id)?.defaultLook === true;
|
||||
const tier = currentById.get(id)?.tier ?? "full";
|
||||
return {
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: latest.url,
|
||||
sha256: latest.sha256,
|
||||
tier,
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
...(defaultLook ? { defaultLook } : {}),
|
||||
};
|
||||
@@ -453,9 +409,6 @@ function copyLocalBuild(repo, destination) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROFILES,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
findLocalPluginBuilds,
|
||||
isLocalBuildStale,
|
||||
localSdkBuildCommand,
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
* re-released.
|
||||
*
|
||||
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
|
||||
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full)
|
||||
* --core-version X skip releases whose engine.termix excludes X
|
||||
*
|
||||
* The release workflow runs it with --all and the version being released.
|
||||
* node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
@@ -25,8 +22,6 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
|
||||
|
||||
async function main() {
|
||||
const all = process.argv.includes("--all");
|
||||
const flag = process.argv.indexOf("--core-version");
|
||||
const coreVersion = flag !== -1 ? process.argv[flag + 1] : null;
|
||||
const response = await fetch(INDEX_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`registry index download failed with ${response.status}`);
|
||||
@@ -35,7 +30,7 @@ async function main() {
|
||||
|
||||
const current = loadBundledPlugins(root);
|
||||
const ids = all ? null : current.map((plugin) => plugin.id);
|
||||
const plugins = pinsFromIndex(index, ids, current, coreVersion);
|
||||
const plugins = pinsFromIndex(index, ids, current);
|
||||
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
|
||||
console.log(
|
||||
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
|
||||
|
||||
@@ -144,32 +144,3 @@ describe("termix-plugin validate layouts and capabilities", () => {
|
||||
expect(output).not.toContain("is declared but");
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin validate mobile entry", () => {
|
||||
function mobile(built: boolean) {
|
||||
const root = plugin([]);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "fixture",
|
||||
version: "1.0.0",
|
||||
contributes: { mobile: { mode: "webview", entry: "dist/mobile.js" } },
|
||||
}),
|
||||
);
|
||||
if (built) {
|
||||
fs.mkdirSync(path.join(root, "dist"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "dist", "mobile.js"), "");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
it("reports a mobile entry that was not built", async () => {
|
||||
expect(await problems(mobile(false))).toContain(
|
||||
"manifest references dist/mobile.js, which does not exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds a built mobile entry", async () => {
|
||||
expect(await problems(mobile(true))).not.toContain("dist/mobile.js");
|
||||
});
|
||||
});
|
||||
@@ -1316,8 +1316,6 @@ const migrateSchema = () => {
|
||||
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
|
||||
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
|
||||
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
|
||||
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
|
||||
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
|
||||
];
|
||||
|
||||
for (const migration of sshDataMigrations) {
|
||||
|
||||
@@ -193,9 +193,6 @@ export const hosts = mysqlTable(
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
notes: text("notes"),
|
||||
// Hex color and Lucide icon name a layout may show next to the host.
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
|
||||
sshPort: int("ssh_port").default(22),
|
||||
|
||||
@@ -193,9 +193,6 @@ export const hosts = pgTable(
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
notes: text("notes"),
|
||||
// Hex color and Lucide icon name a layout may show next to the host.
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
|
||||
sshPort: integer("ssh_port").default(22),
|
||||
|
||||
@@ -183,9 +183,6 @@ export const hosts = sqliteTable(
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
notes: text("notes"),
|
||||
// Hex color and Lucide icon name a layout may show next to the host.
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
||||
|
||||
sshPort: integer("ssh_port").default(22),
|
||||
|
||||
@@ -30,33 +30,6 @@ export function applyHostKeyTypeUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
|
||||
|
||||
/**
|
||||
* Color and icon from a host write. A field left out stays as stored, so
|
||||
* editors that don't know about them never clear them. Bad values store null.
|
||||
*/
|
||||
export function normalizeHostLookFields(values: Record<string, unknown>): {
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
} {
|
||||
const result: { color?: string | null; icon?: string | null } = {};
|
||||
if (values.color !== undefined) {
|
||||
result.color =
|
||||
typeof values.color === "string" && HOST_COLOR.test(values.color)
|
||||
? values.color.toLowerCase()
|
||||
: null;
|
||||
}
|
||||
if (values.icon !== undefined) {
|
||||
result.icon =
|
||||
typeof values.icon === "string" && HOST_ICON.test(values.icon)
|
||||
? values.icon
|
||||
: null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
|
||||
|
||||
export function normalizeProtocolEnableFields(
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
isOptionalAuthType,
|
||||
isOptionalBoolean,
|
||||
isValidPort,
|
||||
normalizeHostLookFields,
|
||||
normalizeProtocolEnableFields,
|
||||
OWNER_PRIVATE_AUTH_FIELDS,
|
||||
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
||||
@@ -336,7 +335,6 @@ router.post(
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
...normalizeProtocolEnableFields(hostData),
|
||||
...normalizeHostLookFields(hostData),
|
||||
sshPort: sshPort || port || 22,
|
||||
};
|
||||
|
||||
@@ -914,7 +912,6 @@ router.put(
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
...normalizeProtocolEnableFields(hostData),
|
||||
...normalizeHostLookFields(hostData),
|
||||
sshPort: sshPort || port || 22,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
safeLayoutForced,
|
||||
unavailableLooks,
|
||||
} from "../../plugins/looks.js";
|
||||
import { safeModeOn } from "../../plugins/safe-mode.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -37,7 +36,7 @@ async function adminDefaults() {
|
||||
* /user-preferences/look:
|
||||
* get:
|
||||
* summary: Get the layout and theme to show the current user
|
||||
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, whether TERMIX_SAFE_MODE runs only layout and theme plugins, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* responses:
|
||||
@@ -76,8 +75,6 @@ async function adminDefaults() {
|
||||
* nullable: true
|
||||
* safeLayout:
|
||||
* type: boolean
|
||||
* safeMode:
|
||||
* type: boolean
|
||||
* unavailable:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -115,7 +112,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
defaults: { ...defaults, layout: known(defaults.layout) },
|
||||
defaultLook: resolveDefaultLook(loader),
|
||||
safeLayout: safeLayoutForced(),
|
||||
safeMode: safeModeOn(),
|
||||
unavailable: unavailableLooks(loader),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
|
||||
import { mayRunInSafeMode } from "../../plugins/safe-mode.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
@@ -244,17 +243,12 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||
* `dependencies` and `optionalDependencies` let the loader activate
|
||||
* frontends in the same order the server does. `engine` holds the
|
||||
* manifest's `api` and `termix` ranges, so other clients (the mobile
|
||||
* app) can tell whether they understand the plugin.
|
||||
* frontends in the same order the server does.
|
||||
*
|
||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||
* only run on that server and stay off here on purpose, and, for
|
||||
* admins, `managedByServer` marks plugins whose on/off state the server
|
||||
* decides.
|
||||
*
|
||||
* While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps
|
||||
* off reports `state: "safe-mode"`.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -305,7 +299,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let features: string[] = [];
|
||||
let docs: string | undefined;
|
||||
let env: PluginEnvVar[] = [];
|
||||
const engine: { api?: string; termix?: string } = {};
|
||||
let desktopMode: unknown = loaded
|
||||
? resolvePluginDesktopMode(loaded.manifest)
|
||||
: undefined;
|
||||
@@ -325,14 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
optionalDependencies?: Record<string, string>;
|
||||
desktop?: unknown;
|
||||
runtime?: unknown;
|
||||
engine?: { api?: unknown; termix?: unknown };
|
||||
};
|
||||
if (typeof manifest?.engine?.api === "string") {
|
||||
engine.api = manifest.engine.api;
|
||||
}
|
||||
if (typeof manifest?.engine?.termix === "string") {
|
||||
engine.termix = manifest.engine.termix;
|
||||
}
|
||||
desktopMode = loaded
|
||||
? resolvePluginDesktopMode(loaded.manifest)
|
||||
: (manifest?.desktop ??
|
||||
@@ -368,14 +354,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state:
|
||||
record.state === "enabled" &&
|
||||
loaded?.state !== "active" &&
|
||||
!mayRunInSafeMode(record.id, loader.list())
|
||||
? "safe-mode"
|
||||
: (loaded?.state ?? record.state),
|
||||
state: loaded?.state ?? record.state,
|
||||
runsOnServer: linked && desktopMode === "server",
|
||||
engine,
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
@@ -956,7 +936,7 @@ router.delete(
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
* 409:
|
||||
* description: This desktop is linked to a server, which decides whether the plugin runs, or safe mode is on and the plugin is not a layout or theme.
|
||||
* description: This desktop is linked to a server, which decides whether the plugin runs.
|
||||
*/
|
||||
router.patch(
|
||||
"/:id/state",
|
||||
@@ -1070,8 +1050,6 @@ router.patch(
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin, or it is not on disk.
|
||||
* 409:
|
||||
* description: Safe mode is on and the plugin is not a layout or theme.
|
||||
* 500:
|
||||
* description: The plugin failed to start again.
|
||||
*/
|
||||
@@ -1095,13 +1073,6 @@ router.post(
|
||||
res.status(404).json({ error: "Plugin is not present on disk" });
|
||||
return;
|
||||
}
|
||||
if (!mayRunInSafeMode(pluginId, loader.list())) {
|
||||
res.status(409).json({
|
||||
error: "Safe mode is on, so only layout and theme plugins can start",
|
||||
code: "SAFE_MODE",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await repository.update(pluginId, {
|
||||
state: "enabled",
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { createCurrentUserRepository } from "../repositories/factory.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
export function registerUserDeleteAccountRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/delete-account:
|
||||
* delete:
|
||||
* summary: Delete user account
|
||||
* description: Deletes the authenticated user's account and all of their data.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Account deleted successfully.
|
||||
* 400:
|
||||
* description: Password is required.
|
||||
* 401:
|
||||
* description: Incorrect password.
|
||||
* 403:
|
||||
* description: Cannot delete external authentication accounts or the last admin user.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to delete account.
|
||||
*/
|
||||
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(password)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Password is required to delete account" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const userRecord = await userRepository.findById(userId);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (userRecord.isExternal) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Cannot delete external authentication accounts through this endpoint",
|
||||
});
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||
if (!isMatch) {
|
||||
authLogger.warn(
|
||||
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
||||
);
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Cannot delete the last admin user" });
|
||||
}
|
||||
|
||||
// Same cleanup as an admin delete, so plugins hear user.deleted too.
|
||||
await deleteUserAndRelatedData(userId);
|
||||
|
||||
authLogger.success(`User account deleted: ${userRecord.username}`);
|
||||
res.json({ message: "Account deleted successfully" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete user account", err);
|
||||
res.status(500).json({ error: "Failed to delete account" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { shownIdentity } from "../../sync/shown-identity.js";
|
||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
|
||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
||||
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
||||
@@ -319,7 +318,7 @@ router.post("/proxy-login", async (req, res) => {
|
||||
try {
|
||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||
authLogger.warn(
|
||||
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
|
||||
"Rejected proxy authentication from an untrusted source",
|
||||
{
|
||||
operation: "trusted_proxy_auth_rejected",
|
||||
sourceAddress,
|
||||
@@ -1230,7 +1229,86 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
registerUserDeleteAccountRoutes(router, authenticateJWT);
|
||||
/**
|
||||
* @openapi
|
||||
* /users/delete-account:
|
||||
* delete:
|
||||
* summary: Delete user account
|
||||
* description: Deletes the authenticated user's account.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Account deleted successfully.
|
||||
* 400:
|
||||
* description: Password is required.
|
||||
* 401:
|
||||
* description: Incorrect password.
|
||||
* 403:
|
||||
* description: Cannot delete external authentication accounts or the last admin user.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to delete account.
|
||||
*/
|
||||
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
|
||||
if (!isNonEmptyString(password)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Password is required to delete account" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRecord = await findCurrentUser(userId);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (userRecord.isExternal) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Cannot delete external authentication accounts through this endpoint",
|
||||
});
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||
if (!isMatch) {
|
||||
authLogger.warn(
|
||||
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
||||
);
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
if (userRecord.isAdmin) {
|
||||
const adminCount = await createCurrentUserRepository().countAdmins();
|
||||
if (adminCount <= 1) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Cannot delete the last admin user" });
|
||||
}
|
||||
}
|
||||
|
||||
await createCurrentUserRepository().delete(userId);
|
||||
|
||||
authLogger.success(`User account deleted: ${userRecord.username}`);
|
||||
res.json({ message: "Account deleted successfully" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete user account", err);
|
||||
res.status(500).json({ error: "Failed to delete account" });
|
||||
}
|
||||
});
|
||||
|
||||
registerUserPasswordResetRoutes(router, { authManager });
|
||||
|
||||
|
||||
@@ -73,8 +73,6 @@ async function exportRow(
|
||||
: resolvedHost.tags || [],
|
||||
pin: !!resolvedHost.pin,
|
||||
notes: resolvedHost.notes || null,
|
||||
color: resolvedHost.color || null,
|
||||
icon: resolvedHost.icon || null,
|
||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
||||
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
||||
// Each plugin protocol's login, secrets included like the SSH ones.
|
||||
|
||||
@@ -28,7 +28,6 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
normalizeHostLookFields,
|
||||
normalizeImportedHost,
|
||||
} from "../../database/routes/host-normalizers.js";
|
||||
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
||||
@@ -572,7 +571,6 @@ export async function runBulkImport(
|
||||
? "true"
|
||||
: "false",
|
||||
notes: hostData.notes || null,
|
||||
...normalizeHostLookFields(hostData as Record<string, unknown>),
|
||||
useSocks5: hostData.useSocks5 ? 1 : 0,
|
||||
socks5Host: hostData.socks5Host || null,
|
||||
socks5Port: hostData.socks5Port || null,
|
||||
|
||||
@@ -14,24 +14,3 @@ export function startablePluginIds(
|
||||
.map((record) => record.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins that shipped in an earlier image and were left on, but whose code is
|
||||
* not in this one (a smaller image tag). They get fetched from the registry.
|
||||
*/
|
||||
export function missingShippedPluginIds(
|
||||
records: { id: string; tier: string; state: string }[],
|
||||
presentIds: ReadonlySet<string>,
|
||||
ignored: ReadonlySet<string>,
|
||||
): string[] {
|
||||
return records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.tier === "bundled" &&
|
||||
record.state !== "disabled" &&
|
||||
!presentIds.has(record.id) &&
|
||||
!ignored.has(record.id),
|
||||
)
|
||||
.map((record) => record.id)
|
||||
.sort();
|
||||
}
|
||||
@@ -23,11 +23,6 @@ import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
|
||||
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
import { startablePluginIds } from "./boot-state.js";
|
||||
import {
|
||||
mayRunInSafeMode,
|
||||
safeModeOn,
|
||||
safeModePluginIds,
|
||||
} from "./safe-mode.js";
|
||||
import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
|
||||
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
|
||||
|
||||
@@ -242,10 +237,6 @@ export async function initializePlugins(
|
||||
if (loaded.length === 0) return [];
|
||||
|
||||
const fresh = await seedPlugins(loaded, options.freshInstall === true);
|
||||
if (!fresh) {
|
||||
const { restoreMissingPlugins } = await import("./manage.js");
|
||||
loaded.push(...(await restoreMissingPlugins()));
|
||||
}
|
||||
await syncCapabilityGrants(loaded);
|
||||
if (loaded.some((plugin) => plugin.source === "bundled")) {
|
||||
const { offerPluginSetup } = await import("./onboarding.js");
|
||||
@@ -257,17 +248,8 @@ export async function initializePlugins(
|
||||
const records = await createCurrentPluginRepository().listAll();
|
||||
const enabled = startablePluginIds(records);
|
||||
|
||||
const safe = safeModeOn() ? safeModePluginIds(loaded) : null;
|
||||
if (safe) {
|
||||
pluginLogger.warn(
|
||||
"Safe mode is on (TERMIX_SAFE_MODE). Only layout and theme plugins start",
|
||||
{ operation: "plugin_init" },
|
||||
);
|
||||
}
|
||||
|
||||
const candidates = loaded
|
||||
.filter((plugin) => enabled.has(plugin.id))
|
||||
.filter((plugin) => !safe || safe.has(plugin.id))
|
||||
.map((plugin) => plugin.id);
|
||||
|
||||
for (const pluginId of candidates) {
|
||||
@@ -557,8 +539,6 @@ export async function activatePlugin(pluginId: string): Promise<void> {
|
||||
// Before activate: a plugin's own activate() may already want to check one
|
||||
// of its permissions.
|
||||
const plugin = pluginLoader.get(pluginId);
|
||||
// Stays enabled and starts on the next boot without safe mode.
|
||||
if (!mayRunInSafeMode(pluginId, pluginLoader.list())) return;
|
||||
if (plugin) await registerPluginPermissions(plugin);
|
||||
|
||||
await pluginLoader.activate(pluginId);
|
||||
|
||||
@@ -18,7 +18,6 @@ import path from "node:path";
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { isDefaultLookPlugin } from "./bundled-index.js";
|
||||
import { mayRunInSafeMode } from "./safe-mode.js";
|
||||
import {
|
||||
activatePlugin,
|
||||
deactivatePlugin,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
registerLoadedPlugins,
|
||||
unloadPlugin,
|
||||
} from "./index.js";
|
||||
import { missingShippedPluginIds } from "./boot-state.js";
|
||||
import type { LoadedPlugin, PluginLoader } from "./loader.js";
|
||||
import {
|
||||
getBundledPluginsDir,
|
||||
@@ -765,13 +763,6 @@ export async function setPluginStateUnlocked(
|
||||
if (!loader.get(pluginId)) {
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
if (enabled && !mayRunInSafeMode(pluginId, loader.list())) {
|
||||
throw new PluginManageError(
|
||||
"Safe mode is on, so only layout and theme plugins can start",
|
||||
409,
|
||||
"SAFE_MODE",
|
||||
);
|
||||
}
|
||||
const plan = await planStateChange(pluginId, enabled);
|
||||
|
||||
if (enabled) {
|
||||
@@ -968,11 +959,7 @@ export function installPlugin(
|
||||
return serialized(async () => {
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
// A record with no code is a plugin an older image shipped: install fills it in.
|
||||
if (
|
||||
(await repository.findById(pluginId)) &&
|
||||
getPluginRuntime().loader.get(pluginId)
|
||||
) {
|
||||
if (await repository.findById(pluginId)) {
|
||||
throw new PluginManageError(
|
||||
"Plugin is already installed",
|
||||
409,
|
||||
@@ -1031,63 +1018,6 @@ export function installPlugin(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at boot before plugins start. A plugin the database has on but the
|
||||
* image no longer ships (an update to a smaller image tag) is fetched from the
|
||||
* official registry, so nothing an install used goes missing. One that cannot
|
||||
* be fetched stays as it was and is tried again on the next start.
|
||||
*/
|
||||
export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
|
||||
return serialized(async () => {
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
const { loader } = getPluginRuntime();
|
||||
const ids = missingShippedPluginIds(
|
||||
await repository.listAll(),
|
||||
new Set(loader.list().map((plugin) => plugin.id)),
|
||||
await readUninstalled(),
|
||||
);
|
||||
const restored: LoadedPlugin[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const record = await repository.findById(id);
|
||||
const { source, release } = await requireRelease(
|
||||
id,
|
||||
record?.pinnedVersion ?? undefined,
|
||||
channelOf(record),
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
);
|
||||
const plugin = await swapIn(id, { release, source });
|
||||
await repository.update(id, {
|
||||
registryId: source.id,
|
||||
tier: tierFor(plugin, source),
|
||||
});
|
||||
await grantDeclared(plugin, null);
|
||||
restored.push(plugin);
|
||||
pluginLogger.info(
|
||||
`Restored ${id}@${release.version} from the registry`,
|
||||
{
|
||||
operation: "plugin_restore_missing",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
|
||||
operation: "plugin_restore_missing",
|
||||
});
|
||||
// The registry is down or offline, so the rest would fail the same way.
|
||||
if (
|
||||
error instanceof PluginManageError &&
|
||||
error.code === "REGISTRY_UNREACHABLE"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlugin(
|
||||
pluginId: string,
|
||||
options: InstallOptions,
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import {
|
||||
@@ -24,11 +23,7 @@ import {
|
||||
youtubeVideoId,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import {
|
||||
isBlockedAddress,
|
||||
safeOutboundFetch,
|
||||
} from "../utils/safe-outbound-fetch.js";
|
||||
import { fetchWithProxy, getProxyAgent } from "../utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
COMMUNITY_PLUGIN_KEYS,
|
||||
@@ -283,72 +278,15 @@ export function canRunRelease(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the admin lets registry fetches reach on a private address, like a
|
||||
* registry in the LAN. Matched by host name on any port, also when another
|
||||
* index or a redirect points there; every other host stays blocked.
|
||||
*/
|
||||
export function getRegistryPrivateHosts(): string[] {
|
||||
return (process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizePrivateHost)
|
||||
.filter((host): host is string => host !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host in the form safeOutboundFetch compares against, or undefined when
|
||||
* the entry carries more than a host, like a scheme, port, path or user.
|
||||
*/
|
||||
function normalizePrivateHost(entry: string): string | undefined {
|
||||
const candidate = isIP(entry) === 6 ? `[${entry}]` : entry;
|
||||
if (/[/?#@\\]|:\d*$/.test(candidate)) return undefined;
|
||||
try {
|
||||
return new URL(`http://${candidate}`).hostname.replace(/^\[|\]$/g, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* With HTTP_PROXY or HTTPS_PROXY set, registry traffic goes through the proxy,
|
||||
* which does its own name lookup. Only the URL itself is checked here: web
|
||||
* pages only, no logins in it, and no private address typed in as a literal.
|
||||
*/
|
||||
function fetchThroughProxy(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
privateHosts: readonly string[],
|
||||
): Promise<Response> {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||
if (
|
||||
!["http:", "https:"].includes(parsed.protocol) ||
|
||||
parsed.username ||
|
||||
parsed.password
|
||||
) {
|
||||
throw new Error("Invalid outbound URL");
|
||||
}
|
||||
if (isIP(host) && isBlockedAddress(host) && !privateHosts.includes(host)) {
|
||||
throw new Error("Private destinations are not allowed");
|
||||
}
|
||||
return fetchWithProxy(url, options);
|
||||
}
|
||||
|
||||
/** Follows redirects by hand so each hop still gets the outbound checks. */
|
||||
async function fetchFollowing(url: string): Promise<Response> {
|
||||
const privateHosts = getRegistryPrivateHosts();
|
||||
let current = url;
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const options: RequestInit = {
|
||||
const response = await safeOutboundFetch(current, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
headers: { "User-Agent": "Termix" },
|
||||
};
|
||||
const response = getProxyAgent(current)
|
||||
? await fetchThroughProxy(current, options, privateHosts)
|
||||
: await safeOutboundFetch(current, options, privateHosts);
|
||||
});
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) throw new Error(`redirect without a location: ${current}`);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
/** TERMIX_SAFE_MODE=1 starts only the plugins that draw the app. */
|
||||
export function safeModeOn(): boolean {
|
||||
const value = process.env.TERMIX_SAFE_MODE?.trim().toLowerCase();
|
||||
return value === "1" || value === "true";
|
||||
}
|
||||
|
||||
function isLookPlugin(manifest: PluginManifest): boolean {
|
||||
return (
|
||||
(manifest.contributes?.layouts?.length ?? 0) > 0 ||
|
||||
(manifest.contributes?.themes?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Layout and theme plugins, plus the plugins they need to start. */
|
||||
export function safeModePluginIds(
|
||||
plugins: { id: string; manifest: PluginManifest }[],
|
||||
): Set<string> {
|
||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
const allowed = new Set<string>();
|
||||
const add = (id: string) => {
|
||||
const plugin = byId.get(id);
|
||||
if (!plugin || allowed.has(id)) return;
|
||||
allowed.add(id);
|
||||
for (const dependency of Object.keys(plugin.manifest.dependencies ?? {})) {
|
||||
add(dependency);
|
||||
}
|
||||
};
|
||||
for (const plugin of plugins) {
|
||||
if (isLookPlugin(plugin.manifest)) add(plugin.id);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/** Whether a plugin may run now. Always true outside safe mode. */
|
||||
export function mayRunInSafeMode(
|
||||
pluginId: string,
|
||||
plugins: { id: string; manifest: PluginManifest }[],
|
||||
): boolean {
|
||||
return !safeModeOn() || safeModePluginIds(plugins).has(pluginId);
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory
|
||||
* On a linked desktop, hosts and credentials someone shared with the account
|
||||
* are read-only copies kept in step by sync. Editing or deleting one here
|
||||
* would be undone by the next sync, so it is refused with a clear reason.
|
||||
* A host's connection origin only means something on this device, so that one
|
||||
* field can still be changed.
|
||||
*/
|
||||
export function rejectSharedCopyWrites(
|
||||
kind: "host" | "credential",
|
||||
@@ -27,19 +25,6 @@ export function rejectSharedCopyWrites(
|
||||
.where(eq(table.id, Number(match[1])))
|
||||
.limit(1);
|
||||
if (row?.sharedSource) {
|
||||
const origin = req.body?.connectionOrigin;
|
||||
if (
|
||||
kind === "host" &&
|
||||
req.method === "PUT" &&
|
||||
(origin === "local" || origin === "remote")
|
||||
) {
|
||||
await createCurrentRepositoryContext()
|
||||
.drizzle.update(hosts)
|
||||
.set({ connectionOrigin: origin })
|
||||
.where(eq(hosts.id, Number(match[1])));
|
||||
res.json({ id: Number(match[1]), connectionOrigin: origin });
|
||||
return;
|
||||
}
|
||||
res.status(403).json({
|
||||
error: "This was shared with you and is managed on the server",
|
||||
code: "SHARED_COPY",
|
||||
|
||||
@@ -214,43 +214,3 @@ describe("host.created", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("host color and icon", () => {
|
||||
async function stored(id: number) {
|
||||
const [row] = await ctx.drizzle
|
||||
.select({ color: hosts.color, icon: hosts.icon })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, id));
|
||||
return row;
|
||||
}
|
||||
|
||||
it("saves them on create and keeps them when an update leaves them out", async () => {
|
||||
const created = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({
|
||||
...base,
|
||||
authType: "password",
|
||||
password: "pw",
|
||||
color: "#22C55E",
|
||||
icon: "database",
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
expect(await stored(created.body.id)).toEqual({
|
||||
color: "#22c55e",
|
||||
icon: "database",
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, name: "renamed" });
|
||||
expect(await stored(created.body.id)).toEqual({
|
||||
color: "#22c55e",
|
||||
icon: "database",
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, color: null, icon: null });
|
||||
expect(await stored(created.body.id)).toEqual({ color: null, icon: null });
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
keepsStoredSshAuth,
|
||||
isOptionalBoolean,
|
||||
isValidPort,
|
||||
normalizeHostLookFields,
|
||||
normalizeImportedHost,
|
||||
normalizeProtocolEnableFields,
|
||||
renameFolderPath,
|
||||
@@ -530,25 +529,3 @@ describe("impliedSshAuthType", () => {
|
||||
expect(impliedSshAuthType({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHostLookFields", () => {
|
||||
it("leaves both fields out when the write does not send them", () => {
|
||||
expect(normalizeHostLookFields({ name: "web" })).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps a hex color and a Lucide name", () => {
|
||||
expect(
|
||||
normalizeHostLookFields({ color: "#3B82F6", icon: "server-cog" }),
|
||||
).toEqual({ color: "#3b82f6", icon: "server-cog" });
|
||||
});
|
||||
|
||||
it("stores null for cleared or bad values", () => {
|
||||
expect(normalizeHostLookFields({ color: "red", icon: "<script>" })).toEqual(
|
||||
{ color: null, icon: null },
|
||||
);
|
||||
expect(normalizeHostLookFields({ color: null, icon: "" })).toEqual({
|
||||
color: null,
|
||||
icon: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,6 @@ beforeEach(async () => {
|
||||
state.defaultLookIds = [];
|
||||
state.installed = [];
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -114,7 +113,6 @@ beforeEach(async () => {
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
@@ -140,7 +138,6 @@ describe("GET /user-preferences/look", () => {
|
||||
defaults: { layout: null, theme: null },
|
||||
defaultLook: { layout: null, theme: null },
|
||||
safeLayout: false,
|
||||
safeMode: false,
|
||||
unavailable: [],
|
||||
});
|
||||
});
|
||||
@@ -162,7 +159,6 @@ describe("GET /user-preferences/look", () => {
|
||||
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
|
||||
];
|
||||
process.env.TERMIX_SAFE_LAYOUT = "1";
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
|
||||
const { body } = await getLook();
|
||||
expect(body).toMatchObject({
|
||||
@@ -171,7 +167,6 @@ describe("GET /user-preferences/look", () => {
|
||||
defaults: { layout: "admin-layout", theme: "gruvbox" },
|
||||
defaultLook: { layout: "shipped", theme: "ink" },
|
||||
safeLayout: true,
|
||||
safeMode: true,
|
||||
});
|
||||
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
|
||||
["broken"],
|
||||
|
||||
@@ -488,55 +488,6 @@ describe("plugins route", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lists a plugin's engine ranges to any user", async () => {
|
||||
state.plugins.set(
|
||||
"sample-plugin",
|
||||
makePlugin({
|
||||
manifestJson: JSON.stringify({
|
||||
engine: { api: "^1.13", termix: ">=26.10.6", extra: 1 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"bad-engine",
|
||||
makePlugin({
|
||||
id: "bad-engine",
|
||||
manifestJson: JSON.stringify({ engine: { api: 2 } }),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await fetch(`${baseUrl}/plugins`, {
|
||||
headers: { "x-test-user-id": "regular-user" },
|
||||
});
|
||||
const body = (await res.json()) as Array<{ id: string; engine: unknown }>;
|
||||
|
||||
expect(body.find((p) => p.id === "sample-plugin")?.engine).toEqual({
|
||||
api: "^1.13",
|
||||
termix: ">=26.10.6",
|
||||
});
|
||||
expect(body.find((p) => p.id === "bad-engine")?.engine).toEqual({});
|
||||
});
|
||||
|
||||
it("reports an enabled plugin that safe mode keeps off", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
|
||||
|
||||
const read = async () =>
|
||||
(
|
||||
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
|
||||
state: string;
|
||||
enabled: boolean;
|
||||
}>
|
||||
)[0];
|
||||
|
||||
expect((await read()).state).not.toBe("safe-mode");
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
try {
|
||||
expect(await read()).toMatchObject({ state: "safe-mode", enabled: true });
|
||||
} finally {
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
}
|
||||
});
|
||||
|
||||
it("grants a declared capability", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
@@ -672,7 +623,6 @@ describe("plugins route", () => {
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
runsOnServer: false,
|
||||
engine: {},
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Request, RequestHandler, Response } from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
users: new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isExternal: boolean;
|
||||
passwordHash: string;
|
||||
}
|
||||
>(),
|
||||
adminCount: 1,
|
||||
deleted: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("bcryptjs", () => ({
|
||||
default: {
|
||||
compare: async (password: string, hash: string) =>
|
||||
hash === `hash:${password}`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
countAdmins: async () => state.adminCount,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/delete-user-data.js", () => ({
|
||||
deleteUserAndRelatedData: async (userId: string) => {
|
||||
state.deleted.push(userId);
|
||||
},
|
||||
}));
|
||||
|
||||
const { registerUserDeleteAccountRoutes } =
|
||||
await import("../../../database/routes/user-delete-account-routes.js");
|
||||
|
||||
let handler: RequestHandler | null = null;
|
||||
registerUserDeleteAccountRoutes(
|
||||
{
|
||||
delete: (_path: string, ...handlers: RequestHandler[]) => {
|
||||
handler = handlers[handlers.length - 1];
|
||||
},
|
||||
} as unknown as import("express").Router,
|
||||
(_req, _res, next) => next(),
|
||||
);
|
||||
|
||||
async function deleteAccount(userId: string, body: Record<string, unknown>) {
|
||||
const req = { userId, body } as unknown as Request;
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as Record<string, unknown> | null,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: Record<string, unknown>) {
|
||||
this.jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
await handler!(req, res as unknown as Response, () => {});
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.adminCount = 1;
|
||||
state.deleted = [];
|
||||
state.users = new Map([
|
||||
[
|
||||
"u1",
|
||||
{
|
||||
id: "u1",
|
||||
username: "user",
|
||||
isAdmin: false,
|
||||
isExternal: false,
|
||||
passwordHash: "hash:secret",
|
||||
},
|
||||
],
|
||||
[
|
||||
"admin1",
|
||||
{
|
||||
id: "admin1",
|
||||
username: "admin",
|
||||
isAdmin: true,
|
||||
isExternal: false,
|
||||
passwordHash: "hash:secret",
|
||||
},
|
||||
],
|
||||
[
|
||||
"sso1",
|
||||
{
|
||||
id: "sso1",
|
||||
username: "sso",
|
||||
isAdmin: false,
|
||||
isExternal: true,
|
||||
passwordHash: "hash:secret",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("DELETE /delete-account", () => {
|
||||
it("runs the full user cleanup", async () => {
|
||||
const res = await deleteAccount("u1", { password: "secret" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(state.deleted).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("needs a password", async () => {
|
||||
const res = await deleteAccount("u1", {});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(state.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a wrong password", async () => {
|
||||
const res = await deleteAccount("u1", { password: "nope" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(state.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses external accounts", async () => {
|
||||
const res = await deleteAccount("sso1", { password: "secret" });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(state.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses the last admin", async () => {
|
||||
const res = await deleteAccount("admin1", { password: "secret" });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(state.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets an admin go when another admin is left", async () => {
|
||||
state.adminCount = 2;
|
||||
const res = await deleteAccount("admin1", { password: "secret" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(state.deleted).toEqual(["admin1"]);
|
||||
});
|
||||
|
||||
it("404s for an unknown user", async () => {
|
||||
const res = await deleteAccount("ghost", { password: "secret" });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
missingShippedPluginIds,
|
||||
startablePluginIds,
|
||||
} from "../../plugins/boot-state.js";
|
||||
import { startablePluginIds } from "../../plugins/boot-state.js";
|
||||
|
||||
describe("startablePluginIds", () => {
|
||||
it("starts enabled plugins and retries ones that failed or were blocked", () => {
|
||||
@@ -16,25 +13,3 @@ describe("startablePluginIds", () => {
|
||||
expect([...ids].sort()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("missingShippedPluginIds", () => {
|
||||
const records = [
|
||||
{ id: "gone", tier: "bundled", state: "enabled" },
|
||||
{ id: "here", tier: "bundled", state: "enabled" },
|
||||
{ id: "off", tier: "bundled", state: "disabled" },
|
||||
{ id: "failed", tier: "bundled", state: "failed" },
|
||||
{ id: "community", tier: "community", state: "enabled" },
|
||||
{ id: "removed", tier: "bundled", state: "enabled" },
|
||||
];
|
||||
|
||||
it("lists shipped plugins that are on but not in the image", () => {
|
||||
expect(
|
||||
missingShippedPluginIds(records, new Set(["here"]), new Set(["removed"])),
|
||||
).toEqual(["failed", "gone"]);
|
||||
});
|
||||
|
||||
it("finds nothing when every plugin is present", () => {
|
||||
const present = new Set(records.map((record) => record.id));
|
||||
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyRuntimeError,
|
||||
composeArgsFor,
|
||||
containerCommand,
|
||||
countContainers,
|
||||
diffContainerStates,
|
||||
dockerArgs,
|
||||
parseComposeProjects,
|
||||
parseContainerList,
|
||||
parseContainerStates,
|
||||
parseContainerStats,
|
||||
parseRuntimeVersion,
|
||||
parseServices,
|
||||
parseStackStatus,
|
||||
stackStateFromServices,
|
||||
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||
|
||||
describe("containerCommand", () => {
|
||||
it("extends PATH on POSIX hosts", () => {
|
||||
const command = containerCommand("podman", "ps");
|
||||
expect(command.startsWith('PATH="')).toBe(true);
|
||||
expect(command.endsWith(" podman ps")).toBe(true);
|
||||
});
|
||||
|
||||
it("runs the plain CLI on Windows hosts", () => {
|
||||
expect(containerCommand("docker", "ps -a", true)).toBe("docker ps -a");
|
||||
});
|
||||
|
||||
it("falls back to docker for an unknown runtime", () => {
|
||||
expect(containerCommand(undefined, "ps", true)).toBe("docker ps");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dockerArgs", () => {
|
||||
it("builds the container commands", () => {
|
||||
expect(dockerArgs.inspect("web")).toBe("inspect web");
|
||||
expect(dockerArgs.action("restart", "web")).toBe("restart web");
|
||||
expect(dockerArgs.remove("web", true)).toBe("rm -f web");
|
||||
expect(dockerArgs.list(true)).toContain("ps -a --format '{");
|
||||
expect(dockerArgs.stats("web")).toContain("stats web --no-stream");
|
||||
});
|
||||
|
||||
it("refuses container ids that could break the command line", () => {
|
||||
expect(() => dockerArgs.inspect("web; rm -rf /")).toThrow();
|
||||
expect(() => dockerArgs.logs("$(id)")).toThrow();
|
||||
expect(() => dockerArgs.action("restart", "-rf")).toThrow();
|
||||
expect(() => dockerArgs.action("kill" as never, "web")).toThrow();
|
||||
});
|
||||
|
||||
it("only passes sane log filters", () => {
|
||||
expect(
|
||||
dockerArgs.logs("web", {
|
||||
tail: 50.7,
|
||||
timestamps: true,
|
||||
since: "2026-10-01T00:00:00Z",
|
||||
}),
|
||||
).toBe("logs web --tail 50 --timestamps --since 2026-10-01T00:00:00Z 2>&1");
|
||||
expect(dockerArgs.logs("web", { since: "1h; id" })).toBe("logs web 2>&1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker parsing", () => {
|
||||
it("reads the version and sorts errors", () => {
|
||||
expect(parseRuntimeVersion("Docker version 27.3.1, build ce12230")).toBe(
|
||||
"27.3.1",
|
||||
);
|
||||
expect(parseRuntimeVersion("podman version 5.2.0")).toBe("5.2.0");
|
||||
expect(parseRuntimeVersion("nope")).toBe("unknown");
|
||||
expect(classifyRuntimeError("bash: docker: command not found")).toBe(
|
||||
"NOT_INSTALLED",
|
||||
);
|
||||
expect(
|
||||
classifyRuntimeError(
|
||||
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
|
||||
),
|
||||
).toBe("DAEMON_NOT_RUNNING");
|
||||
expect(
|
||||
classifyRuntimeError("permission denied while trying to connect"),
|
||||
).toBe("PERMISSION_DENIED");
|
||||
expect(classifyRuntimeError("something else")).toBe("DOCKER_ERROR");
|
||||
});
|
||||
|
||||
it("skips lines that are not JSON in the list", () => {
|
||||
const rows = parseContainerList(
|
||||
'{"id":"a","name":"web"}\nWARNING: x\n{"id":"b","name":"db"}\n',
|
||||
);
|
||||
expect(rows.map((r) => r.name)).toEqual(["web", "db"]);
|
||||
});
|
||||
|
||||
it("splits the stats columns", () => {
|
||||
const stats = parseContainerStats(
|
||||
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
|
||||
);
|
||||
expect(stats).toMatchObject({
|
||||
cpu: "1.5%",
|
||||
memoryUsed: "10MiB",
|
||||
memoryLimit: "1GiB",
|
||||
netInput: "1kB",
|
||||
netOutput: "2kB",
|
||||
pids: "3",
|
||||
});
|
||||
});
|
||||
|
||||
const ps = [
|
||||
`{"name":"web","state":"running","status":"Up 2 hours"}`,
|
||||
`{"name":"db","state":"running","status":"Up 2 hours (unhealthy)"}`,
|
||||
`{"name":"old","state":"exited","status":"Exited (0)"}`,
|
||||
].join("\n");
|
||||
|
||||
it("counts running, stopped and unhealthy containers", () => {
|
||||
expect(countContainers(ps)).toEqual({
|
||||
running: 2,
|
||||
stopped: 1,
|
||||
unhealthy: 1,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("turns two snapshots into events", () => {
|
||||
const before = parseContainerStates(ps);
|
||||
const after = parseContainerStates(
|
||||
[
|
||||
`{"name":"web","state":"exited","status":"Exited (1)"}`,
|
||||
`{"name":"db","state":"running","status":"Up (unhealthy)"}`,
|
||||
`{"name":"old","state":"running","status":"Up 1 second"}`,
|
||||
`{"name":"new","state":"running","status":"Up"}`,
|
||||
].join("\n"),
|
||||
);
|
||||
expect(diffContainerStates(before, after)).toEqual([
|
||||
{ container: "web", event: "exited" },
|
||||
{ container: "old", event: "started" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compose", () => {
|
||||
it("reads stack status text", () => {
|
||||
expect(parseStackStatus("running(2), exited(1)")).toEqual({
|
||||
state: "partial",
|
||||
running: 2,
|
||||
total: 3,
|
||||
});
|
||||
expect(parseStackStatus("paused(2)")).toMatchObject({ state: "paused" });
|
||||
expect(parseStackStatus("")).toMatchObject({ state: "not-deployed" });
|
||||
});
|
||||
|
||||
it("reads projects from compose ls", () => {
|
||||
const stacks = parseComposeProjects(
|
||||
JSON.stringify([
|
||||
{
|
||||
Name: "web",
|
||||
Status: "running(2)",
|
||||
ConfigFiles: "/opt/stacks/web/compose.yaml",
|
||||
},
|
||||
{ Name: "Bad Name", Status: "running(1)", ConfigFiles: "" },
|
||||
]),
|
||||
);
|
||||
expect(stacks).toEqual([
|
||||
{
|
||||
name: "web",
|
||||
managed: false,
|
||||
state: "running",
|
||||
running: 2,
|
||||
total: 2,
|
||||
composeFile: "/opt/stacks/web/compose.yaml",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads services from compose ps, one line or one array", () => {
|
||||
const row = {
|
||||
Service: "app",
|
||||
Name: "web-app-1",
|
||||
ID: "abc",
|
||||
Image: "nginx",
|
||||
State: "running",
|
||||
Status: "Up",
|
||||
Health: "",
|
||||
Publishers: [
|
||||
{ PublishedPort: 8080, TargetPort: 80 },
|
||||
{ PublishedPort: 8080, TargetPort: 80 },
|
||||
{ PublishedPort: 0, TargetPort: 443 },
|
||||
],
|
||||
};
|
||||
const lines = parseServices(JSON.stringify(row));
|
||||
expect(lines[0]).toMatchObject({ service: "app", ports: "8080:80" });
|
||||
expect(
|
||||
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
stackStateFromServices(
|
||||
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
|
||||
),
|
||||
).toEqual({
|
||||
state: "partial",
|
||||
running: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps actions to compose commands", () => {
|
||||
expect(composeArgsFor("redeploy", "app", {})).toEqual([
|
||||
"pull app",
|
||||
"up -d --remove-orphans app",
|
||||
]);
|
||||
expect(
|
||||
composeArgsFor("destroy", undefined, { removeVolumes: true }),
|
||||
).toEqual(["down --remove-orphans -v"]);
|
||||
});
|
||||
});
|
||||
@@ -1,400 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildFilesystemList,
|
||||
filterExcludedFilesystems,
|
||||
findWorstMountIndex,
|
||||
liveCmd,
|
||||
mergeMonitoredFilesystems,
|
||||
parseCpuLine,
|
||||
parseDfLines,
|
||||
parseLive,
|
||||
selectPrimaryFilesystem,
|
||||
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||
|
||||
describe("parseCpuLine", () => {
|
||||
it("parses a standard /proc/stat cpu line", () => {
|
||||
// user nice system idle iowait irq softirq
|
||||
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
|
||||
expect(result).toBeDefined();
|
||||
// idle = idle(800) + iowait(30)
|
||||
expect(result?.idle).toBe(830);
|
||||
// total = sum of all fields
|
||||
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
|
||||
});
|
||||
|
||||
it("tolerates leading/trailing whitespace", () => {
|
||||
const result = parseCpuLine(" cpu 1 2 3 4 ");
|
||||
expect(result?.total).toBe(10);
|
||||
expect(result?.idle).toBe(4);
|
||||
});
|
||||
|
||||
it("returns undefined for non-cpu lines", () => {
|
||||
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
|
||||
expect(parseCpuLine("intr 12345")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there are fewer than 4 numeric fields", () => {
|
||||
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDfLines", () => {
|
||||
it("parses df -T -P output into rows", () => {
|
||||
const output =
|
||||
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].mount).toBe("/");
|
||||
expect(rows[0].type).toBe("ext4");
|
||||
expect(rows[1].mount).toBe("/data");
|
||||
});
|
||||
|
||||
it("filters out pseudo filesystems", () => {
|
||||
const output =
|
||||
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||
"overlay overlay 100 50 50 50% /\n" +
|
||||
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].mount).toBe("/mnt/data");
|
||||
});
|
||||
|
||||
it("captures the filesystem type for network shares", () => {
|
||||
const output =
|
||||
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows[0].type).toBe("nfs4");
|
||||
expect(rows[1].type).toBe("cifs");
|
||||
});
|
||||
|
||||
it("keeps full image and firmware mounts out of disk usage", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
|
||||
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
|
||||
"/dev/sda1 ext4 1000 400 600 40% /\n",
|
||||
);
|
||||
expect(rows.map((row) => row.mount)).toEqual(["/"]);
|
||||
expect(findWorstMountIndex(rows)).toEqual({
|
||||
index: 0,
|
||||
usedBytes: 400,
|
||||
totalBytes: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("retains real storage without requiring a /dev source", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
|
||||
"tank/data zfs 2000 1000 1000 50% /data\n" +
|
||||
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
|
||||
);
|
||||
expect(rows.map((row) => row.type)).toEqual([
|
||||
"btrfs",
|
||||
"zfs",
|
||||
"nfs4",
|
||||
"cifs",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWorstMountIndex", () => {
|
||||
it("picks the most-utilized mount, not just the first row", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
expect(worst.totalBytes).toBe(15393162788864);
|
||||
expect(worst.usedBytes).toBe(15239230844928);
|
||||
});
|
||||
|
||||
it("falls back to the only mount available", () => {
|
||||
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(0);
|
||||
});
|
||||
|
||||
it("skips rows with invalid or zero totals", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
|
||||
"/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
});
|
||||
|
||||
it("returns index -1 when there are no usable rows", () => {
|
||||
const worst = findWorstMountIndex([]);
|
||||
expect(worst.index).toBe(-1);
|
||||
expect(worst.totalBytes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const BYTES_OUTPUT =
|
||||
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
|
||||
const HUMAN_OUTPUT =
|
||||
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
|
||||
|
||||
describe("buildFilesystemList", () => {
|
||||
it("returns every real filesystem with byte maths and human strings", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines(HUMAN_OUTPUT),
|
||||
);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toMatchObject({
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
percent: 40,
|
||||
usedHuman: "400",
|
||||
totalHuman: "1.0K",
|
||||
availableHuman: "600",
|
||||
usedBytes: 400,
|
||||
totalBytes: 1000,
|
||||
});
|
||||
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
|
||||
});
|
||||
|
||||
it("matches human rows by mount when the row counts differ", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
|
||||
);
|
||||
expect(list[0].totalHuman).toBeNull();
|
||||
expect(list[1].totalHuman).toBe("2.0K");
|
||||
});
|
||||
|
||||
it("drops filesystems with a zero or invalid total", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].mount).toBe("/ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectPrimaryFilesystem", () => {
|
||||
it("prefers root over a fuller secondary mount", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines(HUMAN_OUTPUT),
|
||||
);
|
||||
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
|
||||
});
|
||||
|
||||
it("falls back to the most-utilized mount when there is no root", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
|
||||
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
|
||||
});
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(selectPrimaryFilesystem([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterExcludedFilesystems", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 1000 400 600 40% /\n" +
|
||||
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
it("returns the same list when no mounts are excluded", () => {
|
||||
expect(filterExcludedFilesystems(list)).toHaveLength(3);
|
||||
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("excludes an exact mount path match", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||
});
|
||||
|
||||
it("excludes by filesystem type substring, case-insensitively", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["NFS"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||
});
|
||||
|
||||
it("supports excluding multiple network filesystem types at once", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
|
||||
});
|
||||
|
||||
it("ignores blank/whitespace-only entries", () => {
|
||||
const filtered = filterExcludedFilesystems(list, [" ", ""]);
|
||||
expect(filtered).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeMonitoredFilesystems", () => {
|
||||
it("adds an arbitrary path with a user label", () => {
|
||||
const detected = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||
[],
|
||||
);
|
||||
const custom = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||
[],
|
||||
);
|
||||
const result = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
[{ path: "/config", label: "Home Assistant" }],
|
||||
custom,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]).toMatchObject({
|
||||
mount: "/config",
|
||||
label: "Home Assistant",
|
||||
totalBytes: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("labels a path that is already a detected mount", () => {
|
||||
const detected = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
|
||||
[],
|
||||
);
|
||||
const result = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
[{ path: "/data", label: "Media" }],
|
||||
detected,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].label).toBe("Media");
|
||||
});
|
||||
});
|
||||
|
||||
const out = (
|
||||
agg: [number, number],
|
||||
core: [number, number],
|
||||
rx: number,
|
||||
up: number,
|
||||
procs: string,
|
||||
page = "4096",
|
||||
) =>
|
||||
[
|
||||
`cpu ${agg[0]} 0 0 ${agg[1]} 0 0 0 0 0 0\ncpu0 ${core[0]} 0 0 ${core[1]} 0 0 0 0 0 0`,
|
||||
"MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB",
|
||||
`0.1 0.2 0.3 1/2 3\n${up} 5`,
|
||||
`Inter-|\n eth0: ${rx} 0 0 0 0 0 0 0 ${rx * 2} 0\n lo: 9 0 0 0 0 0 0 0 9 0`,
|
||||
page,
|
||||
procs,
|
||||
].join("\n@@\n");
|
||||
|
||||
const proc = (pid: number, ticks: number, start: number, name = "java") =>
|
||||
`${pid}\t${ticks}\t10\t${start}\tR\t1\t${name}\n`;
|
||||
|
||||
describe("parseLive extras", () => {
|
||||
it("reports state, parent, CPU mix, cache and the running count", () => {
|
||||
const mk = (user: number, sys: number, wait: number, idle: number) =>
|
||||
out([user, idle], [user, idle], 1, 10, procs).replace(
|
||||
/cpu {2}\d+ 0 0 \d+ 0 0 0 0/,
|
||||
`cpu ${user} 0 ${sys} ${idle} ${wait} 0 0 0`,
|
||||
);
|
||||
const procs = "5\t1\t10\t7\tR\t2\tjava\n6\t1\t10\t8\tS\t5\tsh\n";
|
||||
const a = parseLive(mk(0, 0, 0, 0), undefined, 0);
|
||||
const b = parseLive(mk(30, 10, 10, 50), a.next, 1000);
|
||||
expect(b.sample.cpuMix).toMatchObject({ user: 30, system: 10, iowait: 10 });
|
||||
expect(b.sample.running).toBe(1);
|
||||
expect(b.sample.procs.find((p) => p.pid === 6)).toMatchObject({
|
||||
state: "S",
|
||||
ppid: 5,
|
||||
});
|
||||
expect(b.sample.memCachedKb).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveCmd", () => {
|
||||
it("asks for the page size once, then passes the cached number", () => {
|
||||
expect(liveCmd()).toContain('pg="$(getconf PAGESIZE)"');
|
||||
expect(liveCmd(16384)).toContain("pg=16384 ");
|
||||
expect(liveCmd(16384)).not.toContain("getconf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLive", () => {
|
||||
it("has no rates on the first sample", () => {
|
||||
const { sample } = parseLive(
|
||||
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
|
||||
undefined,
|
||||
);
|
||||
expect(sample.cpu).toBeNull();
|
||||
expect(sample.net[0].rxBps).toBeNull();
|
||||
expect(sample.memUsedKb).toBe(600);
|
||||
});
|
||||
|
||||
it("derives cpu, per-core, net and process rates from the delta", () => {
|
||||
const a = parseLive(
|
||||
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
|
||||
undefined,
|
||||
);
|
||||
// aggregate: +50 busy, +50 idle; core 0: +25 busy, +75 idle
|
||||
const { sample } = parseLive(
|
||||
out([150, 150], [125, 175], 3000, 11, proc(7, 50, 1)),
|
||||
a.next,
|
||||
);
|
||||
expect(sample.cpu).toBeCloseTo(50, 0);
|
||||
expect(sample.cores[0]).toBeCloseTo(25, 0);
|
||||
expect(sample.net).toEqual([{ name: "eth0", rxBps: 2000, txBps: 4000 }]);
|
||||
expect(sample.procs[0]).toMatchObject({ pid: 7, name: "java", memKb: 40 });
|
||||
expect(sample.procs[0].cpu).toBeCloseTo(50, 0);
|
||||
});
|
||||
|
||||
it("does not charge a reused pid with the old process's ticks", () => {
|
||||
const a = parseLive(
|
||||
out([100, 100], [100, 100], 1000, 10, proc(7, 5, 1)),
|
||||
undefined,
|
||||
);
|
||||
// same pid, new start time, lower ticks must not look like a huge delta
|
||||
const { sample } = parseLive(
|
||||
out([150, 150], [125, 175], 3000, 11, proc(7, 9000, 2)),
|
||||
a.next,
|
||||
);
|
||||
expect(sample.procs[0].cpu).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the cached page size when a run omits it, and keeps names with tabs", () => {
|
||||
const a = parseLive(
|
||||
out([1, 1], [1, 1], 1, 10, proc(1, 0, 1), "16384"),
|
||||
undefined,
|
||||
);
|
||||
expect(a.next.pageBytes).toBe(16384);
|
||||
const b = parseLive(
|
||||
out([2, 2], [2, 2], 2, 11, proc(1, 0, 1, "a b"), ""),
|
||||
a.next,
|
||||
);
|
||||
expect(b.next.pageBytes).toBe(16384);
|
||||
expect(b.sample.procs[0]).toMatchObject({ name: "a\tb", memKb: 160 });
|
||||
});
|
||||
|
||||
it("starts rates again after a reboot", () => {
|
||||
const a = parseLive(
|
||||
out([100, 100], [100, 100], 5000, 900, proc(7, 0, 1)),
|
||||
undefined,
|
||||
);
|
||||
const { sample } = parseLive(
|
||||
out([10, 10], [10, 10], 20, 5, proc(7, 0, 1)),
|
||||
a.next,
|
||||
);
|
||||
expect(sample.cpu).toBeNull();
|
||||
expect(sample.net[0].rxBps).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,244 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isUnit,
|
||||
journalArgs,
|
||||
parseBoots,
|
||||
parseDetect,
|
||||
parseEntry,
|
||||
parseIsActive,
|
||||
parseJournal,
|
||||
parseJournalQuery,
|
||||
parseOpenRcList,
|
||||
parseSystemdTime,
|
||||
parseTimers,
|
||||
parseUnitList,
|
||||
parseUsage,
|
||||
shellQuote as q,
|
||||
vacuumScript,
|
||||
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||
|
||||
describe("names", () => {
|
||||
it("quotes for bash", () => {
|
||||
expect(q("a'b")).toBe(`'a'\\''b'`);
|
||||
});
|
||||
|
||||
it("takes unit names and refuses anything else", () => {
|
||||
expect(isUnit("nginx.service")).toBe(true);
|
||||
expect(isUnit("getty@tty1.service")).toBe(true);
|
||||
expect(isUnit("dev-disk-by\\x2duuid-1.device")).toBe(true);
|
||||
expect(isUnit("-rf.service")).toBe(false);
|
||||
expect(isUnit("a;b.service")).toBe(false);
|
||||
expect(isUnit("a b.service")).toBe(false);
|
||||
expect(isUnit("nginx")).toBe(false);
|
||||
expect(isUnit("$(id).service")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd parsing", () => {
|
||||
const list = [
|
||||
"cron.service loaded active running Regular background program",
|
||||
"● bad.service loaded failed failed Broken thing",
|
||||
"getty@tty1.service loaded active running Getty on tty1",
|
||||
"@@TERMIX_FILES@@",
|
||||
"cron.service enabled enabled",
|
||||
"bad.service disabled enabled",
|
||||
"getty@.service enabled enabled",
|
||||
"old.service masked -",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
it("merges loaded units with unit files and skips templates", () => {
|
||||
const units = parseUnitList(list);
|
||||
expect(units.map((u) => u.name)).toEqual([
|
||||
"bad.service",
|
||||
"cron.service",
|
||||
"getty@tty1.service",
|
||||
"old.service",
|
||||
]);
|
||||
const cron = units.find((u) => u.name === "cron.service")!;
|
||||
expect(cron).toMatchObject({
|
||||
active: "active",
|
||||
sub: "running",
|
||||
description: "Regular background program",
|
||||
fileState: "enabled",
|
||||
preset: "enabled",
|
||||
});
|
||||
expect(units.find((u) => u.name === "bad.service")!.active).toBe("failed");
|
||||
expect(units.find((u) => u.name === "old.service")).toMatchObject({
|
||||
load: "masked",
|
||||
active: "inactive",
|
||||
preset: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads UTC times", () => {
|
||||
expect(parseSystemdTime("Thu 2026-10-22 02:03:51 UTC")).toBe(
|
||||
Date.UTC(2026, 9, 22, 2, 3, 51),
|
||||
);
|
||||
expect(parseSystemdTime("")).toBeNull();
|
||||
expect(parseSystemdTime("n/a")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads timers from systemctl show blocks", () => {
|
||||
const out = [
|
||||
"TimersCalendar={ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=@1792605600 }",
|
||||
"NextElapseUSecRealtime=Thu 2026-10-22 02:03:51 UTC",
|
||||
"LastTriggerUSec=Wed 2026-10-21 17:02:58 UTC",
|
||||
"Id=apt-daily.timer",
|
||||
"Triggers=apt-daily.service",
|
||||
"ActiveState=active",
|
||||
"",
|
||||
"TimersMonotonic={ OnUnitActiveUSec=1d ; next_elapse=1d }",
|
||||
"TimersMonotonic={ OnBootUSec=15min ; next_elapse=15min }",
|
||||
"NextElapseUSecRealtime=",
|
||||
"LastTriggerUSec=",
|
||||
"Id=clean.timer",
|
||||
"Triggers=clean.service",
|
||||
].join("\n");
|
||||
const timers = parseTimers(out);
|
||||
expect(timers[0]).toMatchObject({
|
||||
name: "apt-daily.timer",
|
||||
activates: "apt-daily.service",
|
||||
next: Date.UTC(2026, 9, 22, 2, 3, 51),
|
||||
schedule: ["OnCalendar=*-*-* 06,18:00:00"],
|
||||
});
|
||||
expect(timers[1]).toMatchObject({
|
||||
name: "clean.timer",
|
||||
next: null,
|
||||
last: null,
|
||||
schedule: ["OnUnitActiveUSec=1d", "OnBootUSec=15min"],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps is-active lines to units", () => {
|
||||
const states = parseIsActive(
|
||||
["a.service", "b.service"],
|
||||
"active\nfailed\n",
|
||||
);
|
||||
expect(states.get("a.service")).toBe("active");
|
||||
expect(states.get("b.service")).toBe("failed");
|
||||
});
|
||||
|
||||
it("reads what detect prints", () => {
|
||||
expect(
|
||||
parseDetect(
|
||||
"init=systemd\nversion=257\nos=Debian GNU/Linux 13 (trixie)\nuid=0\njournal=1\nfailed=2\n",
|
||||
),
|
||||
).toMatchObject({
|
||||
init: "systemd",
|
||||
version: "257",
|
||||
root: true,
|
||||
journal: true,
|
||||
failed: 2,
|
||||
});
|
||||
expect(parseDetect("init=weird\nuid=1000\n")).toMatchObject({
|
||||
init: "none",
|
||||
root: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("journal", () => {
|
||||
it("checks filters", () => {
|
||||
const query = parseJournalQuery({
|
||||
unit: ["nginx.service", "ssh.service"],
|
||||
priority: "3",
|
||||
since: "-1h",
|
||||
boot: "-1",
|
||||
grep: "fail'ed",
|
||||
lines: "99999",
|
||||
});
|
||||
expect(query.lines).toBe(5000);
|
||||
const args = journalArgs(query);
|
||||
expect(args).toContain("-u 'nginx.service' -u 'ssh.service'");
|
||||
expect(args).toContain("-p 3");
|
||||
expect(args).toContain("--since '-1h'");
|
||||
expect(args).toContain("-b '-1'");
|
||||
expect(args).toContain(`-g 'fail'\\''ed'`);
|
||||
expect(() => parseJournalQuery({ unit: "x;rm" })).toThrow();
|
||||
expect(() => parseJournalQuery({ priority: "9" })).toThrow();
|
||||
expect(() => parseJournalQuery({ since: "$(id)" })).toThrow();
|
||||
expect(() => parseJournalQuery({ boot: "abc" })).toThrow();
|
||||
});
|
||||
|
||||
it("parses entries, binary messages included", () => {
|
||||
const entry = parseEntry(
|
||||
JSON.stringify({
|
||||
__REALTIME_TIMESTAMP: "1792612429684000",
|
||||
PRIORITY: "3",
|
||||
_SYSTEMD_UNIT: "x.service",
|
||||
SYSLOG_IDENTIFIER: "x",
|
||||
_PID: "12",
|
||||
MESSAGE: [104, 105],
|
||||
}),
|
||||
);
|
||||
expect(entry).toEqual({
|
||||
time: 1792612429684,
|
||||
priority: 3,
|
||||
unit: "x.service",
|
||||
ident: "x",
|
||||
pid: "12",
|
||||
message: "hi",
|
||||
});
|
||||
expect(parseEntry("not json")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters here when journalctl has no -g", () => {
|
||||
const line = (message: string) =>
|
||||
JSON.stringify({ __REALTIME_TIMESTAMP: "1000", MESSAGE: message });
|
||||
const out = `@@TERMIX_NOGREP@@\n${line("Disk Error")}\n${line("ok")}\n`;
|
||||
const query = parseJournalQuery({ grep: "error", lines: "10" });
|
||||
expect(parseJournal(out, query).map((e) => e.message)).toEqual([
|
||||
"Disk Error",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads boots and disk use", () => {
|
||||
const boots = parseBoots(
|
||||
[
|
||||
"IDX BOOT ID FIRST ENTRY LAST ENTRY",
|
||||
"-1 d2785a657f4c4561a1f436db27555b50 Tue 2026-10-20 13:56:45 UTC Wed 2026-10-21 10:37:00 UTC",
|
||||
" 0 61b15588a75b4c0cb8dea321fcc438ab Wed 2026-10-21 10:38:10 UTC Wed 2026-10-21 20:00:00 UTC",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(boots.map((b) => b.offset)).toEqual([0, -1]);
|
||||
expect(boots[0].first).toBe("Wed 2026-10-21 10:38:10 UTC");
|
||||
expect(
|
||||
parseUsage(
|
||||
"Archived and active journals take up 378.6M in the file system.",
|
||||
),
|
||||
).toBe("378.6M");
|
||||
});
|
||||
|
||||
it("only takes sane vacuum values", () => {
|
||||
expect(vacuumScript({ size: "500M" })).toContain("--vacuum-size=500M");
|
||||
expect(vacuumScript({ time: "4w" })).toContain("--vacuum-time=4w");
|
||||
expect(() => vacuumScript({ size: "1; rm -rf /" })).toThrow();
|
||||
expect(() => vacuumScript({})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openrc", () => {
|
||||
it("maps services and runlevels", () => {
|
||||
const units = parseOpenRcList(
|
||||
[
|
||||
" sshd [ started ]",
|
||||
" crond [ stopped ]",
|
||||
" nginx [ crashed ]",
|
||||
"@@TERMIX_RUNLEVELS@@",
|
||||
" sshd | default",
|
||||
" nginx | ",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(units.find((u) => u.name === "sshd")).toMatchObject({
|
||||
active: "active",
|
||||
fileState: "enabled",
|
||||
});
|
||||
expect(units.find((u) => u.name === "nginx")).toMatchObject({
|
||||
active: "failed",
|
||||
fileState: "disabled",
|
||||
});
|
||||
expect(units.find((u) => u.name === "crond")!.active).toBe("inactive");
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actionScript,
|
||||
errLine,
|
||||
isActiveScript,
|
||||
listUnitsScript,
|
||||
openRcActionScript,
|
||||
parseKeys,
|
||||
passwordSudoScript,
|
||||
scriptError,
|
||||
scriptShell,
|
||||
SUDO_REFUSED_RE,
|
||||
SUDO_WRONG_RE,
|
||||
toBase64,
|
||||
utf8Decode,
|
||||
utf8Encode,
|
||||
watchdog,
|
||||
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||
|
||||
describe("text helpers", () => {
|
||||
it("encodes base64 like Buffer does", () => {
|
||||
for (const text of [
|
||||
"",
|
||||
"a",
|
||||
"ab",
|
||||
"abc",
|
||||
"héllo wörld",
|
||||
"emoji 🙂 here",
|
||||
"line\nbreak",
|
||||
]) {
|
||||
expect(toBase64(text)).toBe(Buffer.from(text, "utf8").toString("base64"));
|
||||
}
|
||||
});
|
||||
|
||||
it("round trips UTF-8 and replaces bad bytes", () => {
|
||||
const text = "plain, ünïcode, 日本, 🙂";
|
||||
expect(utf8Decode(utf8Encode(text))).toBe(text);
|
||||
expect(Array.from(utf8Encode(text))).toEqual(
|
||||
Array.from(Buffer.from(text, "utf8")),
|
||||
);
|
||||
expect(utf8Decode([0x61, 0xff, 0x62])).toBe("a\ufffdb");
|
||||
expect(utf8Decode([0xe6, 0x97])).toBe("\ufffd");
|
||||
});
|
||||
|
||||
it("reads key=value lines", () => {
|
||||
expect(parseKeys("a=1\nb = two=2\njunk\n")).toEqual({ a: "1", b: "two=2" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("script shells", () => {
|
||||
it("always reads the script from stdin", () => {
|
||||
for (const mode of ["plain", "auto", "root"] as const) {
|
||||
expect(scriptShell(mode)).toContain("bash -s");
|
||||
}
|
||||
expect(scriptShell("auto")).toContain("sudo -n true");
|
||||
expect(scriptShell("root-only")).toContain("SCRIPT_ERR:");
|
||||
});
|
||||
|
||||
it("collects error lines, old and new markers", () => {
|
||||
expect(scriptError("x\nSCRIPT_ERR:one\nSERVICES_ERR:two\n")).toBe(
|
||||
"one\ntwo",
|
||||
);
|
||||
expect(errLine("it's broken")).toBe(`echo 'SCRIPT_ERR:it'\\''s broken'`);
|
||||
});
|
||||
|
||||
it("matches sudo refusals", () => {
|
||||
expect(SUDO_REFUSED_RE.test("sudo: a password is required")).toBe(true);
|
||||
expect(SUDO_WRONG_RE.test("sudo: 1 incorrect password attempt")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the sudo password out of the command line", () => {
|
||||
const { command, stdin } = passwordSudoScript(
|
||||
"systemctl restart 'a.service'\n",
|
||||
"s3cr'et $(id)",
|
||||
);
|
||||
expect(command).not.toContain("s3cr");
|
||||
expect(stdin.split("\n")[0]).toBe("IFS= read -r TERMIX_PW");
|
||||
expect(stdin.split("\n")[1]).toBe("s3cr'et $(id)");
|
||||
expect(stdin).toContain("sudo -k -S -p ''");
|
||||
expect(stdin).not.toContain("systemctl restart");
|
||||
expect(() => passwordSudoScript("x", "a\nb")).toThrow();
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"runs the script and never runs the password",
|
||||
() => {
|
||||
const { stdin } = passwordSudoScript(
|
||||
'echo "ran:$1${TERMIX_PW:-none}"\n',
|
||||
"touch /tmp/should-not-exist",
|
||||
);
|
||||
// No sudo here: swap it for a fake that drops its own stdin line.
|
||||
const fake = stdin.replace(
|
||||
/printf '%s\\n' "\$P" \| sudo -k -S -p '' env PATH="\$PATH" [^\n]* bash -c "\$TERMIX_S"/,
|
||||
'printf "%s\\n" "$P" | { IFS= read -r _; bash -c "$TERMIX_S"; }',
|
||||
);
|
||||
expect(fake).not.toBe(stdin);
|
||||
const out = execFileSync(
|
||||
"bash",
|
||||
["-c", "sudo() { return 1; }; export -f sudo; exec bash -s"],
|
||||
{
|
||||
input: fake,
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(out.trim()).toBe("ran:none");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("unit scripts", () => {
|
||||
it("quotes units and refuses bad names", () => {
|
||||
expect(actionScript("a.service", "enable-now")).toContain(
|
||||
"systemctl enable --now -- 'a.service'",
|
||||
);
|
||||
expect(() => actionScript("a;b.service", "start")).toThrow();
|
||||
expect(isActiveScript(["a.service", "b.timer"])).toContain(
|
||||
"-- 'a.service' 'b.timer'",
|
||||
);
|
||||
expect(() => listUnitsScript("bogus" as never)).toThrow();
|
||||
expect(openRcActionScript("sshd", "enable")).toContain(
|
||||
"rc-update add 'sshd' default",
|
||||
);
|
||||
expect(() => openRcActionScript("sshd", "mask")).toThrow();
|
||||
});
|
||||
|
||||
it("stops a watched command once stdin closes", () => {
|
||||
expect(watchdog("journalctl -f")).toContain("cat >/dev/null");
|
||||
});
|
||||
});
|
||||
@@ -621,26 +621,6 @@ describe("dependencies", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("safe mode", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
});
|
||||
|
||||
it("refuses to start a plugin that is not a layout or theme, but still stops one", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
const loader = await boot();
|
||||
await manage.setPluginState("base", false);
|
||||
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
await expect(manage.setPluginState("base", true)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: "SAFE_MODE",
|
||||
});
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
expect(db.rows.get("base")?.state).toBe("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloading a bundled plugin for the dev runner", () => {
|
||||
it("loads the rebuilt copy and restarts what depends on it", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
|
||||
@@ -650,110 +650,6 @@ describe("panels, dashboard cards and guest pages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.mobile", () => {
|
||||
const tabs = [
|
||||
{ id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] },
|
||||
];
|
||||
|
||||
it("accepts each mode with its own fields", () => {
|
||||
for (const mobile of [
|
||||
{
|
||||
mode: "webview",
|
||||
entry: "dist/mobile.js",
|
||||
sizes: ["phone", "tablet"],
|
||||
views: ["sample"],
|
||||
note: "mobile.note",
|
||||
},
|
||||
{ mode: "webview" },
|
||||
{ mode: "native", feature: "file-manager" },
|
||||
{ mode: "none", note: "Not on phones" },
|
||||
]) {
|
||||
expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("needs a known mode and rejects unknown fields", () => {
|
||||
const errors = validateManifest(
|
||||
base({ contributes: { mobile: { mode: "app", width: 3 } } }),
|
||||
).join();
|
||||
expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/);
|
||||
expect(errors).toMatch(/contributes\.mobile\.mode must be one of/);
|
||||
expect(
|
||||
validateManifest(base({ contributes: { mobile: "webview" } })).join(),
|
||||
).toMatch(/"contributes\.mobile" must be an object/);
|
||||
});
|
||||
|
||||
it("keeps webview and native fields to their own mode", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
tabs,
|
||||
mobile: {
|
||||
mode: "native",
|
||||
entry: "dist/mobile.js",
|
||||
views: ["sample"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/mobile\.entry is only for mode "webview"/);
|
||||
expect(errors).toMatch(/mobile\.views is only for mode "webview"/);
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }),
|
||||
).join(),
|
||||
).toMatch(/mobile\.feature is only for mode "native"/);
|
||||
});
|
||||
|
||||
it("puts the entry beside the frontend bundle", () => {
|
||||
const check = (entry: string, frontend?: string) =>
|
||||
validateManifest(
|
||||
base({
|
||||
...(frontend ? { frontend } : {}),
|
||||
contributes: { mobile: { mode: "webview", entry } },
|
||||
}),
|
||||
).join();
|
||||
expect(check("dist/mobile.js")).toBe("");
|
||||
expect(check("./dist/mobile.js")).toBe("");
|
||||
expect(check("build/mobile.js", "build/frontend.js")).toBe("");
|
||||
expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/);
|
||||
expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/);
|
||||
expect(check("../dist/mobile.js")).toMatch(/relative path inside/);
|
||||
});
|
||||
|
||||
it("checks sizes, views and the feature id", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
tabs,
|
||||
panels: [{ id: "side", titleKey: "nav.side" }],
|
||||
mobile: {
|
||||
mode: "webview",
|
||||
sizes: ["phone", "phone", "watch"],
|
||||
views: ["sample", "side", "missing"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/sizes duplicates "phone"/);
|
||||
expect(errors).toMatch(/sizes entries must be one of: phone, tablet/);
|
||||
expect(errors).toMatch(/views entries must be ids .*"missing"/);
|
||||
expect(errors).not.toMatch(/"side"/);
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { mobile: { mode: "webview", sizes: [] } } }),
|
||||
).join(),
|
||||
).toMatch(/sizes must be a non-empty array/);
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { mobile: { mode: "native", feature: "Files" } } }),
|
||||
).join(),
|
||||
).toMatch(/mobile\.feature must match/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("named service providers", () => {
|
||||
const provides = (names: unknown) => [
|
||||
{
|
||||
|
||||
@@ -8,23 +8,10 @@ import * as tar from "tar";
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
const testKey = vi.hoisted(() => ({ value: null as null | object }));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("../../utils/safe-outbound-fetch.js")
|
||||
>()),
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: fetchMock,
|
||||
}));
|
||||
|
||||
const proxyMock = vi.hoisted(() => ({
|
||||
on: false,
|
||||
fetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/proxy-agent.js", () => ({
|
||||
getProxyAgent: () => (proxyMock.on ? {} : undefined),
|
||||
fetchWithProxy: proxyMock.fetch,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/app-version.js", () => ({
|
||||
getLocalVersion: () => "26.10.3",
|
||||
}));
|
||||
@@ -56,11 +43,9 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
||||
import {
|
||||
communitySource,
|
||||
downloadRelease,
|
||||
fetchIndexAt,
|
||||
fetchRegistryIndex,
|
||||
fetchRegistryStats,
|
||||
findRelease,
|
||||
getRegistryPrivateHosts,
|
||||
latestOnChannel,
|
||||
getStatsUrl,
|
||||
canRunRelease,
|
||||
@@ -424,91 +409,6 @@ describe("fetchRegistryIndex", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry fetches with a proxy", () => {
|
||||
afterEach(() => {
|
||||
proxyMock.on = false;
|
||||
proxyMock.fetch.mockReset();
|
||||
});
|
||||
|
||||
it("goes through the proxy when one is configured", async () => {
|
||||
proxyMock.on = true;
|
||||
proxyMock.fetch.mockResolvedValueOnce(
|
||||
response(JSON.stringify({ plugins: [] })),
|
||||
);
|
||||
await fetchIndexAt("https://registry.example/index.json");
|
||||
expect(proxyMock.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still refuses a private address typed in as a literal", async () => {
|
||||
proxyMock.on = true;
|
||||
await expect(fetchIndexAt("https://127.0.0.1/index.json")).rejects.toThrow(
|
||||
"Private destinations are not allowed",
|
||||
);
|
||||
expect(proxyMock.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("private registry hosts", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
|
||||
});
|
||||
|
||||
it("reads a comma separated list of host names", () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
|
||||
" GitLab.lan ,, 192.168.1.5 ";
|
||||
expect(getRegistryPrivateHosts()).toEqual(["gitlab.lan", "192.168.1.5"]);
|
||||
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
|
||||
expect(getRegistryPrivateHosts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("brings each entry into the form the fetch compares against", () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
|
||||
"GitLab.LAN,[FD00::0001],fd00::2,bücher.lan";
|
||||
expect(getRegistryPrivateHosts()).toEqual([
|
||||
"gitlab.lan",
|
||||
"fd00::1",
|
||||
"fd00::2",
|
||||
"xn--bcher-kva.lan",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops entries with more than a host", () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
|
||||
"gitlab.lan:8443,https://git.lan,[fd00::1]:80,nas.lan";
|
||||
expect(getRegistryPrivateHosts()).toEqual(["nas.lan"]);
|
||||
});
|
||||
|
||||
it("allows the listed hosts on every hop", async () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response("", 302, { location: "https://gitlab.lan/raw/index.json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(response(JSON.stringify({ plugins: [] })));
|
||||
await fetchIndexAt("https://gitlab.lan/index.json");
|
||||
expect(fetchMock.mock.calls.map((call) => call[2])).toEqual([
|
||||
["gitlab.lan"],
|
||||
["gitlab.lan"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("still blocks a redirect to a private host that is not listed", async () => {
|
||||
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../utils/safe-outbound-fetch.js")
|
||||
>("../../utils/safe-outbound-fetch.js");
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response("", 302, { location: "https://127.0.0.1/index.json" }),
|
||||
)
|
||||
.mockImplementationOnce(actual.safeOutboundFetch);
|
||||
await expect(fetchIndexAt("https://gitlab.lan/index.json")).rejects.toThrow(
|
||||
"Private destinations are not allowed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** A real .tmxplug holding only a manifest, enough for the checks. */
|
||||
function pack(manifest: Record<string, unknown>): Buffer {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
mayRunInSafeMode,
|
||||
safeModeOn,
|
||||
safeModePluginIds,
|
||||
} from "../../plugins/safe-mode.js";
|
||||
import type { PluginManifest } from "../../plugins/manifest.js";
|
||||
|
||||
function plugin(id: string, manifest: Partial<PluginManifest> = {}) {
|
||||
return { id, manifest: { id, ...manifest } as PluginManifest };
|
||||
}
|
||||
|
||||
const plugins = [
|
||||
plugin("layout", {
|
||||
contributes: { layouts: [{ id: "classic" }] },
|
||||
dependencies: { helper: "^1.0.0" },
|
||||
} as Partial<PluginManifest>),
|
||||
plugin("themes", {
|
||||
contributes: { themes: [{ id: "dark" }] },
|
||||
} as Partial<PluginManifest>),
|
||||
plugin("helper"),
|
||||
plugin("docker"),
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_SAFE_MODE;
|
||||
});
|
||||
|
||||
describe("safeModeOn", () => {
|
||||
it("reads 1 and true, anything else is off", () => {
|
||||
expect(safeModeOn()).toBe(false);
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
expect(safeModeOn()).toBe(true);
|
||||
process.env.TERMIX_SAFE_MODE = " TRUE ";
|
||||
expect(safeModeOn()).toBe(true);
|
||||
process.env.TERMIX_SAFE_MODE = "0";
|
||||
expect(safeModeOn()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeModePluginIds", () => {
|
||||
it("keeps layout and theme plugins and what they depend on", () => {
|
||||
expect([...safeModePluginIds(plugins)].sort()).toEqual([
|
||||
"helper",
|
||||
"layout",
|
||||
"themes",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mayRunInSafeMode", () => {
|
||||
it("lets every plugin run when safe mode is off", () => {
|
||||
expect(mayRunInSafeMode("docker", plugins)).toBe(true);
|
||||
});
|
||||
|
||||
it("only lets look plugins run when safe mode is on", () => {
|
||||
process.env.TERMIX_SAFE_MODE = "1";
|
||||
expect(mayRunInSafeMode("docker", plugins)).toBe(false);
|
||||
expect(mayRunInSafeMode("layout", plugins)).toBe(true);
|
||||
expect(mayRunInSafeMode("helper", plugins)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
row: { sharedSource: '{"owner":"x"}' } as { sharedSource: string | null },
|
||||
updates: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentRepositoryContext: () => ({
|
||||
drizzle: {
|
||||
select: () => ({
|
||||
from: () => ({ where: () => ({ limit: async () => [h.row] }) }),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (values: unknown) => ({
|
||||
where: async () => {
|
||||
h.updates.push(values);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { rejectSharedCopyWrites } =
|
||||
await import("../../sync/shared-copy-guard.js");
|
||||
|
||||
function run(body: Record<string, unknown>) {
|
||||
const guard = rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/);
|
||||
const res = {
|
||||
code: 0,
|
||||
payload: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.code = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.payload = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
const next = vi.fn();
|
||||
return guard(
|
||||
{ method: "PUT", path: "/db/host/5", body } as never,
|
||||
res as never,
|
||||
next,
|
||||
).then(() => ({ res, next }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ELECTRON_EMBEDDED = "true";
|
||||
h.row = { sharedSource: '{"owner":"x"}' };
|
||||
h.updates = [];
|
||||
});
|
||||
|
||||
describe("shared copy guard", () => {
|
||||
it("refuses edits to a shared copy", async () => {
|
||||
const { res, next } = await run({ name: "new" });
|
||||
expect(res.code).toBe(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(h.updates).toEqual([]);
|
||||
});
|
||||
|
||||
it("still lets the connection origin change on this device", async () => {
|
||||
const { res, next } = await run({ name: "new", connectionOrigin: "local" });
|
||||
expect(res.code).toBe(0);
|
||||
expect(res.payload).toMatchObject({ connectionOrigin: "local" });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(h.updates).toEqual([{ connectionOrigin: "local" }]);
|
||||
});
|
||||
|
||||
it("passes hosts that are not shared copies", async () => {
|
||||
h.row = { sharedSource: null };
|
||||
const { next } = await run({ name: "new" });
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+1
-5
@@ -12,7 +12,6 @@ import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./ui/i18n/i18n";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
|
||||
import { captureAppReturn } from "@/lib/app-return";
|
||||
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
|
||||
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
|
||||
import { Toaster } from "@/components/sonner";
|
||||
@@ -169,11 +168,8 @@ function App() {
|
||||
// Keeps the window controls the system draws in the colors of the theme.
|
||||
useTitleBarOverlayColors();
|
||||
const stored = getStoredAuth();
|
||||
// A sign in for the mobile app always shows the login form, so the app gets
|
||||
// its own fresh session.
|
||||
const [appReturn] = useState(captureAppReturn);
|
||||
const [phase, setPhase] = useState<Phase>(
|
||||
stored?.loggedIn && !appReturn ? "verifying" : "idle-auth",
|
||||
stored?.loggedIn ? "verifying" : "idle-auth",
|
||||
);
|
||||
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
||||
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
|
||||
|
||||
@@ -72,10 +72,6 @@ export type Host = {
|
||||
terminalConfig?: HostTerminalConfig;
|
||||
sshOptions?: HostSshOptions;
|
||||
notes?: string;
|
||||
/** Hex color, like #3b82f6. */
|
||||
color?: string | null;
|
||||
/** Lucide icon name. */
|
||||
icon?: string | null;
|
||||
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
@@ -186,10 +182,6 @@ export interface HostData {
|
||||
terminalConfig?: HostTerminalConfig;
|
||||
sshOptions?: HostSshOptions;
|
||||
notes?: string;
|
||||
/** Hex color, like #3b82f6. */
|
||||
color?: string | null;
|
||||
/** Lucide icon name. */
|
||||
icon?: string | null;
|
||||
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface LookResponse {
|
||||
defaultLook: { layout: string | null; theme: string | null };
|
||||
/** TERMIX_SAFE_LAYOUT is set on the server. */
|
||||
safeLayout: boolean;
|
||||
/** TERMIX_SAFE_MODE is set: only layout and theme plugins run. */
|
||||
safeMode?: boolean;
|
||||
unavailable: UnavailableLook[];
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ export interface PluginSummary {
|
||||
name: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
/** enabled | disabled | blocked | failed | safe-mode, or the loader's live state. */
|
||||
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
||||
state: string;
|
||||
/** A linked desktop leaves this plugin to its server. */
|
||||
runsOnServer?: boolean;
|
||||
|
||||
+19
-33
@@ -1,11 +1,9 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
isEmbeddedAuthFrame,
|
||||
isInElectronWebView,
|
||||
isInMobileWebView,
|
||||
type EmbeddedFrameWindow,
|
||||
} from "@/lib/embedded-frame";
|
||||
import { handOffToApp, pendingAppReturn } from "@/lib/app-return";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
@@ -147,13 +145,6 @@ export function storeAuth(username: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A browser login the mobile app asked for goes back to the app. */
|
||||
async function handOffLogin(token?: string | null): Promise<boolean> {
|
||||
if (!pendingAppReturn() || isEmbeddedAuthFrame()) return false;
|
||||
const value = token || (await getCurrentToken());
|
||||
return handOffToApp(value ? { token: value } : { error: "no_token" });
|
||||
}
|
||||
|
||||
type AuthView = "login" | "register" | "reset" | "second-factor" | "external";
|
||||
type ResetStep = "email" | "code" | "newpass";
|
||||
|
||||
@@ -340,9 +331,8 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
if (proxySigninHandledRef.current || isElectron()) return;
|
||||
proxySigninHandledRef.current = true;
|
||||
requestTrustedProxyLogin()
|
||||
.then(async (result) => {
|
||||
.then((result) => {
|
||||
if (!result.enabled || !result.success) return;
|
||||
if (await handOffLogin()) return;
|
||||
storeAuth(result.username || "");
|
||||
onLogin(
|
||||
result.username || "",
|
||||
@@ -635,27 +625,24 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
void handOffLogin(urlParams.get("token")).then((handedOff) => {
|
||||
if (handedOff) return;
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
storeAuth(meRes.username || "");
|
||||
clearDesktopManualLogout();
|
||||
onLogin(
|
||||
meRes.username || "",
|
||||
meRes.userId || undefined,
|
||||
!!meRes.is_admin,
|
||||
);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("errors.failedUserInfo"));
|
||||
});
|
||||
});
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
storeAuth(meRes.username || "");
|
||||
clearDesktopManualLogout();
|
||||
onLogin(
|
||||
meRes.username || "",
|
||||
meRes.userId || undefined,
|
||||
!!meRes.is_admin,
|
||||
);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("errors.failedUserInfo"));
|
||||
});
|
||||
}
|
||||
}, [onLogin, t]);
|
||||
|
||||
@@ -759,7 +746,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
setWebviewAuthSuccess(true);
|
||||
return;
|
||||
}
|
||||
if (await handOffLogin(res?.token)) return;
|
||||
const meRes = await getUserInfo();
|
||||
const name =
|
||||
meRes.username || res.username || options.fallbackUsername || "";
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Sign in for the Termix mobile app in a real browser. The app opens the
|
||||
* login page with ?return_to=termix-mobile://..., and after any login method
|
||||
* the page sends the token back to that address. Only the app's own scheme
|
||||
* is accepted, so the value can never send a token to a website.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "termix-app-return";
|
||||
const PARAM = "return_to";
|
||||
const APP_RETURN = /^termix-mobile:\/\/[A-Za-z0-9._~/-]*$/;
|
||||
|
||||
export function isAppReturn(value: unknown): value is string {
|
||||
return typeof value === "string" && APP_RETURN.test(value);
|
||||
}
|
||||
|
||||
/** Reads return_to from the address bar once and keeps it for this tab. */
|
||||
export function captureAppReturn(): string | null {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const value = url.searchParams.get(PARAM);
|
||||
if (value !== null) {
|
||||
url.searchParams.delete(PARAM);
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
if (isAppReturn(value)) sessionStorage.setItem(STORAGE_KEY, value);
|
||||
else sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
return pendingAppReturn();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function pendingAppReturn(): string | null {
|
||||
try {
|
||||
const value = sessionStorage.getItem(STORAGE_KEY);
|
||||
return isAppReturn(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function appReturnUrl(
|
||||
base: string,
|
||||
params: Record<string, string>,
|
||||
): string {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return query ? `${base}?${query}` : base;
|
||||
}
|
||||
|
||||
/** Sends the result to the app. False when no app asked for it. */
|
||||
export function handOffToApp(params: Record<string, string>): boolean {
|
||||
const base = pendingAppReturn();
|
||||
if (!base) return false;
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// The redirect still goes out.
|
||||
}
|
||||
window.location.replace(appReturnUrl(base, params));
|
||||
return true;
|
||||
}
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} is ready to install",
|
||||
"readyHint": "It installs the next time Termix restarts."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Safe mode",
|
||||
"description": "Only layout and theme plugins are running. Restart the server without TERMIX_SAFE_MODE to turn the rest back on."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Close panel",
|
||||
"moveSide": "Move panel",
|
||||
@@ -1315,8 +1311,7 @@
|
||||
"disabled": "Disabled",
|
||||
"failed": "Stopped unexpectedly",
|
||||
"blocked": "Waiting on another plugin",
|
||||
"server": "Runs on your server",
|
||||
"safeMode": "Off in safe mode"
|
||||
"server": "Runs on your server"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Running",
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} is gereed om te installeer",
|
||||
"readyHint": "Dit word die volgende keer wat Termix herbegin geïnstalleer."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Veilige modus",
|
||||
"description": "Slegs uitleg- en tema-plug-ins word uitgevoer. Herbegin die bediener sonder TERMIX_SAFE_MODE om die res weer aan te skakel."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Maak paneel toe",
|
||||
"moveSide": "Skuif paneel",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "Stel die verstek vir almal terug",
|
||||
"update": "Dateer {{name}} op na {{version}}",
|
||||
"disable": "Skakel {{name}} af",
|
||||
"enable": "Skakel {{name}} aan",
|
||||
"startAgain": "Begin {{name}} weer",
|
||||
"install": "Vind lêe en temas",
|
||||
"installTitle": "Lêe en temas",
|
||||
"installButton": "Installeer",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "Afgeskakel",
|
||||
"failed": "Onverwags gestop",
|
||||
"blocked": "Wag op 'n ander plugin",
|
||||
"server": "Loop op jou bediener",
|
||||
"safeMode": "Af in veilige modus"
|
||||
"server": "Loop op jou bediener"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Aktief",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "Onlangs opgedateer",
|
||||
"category": "Kategorie"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Kolomme",
|
||||
"auto": "Outo",
|
||||
"count": "{{count}} breed"
|
||||
},
|
||||
"table": {
|
||||
"name": "Naam",
|
||||
"description": "Beskrywing",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "Rooster",
|
||||
"listView": "Lys",
|
||||
"compactRows": "Kompakte rye",
|
||||
"comfortableRows": "Gerieflike rye",
|
||||
"columns": {
|
||||
"title": "Kolomme",
|
||||
"auto": "Outo",
|
||||
"count": "{{count}} breed"
|
||||
}
|
||||
"comfortableRows": "Gerieflike rye"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Opgeslaan. Tik om dit te vervang."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} جاهز للتثبيت",
|
||||
"readyHint": "سيتم تثبيته في المرة القادمة التي يعاد فيها تشغيل Termix."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "الوضع الآمن",
|
||||
"description": "لا تعمل سوى إضافات التخطيط والمظهر. أعد تشغيل الخادم بدون TERMIX_SAFE_MODE لإعادة تشغيل الباقي."
|
||||
},
|
||||
"companion": {
|
||||
"close": "إغلاق اللوحة",
|
||||
"moveSide": "نقل اللوحة",
|
||||
@@ -1170,8 +1166,6 @@
|
||||
"resetEveryone": "إعادة تعيين الافتراضي للجميع",
|
||||
"update": "تحديث {{name}} إلى {{version}}",
|
||||
"disable": "إيقاف {{name}}",
|
||||
"enable": "تفعيل {{name}}",
|
||||
"startAgain": "إعادة تشغيل {{name}}",
|
||||
"install": "البحث عن التخطيطات والسمات",
|
||||
"installTitle": "التخطيطات والسمات",
|
||||
"installButton": "تثبيت",
|
||||
@@ -1383,8 +1377,7 @@
|
||||
"disabled": "معطلة",
|
||||
"failed": "توقفت بشكل غير متوقع",
|
||||
"blocked": "في انتظار إضافة أخرى",
|
||||
"server": "يعمل على خادمك",
|
||||
"safeMode": "معطل في الوضع الآمن"
|
||||
"server": "يعمل على خادمك"
|
||||
},
|
||||
"groups": {
|
||||
"running": "تعمل",
|
||||
@@ -1398,6 +1391,11 @@
|
||||
"updated": "تم تحديثها مؤخراً",
|
||||
"category": "الفئة"
|
||||
},
|
||||
"columns": {
|
||||
"title": "الأعمدة",
|
||||
"auto": "تلقائي",
|
||||
"count": "عرض {{count}}"
|
||||
},
|
||||
"table": {
|
||||
"name": "الاسم",
|
||||
"description": "الوصف",
|
||||
@@ -1822,12 +1820,7 @@
|
||||
"gridView": "شبكة",
|
||||
"listView": "قائمة",
|
||||
"compactRows": "صفوف مدمجة",
|
||||
"comfortableRows": "صفوف مريحة",
|
||||
"columns": {
|
||||
"title": "الأعمدة",
|
||||
"auto": "تلقائي",
|
||||
"count": "عرض {{count}}"
|
||||
}
|
||||
"comfortableRows": "صفوف مريحة"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "تم الحفظ. اكتب لاستبداله."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} е готов за инсталация",
|
||||
"readyHint": "Ще се инсталира следващия път, когато Termix се рестартира."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Режим на безопасност",
|
||||
"description": "Само плъгините за подредба и тема са активни. Рестартирайте сървъра без променливата TERMIX_SAFE_MODE, за да активирате останалите."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Затвори панела",
|
||||
"moveSide": "Премести панела",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "Върни настройките по подразбиране за всички",
|
||||
"update": "Актуализирай {{name}} до {{version}}",
|
||||
"disable": "Изключи {{name}}",
|
||||
"enable": "Активиране на {{name}}",
|
||||
"startAgain": "Стартиране на {{name}} отново",
|
||||
"install": "Намери подредби и теми",
|
||||
"installTitle": "Подредби и теми",
|
||||
"installButton": "Инсталирай",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "Изключена",
|
||||
"failed": "Спряна неочаквано",
|
||||
"blocked": "Чака друга добавка",
|
||||
"server": "Работи на вашия сървър",
|
||||
"safeMode": "Изключен в режим на безопасност"
|
||||
"server": "Работи на вашия сървър"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Работещи",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "Наскоро актуализирани",
|
||||
"category": "Категория"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Колони",
|
||||
"auto": "Автоматично",
|
||||
"count": "{{count}} колони"
|
||||
},
|
||||
"table": {
|
||||
"name": "Име",
|
||||
"description": "Описание",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "Мрежа",
|
||||
"listView": "Списък",
|
||||
"compactRows": "Компактни редове",
|
||||
"comfortableRows": "Комфортни редове",
|
||||
"columns": {
|
||||
"title": "Колони",
|
||||
"auto": "Автоматично",
|
||||
"count": "ширина {{count}}"
|
||||
}
|
||||
"comfortableRows": "Комфортни редове"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Запазено. Въведете ново, за да го замените."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} ইনস্টল করার জন্য প্রস্তুত",
|
||||
"readyHint": "এটি পরবর্তীবার Termix রিস্টার্ট করলে ইনস্টল হবে।"
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "সেফ মোড",
|
||||
"description": "শুধুমাত্র লেআউট এবং থিম প্লাগইন চলছে। বাকিগুলো আবার চালু করতে TERMIX_SAFE_MODE ছাড়া সার্ভারটি রিস্টার্ট করুন।"
|
||||
},
|
||||
"companion": {
|
||||
"close": "প্যানেল বন্ধ করুন",
|
||||
"moveSide": "প্যানেল সরান",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "সবার জন্য ডিফল্ট রিসেট করুন",
|
||||
"update": "{{name}} কে {{version}} এ আপডেট করুন",
|
||||
"disable": "{{name}} বন্ধ করুন",
|
||||
"enable": "{{name}} চালু করুন",
|
||||
"startAgain": "{{name}} আবার শুরু করুন",
|
||||
"install": "লেআউট এবং থিম খুঁজুন",
|
||||
"installTitle": "লেআউট এবং থিম",
|
||||
"installButton": "ইনস্টল করুন",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "নিষ্ক্রিয়",
|
||||
"failed": "অপ্রত্যাশিতভাবে থেমে গেছে",
|
||||
"blocked": "অন্য প্লাগইনের জন্য অপেক্ষা করছে",
|
||||
"server": "আপনার সার্ভারে চলে",
|
||||
"safeMode": "সেফ মোডে বন্ধ"
|
||||
"server": "আপনার সার্ভারে চলে"
|
||||
},
|
||||
"groups": {
|
||||
"running": "চলমান",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "সাম্প্রতিক আপডেট",
|
||||
"category": "ক্যাটাগরি"
|
||||
},
|
||||
"columns": {
|
||||
"title": "কলাম",
|
||||
"auto": "স্বয়ংক্রিয়",
|
||||
"count": "{{count}} প্রশস্ত"
|
||||
},
|
||||
"table": {
|
||||
"name": "নাম",
|
||||
"description": "বিবরণ",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "গ্রিড",
|
||||
"listView": "তালিকা",
|
||||
"compactRows": "কমপ্যাক্ট সারি",
|
||||
"comfortableRows": "আরামদায়ক সারি",
|
||||
"columns": {
|
||||
"title": "কলাম",
|
||||
"auto": "স্বয়ংক্রিয়",
|
||||
"count": "{{count}} প্রশস্ত"
|
||||
}
|
||||
"comfortableRows": "আরামদায়ক সারি"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "সংরক্ষিত হয়েছে। এটি প্রতিস্থাপন করতে টাইপ করুন।"
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} està llest per instal·lar-se",
|
||||
"readyHint": "S'instal·larà la propera vegada que reiniciïs Termix."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Mode segur",
|
||||
"description": "Només s'estan executant els complements de disseny i tema. Reinicia el servidor sense TERMIX_SAFE_MODE per activar la resta."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Tanca el panell",
|
||||
"moveSide": "Mou el panell",
|
||||
@@ -1119,8 +1115,6 @@
|
||||
"resetEveryone": "Restableix el predeterminat per a tothom",
|
||||
"update": "Actualitza {{name}} a {{version}}",
|
||||
"disable": "Desactiva {{name}}",
|
||||
"enable": "Activar {{name}}",
|
||||
"startAgain": "Reiniciar {{name}}",
|
||||
"install": "Cerca dissenys i temes",
|
||||
"installTitle": "Disseny i temes",
|
||||
"installButton": "Instal·la",
|
||||
@@ -1332,8 +1326,7 @@
|
||||
"disabled": "Desactivat",
|
||||
"failed": "Aturat inesperadament",
|
||||
"blocked": "Esperant un altre plugin",
|
||||
"server": "S'executa al teu servidor",
|
||||
"safeMode": "Desactivat en mode segur"
|
||||
"server": "S'executa al teu servidor"
|
||||
},
|
||||
"groups": {
|
||||
"running": "En execució",
|
||||
@@ -1347,6 +1340,11 @@
|
||||
"updated": "Actualitzats recentment",
|
||||
"category": "Categoria"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Columnes",
|
||||
"auto": "Automàtic",
|
||||
"count": "{{count}} d'amplada"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"description": "Descripció",
|
||||
@@ -1696,12 +1694,7 @@
|
||||
"gridView": "Graella",
|
||||
"listView": "Llista",
|
||||
"compactRows": "Files compactes",
|
||||
"comfortableRows": "Files còmodes",
|
||||
"columns": {
|
||||
"title": "Columnes",
|
||||
"auto": "Automàtic",
|
||||
"count": "{{count}} d'ample"
|
||||
}
|
||||
"comfortableRows": "Files còmodes"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Desat. Escriu per substituir-lo."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} je připraven k instalaci",
|
||||
"readyHint": "Nainstaluje se při příštím restartu Termixu."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Režim nouzového spuštění",
|
||||
"description": "Spouštějí se pouze zásuvné moduly rozložení a motivu. Restartujte server bez proměnné TERMIX_SAFE_MODE, abyste znovu povolili zbytek."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Zavřít panel",
|
||||
"moveSide": "Přesunout panel",
|
||||
@@ -1136,8 +1132,6 @@
|
||||
"resetEveryone": "Obnovit výchozí nastavení pro všechny",
|
||||
"update": "Aktualizovat {{name}} na {{version}}",
|
||||
"disable": "Vypnout {{name}}",
|
||||
"enable": "Zapnout {{name}}",
|
||||
"startAgain": "Spustit {{name}} znovu",
|
||||
"install": "Hledat rozložení a motivy",
|
||||
"installTitle": "Rozložení a motivy",
|
||||
"installButton": "Instalovat",
|
||||
@@ -1349,8 +1343,7 @@
|
||||
"disabled": "Zakázáno",
|
||||
"failed": "Neočekávaně zastaveno",
|
||||
"blocked": "Čeká na jiný plugin",
|
||||
"server": "Běží na vašem serveru",
|
||||
"safeMode": "Vypnuto v režimu nouzového spuštění"
|
||||
"server": "Běží na vašem serveru"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Spuštěné",
|
||||
@@ -1364,6 +1357,11 @@
|
||||
"updated": "Nedávno aktualizované",
|
||||
"category": "Kategorie"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Sloupce",
|
||||
"auto": "Automaticky",
|
||||
"count": "Šířka {{count}}"
|
||||
},
|
||||
"table": {
|
||||
"name": "Název",
|
||||
"description": "Popis",
|
||||
@@ -1738,12 +1736,7 @@
|
||||
"gridView": "Mřížka",
|
||||
"listView": "Seznam",
|
||||
"compactRows": "Kompaktní řádky",
|
||||
"comfortableRows": "Pohodlné řádky",
|
||||
"columns": {
|
||||
"title": "Sloupce",
|
||||
"auto": "Automaticky",
|
||||
"count": "šířka {{count}}"
|
||||
}
|
||||
"comfortableRows": "Pohodlné řádky"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Uloženo. Napište pro nahrazení."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} er klar til installation",
|
||||
"readyHint": "Den installeres næste gang Termix genstartes."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Sikker tilstand",
|
||||
"description": "Kun layout- og tema-plugins kører. Genstart serveren uden TERMIX_SAFE_MODE for at aktivere de øvrige plugins igen."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Luk panel",
|
||||
"moveSide": "Flyt panel",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "Nulstil standardindstillingen for alle",
|
||||
"update": "Opdater {{name}} til {{version}}",
|
||||
"disable": "Sluk {{name}}",
|
||||
"enable": "Aktivér {{name}}",
|
||||
"startAgain": "Start {{name}} igen",
|
||||
"install": "Find layouts og temaer",
|
||||
"installTitle": "Layouts og temaer",
|
||||
"installButton": "Installer",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "Deaktiveret",
|
||||
"failed": "Stoppet uventet",
|
||||
"blocked": "Venter på et andet plugin",
|
||||
"server": "Kører på din server",
|
||||
"safeMode": "Deaktiveret i sikker tilstand"
|
||||
"server": "Kører på din server"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Kører",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "Nyligt opdateret",
|
||||
"category": "Kategori"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Kolonner",
|
||||
"auto": "Auto",
|
||||
"count": "{{count}} bred"
|
||||
},
|
||||
"table": {
|
||||
"name": "Navn",
|
||||
"description": "Beskrivelse",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "Gitter",
|
||||
"listView": "Liste",
|
||||
"compactRows": "Kompakte rækker",
|
||||
"comfortableRows": "Komfortable rækker",
|
||||
"columns": {
|
||||
"title": "Søjler",
|
||||
"auto": "Automatisk",
|
||||
"count": "{{count}} bred"
|
||||
}
|
||||
"comfortableRows": "Komfortable rækker"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Gemt. Skriv for at erstatte det."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} ist bereit zur Installation",
|
||||
"readyHint": "Es wird beim nächsten Neustart von Termix installiert."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Sicherer Modus",
|
||||
"description": "Nur Layout- und Themen-Plugins sind aktiv. Starten Sie den Server ohne TERMIX_SAFE_MODE neu, um die restlichen Plugins wieder zu aktivieren."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Panel schließen",
|
||||
"moveSide": "Panel verschieben",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "Standard für alle zurücksetzen",
|
||||
"update": "{{name}} auf {{version}} aktualisieren",
|
||||
"disable": "{{name}} deaktivieren",
|
||||
"enable": "{{name}} aktivieren",
|
||||
"startAgain": "{{name}} neu starten",
|
||||
"install": "Layouts und Themes finden",
|
||||
"installTitle": "Layouts und Themes",
|
||||
"installButton": "Installieren",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "Deaktiviert",
|
||||
"failed": "Unerwartet gestoppt",
|
||||
"blocked": "Wartet auf ein anderes Plugin",
|
||||
"server": "Läuft auf deinem Server",
|
||||
"safeMode": "Im sicheren Modus deaktiviert"
|
||||
"server": "Läuft auf deinem Server"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Aktiv",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "Kürzlich aktualisiert",
|
||||
"category": "Kategorie"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Spalten",
|
||||
"auto": "Auto",
|
||||
"count": "{{count}} Spalten breit"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "Raster",
|
||||
"listView": "Liste",
|
||||
"compactRows": "Kompakte Zeilen",
|
||||
"comfortableRows": "Bequeme Zeilen",
|
||||
"columns": {
|
||||
"title": "Spalten",
|
||||
"auto": "Automatisch",
|
||||
"count": "{{count}} Spalten breit"
|
||||
}
|
||||
"comfortableRows": "Bequeme Zeilen"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Gespeichert. Tippen, um es zu ersetzen."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Το Termix {{version}} είναι έτοιμο για εγκατάσταση",
|
||||
"readyHint": "Εγκαθίσταται την επόμενη φορά που θα επανεκκινήσει το Termix."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Λειτουργία ασφαλείας",
|
||||
"description": "Τρέχουν μόνο οι προσθήκες διάταξης και θέματος. Επανεκκινήστε τον διακομιστή χωρίς το TERMIX_SAFE_MODE για να ενεργοποιήσετε ξανά τις υπόλοιπες."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Κλείσιμο πάνελ",
|
||||
"moveSide": "Μετακίνηση πάνελ",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "Επαναφορά της προεπιλογής για όλους",
|
||||
"update": "Ενημέρωση του {{name}} στην έκδοση {{version}}",
|
||||
"disable": "Απενεργοποίηση του {{name}}",
|
||||
"enable": "Ενεργοποίηση {{name}}",
|
||||
"startAgain": "Εκκίνηση {{name}} ξανά",
|
||||
"install": "Εύρεση layouts και θεμάτων",
|
||||
"installTitle": "Layouts και θέματα",
|
||||
"installButton": "Εγκατάσταση",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "Απενεργοποιημένο",
|
||||
"failed": "Διακόπηκε απροσδόκητα",
|
||||
"blocked": "Περιμένει άλλο plugin",
|
||||
"server": "Τρέχει στον διακομιστή σας",
|
||||
"safeMode": "Ανενεργό στη λειτουργία ασφαλείας"
|
||||
"server": "Τρέχει στον διακομιστή σας"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Εν ενεργεία",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "Πρόσφατα ενημερωμένα",
|
||||
"category": "Κατηγορία"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Στήλες",
|
||||
"auto": "Αυτόματο",
|
||||
"count": "{{count}} στήλες"
|
||||
},
|
||||
"table": {
|
||||
"name": "Όνομα",
|
||||
"description": "Περιγραφή",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "Πλέγμα",
|
||||
"listView": "Λίστα",
|
||||
"compactRows": "Συμπαγείς γραμμές",
|
||||
"comfortableRows": "Άνετες γραμμές",
|
||||
"columns": {
|
||||
"title": "Στήλες",
|
||||
"auto": "Αυτόματο",
|
||||
"count": "Πλάτος {{count}}"
|
||||
}
|
||||
"comfortableRows": "Άνετες γραμμές"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Αποθηκεύτηκε. Πληκτρολογήστε για να το αντικαταστήσετε."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} está listo para instalar",
|
||||
"readyHint": "Se instalará la próxima vez que reinicies Termix."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Modo seguro",
|
||||
"description": "Solo se están ejecutando los complementos de diseño y tema. Reinicia el servidor sin TERMIX_SAFE_MODE para volver a activar el resto."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Cerrar panel",
|
||||
"moveSide": "Mover panel",
|
||||
@@ -1119,8 +1115,6 @@
|
||||
"resetEveryone": "Restablecer el valor predeterminado para todos",
|
||||
"update": "Actualizar {{name}} a {{version}}",
|
||||
"disable": "Desactivar {{name}}",
|
||||
"enable": "Activar {{name}}",
|
||||
"startAgain": "Reiniciar {{name}}",
|
||||
"install": "Buscar diseños y temas",
|
||||
"installTitle": "Diseños y temas",
|
||||
"installButton": "Instalar",
|
||||
@@ -1332,8 +1326,7 @@
|
||||
"disabled": "Desactivado",
|
||||
"failed": "Detenido inesperadamente",
|
||||
"blocked": "Esperando a otro complemento",
|
||||
"server": "Se ejecuta en tu servidor",
|
||||
"safeMode": "Desactivado en modo seguro"
|
||||
"server": "Se ejecuta en tu servidor"
|
||||
},
|
||||
"groups": {
|
||||
"running": "En ejecución",
|
||||
@@ -1347,6 +1340,11 @@
|
||||
"updated": "Actualizados recientemente",
|
||||
"category": "Categoría"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Columnas",
|
||||
"auto": "Automático",
|
||||
"count": "{{count}} de ancho"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nombre",
|
||||
"description": "Descripción",
|
||||
@@ -1696,12 +1694,7 @@
|
||||
"gridView": "Cuadrícula",
|
||||
"listView": "Lista",
|
||||
"compactRows": "Filas compactas",
|
||||
"comfortableRows": "Filas cómodas",
|
||||
"columns": {
|
||||
"title": "Columnas",
|
||||
"auto": "Automático",
|
||||
"count": "{{count}} de ancho"
|
||||
}
|
||||
"comfortableRows": "Filas cómodas"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Guardado. Escribe para reemplazarlo."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} on paigaldamiseks valmis",
|
||||
"readyHint": "See paigaldatakse järgmisel Termixi taaskäivitamisel."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Turvarežiim",
|
||||
"description": "Töötavad ainult paigutuse ja teema lisad. Taaskäivitage server ilma TERMIX_SAFE_MODE määramata, et ülejäänud lisad uuesti lubada."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Sulge paneel",
|
||||
"moveSide": "Liiguta paneeli",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "Taasta vaikimisi seadistus kõigile",
|
||||
"update": "Uuenda {{name}} versioonile {{version}}",
|
||||
"disable": "Lülita {{name}} välja",
|
||||
"enable": "Lülita {{name}} sisse",
|
||||
"startAgain": "Käivita {{name}} uuesti",
|
||||
"install": "Otsi paigutusi ja teemasid",
|
||||
"installTitle": "Paigutused ja teemad",
|
||||
"installButton": "Paigalda",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "Keelatud",
|
||||
"failed": "Peatati ootamatult",
|
||||
"blocked": "Ootab teist pistikprogrammi",
|
||||
"server": "Töötab sinu serveris",
|
||||
"safeMode": "Turvarežiimis välja lülitatud"
|
||||
"server": "Töötab sinu serveris"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Käimas",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "Hiljuti uuendatud",
|
||||
"category": "Kategooria"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Veerud",
|
||||
"auto": "Automaatne",
|
||||
"count": "{{count}} lai"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nimi",
|
||||
"description": "Kirjeldus",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "Ruudustik",
|
||||
"listView": "Loend",
|
||||
"compactRows": "Kompaktsead read",
|
||||
"comfortableRows": "Mugavad read",
|
||||
"columns": {
|
||||
"title": "Veerud",
|
||||
"auto": "Automaatne",
|
||||
"count": "{{count}} veeru laiune"
|
||||
}
|
||||
"comfortableRows": "Mugavad read"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Salvestatud. Tüüpi, et asendada."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} on valmis asennettavaksi",
|
||||
"readyHint": "Se asennetaan seuraavan kerran, kun Termix käynnistetään uudelleen."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Turvatoiminto",
|
||||
"description": "Vain asettelun ja teeman lisäosat ovat käytössä. Käynnistä palvelin uudelleen ilman TERMIX_SAFE_MODE-muuttujaa ottaaksesi muut lisäosat takaisin käyttöön."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Sulje paneeli",
|
||||
"moveSide": "Siirrä paneeli",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "Palauta oletus kaikille",
|
||||
"update": "Päivitä {{name}} versioon {{version}}",
|
||||
"disable": "Sammuta {{name}}",
|
||||
"enable": "Ota {{name}} käyttöön",
|
||||
"startAgain": "Käynnistä {{name}} uudelleen",
|
||||
"install": "Etsi asetteluja ja teemoja",
|
||||
"installTitle": "Asettelut ja teemat",
|
||||
"installButton": "Asenna",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "Poistettu käytöstä",
|
||||
"failed": "Pysähtyi odottamatta",
|
||||
"blocked": "Odottaa toista lisäosaa",
|
||||
"server": "Käynnistyy palvelimellasi",
|
||||
"safeMode": "Pois käytöstä turvatoiminnossa"
|
||||
"server": "Käynnistyy palvelimellasi"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Käynnissä",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "Viimeksi päivitetty",
|
||||
"category": "Kategoria"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Sarakkeet",
|
||||
"auto": "Automaattinen",
|
||||
"count": "{{count}} saraketta leveä"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nimi",
|
||||
"description": "Kuvaus",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "Ruudukko",
|
||||
"listView": "Lista",
|
||||
"compactRows": "Tiiviit rivit",
|
||||
"comfortableRows": "Mukavat rivit",
|
||||
"columns": {
|
||||
"title": "Sarakkeet",
|
||||
"auto": "Automaattinen",
|
||||
"count": "{{count}} saraketta leveä"
|
||||
}
|
||||
"comfortableRows": "Mukavat rivit"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Tallennettu. Kirjoita korvataksesi sen."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} est prêt à être installé",
|
||||
"readyHint": "Il sera installé au prochain redémarrage de Termix."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Mode sans échec",
|
||||
"description": "Seuls les plugins de mise en page et de thème sont actifs. Redémarrez le serveur sans la variable TERMIX_SAFE_MODE pour réactiver les autres."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Fermer le panneau",
|
||||
"moveSide": "Déplacer le panneau",
|
||||
@@ -1119,8 +1115,6 @@
|
||||
"resetEveryone": "Réinitialiser la valeur par défaut pour tout le monde",
|
||||
"update": "Mettre à jour {{name}} vers {{version}}",
|
||||
"disable": "Désactiver {{name}}",
|
||||
"enable": "Activer {{name}}",
|
||||
"startAgain": "Relancer {{name}}",
|
||||
"install": "Trouver des dispositions et des thèmes",
|
||||
"installTitle": "Dispositions et thèmes",
|
||||
"installButton": "Installer",
|
||||
@@ -1332,8 +1326,7 @@
|
||||
"disabled": "Désactivé",
|
||||
"failed": "Arrêt inattendu",
|
||||
"blocked": "En attente d'un autre plugin",
|
||||
"server": "Exécute sur votre serveur",
|
||||
"safeMode": "Désactivé en mode sans échec"
|
||||
"server": "Exécute sur votre serveur"
|
||||
},
|
||||
"groups": {
|
||||
"running": "En cours d'exécution",
|
||||
@@ -1347,6 +1340,11 @@
|
||||
"updated": "Récemment mis à jour",
|
||||
"category": "Catégorie"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Colonnes",
|
||||
"auto": "Auto",
|
||||
"count": "{{count}} de large"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"description": "Description",
|
||||
@@ -1696,12 +1694,7 @@
|
||||
"gridView": "Grille",
|
||||
"listView": "Liste",
|
||||
"compactRows": "Lignes compactes",
|
||||
"comfortableRows": "Lignes confortables",
|
||||
"columns": {
|
||||
"title": "Colonnes",
|
||||
"auto": "Auto",
|
||||
"count": "{{count}} de large"
|
||||
}
|
||||
"comfortableRows": "Lignes confortables"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Enregistré. Tapez pour le remplacer."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} מוכן להתקנה",
|
||||
"readyHint": "הוא מותקן בפעם הבאה ש-Termix מופעל מחדש."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "מצב בטוח",
|
||||
"description": "רק תוספי פריסה ועיצוב פועלים. הפעל מחדש את השרת ללא המשתנה TERMIX_SAFE_MODE כדי להפעיל מחדש את שאר התוספים."
|
||||
},
|
||||
"companion": {
|
||||
"close": "סגור לוח",
|
||||
"moveSide": "הזז לוח",
|
||||
@@ -1119,8 +1115,6 @@
|
||||
"resetEveryone": "אפס את ברירת המחדל לכולם",
|
||||
"update": "עדכן את {{name}} ל-{{version}}",
|
||||
"disable": "כבה את {{name}}",
|
||||
"enable": "הפעל את {{name}}",
|
||||
"startAgain": "הפעל מחדש את {{name}}",
|
||||
"install": "חפש פריסות וערכות נושא",
|
||||
"installTitle": "פריסות וערכות נושא",
|
||||
"installButton": "התקן",
|
||||
@@ -1332,8 +1326,7 @@
|
||||
"disabled": "מושבת",
|
||||
"failed": "הופסק ללא ציפייה",
|
||||
"blocked": "ממתין לתוסף אחר",
|
||||
"server": "רץ על השרת שלך",
|
||||
"safeMode": "כבוי במצב בטוח"
|
||||
"server": "רץ על השרת שלך"
|
||||
},
|
||||
"groups": {
|
||||
"running": "פעילים",
|
||||
@@ -1347,6 +1340,11 @@
|
||||
"updated": "עודכן לאחרונה",
|
||||
"category": "קטגוריה"
|
||||
},
|
||||
"columns": {
|
||||
"title": "עמודות",
|
||||
"auto": "אוטומטי",
|
||||
"count": "רוחב של {{count}}"
|
||||
},
|
||||
"table": {
|
||||
"name": "שם",
|
||||
"description": "תיאור",
|
||||
@@ -1696,12 +1694,7 @@
|
||||
"gridView": "רשת",
|
||||
"listView": "רשימה",
|
||||
"compactRows": "שורות צפופות",
|
||||
"comfortableRows": "שורות נוחות",
|
||||
"columns": {
|
||||
"title": "עמודות",
|
||||
"auto": "אוטומטי",
|
||||
"count": "רוחב של {{count}}"
|
||||
}
|
||||
"comfortableRows": "שורות נוחות"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "נשמר. הקלד כדי להחליף אותו."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} इंस्टॉल करने के लिए तैयार है",
|
||||
"readyHint": "यह अगली बार जब Termix पुनरारंभ होगा तब इंस्टॉल होगा।"
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "सुरक्षित मोड",
|
||||
"description": "केवल लेआउट और थीम प्लगइन चल रहे हैं। बाकी सब कुछ वापस चालू करने के लिए TERMIX_SAFE_MODE के बिना सर्वर को पुनः आरंभ करें।"
|
||||
},
|
||||
"companion": {
|
||||
"close": "पैनल बंद करें",
|
||||
"moveSide": "पैनल स्थानांतरित करें",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "सभी के लिए डिफ़ॉल्ट रीसेट करें",
|
||||
"update": "{{name}} को {{version}} पर अपडेट करें",
|
||||
"disable": "{{name}} बंद करें",
|
||||
"enable": "{{name}} चालू करें",
|
||||
"startAgain": "{{name}} को फिर से शुरू करें",
|
||||
"install": "लेआउट और थीम खोजें",
|
||||
"installTitle": "लेआउट और थीम",
|
||||
"installButton": "इंस्टॉल करें",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "अक्षम",
|
||||
"failed": "अप्रत्याशित रूप से रुका",
|
||||
"blocked": "दूसरे प्लगइन का इंतज़ार कर रहा है",
|
||||
"server": "आपके सर्वर पर चलता है",
|
||||
"safeMode": "सुरक्षित मोड में बंद"
|
||||
"server": "आपके सर्वर पर चलता है"
|
||||
},
|
||||
"groups": {
|
||||
"running": "चल रहे",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "हाल ही में अपडेट किया गया",
|
||||
"category": "श्रेणी"
|
||||
},
|
||||
"columns": {
|
||||
"title": "कॉलम",
|
||||
"auto": "स्वचालित",
|
||||
"count": "{{count}} चौड़ा"
|
||||
},
|
||||
"table": {
|
||||
"name": "नाम",
|
||||
"description": "विवरण",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "ग्रिड",
|
||||
"listView": "सूची",
|
||||
"compactRows": "संक्षिप्त पंक्तियाँ",
|
||||
"comfortableRows": "आरामदायक पंक्तियाँ",
|
||||
"columns": {
|
||||
"title": "कॉलम",
|
||||
"auto": "स्वचालित",
|
||||
"count": "{{count}} चौड़ा"
|
||||
}
|
||||
"comfortableRows": "आरामदायक पंक्तियाँ"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "सहेजा गया। इसे बदलने के लिए टाइप करें।"
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "Termix {{version}} je spreman za instalaciju",
|
||||
"readyHint": "Instalirat će se sljedeći put kada se Termix ponovno pokrene."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Sigurni način rada",
|
||||
"description": "Pokreću se samo dodaci za raspored i temu. Ponovno pokrenite poslužitelj bez postavke TERMIX_SAFE_MODE da biste ponovno omogućili ostale dodatke."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Zatvori panel",
|
||||
"moveSide": "Pomakni panel",
|
||||
@@ -1119,8 +1115,6 @@
|
||||
"resetEveryone": "Vrati zadano za sve",
|
||||
"update": "Ažuriraj {{name}} na {{version}}",
|
||||
"disable": "Isključi {{name}}",
|
||||
"enable": "Uključi {{name}}",
|
||||
"startAgain": "Ponovno pokreni {{name}}",
|
||||
"install": "Pronađi rasporede i teme",
|
||||
"installTitle": "Rasporedi i teme",
|
||||
"installButton": "Instaliraj",
|
||||
@@ -1332,8 +1326,7 @@
|
||||
"disabled": "Onemogućeno",
|
||||
"failed": "Neočekivano zaustavljeno",
|
||||
"blocked": "Čeka drugi dodatak",
|
||||
"server": "Radi na vašem poslužitelju",
|
||||
"safeMode": "Isključeno u sigurnom načinu rada"
|
||||
"server": "Radi na vašem poslužitelju"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Pokrenuto",
|
||||
@@ -1347,6 +1340,11 @@
|
||||
"updated": "Nedavno ažurirano",
|
||||
"category": "Kategorija"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Stupci",
|
||||
"auto": "Automatski",
|
||||
"count": "{{count}} široko"
|
||||
},
|
||||
"table": {
|
||||
"name": "Naziv",
|
||||
"description": "Opis",
|
||||
@@ -1696,12 +1694,7 @@
|
||||
"gridView": "Mreža",
|
||||
"listView": "Popis",
|
||||
"compactRows": "Kompaktni redovi",
|
||||
"comfortableRows": "Prostrani redovi",
|
||||
"columns": {
|
||||
"title": "Stupci",
|
||||
"auto": "Automatski",
|
||||
"count": "Širina: {{count}}"
|
||||
}
|
||||
"comfortableRows": "Prostrani redovi"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Spremljeno. Upišite za zamjenu."
|
||||
|
||||
@@ -235,10 +235,6 @@
|
||||
"ready": "A Termix {{version}} verziója készen áll a telepítésre",
|
||||
"readyHint": "A következő újraindításkor települ."
|
||||
},
|
||||
"safeMode": {
|
||||
"title": "Biztonságos mód",
|
||||
"description": "Csak a elrendezés- és témabővítmények futnak. Indítsa újra a szervert a TERMIX_SAFE_MODE nélkül, hogy a többit is újra bekapcsolja."
|
||||
},
|
||||
"companion": {
|
||||
"close": "Panel bezárása",
|
||||
"moveSide": "Panel áthelyezése",
|
||||
@@ -1102,8 +1098,6 @@
|
||||
"resetEveryone": "Az alapértelmezett visszaállítása mindenkinek",
|
||||
"update": "{{name}} frissítése a {{version}} verzióra",
|
||||
"disable": "{{name}} kikapcsolása",
|
||||
"enable": "{{name}} bekapcsolása",
|
||||
"startAgain": "{{name}} újraindítása",
|
||||
"install": "Elrendezések és témák keresése",
|
||||
"installTitle": "Elrendezések és témák",
|
||||
"installButton": "Telepítés",
|
||||
@@ -1315,8 +1309,7 @@
|
||||
"disabled": "Letiltva",
|
||||
"failed": "Váratlanul leállt",
|
||||
"blocked": "Várakozás egy másik bővítményre",
|
||||
"server": "A szerverén fut",
|
||||
"safeMode": "Kikapcsolva biztonságos módban"
|
||||
"server": "A szerverén fut"
|
||||
},
|
||||
"groups": {
|
||||
"running": "Fut",
|
||||
@@ -1330,6 +1323,11 @@
|
||||
"updated": "Legutóbb frissítve",
|
||||
"category": "Kategória"
|
||||
},
|
||||
"columns": {
|
||||
"title": "Oszlopok",
|
||||
"auto": "Automatikus",
|
||||
"count": "{{count}} oszlop széles"
|
||||
},
|
||||
"table": {
|
||||
"name": "Név",
|
||||
"description": "Leírás",
|
||||
@@ -1654,12 +1652,7 @@
|
||||
"gridView": "Rács",
|
||||
"listView": "Lista",
|
||||
"compactRows": "Kompakt sorok",
|
||||
"comfortableRows": "Kényelmes sorok",
|
||||
"columns": {
|
||||
"title": "Oszlopok",
|
||||
"auto": "Automatikus",
|
||||
"count": "{{count}} oszlop széles"
|
||||
}
|
||||
"comfortableRows": "Kényelmes sorok"
|
||||
},
|
||||
"fields": {
|
||||
"secretSaved": "Mentve. Írjon be új értéket a helyettesítéshez."
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user