Compare commits

..
Author SHA1 Message Date
LukeGus 5f332aa5c3 feat: add an auto or fixed columns picker to card grids 2026-10-21 21:35:25 -05:00
135 changed files with 473 additions and 19765 deletions

No files matched your search

+3 -15
View File
@@ -41,15 +41,9 @@ on:
default: "" default: ""
jobs: jobs:
# One image per plugin profile (docker/bundled-plugins.json). standard keeps
# the plain tags; minimal and full add a suffix.
build: build:
runs-on: blacksmith-8vcpu-ubuntu-2404 runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20 timeout-minutes: 20
strategy:
fail-fast: false
matrix:
profile: [standard, minimal, full]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
@@ -86,17 +80,12 @@ jobs:
TAGS=() TAGS=()
ALL_TAGS=() ALL_TAGS=()
SUFFIX=""
if [ "${{ matrix.profile }}" != "standard" ]; then
SUFFIX="-${{ matrix.profile }}"
fi
if [ "$BUILD_TYPE" = "Production" ]; then if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX") TAGS+=("$VERSION" "latest")
elif [ "$BUILD_TYPE" = "Beta" ]; then elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX") TAGS+=("beta" "$VERSION")
else else
TAGS+=("dev-$VERSION$SUFFIX") TAGS+=("dev-$VERSION")
fi fi
for tag in "${TAGS[@]}"; do for tag in "${TAGS[@]}"; do
@@ -135,7 +124,6 @@ jobs:
tags: ${{ env.ALL_TAGS }} tags: ${{ env.ALL_TAGS }}
build-args: | build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
PLUGIN_PROFILE=${{ matrix.profile }}
labels: | labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }} org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
+7 -15
View File
@@ -161,10 +161,9 @@ jobs:
echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT" echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
# Translates new or changed strings with the Termix LLM server and pins the # Translates new or changed strings with the Termix LLM server and commits
# bundled plugins to their newest registry releases, then commits both to the # them to the dev branch, so the beta or stable build includes them. If the
# dev branch so the beta or stable build includes them. If the server is down # server is down it warns and the release goes on with what is there.
# it warns and the release goes on with what is there.
translate: translate:
needs: [prep] needs: [prep]
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }} if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
@@ -192,26 +191,19 @@ jobs:
languages: src/ui/locales/languages.json languages: src/ui/locales/languages.json
api-key: ${{ secrets.TERMIX_LLM_API_KEY }} api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
- name: Install dependencies - name: Commit translations
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
id: commit id: commit
run: | run: |
git add src/ui/locales docker/bundled-plugins.json git add src/ui/locales
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No translation changes." echo "No translation changes."
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then 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 git --no-pager diff --cached --stat
else else
git config user.name "LukeGus" git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com" 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 }}" git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
fi fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
-22
View File
@@ -1,33 +1,11 @@
# Changelog # 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
## 26.10.5 ## 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 ### 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 - 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 ## 26.10.4
### Fixed ### Fixed
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do cask "termix" do
version "26.10.5" version "26.10.3"
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c" sha256 "a0c9db16f144c1e2be6ce2f4ead9268a010d12e0d0608feb34976750661a2239"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg" url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix" name "Termix"
-1
View File
@@ -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. | | [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 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. | | [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. | | [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. | | [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. | | [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
View File
@@ -34,20 +34,9 @@ WORKDIR /app
COPY . . 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. # 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 \ 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 # Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:24-slim AS opkssh-downloader 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=production-deps /app/node_modules /app/node_modules
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
# Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved # Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by
# as dist/plugins by getBundledPluginsDir(). # getBundledPluginsDir().
COPY --chown=node:node --from=plugins /app/dist/plugins ./dist/plugins 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. # 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/package.json ./packages/plugin-sdk/package.json
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
+61 -155
View File
@@ -3,23 +3,20 @@
{ {
"id": "acme-ssl", "id": "acme-ssl",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a", "sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
"tier": "full"
}, },
{ {
"id": "ai", "id": "ai",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.2.0/ai-1.2.0.tmxplug",
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6", "sha256": "3b486133af78ae5168e6f5b8a4c71f3db3403cfdae537ed1ca8d6cc16035d8dd"
"tier": "full"
}, },
{ {
"id": "alerts", "id": "alerts",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c", "sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -29,22 +26,19 @@
"id": "audit-tools", "id": "audit-tools",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug",
"sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b", "sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b"
"tier": "full"
}, },
{ {
"id": "automations", "id": "automations",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.3/automations-1.0.3.tmxplug",
"sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea", "sha256": "b5e5b39a053010b72ad1ba03ea6bb05afbbb2b789a86e0e98634d9ada543d02d"
"tier": "full"
}, },
{ {
"id": "classic-layout", "id": "classic-layout",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.1/classic-layout-1.0.1.tmxplug",
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14", "sha256": "6b2d9acd77ef4b33fa0761c1aea7ed3c6171c325ee8febcedff94b5b2bc7be34",
"tier": "minimal",
"defaultLook": true "defaultLook": true
}, },
{ {
@@ -52,36 +46,19 @@
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c", "sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
"tier": "minimal",
"defaultLook": true "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", "id": "credential-tools",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce", "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"
}, },
{ {
"id": "docker", "id": "docker",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.1.0/docker-1.1.0.tmxplug",
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff", "sha256": "da32dfbe9d4cffec5d80c4a1037a79e7591a3d16c50f882db6a146322a50c2b7",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -90,9 +67,8 @@
{ {
"id": "file-manager", "id": "file-manager",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.0/file-manager-1.1.0.tmxplug",
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd", "sha256": "807e9ffa80d6054a5911522721a555e0daa3e50f6edffdebe97efe7c91aea7a2",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -102,22 +78,19 @@
"id": "fleets", "id": "fleets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59", "sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
"tier": "full"
}, },
{ {
"id": "homepage", "id": "homepage",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug",
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00", "sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00"
"tier": "full"
}, },
{ {
"id": "host-metrics", "id": "host-metrics",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.1.0/host-metrics-1.1.0.tmxplug",
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3", "sha256": "4f1d422ec1d0565c79f94fa814ee0864d556d7367793b11035e68239e49e297f",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -127,142 +100,93 @@
"id": "host-status", "id": "host-status",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170", "sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170"
"tier": "standard"
}, },
{ {
"id": "hosts-config", "id": "hosts-config",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug",
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad", "sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad"
"tier": "full"
}, },
{ {
"id": "import-export", "id": "import-export",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58", "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"
}, },
{ {
"id": "ldap", "id": "ldap",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03", "sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
"tier": "full"
}, },
{ {
"id": "login-security", "id": "login-security",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug",
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa", "sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa"
"tier": "full"
}, },
{ {
"id": "network-topology", "id": "network-topology",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56", "sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
"tier": "full"
}, },
{ {
"id": "opkssh", "id": "opkssh",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219", "sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
"tier": "full"
}, },
{ {
"id": "proxmox", "id": "proxmox",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf", "sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
"tier": "full"
}, },
{ {
"id": "remote-desktop", "id": "remote-desktop",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.0/remote-desktop-1.2.0.tmxplug",
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7", "sha256": "0de40896bfeb4b1a702c3ce815355b7b7b0bb4bfe424f33e905bb5a0209d02f6",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "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", "id": "secret-sources",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134", "sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
"tier": "full"
}, },
{ {
"id": "serial", "id": "serial",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715", "sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": "desktop", "recommended": "desktop",
"consent": false "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", "id": "session-recording",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a", "sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
"tier": "full"
}, },
{ {
"id": "session-sharing", "id": "session-sharing",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.1.0/session-sharing-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "2988b39221d93b3dbc5e2a81e104e5dc01ce264afb1e66df3407492658ab9fdc", "sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
"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"
}, },
{ {
"id": "snippets", "id": "snippets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3", "sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -271,9 +195,8 @@
{ {
"id": "ssh-terminal", "id": "ssh-terminal",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.0/ssh-terminal-1.1.0.tmxplug",
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8", "sha256": "dacdbadb3c6a0d7014b1de4f62b499f80abf358c7799a6e6326674660b055e4a",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -282,44 +205,37 @@
{ {
"id": "sso", "id": "sso",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9", "sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": false, "recommended": false,
"consent": false, "consent": false,
"enabledByEnv": [ "enabledByEnv": ["OIDC_CLIENT_ID"]
"OIDC_CLIENT_ID"
]
} }
}, },
{ {
"id": "step-ca", "id": "step-ca",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a", "sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
"tier": "full"
}, },
{ {
"id": "sync", "id": "sync",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.0/sync-1.0.0.tmxplug",
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e", "sha256": "12ced05523f7c1a9151b5e680726bd31f53888e8bb0047d7bd12a2570ea168bf"
"tier": "standard"
}, },
{ {
"id": "tailscale", "id": "tailscale",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf", "sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
"tier": "full"
}, },
{ {
"id": "telemetry", "id": "telemetry",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec", "sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": true "consent": true
@@ -329,22 +245,19 @@
"id": "termix-identity", "id": "termix-identity",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927", "sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
"tier": "full"
}, },
{ {
"id": "tmux-monitor", "id": "tmux-monitor",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.4/tmux-monitor-1.0.4.tmxplug",
"sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006", "sha256": "42b8a2284e68a321c1736984f5c641b1857c87c6a65ce8cf694c6bc946de2444"
"tier": "full"
}, },
{ {
"id": "totp", "id": "totp",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0", "sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -353,9 +266,8 @@
{ {
"id": "tunnels", "id": "tunnels",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.1.0/tunnels-1.1.0.tmxplug",
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159", "sha256": "b1e4f1b6dd2bbd15a9a240ae1c1d381111c3609258c8946d19493a83f861ea1e",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -365,36 +277,31 @@
"id": "vault", "id": "vault",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28", "sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
"tier": "full"
}, },
{ {
"id": "wake-on-lan", "id": "wake-on-lan",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8", "sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
"tier": "full"
}, },
{ {
"id": "warpgate", "id": "warpgate",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47", "sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
"tier": "full"
}, },
{ {
"id": "web-endpoint", "id": "web-endpoint",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.3/web-endpoint-1.0.3.tmxplug",
"sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c", "sha256": "014cbf23c82cab20e485bc4f4c9b8baa779aee9bb3ed644b41f22a9b1be95687"
"tier": "full"
}, },
{ {
"id": "webauthn", "id": "webauthn",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de", "sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -403,9 +310,8 @@
{ {
"id": "workspaces", "id": "workspaces",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf", "sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
-2
View File
@@ -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
-7
View File
@@ -533,13 +533,6 @@
"when": 1792430772041, "when": 1792430772041,
"tag": "0075_session_policy", "tag": "0075_session_policy",
"breakpoints": true "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
-7
View File
@@ -533,13 +533,6 @@
"when": 1792430770990, "when": 1792430770990,
"tag": "0075_session_policy", "tag": "0075_session_policy",
"breakpoints": true "breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1792945360507,
"tag": "0076_host_color_icon",
"breakpoints": true
} }
] ]
} }
-2
View File
@@ -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
-7
View File
@@ -498,13 +498,6 @@
"when": 1792430769899, "when": 1792430769899,
"tag": "0070_session_policy", "tag": "0070_session_policy",
"breakpoints": true "breakpoints": true
},
{
"idx": 71,
"version": "6",
"when": 1792945434651,
"tag": "0071_host_color_icon",
"breakpoints": true
} }
] ]
} }
-6
View File
@@ -128,10 +128,6 @@
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"hardenedRuntime": true, "hardenedRuntime": true,
"gatekeeperAssess": false, "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", "entitlements": "packaging/build/entitlements.mac.plist",
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist", "entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution", "type": "distribution",
@@ -157,8 +153,6 @@
"artifactName": "termix_macos_${arch}_mas.${ext}", "artifactName": "termix_macos_${arch}_mas.${ext}",
"extendInfo": { "extendInfo": {
"ITSAppUsesNonExemptEncryption": false, "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." "NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
} }
}, },
+1 -12
View File
@@ -298,7 +298,7 @@
{ {
"name": "HTTP_PROXY", "name": "HTTP_PROXY",
"group": "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", "name": "HTTPS_PROXY",
@@ -353,11 +353,6 @@
"group": "plugins", "group": "plugins",
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index." "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", "name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
"group": "plugins", "group": "plugins",
@@ -370,12 +365,6 @@
"default": "false", "default": "false",
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app." "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", "name": "TERMIX_BUNDLED_PLUGINS_DIR",
"group": "plugins", "group": "plugins",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "26.10.6", "version": "26.10.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "26.10.6", "version": "26.10.5",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
+2 -3
View File
@@ -4,7 +4,7 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"version": "26.10.6", "version": "26.10.5",
"description": "Self-hosted, plugin-based server management.", "description": "Self-hosted, plugin-based server management.",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
@@ -31,8 +31,7 @@
"dev:docker": "node scripts/dev.mjs docker", "dev:docker": "node scripts/dev.mjs docker",
"dev:frontend": "vite", "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": "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:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build:backend": "npm run build:backend:core && npm run build:plugins",
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs", "generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty", "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", "build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
-22
View File
@@ -1,27 +1,5 @@
# Changelog # 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 ## 1.0.20
Plugin API 1.12.0. Plugin API 1.12.0.
+3 -48
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # 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 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. `@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`, `contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`, `settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first. `uiPresets`, `uses`, `layouts` and `themes`. 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" }`.
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your 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. 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 `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 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 sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
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`.
### The desktop app ### 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/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | | `@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-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-certs` | `applyCertificateAuth()` for certificate based SSH auth |
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` | | `@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 | | `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
+36 -55
View File
@@ -25,43 +25,6 @@ const FRONTEND_ENTRIES = [
"src/frontend/index.mjs", "src/frontend/index.mjs",
"src/frontend/index.js", "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 * 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. // build() adds to what a compiler already emitted, so each half gets its own.
const compiler = () => const compiler = () =>
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} }); compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
// The mobile bundle uses the same stylesheet. const scanner = new Scanner({
const sources = ["frontend", "mobile"] sources: [
.map((dir) => path.join(cwd, "src", dir)) {
.filter((dir, index) => index === 0 || fs.existsSync(dir)) base: path.join(cwd, "src", "frontend"),
.map((base) => ({ base, pattern: "**/*", negated: false })); pattern: "**/*",
const scanner = new Scanner({ sources }); negated: false,
},
],
});
// Flattened and minified the way core's own CSS is, so it does not rely // Flattened and minified the way core's own CSS is, so it does not rely
// on native CSS nesting. // on native CSS nesting.
const emit = async (candidates, options) => const emit = async (candidates, options) =>
@@ -331,18 +297,33 @@ export async function build({ cwd }) {
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES); const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) { if (frontendEntry) {
await buildBrowserBundle( await esbuild.build({
frontendEntry, entryPoints: [frontendEntry],
path.join(outDir, "frontend.js"), outfile: path.join(outDir, "frontend.js"),
outDir, bundle: true,
); format: "esm",
} platform: "browser",
target: "es2022",
// A bundle the mobile app loads in its WebView, next to the frontend one. jsx: "automatic",
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES); sourcemap: true,
const mobileOut = manifest.contributes?.mobile?.entry; logLevel: "warning",
if (mobileEntry && typeof mobileOut === "string") { external: FRONTEND_EXTERNALS,
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir); // 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); if (frontendEntry) await buildTailwind(cwd, outDir);
@@ -28,10 +28,6 @@ export async function validate({ cwd }) {
// A theme only plugin has no frontend code. // A theme only plugin has no frontend code.
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]), ...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
raw.locales ?? "locales", 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) { for (const rel of referenced) {
if (!fs.existsSync(path.join(cwd, rel))) { if (!fs.existsSync(path.join(cwd, rel))) {
+1 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "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.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -81,10 +81,6 @@
"types": "./dist/host-commands.d.ts", "types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js" "default": "./dist/host-commands.js"
}, },
"./host-scripts": {
"types": "./dist/host-scripts/index.d.ts",
"default": "./dist/host-scripts/index.js"
},
"./ssh-certs": { "./ssh-certs": {
"types": "./dist/ssh-certs.d.ts", "types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js" "default": "./dist/ssh-certs.js"
@@ -102,10 +98,6 @@
"./testing-host": { "./testing-host": {
"default": "./dist/host/testing-host.js" "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" "./theme.css": "./dist/host/theme.css"
}, },
"files": [ "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."
}
}
} }
} }
}, },
+8 -3
View File
@@ -11,9 +11,6 @@
export type { Client, ClientChannel } from "ssh2"; export type { Client, ClientChannel } from "ssh2";
import 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 { export interface ExecResult {
stdout: string; stdout: string;
@@ -117,6 +114,14 @@ export interface PlatformInfo {
osPrettyName: string | null; 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 = const DOCKER_PROBE =
`command -v docker >/dev/null 2>&1` + `command -v docker >/dev/null 2>&1` +
` || [ -S /var/run/docker.sock ]` + ` || [ -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);
}
+1 -157
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core * "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server. * 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. */ /** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String( export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -526,36 +526,6 @@ export interface PluginContributions {
layouts?: PluginLayoutContribution[]; layouts?: PluginLayoutContribution[];
/** Themes. Needs the ui:theme capability. */ /** Themes. Needs the ui:theme capability. */
themes?: PluginThemeContribution[]; 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. */ /** A field a protocol login carries besides username and password. */
@@ -795,7 +765,6 @@ const ALLOWED_CONTRIBUTES = new Set([
"protocols", "protocols",
"layouts", "layouts",
"themes", "themes",
"mobile",
]); ]);
const ALLOWED_SETTINGS_FIELD = [ const ALLOWED_SETTINGS_FIELD = [
@@ -977,135 +946,10 @@ export function validateManifest(manifest: unknown): string[] {
typeof m.id === "string" ? m.id : undefined, typeof m.id === "string" ? m.id : undefined,
errors, errors,
); );
if (isPlainObject(m.contributes)) {
validateMobile(
m.contributes,
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
errors,
);
}
return 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 { function validateFeatures(features: unknown, errors: string[]): void {
if (features === undefined) return; if (features === undefined) return;
if (!Array.isArray(features)) { if (!Array.isArray(features)) {
+2 -7
View File
@@ -3,8 +3,6 @@
* *
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves * getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes. * 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 * Each .tmxplug is downloaded (or read from a path), checked against its
* pinned sha256 and unpacked. * pinned sha256 and unpacked.
* *
@@ -27,16 +25,13 @@ const {
localSdkBuildCommand, localSdkBuildCommand,
copyLocalBuild, copyLocalBuild,
buildBundledIndex, buildBundledIndex,
resolveProfile,
pluginsForProfile,
} = require("./lib/bundled-plugins.cjs"); } = require("./lib/bundled-plugins.cjs");
const root = path.resolve(__dirname, ".."); const root = path.resolve(__dirname, "..");
const destination = path.join(root, "dist", "plugins"); const destination = path.join(root, "dist", "plugins");
async function main() { async function main() {
const profile = resolveProfile(); const plugins = loadBundledPlugins(root);
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
const flag = process.argv.indexOf("--local"); const flag = process.argv.indexOf("--local");
const localArg = const localArg =
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS; flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
@@ -79,7 +74,7 @@ async function main() {
); );
console.log( console.log(
`Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`, `Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
); );
} }
+6 -14
View File
@@ -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/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
* dist/host/testing-host.js what renderWithApp() renders plugins into * 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/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 * 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 * 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: { entryPoints: {
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"), ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"), "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, outdir: outDir,
bundle: true, bundle: true,
@@ -75,9 +72,6 @@ await esbuild.build({
// so the translated locale files it would list are simply none. // so the translated locale files it would list are simply none.
banner: { js: "const __termixNoGlob = () => ({});" }, banner: { js: "const __termixNoGlob = () => ({});" },
define: { 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.glob": "__termixNoGlob",
"import.meta.env.DEV": "false", "import.meta.env.DEV": "false",
"import.meta.env.PROD": "true", "import.meta.env.PROD": "true",
@@ -163,16 +157,14 @@ const rootSource =
const scanner = new Scanner({ const scanner = new Scanner({
sources: [...rootSource, ...compiler.sources], sources: [...rootSource, ...compiler.sources],
}); });
const coreCss = optimize(compiler.build(scanner.scan()), { const coreClasses = [
minify: true, ...classNamesInCss(
}).code; optimize(compiler.build(scanner.scan()), { minify: true }).code,
const coreClasses = [...classNamesInCss(coreCss)].sort(); ),
].sort();
fs.writeFileSync( fs.writeFileSync(
path.join(outDir, "core-classes.json"), path.join(outDir, "core-classes.json"),
`${JSON.stringify(coreClasses)}\n`, `${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)}`); console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
-102
View File
@@ -16,8 +16,6 @@ import {
localSdkBuildCommand, localSdkBuildCommand,
rebuildLocalPlugins, rebuildLocalPlugins,
copyLocalBuild, copyLocalBuild,
resolveProfile,
pluginsForProfile,
} from "./lib/bundled-plugins.cjs"; } from "./lib/bundled-plugins.cjs";
const cleanups: Array<() => void> = []; const cleanups: Array<() => void> = [];
@@ -265,52 +263,10 @@ describe("pinsFromIndex", () => {
source: "tmxplug", source: "tmxplug",
url: "https://example.test/beta-1.1.0.tmxplug", url: "https://example.test/beta-1.1.0.tmxplug",
sha256: "b".repeat(64), sha256: "b".repeat(64),
tier: "full",
}); });
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]); 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", () => { it("keeps the onboarding defaults of an existing pin", () => {
const current = [ const current = [
{ id: "beta", onboarding: { recommended: true, consent: false } }, { 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); 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,
);
});
});
-2
View File
@@ -16,8 +16,6 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
const INTERNAL_EXPORTS = new Set([ const INTERNAL_EXPORTS = new Set([
"./testing/setup", "./testing/setup",
"./testing-host", "./testing-host",
"./frame-host",
"./frame-host.css",
"./tsconfig.plugin.json", "./tsconfig.plugin.json",
"./schema/manifest.schema.json", "./schema/manifest.schema.json",
"./theme.css", "./theme.css",
+2 -49
View File
@@ -16,11 +16,6 @@
* environment variables is set, for a server configured before first boot. * environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked. * 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 * "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 * 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 * 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 ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]); const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
const SHA256 = /^[0-9a-f]{64}$/; const SHA256 = /^[0-9a-f]{64}$/;
const PROFILES = ["minimal", "standard", "full"];
const DEFAULT_PROFILE = "standard";
function parseOnboarding(value, id, problems) { function parseOnboarding(value, id, problems) {
if (value === undefined) return null; if (value === undefined) return null;
@@ -129,9 +122,6 @@ function parseBundledPlugins(raw) {
) { ) {
problems.push(`${entry.id} defaultLook must be a boolean`); 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; if (problems.length > before) continue;
plugins.push({ plugins.push({
id: entry.id, id: entry.id,
@@ -141,7 +131,6 @@ function parseBundledPlugins(raw) {
sha256: entry.sha256, sha256: entry.sha256,
onboarding, onboarding,
defaultLook: entry.defaultLook === true, defaultLook: entry.defaultLook === true,
tier: entry.tier ?? "full",
}); });
} else { } else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`); problems.push(`${entry.id} has an unknown source "${entry.source}"`);
@@ -252,35 +241,12 @@ function buildBundledIndex(plugins, stagedIds) {
return { version: 1, plugins: out }; 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 * 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 * 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; * pin list, so fields the registry does not know (onboarding) 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.
*/ */
function pinsFromIndex(index, ids, current = [], coreVersion = null) { function pinsFromIndex(index, ids, current = []) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : []; const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry])); 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 ?? [])] const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
.filter((entry) => semver.valid(entry.version)) .filter((entry) => semver.valid(entry.version))
.filter((entry) => !semver.prerelease(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]; .sort((a, b) => semver.rcompare(a.version, b.version))[0];
if (!latest) throw new Error(`${id} is not in the registry index`); if (!latest) throw new Error(`${id} is not in the registry index`);
const onboarding = currentById.get(id)?.onboarding; const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true; const defaultLook = currentById.get(id)?.defaultLook === true;
const tier = currentById.get(id)?.tier ?? "full";
return { return {
id, id,
source: "tmxplug", source: "tmxplug",
url: latest.url, url: latest.url,
sha256: latest.sha256, sha256: latest.sha256,
tier,
...(onboarding ? { onboarding } : {}), ...(onboarding ? { onboarding } : {}),
...(defaultLook ? { defaultLook } : {}), ...(defaultLook ? { defaultLook } : {}),
}; };
@@ -453,9 +409,6 @@ function copyLocalBuild(repo, destination) {
} }
module.exports = { module.exports = {
PROFILES,
resolveProfile,
pluginsForProfile,
findLocalPluginBuilds, findLocalPluginBuilds,
isLocalBuildStale, isLocalBuildStale,
localSdkBuildCommand, localSdkBuildCommand,
+2 -7
View File
@@ -4,10 +4,7 @@
* re-released. * re-released.
* *
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed * 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) * node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
* --core-version X skip releases whose engine.termix excludes X
*
* The release workflow runs it with --all and the version being released.
*/ */
const fs = require("node:fs"); const fs = require("node:fs");
@@ -25,8 +22,6 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
async function main() { async function main() {
const all = process.argv.includes("--all"); 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); const response = await fetch(INDEX_URL);
if (!response.ok) { if (!response.ok) {
throw new Error(`registry index download failed with ${response.status}`); throw new Error(`registry index download failed with ${response.status}`);
@@ -35,7 +30,7 @@ async function main() {
const current = loadBundledPlugins(root); const current = loadBundledPlugins(root);
const ids = all ? null : current.map((plugin) => plugin.id); 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`); fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
console.log( console.log(
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`, `Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
-29
View File
@@ -144,32 +144,3 @@ describe("termix-plugin validate layouts and capabilities", () => {
expect(output).not.toContain("is declared but"); 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");
});
});
-2
View File
@@ -1316,8 +1316,6 @@ const migrateSchema = () => {
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" }, { 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: "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: "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) { for (const migration of sshDataMigrations) {
-3
View File
@@ -193,9 +193,6 @@ export const hosts = mysqlTable(
// legacy algorithms, agent, environment). JSON. // legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"), sshOptions: text("ssh_options"),
notes: text("notes"), 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), enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: int("ssh_port").default(22), sshPort: int("ssh_port").default(22),
-3
View File
@@ -193,9 +193,6 @@ export const hosts = pgTable(
// legacy algorithms, agent, environment). JSON. // legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"), sshOptions: text("ssh_options"),
notes: text("notes"), 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), enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: integer("ssh_port").default(22), sshPort: integer("ssh_port").default(22),
-3
View File
@@ -183,9 +183,6 @@ export const hosts = sqliteTable(
// legacy algorithms, agent, environment). JSON. // legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"), sshOptions: text("ssh_options"),
notes: text("notes"), 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), enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
sshPort: integer("ssh_port").default(22), 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; const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
export function normalizeProtocolEnableFields( export function normalizeProtocolEnableFields(
-3
View File
@@ -33,7 +33,6 @@ import {
isOptionalAuthType, isOptionalAuthType,
isOptionalBoolean, isOptionalBoolean,
isValidPort, isValidPort,
normalizeHostLookFields,
normalizeProtocolEnableFields, normalizeProtocolEnableFields,
OWNER_PRIVATE_AUTH_FIELDS, OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS, OWNER_PRIVATE_SSH_OPTION_FIELDS,
@@ -336,7 +335,6 @@ router.post(
? JSON.stringify(portKnockSequence) ? JSON.stringify(portKnockSequence)
: null, : null,
...normalizeProtocolEnableFields(hostData), ...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22, sshPort: sshPort || port || 22,
}; };
@@ -914,7 +912,6 @@ router.put(
? JSON.stringify(portKnockSequence) ? JSON.stringify(portKnockSequence)
: null, : null,
...normalizeProtocolEnableFields(hostData), ...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22, sshPort: sshPort || port || 22,
}; };
+1 -5
View File
@@ -18,7 +18,6 @@ import {
safeLayoutForced, safeLayoutForced,
unavailableLooks, unavailableLooks,
} from "../../plugins/looks.js"; } from "../../plugins/looks.js";
import { safeModeOn } from "../../plugins/safe-mode.js";
const router = express.Router(); const router = express.Router();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
@@ -37,7 +36,7 @@ async function adminDefaults() {
* /user-preferences/look: * /user-preferences/look:
* get: * get:
* summary: Get the layout and theme to show the current user * 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: * tags:
* - User Preferences * - User Preferences
* responses: * responses:
@@ -76,8 +75,6 @@ async function adminDefaults() {
* nullable: true * nullable: true
* safeLayout: * safeLayout:
* type: boolean * type: boolean
* safeMode:
* type: boolean
* unavailable: * unavailable:
* type: array * type: array
* items: * items:
@@ -115,7 +112,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
defaults: { ...defaults, layout: known(defaults.layout) }, defaults: { ...defaults, layout: known(defaults.layout) },
defaultLook: resolveDefaultLook(loader), defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(), safeLayout: safeLayoutForced(),
safeMode: safeModeOn(),
unavailable: unavailableLooks(loader), unavailable: unavailableLooks(loader),
}); });
} catch (error) { } catch (error) {
+3 -32
View File
@@ -16,7 +16,6 @@ import { getPluginRuntime } from "../../plugins/index.js";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { describePluginFrontend } from "../../plugins/assets.js"; import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js"; import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { mayRunInSafeMode } from "../../plugins/safe-mode.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.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 * `assetVersion` is its cache key, `css` says a stylesheet sits beside
* it, and `locales` lists the languages it ships ("en" and xx_YY). * it, and `locales` lists the languages it ships ("en" and xx_YY).
* `dependencies` and `optionalDependencies` let the loader activate * `dependencies` and `optionalDependencies` let the loader activate
* frontends in the same order the server does. `engine` holds the * frontends in the same order the server does.
* manifest's `api` and `termix` ranges, so other clients (the mobile
* app) can tell whether they understand the plugin.
* *
* On a desktop linked to a server, `runsOnServer` marks plugins that * On a desktop linked to a server, `runsOnServer` marks plugins that
* only run on that server and stay off here on purpose, and, for * only run on that server and stay off here on purpose, and, for
* admins, `managedByServer` marks plugins whose on/off state the server * admins, `managedByServer` marks plugins whose on/off state the server
* decides. * decides.
*
* While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps
* off reports `state: "safe-mode"`.
* tags: * tags:
* - Plugins * - Plugins
* responses: * responses:
@@ -305,7 +299,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let features: string[] = []; let features: string[] = [];
let docs: string | undefined; let docs: string | undefined;
let env: PluginEnvVar[] = []; let env: PluginEnvVar[] = [];
const engine: { api?: string; termix?: string } = {};
let desktopMode: unknown = loaded let desktopMode: unknown = loaded
? resolvePluginDesktopMode(loaded.manifest) ? resolvePluginDesktopMode(loaded.manifest)
: undefined; : undefined;
@@ -325,14 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
optionalDependencies?: Record<string, string>; optionalDependencies?: Record<string, string>;
desktop?: unknown; desktop?: unknown;
runtime?: 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 desktopMode = loaded
? resolvePluginDesktopMode(loaded.manifest) ? resolvePluginDesktopMode(loaded.manifest)
: (manifest?.desktop ?? : (manifest?.desktop ??
@@ -368,14 +354,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
name: record.name, name: record.name,
version: record.version, version: record.version,
enabled: record.state === "enabled", enabled: record.state === "enabled",
state: state: loaded?.state ?? record.state,
record.state === "enabled" &&
loaded?.state !== "active" &&
!mayRunInSafeMode(record.id, loader.list())
? "safe-mode"
: (loaded?.state ?? record.state),
runsOnServer: linked && desktopMode === "server", runsOnServer: linked && desktopMode === "server",
engine,
contributes, contributes,
icon, icon,
dependencies, dependencies,
@@ -956,7 +936,7 @@ router.delete(
* 404: * 404:
* description: No such plugin. * description: No such plugin.
* 409: * 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( router.patch(
"/:id/state", "/:id/state",
@@ -1070,8 +1050,6 @@ router.patch(
* description: The caller lacks admin.plugins.manage. * description: The caller lacks admin.plugins.manage.
* 404: * 404:
* description: No such plugin, or it is not on disk. * 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: * 500:
* description: The plugin failed to start again. * description: The plugin failed to start again.
*/ */
@@ -1095,13 +1073,6 @@ router.post(
res.status(404).json({ error: "Plugin is not present on disk" }); res.status(404).json({ error: "Plugin is not present on disk" });
return; 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, { await repository.update(pluginId, {
state: "enabled", state: "enabled",
-2
View File
@@ -73,8 +73,6 @@ async function exportRow(
: resolvedHost.tags || [], : resolvedHost.tags || [],
pin: !!resolvedHost.pin, pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null, notes: resolvedHost.notes || null,
color: resolvedHost.color || null,
icon: resolvedHost.icon || null,
// Every plugin's host settings, secrets redacted, for import to hand back. // Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: pluginSettings.get(host.id as number) ?? {}, pluginSettings: pluginSettings.get(host.id as number) ?? {},
// Each plugin protocol's login, secrets included like the SSH ones. // Each plugin protocol's login, secrets included like the SSH ones.
-2
View File
@@ -28,7 +28,6 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
import { import {
isNonEmptyString, isNonEmptyString,
isValidPort, isValidPort,
normalizeHostLookFields,
normalizeImportedHost, normalizeImportedHost,
} from "../../database/routes/host-normalizers.js"; } from "../../database/routes/host-normalizers.js";
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js"; import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
@@ -572,7 +571,6 @@ export async function runBulkImport(
? "true" ? "true"
: "false", : "false",
notes: hostData.notes || null, notes: hostData.notes || null,
...normalizeHostLookFields(hostData as Record<string, unknown>),
useSocks5: hostData.useSocks5 ? 1 : 0, useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null, socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null, socks5Port: hostData.socks5Port || null,
-21
View File
@@ -14,24 +14,3 @@ export function startablePluginIds(
.map((record) => record.id), .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();
}
-20
View File
@@ -23,11 +23,6 @@ import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js"; import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js"; import { recordConflict } from "./conflicts.js";
import { startablePluginIds } from "./boot-state.js"; import { startablePluginIds } from "./boot-state.js";
import {
mayRunInSafeMode,
safeModeOn,
safeModePluginIds,
} from "./safe-mode.js";
import { setPluginImpersonationCheck } from "../utils/auth-manager.js"; import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js"; import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
@@ -242,10 +237,6 @@ export async function initializePlugins(
if (loaded.length === 0) return []; if (loaded.length === 0) return [];
const fresh = await seedPlugins(loaded, options.freshInstall === true); const fresh = await seedPlugins(loaded, options.freshInstall === true);
if (!fresh) {
const { restoreMissingPlugins } = await import("./manage.js");
loaded.push(...(await restoreMissingPlugins()));
}
await syncCapabilityGrants(loaded); await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) { if (loaded.some((plugin) => plugin.source === "bundled")) {
const { offerPluginSetup } = await import("./onboarding.js"); const { offerPluginSetup } = await import("./onboarding.js");
@@ -257,17 +248,8 @@ export async function initializePlugins(
const records = await createCurrentPluginRepository().listAll(); const records = await createCurrentPluginRepository().listAll();
const enabled = startablePluginIds(records); 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 const candidates = loaded
.filter((plugin) => enabled.has(plugin.id)) .filter((plugin) => enabled.has(plugin.id))
.filter((plugin) => !safe || safe.has(plugin.id))
.map((plugin) => plugin.id); .map((plugin) => plugin.id);
for (const pluginId of candidates) { 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 // Before activate: a plugin's own activate() may already want to check one
// of its permissions. // of its permissions.
const plugin = pluginLoader.get(pluginId); 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); if (plugin) await registerPluginPermissions(plugin);
await pluginLoader.activate(pluginId); await pluginLoader.activate(pluginId);
+1 -71
View File
@@ -18,7 +18,6 @@ import path from "node:path";
import semver from "semver"; import semver from "semver";
import { pluginLogger } from "../utils/logger.js"; import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js"; import { isDefaultLookPlugin } from "./bundled-index.js";
import { mayRunInSafeMode } from "./safe-mode.js";
import { import {
activatePlugin, activatePlugin,
deactivatePlugin, deactivatePlugin,
@@ -26,7 +25,6 @@ import {
registerLoadedPlugins, registerLoadedPlugins,
unloadPlugin, unloadPlugin,
} from "./index.js"; } from "./index.js";
import { missingShippedPluginIds } from "./boot-state.js";
import type { LoadedPlugin, PluginLoader } from "./loader.js"; import type { LoadedPlugin, PluginLoader } from "./loader.js";
import { import {
getBundledPluginsDir, getBundledPluginsDir,
@@ -765,13 +763,6 @@ export async function setPluginStateUnlocked(
if (!loader.get(pluginId)) { if (!loader.get(pluginId)) {
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND"); 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); const plan = await planStateChange(pluginId, enabled);
if (enabled) { if (enabled) {
@@ -968,11 +959,7 @@ export function installPlugin(
return serialized(async () => { return serialized(async () => {
const { createCurrentPluginRepository } = await repos(); const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository(); const repository = createCurrentPluginRepository();
// A record with no code is a plugin an older image shipped: install fills it in. if (await repository.findById(pluginId)) {
if (
(await repository.findById(pluginId)) &&
getPluginRuntime().loader.get(pluginId)
) {
throw new PluginManageError( throw new PluginManageError(
"Plugin is already installed", "Plugin is already installed",
409, 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( export function updatePlugin(
pluginId: string, pluginId: string,
options: InstallOptions, options: InstallOptions,
+3 -65
View File
@@ -12,7 +12,6 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { isIP } from "node:net";
import path from "node:path"; import path from "node:path";
import semver from "semver"; import semver from "semver";
import { import {
@@ -24,11 +23,7 @@ import {
youtubeVideoId, youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest"; } from "@termix-ssh/plugin-sdk/manifest";
import { getLocalVersion } from "../utils/app-version.js"; import { getLocalVersion } from "../utils/app-version.js";
import { import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
isBlockedAddress,
safeOutboundFetch,
} from "../utils/safe-outbound-fetch.js";
import { fetchWithProxy, getProxyAgent } from "../utils/proxy-agent.js";
import { pluginLogger } from "../utils/logger.js"; import { pluginLogger } from "../utils/logger.js";
import { import {
COMMUNITY_PLUGIN_KEYS, 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. */ /** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> { async function fetchFollowing(url: string): Promise<Response> {
const privateHosts = getRegistryPrivateHosts();
let current = url; let current = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const options: RequestInit = { const response = await safeOutboundFetch(current, {
method: "GET", method: "GET",
redirect: "manual", redirect: "manual",
headers: { "User-Agent": "Termix" }, 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) { if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location"); const location = response.headers.get("location");
if (!location) throw new Error(`redirect without a location: ${current}`); if (!location) throw new Error(`redirect without a location: ${current}`);
-42
View File
@@ -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);
}
-15
View File
@@ -7,8 +7,6 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory
* On a linked desktop, hosts and credentials someone shared with the account * 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 * 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. * 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( export function rejectSharedCopyWrites(
kind: "host" | "credential", kind: "host" | "credential",
@@ -27,19 +25,6 @@ export function rejectSharedCopyWrites(
.where(eq(table.id, Number(match[1]))) .where(eq(table.id, Number(match[1])))
.limit(1); .limit(1);
if (row?.sharedSource) { 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({ res.status(403).json({
error: "This was shared with you and is managed on the server", error: "This was shared with you and is managed on the server",
code: "SHARED_COPY", 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, keepsStoredSshAuth,
isOptionalBoolean, isOptionalBoolean,
isValidPort, isValidPort,
normalizeHostLookFields,
normalizeImportedHost, normalizeImportedHost,
normalizeProtocolEnableFields, normalizeProtocolEnableFields,
renameFolderPath, renameFolderPath,
@@ -530,25 +529,3 @@ describe("impliedSshAuthType", () => {
expect(impliedSshAuthType({})).toBeUndefined(); 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.defaultLookIds = [];
state.installed = []; state.installed = [];
delete process.env.TERMIX_SAFE_LAYOUT; delete process.env.TERMIX_SAFE_LAYOUT;
delete process.env.TERMIX_SAFE_MODE;
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@@ -114,7 +113,6 @@ beforeEach(async () => {
afterEach(async () => { afterEach(async () => {
delete process.env.TERMIX_SAFE_LAYOUT; delete process.env.TERMIX_SAFE_LAYOUT;
delete process.env.TERMIX_SAFE_MODE;
await new Promise<void>((resolve) => server.close(() => resolve())); await new Promise<void>((resolve) => server.close(() => resolve()));
}); });
@@ -140,7 +138,6 @@ describe("GET /user-preferences/look", () => {
defaults: { layout: null, theme: null }, defaults: { layout: null, theme: null },
defaultLook: { layout: null, theme: null }, defaultLook: { layout: null, theme: null },
safeLayout: false, safeLayout: false,
safeMode: false,
unavailable: [], unavailable: [],
}); });
}); });
@@ -162,7 +159,6 @@ describe("GET /user-preferences/look", () => {
{ id: "other", version: null, layouts: [], themes: [], error: "y" }, { id: "other", version: null, layouts: [], themes: [], error: "y" },
]; ];
process.env.TERMIX_SAFE_LAYOUT = "1"; process.env.TERMIX_SAFE_LAYOUT = "1";
process.env.TERMIX_SAFE_MODE = "1";
const { body } = await getLook(); const { body } = await getLook();
expect(body).toMatchObject({ expect(body).toMatchObject({
@@ -171,7 +167,6 @@ describe("GET /user-preferences/look", () => {
defaults: { layout: "admin-layout", theme: "gruvbox" }, defaults: { layout: "admin-layout", theme: "gruvbox" },
defaultLook: { layout: "shipped", theme: "ink" }, defaultLook: { layout: "shipped", theme: "ink" },
safeLayout: true, safeLayout: true,
safeMode: true,
}); });
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual( expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
["broken"], ["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 () => { it("grants a declared capability", async () => {
state.plugins.set("sample-plugin", makePlugin()); state.plugins.set("sample-plugin", makePlugin());
@@ -672,7 +623,6 @@ describe("plugins route", () => {
enabled: true, enabled: true,
state: "enabled", state: "enabled",
runsOnServer: false, runsOnServer: false,
engine: {},
contributes: null, contributes: null,
dependencies: {}, dependencies: {},
optionalDependencies: {}, optionalDependencies: {},
+1 -26
View File
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { startablePluginIds } from "../../plugins/boot-state.js";
missingShippedPluginIds,
startablePluginIds,
} from "../../plugins/boot-state.js";
describe("startablePluginIds", () => { describe("startablePluginIds", () => {
it("starts enabled plugins and retries ones that failed or were blocked", () => { 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"]); 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");
});
});
-20
View File
@@ -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", () => { describe("reloading a bundled plugin for the dev runner", () => {
it("loads the rebuilt copy and restarts what depends on it", async () => { it("loads the rebuilt copy and restarts what depends on it", async () => {
createFixturePlugin({ id: "base", root: bundled }); createFixturePlugin({ id: "base", root: bundled });
-104
View File
@@ -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", () => { describe("named service providers", () => {
const provides = (names: unknown) => [ const provides = (names: unknown) => [
{ {
@@ -8,23 +8,10 @@ import * as tar from "tar";
const fetchMock = vi.hoisted(() => vi.fn()); const fetchMock = vi.hoisted(() => vi.fn());
const testKey = vi.hoisted(() => ({ value: null as null | object })); const testKey = vi.hoisted(() => ({ value: null as null | object }));
vi.mock("../../utils/safe-outbound-fetch.js", async (importOriginal) => ({ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
...(await importOriginal<
typeof import("../../utils/safe-outbound-fetch.js")
>()),
safeOutboundFetch: fetchMock, 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", () => ({ vi.mock("../../utils/app-version.js", () => ({
getLocalVersion: () => "26.10.3", getLocalVersion: () => "26.10.3",
})); }));
@@ -56,11 +43,9 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
import { import {
communitySource, communitySource,
downloadRelease, downloadRelease,
fetchIndexAt,
fetchRegistryIndex, fetchRegistryIndex,
fetchRegistryStats, fetchRegistryStats,
findRelease, findRelease,
getRegistryPrivateHosts,
latestOnChannel, latestOnChannel,
getStatsUrl, getStatsUrl,
canRunRelease, 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. */ /** A real .tmxplug holding only a manifest, enough for the checks. */
function pack(manifest: Record<string, unknown>): Buffer { function pack(manifest: Record<string, unknown>): Buffer {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-")); 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
View File
@@ -12,7 +12,6 @@ import { ThemeProvider } from "@/components/theme-provider";
import "./ui/i18n/i18n"; import "./ui/i18n/i18n";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame"; import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
import { captureAppReturn } from "@/lib/app-return";
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume"; import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status"; import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
import { Toaster } from "@/components/sonner"; import { Toaster } from "@/components/sonner";
@@ -169,11 +168,8 @@ function App() {
// Keeps the window controls the system draws in the colors of the theme. // Keeps the window controls the system draws in the colors of the theme.
useTitleBarOverlayColors(); useTitleBarOverlayColors();
const stored = getStoredAuth(); 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>( const [phase, setPhase] = useState<Phase>(
stored?.loggedIn && !appReturn ? "verifying" : "idle-auth", stored?.loggedIn ? "verifying" : "idle-auth",
); );
const [authUsername, setAuthUsername] = useState(stored?.username ?? ""); const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
const [verifyRetryCount, setVerifyRetryCount] = useState(0); const [verifyRetryCount, setVerifyRetryCount] = useState(0);
-8
View File
@@ -72,10 +72,6 @@ export type Host = {
terminalConfig?: HostTerminalConfig; terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions; sshOptions?: HostSshOptions;
notes?: string; notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean; useSocks5?: boolean;
socks5Host?: string; socks5Host?: string;
@@ -186,10 +182,6 @@ export interface HostData {
terminalConfig?: HostTerminalConfig; terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions; sshOptions?: HostSshOptions;
notes?: string; notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean; useSocks5?: boolean;
socks5Host?: string; socks5Host?: string;
-2
View File
@@ -19,8 +19,6 @@ export interface LookResponse {
defaultLook: { layout: string | null; theme: string | null }; defaultLook: { layout: string | null; theme: string | null };
/** TERMIX_SAFE_LAYOUT is set on the server. */ /** TERMIX_SAFE_LAYOUT is set on the server. */
safeLayout: boolean; safeLayout: boolean;
/** TERMIX_SAFE_MODE is set: only layout and theme plugins run. */
safeMode?: boolean;
unavailable: UnavailableLook[]; unavailable: UnavailableLook[];
} }
+1 -1
View File
@@ -237,7 +237,7 @@ export interface PluginSummary {
name: string; name: string;
version: string; version: string;
enabled: boolean; 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; state: string;
/** A linked desktop leaves this plugin to its server. */ /** A linked desktop leaves this plugin to its server. */
runsOnServer?: boolean; runsOnServer?: boolean;
+19 -33
View File
@@ -1,11 +1,9 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { import {
isEmbeddedAuthFrame,
isInElectronWebView, isInElectronWebView,
isInMobileWebView, isInMobileWebView,
type EmbeddedFrameWindow, type EmbeddedFrameWindow,
} from "@/lib/embedded-frame"; } from "@/lib/embedded-frame";
import { handOffToApp, pendingAppReturn } from "@/lib/app-return";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; 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 AuthView = "login" | "register" | "reset" | "second-factor" | "external";
type ResetStep = "email" | "code" | "newpass"; type ResetStep = "email" | "code" | "newpass";
@@ -340,9 +331,8 @@ export function Auth({ onLogin }: AuthProps) {
if (proxySigninHandledRef.current || isElectron()) return; if (proxySigninHandledRef.current || isElectron()) return;
proxySigninHandledRef.current = true; proxySigninHandledRef.current = true;
requestTrustedProxyLogin() requestTrustedProxyLogin()
.then(async (result) => { .then((result) => {
if (!result.enabled || !result.success) return; if (!result.enabled || !result.success) return;
if (await handOffLogin()) return;
storeAuth(result.username || ""); storeAuth(result.username || "");
onLogin( onLogin(
result.username || "", result.username || "",
@@ -635,27 +625,24 @@ export function Auth({ onLogin }: AuthProps) {
} }
return; return;
} }
void handOffLogin(urlParams.get("token")).then((handedOff) => { getUserInfo()
if (handedOff) return; .then((meRes) => {
getUserInfo() storeAuth(meRes.username || "");
.then((meRes) => { clearDesktopManualLogout();
storeAuth(meRes.username || ""); onLogin(
clearDesktopManualLogout(); meRes.username || "",
onLogin( meRes.userId || undefined,
meRes.username || "", !!meRes.is_admin,
meRes.userId || undefined, );
!!meRes.is_admin, window.history.replaceState(
); {},
window.history.replaceState( document.title,
{}, window.location.pathname,
document.title, );
window.location.pathname, })
); .catch(() => {
}) toast.error(t("errors.failedUserInfo"));
.catch(() => { });
toast.error(t("errors.failedUserInfo"));
});
});
} }
}, [onLogin, t]); }, [onLogin, t]);
@@ -759,7 +746,6 @@ export function Auth({ onLogin }: AuthProps) {
setWebviewAuthSuccess(true); setWebviewAuthSuccess(true);
return; return;
} }
if (await handOffLogin(res?.token)) return;
const meRes = await getUserInfo(); const meRes = await getUserInfo();
const name = const name =
meRes.username || res.username || options.fallbackUsername || ""; meRes.username || res.username || options.fallbackUsername || "";
-61
View File
@@ -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;
}
+1 -6
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} is ready to install", "ready": "Termix {{version}} is ready to install",
"readyHint": "It installs the next time Termix restarts." "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": { "companion": {
"close": "Close panel", "close": "Close panel",
"moveSide": "Move panel", "moveSide": "Move panel",
@@ -1315,8 +1311,7 @@
"disabled": "Disabled", "disabled": "Disabled",
"failed": "Stopped unexpectedly", "failed": "Stopped unexpectedly",
"blocked": "Waiting on another plugin", "blocked": "Waiting on another plugin",
"server": "Runs on your server", "server": "Runs on your server"
"safeMode": "Off in safe mode"
}, },
"groups": { "groups": {
"running": "Running", "running": "Running",
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} is gereed om te installeer", "ready": "Termix {{version}} is gereed om te installeer",
"readyHint": "Dit word die volgende keer wat Termix herbegin geïnstalleer." "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": { "companion": {
"close": "Maak paneel toe", "close": "Maak paneel toe",
"moveSide": "Skuif paneel", "moveSide": "Skuif paneel",
@@ -1102,8 +1098,6 @@
"resetEveryone": "Stel die verstek vir almal terug", "resetEveryone": "Stel die verstek vir almal terug",
"update": "Dateer {{name}} op na {{version}}", "update": "Dateer {{name}} op na {{version}}",
"disable": "Skakel {{name}} af", "disable": "Skakel {{name}} af",
"enable": "Skakel {{name}} aan",
"startAgain": "Begin {{name}} weer",
"install": "Vind lêe en temas", "install": "Vind lêe en temas",
"installTitle": "Lêe en temas", "installTitle": "Lêe en temas",
"installButton": "Installeer", "installButton": "Installeer",
@@ -1315,8 +1309,7 @@
"disabled": "Afgeskakel", "disabled": "Afgeskakel",
"failed": "Onverwags gestop", "failed": "Onverwags gestop",
"blocked": "Wag op 'n ander plugin", "blocked": "Wag op 'n ander plugin",
"server": "Loop op jou bediener", "server": "Loop op jou bediener"
"safeMode": "Af in veilige modus"
}, },
"groups": { "groups": {
"running": "Aktief", "running": "Aktief",
@@ -1330,6 +1323,11 @@
"updated": "Onlangs opgedateer", "updated": "Onlangs opgedateer",
"category": "Kategorie" "category": "Kategorie"
}, },
"columns": {
"title": "Kolomme",
"auto": "Outo",
"count": "{{count}} breed"
},
"table": { "table": {
"name": "Naam", "name": "Naam",
"description": "Beskrywing", "description": "Beskrywing",
@@ -1654,12 +1652,7 @@
"gridView": "Rooster", "gridView": "Rooster",
"listView": "Lys", "listView": "Lys",
"compactRows": "Kompakte rye", "compactRows": "Kompakte rye",
"comfortableRows": "Gerieflike rye", "comfortableRows": "Gerieflike rye"
"columns": {
"title": "Kolomme",
"auto": "Outo",
"count": "{{count}} breed"
}
}, },
"fields": { "fields": {
"secretSaved": "Opgeslaan. Tik om dit te vervang." "secretSaved": "Opgeslaan. Tik om dit te vervang."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} جاهز للتثبيت", "ready": "Termix {{version}} جاهز للتثبيت",
"readyHint": "سيتم تثبيته في المرة القادمة التي يعاد فيها تشغيل Termix." "readyHint": "سيتم تثبيته في المرة القادمة التي يعاد فيها تشغيل Termix."
}, },
"safeMode": {
"title": "الوضع الآمن",
"description": "لا تعمل سوى إضافات التخطيط والمظهر. أعد تشغيل الخادم بدون TERMIX_SAFE_MODE لإعادة تشغيل الباقي."
},
"companion": { "companion": {
"close": "إغلاق اللوحة", "close": "إغلاق اللوحة",
"moveSide": "نقل اللوحة", "moveSide": "نقل اللوحة",
@@ -1170,8 +1166,6 @@
"resetEveryone": "إعادة تعيين الافتراضي للجميع", "resetEveryone": "إعادة تعيين الافتراضي للجميع",
"update": "تحديث {{name}} إلى {{version}}", "update": "تحديث {{name}} إلى {{version}}",
"disable": "إيقاف {{name}}", "disable": "إيقاف {{name}}",
"enable": "تفعيل {{name}}",
"startAgain": "إعادة تشغيل {{name}}",
"install": "البحث عن التخطيطات والسمات", "install": "البحث عن التخطيطات والسمات",
"installTitle": "التخطيطات والسمات", "installTitle": "التخطيطات والسمات",
"installButton": "تثبيت", "installButton": "تثبيت",
@@ -1383,8 +1377,7 @@
"disabled": "معطلة", "disabled": "معطلة",
"failed": "توقفت بشكل غير متوقع", "failed": "توقفت بشكل غير متوقع",
"blocked": "في انتظار إضافة أخرى", "blocked": "في انتظار إضافة أخرى",
"server": "يعمل على خادمك", "server": "يعمل على خادمك"
"safeMode": "معطل في الوضع الآمن"
}, },
"groups": { "groups": {
"running": "تعمل", "running": "تعمل",
@@ -1398,6 +1391,11 @@
"updated": "تم تحديثها مؤخراً", "updated": "تم تحديثها مؤخراً",
"category": "الفئة" "category": "الفئة"
}, },
"columns": {
"title": "الأعمدة",
"auto": "تلقائي",
"count": "عرض {{count}}"
},
"table": { "table": {
"name": "الاسم", "name": "الاسم",
"description": "الوصف", "description": "الوصف",
@@ -1822,12 +1820,7 @@
"gridView": "شبكة", "gridView": "شبكة",
"listView": "قائمة", "listView": "قائمة",
"compactRows": "صفوف مدمجة", "compactRows": "صفوف مدمجة",
"comfortableRows": "صفوف مريحة", "comfortableRows": "صفوف مريحة"
"columns": {
"title": "الأعمدة",
"auto": "تلقائي",
"count": "عرض {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "تم الحفظ. اكتب لاستبداله." "secretSaved": "تم الحفظ. اكتب لاستبداله."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} е готов за инсталация", "ready": "Termix {{version}} е готов за инсталация",
"readyHint": "Ще се инсталира следващия път, когато Termix се рестартира." "readyHint": "Ще се инсталира следващия път, когато Termix се рестартира."
}, },
"safeMode": {
"title": "Режим на безопасност",
"description": "Само плъгините за подредба и тема са активни. Рестартирайте сървъра без променливата TERMIX_SAFE_MODE, за да активирате останалите."
},
"companion": { "companion": {
"close": "Затвори панела", "close": "Затвори панела",
"moveSide": "Премести панела", "moveSide": "Премести панела",
@@ -1102,8 +1098,6 @@
"resetEveryone": "Върни настройките по подразбиране за всички", "resetEveryone": "Върни настройките по подразбиране за всички",
"update": "Актуализирай {{name}} до {{version}}", "update": "Актуализирай {{name}} до {{version}}",
"disable": "Изключи {{name}}", "disable": "Изключи {{name}}",
"enable": "Активиране на {{name}}",
"startAgain": "Стартиране на {{name}} отново",
"install": "Намери подредби и теми", "install": "Намери подредби и теми",
"installTitle": "Подредби и теми", "installTitle": "Подредби и теми",
"installButton": "Инсталирай", "installButton": "Инсталирай",
@@ -1315,8 +1309,7 @@
"disabled": "Изключена", "disabled": "Изключена",
"failed": "Спряна неочаквано", "failed": "Спряна неочаквано",
"blocked": "Чака друга добавка", "blocked": "Чака друга добавка",
"server": "Работи на вашия сървър", "server": "Работи на вашия сървър"
"safeMode": "Изключен в режим на безопасност"
}, },
"groups": { "groups": {
"running": "Работещи", "running": "Работещи",
@@ -1330,6 +1323,11 @@
"updated": "Наскоро актуализирани", "updated": "Наскоро актуализирани",
"category": "Категория" "category": "Категория"
}, },
"columns": {
"title": "Колони",
"auto": "Автоматично",
"count": "{{count}} колони"
},
"table": { "table": {
"name": "Име", "name": "Име",
"description": "Описание", "description": "Описание",
@@ -1654,12 +1652,7 @@
"gridView": "Мрежа", "gridView": "Мрежа",
"listView": "Списък", "listView": "Списък",
"compactRows": "Компактни редове", "compactRows": "Компактни редове",
"comfortableRows": "Комфортни редове", "comfortableRows": "Комфортни редове"
"columns": {
"title": "Колони",
"auto": "Автоматично",
"count": "ширина {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Запазено. Въведете ново, за да го замените." "secretSaved": "Запазено. Въведете ново, за да го замените."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} ইনস্টল করার জন্য প্রস্তুত", "ready": "Termix {{version}} ইনস্টল করার জন্য প্রস্তুত",
"readyHint": "এটি পরবর্তীবার Termix রিস্টার্ট করলে ইনস্টল হবে।" "readyHint": "এটি পরবর্তীবার Termix রিস্টার্ট করলে ইনস্টল হবে।"
}, },
"safeMode": {
"title": "সেফ মোড",
"description": "শুধুমাত্র লেআউট এবং থিম প্লাগইন চলছে। বাকিগুলো আবার চালু করতে TERMIX_SAFE_MODE ছাড়া সার্ভারটি রিস্টার্ট করুন।"
},
"companion": { "companion": {
"close": "প্যানেল বন্ধ করুন", "close": "প্যানেল বন্ধ করুন",
"moveSide": "প্যানেল সরান", "moveSide": "প্যানেল সরান",
@@ -1102,8 +1098,6 @@
"resetEveryone": "সবার জন্য ডিফল্ট রিসেট করুন", "resetEveryone": "সবার জন্য ডিফল্ট রিসেট করুন",
"update": "{{name}} কে {{version}} এ আপডেট করুন", "update": "{{name}} কে {{version}} এ আপডেট করুন",
"disable": "{{name}} বন্ধ করুন", "disable": "{{name}} বন্ধ করুন",
"enable": "{{name}} চালু করুন",
"startAgain": "{{name}} আবার শুরু করুন",
"install": "লেআউট এবং থিম খুঁজুন", "install": "লেআউট এবং থিম খুঁজুন",
"installTitle": "লেআউট এবং থিম", "installTitle": "লেআউট এবং থিম",
"installButton": "ইনস্টল করুন", "installButton": "ইনস্টল করুন",
@@ -1315,8 +1309,7 @@
"disabled": "নিষ্ক্রিয়", "disabled": "নিষ্ক্রিয়",
"failed": "অপ্রত্যাশিতভাবে থেমে গেছে", "failed": "অপ্রত্যাশিতভাবে থেমে গেছে",
"blocked": "অন্য প্লাগইনের জন্য অপেক্ষা করছে", "blocked": "অন্য প্লাগইনের জন্য অপেক্ষা করছে",
"server": "আপনার সার্ভারে চলে", "server": "আপনার সার্ভারে চলে"
"safeMode": "সেফ মোডে বন্ধ"
}, },
"groups": { "groups": {
"running": "চলমান", "running": "চলমান",
@@ -1330,6 +1323,11 @@
"updated": "সাম্প্রতিক আপডেট", "updated": "সাম্প্রতিক আপডেট",
"category": "ক্যাটাগরি" "category": "ক্যাটাগরি"
}, },
"columns": {
"title": "কলাম",
"auto": "স্বয়ংক্রিয়",
"count": "{{count}} প্রশস্ত"
},
"table": { "table": {
"name": "নাম", "name": "নাম",
"description": "বিবরণ", "description": "বিবরণ",
@@ -1654,12 +1652,7 @@
"gridView": "গ্রিড", "gridView": "গ্রিড",
"listView": "তালিকা", "listView": "তালিকা",
"compactRows": "কমপ্যাক্ট সারি", "compactRows": "কমপ্যাক্ট সারি",
"comfortableRows": "আরামদায়ক সারি", "comfortableRows": "আরামদায়ক সারি"
"columns": {
"title": "কলাম",
"auto": "স্বয়ংক্রিয়",
"count": "{{count}} প্রশস্ত"
}
}, },
"fields": { "fields": {
"secretSaved": "সংরক্ষিত হয়েছে। এটি প্রতিস্থাপন করতে টাইপ করুন।" "secretSaved": "সংরক্ষিত হয়েছে। এটি প্রতিস্থাপন করতে টাইপ করুন।"
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} està llest per instal·lar-se", "ready": "Termix {{version}} està llest per instal·lar-se",
"readyHint": "S'instal·larà la propera vegada que reiniciïs Termix." "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": { "companion": {
"close": "Tanca el panell", "close": "Tanca el panell",
"moveSide": "Mou el panell", "moveSide": "Mou el panell",
@@ -1119,8 +1115,6 @@
"resetEveryone": "Restableix el predeterminat per a tothom", "resetEveryone": "Restableix el predeterminat per a tothom",
"update": "Actualitza {{name}} a {{version}}", "update": "Actualitza {{name}} a {{version}}",
"disable": "Desactiva {{name}}", "disable": "Desactiva {{name}}",
"enable": "Activar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Cerca dissenys i temes", "install": "Cerca dissenys i temes",
"installTitle": "Disseny i temes", "installTitle": "Disseny i temes",
"installButton": "Instal·la", "installButton": "Instal·la",
@@ -1332,8 +1326,7 @@
"disabled": "Desactivat", "disabled": "Desactivat",
"failed": "Aturat inesperadament", "failed": "Aturat inesperadament",
"blocked": "Esperant un altre plugin", "blocked": "Esperant un altre plugin",
"server": "S'executa al teu servidor", "server": "S'executa al teu servidor"
"safeMode": "Desactivat en mode segur"
}, },
"groups": { "groups": {
"running": "En execució", "running": "En execució",
@@ -1347,6 +1340,11 @@
"updated": "Actualitzats recentment", "updated": "Actualitzats recentment",
"category": "Categoria" "category": "Categoria"
}, },
"columns": {
"title": "Columnes",
"auto": "Automàtic",
"count": "{{count}} d'amplada"
},
"table": { "table": {
"name": "Nom", "name": "Nom",
"description": "Descripció", "description": "Descripció",
@@ -1696,12 +1694,7 @@
"gridView": "Graella", "gridView": "Graella",
"listView": "Llista", "listView": "Llista",
"compactRows": "Files compactes", "compactRows": "Files compactes",
"comfortableRows": "Files còmodes", "comfortableRows": "Files còmodes"
"columns": {
"title": "Columnes",
"auto": "Automàtic",
"count": "{{count}} d'ample"
}
}, },
"fields": { "fields": {
"secretSaved": "Desat. Escriu per substituir-lo." "secretSaved": "Desat. Escriu per substituir-lo."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} je připraven k instalaci", "ready": "Termix {{version}} je připraven k instalaci",
"readyHint": "Nainstaluje se při příštím restartu Termixu." "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": { "companion": {
"close": "Zavřít panel", "close": "Zavřít panel",
"moveSide": "Přesunout panel", "moveSide": "Přesunout panel",
@@ -1136,8 +1132,6 @@
"resetEveryone": "Obnovit výchozí nastavení pro všechny", "resetEveryone": "Obnovit výchozí nastavení pro všechny",
"update": "Aktualizovat {{name}} na {{version}}", "update": "Aktualizovat {{name}} na {{version}}",
"disable": "Vypnout {{name}}", "disable": "Vypnout {{name}}",
"enable": "Zapnout {{name}}",
"startAgain": "Spustit {{name}} znovu",
"install": "Hledat rozložení a motivy", "install": "Hledat rozložení a motivy",
"installTitle": "Rozložení a motivy", "installTitle": "Rozložení a motivy",
"installButton": "Instalovat", "installButton": "Instalovat",
@@ -1349,8 +1343,7 @@
"disabled": "Zakázáno", "disabled": "Zakázáno",
"failed": "Neočekávaně zastaveno", "failed": "Neočekávaně zastaveno",
"blocked": "Čeká na jiný plugin", "blocked": "Čeká na jiný plugin",
"server": "Běží na vašem serveru", "server": "Běží na vašem serveru"
"safeMode": "Vypnuto v režimu nouzového spuštění"
}, },
"groups": { "groups": {
"running": "Spuštěné", "running": "Spuštěné",
@@ -1364,6 +1357,11 @@
"updated": "Nedávno aktualizované", "updated": "Nedávno aktualizované",
"category": "Kategorie" "category": "Kategorie"
}, },
"columns": {
"title": "Sloupce",
"auto": "Automaticky",
"count": "Šířka {{count}}"
},
"table": { "table": {
"name": "Název", "name": "Název",
"description": "Popis", "description": "Popis",
@@ -1738,12 +1736,7 @@
"gridView": "Mřížka", "gridView": "Mřížka",
"listView": "Seznam", "listView": "Seznam",
"compactRows": "Kompaktní řádky", "compactRows": "Kompaktní řádky",
"comfortableRows": "Pohodlné řádky", "comfortableRows": "Pohodlné řádky"
"columns": {
"title": "Sloupce",
"auto": "Automaticky",
"count": "šířka {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Uloženo. Napište pro nahrazení." "secretSaved": "Uloženo. Napište pro nahrazení."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} er klar til installation", "ready": "Termix {{version}} er klar til installation",
"readyHint": "Den installeres næste gang Termix genstartes." "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": { "companion": {
"close": "Luk panel", "close": "Luk panel",
"moveSide": "Flyt panel", "moveSide": "Flyt panel",
@@ -1102,8 +1098,6 @@
"resetEveryone": "Nulstil standardindstillingen for alle", "resetEveryone": "Nulstil standardindstillingen for alle",
"update": "Opdater {{name}} til {{version}}", "update": "Opdater {{name}} til {{version}}",
"disable": "Sluk {{name}}", "disable": "Sluk {{name}}",
"enable": "Aktivér {{name}}",
"startAgain": "Start {{name}} igen",
"install": "Find layouts og temaer", "install": "Find layouts og temaer",
"installTitle": "Layouts og temaer", "installTitle": "Layouts og temaer",
"installButton": "Installer", "installButton": "Installer",
@@ -1315,8 +1309,7 @@
"disabled": "Deaktiveret", "disabled": "Deaktiveret",
"failed": "Stoppet uventet", "failed": "Stoppet uventet",
"blocked": "Venter på et andet plugin", "blocked": "Venter på et andet plugin",
"server": "Kører på din server", "server": "Kører på din server"
"safeMode": "Deaktiveret i sikker tilstand"
}, },
"groups": { "groups": {
"running": "Kører", "running": "Kører",
@@ -1330,6 +1323,11 @@
"updated": "Nyligt opdateret", "updated": "Nyligt opdateret",
"category": "Kategori" "category": "Kategori"
}, },
"columns": {
"title": "Kolonner",
"auto": "Auto",
"count": "{{count}} bred"
},
"table": { "table": {
"name": "Navn", "name": "Navn",
"description": "Beskrivelse", "description": "Beskrivelse",
@@ -1654,12 +1652,7 @@
"gridView": "Gitter", "gridView": "Gitter",
"listView": "Liste", "listView": "Liste",
"compactRows": "Kompakte rækker", "compactRows": "Kompakte rækker",
"comfortableRows": "Komfortable rækker", "comfortableRows": "Komfortable rækker"
"columns": {
"title": "Søjler",
"auto": "Automatisk",
"count": "{{count}} bred"
}
}, },
"fields": { "fields": {
"secretSaved": "Gemt. Skriv for at erstatte det." "secretSaved": "Gemt. Skriv for at erstatte det."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} ist bereit zur Installation", "ready": "Termix {{version}} ist bereit zur Installation",
"readyHint": "Es wird beim nächsten Neustart von Termix installiert." "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": { "companion": {
"close": "Panel schließen", "close": "Panel schließen",
"moveSide": "Panel verschieben", "moveSide": "Panel verschieben",
@@ -1102,8 +1098,6 @@
"resetEveryone": "Standard für alle zurücksetzen", "resetEveryone": "Standard für alle zurücksetzen",
"update": "{{name}} auf {{version}} aktualisieren", "update": "{{name}} auf {{version}} aktualisieren",
"disable": "{{name}} deaktivieren", "disable": "{{name}} deaktivieren",
"enable": "{{name}} aktivieren",
"startAgain": "{{name}} neu starten",
"install": "Layouts und Themes finden", "install": "Layouts und Themes finden",
"installTitle": "Layouts und Themes", "installTitle": "Layouts und Themes",
"installButton": "Installieren", "installButton": "Installieren",
@@ -1315,8 +1309,7 @@
"disabled": "Deaktiviert", "disabled": "Deaktiviert",
"failed": "Unerwartet gestoppt", "failed": "Unerwartet gestoppt",
"blocked": "Wartet auf ein anderes Plugin", "blocked": "Wartet auf ein anderes Plugin",
"server": "Läuft auf deinem Server", "server": "Läuft auf deinem Server"
"safeMode": "Im sicheren Modus deaktiviert"
}, },
"groups": { "groups": {
"running": "Aktiv", "running": "Aktiv",
@@ -1330,6 +1323,11 @@
"updated": "Kürzlich aktualisiert", "updated": "Kürzlich aktualisiert",
"category": "Kategorie" "category": "Kategorie"
}, },
"columns": {
"title": "Spalten",
"auto": "Auto",
"count": "{{count}} Spalten breit"
},
"table": { "table": {
"name": "Name", "name": "Name",
"description": "Beschreibung", "description": "Beschreibung",
@@ -1654,12 +1652,7 @@
"gridView": "Raster", "gridView": "Raster",
"listView": "Liste", "listView": "Liste",
"compactRows": "Kompakte Zeilen", "compactRows": "Kompakte Zeilen",
"comfortableRows": "Bequeme Zeilen", "comfortableRows": "Bequeme Zeilen"
"columns": {
"title": "Spalten",
"auto": "Automatisch",
"count": "{{count}} Spalten breit"
}
}, },
"fields": { "fields": {
"secretSaved": "Gespeichert. Tippen, um es zu ersetzen." "secretSaved": "Gespeichert. Tippen, um es zu ersetzen."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Το Termix {{version}} είναι έτοιμο για εγκατάσταση", "ready": "Το Termix {{version}} είναι έτοιμο για εγκατάσταση",
"readyHint": "Εγκαθίσταται την επόμενη φορά που θα επανεκκινήσει το Termix." "readyHint": "Εγκαθίσταται την επόμενη φορά που θα επανεκκινήσει το Termix."
}, },
"safeMode": {
"title": "Λειτουργία ασφαλείας",
"description": "Τρέχουν μόνο οι προσθήκες διάταξης και θέματος. Επανεκκινήστε τον διακομιστή χωρίς το TERMIX_SAFE_MODE για να ενεργοποιήσετε ξανά τις υπόλοιπες."
},
"companion": { "companion": {
"close": "Κλείσιμο πάνελ", "close": "Κλείσιμο πάνελ",
"moveSide": "Μετακίνηση πάνελ", "moveSide": "Μετακίνηση πάνελ",
@@ -1102,8 +1098,6 @@
"resetEveryone": "Επαναφορά της προεπιλογής για όλους", "resetEveryone": "Επαναφορά της προεπιλογής για όλους",
"update": "Ενημέρωση του {{name}} στην έκδοση {{version}}", "update": "Ενημέρωση του {{name}} στην έκδοση {{version}}",
"disable": "Απενεργοποίηση του {{name}}", "disable": "Απενεργοποίηση του {{name}}",
"enable": "Ενεργοποίηση {{name}}",
"startAgain": "Εκκίνηση {{name}} ξανά",
"install": "Εύρεση layouts και θεμάτων", "install": "Εύρεση layouts και θεμάτων",
"installTitle": "Layouts και θέματα", "installTitle": "Layouts και θέματα",
"installButton": "Εγκατάσταση", "installButton": "Εγκατάσταση",
@@ -1315,8 +1309,7 @@
"disabled": "Απενεργοποιημένο", "disabled": "Απενεργοποιημένο",
"failed": "Διακόπηκε απροσδόκητα", "failed": "Διακόπηκε απροσδόκητα",
"blocked": "Περιμένει άλλο plugin", "blocked": "Περιμένει άλλο plugin",
"server": "Τρέχει στον διακομιστή σας", "server": "Τρέχει στον διακομιστή σας"
"safeMode": "Ανενεργό στη λειτουργία ασφαλείας"
}, },
"groups": { "groups": {
"running": "Εν ενεργεία", "running": "Εν ενεργεία",
@@ -1330,6 +1323,11 @@
"updated": "Πρόσφατα ενημερωμένα", "updated": "Πρόσφατα ενημερωμένα",
"category": "Κατηγορία" "category": "Κατηγορία"
}, },
"columns": {
"title": "Στήλες",
"auto": "Αυτόματο",
"count": "{{count}} στήλες"
},
"table": { "table": {
"name": "Όνομα", "name": "Όνομα",
"description": "Περιγραφή", "description": "Περιγραφή",
@@ -1654,12 +1652,7 @@
"gridView": "Πλέγμα", "gridView": "Πλέγμα",
"listView": "Λίστα", "listView": "Λίστα",
"compactRows": "Συμπαγείς γραμμές", "compactRows": "Συμπαγείς γραμμές",
"comfortableRows": "Άνετες γραμμές", "comfortableRows": "Άνετες γραμμές"
"columns": {
"title": "Στήλες",
"auto": "Αυτόματο",
"count": "Πλάτος {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Αποθηκεύτηκε. Πληκτρολογήστε για να το αντικαταστήσετε." "secretSaved": "Αποθηκεύτηκε. Πληκτρολογήστε για να το αντικαταστήσετε."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} está listo para instalar", "ready": "Termix {{version}} está listo para instalar",
"readyHint": "Se instalará la próxima vez que reinicies Termix." "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": { "companion": {
"close": "Cerrar panel", "close": "Cerrar panel",
"moveSide": "Mover panel", "moveSide": "Mover panel",
@@ -1119,8 +1115,6 @@
"resetEveryone": "Restablecer el valor predeterminado para todos", "resetEveryone": "Restablecer el valor predeterminado para todos",
"update": "Actualizar {{name}} a {{version}}", "update": "Actualizar {{name}} a {{version}}",
"disable": "Desactivar {{name}}", "disable": "Desactivar {{name}}",
"enable": "Activar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Buscar diseños y temas", "install": "Buscar diseños y temas",
"installTitle": "Diseños y temas", "installTitle": "Diseños y temas",
"installButton": "Instalar", "installButton": "Instalar",
@@ -1332,8 +1326,7 @@
"disabled": "Desactivado", "disabled": "Desactivado",
"failed": "Detenido inesperadamente", "failed": "Detenido inesperadamente",
"blocked": "Esperando a otro complemento", "blocked": "Esperando a otro complemento",
"server": "Se ejecuta en tu servidor", "server": "Se ejecuta en tu servidor"
"safeMode": "Desactivado en modo seguro"
}, },
"groups": { "groups": {
"running": "En ejecución", "running": "En ejecución",
@@ -1347,6 +1340,11 @@
"updated": "Actualizados recientemente", "updated": "Actualizados recientemente",
"category": "Categoría" "category": "Categoría"
}, },
"columns": {
"title": "Columnas",
"auto": "Automático",
"count": "{{count}} de ancho"
},
"table": { "table": {
"name": "Nombre", "name": "Nombre",
"description": "Descripción", "description": "Descripción",
@@ -1696,12 +1694,7 @@
"gridView": "Cuadrícula", "gridView": "Cuadrícula",
"listView": "Lista", "listView": "Lista",
"compactRows": "Filas compactas", "compactRows": "Filas compactas",
"comfortableRows": "Filas cómodas", "comfortableRows": "Filas cómodas"
"columns": {
"title": "Columnas",
"auto": "Automático",
"count": "{{count}} de ancho"
}
}, },
"fields": { "fields": {
"secretSaved": "Guardado. Escribe para reemplazarlo." "secretSaved": "Guardado. Escribe para reemplazarlo."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} on paigaldamiseks valmis", "ready": "Termix {{version}} on paigaldamiseks valmis",
"readyHint": "See paigaldatakse järgmisel Termixi taaskäivitamisel." "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": { "companion": {
"close": "Sulge paneel", "close": "Sulge paneel",
"moveSide": "Liiguta paneeli", "moveSide": "Liiguta paneeli",
@@ -1102,8 +1098,6 @@
"resetEveryone": "Taasta vaikimisi seadistus kõigile", "resetEveryone": "Taasta vaikimisi seadistus kõigile",
"update": "Uuenda {{name}} versioonile {{version}}", "update": "Uuenda {{name}} versioonile {{version}}",
"disable": "Lülita {{name}} välja", "disable": "Lülita {{name}} välja",
"enable": "Lülita {{name}} sisse",
"startAgain": "Käivita {{name}} uuesti",
"install": "Otsi paigutusi ja teemasid", "install": "Otsi paigutusi ja teemasid",
"installTitle": "Paigutused ja teemad", "installTitle": "Paigutused ja teemad",
"installButton": "Paigalda", "installButton": "Paigalda",
@@ -1315,8 +1309,7 @@
"disabled": "Keelatud", "disabled": "Keelatud",
"failed": "Peatati ootamatult", "failed": "Peatati ootamatult",
"blocked": "Ootab teist pistikprogrammi", "blocked": "Ootab teist pistikprogrammi",
"server": "Töötab sinu serveris", "server": "Töötab sinu serveris"
"safeMode": "Turvarežiimis välja lülitatud"
}, },
"groups": { "groups": {
"running": "Käimas", "running": "Käimas",
@@ -1330,6 +1323,11 @@
"updated": "Hiljuti uuendatud", "updated": "Hiljuti uuendatud",
"category": "Kategooria" "category": "Kategooria"
}, },
"columns": {
"title": "Veerud",
"auto": "Automaatne",
"count": "{{count}} lai"
},
"table": { "table": {
"name": "Nimi", "name": "Nimi",
"description": "Kirjeldus", "description": "Kirjeldus",
@@ -1654,12 +1652,7 @@
"gridView": "Ruudustik", "gridView": "Ruudustik",
"listView": "Loend", "listView": "Loend",
"compactRows": "Kompaktsead read", "compactRows": "Kompaktsead read",
"comfortableRows": "Mugavad read", "comfortableRows": "Mugavad read"
"columns": {
"title": "Veerud",
"auto": "Automaatne",
"count": "{{count}} veeru laiune"
}
}, },
"fields": { "fields": {
"secretSaved": "Salvestatud. Tüüpi, et asendada." "secretSaved": "Salvestatud. Tüüpi, et asendada."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} on valmis asennettavaksi", "ready": "Termix {{version}} on valmis asennettavaksi",
"readyHint": "Se asennetaan seuraavan kerran, kun Termix käynnistetään uudelleen." "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": { "companion": {
"close": "Sulje paneeli", "close": "Sulje paneeli",
"moveSide": "Siirrä paneeli", "moveSide": "Siirrä paneeli",
@@ -1102,8 +1098,6 @@
"resetEveryone": "Palauta oletus kaikille", "resetEveryone": "Palauta oletus kaikille",
"update": "Päivitä {{name}} versioon {{version}}", "update": "Päivitä {{name}} versioon {{version}}",
"disable": "Sammuta {{name}}", "disable": "Sammuta {{name}}",
"enable": "Ota {{name}} käyttöön",
"startAgain": "Käynnistä {{name}} uudelleen",
"install": "Etsi asetteluja ja teemoja", "install": "Etsi asetteluja ja teemoja",
"installTitle": "Asettelut ja teemat", "installTitle": "Asettelut ja teemat",
"installButton": "Asenna", "installButton": "Asenna",
@@ -1315,8 +1309,7 @@
"disabled": "Poistettu käytöstä", "disabled": "Poistettu käytöstä",
"failed": "Pysähtyi odottamatta", "failed": "Pysähtyi odottamatta",
"blocked": "Odottaa toista lisäosaa", "blocked": "Odottaa toista lisäosaa",
"server": "Käynnistyy palvelimellasi", "server": "Käynnistyy palvelimellasi"
"safeMode": "Pois käytöstä turvatoiminnossa"
}, },
"groups": { "groups": {
"running": "Käynnissä", "running": "Käynnissä",
@@ -1330,6 +1323,11 @@
"updated": "Viimeksi päivitetty", "updated": "Viimeksi päivitetty",
"category": "Kategoria" "category": "Kategoria"
}, },
"columns": {
"title": "Sarakkeet",
"auto": "Automaattinen",
"count": "{{count}} saraketta leveä"
},
"table": { "table": {
"name": "Nimi", "name": "Nimi",
"description": "Kuvaus", "description": "Kuvaus",
@@ -1654,12 +1652,7 @@
"gridView": "Ruudukko", "gridView": "Ruudukko",
"listView": "Lista", "listView": "Lista",
"compactRows": "Tiiviit rivit", "compactRows": "Tiiviit rivit",
"comfortableRows": "Mukavat rivit", "comfortableRows": "Mukavat rivit"
"columns": {
"title": "Sarakkeet",
"auto": "Automaattinen",
"count": "{{count}} saraketta leveä"
}
}, },
"fields": { "fields": {
"secretSaved": "Tallennettu. Kirjoita korvataksesi sen." "secretSaved": "Tallennettu. Kirjoita korvataksesi sen."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} est prêt à être installé", "ready": "Termix {{version}} est prêt à être installé",
"readyHint": "Il sera installé au prochain redémarrage de Termix." "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": { "companion": {
"close": "Fermer le panneau", "close": "Fermer le panneau",
"moveSide": "Déplacer le panneau", "moveSide": "Déplacer le panneau",
@@ -1119,8 +1115,6 @@
"resetEveryone": "Réinitialiser la valeur par défaut pour tout le monde", "resetEveryone": "Réinitialiser la valeur par défaut pour tout le monde",
"update": "Mettre à jour {{name}} vers {{version}}", "update": "Mettre à jour {{name}} vers {{version}}",
"disable": "Désactiver {{name}}", "disable": "Désactiver {{name}}",
"enable": "Activer {{name}}",
"startAgain": "Relancer {{name}}",
"install": "Trouver des dispositions et des thèmes", "install": "Trouver des dispositions et des thèmes",
"installTitle": "Dispositions et thèmes", "installTitle": "Dispositions et thèmes",
"installButton": "Installer", "installButton": "Installer",
@@ -1332,8 +1326,7 @@
"disabled": "Désactivé", "disabled": "Désactivé",
"failed": "Arrêt inattendu", "failed": "Arrêt inattendu",
"blocked": "En attente d'un autre plugin", "blocked": "En attente d'un autre plugin",
"server": "Exécute sur votre serveur", "server": "Exécute sur votre serveur"
"safeMode": "Désactivé en mode sans échec"
}, },
"groups": { "groups": {
"running": "En cours d'exécution", "running": "En cours d'exécution",
@@ -1347,6 +1340,11 @@
"updated": "Récemment mis à jour", "updated": "Récemment mis à jour",
"category": "Catégorie" "category": "Catégorie"
}, },
"columns": {
"title": "Colonnes",
"auto": "Auto",
"count": "{{count}} de large"
},
"table": { "table": {
"name": "Nom", "name": "Nom",
"description": "Description", "description": "Description",
@@ -1696,12 +1694,7 @@
"gridView": "Grille", "gridView": "Grille",
"listView": "Liste", "listView": "Liste",
"compactRows": "Lignes compactes", "compactRows": "Lignes compactes",
"comfortableRows": "Lignes confortables", "comfortableRows": "Lignes confortables"
"columns": {
"title": "Colonnes",
"auto": "Auto",
"count": "{{count}} de large"
}
}, },
"fields": { "fields": {
"secretSaved": "Enregistré. Tapez pour le remplacer." "secretSaved": "Enregistré. Tapez pour le remplacer."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} מוכן להתקנה", "ready": "Termix {{version}} מוכן להתקנה",
"readyHint": "הוא מותקן בפעם הבאה ש-Termix מופעל מחדש." "readyHint": "הוא מותקן בפעם הבאה ש-Termix מופעל מחדש."
}, },
"safeMode": {
"title": "מצב בטוח",
"description": "רק תוספי פריסה ועיצוב פועלים. הפעל מחדש את השרת ללא המשתנה TERMIX_SAFE_MODE כדי להפעיל מחדש את שאר התוספים."
},
"companion": { "companion": {
"close": "סגור לוח", "close": "סגור לוח",
"moveSide": "הזז לוח", "moveSide": "הזז לוח",
@@ -1119,8 +1115,6 @@
"resetEveryone": "אפס את ברירת המחדל לכולם", "resetEveryone": "אפס את ברירת המחדל לכולם",
"update": "עדכן את {{name}} ל-{{version}}", "update": "עדכן את {{name}} ל-{{version}}",
"disable": "כבה את {{name}}", "disable": "כבה את {{name}}",
"enable": "הפעל את {{name}}",
"startAgain": "הפעל מחדש את {{name}}",
"install": "חפש פריסות וערכות נושא", "install": "חפש פריסות וערכות נושא",
"installTitle": "פריסות וערכות נושא", "installTitle": "פריסות וערכות נושא",
"installButton": "התקן", "installButton": "התקן",
@@ -1332,8 +1326,7 @@
"disabled": "מושבת", "disabled": "מושבת",
"failed": "הופסק ללא ציפייה", "failed": "הופסק ללא ציפייה",
"blocked": "ממתין לתוסף אחר", "blocked": "ממתין לתוסף אחר",
"server": "רץ על השרת שלך", "server": "רץ על השרת שלך"
"safeMode": "כבוי במצב בטוח"
}, },
"groups": { "groups": {
"running": "פעילים", "running": "פעילים",
@@ -1347,6 +1340,11 @@
"updated": "עודכן לאחרונה", "updated": "עודכן לאחרונה",
"category": "קטגוריה" "category": "קטגוריה"
}, },
"columns": {
"title": "עמודות",
"auto": "אוטומטי",
"count": "רוחב של {{count}}"
},
"table": { "table": {
"name": "שם", "name": "שם",
"description": "תיאור", "description": "תיאור",
@@ -1696,12 +1694,7 @@
"gridView": "רשת", "gridView": "רשת",
"listView": "רשימה", "listView": "רשימה",
"compactRows": "שורות צפופות", "compactRows": "שורות צפופות",
"comfortableRows": "שורות נוחות", "comfortableRows": "שורות נוחות"
"columns": {
"title": "עמודות",
"auto": "אוטומטי",
"count": "רוחב של {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "נשמר. הקלד כדי להחליף אותו." "secretSaved": "נשמר. הקלד כדי להחליף אותו."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} इंस्टॉल करने के लिए तैयार है", "ready": "Termix {{version}} इंस्टॉल करने के लिए तैयार है",
"readyHint": "यह अगली बार जब Termix पुनरारंभ होगा तब इंस्टॉल होगा।" "readyHint": "यह अगली बार जब Termix पुनरारंभ होगा तब इंस्टॉल होगा।"
}, },
"safeMode": {
"title": "सुरक्षित मोड",
"description": "केवल लेआउट और थीम प्लगइन चल रहे हैं। बाकी सब कुछ वापस चालू करने के लिए TERMIX_SAFE_MODE के बिना सर्वर को पुनः आरंभ करें।"
},
"companion": { "companion": {
"close": "पैनल बंद करें", "close": "पैनल बंद करें",
"moveSide": "पैनल स्थानांतरित करें", "moveSide": "पैनल स्थानांतरित करें",
@@ -1102,8 +1098,6 @@
"resetEveryone": "सभी के लिए डिफ़ॉल्ट रीसेट करें", "resetEveryone": "सभी के लिए डिफ़ॉल्ट रीसेट करें",
"update": "{{name}} को {{version}} पर अपडेट करें", "update": "{{name}} को {{version}} पर अपडेट करें",
"disable": "{{name}} बंद करें", "disable": "{{name}} बंद करें",
"enable": "{{name}} चालू करें",
"startAgain": "{{name}} को फिर से शुरू करें",
"install": "लेआउट और थीम खोजें", "install": "लेआउट और थीम खोजें",
"installTitle": "लेआउट और थीम", "installTitle": "लेआउट और थीम",
"installButton": "इंस्टॉल करें", "installButton": "इंस्टॉल करें",
@@ -1315,8 +1309,7 @@
"disabled": "अक्षम", "disabled": "अक्षम",
"failed": "अप्रत्याशित रूप से रुका", "failed": "अप्रत्याशित रूप से रुका",
"blocked": "दूसरे प्लगइन का इंतज़ार कर रहा है", "blocked": "दूसरे प्लगइन का इंतज़ार कर रहा है",
"server": "आपके सर्वर पर चलता है", "server": "आपके सर्वर पर चलता है"
"safeMode": "सुरक्षित मोड में बंद"
}, },
"groups": { "groups": {
"running": "चल रहे", "running": "चल रहे",
@@ -1330,6 +1323,11 @@
"updated": "हाल ही में अपडेट किया गया", "updated": "हाल ही में अपडेट किया गया",
"category": "श्रेणी" "category": "श्रेणी"
}, },
"columns": {
"title": "कॉलम",
"auto": "स्वचालित",
"count": "{{count}} चौड़ा"
},
"table": { "table": {
"name": "नाम", "name": "नाम",
"description": "विवरण", "description": "विवरण",
@@ -1654,12 +1652,7 @@
"gridView": "ग्रिड", "gridView": "ग्रिड",
"listView": "सूची", "listView": "सूची",
"compactRows": "संक्षिप्त पंक्तियाँ", "compactRows": "संक्षिप्त पंक्तियाँ",
"comfortableRows": "आरामदायक पंक्तियाँ", "comfortableRows": "आरामदायक पंक्तियाँ"
"columns": {
"title": "कॉलम",
"auto": "स्वचालित",
"count": "{{count}} चौड़ा"
}
}, },
"fields": { "fields": {
"secretSaved": "सहेजा गया। इसे बदलने के लिए टाइप करें।" "secretSaved": "सहेजा गया। इसे बदलने के लिए टाइप करें।"
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} je spreman za instalaciju", "ready": "Termix {{version}} je spreman za instalaciju",
"readyHint": "Instalirat će se sljedeći put kada se Termix ponovno pokrene." "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": { "companion": {
"close": "Zatvori panel", "close": "Zatvori panel",
"moveSide": "Pomakni panel", "moveSide": "Pomakni panel",
@@ -1119,8 +1115,6 @@
"resetEveryone": "Vrati zadano za sve", "resetEveryone": "Vrati zadano za sve",
"update": "Ažuriraj {{name}} na {{version}}", "update": "Ažuriraj {{name}} na {{version}}",
"disable": "Isključi {{name}}", "disable": "Isključi {{name}}",
"enable": "Uključi {{name}}",
"startAgain": "Ponovno pokreni {{name}}",
"install": "Pronađi rasporede i teme", "install": "Pronađi rasporede i teme",
"installTitle": "Rasporedi i teme", "installTitle": "Rasporedi i teme",
"installButton": "Instaliraj", "installButton": "Instaliraj",
@@ -1332,8 +1326,7 @@
"disabled": "Onemogućeno", "disabled": "Onemogućeno",
"failed": "Neočekivano zaustavljeno", "failed": "Neočekivano zaustavljeno",
"blocked": "Čeka drugi dodatak", "blocked": "Čeka drugi dodatak",
"server": "Radi na vašem poslužitelju", "server": "Radi na vašem poslužitelju"
"safeMode": "Isključeno u sigurnom načinu rada"
}, },
"groups": { "groups": {
"running": "Pokrenuto", "running": "Pokrenuto",
@@ -1347,6 +1340,11 @@
"updated": "Nedavno ažurirano", "updated": "Nedavno ažurirano",
"category": "Kategorija" "category": "Kategorija"
}, },
"columns": {
"title": "Stupci",
"auto": "Automatski",
"count": "{{count}} široko"
},
"table": { "table": {
"name": "Naziv", "name": "Naziv",
"description": "Opis", "description": "Opis",
@@ -1696,12 +1694,7 @@
"gridView": "Mreža", "gridView": "Mreža",
"listView": "Popis", "listView": "Popis",
"compactRows": "Kompaktni redovi", "compactRows": "Kompaktni redovi",
"comfortableRows": "Prostrani redovi", "comfortableRows": "Prostrani redovi"
"columns": {
"title": "Stupci",
"auto": "Automatski",
"count": "Širina: {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Spremljeno. Upišite za zamjenu." "secretSaved": "Spremljeno. Upišite za zamjenu."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "A Termix {{version}} verziója készen áll a telepítésre", "ready": "A Termix {{version}} verziója készen áll a telepítésre",
"readyHint": "A következő újraindításkor települ." "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": { "companion": {
"close": "Panel bezárása", "close": "Panel bezárása",
"moveSide": "Panel áthelyezése", "moveSide": "Panel áthelyezése",
@@ -1102,8 +1098,6 @@
"resetEveryone": "Az alapértelmezett visszaállítása mindenkinek", "resetEveryone": "Az alapértelmezett visszaállítása mindenkinek",
"update": "{{name}} frissítése a {{version}} verzióra", "update": "{{name}} frissítése a {{version}} verzióra",
"disable": "{{name}} kikapcsolása", "disable": "{{name}} kikapcsolása",
"enable": "{{name}} bekapcsolása",
"startAgain": "{{name}} újraindítása",
"install": "Elrendezések és témák keresése", "install": "Elrendezések és témák keresése",
"installTitle": "Elrendezések és témák", "installTitle": "Elrendezések és témák",
"installButton": "Telepítés", "installButton": "Telepítés",
@@ -1315,8 +1309,7 @@
"disabled": "Letiltva", "disabled": "Letiltva",
"failed": "Váratlanul leállt", "failed": "Váratlanul leállt",
"blocked": "Várakozás egy másik bővítményre", "blocked": "Várakozás egy másik bővítményre",
"server": "A szerverén fut", "server": "A szerverén fut"
"safeMode": "Kikapcsolva biztonságos módban"
}, },
"groups": { "groups": {
"running": "Fut", "running": "Fut",
@@ -1330,6 +1323,11 @@
"updated": "Legutóbb frissítve", "updated": "Legutóbb frissítve",
"category": "Kategória" "category": "Kategória"
}, },
"columns": {
"title": "Oszlopok",
"auto": "Automatikus",
"count": "{{count}} oszlop széles"
},
"table": { "table": {
"name": "Név", "name": "Név",
"description": "Leírás", "description": "Leírás",
@@ -1654,12 +1652,7 @@
"gridView": "Rács", "gridView": "Rács",
"listView": "Lista", "listView": "Lista",
"compactRows": "Kompakt sorok", "compactRows": "Kompakt sorok",
"comfortableRows": "Kényelmes sorok", "comfortableRows": "Kényelmes sorok"
"columns": {
"title": "Oszlopok",
"auto": "Automatikus",
"count": "{{count}} oszlop széles"
}
}, },
"fields": { "fields": {
"secretSaved": "Mentve. Írjon be új értéket a helyettesítéshez." "secretSaved": "Mentve. Írjon be új értéket a helyettesítéshez."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} siap untuk diinstal", "ready": "Termix {{version}} siap untuk diinstal",
"readyHint": "Ini akan diinstal saat Termix dimulai ulang berikutnya." "readyHint": "Ini akan diinstal saat Termix dimulai ulang berikutnya."
}, },
"safeMode": {
"title": "Mode aman",
"description": "Hanya plugin tata letak dan tema yang berjalan. Mulai ulang server tanpa TERMIX_SAFE_MODE untuk mengaktifkan kembali yang lainnya."
},
"companion": { "companion": {
"close": "Tutup panel", "close": "Tutup panel",
"moveSide": "Pindahkan panel", "moveSide": "Pindahkan panel",
@@ -1085,8 +1081,6 @@
"resetEveryone": "Atur ulang default untuk semua orang", "resetEveryone": "Atur ulang default untuk semua orang",
"update": "Perbarui {{name}} ke {{version}}", "update": "Perbarui {{name}} ke {{version}}",
"disable": "Matikan {{name}}", "disable": "Matikan {{name}}",
"enable": "Aktifkan {{name}}",
"startAgain": "Mulai ulang {{name}}",
"install": "Cari tata letak dan tema", "install": "Cari tata letak dan tema",
"installTitle": "Tata letak dan tema", "installTitle": "Tata letak dan tema",
"installButton": "Instal", "installButton": "Instal",
@@ -1298,8 +1292,7 @@
"disabled": "Dinonaktifkan", "disabled": "Dinonaktifkan",
"failed": "Berhenti secara tak terduga", "failed": "Berhenti secara tak terduga",
"blocked": "Menunggu plugin lain", "blocked": "Menunggu plugin lain",
"server": "Berjalan di server Anda", "server": "Berjalan di server Anda"
"safeMode": "Nonaktif di mode aman"
}, },
"groups": { "groups": {
"running": "Berjalan", "running": "Berjalan",
@@ -1313,6 +1306,11 @@
"updated": "Baru diperbarui", "updated": "Baru diperbarui",
"category": "Kategori" "category": "Kategori"
}, },
"columns": {
"title": "Kolom",
"auto": "Otomatis",
"count": "Lebar {{count}}"
},
"table": { "table": {
"name": "Nama", "name": "Nama",
"description": "Deskripsi", "description": "Deskripsi",
@@ -1612,12 +1610,7 @@
"gridView": "Grid", "gridView": "Grid",
"listView": "Daftar", "listView": "Daftar",
"compactRows": "Baris ringkas", "compactRows": "Baris ringkas",
"comfortableRows": "Baris nyaman", "comfortableRows": "Baris nyaman"
"columns": {
"title": "Kolom",
"auto": "Otomatis",
"count": "Lebar {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Tersimpan. Ketik untuk menggantinya." "secretSaved": "Tersimpan. Ketik untuk menggantinya."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} è pronto per l'installazione", "ready": "Termix {{version}} è pronto per l'installazione",
"readyHint": "Verrà installato al prossimo riavvio di Termix." "readyHint": "Verrà installato al prossimo riavvio di Termix."
}, },
"safeMode": {
"title": "Modalità sicura",
"description": "Sono attivi solo i plugin per layout e temi. Riavvia il server senza TERMIX_SAFE_MODE per riattivare gli altri."
},
"companion": { "companion": {
"close": "Chiudi pannello", "close": "Chiudi pannello",
"moveSide": "Sposta pannello", "moveSide": "Sposta pannello",
@@ -1119,8 +1115,6 @@
"resetEveryone": "Reimposta il predefinito per tutti", "resetEveryone": "Reimposta il predefinito per tutti",
"update": "Aggiorna {{name}} a {{version}}", "update": "Aggiorna {{name}} a {{version}}",
"disable": "Disattiva {{name}}", "disable": "Disattiva {{name}}",
"enable": "Attiva {{name}}",
"startAgain": "Riavvia {{name}}",
"install": "Trova layout e temi", "install": "Trova layout e temi",
"installTitle": "Layout e temi", "installTitle": "Layout e temi",
"installButton": "Installa", "installButton": "Installa",
@@ -1332,8 +1326,7 @@
"disabled": "Disabilitato", "disabled": "Disabilitato",
"failed": "Arrestato inaspettatamente", "failed": "Arrestato inaspettatamente",
"blocked": "In attesa di un altro plugin", "blocked": "In attesa di un altro plugin",
"server": "Eseguito sul tuo server", "server": "Eseguito sul tuo server"
"safeMode": "Disattivato in modalità sicura"
}, },
"groups": { "groups": {
"running": "In esecuzione", "running": "In esecuzione",
@@ -1347,6 +1340,11 @@
"updated": "Aggiornati di recente", "updated": "Aggiornati di recente",
"category": "Categoria" "category": "Categoria"
}, },
"columns": {
"title": "Colonne",
"auto": "Automatico",
"count": "Larghezza {{count}}"
},
"table": { "table": {
"name": "Nome", "name": "Nome",
"description": "Descrizione", "description": "Descrizione",
@@ -1696,12 +1694,7 @@
"gridView": "Griglia", "gridView": "Griglia",
"listView": "Elenco", "listView": "Elenco",
"compactRows": "Righe compatte", "compactRows": "Righe compatte",
"comfortableRows": "Righe spaziose", "comfortableRows": "Righe spaziose"
"columns": {
"title": "Colonne",
"auto": "Automatico",
"count": "{{count}} colonne"
}
}, },
"fields": { "fields": {
"secretSaved": "Salvato. Digita per sostituirlo." "secretSaved": "Salvato. Digita per sostituirlo."
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} のインストール準備ができました", "ready": "Termix {{version}} のインストール準備ができました",
"readyHint": "Termix を再起動したときにインストールされます。" "readyHint": "Termix を再起動したときにインストールされます。"
}, },
"safeMode": {
"title": "セーフモード",
"description": "レイアウトとテーマのプラグインのみが実行されています。TERMIX_SAFE_MODE を設定せずにサーバーを再起動すると、他のプラグインが再度有効になります。"
},
"companion": { "companion": {
"close": "パネルを閉じる", "close": "パネルを閉じる",
"moveSide": "パネルを移動", "moveSide": "パネルを移動",
@@ -1085,8 +1081,6 @@
"resetEveryone": "すべてのユーザーのデフォルトをリセット", "resetEveryone": "すべてのユーザーのデフォルトをリセット",
"update": "{{name}} を {{version}} に更新", "update": "{{name}} を {{version}} に更新",
"disable": "{{name}} を無効にする", "disable": "{{name}} を無効にする",
"enable": "{{name}} を有効にする",
"startAgain": "{{name}} を再起動する",
"install": "レイアウトとテーマを探す", "install": "レイアウトとテーマを探す",
"installTitle": "レイアウトとテーマ", "installTitle": "レイアウトとテーマ",
"installButton": "インストール", "installButton": "インストール",
@@ -1298,8 +1292,7 @@
"disabled": "無効", "disabled": "無効",
"failed": "予期せず停止", "failed": "予期せず停止",
"blocked": "他のプラグインを待機中", "blocked": "他のプラグインを待機中",
"server": "サーバー上で実行", "server": "サーバー上で実行"
"safeMode": "セーフモードで無効"
}, },
"groups": { "groups": {
"running": "実行中", "running": "実行中",
@@ -1313,6 +1306,11 @@
"updated": "最近更新", "updated": "最近更新",
"category": "カテゴリ" "category": "カテゴリ"
}, },
"columns": {
"title": "列",
"auto": "自動",
"count": "{{count}} 列"
},
"table": { "table": {
"name": "名前", "name": "名前",
"description": "説明", "description": "説明",
@@ -1612,12 +1610,7 @@
"gridView": "グリッド", "gridView": "グリッド",
"listView": "リスト", "listView": "リスト",
"compactRows": "コンパクトな行", "compactRows": "コンパクトな行",
"comfortableRows": "ゆったりした行", "comfortableRows": "ゆったりした行"
"columns": {
"title": "カラム",
"auto": "自動",
"count": "{{count}} カラム"
}
}, },
"fields": { "fields": {
"secretSaved": "保存しました。入力して置き換えてください。" "secretSaved": "保存しました。入力して置き換えてください。"
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more