mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
27
Commits
sdk-v1.0.4
...
sdk-v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
931761190f | ||
|
|
28fe98e787 | ||
|
|
2ba11652eb | ||
|
|
2e3c242e34 | ||
|
|
4c68d35c21 | ||
|
|
0ce378ec55 | ||
|
|
83cce344e8 | ||
|
|
6f4198cc83 | ||
|
|
abdbb34ce2 | ||
|
|
0b41672a3c | ||
|
|
0f5c2ac862 | ||
|
|
6f7fc05892 | ||
|
|
4eac6d977d | ||
|
|
406766eddd | ||
|
|
f803bfc140 | ||
|
|
b0a0ee3e00 | ||
|
|
af09aa1028 | ||
|
|
45908b5df3 | ||
|
|
b156829ef3 | ||
|
|
399835d8bc | ||
|
|
e1d7b3e7c6 | ||
|
|
970aae4740 | ||
|
|
4fd9aebee4 | ||
|
|
acc9484280 | ||
|
|
7508a45f36 | ||
|
|
40df00bd95 | ||
|
|
6c342f3957 |
No files matched your search
+21
-44
@@ -5,13 +5,22 @@ yarn-error.log*
|
||||
|
||||
dist
|
||||
build
|
||||
.next
|
||||
.nuxt
|
||||
coverage
|
||||
release
|
||||
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
# Local data, secrets and editor state
|
||||
db
|
||||
old_db
|
||||
ssl
|
||||
uploads
|
||||
plugins
|
||||
.env
|
||||
.env.*
|
||||
.mcp.json
|
||||
.claude
|
||||
CLAUDE.md
|
||||
termix-plugin-signing.key
|
||||
*.tmxplug.sig
|
||||
|
||||
.vscode
|
||||
.idea
|
||||
@@ -29,63 +38,31 @@ Thumbs.db
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
.husky
|
||||
|
||||
README.md
|
||||
CONTRIBUTING.md
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
|
||||
|
||||
uploads/
|
||||
|
||||
electron/
|
||||
electron-builder.json
|
||||
Casks
|
||||
packaging
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
logs
|
||||
*.log
|
||||
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
jspm_packages/
|
||||
|
||||
.npm
|
||||
|
||||
.eslintcache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
.node_repl_history
|
||||
|
||||
*.tgz
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
.next
|
||||
|
||||
.nuxt
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
.serverless
|
||||
|
||||
.fusebox/
|
||||
|
||||
.dynamodb/
|
||||
|
||||
.tern-port
|
||||
*.tgz
|
||||
.node_repl_history
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g., 1.8.0)"
|
||||
description: "Version to build (e.g., 26.10.0)"
|
||||
required: true
|
||||
build_type:
|
||||
description: "Build type"
|
||||
@@ -22,11 +22,11 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g., 1.8.0)"
|
||||
description: "Version to build (e.g., 26.10.0)"
|
||||
required: true
|
||||
type: string
|
||||
build_type:
|
||||
description: "Build type (Development or Production)"
|
||||
description: "Build type (Development, Beta or Production)"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
@@ -52,7 +52,14 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Resolve source revision
|
||||
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
run: |
|
||||
echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
echo "BUILD_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||
|
||||
# The image reads its version from package.json, so a beta has to say so.
|
||||
- name: Stamp beta version
|
||||
if: ${{ inputs.build_type == 'Beta' }}
|
||||
run: node scripts/sync-version.cjs --version "${{ inputs.version }}"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -74,7 +81,7 @@ jobs:
|
||||
ALL_TAGS=()
|
||||
|
||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||
TAGS+=("release-$VERSION" "$VERSION" "latest")
|
||||
TAGS+=("$VERSION" "latest")
|
||||
elif [ "$BUILD_TYPE" = "Beta" ]; then
|
||||
TAGS+=("beta" "$VERSION")
|
||||
else
|
||||
@@ -120,7 +127,7 @@ jobs:
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
|
||||
org.opencontainers.image.created=${{ github.run_id }}
|
||||
org.opencontainers.image.created=${{ env.BUILD_CREATED }}
|
||||
outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}
|
||||
|
||||
- name: Login to Docker Hub as termixssh
|
||||
|
||||
@@ -117,6 +117,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
|
||||
|
||||
- name: Get version
|
||||
id: package-version
|
||||
run: |
|
||||
@@ -220,6 +225,11 @@ jobs:
|
||||
npm install --force @rollup/rollup-linux-arm64-gnu
|
||||
npm install --force @rollup/rollup-linux-arm-gnueabihf
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
|
||||
|
||||
- name: Build Linux x64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -429,6 +439,11 @@ jobs:
|
||||
npm install dmg-license
|
||||
node scripts/install-macos-sharp.cjs
|
||||
|
||||
# A beta build has to know it is a beta, or it reports the stable version.
|
||||
- name: Stamp beta version
|
||||
if: inputs.version_override != ''
|
||||
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
|
||||
|
||||
- name: Check for Code Signing Certificates
|
||||
id: check_certs
|
||||
run: |
|
||||
@@ -468,14 +483,15 @@ jobs:
|
||||
|
||||
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||
|
||||
# Betas never go to the App Store, so they skip the MAS package.
|
||||
- name: Build macOS App Store Package
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
id: mas_build
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && inputs.version_override == ''
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||
@@ -534,7 +550,7 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
|
||||
if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
|
||||
npm run build
|
||||
fi
|
||||
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||
@@ -555,7 +571,7 @@ jobs:
|
||||
echo "path=$MAS_PKG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload macOS MAS PKG
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
|
||||
if: steps.mas_build.outcome == 'success' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: termix_macos_universal_mas
|
||||
@@ -722,7 +738,7 @@ jobs:
|
||||
choco pack termix-ssh.nuspec
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Chocolatey push failed with exit code $LASTEXITCODE"
|
||||
throw "Chocolatey pack failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
- name: Check for Chocolatey API Key
|
||||
@@ -952,8 +968,8 @@ jobs:
|
||||
|
||||
cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
- name: Verify Cask syntax
|
||||
run: |
|
||||
|
||||
+19
-2
@@ -4,14 +4,31 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Temp
|
||||
- Plugins. Every feature beyond hosts, credentials and sharing is now its own plugin with its own repo, and you pick which ones you want
|
||||
- A Plugins tab to install, update, pin, turn off and remove plugins from the official registry, with release notes and a beta channel per plugin
|
||||
- Developer mode to install a plugin from a file, and a plugin SDK on npm to build your own
|
||||
- The community plugin registry is open for submissions. Termix can install community plugins in a later update
|
||||
- Redesigned UI with a Manage tab for hosts and credentials and a Settings tab with search
|
||||
- New full screen onboarding where you choose which plugins to start with
|
||||
- Beta channel for Termix itself in Settings > Updates
|
||||
- Docs links on every settings page, in the Plugins tab, in plugin settings and in the command palette
|
||||
- Plugins can link their own docs and list the environment variables they read, shown on their page in the Plugins tab
|
||||
- Report a bug links that open a prefilled issue in the right repo
|
||||
- `TRUSTED_PROXIES` to choose which proxies nginx takes client IPs from
|
||||
|
||||
### Changed
|
||||
|
||||
- Tem
|
||||
- Termix now uses calendar versions (year, month, patch), starting with 26.10.0
|
||||
- Redesigned host defaults with admin, user and folder levels
|
||||
- Simpler host status: online or offline, kept live without a refresh
|
||||
- Bug reports and feature requests now go to each repo instead of the Support repo
|
||||
|
||||
### Fixed
|
||||
|
||||
- A lot of bugs across the app, the desktop app and every plugin
|
||||
- Several security issues reported through GitHub advisories
|
||||
|
||||
### Removed
|
||||
|
||||
- Downgrading to 2.8 is no longer supported. A 2.8 install has to start 2.9 once before upgrading
|
||||
- The donation reminder popup
|
||||
@@ -92,7 +92,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
||||
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
||||
|
||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry).
|
||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Its community registry is open for submissions now, and Termix will be able to install community plugins in a later update. See [submitting a plugin](https://docs.termix.site/develop/community-registry).
|
||||
|
||||
<br />
|
||||
|
||||
@@ -109,14 +109,14 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
||||
<td>Portable · MSI Installer · Chocolatey</td>
|
||||
<td>Portable · EXE and MSI Installer · Chocolatey</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Linux</b> <sub>x64/ia32</sub></td>
|
||||
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
||||
<td>Portable · AUR · AppImage · Deb · Flatpak</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>macOS</b> <sub>x64/ia32, v12.0+</sub></td>
|
||||
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
||||
<td>Apple App Store · DMG · Homebrew</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -149,6 +149,10 @@ services:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
GUACD_HOST: "guacd"
|
||||
GUACD_TUNNEL_HOST: "termix"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
@@ -158,8 +162,8 @@ services:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
volumes:
|
||||
- termix-data:/termix-data
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"css": "src/ui/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
@@ -15,7 +15,7 @@
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"ui": "@/components",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
# Stage 1: Install dependencies
|
||||
# The builders only produce JS, so they run on the build machine's own arch
|
||||
# once instead of under QEMU per target. Rolldown hangs under arm64 emulation.
|
||||
FROM --platform=$BUILDPLATFORM node:26-slim AS deps
|
||||
FROM --platform=$BUILDPLATFORM node:24-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
@@ -37,7 +37,7 @@ COPY . .
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||
FROM node:26-slim AS opkssh-downloader
|
||||
FROM node:24-slim AS opkssh-downloader
|
||||
ARG TARGETARCH
|
||||
ARG OPKSSH_VERSION=v0.16.0
|
||||
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
|
||||
@@ -58,7 +58,7 @@ RUN case "$TARGETARCH" in \
|
||||
echo -n "$OPKSSH_VERSION" > version.txt
|
||||
|
||||
# Stage 5: Production dependencies only
|
||||
FROM node:26-slim AS production-deps
|
||||
FROM node:24-slim AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
@@ -81,7 +81,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final optimized image
|
||||
FROM node:26-slim
|
||||
FROM node:24-slim
|
||||
WORKDIR /app
|
||||
|
||||
# The release the opkssh-downloader stage baked in, so the plugin trusts that
|
||||
|
||||
+60
-46
@@ -4,152 +4,162 @@
|
||||
"id": "acme-ssl",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.0/acme-ssl-1.0.0.tmxplug",
|
||||
"sha256": "1725735a058065b211436b166c0ef7d4ca6e74438102764664381160a2b305d3"
|
||||
"sha256": "05b7b4c27897135437af6340c5c31501d2e96ac013dca0672940b74c76085433"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
|
||||
"sha256": "ea121658305bed9d28e046e096fc458976258d7cb77387e1b8ac82ccb9a001cb"
|
||||
"sha256": "ed7a64a4be5978238427d4abbd2153ef2b44c17ebfec78fcb5df579967d01eed"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
|
||||
"sha256": "93274529eb3445db9e1dddc630b7608b15250eb179bb7919a43c3e6707fa20b5",
|
||||
"sha256": "97e7be293ace897cd45615a8764e8395cf613546b86de82601ab49669dc36591",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
|
||||
"sha256": "6cb00cf9464e42dbc46786a6ef74b07d62da39ece9905197584d2934265b9b10"
|
||||
"sha256": "0972aef1c14917321c035f7497db81fe95f6b9e47d854b95b1c762444d9cb2a2"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
|
||||
"sha256": "d1d1dd441b51a97231d5da94e38a12b2d2b25db6da5e2b13b4a4b9486881ed2d",
|
||||
"sha256": "ca9b8cf573d27a71adb544fd6ff4d19278349f10fdca98f9cc63d7fec63bc441",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
|
||||
"sha256": "8d60f6299a38fcc69a17ca30abb0e2a168aa880c0cfb50d57f1dd37158d811a5",
|
||||
"sha256": "d1d92ae051c79c7ce6f05c7dbf33c96de9d8b58083eaccb104d1d21a6bd8b58f",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
|
||||
"sha256": "4a774d4a3685b0ee8da5b73d14c60d40f10b6a3f1c5b31a5dfefb94ba30fb7ad"
|
||||
"sha256": "410b19f3a6f47e2a292821b11876b8b5a0bdf779f8d852d5ac04a7501d0f12c0"
|
||||
},
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
|
||||
"sha256": "403513bc1f96a7c21d39d55c8fd1d4e55fde7b8e6ec63228839bf3243d93e68a"
|
||||
"sha256": "5652e378dbb208c7bb349ba2262034077f1cfca95b2d6d029f4186c70ed27999"
|
||||
},
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
|
||||
"sha256": "1cfc81f724418fd68306e3ac30daa529b03aa065740a866f6fbcaa67edb440ac",
|
||||
"sha256": "ca50e27f06bc7a275bd1bfb1deb5ac97f19023c84567105fe74e075602ec86da",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
|
||||
"sha256": "2d8cb269242c12522a5195fa44ec14e95ade3b00d7e7be12e9204631518a0f88"
|
||||
"sha256": "44d3f9e9be913b9791a084aff588d7d4217dd5cd3f61178006ed4fa2455cad23"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug",
|
||||
"sha256": "5c0a585dcffd1cd523e94d45f2eda32800c403a4c832854fec7331b651da7b7d"
|
||||
"sha256": "ba3db2a6bc87533c8273648549c466b95f2c7538700407b2d62e48f813526bac"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
|
||||
"sha256": "7e8a17aa9053954e937ac2274928e29b821dfd671759e356551b27c13e23c9ad"
|
||||
"sha256": "05884e2244b4cc7f7f0465fe4b775ff95a99183ce6aba60848b68f1f3ec4078f"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
|
||||
"sha256": "cb6149ab69e348e5a0a1939b31943cb64aded3e1ee930b91dc77c46a715990d0"
|
||||
"sha256": "3a304ba327352093f4bf5dff1a7d6fc981a83180ffc2e06fe1c63e59600e8e3c"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
|
||||
"sha256": "5201dc1ce4846ef9f509a4c609698e2b2970e31fbc152bb2fe5c39c72c5c6d39",
|
||||
"sha256": "2dd13c79066c99816f78b3c8aa5f3e31e17d3d2568f2e48575ef1e01f1195c28",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug",
|
||||
"sha256": "b6fbe38cd6d62d0b361c102f13f050ee8a281983dc806e32e8d8feeb416ceb20"
|
||||
"sha256": "fdab87ca97ea05c60d7008b22da4f56c4897abf11505b6fa8b5c7bd8bc1a53e3"
|
||||
},
|
||||
{
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
|
||||
"sha256": "c8c3ac8c916253c928195005773e558ee87555fb47b7b56f3e43c0df0fb852c4",
|
||||
"sha256": "a645144a11e591f9668f2e7b23b8423274006180862be9c5f3e59df361c27e5e",
|
||||
"onboarding": {
|
||||
"recommended": "desktop"
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "session-recording",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug",
|
||||
"sha256": "ff0e6948806e0127399d41a6abd0f41fce404e65ba2c9d8044fb6dced401f4bb"
|
||||
"sha256": "3a9ab3a3638d0cf13a4c7debbcd315d9a324b9991faea1c192ae1e8fda1e525b"
|
||||
},
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug",
|
||||
"sha256": "4d2e363086a8fe79815bf76b4ed359ac82d5926373ab24854028e4dbc5a57ac6"
|
||||
"sha256": "f838e53401f6d75923821797829806b9ca03f0dd64fe87f8524d14a2e05f684e"
|
||||
},
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
|
||||
"sha256": "0239e63df6d33cb38a1212935dae972402b174a7eab8b351084dab45196f1fe5",
|
||||
"sha256": "a434dd442c54fd7a124f4ebc9579a0af1aa4b07b1a90e3875f8ae72c35ced410",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
|
||||
"sha256": "66d2a4e87a2b5ac5551c74a4fad5808a6ee81d6e8f0a43c1f6456a12d6232714",
|
||||
"sha256": "64031970a7b6eb1c860aac79c1587a582ccd38b2ffcd5974b51a716240a8474d",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
|
||||
"sha256": "88d7fcf8eb3b0916d69bcc7349a58d98e37c493bfadc8b90f81b8aa5c845abc5",
|
||||
"sha256": "2045d1da03dcd47089cb6566a7f25fe021326cb556cbc36b01563a2c234d1230",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
"enabledByEnv": ["OIDC_CLIENT_ID"]
|
||||
}
|
||||
},
|
||||
@@ -157,19 +167,19 @@
|
||||
"id": "step-ca",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug",
|
||||
"sha256": "997af2068f88c017c76a057683ac5e367566b5b8bc0961fc1e29c32b702a9059"
|
||||
"sha256": "fc38ac6dcf8c43c550b7a7c65836a095ec5a8bae76c8797b5563b2c6c8e8dad1"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
|
||||
"sha256": "963a84859f8411bcb27e2950d46a7a43b239ace1295ac2abc4a77ecec43806d9"
|
||||
"sha256": "f7732afe584044258cc46205fee327223cdd290e1ba6e6858ddd22be6b3ef12d"
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
|
||||
"sha256": "9f973902320a01eed5fead9b31288face503a790bf68a2826176e48fb663c30c",
|
||||
"sha256": "103e2ef2d52993bf15fdf2cda0addfa4aeba5817b4340f28e3fdea634aba6af0",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
@@ -179,72 +189,76 @@
|
||||
"id": "termix-identity",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug",
|
||||
"sha256": "00dfe3da65f78d7d085817c93a4e01bfef6931fb70ff5576c76e2f641e337555"
|
||||
"sha256": "f1f712178b5d64ae263227640b61d1f5ea5a301eae3ff8adb8b52de5f20892fe"
|
||||
},
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug",
|
||||
"sha256": "bbc508410296ca3434b5ba8c40fa5ef87646d104ec6f75da3bf0e22313bbbf18"
|
||||
"sha256": "d6a98bcf99dbb31201d7079baf348100bca1c0eda0928aec4aeb075ea373a8d5"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
|
||||
"sha256": "c0b6b43d778dfd47af7bdbca099667bf46539de62936c3291947ac2425c02de9",
|
||||
"sha256": "aed42422ccc38c661f1aac2f7409f7872acf271552d6623b79bbcc43813d4fca",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
|
||||
"sha256": "82df362cfc820437db8c56ebd1e3140d7fcef677d98a5439d7e868486cc21ad4",
|
||||
"sha256": "215eae643d61d032bb6190bab7ed42140fd91c2165e2c4de079ff541e1522763",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vault",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
|
||||
"sha256": "d84b0ef1a309bb3a89288f488a242dd19df9e46b237df778c2f2a77e77867960"
|
||||
"sha256": "0bbd95002866807ddd469f102d09bff9cf1b39e005dec6b96530066c6c3b0d19"
|
||||
},
|
||||
{
|
||||
"id": "wake-on-lan",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug",
|
||||
"sha256": "e680c22279438a566d1033939e4a3063a0fbbeb7f57b6db3bd6712bed09a4388"
|
||||
"sha256": "39b97642e44e7b128df3632b10962bff6a26313f2e989e5acd29760cf91c4e04"
|
||||
},
|
||||
{
|
||||
"id": "warpgate",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
|
||||
"sha256": "c974be686488905277a52440b248d14370f3f543a7bd53f3485d5b6db4fab40b"
|
||||
"sha256": "a7b7ed6d39639d0d6050958417d33d646a0304f99dec4eb9da71a46e42ae80f1"
|
||||
},
|
||||
{
|
||||
"id": "web-endpoint",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug",
|
||||
"sha256": "e93d8535617783bc538d415f25a5acc04e3805417002d68d4ebc4044c8fbed8c"
|
||||
"sha256": "22f5f276f6dab85e9b211f8e990f82fcbf63ebb8fa90379463f64d0fa38905ab"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
|
||||
"sha256": "fba89362c503813a12a7b73c9aed6994a10a1d010db8e1237a1f901131eb8713",
|
||||
"sha256": "50994ae148b2a5e68cf924b922eb2fb22cf6215905049dd5b61a16e30a376a50",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
|
||||
"sha256": "31d4d2ba02b59f2c2189c0400eb22d07aa8ba72728cd73a4512995581cf3afcd",
|
||||
"sha256": "23c6d2b0845b0e854dc6dde0ab4b60b9a58af020099fd4f6fcc1463a48d9ee62",
|
||||
"onboarding": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -491,6 +491,13 @@
|
||||
"when": 1791388888997,
|
||||
"tag": "0069_plugin_channel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "5",
|
||||
"when": 1791550497926,
|
||||
"tag": "0070_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" DROP COLUMN "donation_modal_dismissed";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -491,6 +491,13 @@
|
||||
"when": 1791388888397,
|
||||
"tag": "0069_plugin_channel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "7",
|
||||
"when": 1791550496661,
|
||||
"tag": "0070_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -456,6 +456,13 @@
|
||||
"when": 1791388887785,
|
||||
"tag": "0064_plugin_channel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 65,
|
||||
"version": "6",
|
||||
"when": 1791550495357,
|
||||
"tag": "0065_drop_donation_modal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -123,7 +123,7 @@
|
||||
"entitlements": "packaging/build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
|
||||
"type": "distribution",
|
||||
"minimumSystemVersion": "10.15",
|
||||
"minimumSystemVersion": "12.0",
|
||||
"mergeASARs": false,
|
||||
"singleArchFiles": "**/*.{node,bare,dylib}",
|
||||
"x64ArchFiles": "**/{*.{node,bare,dylib},node-pty/prebuilds/*/spawn-helper}"
|
||||
|
||||
+2
-2
@@ -1041,7 +1041,7 @@ function getBackendPaths() {
|
||||
backendCwd: backendDir,
|
||||
};
|
||||
}
|
||||
// fork() does not go through Electron's asar redirector — use the unpacked path.
|
||||
// fork() does not go through Electron's asar redirector, use the unpacked path.
|
||||
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
|
||||
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
|
||||
const unpackedRoot = getUnpackedAppRoot(appRoot);
|
||||
@@ -1303,7 +1303,7 @@ function createTray() {
|
||||
try {
|
||||
const { nativeImage } = require("electron");
|
||||
|
||||
// Native APIs (Tray, nativeImage) can't load files from inside app.asar —
|
||||
// Native APIs (Tray, nativeImage) can't load files from inside app.asar,
|
||||
// use the unpacked path so the OS sees a real file.
|
||||
const publicRoot = isDev
|
||||
? path.join(appRoot, "public")
|
||||
|
||||
+3
-3
@@ -53,7 +53,7 @@ export default tseslint.config([
|
||||
},
|
||||
{
|
||||
// MySQL has no RETURNING clause, and drizzle's mysql-core does not expose
|
||||
// the method at all — a bare .returning() is a TypeError there, not a bad
|
||||
// the method at all. A bare .returning() is a TypeError there, not a bad
|
||||
// query, and it only fails on the engine no test in this repo runs against.
|
||||
//
|
||||
// 175 call sites were migrated off it. This is what stops number 176.
|
||||
@@ -85,14 +85,14 @@ export default tseslint.config([
|
||||
{
|
||||
// Postgres and SQLite spell it ON CONFLICT; MySQL spells it ON
|
||||
// DUPLICATE KEY and names no columns, so drizzle's mysql-core has no
|
||||
// onConflictDoUpdate at all — another TypeError, not a bad query.
|
||||
// onConflictDoUpdate at all, another TypeError, not a bad query.
|
||||
selector: "CallExpression[callee.property.name='onConflictDoUpdate']",
|
||||
message:
|
||||
"MySQL has no ON CONFLICT. Use upsert() from ./returning.js.",
|
||||
},
|
||||
{
|
||||
// better-sqlite3 puts these on a write result; node-postgres and
|
||||
// mysql2 do not, so reading them directly yields undefined — and
|
||||
// mysql2 do not, so reading them directly yields undefined, and
|
||||
// Number(undefined) is NaN, which reaches the database as the string
|
||||
// "NaN" and fails an integer column. Three call sites did exactly
|
||||
// this and only broke on Postgres.
|
||||
|
||||
-29
@@ -20,35 +20,6 @@
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<title>Termix</title>
|
||||
<style>
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skinny-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4a4a4a #1e1e21;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1e1e21;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #1e1e21;
|
||||
}
|
||||
|
||||
.toolbar-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4a4a4a transparent;
|
||||
|
||||
Generated
+13
-1
@@ -15015,7 +15015,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
@@ -15054,9 +15054,21 @@
|
||||
"drizzle-orm": {
|
||||
"optional": true
|
||||
},
|
||||
"i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"sonner": {
|
||||
"optional": true
|
||||
},
|
||||
"ssh2": {
|
||||
"optional": true
|
||||
},
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.5
|
||||
|
||||
### Fixed
|
||||
|
||||
- `execCommand` timeout errors no longer include the command, which could hold a sudo password
|
||||
- `termix-plugin validate` fails when a plugin ships migrations for some database engines but not all three
|
||||
- `termix-plugin migrations` loads the table definitions from a path with a `#` or other URL characters in it
|
||||
- Text, json and encrypted columns are `longtext` on MySQL in new migrations, so they are no longer capped at 64KB. Existing migrations keep `text`
|
||||
- `i18next`, `react-dom`, `react-i18next` and `sonner` are optional peer dependencies, so npm stops installing them into backend-only plugins
|
||||
- `termix-plugin validate` accepts a beta version like `1.1.0-beta.1` when the changelog's newest release is `1.1.0` or older, so beta releases pass
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -28,6 +28,7 @@ npm install --save-dev @termix-ssh/plugin-sdk
|
||||
|
||||
```bash
|
||||
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
|
||||
npx termix-plugin patch # apply the plugin's dependency patches (build does this too)
|
||||
npx termix-plugin test # run the plugin's Vitest suite
|
||||
npx termix-plugin validate # check manifest.json and the files it names
|
||||
npx termix-plugin openapi # write dist/openapi.json from @openapi comments
|
||||
|
||||
@@ -108,13 +108,6 @@ const HOST_DIR = path.join(
|
||||
const THEME_CSS = path.join(HOST_DIR, "theme.css");
|
||||
const CORE_CLASSES = path.join(HOST_DIR, "core-classes.json");
|
||||
|
||||
/**
|
||||
* Compiles the Tailwind classes the plugin's frontend uses into
|
||||
* dist/frontend.css, against the theme core ships in the SDK. Core only
|
||||
* compiles its own classes, so a class no core file happens to use would
|
||||
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
|
||||
* produced (a library's stylesheet the plugin imports).
|
||||
*/
|
||||
/**
|
||||
* Moves a plugin's Tailwind layers under their own names. Core orders
|
||||
* termix-plugin-utilities above its base styles and below its own utilities,
|
||||
@@ -136,6 +129,13 @@ function readCoreClasses() {
|
||||
return new Set(JSON.parse(fs.readFileSync(CORE_CLASSES, "utf8")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the Tailwind classes the plugin's frontend uses into
|
||||
* dist/frontend.css, against the theme core ships in the SDK. Core only
|
||||
* compiles its own classes, so a class no core file happens to use would
|
||||
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
|
||||
* produced (a library's stylesheet the plugin imports).
|
||||
*/
|
||||
export async function buildTailwind(cwd, outDir) {
|
||||
const { compile, optimize } = await import("@tailwindcss/node");
|
||||
const { Scanner } = await import("@tailwindcss/oxide");
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs";
|
||||
@@ -53,7 +53,7 @@ async function loadDefinitions(cwd) {
|
||||
external: BACKEND_EXTERNALS,
|
||||
});
|
||||
|
||||
const module = await import(`file://${outfile.split(path.sep).join("/")}`);
|
||||
const module = await import(pathToFileURL(outfile).href);
|
||||
const tables = module.tables ?? module.default;
|
||||
if (!Array.isArray(tables)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -218,6 +218,11 @@ async function validateMigrations(cwd, pluginId) {
|
||||
const present = DIALECTS.filter((dialect) =>
|
||||
fs.existsSync(path.join(root, dialect)),
|
||||
);
|
||||
for (const dialect of DIALECTS) {
|
||||
if (present.length > 0 && !present.includes(dialect)) {
|
||||
problems.push(`migrations/${dialect} is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
const byDialect = new Map();
|
||||
for (const dialect of present) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -148,6 +148,18 @@
|
||||
},
|
||||
"@testing-library/react": {
|
||||
"optional": true
|
||||
},
|
||||
"i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-i18next": {
|
||||
"optional": true
|
||||
},
|
||||
"sonner": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -194,7 +194,9 @@ export function parseChangelog(markdown: string): {
|
||||
|
||||
/**
|
||||
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
|
||||
* be that version, so a release never ships without its notes.
|
||||
* be that version, so a release never ships without its notes. A beta like
|
||||
* 1.1.0-beta.2 gets its notes from git, so it only needs the newest release
|
||||
* to be 1.1.0 or older.
|
||||
*/
|
||||
export function validateChangelog(
|
||||
markdown: string,
|
||||
@@ -206,7 +208,16 @@ export function validateChangelog(
|
||||
return problems;
|
||||
}
|
||||
const newest = changelog.releases[0].version;
|
||||
if (manifestVersion && newest !== manifestVersion) {
|
||||
const base = manifestVersion
|
||||
? semver.valid(semver.coerce(manifestVersion))
|
||||
: null;
|
||||
const betaOk =
|
||||
!!manifestVersion &&
|
||||
!!semver.prerelease(manifestVersion) &&
|
||||
!!base &&
|
||||
!!semver.valid(newest) &&
|
||||
semver.lte(newest, base);
|
||||
if (manifestVersion && newest !== manifestVersion && !betaOk) {
|
||||
problems.push(
|
||||
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ function builder(column: PluginColumn): Builder {
|
||||
export const id = () =>
|
||||
builder({ type: "id", primaryKey: true, notNull: true });
|
||||
|
||||
/** Unbounded string. Never indexable: MySQL cannot index TEXT. */
|
||||
/** Unbounded string, LONGTEXT on MySQL. Never indexable: MySQL cannot index TEXT. */
|
||||
export const text = () => builder({ type: "text" });
|
||||
|
||||
/** Bounded string. Indexable on every engine. */
|
||||
|
||||
@@ -80,11 +80,13 @@ function columnType(
|
||||
// though it was declared unbounded. defineTable refuses to index a
|
||||
// text/json column, so in practice this only catches a primary key.
|
||||
if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`;
|
||||
// MySQL TEXT stops at 64KB, so free text, json and secrets go long.
|
||||
if (dialect === "mysql" && column.type !== "timestamp") return "longtext";
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
/** Column types that are TEXT on MySQL when nothing keys them. */
|
||||
/** Column types that are TEXT or LONGTEXT on MySQL when nothing keys them. */
|
||||
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
|
||||
"text",
|
||||
"json",
|
||||
|
||||
@@ -32,7 +32,7 @@ export function execCommand(
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -7,8 +7,8 @@
|
||||
* drizzle/mysql. Nothing imports them to run a query.
|
||||
*
|
||||
* That is not an oversight. The query builder needs two things from a table
|
||||
* object — the identifiers to interpolate, and the encoders that turn JS values
|
||||
* into driver values — and the sqlite definitions supply both correctly for
|
||||
* object: the identifiers to interpolate, and the encoders that turn JS values
|
||||
* into driver values. The sqlite definitions supply both correctly for
|
||||
* every engine, which is why all 44 repositories import schema.ts directly:
|
||||
*
|
||||
* - text and integer encode as themselves everywhere
|
||||
@@ -17,15 +17,15 @@
|
||||
* which is true for JS `true` as well as for 1
|
||||
* - real is a plain number on all three
|
||||
*
|
||||
* What genuinely differs between the dialects is DDL — column types, key
|
||||
* lengths, autoincrement syntax — and DDL is exactly what these files exist to
|
||||
* What genuinely differs between the dialects is DDL (column types, key
|
||||
* lengths, autoincrement syntax), and DDL is exactly what these files exist to
|
||||
* generate. See scripts/verify-dialects.mjs, which asserts the round-trips
|
||||
* above against real servers rather than trusting this comment.
|
||||
*
|
||||
* The schema is declared once, in sqlite-core, and the other two dialects are
|
||||
* derived. Hand-maintaining three copies of 52 tables would mean a renamed
|
||||
* table has to land in three places consistently or a foreign key silently
|
||||
* points at the wrong one — and the schema is regular enough that the mapping
|
||||
* points at the wrong one, and the schema is regular enough that the mapping
|
||||
* is mechanical.
|
||||
*
|
||||
* What varies between dialects is small and closed:
|
||||
@@ -198,7 +198,7 @@ function transform(source, dialect) {
|
||||
);
|
||||
|
||||
// Timestamps are stored as text (see sql-timestamp.ts). MySQL only accepts
|
||||
// DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column — on a TEXT
|
||||
// DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column. On a TEXT
|
||||
// one it is ER_INVALID_DEFAULT, "Invalid default value". Since 8.0.13 an
|
||||
// expression default works on any type, and an expression is written
|
||||
// parenthesised. MariaDB accepts the bare form, which is why this only
|
||||
@@ -272,13 +272,13 @@ function transform(source, dialect) {
|
||||
}
|
||||
|
||||
function header(dialect) {
|
||||
return `// GENERATED FILE — do not edit.
|
||||
return `// GENERATED FILE, do not edit.
|
||||
//
|
||||
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
|
||||
// Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`.
|
||||
// Target dialect: ${dialect}.
|
||||
//
|
||||
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
|
||||
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
|
||||
// schema.ts on every dialect. See the generator header for why that is correct.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("mysql output", () => {
|
||||
expect(out).toContain('double("score")');
|
||||
});
|
||||
|
||||
it("makes key columns varchar — MySQL cannot index unbounded TEXT", () => {
|
||||
it("makes key columns varchar since MySQL cannot index unbounded TEXT", () => {
|
||||
expect(out).toContain('varchar("user_id", { length: 255 })');
|
||||
expect(out).toContain('text("name")');
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ await sharp(svgBuffer)
|
||||
.toFile(join(publicDir, "full-icon.png"));
|
||||
console.log(" ✓ full-icon.png");
|
||||
|
||||
// favicon.ico — embed 16, 32, 48 px layers
|
||||
// favicon.ico: embed 16, 32, 48 px layers
|
||||
console.log("Generating favicon.ico...");
|
||||
const icoSizes = [16, 32, 48];
|
||||
const icoBuffers = await Promise.all(
|
||||
@@ -55,7 +55,7 @@ const icoBuffers = await Promise.all(
|
||||
writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes));
|
||||
console.log(" ✓ favicon.ico");
|
||||
|
||||
// icon.ico — embed 16, 32, 48, 64, 128, 256 px layers
|
||||
// icon.ico: embed 16, 32, 48, 64, 128, 256 px layers
|
||||
console.log("Generating icon.ico...");
|
||||
const winSizes = [16, 32, 48, 64, 128, 256];
|
||||
const winBuffers = await Promise.all(
|
||||
|
||||
@@ -28,7 +28,7 @@ function fail(message) {
|
||||
function buildTable(version, mobileVersion, mobileTag) {
|
||||
const tag = `v${version}`;
|
||||
const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`;
|
||||
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `release-${mobileVersion}-tag`}`;
|
||||
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `v${mobileVersion}`}`;
|
||||
|
||||
const win = (file) => `${base}/${file}`;
|
||||
const linux = (file) => `${base}/${file}`;
|
||||
@@ -37,11 +37,11 @@ function buildTable(version, mobileVersion, mobileTag) {
|
||||
return [
|
||||
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
|
||||
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
|
||||
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | — | — |`,
|
||||
`| **AArch64 (ARM64)** | — | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
|
||||
`| **ARMv7 (32-bit)** | — | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | — | — | — |`,
|
||||
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | — | — | — | — |`,
|
||||
`| **Universal** | [Chocolatey](https://docs.termix.site/install/connector/windows) | [Flatpak](https://docs.termix.site/install/connector/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/connector/macos) | — | — |`,
|
||||
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
|
||||
`| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
|
||||
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
|
||||
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
|
||||
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("buildBody", () => {
|
||||
expect(body).not.toContain("A bug");
|
||||
expect(body).toContain("/releases/download/v26.10.0/termix_");
|
||||
expect(body).toContain(
|
||||
"Mobile/releases/download/release-1.2.0-tag/termix_android.apk",
|
||||
"Mobile/releases/download/v1.2.0/termix_android.apk",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -131,6 +131,17 @@ describe("validateChangelog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a beta through when its notes are not written yet", () => {
|
||||
expect(validateChangelog(FILE, "1.1.0-beta.1")).toEqual([]);
|
||||
expect(validateChangelog(FILE, "1.2.0-beta.3")).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a beta older than the newest release", () => {
|
||||
expect(validateChangelog(FILE, "1.0.1-beta.1")).toContain(
|
||||
"the newest release is 1.1.0, but the manifest version is 1.0.1-beta.1",
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a file with no releases", () => {
|
||||
expect(validateChangelog("# Changelog\n", "1.0.0")).toEqual([
|
||||
'no releases found, add a "## 1.0.0" heading',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { validate } from "../packages/plugin-sdk/cli/commands/validate.mjs";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const root of roots.splice(0))
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function plugin(dialects: string[]) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-validate-"));
|
||||
roots.push(root);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "manifest.json"),
|
||||
JSON.stringify({ id: "fixture", version: "1.0.0" }),
|
||||
);
|
||||
for (const dialect of dialects) {
|
||||
const dir = path.join(root, "migrations", dialect);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "0001_init.sql"),
|
||||
"CREATE TABLE p_fixture_items (id INTEGER PRIMARY KEY);\n",
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
async function problems(cwd: string) {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "error").mockImplementation((line) => lines.push(line));
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await expect(validate({ cwd })).rejects.toThrow();
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("termix-plugin validate migrations", () => {
|
||||
it("flags an engine with no migrations folder", async () => {
|
||||
const output = await problems(plugin(["sqlite", "postgres"]));
|
||||
expect(output).toContain("migrations/mysql is missing");
|
||||
expect(output).not.toContain("migrations/postgres is missing");
|
||||
});
|
||||
|
||||
it("accepts all three engines", async () => {
|
||||
const output = await problems(plugin(["sqlite", "postgres", "mysql"]));
|
||||
expect(output).not.toMatch(/migrations\/\w+ is missing/);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@
|
||||
* Runs the repository layer against a real Postgres or MySQL server.
|
||||
*
|
||||
* The unit tests only ever see SQLite, so the parts of this codebase that
|
||||
* differ per engine — the RETURNING replacements, the read-then-write
|
||||
* transactions, the value encoders — have no coverage there at all. This is
|
||||
* differ per engine (the RETURNING replacements, the read-then-write
|
||||
* transactions, the value encoders) have no coverage there at all. This is
|
||||
* what covers them, and it needs a live server, which is why it is a script
|
||||
* rather than a test.
|
||||
*
|
||||
@@ -12,7 +12,7 @@
|
||||
* npm run verify:dialect -- mysql://user:pass@host:3306/db
|
||||
*
|
||||
* Applies the migrations first, through the same runRemoteMigrations() the
|
||||
* application uses at startup — so a broken migration fails here rather than in
|
||||
* application uses at startup, so a broken migration fails here rather than in
|
||||
* production. Writes real rows: point it at a scratch database.
|
||||
*/
|
||||
|
||||
@@ -40,7 +40,7 @@ const { drizzle } = await import(
|
||||
dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2"
|
||||
);
|
||||
|
||||
// No schema option on purpose — see connect.ts.
|
||||
// No schema option on purpose, see connect.ts.
|
||||
const db = drizzle(url);
|
||||
const context = { dialect, drizzle: db };
|
||||
|
||||
@@ -87,7 +87,7 @@ check("insert returns the stored row", created?.username, "before");
|
||||
check("boolean true survives the round trip", created?.isAdmin, true);
|
||||
|
||||
// updateReturning must report the state AFTER the write. Reading first would
|
||||
// return the value the update replaced — silently, with no error.
|
||||
// return the value the update replaced, silently, with no error.
|
||||
const updated = await users.update(userId, { username: "after" });
|
||||
check("update returns the new value", updated?.username, "after");
|
||||
|
||||
|
||||
@@ -171,6 +171,27 @@ async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
|
||||
|
||||
type DeviceType = ReturnType<typeof parseUserAgent>["type"];
|
||||
|
||||
/**
|
||||
* An account that only signs in through SSO or LDAP must keep doing so. A
|
||||
* passkey or any other local method would skip the identity provider, so a
|
||||
* user disabled there could still get in.
|
||||
*/
|
||||
export function assertMethodAllowedFor(
|
||||
user: Pick<UserRecord, "isExternal" | "passwordHash">,
|
||||
identity: VerifiedIdentity,
|
||||
methodId: string,
|
||||
): void {
|
||||
if (identity.kind === "external" || !user.isExternal) return;
|
||||
if (user.passwordHash && user.passwordHash.trim() !== "") return;
|
||||
ensureCoreLoginProviders();
|
||||
if (getLoginMethod(methodId)?.external) return;
|
||||
throw new LoginMethodError(
|
||||
"This account signs in through its identity provider",
|
||||
403,
|
||||
"external_login_required",
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveUser(
|
||||
identity: VerifiedIdentity,
|
||||
deviceType: DeviceType,
|
||||
@@ -253,6 +274,7 @@ export async function runLogin(
|
||||
): Promise<LoginResult> {
|
||||
const deviceType = parseUserAgent(req).type;
|
||||
const user = await resolveUser(identity, deviceType);
|
||||
assertMethodAllowedFor(user, identity, context.methodId);
|
||||
|
||||
await unlockUser(user, identity, deviceType);
|
||||
if ("password" in identity && identity.password) {
|
||||
|
||||
@@ -268,7 +268,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
|
||||
* localVersion:
|
||||
* type: string
|
||||
* description: Version of the running instance. Use this field for client compatibility checks.
|
||||
* example: 2.7.1
|
||||
* example: 26.10.0
|
||||
* status:
|
||||
* type: string
|
||||
* description: Update comparison result, update_check_disabled when explicitly disabled, or unknown when the remote lookup fails or the release tag cannot be parsed.
|
||||
@@ -280,12 +280,12 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
|
||||
* remoteVersion:
|
||||
* type: string
|
||||
* description: Newest release version on the channel. Present only when the update check succeeds.
|
||||
* example: 2.8.0
|
||||
* example: 26.10.0
|
||||
* version:
|
||||
* type: string
|
||||
* deprecated: true
|
||||
* description: Legacy alias of remoteVersion, not the running instance's version. Present only when the update check succeeds. Use localVersion for the instance or remoteVersion for the latest release.
|
||||
* example: 2.8.0
|
||||
* example: 26.10.0
|
||||
* latest_release:
|
||||
* type: object
|
||||
* description: GitHub release metadata. Present only when the update check succeeds.
|
||||
|
||||
@@ -50,7 +50,7 @@ export function sslOption(
|
||||
/**
|
||||
* Opens a connection to a client-server engine.
|
||||
*
|
||||
* SQLite is not handled here — it has its own lifecycle in db/index.ts, where
|
||||
* SQLite is not handled here, it has its own lifecycle in db/index.ts, where
|
||||
* the database is decrypted into memory and serialised back to a file. This
|
||||
* covers the engines that connect to something already running.
|
||||
*
|
||||
@@ -109,7 +109,7 @@ export async function connectRemoteDatabase(
|
||||
|
||||
// No `schema` option: it only feeds drizzle's relational query API
|
||||
// (`db.query.*`), which nothing here uses. The query builder takes its table
|
||||
// names and value encoders from the table objects the repositories import —
|
||||
// names and value encoders from the table objects the repositories import,
|
||||
// see the note in schema.pg.ts on why the generated schemas are DDL-only.
|
||||
if (dialect === "postgres") {
|
||||
const { drizzle } = await import("drizzle-orm/node-postgres");
|
||||
@@ -119,7 +119,7 @@ export async function connectRemoteDatabase(
|
||||
}
|
||||
|
||||
// mysql2 names the pool limit differently and wants no `ssl` key at all when
|
||||
// TLS is off — passing false is not the same as omitting it.
|
||||
// TLS is off, passing false is not the same as omitting it.
|
||||
const { drizzle } = await import("drizzle-orm/mysql2");
|
||||
return drizzle({
|
||||
connection: {
|
||||
|
||||
@@ -42,7 +42,7 @@ export function resolveDatabaseDialect(
|
||||
*
|
||||
* SQLite here is an in-memory database serialised back to an encrypted file, so
|
||||
* every write needs a trigger to flush it. Client-server engines have already
|
||||
* durably committed by the time the query returns — there is no file to write
|
||||
* durably committed by the time the query returns, there is no file to write
|
||||
* and nothing to schedule.
|
||||
*/
|
||||
export function needsExplicitPersist(dialect: DatabaseDialect): boolean {
|
||||
|
||||
@@ -242,8 +242,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -970,9 +969,7 @@ const migrateSchema = () => {
|
||||
// CURRENT_TIMESTAMP, so the column is added empty and backfilled below.
|
||||
addColumnIfNotExists("users", "registered_at", "TEXT");
|
||||
if (!hadRegisteredAtColumn) {
|
||||
// Pre-existing users are backdated past the 30 day mark so they see the
|
||||
// donation modal immediately on upgrade instead of waiting a fresh
|
||||
// 30 days as if they had just registered.
|
||||
// Pre-existing users get a date in the past, since their real one is unknown.
|
||||
try {
|
||||
sqlite.exec(
|
||||
`UPDATE users SET registered_at = datetime('now', '-31 days') WHERE registered_at IS NULL`,
|
||||
@@ -1000,12 +997,6 @@ const migrateSchema = () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
addColumnIfNotExists(
|
||||
"users",
|
||||
"donation_modal_dismissed",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
@@ -1103,7 +1094,7 @@ const migrateSchema = () => {
|
||||
// Derive the replacement table from the live definition instead of
|
||||
// restating it here. The table keeps gaining columns (cert_public_key,
|
||||
// pin, sort_order, sync_id, ...), and a second copy of the column list
|
||||
// falls behind every time one is added — leaving the copy narrower than
|
||||
// falls behind every time one is added, leaving the copy narrower than
|
||||
// the table, so the INSERT below fails and the constraint stays put.
|
||||
const createSql = sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'")
|
||||
@@ -2108,7 +2099,7 @@ export function getSqlite(): Database.Database {
|
||||
if (dialect !== "sqlite") {
|
||||
throw new Error(
|
||||
`No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` +
|
||||
`synchronous query, which only SQLite offers — give it an async path instead.`,
|
||||
`synchronous query, which only SQLite offers. Give it an async path instead.`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
|
||||
@@ -25,7 +25,7 @@ export function migrationsFolder(
|
||||
* Brings a remote database up to the current schema.
|
||||
*
|
||||
* drizzle's migrator records what it has applied in its own table, so this is
|
||||
* safe to run on every start — including against a database another instance
|
||||
* safe to run on every start, including against a database another instance
|
||||
* already migrated.
|
||||
*/
|
||||
export async function runRemoteMigrations(
|
||||
|
||||
@@ -77,6 +77,7 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
"identifier_path",
|
||||
"name_path",
|
||||
"scopes",
|
||||
"donation_modal_dismissed",
|
||||
],
|
||||
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
||||
user_preferences: [
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// GENERATED FILE — do not edit.
|
||||
// GENERATED FILE, do not edit.
|
||||
//
|
||||
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
|
||||
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
|
||||
// Target dialect: mysql.
|
||||
//
|
||||
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
|
||||
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
|
||||
// schema.ts on every dialect. See the generator header for why that is correct.
|
||||
|
||||
import {
|
||||
@@ -27,9 +27,6 @@ export const users = mysqlTable("users", {
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const settings = mysqlTable("settings", {
|
||||
@@ -542,7 +539,7 @@ export const sharedHostSecrets = mysqlTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
(table) => [
|
||||
uniqueIndex("idx_shared_host_secrets_scope").on(
|
||||
table.hostAccessId,
|
||||
@@ -592,7 +589,7 @@ export const userRoles = mysqlTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
//
|
||||
// The unique pair already serves lookups by user, since user_id leads it.
|
||||
// Listing a role's members starts from role_id, which it cannot serve.
|
||||
@@ -1060,7 +1057,7 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves — kept separate from `plugins` so
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = mysqlTable(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// GENERATED FILE — do not edit.
|
||||
// GENERATED FILE, do not edit.
|
||||
//
|
||||
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
|
||||
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
|
||||
// Target dialect: postgres.
|
||||
//
|
||||
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
|
||||
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
|
||||
// schema.ts on every dialect. See the generator header for why that is correct.
|
||||
|
||||
import {
|
||||
@@ -27,9 +27,6 @@ export const users = pgTable("users", {
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const settings = pgTable("settings", {
|
||||
@@ -542,7 +539,7 @@ export const sharedHostSecrets = pgTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
(table) => [
|
||||
uniqueIndex("idx_shared_host_secrets_scope").on(
|
||||
table.hostAccessId,
|
||||
@@ -592,7 +589,7 @@ export const userRoles = pgTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
//
|
||||
// The unique pair already serves lookups by user, since user_id leads it.
|
||||
// Listing a role's members starts from role_id, which it cannot serve.
|
||||
@@ -1060,7 +1057,7 @@ export const pluginRegistries = pgTable("plugin_registries", {
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves — kept separate from `plugins` so
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = pgTable(
|
||||
|
||||
@@ -15,11 +15,6 @@ export const users = sqliteTable("users", {
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: integer("donation_modal_dismissed", {
|
||||
mode: "boolean",
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
@@ -534,7 +529,7 @@ export const sharedHostSecrets = sqliteTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
(table) => [
|
||||
uniqueIndex("idx_shared_host_secrets_scope").on(
|
||||
table.hostAccessId,
|
||||
@@ -584,7 +579,7 @@ export const userRoles = sqliteTable(
|
||||
},
|
||||
// Declared inline in the production DDL as UNIQUE(...), but never here,
|
||||
// so the generated Postgres and MySQL schemas allowed duplicates the
|
||||
// SQLite deployment forbids — and the upsert had nothing to conflict on.
|
||||
// SQLite deployment forbids, and the upsert had nothing to conflict on.
|
||||
//
|
||||
// The unique pair already serves lookups by user, since user_id leads it.
|
||||
// Listing a role's members starts from role_id, which it cannot serve.
|
||||
@@ -1058,7 +1053,7 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
|
||||
/**
|
||||
* Install counts populated by a background job (GitHub release download
|
||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
||||
* install/uninstall actions themselves — kept separate from `plugins` so
|
||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
||||
* that job can overwrite counts without touching install state.
|
||||
*/
|
||||
export const pluginInstallCounts = sqliteTable(
|
||||
|
||||
@@ -74,7 +74,7 @@ export class AuditLogRepository {
|
||||
/**
|
||||
* Cached row count backing the cap check. Static because the factory builds
|
||||
* a repository per call, so a per-instance count would never survive to be
|
||||
* reused. Null means "unknown, re-read" — which is also how any path that
|
||||
* reused. Null means "unknown, re-read", which is also how any path that
|
||||
* deletes rows invalidates it.
|
||||
*/
|
||||
private static cachedCount: number | null = null;
|
||||
@@ -233,13 +233,13 @@ export class AuditLogRepository {
|
||||
*
|
||||
* Both passes used to run inline on every insert: a retention DELETE plus a
|
||||
* COUNT over the whole table, thousands of times an hour on a busy install,
|
||||
* almost always to find nothing to do — and audit writes sit in the request
|
||||
* almost always to find nothing to do, and audit writes sit in the request
|
||||
* path of the actions they record.
|
||||
*
|
||||
* They are split by what they cost and what they guarantee. Retention is
|
||||
* time-based, so nothing is lost by checking it on an interval. The row cap
|
||||
* is a disk-space guard that has to react to inserts, so it is still checked
|
||||
* on the write that crosses it — but against a cached count, so the common
|
||||
* on the write that crosses it, but against a cached count, so the common
|
||||
* case is an integer compare rather than a COUNT.
|
||||
*/
|
||||
private async pruneIfDue(): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||
import type * as schema from "../db/schema.js";
|
||||
|
||||
// Re-exported so repositories can keep importing it from here, but defined in
|
||||
// db/dialect.ts — a local copy that said "sqlite" survived here for a while and
|
||||
// db/dialect.ts, a local copy that said "sqlite" survived here for a while and
|
||||
// typed every context as SQLite-only while the runtime already carried all
|
||||
// three, which silently made the dialect branches unreachable to the checker.
|
||||
export type { DatabaseDialect } from "../db/dialect.js";
|
||||
@@ -18,7 +18,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
|
||||
*
|
||||
* This is a deliberate approximation, not an accident. The query-builder
|
||||
* surface the repositories actually use is the same on all three engines, and
|
||||
* that equivalence is asserted in multi-dialect.test.ts rather than assumed —
|
||||
* that equivalence is asserted in multi-dialect.test.ts rather than assumed,
|
||||
* identifier quoting, placeholder style and value coercion are all covered
|
||||
* there. At runtime this may hold a Postgres or MySQL instance.
|
||||
*
|
||||
@@ -32,7 +32,7 @@ export type PortableDatabase = BetterSQLite3Database<typeof schema>;
|
||||
*
|
||||
* Deliberately drizzle-only: with no raw driver handle here, no repository can
|
||||
* reach for engine-specific SQL. Retention queries that previously needed
|
||||
* `datetime('now', ?)` compute their cutoff in JS instead — see
|
||||
* `datetime('now', ?)` compute their cutoff in JS instead, see
|
||||
* ./sql-timestamp.ts.
|
||||
*/
|
||||
export interface DatabaseContext {
|
||||
|
||||
@@ -49,7 +49,7 @@ import { UserRepository } from "./user-repository.js";
|
||||
* write.
|
||||
*
|
||||
* Both cross-dialect harnesses build a DatabaseContext themselves, so neither
|
||||
* exercises this function — see tests/database/repositories/factory-context.
|
||||
* exercises this function, see tests/database/repositories/factory-context.
|
||||
*/
|
||||
export function createCurrentRepositoryContext(): DatabaseContext {
|
||||
return {
|
||||
@@ -64,7 +64,7 @@ export function createCurrentRepositoryContext(): DatabaseContext {
|
||||
* Only meaningful for SQLite, where the database lives in memory and has to be
|
||||
* serialised back to its encrypted file. On Postgres and MySQL the write is
|
||||
* already durable, so no hook is installed at all rather than one that does
|
||||
* nothing — repositories call it as `this.onWrite?.()`.
|
||||
* nothing, repositories call it as `this.onWrite?.()`.
|
||||
*/
|
||||
export function createCurrentRepositoryWriteHook(
|
||||
reason: string,
|
||||
@@ -91,7 +91,7 @@ export function createCurrentRepositoryLazyWriteHook(
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw driver handle for the few synchronous call sites that cannot await —
|
||||
* Raw driver handle for the few synchronous call sites that cannot await,
|
||||
* getCurrentSettingValue below, and settings reads during startup. Repositories
|
||||
* must not use this: they take a DatabaseContext, which is drizzle-only.
|
||||
* Porting to another engine means giving these callers an async path first.
|
||||
@@ -403,8 +403,8 @@ let refreshTimer: NodeJS.Timeout | null = null;
|
||||
*
|
||||
* The cache is per-process and updated in the process that writes. That is
|
||||
* enough for SQLite, where there is only ever one process. On Postgres and
|
||||
* MySQL — which exist here precisely so more than one instance can share the
|
||||
* data — a setting changed on one replica would otherwise never reach the
|
||||
* MySQL, which exist here precisely so more than one instance can share the
|
||||
* data, a setting changed on one replica would otherwise never reach the
|
||||
* others, because the synchronous read has no way to go back to the database.
|
||||
*
|
||||
* Periodic re-priming does not make the value immediately consistent. It bounds
|
||||
|
||||
@@ -31,7 +31,7 @@ export class HostFolderRepository {
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RenameFolderResult> {
|
||||
// CAST target: every engine spells the text type differently enough to
|
||||
// matter here — MySQL has no `text` cast and wants `char`.
|
||||
// matter here, MySQL has no `text` cast and wants `char`.
|
||||
const textType = this.context.dialect === "mysql" ? "char" : "text";
|
||||
const oldPrefix = `${oldName} / `;
|
||||
const newPrefix = `${newName} / `;
|
||||
|
||||
@@ -4,18 +4,18 @@ import type { DatabaseDialect } from "../db/dialect.js";
|
||||
* Reading the outcome of a write without depending on RETURNING.
|
||||
*
|
||||
* SQLite and Postgres can attach `.returning()` to a delete or update and get
|
||||
* the affected rows back. **MySQL cannot** — it has no RETURNING clause, and
|
||||
* the affected rows back. **MySQL cannot**: it has no RETURNING clause, and
|
||||
* drizzle's mysql-core does not expose the method at all, so the call is a
|
||||
* TypeError rather than a bad query. 175 call sites here read a write's result,
|
||||
* so the difference has to be absorbed somewhere.
|
||||
*
|
||||
* The split that matters is what the caller actually needs:
|
||||
*
|
||||
* - **How many rows changed** — the majority, and none of them need the rows.
|
||||
* - **How many rows changed**: the majority, and none of them need the rows.
|
||||
* They used to ask for them anyway, via `.returning().length`. Dropping the
|
||||
* `.returning()` and reading the driver's own count is both portable and one
|
||||
* less thing for the database to send back.
|
||||
* - **The rows themselves** — cannot be emulated on MySQL without reading
|
||||
* - **The rows themselves**: cannot be emulated on MySQL without reading
|
||||
* first, which needs a transaction to stay correct under concurrency. Those
|
||||
* call sites are handled individually rather than behind a helper that hides
|
||||
* an extra round trip.
|
||||
@@ -33,7 +33,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
|
||||
* | node-postgres | `{ rowCount, rows, command }` |
|
||||
* | mysql2 | `[{ affectedRows, insertId }, fields]` |
|
||||
*
|
||||
* These are the shapes returned when NO `.returning()` is attached — which is
|
||||
* These are the shapes returned when NO `.returning()` is attached, which is
|
||||
* the portable way to write, since MySQL has no RETURNING clause at all.
|
||||
*/
|
||||
interface WriteHeader {
|
||||
@@ -47,7 +47,7 @@ interface WriteHeader {
|
||||
const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const;
|
||||
|
||||
/**
|
||||
* mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array — so
|
||||
* mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array, so
|
||||
* "is it an array" cannot distinguish a write header from a returning() result.
|
||||
* The header is identified by carrying one of the fields above instead.
|
||||
*/
|
||||
@@ -70,7 +70,7 @@ function asWriteHeader(result: unknown): WriteHeader | null {
|
||||
/**
|
||||
* Number of rows a write touched.
|
||||
*
|
||||
* Pass the result of the write itself — every driver's header is understood, so
|
||||
* Pass the result of the write itself, every driver's header is understood, so
|
||||
* the caller neither branches on the dialect nor attaches `.returning()` just to
|
||||
* count what came back.
|
||||
*
|
||||
@@ -97,7 +97,7 @@ export function rowsAffected(result: unknown): number {
|
||||
*
|
||||
* **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and
|
||||
* MySQL's `insertId` are connection-level values that survive the statement that
|
||||
* set them — after a delete, SQLite still reports whatever the last insert
|
||||
* set them, after a delete, SQLite still reports whatever the last insert
|
||||
* produced. Passing an update or delete result here gets a stale id, not null.
|
||||
*
|
||||
* Returns null when the table has no autoincrement key.
|
||||
@@ -143,7 +143,7 @@ export function supportsReturning(dialect: DatabaseDialect): boolean {
|
||||
*
|
||||
* `sql<number>` is a type assertion, not a conversion. Postgres returns COUNT()
|
||||
* as bigint, which node-postgres hands back as a **string** so that values past
|
||||
* 2^53 survive — so the annotation is a lie there and comparisons like
|
||||
* 2^53 survive, so the annotation is a lie there and comparisons like
|
||||
* `count < max` compare a string to a number.
|
||||
*/
|
||||
export function countValue(value: unknown): number {
|
||||
|
||||
@@ -11,15 +11,15 @@ import {
|
||||
* Writes that need the affected rows back.
|
||||
*
|
||||
* `mutation-result.ts` covers the call sites that only wanted a count. These are
|
||||
* the ones that genuinely read the rows — an updated record to return to the
|
||||
* the ones that genuinely read the rows, an updated record to return to the
|
||||
* caller, a deleted row's fields to clean up alongside it.
|
||||
*
|
||||
* SQLite and Postgres do this in one statement with RETURNING. MySQL has no
|
||||
* such clause, so the read is a second statement, and the pair has to be atomic:
|
||||
*
|
||||
* - **insert** — write, then read the row back by its key.
|
||||
* - **update** — write, then read. Reading first would return the old values.
|
||||
* - **delete** — read, then write. Reading after would return nothing.
|
||||
* - **insert**: write, then read the row back by its key.
|
||||
* - **update**: write, then read. Reading first would return the old values.
|
||||
* - **delete**: read, then write. Reading after would return nothing.
|
||||
*
|
||||
* Both run in a transaction. Without one, a concurrent write between the two
|
||||
* statements makes the returned rows describe a state that never existed, and
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
* ## The trap, and why it cannot bite silently
|
||||
*
|
||||
* On MySQL the update path re-reads using the same `where`. If the update
|
||||
* changes a column that `where` tests, the read finds nothing — SQLite would
|
||||
* changes a column that `where` tests, the read finds nothing, SQLite would
|
||||
* have returned the row. Every current caller filters on an id it does not
|
||||
* modify, but that is a convention, not a guarantee, so the mismatch is
|
||||
* detected and thrown rather than returned as an empty array. Same for an
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
*/
|
||||
|
||||
/**
|
||||
* What `.set()` accepts: a column's own type, or a SQL expression in its place —
|
||||
* What `.set()` accepts: a column's own type, or a SQL expression in its place,
|
||||
* `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here.
|
||||
*/
|
||||
type UpdateValues<T extends SQLiteTable> = {
|
||||
@@ -68,7 +68,7 @@ export async function updateReturning<T extends SQLiteTable>(
|
||||
const rows = await tx.select().from(table).where(where);
|
||||
|
||||
// The trap this catches: if the update changed a column that `where` tests,
|
||||
// the read finds nothing and the caller gets [] — on MySQL only, with no
|
||||
// the read finds nothing and the caller gets [], on MySQL only, with no
|
||||
// error, where SQLite would have returned the row. Rows changed but none
|
||||
// readable back is exactly that case, so make it loud instead.
|
||||
if (rows.length === 0 && rowsAffected(written) > 0) {
|
||||
@@ -108,10 +108,10 @@ type Keyed = SQLiteTable & { id: SQLiteColumn };
|
||||
|
||||
/**
|
||||
* Inserts one row and returns it as stored, including whatever the database
|
||||
* filled in — defaults, an autoincrement id, a CURRENT_TIMESTAMP.
|
||||
* filled in, defaults, an autoincrement id, a CURRENT_TIMESTAMP.
|
||||
*
|
||||
* This is the one case Postgres cannot shortcut either: without RETURNING there
|
||||
* is no id to read back by. Hence the split is genuinely three-way — except
|
||||
* is no id to read back by. Hence the split is genuinely three-way, except
|
||||
* that sqlite and pg both have RETURNING, so it collapses to two again.
|
||||
*
|
||||
* On MySQL the key comes from one of two places:
|
||||
@@ -161,7 +161,7 @@ export async function insertReturning<T extends Keyed>(
|
||||
* back by an explicit condition.
|
||||
*
|
||||
* `user_preferences` is keyed by `userId` and has no `id` column at all, so
|
||||
* there is no insertId to read back by — the caller has to say what identifies
|
||||
* there is no insertId to read back by, the caller has to say what identifies
|
||||
* the row it just wrote.
|
||||
*/
|
||||
export async function insertReturningWhere<T extends SQLiteTable>(
|
||||
@@ -195,13 +195,13 @@ export async function insertReturningWhere<T extends SQLiteTable>(
|
||||
*
|
||||
* The clause has three spellings. SQLite and Postgres take
|
||||
* `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and
|
||||
* names no columns** — it uses whichever unique key was violated. drizzle
|
||||
* names no columns**: it uses whichever unique key was violated. drizzle
|
||||
* follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and
|
||||
* calling it is a TypeError rather than a rejected query.
|
||||
*
|
||||
* The conflict target still has to be passed: it is what SQLite and Postgres
|
||||
* need, and stating it keeps the caller honest about which unique constraint it
|
||||
* is relying on — four of those were missing from the schema entirely until the
|
||||
* is relying on, four of those were missing from the schema entirely until the
|
||||
* cross-dialect tests went looking.
|
||||
*/
|
||||
export async function upsert<T extends SQLiteTable>(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Synchronous read-through cache for the settings table.
|
||||
*
|
||||
* 27 call sites read settings synchronously — during startup, inside request
|
||||
* 27 call sites read settings synchronously, during startup, inside request
|
||||
* handlers, and from the guacd server bootstrap. On SQLite that works because
|
||||
* better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous
|
||||
* query at all, and making all 27 async would push `await` through code paths
|
||||
@@ -35,7 +35,7 @@ export function primeSettingsCache(
|
||||
/**
|
||||
* Reads a cached setting.
|
||||
*
|
||||
* Returns null both for "not set" and "cache not primed yet" — every caller
|
||||
* Returns null both for "not set" and "cache not primed yet", every caller
|
||||
* already treats a missing setting as "use the default", and startup ordering
|
||||
* means a read before priming should behave the same way rather than throw.
|
||||
*/
|
||||
|
||||
@@ -141,8 +141,8 @@ export class UserRepository {
|
||||
* at once would otherwise both see an empty table and both become admin.
|
||||
*
|
||||
* The two branches are not a style choice. better-sqlite3 is synchronous and
|
||||
* rejects an async transaction callback outright — "Transaction function
|
||||
* cannot return a promise" — so a single body cannot serve both. It fails
|
||||
* rejects an async transaction callback outright, "Transaction function
|
||||
* cannot return a promise", so a single body cannot serve both. It fails
|
||||
* loudly rather than silently skipping the write, which is the one mercy here.
|
||||
*/
|
||||
private async createCheckingIfFirst(
|
||||
|
||||
@@ -17,7 +17,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
||||
* /credential-sidebar/preferences:
|
||||
* get:
|
||||
* summary: Get the credential sidebar preferences for the current user
|
||||
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here — credentials never had exploded preference columns on userPreferences — so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
|
||||
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here, credentials never had exploded preference columns on userPreferences, so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
|
||||
* tags:
|
||||
* - Credential Sidebar
|
||||
* responses:
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
const DONATION_MODAL_DELAY_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function shouldShowDonationModal(
|
||||
registeredAt: string,
|
||||
donationModalDismissed: boolean,
|
||||
now: number = Date.now(),
|
||||
): boolean {
|
||||
if (donationModalDismissed) return false;
|
||||
const registeredAtMs = Date.parse(registeredAt);
|
||||
if (Number.isNaN(registeredAtMs)) return false;
|
||||
return now - registeredAtMs >= DONATION_MODAL_DELAY_MS;
|
||||
}
|
||||
@@ -9,6 +9,14 @@ const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
const DEFAULT_TAB_TTL_MINUTES = 30;
|
||||
|
||||
// Tabs live as long as the terminal keeps a detached session.
|
||||
function getTabTtlMs(): number {
|
||||
const minutes = liveTerminalSessions.idleTimeoutMinutes();
|
||||
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs:
|
||||
@@ -20,14 +28,6 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
||||
* 200:
|
||||
* description: List of open tabs ordered by tab_order.
|
||||
*/
|
||||
const DEFAULT_TAB_TTL_MINUTES = 30;
|
||||
|
||||
// Tabs live as long as the terminal keeps a detached session.
|
||||
function getTabTtlMs(): number {
|
||||
const minutes = liveTerminalSessions.idleTimeoutMinutes();
|
||||
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
|
||||
}
|
||||
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import { PluginManageError } from "../../plugins/manage.js";
|
||||
import { MAX_UPLOAD_BYTES, PluginManageError } from "../../plugins/manage.js";
|
||||
import { parsePluginChannel } from "../../plugins/registry-index.js";
|
||||
import {
|
||||
pluginDocsUrl,
|
||||
@@ -2262,7 +2262,10 @@ router.post(
|
||||
"/upload",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
express.raw({ type: "application/octet-stream", limit: "50mb" }),
|
||||
express.raw({
|
||||
type: "application/octet-stream",
|
||||
limit: MAX_UPLOAD_BYTES,
|
||||
}),
|
||||
async (req: Request, res: Response) => {
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
isNativeTokenExportRequest,
|
||||
resolveDesktopAutoSessionUser,
|
||||
} from "./desktop-auto-session.js";
|
||||
import { shouldShowDonationModal } from "./donation-modal-utils.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||
import { registerBrandingRoutes } from "./branding-routes.js";
|
||||
@@ -595,11 +594,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
|
||||
const isDualAuth = hasPassword && isExternalAccount(user);
|
||||
|
||||
const showDonationModal = shouldShowDonationModal(
|
||||
user.registeredAt,
|
||||
!!user.donationModalDismissed,
|
||||
);
|
||||
|
||||
res.json({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
@@ -610,7 +604,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
user.id,
|
||||
),
|
||||
show_donation_modal: showDonationModal,
|
||||
linked: await describeDesktopLink(user.id),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -619,48 +612,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/me/dismiss-donation-modal:
|
||||
* post:
|
||||
* summary: Permanently dismiss the donation reminder modal
|
||||
* description: Marks the donation reminder modal as dismissed for the currently authenticated user so it is never shown to them again.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Donation modal dismissed.
|
||||
* 401:
|
||||
* description: Invalid userId or user not found.
|
||||
* 500:
|
||||
* description: Failed to dismiss donation modal.
|
||||
*/
|
||||
router.post(
|
||||
"/me/dismiss-donation-modal",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(401).json({ error: "Invalid userId" });
|
||||
}
|
||||
try {
|
||||
const updated = await createCurrentUserRepository().update(userId, {
|
||||
donationModalDismissed: true,
|
||||
});
|
||||
if (!updated) {
|
||||
return res.status(401).json({ error: "User not found" });
|
||||
}
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to dismiss donation modal", err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to dismiss donation modal" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/me/token:
|
||||
|
||||
@@ -16,7 +16,7 @@ export function normalizeHostAddress(value: unknown): string {
|
||||
* A client addresses a host by the numeric row id of the database it is
|
||||
* displaying. When the desktop app delegates a connection to a remote sync
|
||||
* server, the id is resolved against *that* server's table instead, and
|
||||
* autoincrement ids need not line up between the two — they diverge as soon as
|
||||
* autoincrement ids need not line up between the two, they diverge as soon as
|
||||
* the sides accumulate inserts and deletes in a different order.
|
||||
*
|
||||
* The resolved row then supplies the address, the credentials, the jump hosts
|
||||
|
||||
@@ -25,7 +25,7 @@ const sshLogger = logger;
|
||||
* `id` is an autoincrement belonging to whichever database produced the row.
|
||||
* When the desktop app delegates a connection to a sync server, the two
|
||||
* sequences have no reason to agree, and resolving the client's id here lands
|
||||
* on whatever host happens to own that number — a different machine, with its
|
||||
* on whatever host happens to own that number, a different machine, with its
|
||||
* own address, credentials and host key. `syncId` is the same string on both
|
||||
* sides, so it names the host the user actually picked.
|
||||
*
|
||||
|
||||
@@ -8,10 +8,7 @@
|
||||
* needs no core change.
|
||||
*/
|
||||
|
||||
import {
|
||||
createCurrentHostProtocolAuthRepository,
|
||||
createCurrentSharedHostAuthOverrideRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js";
|
||||
import {
|
||||
decryptProtocolLogin,
|
||||
type HostProtocolAuthRecord,
|
||||
@@ -605,17 +602,6 @@ export async function resolveRecipientProtocolLogin(
|
||||
};
|
||||
}
|
||||
|
||||
/** Credential ids a recipient chose for this host, per protocol. */
|
||||
export async function listRecipientOverrideIds(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<Record<string, number>> {
|
||||
return (await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
|
||||
hostId,
|
||||
userId,
|
||||
)) as Record<string, number>;
|
||||
}
|
||||
|
||||
/** A login in an export or on the sync wire: plaintext, keyed by protocol. */
|
||||
export interface PortableProtocolLogin {
|
||||
authType: string;
|
||||
|
||||
@@ -169,7 +169,7 @@ export class SSHConnectionPool {
|
||||
if (available) {
|
||||
if (!this.isConnectionHealthy(available.client)) {
|
||||
connections = this.removeUnhealthy(key, connections, available);
|
||||
// Fall through — may create or wait again.
|
||||
// Fall through, may create or wait again.
|
||||
} else {
|
||||
const waiter = queue.shift()!;
|
||||
if (queue.length === 0) this.waiters.delete(key);
|
||||
|
||||
@@ -61,8 +61,3 @@ export function startPluginAutoUpdate(): void {
|
||||
timer = setInterval(run, CHECK_EVERY_MS);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function stopPluginAutoUpdate(): void {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
@@ -43,29 +42,21 @@ export function getUnpackedPluginsDir(): string {
|
||||
* plugin is part of the install and must not be deletable or shadowable by
|
||||
* whatever happens to be in the data directory.
|
||||
*
|
||||
* Resolved from this module's location rather than cwd, because the build
|
||||
* emits to dist/backend/backend/plugins/ and the bundled plugins sit beside
|
||||
* dist/backend.
|
||||
* Resolved from this module's location rather than cwd. The build emits to
|
||||
* dist/backend/backend/plugins/ with the bundled plugins beside dist/backend,
|
||||
* and a run from source reads the same dist/plugins.
|
||||
*/
|
||||
export function getBundledPluginsDir(): string {
|
||||
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
if (override) return override;
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const candidates = [
|
||||
// Built server: dist/backend/backend/plugins -> dist/plugins
|
||||
// Source tree: src/backend/plugins -> plugins
|
||||
// Both are three levels up, which is why there is one entry, not two.
|
||||
path.resolve(here, "../../../plugins"),
|
||||
// Built server invoked from a nested layout: .../dist/backend/backend/plugins
|
||||
path.resolve(here, "../../../../plugins"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return candidates[0];
|
||||
// Built: dist/backend/backend/plugins -> dist/plugins
|
||||
// Source (vitest, tsx): src/backend/plugins -> dist/plugins
|
||||
const fromSource = path.basename(path.resolve(here, "../..")) === "src";
|
||||
return fromSource
|
||||
? path.resolve(here, "../../../dist/plugins")
|
||||
: path.resolve(here, "../../../plugins");
|
||||
}
|
||||
|
||||
/** The backend entry, from the manifest's own `backend` field. */
|
||||
|
||||
@@ -371,10 +371,6 @@ function disposeRoute(routeKey: string, pluginId: string, path: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function getRegisteredWsRoutes(): string[] {
|
||||
return [...routes.keys()];
|
||||
}
|
||||
|
||||
/** The socket paths a plugin serves without core's login check. */
|
||||
export function getPluginPublicWsRoutes(pluginId: string): string[] {
|
||||
return [...routes.values()]
|
||||
|
||||
@@ -16,10 +16,12 @@ import { sendCoreAlert } from "../../notify/core-notify.js";
|
||||
import {
|
||||
getPluginRuntime,
|
||||
installPluginArtifact,
|
||||
registerLoadedPlugins,
|
||||
setPluginEnabled,
|
||||
unloadPlugin,
|
||||
} from "../../plugins/index.js";
|
||||
import { getPluginsDir } from "../../plugins/paths.js";
|
||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||
import {
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
createCurrentPluginRepository,
|
||||
@@ -56,6 +58,7 @@ async function isEnabledLocally(pluginId: string): Promise<boolean> {
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** Downloads next to the installed copy, which stays until the swap. */
|
||||
async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
|
||||
const response = await remoteFetch(
|
||||
link,
|
||||
@@ -68,7 +71,7 @@ async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const dir = getPluginsDir();
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, `${plugin.id}.tmxplug`);
|
||||
const file = path.join(dir, `${plugin.id}.tmxplug.download`);
|
||||
await fs.promises.writeFile(file, buffer);
|
||||
const signature = response.headers.get("x-termix-signature");
|
||||
const sigFile = `${file}.sig`;
|
||||
@@ -83,6 +86,68 @@ async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
|
||||
return file;
|
||||
}
|
||||
|
||||
async function moveIfThere(from: string, to: string): Promise<void> {
|
||||
await fs.promises.rm(to, { force: true });
|
||||
if (fs.existsSync(from)) await fs.promises.rename(from, to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps a downloaded copy in for the installed one. If it fails to install,
|
||||
* the old copy goes back on disk and is loaded again, so a bad download never
|
||||
* leaves the desktop without the plugin.
|
||||
*/
|
||||
async function replaceWithDownload(
|
||||
pluginId: string,
|
||||
downloaded: string,
|
||||
previous: LoadedPlugin | undefined,
|
||||
wasEnabled: boolean,
|
||||
): Promise<LoadedPlugin> {
|
||||
const file = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
||||
const backup = `${file}.previous`;
|
||||
await moveIfThere(file, backup);
|
||||
await moveIfThere(`${file}.sig`, `${backup}.sig`);
|
||||
await moveIfThere(downloaded, file);
|
||||
await moveIfThere(`${downloaded}.sig`, `${file}.sig`);
|
||||
if (previous) await unloadPlugin(pluginId);
|
||||
try {
|
||||
const installed = await installPluginArtifact(file);
|
||||
await fs.promises.rm(backup, { force: true });
|
||||
await fs.promises.rm(`${backup}.sig`, { force: true });
|
||||
return installed;
|
||||
} catch (error) {
|
||||
await moveIfThere(backup, file);
|
||||
await moveIfThere(`${backup}.sig`, `${file}.sig`);
|
||||
if (previous) await restorePrevious(pluginId, previous, file, wasEnabled);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function restorePrevious(
|
||||
pluginId: string,
|
||||
previous: LoadedPlugin,
|
||||
file: string,
|
||||
wasEnabled: boolean,
|
||||
): Promise<void> {
|
||||
const { loader } = getPluginRuntime();
|
||||
if (loader.get(pluginId)) return;
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
await installPluginArtifact(file);
|
||||
} else if (previous.source === "bundled") {
|
||||
await registerLoadedPlugins([await loader.loadBundled(pluginId)]);
|
||||
}
|
||||
if (wasEnabled && loader.get(pluginId)) {
|
||||
await setPluginEnabled(pluginId, true);
|
||||
}
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not restore a feature after a failed update", {
|
||||
operation: "sync_plugin_restore",
|
||||
pluginId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function matchGrants(plugin: RemotePlugin): Promise<void> {
|
||||
const repository = createCurrentPluginPermissionGrantRepository();
|
||||
const local = getPluginRuntime().loader.get(plugin.id);
|
||||
@@ -161,9 +226,14 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
// A signed update of a bundled plugin. The loader checks the signature
|
||||
// and that the version really is newer.
|
||||
const wasEnabled = await isEnabledLocally(plugin.id);
|
||||
if (local) await unloadPlugin(plugin.id);
|
||||
try {
|
||||
local = await installPluginArtifact(await download(link, plugin));
|
||||
const downloaded = await download(link, plugin);
|
||||
local = await replaceWithDownload(
|
||||
plugin.id,
|
||||
downloaded,
|
||||
local,
|
||||
wasEnabled,
|
||||
);
|
||||
} catch (error) {
|
||||
await alertOlderDesktop(plugin);
|
||||
throw error;
|
||||
@@ -180,9 +250,13 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
local.manifest.version !== plugin.version;
|
||||
if (outdated && (!local || local.source === "user")) {
|
||||
const wasEnabled = local ? await isEnabledLocally(plugin.id) : false;
|
||||
if (local) await unloadPlugin(plugin.id);
|
||||
const file = await download(link, plugin);
|
||||
local = await installPluginArtifact(file);
|
||||
const downloaded = await download(link, plugin);
|
||||
local = await replaceWithDownload(
|
||||
plugin.id,
|
||||
downloaded,
|
||||
local,
|
||||
wasEnabled,
|
||||
);
|
||||
if (wasEnabled && !plugin.enabled) {
|
||||
await setPluginEnabled(plugin.id, false);
|
||||
}
|
||||
|
||||
@@ -126,11 +126,6 @@ export async function reconcileUser(
|
||||
return { changed, wire };
|
||||
}
|
||||
|
||||
/** Forgets a user's snapshot so the next reconcile reads everything. */
|
||||
export function invalidateSnapshot(userId: string): void {
|
||||
snapshots.delete(userId);
|
||||
}
|
||||
|
||||
const listeners = new Map<string, Set<Response>>();
|
||||
let debounce: NodeJS.Timeout | null = null;
|
||||
let heartbeat: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -87,12 +87,17 @@ router.get("/v2/info", async (_req: Request, res: Response) => {
|
||||
* description: The desktop session token and the account it belongs to.
|
||||
* 401:
|
||||
* description: Not signed in.
|
||||
* 403:
|
||||
* description: API keys cannot link a desktop.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/link",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
if ((req as AuthenticatedRequest).apiKeyId) {
|
||||
return res.status(403).json({ error: "API keys cannot link a desktop" });
|
||||
}
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user) return res.status(401).json({ error: "User not found" });
|
||||
|
||||
@@ -64,12 +64,6 @@ export function locate(
|
||||
return and(eq(table.syncId, syncId), eq(ownerColumn(entity), userId))!;
|
||||
}
|
||||
|
||||
export function rowSyncId(entity: RegisteredSyncEntity, row: SyncRow): string {
|
||||
return entity.singleton
|
||||
? singletonSyncId(entity.type)
|
||||
: (row.syncId as string);
|
||||
}
|
||||
|
||||
let hashKey: Buffer | null = null;
|
||||
|
||||
export async function getHashKey(): Promise<Buffer> {
|
||||
|
||||
@@ -527,6 +527,46 @@ describe("external identities", () => {
|
||||
});
|
||||
afterEach(() => disposeOidc());
|
||||
|
||||
function addSsoUser(passwordHash = "") {
|
||||
addUser({ passwordHash });
|
||||
h.state.identities.push({
|
||||
userId: "u1",
|
||||
providerId: "3",
|
||||
subject: "sub-1",
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("refuses a passkey sign in for an account that only uses SSO", async () => {
|
||||
addSsoUser();
|
||||
await expect(
|
||||
runLogin(
|
||||
fakeRequest() as never,
|
||||
{ kind: "user", userId: "u1", mfaSatisfied: true },
|
||||
{ methodId: "passkey", rememberMe: false },
|
||||
),
|
||||
).rejects.toMatchObject({ status: 403, code: "external_login_required" });
|
||||
});
|
||||
|
||||
it("still lets an SSO account with a password use a passkey", async () => {
|
||||
addSsoUser(bcrypt.hashSync(PASSWORD, 4));
|
||||
const result = await runLogin(
|
||||
fakeRequest() as never,
|
||||
{ kind: "user", userId: "u1", mfaSatisfied: true },
|
||||
{ methodId: "passkey", rememberMe: false },
|
||||
);
|
||||
expect(result.kind).toBe("session");
|
||||
});
|
||||
|
||||
it("lets an external method sign an SSO account in by user id", async () => {
|
||||
addSsoUser();
|
||||
const result = await runLogin(
|
||||
fakeRequest() as never,
|
||||
{ kind: "user", userId: "u1" },
|
||||
{ methodId: "oidc", rememberMe: false },
|
||||
);
|
||||
expect(result.kind).toBe("session");
|
||||
});
|
||||
|
||||
it("provisions the first user as admin and links the identity", async () => {
|
||||
const result = await runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
|
||||
@@ -10,8 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
*
|
||||
* The Drizzle schema said so, the repository was written against it, but the
|
||||
* runtime bootstrap still created `user_id TEXT NOT NULL`. A second, corrected
|
||||
* `CREATE TABLE IF NOT EXISTS` further down was a no-op — the table already
|
||||
* existed — so every fresh install got the old constraint and every user
|
||||
* `CREATE TABLE IF NOT EXISTS` further down was a no-op, the table already
|
||||
* existed, so every fresh install got the old constraint and every user
|
||||
* deletion (including the OIDC account-link cleanup) failed once the account
|
||||
* had logged in at least once.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
/**
|
||||
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
|
||||
* number of tables that the primary bootstrap already creates. The probe never
|
||||
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and one
|
||||
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran, and one
|
||||
* of those unreachable copies had drifted away from the real definition
|
||||
* (`sessions` had lost `ON DELETE CASCADE`).
|
||||
*
|
||||
@@ -61,7 +61,7 @@ describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
});
|
||||
|
||||
it("creates them on a database old enough to predate them", async () => {
|
||||
// A database with users and hosts but none of the tables above — the
|
||||
// A database with users and hosts but none of the tables above, the
|
||||
// upgrade path the deleted probes appeared to be protecting.
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("needsExplicitPersist", () => {
|
||||
|
||||
/**
|
||||
* schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the
|
||||
* generated output is usable rather than merely syntactically valid — the
|
||||
* generated output is usable rather than merely syntactically valid, the
|
||||
* repository layer's correctness rests on all three behaving the same way.
|
||||
*/
|
||||
describe("generated schemas", () => {
|
||||
@@ -133,7 +133,7 @@ describe("generated schemas", () => {
|
||||
* SQLite builds its indexes at runtime from PERFORMANCE_INDEXES; Postgres and
|
||||
* MySQL only ever get what the migrations declare, which comes from schema.ts.
|
||||
* Anything listed in one and missing from the other is an index the engines
|
||||
* chosen for scale silently do without — which is how 31 of them went missing.
|
||||
* chosen for scale silently do without, which is how 31 of them went missing.
|
||||
*/
|
||||
it("declares every performance index in schema.ts", () => {
|
||||
// Both are the leading column of an existing composite unique index, which
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
/**
|
||||
* Sharing a host used to hand the owner's SSH authentication to the recipient
|
||||
* unconditionally. 2.6.1 put that behind `ssh_data.share_ssh_auth`, added as
|
||||
* `NOT NULL DEFAULT 0` — so every host shared before the upgrade silently
|
||||
* `NOT NULL DEFAULT 0`, so every host shared before the upgrade silently
|
||||
* stopped supplying credentials, and recipients hit "No valid authentication
|
||||
* method provided" on hosts that had worked the day before.
|
||||
*
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
/**
|
||||
* `ssh_credentials.username` became nullable when key-only credentials landed,
|
||||
* and databases created before that are rebuilt on startup to drop the
|
||||
* constraint — SQLite cannot ALTER a column.
|
||||
* constraint, SQLite cannot ALTER a column.
|
||||
*
|
||||
* The rebuild restated the table's columns as a literal, then copied rows with
|
||||
* `INSERT INTO temp SELECT <every live column>`. The table has gained columns
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("rowsAffected", () => {
|
||||
});
|
||||
|
||||
it("reads changes from a better-sqlite3 write result", () => {
|
||||
// The shape of a write with no .returning() attached — verified against
|
||||
// The shape of a write with no .returning() attached, verified against
|
||||
// the driver, not assumed.
|
||||
expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1);
|
||||
expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0);
|
||||
@@ -81,7 +81,7 @@ describe("supportsReturning", () => {
|
||||
expect(supportsReturning("sqlite")).toBe(true);
|
||||
expect(supportsReturning("postgres")).toBe(true);
|
||||
// No RETURNING clause in MySQL, and drizzle's mysql-core does not expose
|
||||
// the method — call sites that need rows back must read first.
|
||||
// the method, call sites that need rows back must read first.
|
||||
expect(supportsReturning("mysql")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
|
||||
* The MySQL path cannot be exercised against a real engine here, and its whole
|
||||
* correctness is an ordering property: an update must be read AFTER the write,
|
||||
* a delete BEFORE it. Get either backwards and the rows describe the wrong
|
||||
* state — silently, with no error anywhere.
|
||||
* state, silently, with no error anywhere.
|
||||
*
|
||||
* So the drizzle handle is stubbed and the order of calls is recorded.
|
||||
*/
|
||||
@@ -169,7 +169,7 @@ describe("deleteReturning", () => {
|
||||
await deleteReturning(context, {} as never, where);
|
||||
|
||||
// Without this, a concurrent write between them makes the returned rows
|
||||
// describe a state that never existed — and with a pool the second
|
||||
// describe a state that never existed, and with a pool the second
|
||||
// statement need not even reach the same connection.
|
||||
expect(calls[0]).toBe("begin");
|
||||
expect(calls[calls.length - 1]).toBe("commit");
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
/**
|
||||
* The settings cache lives in one process and is updated by whichever process
|
||||
* wrote the setting. On SQLite that is the only process there is. On Postgres
|
||||
* and MySQL — the reason those exist here is to let several instances share one
|
||||
* database — a setting changed on one replica would otherwise never reach the
|
||||
* and MySQL, the reason those exist here is to let several instances share one
|
||||
* database, a setting changed on one replica would otherwise never reach the
|
||||
* others, because the synchronous read cannot go back to the database.
|
||||
*
|
||||
* Re-priming on a timer does not make settings immediately consistent. It
|
||||
@@ -57,7 +57,7 @@ describe("settings cache refresh", () => {
|
||||
|
||||
it("keeps running after a refresh throws", async () => {
|
||||
// A transient database blip must not stop the loop, or the replica is stuck
|
||||
// on stale settings until it restarts — the exact failure this prevents.
|
||||
// on stale settings until it restarts, the exact failure this prevents.
|
||||
const refresh = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("connection reset"))
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
|
||||
*
|
||||
* Defaults to SQLite, so `npm test` behaves as it always has and needs no
|
||||
* server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run
|
||||
* the same tests against a real one — see the database-dialects CI job.
|
||||
* the same tests against a real one, see the database-dialects CI job.
|
||||
*/
|
||||
export function testDialect(env = process.env): DatabaseDialect {
|
||||
const value = env.TEST_DIALECT?.trim().toLowerCase();
|
||||
@@ -125,7 +125,7 @@ export class TestSqliteDatabase {
|
||||
* Portable write for test setup.
|
||||
*
|
||||
* Separate from query() because better-sqlite3 refuses `.all()` on a
|
||||
* statement that returns no rows — "This statement does not return data".
|
||||
* statement that returns no rows, "This statement does not return data".
|
||||
*/
|
||||
async run(statement: SQL): Promise<void> {
|
||||
const context = this.context;
|
||||
@@ -207,7 +207,7 @@ async function truncateAll(context: DatabaseContext): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits seed SQL into statements, ignoring semicolons inside string literals —
|
||||
* Splits seed SQL into statements, ignoring semicolons inside string literals,
|
||||
* JSON payloads in the fixtures contain them.
|
||||
*/
|
||||
function splitStatements(sql: string): string[] {
|
||||
@@ -274,7 +274,7 @@ function portableSql(statement: string, dialect: DatabaseDialect): string {
|
||||
if (dialect !== "mysql") return out;
|
||||
|
||||
// Only the column list, before VALUES. A blanket replace also mangles the
|
||||
// double quotes inside JSON payloads in the values — '{"slots":[]}' became
|
||||
// double quotes inside JSON payloads in the values, '{"slots":[]}' became
|
||||
// '{`slots`:[]}', which is valid SQL and silently wrong data.
|
||||
const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out);
|
||||
if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`");
|
||||
@@ -354,7 +354,7 @@ async function resyncAutoIncrement(
|
||||
for (const table of tables) {
|
||||
// Only tables whose id is generated. A text primary key, like users.id,
|
||||
// has no sequence and no counter to move, and a table keyed on something
|
||||
// else entirely — host_sidebar_preferences.user_id — has no id at all.
|
||||
// else entirely, host_sidebar_preferences.user_id, has no id at all.
|
||||
if (context.dialect === "postgres") {
|
||||
// pg_get_serial_sequence() raises 42703 rather than returning null when
|
||||
// the column is missing, so let information_schema decide whether there
|
||||
@@ -408,7 +408,7 @@ async function resyncAutoIncrement(
|
||||
*
|
||||
* Every test builds a fixture, and each would otherwise re-run the migrator
|
||||
* against the same shared database. drizzle's journal makes that a no-op only
|
||||
* when the first run finished — several fixtures racing inside one file hit
|
||||
* when the first run finished, several fixtures racing inside one file hit
|
||||
* "table already exists" instead.
|
||||
*/
|
||||
const migrations = new Map<string, Promise<void>>();
|
||||
@@ -436,7 +436,7 @@ let cachedSqliteSchema: string | null = null;
|
||||
* The full schema, from the generated SQLite migrations rather than
|
||||
* hand-written DDL in each test file.
|
||||
*
|
||||
* Tests used to declare a cut-down version of every table they touched — a
|
||||
* Tests used to declare a cut-down version of every table they touched, a
|
||||
* `users` with five columns where the real one has thirty. That drifts from the
|
||||
* schema silently, and it is the reason the same tests could not be pointed at
|
||||
* another engine.
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shouldShowDonationModal } from "../../../database/routes/donation-modal-utils.js";
|
||||
|
||||
describe("shouldShowDonationModal", () => {
|
||||
const now = Date.parse("2026-07-17T00:00:00.000Z");
|
||||
|
||||
it("returns false when the user already dismissed it", () => {
|
||||
const registeredAt = new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(shouldShowDonationModal(registeredAt, true, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false before the 30 day mark", () => {
|
||||
const registeredAt = new Date(now - 29 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true at exactly 30 days", () => {
|
||||
const registeredAt = new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true well past the 30 day mark", () => {
|
||||
const registeredAt = new Date(
|
||||
now - 200 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an unparseable registeredAt", () => {
|
||||
expect(shouldShowDonationModal("not-a-date", false, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a backdated registeredAt for pre-existing users as immediately eligible", () => {
|
||||
const registeredAt = new Date(now - 31 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
*
|
||||
* The resolved row supplies the address, the credentials, the jump hosts and
|
||||
* the stored host key, so the session opened on whichever machine owned that
|
||||
* id on the server — the host list stayed correct the whole time, and nothing
|
||||
* id on the server, the host list stayed correct the whole time, and nothing
|
||||
* announced the substitution.
|
||||
*/
|
||||
describe("hostAddressMismatch", () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
|
||||
/**
|
||||
* A numeric host id belongs to whichever database produced it. Resolving the
|
||||
* desktop app's id against a sync server's table lands on whatever host owns
|
||||
* that number there — a different machine, with its own address, credentials
|
||||
* that number there, a different machine, with its own address, credentials
|
||||
* and host key. `sync_id` is the same string on both sides.
|
||||
*/
|
||||
describe("resolveHostBySyncId", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isValidPackageName,
|
||||
buildSudoCommand,
|
||||
parseUpgradable,
|
||||
execCommand,
|
||||
} from "@termix-ssh/plugin-sdk/host-commands";
|
||||
|
||||
describe("parsePlatformProbe", () => {
|
||||
@@ -114,3 +115,13 @@ describe("parseUpgradable", () => {
|
||||
expect(parseUpgradable(null, "anything")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("execCommand", () => {
|
||||
it("leaves the command out of the timeout error", async () => {
|
||||
const client = { exec: () => {} } as never;
|
||||
const command = buildSudoCommand("apt update", "hunter2");
|
||||
await expect(execCommand(client, command, 5)).rejects.toThrow(
|
||||
/^Command timeout after 5ms$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getBundledPluginsDir } from "../../plugins/paths.js";
|
||||
|
||||
describe("getBundledPluginsDir", () => {
|
||||
const saved = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
else process.env.TERMIX_BUNDLED_PLUGINS_DIR = saved;
|
||||
});
|
||||
|
||||
it("reads dist/plugins when run from source", () => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
expect(getBundledPluginsDir()).toBe(
|
||||
path.resolve(process.cwd(), "dist", "plugins"),
|
||||
);
|
||||
});
|
||||
|
||||
it("honors the override", () => {
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/somewhere/else";
|
||||
expect(getBundledPluginsDir()).toBe("/somewhere/else");
|
||||
});
|
||||
});
|
||||
@@ -184,9 +184,9 @@ describe("createTableSql", () => {
|
||||
\`user_id\` varchar(255) NOT NULL,
|
||||
\`host_id\` int NOT NULL,
|
||||
\`title\` varchar(200) NOT NULL,
|
||||
\`body\` text,
|
||||
\`meta\` text,
|
||||
\`secret\` text,
|
||||
\`body\` longtext,
|
||||
\`meta\` longtext,
|
||||
\`secret\` longtext,
|
||||
\`pinned\` boolean DEFAULT false,
|
||||
\`position\` int DEFAULT 0,
|
||||
\`size\` bigint,
|
||||
@@ -286,9 +286,9 @@ describe("adoptTableSql", () => {
|
||||
expect(mysql[1]).toBe("RENAME TABLE `fleets` TO `p_fleets_fleets`;");
|
||||
});
|
||||
|
||||
it("writes a TEXT default as an expression on MySQL", () => {
|
||||
it("writes a long text default as an expression on MySQL", () => {
|
||||
expect(adoptTableSql("mysql", "fleets", adopted)[0]).toContain(
|
||||
"`name` text NOT NULL DEFAULT ('x')",
|
||||
"`name` longtext NOT NULL DEFAULT ('x')",
|
||||
);
|
||||
expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain(
|
||||
"\"name\" text NOT NULL DEFAULT 'x'",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
dir: "",
|
||||
loaded: new Map<string, Record<string, unknown>>(),
|
||||
enabled: new Set<string>(),
|
||||
install: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
loadBundled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
syncLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../notify/core-notify.js", () => ({
|
||||
sendCoreAlert: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../../../plugins/paths.js", () => ({
|
||||
getPluginsDir: () => state.dir,
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) => ({
|
||||
state: state.enabled.has(id) ? "enabled" : "disabled",
|
||||
}),
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({}),
|
||||
}));
|
||||
vi.mock("../../../sync/client/http.js", () => ({
|
||||
remoteFetch: state.fetch,
|
||||
remoteJson: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: {
|
||||
get: (id: string) => state.loaded.get(id),
|
||||
list: () => [...state.loaded.values()],
|
||||
loadBundled: state.loadBundled,
|
||||
},
|
||||
}),
|
||||
installPluginArtifact: state.install,
|
||||
registerLoadedPlugins: vi.fn(async () => {}),
|
||||
setPluginEnabled: vi.fn(async (id: string, on: boolean) => {
|
||||
if (on) state.enabled.add(id);
|
||||
else state.enabled.delete(id);
|
||||
}),
|
||||
unloadPlugin: vi.fn(async (id: string) => {
|
||||
state.loaded.delete(id);
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mirrorPlugins } = await import("../../../sync/client/plugins.js");
|
||||
const { remoteJson } = await import("../../../sync/client/http.js");
|
||||
|
||||
const bundled = {
|
||||
id: "ssh-terminal",
|
||||
source: "bundled",
|
||||
state: "active",
|
||||
manifest: { id: "ssh-terminal", version: "1.0.0", capabilities: [] },
|
||||
};
|
||||
|
||||
function serverHas(version: string) {
|
||||
vi.mocked(remoteJson).mockResolvedValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "ssh-terminal",
|
||||
name: "SSH Terminal",
|
||||
version,
|
||||
source: "bundled",
|
||||
artifact: true,
|
||||
enabled: true,
|
||||
active: true,
|
||||
desktop: "mirror",
|
||||
syncEntities: [],
|
||||
granted: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-mirror-"));
|
||||
state.loaded = new Map([["ssh-terminal", { ...bundled }]]);
|
||||
state.enabled = new Set(["ssh-terminal"]);
|
||||
state.install.mockReset();
|
||||
state.fetch.mockReset();
|
||||
state.loadBundled.mockReset();
|
||||
state.loadBundled.mockImplementation(async (id: string) => {
|
||||
const plugin = { ...bundled, id };
|
||||
state.loaded.set(id, plugin);
|
||||
return plugin;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(state.dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("mirrorPlugins", () => {
|
||||
it("keeps the installed copy when the download fails", async () => {
|
||||
serverHas("1.1.0");
|
||||
state.fetch.mockResolvedValue({ ok: false, status: 502 });
|
||||
|
||||
await mirrorPlugins({} as never);
|
||||
|
||||
expect(state.loaded.has("ssh-terminal")).toBe(true);
|
||||
expect(state.enabled.has("ssh-terminal")).toBe(true);
|
||||
expect(state.install).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("puts the shipped copy back when the new one fails to install", async () => {
|
||||
serverHas("1.1.0");
|
||||
state.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(4),
|
||||
headers: new Headers(),
|
||||
});
|
||||
state.install.mockRejectedValue(new Error("signature check failed"));
|
||||
|
||||
await mirrorPlugins({} as never);
|
||||
|
||||
expect(state.loadBundled).toHaveBeenCalledWith("ssh-terminal");
|
||||
expect(state.loaded.has("ssh-terminal")).toBe(true);
|
||||
expect(state.enabled.has("ssh-terminal")).toBe(true);
|
||||
expect(fs.existsSync(path.join(state.dir, "ssh-terminal.tmxplug"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("restores an earlier update file when a newer one fails", async () => {
|
||||
serverHas("1.2.0");
|
||||
const file = path.join(state.dir, "ssh-terminal.tmxplug");
|
||||
fs.writeFileSync(file, "old");
|
||||
state.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(4),
|
||||
headers: new Headers(),
|
||||
});
|
||||
state.install
|
||||
.mockRejectedValueOnce(new Error("bad archive"))
|
||||
.mockImplementationOnce(async () => {
|
||||
const plugin = { ...bundled };
|
||||
state.loaded.set("ssh-terminal", plugin);
|
||||
return plugin;
|
||||
});
|
||||
|
||||
await mirrorPlugins({} as never);
|
||||
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("old");
|
||||
expect(state.install).toHaveBeenCalledTimes(2);
|
||||
expect(state.loaded.has("ssh-terminal")).toBe(true);
|
||||
expect(state.enabled.has("ssh-terminal")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -123,7 +123,7 @@ describe("forwardAuditEntry", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows transport errors — a dead SIEM must not break auditing", async () => {
|
||||
it("swallows transport errors, a dead SIEM must not break auditing", async () => {
|
||||
safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
||||
|
||||
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
|
||||
@@ -140,7 +140,7 @@ describe("forwardAuditEntry", () => {
|
||||
await forwardAuditEntry(ENTRY, NOW, env);
|
||||
}
|
||||
|
||||
// 5 per-entry warnings, then one suppression notice — not 8.
|
||||
// 5 per-entry warnings, then one suppression notice, not 8.
|
||||
const perEntry = logs.warn.mock.calls.filter(
|
||||
(call) => call[0] === "Failed to forward audit entry",
|
||||
);
|
||||
|
||||
@@ -19,7 +19,10 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentSessionRepository: () => ({
|
||||
findById: async (id: string) => ({ id }),
|
||||
findById: async (id: string) =>
|
||||
id === "expired"
|
||||
? { id, expiresAt: new Date(Date.now() - 1000).toISOString() }
|
||||
: { id },
|
||||
}),
|
||||
createCurrentUserRepository: () => ({}),
|
||||
createCurrentApiKeyRepository: () => ({}),
|
||||
@@ -151,6 +154,15 @@ describe("AuthManager token handling", () => {
|
||||
expect(await authManager.verifyJWTToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a token whose session has expired", async () => {
|
||||
const token = jwt.sign(
|
||||
{ userId: "user-1", sessionId: "expired" },
|
||||
jwtSecret,
|
||||
{ expiresIn: "1h" },
|
||||
);
|
||||
expect(await authManager.verifyJWTToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("still accepts the sessionless pending second-factor token", async () => {
|
||||
const token = jwt.sign({ userId: "user-1", pendingTOTP: true }, jwtSecret, {
|
||||
expiresIn: "10m",
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("isBlockedAddress", () => {
|
||||
expect(isBlockedAddress("192.168.1.1")).toBe(true);
|
||||
expect(isBlockedAddress("127.0.0.1")).toBe(true);
|
||||
expect(isBlockedAddress("169.254.1.1")).toBe(true);
|
||||
expect(isBlockedAddress("192.0.0.170")).toBe(true);
|
||||
expect(isBlockedAddress("100.64.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function readForwardSetting(key: string): string | null {
|
||||
|
||||
/**
|
||||
* How many consecutive failures before the forwarder stops complaining on every
|
||||
* entry. It keeps trying — this only throttles the log noise, and it reports
|
||||
* entry. It keeps trying, this only throttles the log noise, and it reports
|
||||
* again once delivery recovers.
|
||||
*/
|
||||
const QUIET_AFTER_FAILURES = 5;
|
||||
|
||||
@@ -414,6 +414,10 @@ class AuthManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.migrateDataKeyFromPayload(payload);
|
||||
} catch (dbError) {
|
||||
databaseLogger.error(
|
||||
|
||||
@@ -6,8 +6,8 @@ import { type Request, type RequestHandler, type Response } from "express";
|
||||
*
|
||||
* The default is 1KB; this is slightly higher because almost every response
|
||||
* under a few KB here is a small status or preference object where the CPU and
|
||||
* the extra headers are not worth it. The payloads that matter — host lists,
|
||||
* audit pages, fleet inventories — are orders of magnitude above this.
|
||||
* the extra headers are not worth it. The payloads that matter, host lists,
|
||||
* audit pages, fleet inventories, are orders of magnitude above this.
|
||||
*/
|
||||
const MIN_RESPONSE_BYTES = 2048;
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function runLegacySharedCredentialCleanup(): Promise<{
|
||||
|
||||
// Nothing to clean up on an engine this application has never run on. A
|
||||
// Postgres or MySQL database is created by the drizzle migrations, which have
|
||||
// never emitted these legacy columns, so there is nothing to drop — and the
|
||||
// never emitted these legacy columns, so there is nothing to drop, and the
|
||||
// check itself needs a synchronous PRAGMA that only SQLite offers.
|
||||
if (!needsExplicitPersist(resolveDatabaseDialect())) return result;
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ class DataCrypto {
|
||||
migratedFieldsCount: number;
|
||||
}> {
|
||||
// Only a database that predates field encryption has plaintext to migrate,
|
||||
// and only SQLite deployments can predate it — Postgres and MySQL support
|
||||
// and only SQLite deployments can predate it, Postgres and MySQL support
|
||||
// arrived after. The store also needs synchronous queries no other driver
|
||||
// has, so this would throw rather than find nothing to do.
|
||||
if (!needsExplicitPersist(resolveDatabaseDialect())) {
|
||||
|
||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user