* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

* feat: shared list kit, settings search, rail order and ui consistency fixes

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 14:58:27 -05:00
1 parent a075ff4808
commit 0149260926
3656 files changed
+116210 -460999

No files matched your search

-26
View File
@@ -1,26 +0,0 @@
/**
* Applies every workspace plugin's dependency patches after npm install, so
* the Vite dev server serves the same patched libraries a build bundles.
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
const plugins = path.join(root, "plugins");
for (const id of fs.readdirSync(plugins)) {
const dir = path.join(plugins, id);
const pkgPath = path.join(dir, "package.json");
if (!fs.existsSync(pkgPath)) continue;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (!pkg.termix?.patches?.length && !fs.existsSync(path.join(dir, "patches")))
continue;
const result = spawnSync(process.execPath, [cli, "patch"], {
cwd: dir,
stdio: "inherit",
});
if (result.status !== 0) process.exit(result.status ?? 1);
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Works out beta numbers from a list of tags on stdin.
*
* node scripts/beta-version.cjs 26.11.0 < tags.txt -> 26.11.0-beta.3
* node scripts/beta-version.cjs 26.11.0 --latest < tags.txt -> v26.11.0-beta.2
*/
const fs = require("fs");
function betaNumbers(base, tags) {
const prefix = `v${base}-beta.`;
return tags
.map((tag) => tag.trim())
.filter((tag) => tag.startsWith(prefix))
.map((tag) => Number(tag.slice(prefix.length)))
.filter((n) => Number.isInteger(n) && n > 0)
.sort((a, b) => a - b);
}
function nextBeta(base, tags) {
const numbers = betaNumbers(base, tags);
return `${base}-beta.${(numbers.at(-1) ?? 0) + 1}`;
}
function latestBetaTag(base, tags) {
const numbers = betaNumbers(base, tags);
return numbers.length ? `v${base}-beta.${numbers.at(-1)}` : "";
}
if (require.main === module) {
const base = process.argv[2];
if (!base || !/^\d+\.\d+\.\d+$/.test(base)) {
console.error("beta-version: pass a version like 26.11.0");
process.exit(1);
}
const tags = fs.readFileSync(0, "utf8").split("\n");
console.log(
process.argv.includes("--latest")
? latestBetaTag(base, tags)
: nextBeta(base, tags),
);
}
module.exports = { latestBetaTag, nextBeta };
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { latestBetaTag, nextBeta } = require("./beta-version.cjs");
const TAGS = [
"v26.10.0",
"v26.11.0-beta.1",
"v26.11.0-beta.10",
"v26.11.0-beta.2",
"v26.11.0-beta.x",
"v26.12.0-beta.4",
];
describe("beta-version", () => {
it("numbers the next beta after the highest one, not the last listed", () => {
expect(nextBeta("26.11.0", TAGS)).toBe("26.11.0-beta.11");
expect(latestBetaTag("26.11.0", TAGS)).toBe("v26.11.0-beta.10");
});
it("starts at 1 for a version with no betas", () => {
expect(nextBeta("27.1.0", TAGS)).toBe("27.1.0-beta.1");
expect(latestBetaTag("27.1.0", TAGS)).toBe("");
});
});
+49 -49
View File
@@ -3,78 +3,78 @@
*
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
* A workspace plugin is built from plugins/<id>; a tmxplug plugin is
* downloaded (or read from a path), checked against its pinned sha256 and
* unpacked. Either way only what a server needs at runtime lands there.
* Each .tmxplug is downloaded (or read from a path), checked against its
* pinned sha256 and unpacked.
*
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
* folder of plugin repos like ../Termix-Plugins. A plugin there whose sources
* changed since its last build is rebuilt first with this checkout's SDK, so
* SDK edits apply without publishing, then any built one (has
* dist/) is copied in place of its pin, or added when it has no pin.
*/
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
loadBundledPlugins,
fetchArtifact,
extractArtifact,
findLocalPluginBuilds,
rebuildLocalPlugins,
localSdkBuildCommand,
copyLocalBuild,
buildBundledIndex,
} = require("./lib/bundled-plugins.cjs");
const root = path.resolve(__dirname, "..");
const source = path.join(root, "plugins");
const destination = path.join(root, "dist", "plugins");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
// Same list as a .tmxplug, see packages/plugin-sdk/cli/lib/tmxplug.mjs.
const SHIPPED = [
"manifest.json",
"dist",
"locales",
"migrations",
"README.md",
"CHANGELOG.md",
"icon.svg",
];
function stageWorkspace(id) {
const pluginDir = path.join(source, id);
execFileSync(process.execPath, [cli, "build"], {
cwd: pluginDir,
stdio: "inherit",
});
if (!fs.existsSync(path.join(pluginDir, "dist", "backend.js"))) {
throw new Error(`${id} produced no dist/backend.js`);
}
const outDir = path.join(destination, id);
fs.mkdirSync(outDir, { recursive: true });
for (const entry of SHIPPED) {
const from = path.join(pluginDir, entry);
if (!fs.existsSync(from)) continue;
fs.cpSync(from, path.join(outDir, entry), { recursive: true });
}
}
async function main() {
const plugins = loadBundledPlugins(root);
const flag = process.argv.indexOf("--local");
const localArg =
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
const localDir = localArg ? path.resolve(root, localArg) : null;
const [node, buildArgs] = localSdkBuildCommand();
rebuildLocalPlugins(localDir, null, (repo, id) => {
console.log(`building ${id}`);
execFileSync(node, buildArgs, { cwd: repo, stdio: "inherit" });
});
const local = findLocalPluginBuilds(localDir);
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
for (const plugin of plugins) {
if (plugin.source === "workspace") {
stageWorkspace(plugin.id);
} else {
const buffer = await fetchArtifact(plugin, root);
await extractArtifact(
buffer,
path.join(destination, plugin.id),
plugin.id,
);
console.log(`unpacked ${plugin.id}`);
const repo = local.get(plugin.id);
if (repo) {
copyLocalBuild(repo, path.join(destination, plugin.id));
console.log(`copied local build of ${plugin.id}`);
continue;
}
const buffer = await fetchArtifact(plugin, root);
await extractArtifact(buffer, path.join(destination, plugin.id), plugin.id);
console.log(`unpacked ${plugin.id}`);
}
// A local plugin with no pin yet still gets staged, so new plugins work too.
const pinned = new Set(plugins.map((p) => p.id));
const extra = [...local.keys()].filter((id) => !pinned.has(id));
for (const id of extra) {
copyLocalBuild(local.get(id), path.join(destination, id));
console.log(`copied local build of ${id} (not pinned)`);
}
const staged = [...pinned, ...extra];
fs.writeFileSync(
path.join(destination, "bundled-index.json"),
`${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)}
`,
);
console.log(
`Bundled ${plugins.length} plugin(s): ${plugins.map((p) => p.id).join(", ")}`,
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
);
}
+35 -5
View File
@@ -1,10 +1,11 @@
/**
* Builds the two pieces of the plugin SDK that come from core:
*
* dist/host/ui.js @termix/plugin-sdk/ui, the shell's UI kit
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
* dist/host/testing-host.js what renderWithApp() renders plugins into
* dist/host/core-classes.json the classes core's CSS has rules for
*
* Inside Termix the page's import map points @termix/plugin-sdk/ui at the
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
* its own repo can typecheck and run its tests without a Termix checkout.
*
@@ -18,6 +19,9 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import * as esbuild from "esbuild";
import { compile, optimize } from "@tailwindcss/node";
import { Scanner } from "@tailwindcss/oxide";
import { classNamesInCss } from "../packages/plugin-sdk/cli/lib/css-classes.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outDir = path.join(root, "packages", "plugin-sdk", "dist", "host");
@@ -32,7 +36,7 @@ const EXTERNALS = [
"i18next",
"react-i18next",
"sonner",
"@termix/plugin-sdk/frontend",
"@termix-ssh/plugin-sdk/frontend",
"@testing-library/react",
];
@@ -78,7 +82,7 @@ await esbuild.build({
},
});
// Types for @termix/plugin-sdk/ui: the declarations tsc emits for sdk-ui.ts
// Types for @termix-ssh/plugin-sdk/ui: the declarations tsc emits for sdk-ui.ts
// and everything it reaches, with core's "@/" paths made relative.
const typesDir = path.join(outDir, "types");
const tsc = spawnSync(
@@ -93,7 +97,7 @@ const tsc = spawnSync(
{ cwd: root, stdio: "inherit" },
);
if (tsc.status !== 0) process.exit(tsc.status ?? 1);
// tsc follows @termix/plugin-sdk/frontend into the SDK's source; plugins get
// tsc follows @termix-ssh/plugin-sdk/frontend into the SDK's source; plugins get
// those types from the package itself.
fs.rmSync(path.join(typesDir, "packages"), { recursive: true, force: true });
@@ -137,4 +141,30 @@ fs.copyFileSync(
path.join(outDir, "theme.css"),
);
// Every class core's own CSS has a rule for, found the way the Vite build
// finds them, so termix-plugin build knows which plugin classes it shares.
const indexCss = path.join(root, "src/ui/index.css");
const compiler = await compile(fs.readFileSync(indexCss, "utf8"), {
base: path.dirname(indexCss),
onDependency: () => {},
});
const rootSource =
compiler.root === "none"
? []
: compiler.root === null
? [{ base: root, pattern: "**/*", negated: false }]
: [{ ...compiler.root, negated: false }];
const scanner = new Scanner({
sources: [...rootSource, ...compiler.sources],
});
const coreClasses = [
...classNamesInCss(
optimize(compiler.build(scanner.scan()), { minify: true }).code,
),
].sort();
fs.writeFileSync(
path.join(outDir, "core-classes.json"),
`${JSON.stringify(coreClasses)}\n`,
);
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
-159
View File
@@ -1,159 +0,0 @@
/**
* Writes src/backend/tests/fixtures/upgrade/{db.sqlite,fixture.env,files/}: a 2.8 install's
* data directory with every feature table filled. The schema is 2.8's own
* dump, the rows come from rows.ts, and the columns 2.8 encrypted are
* encrypted the way 2.8 did it (a v3 system-wrapped data key per user, then
* FieldCrypto per field), so the upgrade test has to really decrypt them.
*
* Usage: npx tsx scripts/build-upgrade-fixture.ts
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
import bcrypt from "bcryptjs";
import { FieldCrypto } from "../src/backend/utils/field-crypto.js";
import {
ENCRYPTED_COLUMNS,
FIXTURE_PASSWORD,
RECORDING_FILES,
ROWS,
type Row,
} from "../src/backend/tests/fixtures/upgrade/rows.js";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const FIXTURES = path.join(ROOT, "src/backend/tests/fixtures");
const OUT = path.join(FIXTURES, "upgrade");
const hex = (label: string) =>
crypto
.createHash("sha256")
.update(`termix-d4-fixture:${label}`)
.digest("hex");
const ENV = {
JWT_SECRET: hex("jwt") + hex("jwt-2"),
DATABASE_KEY: hex("database"),
ENCRYPTION_KEY: hex("encryption"),
INTERNAL_AUTH_TOKEN: hex("internal"),
};
const masterKey = Buffer.from(ENV.ENCRYPTION_KEY, "hex");
/** UserKeyManager's v3 wrap, unchanged since 2.8. */
function wrapDek(userId: string, dek: Buffer): string {
const wrapKey = Buffer.from(
crypto.hkdfSync(
"sha256",
masterKey,
Buffer.alloc(0),
`termix:dek-wrap:v3:${userId}`,
32,
),
);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", wrapKey, iv);
cipher.setAAD(Buffer.from(userId, "utf8"));
const ct = Buffer.concat([cipher.update(dek), cipher.final()]);
return JSON.stringify({
v: 3,
alg: "aes-256-gcm",
iv: iv.toString("base64"),
ct: ct.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
createdAt: "2026-05-01T10:00:00.000Z",
});
}
const deks = new Map<string, Buffer>();
const dekFor = (userId: string) => {
let dek = deks.get(userId);
if (!dek) {
dek = Buffer.from(hex(`dek:${userId}`), "hex");
deks.set(userId, dek);
}
return dek;
};
function prepareRow(table: string, row: Row): Row {
const out: Row = { ...row };
if (table === "users" && out.password_hash === undefined) {
out.password_hash = bcrypt.hashSync(FIXTURE_PASSWORD, 10);
}
for (const [encTable, column, field, userColumn] of ENCRYPTED_COLUMNS) {
if (encTable !== table || typeof out[column] !== "string" || !out[column])
continue;
const userId = String(out[userColumn]);
const recordId = table === "users" ? userId : String(out.id);
out[column] = FieldCrypto.encryptField(
out[column] as string,
dekFor(userId),
recordId,
field,
);
}
return out;
}
function insert(sqlite: Database.Database, table: string, row: Row): void {
const keys = Object.keys(row);
sqlite
.prepare(
`INSERT INTO "${table}" (${keys.map((key) => `"${key}"`).join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`,
)
.run(...keys.map((key) => row[key]));
}
fs.mkdirSync(OUT, { recursive: true });
const dbPath = path.join(OUT, "db.sqlite");
fs.rmSync(dbPath, { force: true });
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = DELETE");
sqlite.exec(
fs.readFileSync(path.join(FIXTURES, "sqlite-2.8-schema.sql"), "utf8"),
);
sqlite.pragma("foreign_keys = ON");
sqlite.transaction(() => {
for (const [table, rows] of Object.entries(ROWS)) {
for (const row of rows) insert(sqlite, table, prepareRow(table, row));
}
for (const user of ROWS.users) {
const userId = String(user.id);
insert(sqlite, "settings", {
key: `user_dek_v3_${userId}`,
value: wrapDek(userId, dekFor(userId)),
});
}
})();
const violations = sqlite.pragma("foreign_key_check") as unknown[];
if (violations.length > 0) {
throw new Error(`Foreign key violations: ${JSON.stringify(violations)}`);
}
sqlite.exec("VACUUM");
sqlite.close();
fs.writeFileSync(
path.join(OUT, "fixture.env"),
Object.entries(ENV)
.map(([key, value]) => `${key}=${value}`)
.join("\n") + "\n",
);
// The recording files 2.8 left in DATA_DIR, which the rows point at.
const files = path.join(OUT, "files");
fs.rmSync(files, { recursive: true, force: true });
for (const [kind, relative] of Object.entries(RECORDING_FILES)) {
const file = path.join(files, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
`d4 ${kind} recording
`,
);
}
console.log(`Wrote ${path.relative(ROOT, dbPath)} and fixture.env`);
+311 -54
View File
@@ -9,6 +9,13 @@ import {
loadBundledPlugins,
fetchArtifact,
extractArtifact,
pinsFromIndex,
buildBundledIndex,
findLocalPluginBuilds,
isLocalBuildStale,
localSdkBuildCommand,
rebuildLocalPlugins,
copyLocalBuild,
} from "./lib/bundled-plugins.cjs";
const cleanups: Array<() => void> = [];
@@ -39,70 +46,113 @@ const sha256 = (buffer: Buffer) =>
crypto.createHash("sha256").update(buffer).digest("hex");
describe("parseBundledPlugins", () => {
it("accepts workspace and tmxplug entries", () => {
const { plugins, problems } = parseBundledPlugins(
{
plugins: [
{ id: "one", source: "workspace" },
{
id: "two",
source: "tmxplug",
url: "https://x/two.tmxplug",
sha256: SHA,
},
{
id: "three",
source: "tmxplug",
path: "vendor/three.tmxplug",
sha256: SHA,
},
],
},
["one"],
);
it("accepts tmxplug entries", () => {
const { plugins, problems } = parseBundledPlugins({
plugins: [
{
id: "two",
source: "tmxplug",
url: "https://x/two.tmxplug",
sha256: SHA,
},
{
id: "three",
source: "tmxplug",
path: "vendor/three.tmxplug",
sha256: SHA,
},
],
});
expect(problems).toEqual([]);
expect(plugins.map((p: { id: string }) => p.id)).toEqual([
"one",
"two",
"three",
]);
});
it("reports a plugins folder nobody listed", () => {
const { problems } = parseBundledPlugins(
{ plugins: [{ id: "one", source: "workspace" }] },
["one", "stray"],
);
expect(problems).toEqual([
"plugins/stray is not listed in docker/bundled-plugins.json",
]);
expect(plugins.map((p: { id: string }) => p.id)).toEqual(["two", "three"]);
});
it("reports bad entries", () => {
const { problems } = parseBundledPlugins(
{
plugins: [
{ id: "gone", source: "workspace" },
{ id: "nohash", source: "tmxplug", url: "https://x" },
{ id: "plain", source: "tmxplug", url: "http://x", sha256: SHA },
{ id: "still-here", source: "tmxplug", path: "a", sha256: SHA },
{ id: "gone", source: "workspace" },
{ id: "odd", source: "npm" },
],
},
["still-here"],
);
const { problems } = parseBundledPlugins({
plugins: [
{ id: "nohash", source: "tmxplug", url: "https://x" },
{ id: "plain", source: "tmxplug", url: "http://x", sha256: SHA },
{ id: "ok", source: "tmxplug", path: "a", sha256: SHA },
{ id: "ok", source: "tmxplug", path: "a", sha256: SHA },
{ id: "odd", source: "workspace" },
],
});
expect(problems).toEqual([
"gone is a workspace plugin but plugins/gone does not exist",
"nohash needs a lowercase hex sha256",
"plain needs exactly one of an https url or a path",
"still-here comes from a .tmxplug, so delete plugins/still-here",
"gone is listed twice",
'odd has an unknown source "npm"',
"ok is listed twice",
'odd has an unknown source "workspace"',
]);
});
it("matches the plugins folder in this repo", () => {
it("reads onboarding defaults", () => {
const { plugins, problems } = parseBundledPlugins({
plugins: [
{
id: "kept",
source: "tmxplug",
path: "a",
sha256: SHA,
onboarding: { recommended: true },
},
{
id: "desk",
source: "tmxplug",
path: "a",
sha256: SHA,
onboarding: { recommended: "desktop" },
},
{
id: "ask",
source: "tmxplug",
path: "a",
sha256: SHA,
onboarding: { recommended: true, consent: true },
},
{
id: "env",
source: "tmxplug",
path: "a",
sha256: SHA,
onboarding: { enabledByEnv: ["OIDC_CLIENT_ID"] },
},
{ id: "plain", source: "tmxplug", path: "a", sha256: SHA },
],
});
expect(problems).toEqual([]);
expect(plugins.map((p: { onboarding: unknown }) => p.onboarding)).toEqual([
{ recommended: true, consent: false },
{ recommended: "desktop", consent: false },
{ recommended: true, consent: true },
{ recommended: false, consent: false, enabledByEnv: ["OIDC_CLIENT_ID"] },
null,
]);
});
it("rejects bad onboarding defaults", () => {
const entry = { source: "tmxplug", path: "a", sha256: SHA };
const { plugins, problems } = parseBundledPlugins({
plugins: [
{ ...entry, id: "a", onboarding: "yes" },
{ ...entry, id: "b", onboarding: { recommended: "web" } },
{ ...entry, id: "c", onboarding: { consent: 1 } },
{ ...entry, id: "d", onboarding: { pinned: true } },
{ ...entry, id: "e", onboarding: { enabledByEnv: ["lower"] } },
{ ...entry, id: "f", onboarding: { enabledByEnv: [] } },
],
});
expect(plugins).toEqual([]);
expect(problems).toEqual([
"a has an onboarding field that is not an object",
'b onboarding.recommended must be true, false or "desktop"',
"c onboarding.consent must be a boolean",
'd has an unknown onboarding field "pinned"',
"e onboarding.enabledByEnv must be a list of environment variable names",
"f onboarding.enabledByEnv must be a list of environment variable names",
]);
});
it("reads the list in this repo", () => {
const root = path.resolve(__dirname, "..");
expect(() => loadBundledPlugins(root)).not.toThrow();
});
@@ -159,3 +209,210 @@ describe("tmxplug entries", () => {
).rejects.toThrow(/is for "other"/);
});
});
describe("buildBundledIndex", () => {
it("lists the onboarding defaults of staged plugins only", () => {
const plugins = [
{ id: "a", onboarding: { recommended: true, consent: false } },
{ id: "b", onboarding: { recommended: "desktop", consent: false } },
{ id: "c", onboarding: null },
];
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
version: 1,
plugins: { a: { onboarding: { recommended: true, consent: false } } },
});
});
});
describe("pinsFromIndex", () => {
const index = {
plugins: [
{
id: "beta",
versions: [
{
version: "1.1.0",
url: "https://example.test/beta-1.1.0.tmxplug",
sha256: "b".repeat(64),
},
{
version: "1.0.0",
url: "https://example.test/beta-1.0.0.tmxplug",
sha256: SHA,
},
],
},
{
id: "alpha",
versions: [
{
version: "1.0.0",
url: "https://example.test/alpha-1.0.0.tmxplug",
sha256: "c".repeat(64),
},
],
},
],
};
it("pins the newest version of every plugin, sorted by id", () => {
const pins = pinsFromIndex(index, null);
expect(pins.map((pin) => pin.id)).toEqual(["alpha", "beta"]);
expect(pins[1]).toEqual({
id: "beta",
source: "tmxplug",
url: "https://example.test/beta-1.1.0.tmxplug",
sha256: "b".repeat(64),
});
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
});
it("keeps the onboarding defaults of an existing pin", () => {
const current = [
{ id: "beta", onboarding: { recommended: true, consent: false } },
];
const pins = pinsFromIndex(index, null, current);
expect(pins[1].onboarding).toEqual({ recommended: true, consent: false });
expect(pins[0]).not.toHaveProperty("onboarding");
});
it("pins only the ids asked for", () => {
expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([
"beta",
]);
});
it("refuses an id the index does not list", () => {
expect(() => pinsFromIndex(index, ["gamma"])).toThrow(
/not in the registry/,
);
});
});
describe("local plugin builds", () => {
it("finds built repos by manifest id and skips unbuilt ones", () => {
const dir = tempDir();
const built = path.join(dir, "Plugin-Files");
fs.mkdirSync(path.join(built, "dist"), { recursive: true });
fs.writeFileSync(
path.join(built, "manifest.json"),
JSON.stringify({ id: "file-manager" }),
);
const unbuilt = path.join(dir, "Plugin-Other");
fs.mkdirSync(unbuilt);
fs.writeFileSync(
path.join(unbuilt, "manifest.json"),
JSON.stringify({ id: "other" }),
);
const builds = findLocalPluginBuilds(dir);
expect([...builds.keys()]).toEqual(["file-manager"]);
expect(findLocalPluginBuilds(null).size).toBe(0);
});
it("copies only the files a bundled plugin needs", () => {
const repo = tempDir();
fs.mkdirSync(path.join(repo, "dist"));
fs.writeFileSync(path.join(repo, "dist", "frontend.js"), "x");
fs.writeFileSync(path.join(repo, "manifest.json"), "{}");
fs.mkdirSync(path.join(repo, "src"));
const out = path.join(tempDir(), "file-manager");
copyLocalBuild(repo, out);
expect(fs.existsSync(path.join(out, "dist", "frontend.js"))).toBe(true);
expect(fs.existsSync(path.join(out, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(out, "src"))).toBe(false);
});
});
describe("rebuilding local plugins", () => {
function repo(dir: string, name: string, id: string): string {
const root = path.join(dir, name);
fs.mkdirSync(path.join(root, "src"), { recursive: true });
fs.mkdirSync(path.join(root, "node_modules"));
fs.writeFileSync(path.join(root, "manifest.json"), JSON.stringify({ id }));
fs.writeFileSync(path.join(root, "src", "index.ts"), "x");
return root;
}
function touch(file: string, secondsAgo: number) {
const time = new Date(Date.now() - secondsAgo * 1000);
fs.utimesSync(file, time, time);
}
it("is stale with no dist or with a source newer than the build", () => {
const root = repo(tempDir(), "Plugin-A", "a");
expect(isLocalBuildStale(root)).toBe(true);
fs.mkdirSync(path.join(root, "dist"));
fs.writeFileSync(path.join(root, "dist", "frontend.js"), "x");
touch(path.join(root, "manifest.json"), 60);
touch(path.join(root, "src", "index.ts"), 60);
expect(isLocalBuildStale(root)).toBe(false);
touch(path.join(root, "src", "index.ts"), 0);
touch(path.join(root, "dist", "frontend.js"), 30);
expect(isLocalBuildStale(root)).toBe(true);
});
it("is stale when the SDK CLI changed after the build", () => {
const root = repo(tempDir(), "Plugin-A", "a");
const cli = path.join(tempDir(), "cli");
fs.mkdirSync(cli);
fs.writeFileSync(path.join(cli, "index.mjs"), "x");
fs.mkdirSync(path.join(root, "dist"));
fs.writeFileSync(path.join(root, "dist", "frontend.js"), "x");
touch(path.join(root, "manifest.json"), 60);
touch(path.join(root, "src", "index.ts"), 60);
touch(path.join(cli, "index.mjs"), 60);
expect(isLocalBuildStale(root, cli)).toBe(false);
touch(path.join(root, "dist", "frontend.js"), 30);
touch(path.join(cli, "index.mjs"), 0);
expect(isLocalBuildStale(root, cli)).toBe(true);
expect(isLocalBuildStale(root)).toBe(false);
});
it("builds with this checkout's SDK CLI, not the plugin's own", () => {
const [command, args] = localSdkBuildCommand();
expect(command).toBe(process.execPath);
expect(args).toEqual([
path.resolve(
__dirname,
"..",
"packages",
"plugin-sdk",
"cli",
"index.mjs",
),
"build",
]);
});
it("builds only stale repos for bundled ids that have node_modules", () => {
const dir = tempDir();
repo(dir, "Plugin-A", "a");
repo(dir, "Plugin-B", "b");
const noDeps = repo(dir, "Plugin-C", "c");
fs.rmSync(path.join(noDeps, "node_modules"), { recursive: true });
const run: string[] = [];
const rebuilt = rebuildLocalPlugins(
dir,
new Set(["a", "c"]),
(_repo: string, id: string) => run.push(id),
);
expect(rebuilt).toEqual(["a"]);
expect(run).toEqual(["a"]);
expect(rebuildLocalPlugins(null, new Set(["a"]), () => {})).toEqual([]);
});
it("builds every stale repo when no ids are given", () => {
const dir = tempDir();
repo(dir, "Plugin-A", "a");
repo(dir, "Plugin-B", "b");
const run: string[] = [];
rebuildLocalPlugins(dir, null, (_repo: string, id: string) => run.push(id));
expect(run.sort()).toEqual(["a", "b"]);
});
});
+31
View File
@@ -0,0 +1,31 @@
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const sdk = path.join(root, "packages/plugin-sdk/dist/changelog.js");
if (!fs.existsSync(sdk)) {
console.error("check-changelog: build the SDK first (npm run build:sdk)");
process.exit(1);
}
const { parseChangelog, validateChangelog } = require(sdk);
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const problems = parseChangelog(read("CHANGELOG.md")).problems.map(
(problem) => `CHANGELOG.md: ${problem}`,
);
// The SDK is published on its own, so its newest release has to be the package version.
const sdkVersion = JSON.parse(read("packages/plugin-sdk/package.json")).version;
problems.push(
...validateChangelog(
read("packages/plugin-sdk/CHANGELOG.md"),
sdkVersion,
).map((problem) => `packages/plugin-sdk/CHANGELOG.md: ${problem}`),
);
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);
}
+51 -57
View File
@@ -2,18 +2,15 @@
/**
* Core knows no plugin by name.
*
* Fails when anything under src/ imports from plugins/, or spells a plugin id:
* a bare literal ("docker"), a plugin route ("/plugin-api/docker/..."), or an
* action, slot or permission id that starts with one ("docker.open"). The
* shell (src/ui) also may not spell a view a plugin owns (a tab, panel or
* dashboard card id from a manifest). What core needs from a plugin comes
* through the registries instead. Regex literals are read too, so
* /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
* Fails when anything under src/ spells a plugin id: a bare literal
* ("docker"), a plugin route ("/plugin-api/docker/..."), or an action, slot
* or permission id that starts with one ("docker.open"). What core needs from
* a plugin comes through the registries instead. Regex literals are read too,
* so /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
*
* Ids come from every plugin manifest and from docker/bundled-plugins.json, so
* the check still means something once plugins live in their own repos. An
* action, slot or extension point id a plugin owns ("terminal.open") counts
* too, whatever its prefix. electron/ and vite.config.ts are read as well.
* Plugins live in their own repos, so the ids are the official ones below
* plus anything in docker/bundled-plugins.json. electron/ and vite.config.ts
* are read as well.
*
* src/backend/tests, src/ui/tests and src/ui/locales are exempt, and so is
* src/backend/upgrade/: the one-time
@@ -29,16 +26,50 @@
const fs = require("node:fs");
const path = require("node:path");
const { owners } = require("./check-plugin-uses.cjs");
const SSH_TERMS = new Set(["totp"]);
const OFFICIAL_IDS = [
"acme-ssl",
"ai",
"alerts",
"automations",
"docker",
"file-manager",
"fleets",
"homepage",
"host-metrics",
"ldap",
"network-topology",
"opkssh",
"proxmox",
"remote-desktop",
"secret-sources",
"serial",
"session-recording",
"session-sharing",
"snippets",
"ssh-terminal",
"sso",
"step-ca",
"tailscale",
"telemetry",
"termix-identity",
"tmux-monitor",
"totp",
"tunnels",
"vault",
"wake-on-lan",
"warpgate",
"web-endpoint",
"webauthn",
"workspaces",
];
function paths(root) {
const src = path.join(root, "src");
return {
root,
src,
ui: path.join(src, "ui"),
plugins: path.join(root, "plugins"),
bundled: path.join(root, "docker", "bundled-plugins.json"),
extra: [path.join(root, "electron"), path.join(root, "vite.config.ts")],
exempt: [
@@ -51,37 +82,15 @@ function paths(root) {
};
}
function manifests(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dir, entry.name, "manifest.json"))
.filter((file) => fs.existsSync(file))
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
}
function names(pluginsDir, bundledFile) {
const ids = new Set();
const views = new Set();
function pluginIds(bundledFile) {
const ids = new Set(OFFICIAL_IDS);
if (bundledFile && fs.existsSync(bundledFile)) {
for (const entry of JSON.parse(fs.readFileSync(bundledFile, "utf8"))
.plugins ?? []) {
if (typeof entry?.id === "string") ids.add(entry.id);
}
}
for (const manifest of manifests(pluginsDir)) {
ids.add(manifest.id);
const contributes = manifest.contributes ?? {};
for (const list of [
contributes.tabs,
contributes.panels,
contributes.dashboardCards,
]) {
for (const view of list ?? []) views.add(view.id);
}
}
return { ids, views };
return ids;
}
function walk(dir, exempt, out) {
@@ -131,9 +140,8 @@ function regexLiterals(source) {
}
function scan(root = path.resolve(__dirname, "..")) {
const { src, ui, plugins, bundled, extra, exempt, sshConnect } = paths(root);
const { ids, views } = names(plugins, bundled);
const owned = fs.existsSync(plugins) ? owners(plugins) : new Map();
const { src, bundled, extra, exempt, sshConnect } = paths(root);
const ids = pluginIds(bundled);
const found = {};
const add = (file, what) => {
const key = path.relative(root, file).replaceAll("\\", "/");
@@ -150,23 +158,10 @@ function scan(root = path.resolve(__dirname, "..")) {
.split("\n")
.filter((line) => !/plugin-id-ok: \S/.test(line))
.join("\n");
const inShell = file.startsWith(ui + path.sep);
const inConnect = file.startsWith(sshConnect + path.sep);
for (const match of source.matchAll(
/(?:from|import)\s*\(?\s*["']([^"']+)["']/g,
)) {
const target = match[1].startsWith(".")
? path.resolve(path.dirname(file), match[1])
: "";
if (target.startsWith(plugins + path.sep)) {
add(file, `import ${match[1]}`);
}
}
for (const text of literals(source)) {
const sshTerm = inConnect && SSH_TERMS.has(text);
if ((ids.has(text) && !sshTerm) || (inShell && views.has(text))) {
add(file, text);
}
if (ids.has(text) && !sshTerm) add(file, text);
for (const route of text.matchAll(
/\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g,
)) {
@@ -174,7 +169,6 @@ function scan(root = path.resolve(__dirname, "..")) {
}
const prefix = /^([a-z][a-z0-9-]*)\.[a-zA-Z]/.exec(text);
if (prefix && ids.has(prefix[1])) add(file, text);
if (owned.has(text)) add(file, `${text} (owned by ${owned.get(text)})`);
}
for (const pattern of regexLiterals(source)) {
for (const route of pattern.matchAll(
+12 -24
View File
@@ -19,14 +19,7 @@ afterEach(() => {
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "core-plugin-ids-"));
const all = {
"plugins/docker/manifest.json": JSON.stringify({
id: "docker",
contributes: { tabs: [{ id: "docker-tab" }] },
}),
...files,
};
for (const [file, content] of Object.entries(all)) {
for (const [file, content] of Object.entries(files)) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
@@ -69,40 +62,35 @@ describe("scan", () => {
});
});
it("fails a bare id, and a view id in the shell", () => {
it("fails a bare id and an id-prefixed action, but not in tests", () => {
const dir = fixture({
"src/backend/id.ts": 'if (pluginId === "docker") {}\n',
"src/ui/view.ts": 'open("docker-tab");\n',
"src/ui/action.ts": 'run("docker.open");\n',
"src/ui/tests/skip.ts": 'open("docker");\n',
});
expect(scan(dir)).toEqual({
"src/backend/id.ts": ["docker"],
"src/ui/view.ts": ["docker-tab"],
"src/ui/action.ts": ["docker.open"],
});
});
});
describe("scan beyond src with plugins elsewhere", () => {
it("knows the bundled ids even when plugins/ is empty", () => {
describe("scan beyond src", () => {
it("knows ids from bundled-plugins.json", () => {
const dir = fixture({
"docker/bundled-plugins.json": JSON.stringify({
plugins: [{ id: "tunnels", source: "workspace" }],
plugins: [{ id: "extra-thing", source: "tmxplug" }],
}),
"src/ui/x.ts": 'const route = "/plugin-ws/tunnels/c2s";\n',
"src/ui/x.ts": 'const route = "/plugin-ws/extra-thing/c2s";\n',
});
fs.rmSync(path.join(dir, "plugins"), { recursive: true, force: true });
expect(scan(dir)["src/ui/x.ts"]).toContain("/plugin-ws/tunnels");
expect(scan(dir)["src/ui/x.ts"]).toContain("/plugin-ws/extra-thing");
});
it("reads electron and catches an action a plugin owns", () => {
it("reads electron", () => {
const dir = fixture({
"plugins/docker/src/frontend/index.tsx":
'app.registerAction("terminal.open", fn);\n',
"electron/main.cjs": 'invoke("terminal.open");\n',
"electron/main.cjs": 'invoke("/plugin-api/tunnels/list");\n',
});
expect(scan(dir)["electron/main.cjs"]).toEqual([
"terminal.open (owned by docker)",
]);
expect(scan(dir)["electron/main.cjs"]).toEqual(["/plugin-api/tunnels"]);
});
it("skips a line marked plugin-id-ok with a reason", () => {
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Fails when core reads an env var that env.catalog.json does not list. The
// docs site builds its env var page from that file.
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const DIRS = ["src/backend", "electron"];
const PATTERNS = [
/process\.env\.([A-Z][A-Z0-9_]*)/g,
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
/_ENV\s*=\s*["']([A-Z][A-Z0-9_]*)["']/g,
];
function walk(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === "tests") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (
/\.(ts|cjs|mjs|js)$/.test(entry.name) &&
!/\.test\./.test(entry.name)
)
out.push(full);
}
return out;
}
function readNames(root = ROOT) {
const found = new Map();
for (const dir of DIRS) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) continue;
for (const file of walk(abs)) {
const text = fs.readFileSync(file, "utf8");
for (const pattern of PATTERNS) {
for (const match of text.matchAll(pattern)) {
if (!found.has(match[1]))
found.set(match[1], path.relative(root, file));
}
}
}
}
return found;
}
function main() {
const catalog = JSON.parse(
fs.readFileSync(path.join(ROOT, "env.catalog.json"), "utf8"),
);
const listed = new Set(catalog.vars.map((v) => v.name));
const problems = [];
for (const v of catalog.vars) {
if (!catalog.groups[v.group])
problems.push(`${v.name}: unknown group "${v.group}"`);
if (!v.description?.trim()) problems.push(`${v.name}: no description`);
}
for (const [name, file] of readNames()) {
if (!listed.has(name))
problems.push(
`${name} is read in ${file} but missing from env.catalog.json`,
);
}
if (problems.length) {
for (const p of problems) console.error(` ${p}`);
process.exit(1);
}
console.log(`env catalog ok (${listed.size} vars)`);
}
if (require.main === module) main();
module.exports = { readNames, PATTERNS };
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { readNames } = require("./check-env-catalog.cjs") as {
readNames: (root: string) => Map<string, string>;
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "env-catalog-"));
for (const [file, content] of Object.entries(files)) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
return root;
}
describe("readNames", () => {
it("finds every way core reads an env var", () => {
const dir = fixture({
"src/backend/a.ts": [
"const a = process.env.FIRST_VAR;",
'const b = process.env["SECOND_VAR"];',
"const c = env.THIRD_VAR;",
'export const X_ENV =\n "FOURTH_VAR";',
].join("\n"),
"electron/main.cjs": "process.env.DESKTOP_VAR",
});
expect([...readNames(dir).keys()].sort()).toEqual([
"DESKTOP_VAR",
"FIRST_VAR",
"FOURTH_VAR",
"SECOND_VAR",
"THIRD_VAR",
]);
});
it("skips tests", () => {
const dir = fixture({
"src/backend/tests/a.test.ts": "process.env.ONLY_IN_TESTS",
"src/backend/b.test.ts": "process.env.ALSO_TESTS",
});
expect(readNames(dir).size).toBe(0);
});
});
-228
View File
@@ -1,228 +0,0 @@
#!/usr/bin/env node
/**
* A plugin's strings live in its own locales/en.json.
*
* A plugin's i18next namespace falls back to core's, so a plugin can use
* `common.*` without copying it. Any other key a plugin uses has to be in the
* plugin's own en.json: one that only resolves through the fallback breaks
* the moment core renames or drops it, and core cannot tell it is in use.
*
* Reads t("...") calls and i18n key props (titleKey, labelKey and the rest)
* in plugins/<id>/src, and the *Key values in plugins/<id>/manifest.json.
* A key with a namespace ("docker:hosts.x") is checked against that plugin.
*
* --list prints what is missing per plugin, with whether core has the key.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const PLUGINS = path.join(ROOT, "plugins");
const CORE_LOCALE = path.join(ROOT, "src", "ui", "locales", "en.json");
const SHARED_PREFIXES = ["common."];
const KEY = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
const KEY_PROPS = new Set([
"titleKey",
"labelKey",
"descriptionKey",
"noHostMessageKey",
"paletteKey",
"hintKey",
"messageKey",
"placeholderKey",
"tooltipKey",
"i18nKey",
"enableLabelKey",
"enableDescriptionKey",
"consequenceKey",
"groupKey",
"emptyKey",
"nameKey",
]);
const PLURAL_SUFFIXES = [
"_zero",
"_one",
"_two",
"_few",
"_many",
"_other",
"_plural",
];
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
/**
* Whether a dotted key resolves in a locale tree, the way i18next finds it:
* a segment may itself hold dots ("services.use"), and a last segment may be
* a plural form.
*/
function hasKey(tree, key) {
const find = (node, parts) => {
if (parts.length === 0) return node !== undefined;
if (!node || typeof node !== "object") return false;
for (let end = parts.length; end >= 1; end--) {
const name = parts.slice(0, end).join(".");
if (name in node && find(node[name], parts.slice(end))) return true;
if (
end === parts.length &&
PLURAL_SUFFIXES.some((suffix) => `${name}${suffix}` in node)
) {
return true;
}
}
return false;
};
return find(tree, key.split("."));
}
function isShared(key) {
return SHARED_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(full);
}
return out;
}
/** Drops comments, so an example in one is not read as a call. */
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/**
* Every literal i18n key a source file uses. A prefix a key is built from at
* run time ends in ".*".
*/
function keysInSource(text) {
const source = stripComments(text);
const keys = new Set();
for (const match of source.matchAll(
/\bt\(\s*(["'])((?:(?!\1)[^\\\n$`])+)\1/g,
)) {
keys.add(match[2]);
}
for (const match of source.matchAll(
/\b([A-Za-z]+Key)\s*[:=]\s*\{?\s*(["'])((?:(?!\2)[^\\\n])+)\2/g,
)) {
if (KEY_PROPS.has(match[1])) keys.add(match[3]);
}
// A key built at run time: `hosts.sharing.levels.${level}.label` needs the
// whole hosts.sharing.levels subtree. A one-segment prefix says too little
// to check.
for (const match of source.matchAll(
/\bt\(\s*`((?:[A-Za-z0-9_-]+\.){2,})\$\{/g,
)) {
keys.add(`${match[1]}*`);
}
return [...keys].filter((key) =>
KEY.test(key.replace(/^[a-z0-9-]+:/, "").replace(/\.\*$/, "")),
);
}
function keysInManifest(value, out = new Set()) {
if (Array.isArray(value)) {
for (const item of value) keysInManifest(item, out);
} else if (value && typeof value === "object") {
for (const [name, child] of Object.entries(value)) {
if (KEY_PROPS.has(name) && typeof child === "string") out.add(child);
else keysInManifest(child, out);
}
}
return out;
}
function pluginIds(pluginsDir) {
return fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((id) => fs.existsSync(path.join(pluginsDir, id, "manifest.json")))
.sort();
}
/**
* Keys each plugin uses but does not define, other than the shared ones.
* Returns { [pluginId]: [{ key, file, inCore }] }.
*/
function scan({ pluginsDir = PLUGINS, coreLocale = CORE_LOCALE } = {}) {
const core = fs.existsSync(coreLocale) ? readJson(coreLocale) : {};
const ids = pluginIds(pluginsDir);
const locales = new Map(
ids.map((id) => {
const file = path.join(pluginsDir, id, "locales", "en.json");
return [id, fs.existsSync(file) ? readJson(file) : {}];
}),
);
const missing = {};
const check = (owner, rawKey, file) => {
const colon = rawKey.indexOf(":");
const namespace = colon > 0 ? rawKey.slice(0, colon) : owner;
const key = colon > 0 ? rawKey.slice(colon + 1) : rawKey;
if (isShared(key)) return;
const tree = locales.get(namespace);
const lookup = key.endsWith(".*") ? key.slice(0, -2) : key;
if (tree && hasKey(tree, lookup)) return;
const inCore = hasKey(core, lookup);
// A bare word that is nowhere is a local helper named t, not a string.
if (!key.includes(".") && !inCore) return;
const list = (missing[owner] ??= []);
if (!list.some((entry) => entry.key === rawKey)) {
list.push({
key: rawKey,
file: path.relative(ROOT, file).replaceAll("\\", "/"),
inCore,
});
}
};
for (const id of ids) {
const manifestFile = path.join(pluginsDir, id, "manifest.json");
for (const key of keysInManifest(readJson(manifestFile))) {
if (KEY.test(key.replace(/^[a-z0-9-]+:/, ""))) {
check(id, key, manifestFile);
}
}
for (const file of walk(path.join(pluginsDir, id, "src"))) {
for (const key of keysInSource(fs.readFileSync(file, "utf8"))) {
check(id, key, file);
}
}
}
for (const list of Object.values(missing)) {
list.sort((a, b) => a.key.localeCompare(b.key));
}
return missing;
}
function main() {
const missing = scan();
if (process.argv.includes("--list")) {
console.log(JSON.stringify(missing, null, 2));
return;
}
const entries = Object.entries(missing);
if (entries.length === 0) return;
console.error(
"Plugins use i18n keys their own locales/en.json does not define:",
);
for (const [id, list] of entries) {
for (const { key, file } of list) {
console.error(` ${id}: ${key} (${file})`);
}
}
process.exit(1);
}
if (require.main === module) main();
module.exports = { scan, keysInSource, hasKey };
-127
View File
@@ -1,127 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { scan, keysInSource, hasKey } =
require("./check-plugin-locales.cjs") as {
scan: (options: {
pluginsDir: string;
coreLocale: string;
}) => Record<string, Array<{ key: string; file: string; inCore: boolean }>>;
keysInSource: (source: string) => string[];
hasKey: (tree: unknown, key: string) => boolean;
};
let dir: string | null = null;
afterEach(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
dir = null;
});
function fixture(files: Record<string, unknown>) {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-locales-"));
for (const [file, content] of Object.entries(files)) {
const full = path.join(dir, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(
full,
typeof content === "string" ? content : JSON.stringify(content),
);
}
return {
pluginsDir: path.join(dir, "plugins"),
coreLocale: path.join(dir, "core.json"),
};
}
describe("keysInSource", () => {
it("reads t() calls, key props and run time prefixes, not comments", () => {
const keys = keysInSource(
[
'const a = t("hosts.one");',
"const b = i18next.t('docker:hosts.two', { x: 1 });",
'app.registerTab("x", X, { titleKey: "nav.three" });',
'<EmptyState messageKey="hosts.four" />',
"t(`hosts.sharing.levels.${level}.label`);",
"t(`hosts.${label}`);",
'// t("hosts.commented")',
'storageKey: "not.a.string.key"',
].join("\n"),
);
expect(keys.sort()).toEqual(
[
"docker:hosts.two",
"hosts.four",
"hosts.one",
"hosts.sharing.levels.*",
"nav.three",
].sort(),
);
});
});
describe("hasKey", () => {
it("finds nested keys, dotted names and plural forms", () => {
const tree = {
a: { b: "x" },
permissions: { "services.use": { title: "t" } },
items: { count_one: "1", count_other: "n" },
};
expect(hasKey(tree, "a.b")).toBe(true);
expect(hasKey(tree, "a")).toBe(true);
expect(hasKey(tree, "permissions.services.use.title")).toBe(true);
expect(hasKey(tree, "items.count")).toBe(true);
expect(hasKey(tree, "a.c")).toBe(false);
});
});
describe("scan", () => {
it("passes keys a plugin defines and the shared common ones", () => {
const options = fixture({
"core.json": { common: { save: "Save" }, hosts: { name: "Name" } },
"plugins/demo/manifest.json": { id: "demo", titleKey: "title" },
"plugins/demo/locales/en.json": { title: "Demo", hosts: { name: "N" } },
"plugins/demo/src/frontend/index.tsx":
't("common.save"); t("hosts.name"); t("title");',
});
expect(scan(options)).toEqual({});
});
it("fails a key that only resolves through core's fallback", () => {
const options = fixture({
"core.json": { hosts: { name: "Name" } },
"plugins/demo/manifest.json": { id: "demo" },
"plugins/demo/locales/en.json": {},
"plugins/demo/src/frontend/index.tsx": 't("hosts.name");',
});
expect(scan(options)).toEqual({
demo: [
{
key: "hosts.name",
file: expect.stringContaining("index.tsx"),
inCore: true,
},
],
});
});
it("checks a namespaced key against that plugin and a prefix as a subtree", () => {
const options = fixture({
"core.json": { hosts: { levels: { view: "View" } } },
"plugins/demo/manifest.json": { id: "demo" },
"plugins/demo/locales/en.json": {},
"plugins/demo/src/frontend/index.tsx":
't("other:known.key"); t("other:gone.key"); t(`hosts.levels.${l}`);',
"plugins/other/manifest.json": { id: "other" },
"plugins/other/locales/en.json": { known: { key: "k" } },
});
expect(scan(options).demo.map((entry) => entry.key)).toEqual([
"hosts.levels.*",
"other:gone.key",
]);
});
});
-153
View File
@@ -1,153 +0,0 @@
#!/usr/bin/env node
/**
* A plugin that calls another plugin's action, fills its slot or extends its
* extension point has to say so in manifest.json contributes.uses. Nothing
* else records that coupling: the ids are plain strings, so without it a
* plugin moved to its own repo silently loses a feature when the other one is
* renamed, and nobody can tell what depends on what.
*
* Owners are read from the code: a plugin owns the actions it registers, the
* slots it renders (ActionSlot/ComponentSlot/useSlotContributions/
* declareActionSlot) and the extension points it reads (useExtensions). An id
* nobody owns is core's and needs no declaration.
*
* --list prints what each plugin uses from which owner.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const PLUGINS = path.join(ROOT, "plugins");
// Each pattern captures a string literal or a constant's name; constants are
// resolved against the plugin's own `const NAME = "id"` declarations.
const ID = String.raw`(?:"([^"]+)"|([A-Z][A-Z0-9_]*))`;
const OWNS = [
new RegExp(String.raw`registerAction\(\s*${ID}`, "g"),
new RegExp(String.raw`declareActionSlot\(\s*\{\s*id:\s*${ID}`, "g"),
new RegExp(
String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`,
"g",
),
new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"),
new RegExp(String.raw`useExtensions(?:<[^>]*>)?\(\s*${ID}`, "g"),
new RegExp(String.raw`getExtension\(\s*${ID}`, "g"),
];
const USES = [
new RegExp(String.raw`invokeAction\(\s*${ID}`, "g"),
new RegExp(String.raw`registerSlotContribution\(\s*${ID}`, "g"),
new RegExp(String.raw`registerExtension\(\s*${ID}`, "g"),
new RegExp(
String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`,
"g",
),
new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"),
];
function sourceOf(dir) {
let text = "";
const walk = (current) => {
if (!fs.existsSync(current)) return;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const file = path.join(current, entry.name);
if (entry.isDirectory()) walk(file);
else if (/\.(ts|tsx)$/.test(entry.name)) {
text += fs.readFileSync(file, "utf8");
}
}
};
walk(path.join(dir, "src"));
return text;
}
function constants(text) {
const map = new Map();
for (const match of text.matchAll(
/const ([A-Z][A-Z0-9_]*)\s*=\s*"([^"]+)"/g,
)) {
map.set(match[1], match[2]);
}
return map;
}
function matches(patterns, text) {
const names = constants(text);
const found = new Set();
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const id = match[1] ?? names.get(match[2]);
if (id) found.add(id);
}
}
return found;
}
function collect(pluginsDir = PLUGINS) {
const plugins = fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) =>
fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")),
)
.map((entry) => {
const dir = path.join(pluginsDir, entry.name);
const manifest = JSON.parse(
fs.readFileSync(path.join(dir, "manifest.json"), "utf8"),
);
const source = sourceOf(dir);
return {
id: manifest.id,
declared: new Set(manifest.contributes?.uses ?? []),
owns: matches(OWNS, source),
uses: matches(USES, source),
};
});
const owner = new Map();
for (const plugin of plugins) {
for (const id of plugin.owns) if (!owner.has(id)) owner.set(id, plugin.id);
}
const problems = [];
const usage = {};
for (const plugin of plugins) {
for (const id of plugin.uses) {
const by = owner.get(id);
if (!by || by === plugin.id || plugin.owns.has(id)) continue;
(usage[plugin.id] ??= []).push(`${id} (${by})`);
if (!plugin.declared.has(id)) {
problems.push(
`${plugin.id} uses "${id}" from the ${by} plugin; add it to contributes.uses in plugins/${plugin.id}/manifest.json`,
);
}
}
for (const id of plugin.declared) {
if (!plugin.uses.has(id)) {
problems.push(
`${plugin.id} declares "${id}" in contributes.uses but never uses it`,
);
}
}
}
return { problems, usage, owner };
}
function main() {
const { problems, usage } = collect();
if (process.argv.includes("--list")) {
console.log(JSON.stringify(usage, null, 2));
return;
}
if (problems.length === 0) return;
for (const problem of problems) console.error(problem);
process.exit(1);
}
if (require.main === module) main();
/** Every action, slot and extension point id a plugin owns, by owner. */
function owners(pluginsDir = PLUGINS) {
return collect(pluginsDir).owner;
}
module.exports = { collect, owners };
-70
View File
@@ -1,70 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { collect } = require("./check-plugin-uses.cjs") as {
collect: (dir: string) => { problems: string[]; owner: Map<string, string> };
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function plugins(
specs: Record<string, { source: string; uses?: string[] }>,
): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-uses-"));
for (const [id, spec] of Object.entries(specs)) {
const dir = path.join(root, id);
fs.mkdirSync(path.join(dir, "src"), { recursive: true });
fs.writeFileSync(
path.join(dir, "manifest.json"),
JSON.stringify({ id, contributes: spec.uses ? { uses: spec.uses } : {} }),
);
fs.writeFileSync(path.join(dir, "src", "index.tsx"), spec.source);
}
return root;
}
describe("check-plugin-uses", () => {
it("finds owners through string literals and constants", () => {
const dir = plugins({
terminal: {
source:
'const SLOT = "terminal.toolbar";\napp.declareActionSlot({ id: SLOT });\napp.registerAction("terminal.open", f);\n',
},
});
const { owner } = collect(dir);
expect(owner.get("terminal.open")).toBe("terminal");
expect(owner.get("terminal.toolbar")).toBe("terminal");
});
it("requires another plugin's id in contributes.uses", () => {
const dir = plugins({
terminal: { source: 'app.registerAction("terminal.open", f);\n' },
files: { source: 'invokeAction("terminal.open", host);\n' },
});
expect(collect(dir).problems).toEqual([
expect.stringContaining('files uses "terminal.open" from the terminal'),
]);
});
it("passes a declared use and flags a declared one that is unused", () => {
const dir = plugins({
terminal: { source: 'app.registerAction("terminal.open", f);\n' },
files: {
source: 'invokeAction("terminal.open", host);\n',
uses: ["terminal.open", "terminal.gone"],
},
});
expect(collect(dir).problems).toEqual([
expect.stringContaining('declares "terminal.gone"'),
]);
});
});
+60 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* @termix/plugin-sdk/ui is public API, implemented by
* @termix-ssh/plugin-sdk/ui is public API, implemented by
* src/ui/plugin-host/sdk-ui.ts. Adding to it is a contract change and
* removing from it is a breaking one, so neither should happen by accident.
*
@@ -20,6 +20,7 @@ const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [
"ActionSlot",
"AddButton",
"Alert",
"AlertDescription",
"AlertDialog",
@@ -34,6 +35,7 @@ const ALLOWED = [
"AlertDialogTitle",
"AlertDialogTrigger",
"AlertTitle",
"BackButton",
"Badge",
"BarSeries",
"BarSeriesItem",
@@ -48,17 +50,25 @@ const ALLOWED = [
"CardFooter",
"CardGridCanvas",
"CardHeader",
"CardMasonry",
"CardTitle",
"Checkbox",
"ColumnCountStepper",
"ComponentSlot",
"ConfirmOptions",
"ConnectionGate",
"ConnectionGateController",
"ConnectionGateProps",
"ConnectionLogPanel",
"ConnectionLogProvider",
"ConnectionOrigin",
"ConnectionScreen",
"ConnectionStage",
"ConnectionStatus",
"ConnectionUnavailable",
"CustomKeybinding",
"DataColumn",
"DataView",
"Dialog",
"DialogClose",
"DialogContent",
@@ -69,6 +79,8 @@ const ALLOWED = [
"DialogPortal",
"DialogTitle",
"DialogTrigger",
"DocsLink",
"DocsLinkProps",
"DropdownMenu",
"DropdownMenuCheckboxItem",
"DropdownMenuContent",
@@ -84,20 +96,28 @@ const ALLOWED = [
"DropdownMenuSubContent",
"DropdownMenuSubTrigger",
"DropdownMenuTrigger",
"EditingWidth",
"EmptyState",
"FOLDER_COLORS",
"Facts",
"FakeSwitch",
"Field",
"FieldPair",
"FormFooter",
"FullScreenAppPhase",
"FullScreenAppWrapper",
"GridCardCatalogEntry",
"GridColSpan",
"GridColumns",
"GridLayout",
"GridSlot",
"GroupHeading",
"HostDefaultBadge",
"HostDefaultField",
"HostFeatureFields",
"HostFeatureFieldsProps",
"HostKeyVerificationDialog",
"InlineView",
"Input",
"Kbd",
"KbdKey",
@@ -108,14 +128,31 @@ const ALLOWED = [
"Label",
"LineChart",
"LineChartSeries",
"ListBadge",
"ListRow",
"ListRowAction",
"ListRowFolder",
"ListRowTone",
"LogContext",
"LogEntry",
"MFAPromptMode",
"ManagerCardError",
"ManagerCardShell",
"ManagerSearch",
"Meter",
"MetricCard",
"MiniStat",
"NumberField",
"PANEL",
"PanePrompt",
"PROMPT_BUTTON",
"PROMPT_DESTRUCTIVE_BUTTON",
"PROMPT_PRIMARY_BUTTON",
"PanelDensity",
"PanelList",
"PanelSearch",
"PanelShell",
"PanelViewMode",
"PassphraseDialog",
"PasswordInput",
"PluginComponent",
@@ -127,14 +164,18 @@ const ALLOWED = [
"RadialGauge",
"RadialGaugeProps",
"RecentActivityItem",
"Repeater",
"RobustClipboardProvider",
"SSHAuthDialog",
"ScrollArea",
"ScrollBar",
"SecretField",
"SectionCard",
"Segmented",
"Select",
"Select2",
"SelectContent",
"SelectField",
"SelectGroup",
"SelectItem",
"SelectLabel",
@@ -158,8 +199,16 @@ const ALLOWED = [
"Sparkline",
"SparklineProps",
"StatRow",
"SurfaceKind",
"SurfaceScope",
"Switch",
"SwitchRow",
"TOTPDialog",
"TabStrip",
"TabStripItem",
"TagInput",
"TextAreaField",
"TextField",
"Textarea",
"TextareaProps",
"Tooltip",
@@ -167,7 +216,9 @@ const ALLOWED = [
"TooltipProvider",
"TooltipTrigger",
"UptimeInfo",
"UsagePair",
"VersionInfo",
"ViewToggle",
"WebSocketConnectionTarget",
"WidgetTitle",
"buildOriginWsUrl",
@@ -198,13 +249,17 @@ const ALLOWED = [
"runAdaptiveBackgroundTask",
"runAdaptivePolling",
"runVisibleInterval",
"usageBarColor",
"usageColor",
"useAdaptivePolling",
"useAppTheme",
"useConfirmation",
"useConfirm",
"useConnectionGate",
"useConnectionLog",
"useIsDefaultsEditor",
"useIsMobile",
"useOptionalConnectionLog",
"useSurfaceKind",
"useTabs",
"useTabsSafe",
];
@@ -309,7 +364,7 @@ function main() {
const before = new Set(saved.split("\n"));
const after = new Set(current.split("\n"));
console.error(
"@termix/plugin-sdk/ui changed shape. If that is deliberate, run: node scripts/check-sdk-ui-exports.cjs --update-snapshot",
"@termix-ssh/plugin-sdk/ui changed shape. If that is deliberate, run: node scripts/check-sdk-ui-exports.cjs --update-snapshot",
);
for (const line of saved.split("\n")) {
if (line && !after.has(line)) console.error(` - ${line.slice(0, 200)}`);
@@ -328,13 +383,13 @@ function main() {
if (added.length === 0 && removed.length === 0) return;
if (added.length > 0) {
console.error(
"@termix/plugin-sdk/ui exports names that are not on the allowlist in scripts/check-sdk-ui-exports.cjs:",
"@termix-ssh/plugin-sdk/ui exports names that are not on the allowlist in scripts/check-sdk-ui-exports.cjs:",
);
for (const name of added) console.error(` + ${name}`);
}
if (removed.length > 0) {
console.error(
"@termix/plugin-sdk/ui no longer exports these allowlisted names (a breaking change for plugins):",
"@termix-ssh/plugin-sdk/ui no longer exports these allowlisted names (a breaking change for plugins):",
);
for (const name of removed) console.error(` - ${name}`);
}
+3 -8
View File
@@ -2,7 +2,7 @@
/**
* Tests mirror the source tree in their own directories and never sit next
* to the code they test. A *.test.ts(x) file may only live under
* src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/.
* src/backend/tests/, src/ui/tests/ or scripts/.
*/
const fs = require("node:fs");
@@ -20,12 +20,7 @@ const SKIP = new Set([
".turbo",
]);
const TEST_FILE = /\.test\.tsx?$/;
const ALLOWED = [
/^src\/backend\/tests\//,
/^src\/ui\/tests\//,
/^scripts\//,
/^plugins\/[^/]+\/tests\//,
];
const ALLOWED = [/^src\/backend\/tests\//, /^src\/ui\/tests\//, /^scripts\//];
function walk(dir, root, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -50,7 +45,7 @@ function main() {
const found = misplaced();
if (found.length === 0) return;
console.error(
"Tests belong in src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/, mirroring the source:",
"Tests belong in src/backend/tests/, src/ui/tests/ or scripts/, mirroring the source:",
);
for (const file of found) console.error(` ${file}`);
process.exit(1);
+2 -5
View File
@@ -32,9 +32,8 @@ describe("misplaced", () => {
"src/backend/tests/utils/a.test.ts",
"src/ui/tests/lib/b.test.tsx",
"scripts/c.test.ts",
"plugins/docker/tests/backend/d.test.ts",
"plugins/docker/node_modules/pkg/e.test.ts",
"plugins/docker/dist/f.test.ts",
"node_modules/pkg/e.test.ts",
"dist/f.test.ts",
]);
expect(misplaced(dir)).toEqual([]);
});
@@ -43,13 +42,11 @@ describe("misplaced", () => {
const dir = fixture([
"src/backend/utils/a.test.ts",
"src/ui/lib/b.test.tsx",
"plugins/docker/src/frontend/c.test.tsx",
"packages/plugin-sdk/src/d.test.ts",
"src/backend/utils/not-a-test.ts",
]);
expect(misplaced(dir)).toEqual([
"packages/plugin-sdk/src/d.test.ts",
"plugins/docker/src/frontend/c.test.tsx",
"src/backend/utils/a.test.ts",
"src/ui/lib/b.test.tsx",
]);
-130
View File
@@ -1,130 +0,0 @@
const API = "https://api.crowdin.com/api/v2";
const ENGINE_ID = 649248; // Google Translate MT engine
const PROJECT_NAME = "termix-ssh";
const SOURCE_FILE = "en.json";
function token() {
const t = process.env.CROWDIN_API_KEY;
if (!t) throw new Error("CROWDIN_API_KEY is not set");
return t;
}
async function request(method, pathname, body) {
const res = await fetch(`${API}${pathname}`, {
method,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${method} ${pathname} -> ${res.status}: ${text}`);
}
return res.json();
}
async function paged(pathname) {
const items = [];
let offset = 0;
const limit = 500;
for (;;) {
const sep = pathname.includes("?") ? "&" : "?";
const page = await request(
"GET",
`${pathname}${sep}limit=${limit}&offset=${offset}`,
);
const batch = (page.data || []).map((row) => row.data);
items.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
return items;
}
async function resolveProjectId() {
const projects = await paged("/projects");
const match = projects.find(
(p) => p.identifier === PROJECT_NAME || p.name === PROJECT_NAME,
);
if (!match) throw new Error(`project "${PROJECT_NAME}" not found`);
return { id: match.id, targetLanguageIds: match.targetLanguageIds || [] };
}
// Core is /en.json and each plugin is /plugins/<id>/locales/en.json.
function isSourceFile(file) {
return (
file.path === `/${SOURCE_FILE}` ||
/^\/plugins\/[^/]+\/locales\/en\.json$/.test(file.path || "")
);
}
async function resolveFileIds(projectId) {
const files = await paged(`/projects/${projectId}/files`);
const ids = files.filter(isSourceFile).map((f) => f.id);
if (ids.length === 0)
throw new Error(`no "${SOURCE_FILE}" source files found in project`);
return ids;
}
async function pollPreTranslation(projectId, preTranslationId) {
for (;;) {
const { data } = await request(
"GET",
`/projects/${projectId}/pre-translations/${preTranslationId}`,
);
if (data.status === "finished") return data;
if (data.status === "failed" || data.status === "canceled") {
throw new Error(
`pre-translation ${data.status} (progress ${data.progress}%)`,
);
}
await new Promise((r) => setTimeout(r, 5000));
}
}
async function main() {
const { id: projectId, targetLanguageIds } = await resolveProjectId();
if (targetLanguageIds.length === 0) {
throw new Error("project has no target languages configured");
}
const fileIds = await resolveFileIds(projectId);
console.log(
`Pre-translating project ${projectId}, ${fileIds.length} files, ${targetLanguageIds.length} languages`,
);
// Translation memory first so strings that moved between files keep their
// existing translations, then machine translation for whatever is left.
const passes = [
{ method: "tm", translateUntranslatedOnly: true },
{ method: "mt", engineId: ENGINE_ID, scope: "untranslated" },
];
for (const pass of passes) {
const { data } = await request(
"POST",
`/projects/${projectId}/pre-translations`,
{ languageIds: targetLanguageIds, fileIds, ...pass },
);
const result = await pollPreTranslation(projectId, data.identifier);
console.log(
`${pass.method} pre-translation finished (${result.progress}%)`,
);
}
}
module.exports = {
resolveProjectId,
resolveFileIds,
isSourceFile,
pollPreTranslation,
};
if (require.main === module) {
main().catch((err) => {
console.error(`crowdin-pretranslate: ${err.message}`);
process.exit(1);
});
}
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { isSourceFile } = require("./crowdin-pretranslate.cjs") as {
isSourceFile: (file: { path?: string }) => boolean;
};
describe("isSourceFile", () => {
it("matches the core and plugin source files", () => {
expect(isSourceFile({ path: "/en.json" })).toBe(true);
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/en.json" })).toBe(
true,
);
});
it("skips anything else", () => {
expect(isSourceFile({ path: "/src/ui/locales/en.json" })).toBe(false);
expect(isSourceFile({ path: "/plugins/a/b/locales/en.json" })).toBe(false);
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/de.json" })).toBe(
false,
);
expect(isSourceFile({})).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
SERVICES,
appRunArgs,
serviceEnv,
serviceRunArgs,
} from "./lib/dev-docker.mjs";
import { parseDevArgs } from "./lib/dev-runner.mjs";
describe("serviceRunArgs", () => {
it("runs a service on the dev network with its port, env and volume", () => {
const args = serviceRunArgs(SERVICES.postgres);
expect(args.slice(0, 6)).toEqual([
"run",
"-d",
"--name",
"termix-dev-postgres",
"--network",
"termix-dev",
]);
expect(args).toContain("55432:5432");
expect(args).toContain("POSTGRES_PASSWORD=termix");
expect(args).toContain("termix-dev-postgres:/var/lib/postgresql/data");
expect(args.at(-1)).toBe("postgres:16");
});
it("adds no volume for guacd", () => {
expect(serviceRunArgs(SERVICES.guacd)).not.toContain("-v");
});
});
describe("serviceEnv", () => {
it("is empty for plain sqlite", () => {
expect(serviceEnv(parseDevArgs([]), false)).toEqual({});
});
it("points a local backend at the published ports", () => {
expect(
serviceEnv(parseDevArgs(["--db", "postgres", "--guacd"]), false),
).toEqual({
DATABASE_DIALECT: "postgres",
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
GUACD_HOST: "127.0.0.1",
GUACD_PORT: "4822",
});
});
it("points a container at the other containers by name", () => {
expect(
serviceEnv(parseDevArgs(["--db", "mysql", "--guacd"]), true),
).toEqual({
DATABASE_DIALECT: "mysql",
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
GUACD_HOST: "termix-dev-guacd",
GUACD_PORT: "4822",
});
});
});
describe("appRunArgs", () => {
it("mounts the data and the staged plugins", () => {
const args = appRunArgs({
port: 9000,
env: { GUACD_HOST: "termix-dev-guacd" },
dataMount: "termix-dev-data",
pluginsDir: "/repo/dist/plugins",
});
expect(args).toContain("9000:8080");
expect(args).toContain("GUACD_HOST=termix-dev-guacd");
expect(args).toContain("termix-dev-data:/app/data");
expect(args).toContain("/repo/dist/plugins:/app/dist/plugins");
expect(args.at(-1)).toBe("termix:dev");
});
});
+210
View File
@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
createDebouncer,
createSerialQueue,
dataDirFor,
findBuildablePlugins,
isPluginEdit,
isPluginSourceChange,
needsDocker,
parseDevArgs,
parseTscStatus,
} from "./lib/dev-runner.mjs";
const cleanups: Array<() => void> = [];
afterEach(() => {
vi.useRealTimers();
while (cleanups.length) cleanups.pop()!();
});
describe("parseTscStatus", () => {
it("reads the error count from a watch summary", () => {
expect(
parseTscStatus(
"12:00:00 PM - Found 0 errors. Watching for file changes.",
),
).toBe(0);
expect(parseTscStatus("Found 1 error. Watching for file changes.")).toBe(1);
expect(parseTscStatus("src/a.ts(1,1): error TS2304: x")).toBeNull();
});
});
describe("isPluginSourceChange", () => {
it("accepts sources and skips build output", () => {
expect(
isPluginSourceChange(["src", "backend", "index.ts"].join("\\")),
).toBe(true);
expect(isPluginSourceChange("locales/en.json")).toBe(true);
expect(isPluginSourceChange("manifest.json")).toBe(true);
expect(isPluginSourceChange("dist/backend.js")).toBe(false);
expect(isPluginSourceChange("node_modules/x/index.js")).toBe(false);
expect(isPluginSourceChange("notes.txt")).toBe(false);
expect(isPluginSourceChange(null)).toBe(false);
});
});
describe("isPluginEdit", () => {
function repo() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-edit-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(path.join(dir, "src", "frontend"), { recursive: true });
fs.writeFileSync(path.join(dir, "src", "frontend", "index.ts"), "");
return dir;
}
it("ignores a folder being read", () => {
expect(isPluginEdit(repo(), "src/frontend", () => true)).toBe(false);
});
it("rebuilds an edited file only while the build is out of date", () => {
const dir = repo();
expect(isPluginEdit(dir, "src/frontend/index.ts", () => true)).toBe(true);
expect(isPluginEdit(dir, "src/frontend/index.ts", () => false)).toBe(false);
});
it("rebuilds after a source file is deleted", () => {
expect(isPluginEdit(repo(), "src/gone.ts", () => false)).toBe(true);
});
it("skips paths that do not feed the build", () => {
expect(isPluginEdit(repo(), "dist/index.js", () => true)).toBe(false);
});
});
describe("findBuildablePlugins", () => {
it("lists repos with a manifest and installed dependencies", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-dev-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
const make = (name: string, id: string, deps = true) => {
const repo = path.join(dir, name);
fs.mkdirSync(repo);
fs.writeFileSync(
path.join(repo, "manifest.json"),
JSON.stringify({ id }),
);
if (deps) fs.mkdirSync(path.join(repo, "node_modules"));
};
make("Plugin-B", "b");
make("Plugin-A", "a");
make("Plugin-C", "c", false);
fs.mkdirSync(path.join(dir, "notes"));
expect(findBuildablePlugins(dir).map((p) => p.id)).toEqual(["a", "b"]);
expect(findBuildablePlugins(null)).toEqual([]);
});
});
describe("createSerialQueue", () => {
it("runs one task at a time and survives a failure", async () => {
const queue = createSerialQueue();
const order: string[] = [];
const first = queue(async () => {
await new Promise((r) => setTimeout(r, 10));
order.push("first");
throw new Error("boom");
});
const second = queue(async () => order.push("second"));
await expect(first).rejects.toThrow("boom");
await second;
expect(order).toEqual(["first", "second"]);
});
});
describe("createDebouncer", () => {
it("fires once per quiet key and again for a change mid-run", async () => {
vi.useFakeTimers();
let release!: () => void;
const calls: string[] = [];
const trigger = createDebouncer(100, async (key: string) => {
calls.push(key);
if (calls.length === 1) await new Promise<void>((r) => (release = r));
});
trigger("a");
trigger("a");
await vi.advanceTimersByTimeAsync(100);
expect(calls).toEqual(["a"]);
trigger("a");
await vi.advanceTimersByTimeAsync(100);
expect(calls).toEqual(["a"]);
release();
await vi.advanceTimersByTimeAsync(100);
expect(calls).toEqual(["a", "a"]);
trigger.cancel();
});
});
describe("parseDevArgs", () => {
it("defaults to the web target with sqlite and local plugins", () => {
expect(parseDevArgs([])).toMatchObject({
target: "web",
db: "sqlite",
local: true,
guacd: false,
fresh: false,
port: 8081,
});
});
it("reads a target and options in any order", () => {
expect(
parseDevArgs(["--db", "Postgres", "electron", "--guacd", "--fresh"]),
).toMatchObject({
target: "electron",
db: "postgres",
guacd: true,
fresh: true,
});
expect(
parseDevArgs(["docker", "--port", "9000", "--no-cache", "--no-local"]),
).toMatchObject({
target: "docker",
port: 9000,
noCache: true,
local: false,
});
expect(parseDevArgs(["--plugins", "../x"]).pluginsDir).toBe("../x");
});
it("refuses unknown or incomplete options", () => {
expect(() => parseDevArgs(["--bogus"])).toThrow(/Unknown option/);
expect(() => parseDevArgs(["--db", "oracle"])).toThrow(/--db/);
expect(() => parseDevArgs(["--db"])).toThrow(/needs a value/);
expect(() => parseDevArgs(["--port", "abc"])).toThrow(/port/);
expect(() => parseDevArgs(["--fresh", "--data", "x"])).toThrow(/together/);
});
});
describe("dataDirFor", () => {
it("keeps each engine and fresh runs in their own folder", () => {
expect(dataDirFor(parseDevArgs([]))).toBeNull();
expect(dataDirFor(parseDevArgs(["--db", "mysql"]))).toBe("db/data-mysql");
expect(dataDirFor(parseDevArgs(["--fresh"]))).toBe("db/data-fresh");
expect(dataDirFor(parseDevArgs(["--fresh", "--db", "postgres"]))).toBe(
"db/data-fresh-postgres",
);
expect(dataDirFor(parseDevArgs(["--data", "x"]))).toBe("x");
expect(dataDirFor(parseDevArgs(["electron"]))).toBe("db/data-desktop");
expect(dataDirFor(parseDevArgs(["electron", "--db", "mysql"]))).toBe(
"db/data-desktop-mysql",
);
expect(dataDirFor(parseDevArgs(["electron", "--fresh"]))).toBe(
"db/data-fresh-desktop",
);
});
});
describe("needsDocker", () => {
it("is only true when something runs in a container", () => {
expect(needsDocker(parseDevArgs([]))).toBe(false);
expect(needsDocker(parseDevArgs(["electron"]))).toBe(false);
expect(needsDocker(parseDevArgs(["docker"]))).toBe(true);
expect(needsDocker(parseDevArgs(["--db", "postgres"]))).toBe(true);
expect(needsDocker(parseDevArgs(["--guacd"]))).toBe(true);
});
});
+721
View File
@@ -0,0 +1,721 @@
/**
* npm run dev, one command for manual testing. Run with --help for the targets
* and options.
*
* Every target builds the SDK and stages plugins (local builds from
* ../Termix-Plugins when it exists), then:
*
* - web: compiles the backend in watch mode, starts it, then starts Vite. A
* backend or SDK change restarts the backend. A plugin change rebuilds that
* plugin and reloads it inside the running backend, and the browser swaps
* it in.
* - electron: web, plus the desktop app, restarted when electron/ changes.
* - docker: builds the production image and runs it with dist/plugins
* mounted. A plugin change rebuilds it and restarts the container. Core
* changes wait for "r", since an image build is slow.
*
* --db postgres|mysql and --guacd run those in Docker next to it.
*/
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { spawn, spawnSync, fork } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import {
HELP,
createDebouncer,
createSerialQueue,
dataDirFor,
findBuildablePlugins,
isPluginEdit,
needsDocker,
parseDevArgs,
parseTscStatus,
} from "./lib/dev-runner.mjs";
import {
APP_CONTAINER,
APP_IMAGE,
NETWORK,
SERVICES,
appRunArgs,
serviceEnv,
serviceRunArgs,
} from "./lib/dev-docker.mjs";
const require = createRequire(import.meta.url);
const {
copyLocalBuild,
localSdkBuildCommand,
} = require("./lib/bundled-plugins.cjs");
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const BACKEND_ENTRY = path.join(
root,
"dist",
"backend",
"backend",
"starter.js",
);
const STAGED_PLUGINS = path.join(root, "dist", "plugins");
const SDK_SRC = path.join(root, "packages", "plugin-sdk", "src");
const ELECTRON_DIR = path.join(root, "electron");
const RELOAD_TIMEOUT_MS = 60_000;
const SHUTDOWN_TIMEOUT_MS = 10_000;
const DOCKER_READY_TIMEOUT_MS = 180_000;
const log = (message) => console.log(`\x1b[36m[dev]\x1b[0m ${message}`);
const warn = (message) => console.log(`\x1b[33m[dev]\x1b[0m ${message}`);
let options;
const queue = createSerialQueue();
const startedServices = [];
const watchers = [];
let shuttingDown = false;
let plugins = [];
let vite = null;
let backend = null;
let electron = null;
let tsc = null;
let dockerLogs = null;
let backendEnv = {};
function run(command, args, cwd = root) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "inherit", "inherit"],
// npm is a .cmd on Windows and needs the shell, but the shell splits
// arguments on spaces, so a path like "Personal Projects" gets cut.
shell: process.platform === "win32" && !path.isAbsolute(command),
});
child.on("error", reject);
child.on("exit", (code) =>
code === 0
? resolve()
: reject(new Error(`${command} ${args.join(" ")} exited with ${code}`)),
);
});
}
function docker(args, { quiet = false } = {}) {
const result = spawnSync("docker", args, {
cwd: root,
encoding: "utf8",
stdio: quiet ? "pipe" : ["ignore", "inherit", "inherit"],
});
return { ok: result.status === 0, out: (result.stdout ?? "").trim() };
}
function watch(target, recursive, onChange) {
watchers.push(fs.watch(target, { recursive }, onChange));
}
function appUrl() {
if (options.target === "docker") return `http://localhost:${options.port}`;
return vite?.resolvedUrls?.local?.[0] ?? "http://localhost:5173";
}
function openBrowser(url) {
const [command, args] =
process.platform === "win32"
? ["cmd", ["/c", "start", "", url]]
: process.platform === "darwin"
? ["open", [url]]
: ["xdg-open", [url]];
spawn(command, args, { stdio: "ignore", detached: true }).unref();
}
// Docker side containers
function assertDocker() {
if (!docker(["info"], { quiet: true }).ok) {
throw new Error(
"Docker is not running. Start it, or drop --db, --guacd and the docker target.",
);
}
if (!docker(["network", "inspect", NETWORK], { quiet: true }).ok) {
docker(["network", "create", NETWORK], { quiet: true });
}
}
function containerState(name) {
const result = docker(["inspect", "-f", "{{.State.Running}}", name], {
quiet: true,
});
if (!result.ok) return "missing";
return result.out === "true" ? "running" : "stopped";
}
async function waitForService(service) {
if (!service.ready) return;
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
if (
docker(["exec", service.container, ...service.ready], { quiet: true }).ok
)
return;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`${service.container} did not become ready`);
}
async function startService(name) {
const service = SERVICES[name];
if (options.fresh && service.volume) {
docker(["rm", "-f", service.container], { quiet: true });
docker(["volume", "rm", service.volume[0]], { quiet: true });
}
const state = containerState(service.container);
if (state === "running") {
log(`${service.container} is already running`);
} else {
log(`starting ${service.container}`);
const result =
state === "stopped"
? docker(["start", service.container], { quiet: true })
: docker(serviceRunArgs(service), { quiet: true });
if (!result.ok) throw new Error(`could not start ${service.container}`);
startedServices.push(service.container);
}
await waitForService(service);
}
async function startServices() {
if (options.db !== "sqlite") await startService(options.db);
if (options.guacd) await startService("guacd");
}
// Local backend, Vite and Electron
function notifyBrowser(data) {
vite?.ws.send({ type: "custom", event: "termix:plugins-reloaded", data });
}
function startBackend() {
const child = fork(BACKEND_ENTRY, [], {
cwd: root,
env: { ...process.env, ...backendEnv, TERMIX_DEV_RELOAD: "true" },
stdio: ["ignore", "inherit", "inherit", "ipc"],
});
const pending = new Map();
const state = { child, pending, ready: false, stopping: false };
state.readyPromise = new Promise((resolve, reject) => {
child.on("message", (msg) => {
if (msg?.type === "backend-ready") {
state.ready = true;
resolve();
return;
}
if (msg?.type === "backend-request") {
relayToElectron(child, msg);
return;
}
if (
msg?.type === "plugin-reloaded" ||
msg?.type === "plugin-reload-failed"
) {
pending.get(msg.id)?.(msg);
pending.delete(msg.id);
}
});
child.on("exit", (code) => {
for (const done of pending.values()) {
done({ type: "plugin-reload-failed", error: "backend exited" });
}
pending.clear();
if (backend === state) backend = null;
if (!state.ready) reject(new Error(`backend exited with ${code}`));
else if (!shuttingDown && !state.stopping) {
warn(
`backend exited with ${code}, press r or save a file to restart it`,
);
}
});
});
backend = state;
return state;
}
function stopChild(child, message) {
return new Promise((resolve) => {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return resolve();
}
const timer = setTimeout(() => child.kill("SIGKILL"), SHUTDOWN_TIMEOUT_MS);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
try {
if (message && child.connected) child.send(message);
else child.kill();
} catch {
child.kill();
}
});
}
function stopBackend() {
const state = backend;
if (!state) return Promise.resolve();
backend = null;
state.stopping = true;
return stopChild(state.child, state.ready ? { type: "shutdown" } : null);
}
async function restartBackend(reason) {
log(reason);
await stopBackend();
if (shuttingDown) return;
fs.copyFileSync(
path.join(root, "src", "backend", "package.json"),
path.join(root, "dist", "backend", "package.json"),
);
const state = startBackend();
try {
await state.readyPromise;
} catch (error) {
warn(`${error.message}, press r or save a file to restart it`);
return;
}
log("backend ready");
if (!vite) {
await startVite();
if (options.target === "electron") startElectron();
printKeys();
} else {
notifyBrowser({});
}
}
async function startVite() {
const { createServer } = await import("vite");
vite = await createServer({
root,
configFile: path.join(root, "vite.config.ts"),
});
await vite.listen();
vite.printUrls();
}
// The packaged app forks the backend from Electron's main process, and they
// talk over that IPC channel (opening sign-in windows, the linked server's
// proxy settings). Here both are children of the runner, so it passes those
// messages along. The last proxy settings are kept for an app (re)started
// after the backend sent them.
let lastProxyConfig = null;
const REPLAY_PREFIX = "dev-replay-";
function relayToElectron(backendChild, msg) {
if (msg.channel === "sync-proxy-config") lastProxyConfig = msg;
if (electron?.connected) {
electron.send(msg);
return;
}
const ok = msg.channel === "sync-proxy-config";
try {
backendChild.send({
type: "backend-response",
id: msg.id,
ok,
...(ok
? { result: { success: true } }
: { error: "The desktop app is not running" }),
});
} catch {
// backend gone
}
}
function relayToBackend(msg) {
if (msg?.type !== "backend-response") return;
if (String(msg.id).startsWith(REPLAY_PREFIX)) return;
if (backend?.child.connected) backend.child.send(msg);
}
function startElectron() {
const env = {
...process.env,
NODE_ENV: "development",
TERMIX_DEV_RUNNER: "true",
};
// Set by some editors' terminals; Electron would start as plain Node.
delete env.ELECTRON_RUN_AS_NODE;
const child = spawn(require("electron"), ["."], {
cwd: root,
env,
stdio: ["ignore", "inherit", "inherit", "ipc"],
});
electron = child;
child.on("message", relayToBackend);
if (lastProxyConfig) {
child.send({ ...lastProxyConfig, id: `${REPLAY_PREFIX}${Date.now()}` });
}
log("desktop app started");
child.on("exit", () => {
if (electron !== child) return;
electron = null;
if (!shuttingDown) {
log("desktop app closed");
void shutdown();
}
});
}
async function restartElectron() {
const child = electron;
electron = null;
log("electron changed, restarting the desktop app");
await stopChild(child);
if (!shuttingDown) startElectron();
}
function reloadInBackend(id) {
const state = backend;
if (!state?.ready || !state.child.connected) return Promise.resolve(null);
return new Promise((resolve) => {
const timer = setTimeout(() => {
state.pending.delete(id);
resolve({ type: "plugin-reload-failed", error: "timed out" });
}, RELOAD_TIMEOUT_MS);
state.pending.set(id, (msg) => {
clearTimeout(timer);
resolve(msg);
});
state.child.send({ type: "plugin-reload", id });
});
}
function watchBackend() {
tsc = spawn(
process.execPath,
[
require.resolve("typescript/bin/tsc"),
"-p",
"tsconfig.node.json",
"--watch",
"--preserveWatchOutput",
"--pretty",
"false",
],
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] },
);
let first = true;
let buffer = "";
tsc.stdout.on("data", (chunk) => {
buffer += chunk.toString();
const lines = buffer.split(/\r?\n/);
buffer = lines.pop();
for (const line of lines) {
const errors = parseTscStatus(line);
if (errors === null) {
if (line.includes("error TS")) console.log(line);
continue;
}
if (errors > 0) {
warn(`backend has ${errors} type error(s), fix them to restart it`);
continue;
}
const reason = first
? "starting the backend"
: "backend changed, restarting it";
first = false;
void queue(() => restartBackend(reason));
}
});
}
function watchSdk() {
const changed = createDebouncer(300, () =>
queue(async () => {
try {
await run("npm", ["run", "build:sdk"]);
} catch (error) {
warn(`SDK failed to build: ${error.message}`);
return;
}
await restartBackend("SDK changed, restarting the backend");
}),
);
watch(SDK_SRC, true, () => changed("sdk"));
}
function watchElectron() {
const changed = createDebouncer(300, () => restartElectron());
watch(ELECTRON_DIR, false, (_event, filename) => {
if (/\.(c?js)$/.test(String(filename)) && electron) changed("electron");
});
}
// Docker target
function dockerDataMount() {
if (options.data) return path.resolve(root, options.data);
const volume = options.fresh ? "termix-dev-data-fresh" : "termix-dev-data";
if (options.fresh) docker(["volume", "rm", "-f", volume], { quiet: true });
return volume;
}
async function buildImage() {
log(`building ${APP_IMAGE}, this takes a while`);
await run("docker", [
"build",
"-f",
"docker/Dockerfile",
"-t",
APP_IMAGE,
...(options.noCache ? ["--no-cache"] : []),
".",
]);
}
function followDockerLogs(since) {
dockerLogs?.kill();
dockerLogs = spawn(
"docker",
["logs", "-f", ...(since ? ["--since", since] : []), APP_CONTAINER],
{ cwd: root, stdio: ["ignore", "inherit", "inherit"] },
);
}
async function waitForApp() {
const deadline = Date.now() + DOCKER_READY_TIMEOUT_MS;
while (Date.now() < deadline && !shuttingDown) {
try {
const response = await fetch(appUrl());
if (response.ok) return true;
} catch {
// not up yet
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return false;
}
let dockerMount = null;
async function runAppContainer() {
docker(["rm", "-f", APP_CONTAINER], { quiet: true });
dockerMount ??= dockerDataMount();
const result = docker(
appRunArgs({
port: options.port,
env: serviceEnv(options, true),
dataMount: dockerMount,
pluginsDir: STAGED_PLUGINS,
}),
{ quiet: true },
);
if (!result.ok) throw new Error(`could not start ${APP_CONTAINER}`);
followDockerLogs();
if (await waitForApp()) log(`ready on ${appUrl()}`);
else warn(`${APP_CONTAINER} is not answering on ${appUrl()} yet`);
}
async function restartAppContainer(reason) {
log(reason);
const since = new Date().toISOString();
docker(["restart", APP_CONTAINER], { quiet: true });
followDockerLogs(since);
if (await waitForApp()) log(`ready on ${appUrl()}`);
}
async function rebuildAndRunImage() {
try {
await buildImage();
} catch (error) {
warn(error.message);
return;
}
await runAppContainer();
}
function watchCoreForDocker() {
const changed = createDebouncer(500, () =>
warn("core changed, press r to rebuild the image"),
);
for (const dir of [path.join(root, "src"), SDK_SRC]) {
watch(dir, true, () => changed("core"));
}
}
// Plugins
async function rebuildPlugin({ id, repo }) {
log(`building ${id}`);
try {
await run(...localSdkBuildCommand(), repo);
} catch (error) {
warn(`${id} failed to build: ${error.message}`);
return;
}
const destination = path.join(STAGED_PLUGINS, id);
fs.rmSync(destination, { recursive: true, force: true });
copyLocalBuild(repo, destination);
if (options.target === "docker") {
await restartAppContainer(`${id} rebuilt, restarting ${APP_CONTAINER}`);
return;
}
const result = await reloadInBackend(id);
if (!result) {
log(`${id} staged, it loads when the backend starts`);
return;
}
if (result.type === "plugin-reload-failed") {
warn(`${id} did not reload: ${result.error}`);
return;
}
log(`${id} reloaded (${result.state})`);
notifyBrowser({ id });
}
function watchPlugins(dir) {
plugins = findBuildablePlugins(dir);
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const changed = createDebouncer(300, (id) =>
queue(() => rebuildPlugin(byId.get(id))),
);
for (const plugin of plugins) {
watch(plugin.repo, true, (_event, filename) => {
if (isPluginEdit(plugin.repo, filename)) changed(plugin.id);
});
}
log(`watching ${plugins.length} plugin(s) in ${dir}`);
}
// Keys and shutdown
function printKeys() {
if (!process.stdin.isTTY) return;
log("keys: r restart, p rebuild plugins, o open browser, q quit");
}
function listenForKeys() {
if (!process.stdin.isTTY) return;
process.stdin.setRawMode(true);
process.stdin.setEncoding("utf8");
process.stdin.resume();
process.stdin.on("data", (key) => {
if (key === "\u0003" || key === "q") {
void shutdown();
} else if (key === "r") {
void queue(() =>
options.target === "docker"
? rebuildAndRunImage()
: restartBackend("restarting the backend"),
);
} else if (key === "p") {
if (plugins.length === 0) warn("no local plugins to rebuild");
for (const plugin of plugins) void queue(() => rebuildPlugin(plugin));
} else if (key === "o") {
openBrowser(appUrl());
}
});
}
async function shutdown(code = 0) {
if (shuttingDown) return;
shuttingDown = true;
log("shutting down");
if (process.stdin.isTTY) process.stdin.setRawMode(false);
for (const watcher of watchers) watcher.close();
tsc?.kill();
dockerLogs?.kill();
await Promise.allSettled([stopBackend(), stopChild(electron), vite?.close()]);
if (options.target === "docker") {
docker(["rm", "-f", APP_CONTAINER], { quiet: true });
}
for (const container of startedServices) {
docker(["stop", container], { quiet: true });
}
process.exit(code);
}
async function main() {
try {
options = parseDevArgs(process.argv.slice(2));
} catch (error) {
console.error(error.message);
process.exit(1);
}
if (options.help) {
console.log(HELP);
return;
}
process.on("SIGINT", () => shutdown());
process.on("SIGTERM", () => shutdown());
const pluginsDir = options.local
? path.resolve(
root,
options.pluginsDir ??
process.env.TERMIX_LOCAL_PLUGINS ??
"../Termix-Plugins",
)
: null;
const localPlugins =
pluginsDir && fs.existsSync(pluginsDir) ? pluginsDir : null;
if (pluginsDir && !localPlugins)
warn(`${pluginsDir} not found, using pinned plugins`);
if (needsDocker(options)) {
assertDocker();
await startServices();
}
log("building the SDK");
await run("npm", ["run", "build:sdk"]);
log(
localPlugins
? `staging plugins with local builds from ${localPlugins}`
: "staging pinned plugins",
);
await run(process.execPath, [
"scripts/build-plugins.cjs",
...(localPlugins ? ["--local", localPlugins] : []),
]);
listenForKeys();
if (localPlugins) watchPlugins(localPlugins);
if (options.target === "docker") {
await buildImage();
await runAppContainer();
watchCoreForDocker();
printKeys();
return;
}
const dataDir = dataDirFor(options);
if (dataDir) {
const full = path.resolve(root, dataDir);
if (options.fresh) fs.rmSync(full, { recursive: true, force: true });
fs.mkdirSync(full, { recursive: true });
log(`data folder ${full}`);
}
backendEnv = {
...serviceEnv(options, false),
// The desktop app runs against its own embedded backend, which is what
// turns on the local profile, sync and the desktop-only routes.
...(options.target === "electron" ? { ELECTRON_EMBEDDED: "true" } : {}),
// A folder the runner picked starts empty on purpose, so the guard
// against a mistyped DATA_DIR must not stop it.
...(dataDir
? { DATA_DIR: path.resolve(root, dataDir), ALLOW_EMPTY_DATA_DIR: "true" }
: {}),
};
log("compiling the backend");
watchBackend();
watchSdk();
if (options.target === "electron") watchElectron();
}
main().catch(async (error) => {
console.error(error?.message ?? error);
await shutdown(1);
});
+14 -34
View File
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { changelogSection } = require("./lib/changelog-section.cjs");
// Apple caps the "What's New in This Version" field at 4000 characters.
const MAX_LENGTH = 4000;
@@ -26,18 +27,6 @@ function fail(message) {
process.exit(1);
}
function extractSection(notes, name, { required = true } = {}) {
const pattern = new RegExp(
`<!--\\s*${name}\\s*-->([\\s\\S]*?)<!--\\s*/${name}\\s*-->`,
);
const match = notes.match(pattern);
if (!match) {
if (required) fail(`missing <!-- ${name} --> section in release notes`);
return "";
}
return match[1].trim();
}
// Strip markdown that reads badly as plain text in App Store Connect.
function toPlainText(markdown) {
return markdown
@@ -50,7 +39,7 @@ function toPlainText(markdown) {
text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
text = text.replace(/`([^`]+)`/g, "$1");
text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
text = text.replace(/^#+\s*/, "");
if (/^#+\s/.test(text)) text = `${text.replace(/^#+\s*/, "")}:`;
return text;
})
.join("\n")
@@ -78,28 +67,21 @@ function truncate(text, limit) {
return lines.join("\n").trim();
}
function buildNotes(notesFile) {
const summary = extractSection(notesFile, "SUMMARY");
const updateLog = extractSection(notesFile, "UPDATE_LOG", {
required: false,
});
const bugFixes = extractSection(notesFile, "BUG_FIXES", { required: false });
const parts = [toPlainText(summary)];
if (updateLog) parts.push("", "Update Log:", toPlainText(updateLog));
if (bugFixes) parts.push("", "Bug Fixes:", toPlainText(bugFixes));
const body = parts
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
function buildNotes(changelog, version) {
const notes = changelogSection(changelog, version);
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
const body = toPlainText(notes);
if (!body) fail("release notes produced no text");
return truncate(body, MAX_LENGTH);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const notesPath = args.notes || "RELEASE_NOTES.md";
const changelogPath = args.changelog || "CHANGELOG.md";
const version =
typeof args.version === "string"
? args.version
: require(path.resolve("package.json")).version;
const outDir = args["out-dir"];
const locales = String(args.locales || "en-US")
.split(",")
@@ -109,12 +91,10 @@ function main() {
if (!outDir || outDir === true) fail("--out-dir is required");
if (locales.length === 0) fail("--locales resolved to no locales");
const resolvedNotes = path.resolve(notesPath);
if (!fs.existsSync(resolvedNotes)) {
fail(`release notes file not found: ${resolvedNotes}`);
}
const resolved = path.resolve(changelogPath);
if (!fs.existsSync(resolved)) fail(`changelog not found: ${resolved}`);
const notes = buildNotes(fs.readFileSync(resolvedNotes, "utf8"));
const notes = buildNotes(fs.readFileSync(resolved, "utf8"), version);
for (const locale of locales) {
const localeDir = path.join(path.resolve(outDir), locale);
+37 -57
View File
@@ -9,10 +9,20 @@ const {
MAX_LENGTH,
} = require("./generate-appstore-notes.cjs");
function notesFile(sections: Record<string, string>) {
return Object.entries(sections)
.map(([name, body]) => `<!-- ${name} -->\n${body}\n<!-- /${name} -->`)
.join("\n\n");
function changelog(body: string, version = "26.10.0") {
return [
"# Changelog",
"",
`## ${version}`,
"",
body,
"",
"## 26.9.0",
"",
"### Added",
"- Older",
"",
].join("\n");
}
describe("toPlainText", () => {
@@ -36,6 +46,10 @@ describe("toPlainText", () => {
expect(toPlainText("* One\n* Two")).toBe("- One\n- Two");
});
it("turns headings into labels", () => {
expect(toPlainText("### Added\n- One")).toBe("Added:\n- One");
});
it("collapses runs of blank lines", () => {
expect(toPlainText("- One\n\n\n\n- Two")).toBe("- One\n\n- Two");
});
@@ -52,74 +66,40 @@ describe("truncate", () => {
});
it("drops a section header left with no bullets under it", () => {
const result = truncate("- One\n\nBug Fixes:\n- Two", 18);
const result = truncate("- One\n\nFixed:\n- Two", 14);
expect(result).toBe("- One");
});
});
describe("buildNotes", () => {
it("includes the summary, update log, and bug fixes", () => {
it("uses only the version's section", () => {
const notes = buildNotes(
notesFile({
SUMMARY: "A big release.",
UPDATE_LOG: "- Added a thing",
BUG_FIXES: "- Fixed a thing",
}),
changelog("A summary.\n\n### Added\n- A thing\n\n### Fixed\n- A bug"),
"26.10.0",
);
expect(notes).toContain("A big release.");
expect(notes).toContain("Update Log:");
expect(notes).toContain("- Added a thing");
expect(notes).toContain("Bug Fixes:");
expect(notes).toContain("- Fixed a thing");
expect(notes).toBe("A summary.\n\nAdded:\n- A thing\n\nFixed:\n- A bug");
expect(notes).not.toContain("Older");
});
it("omits optional sections that are absent", () => {
const notes = buildNotes(notesFile({ SUMMARY: "Small release." }));
expect(notes).toBe("Small release.");
expect(notes).not.toContain("Update Log:");
expect(notes).not.toContain("Bug Fixes:");
});
it("stays within the App Store character limit", () => {
it("stays within the App Store limit and never ends mid-bullet", () => {
const bullets = Array.from(
{ length: 400 },
(_, i) => `- Added feature number ${i}`,
);
const notes = buildNotes(
notesFile({
SUMMARY: "Big release.",
UPDATE_LOG: Array.from(
{ length: 400 },
(_, i) => `- Added feature number ${i}`,
).join("\n"),
BUG_FIXES: Array.from(
{ length: 400 },
(_, i) => `- Fixed bug number ${i}`,
).join("\n"),
}),
changelog(`### Added\n${bullets.join("\n")}`),
"26.10.0",
);
expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH);
expect(notes.length).toBeGreaterThan(0);
expect(notes.split("\n").at(-1)).toMatch(/^- Added feature number \d+$/);
});
it("never ends mid-bullet when truncating", () => {
const notes = buildNotes(
notesFile({
SUMMARY: "Big release.",
UPDATE_LOG: Array.from(
{ length: 400 },
(_, i) => `- Added feature number ${i}`,
).join("\n"),
}),
);
const lines = notes.split("\n");
expect(lines[lines.length - 1]).toMatch(/^- Added feature number \d+$/);
});
it("produces non-empty notes for the real release notes file", () => {
it("produces notes for the real changelog", () => {
const fs = require("fs");
const notes = buildNotes(fs.readFileSync("RELEASE_NOTES.md", "utf8"));
const notes = buildNotes(
fs.readFileSync("CHANGELOG.md", "utf8"),
require("../package.json").version,
);
expect(notes.length).toBeGreaterThan(0);
expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH);
});
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* Writes the body of a beta release: what a beta is, how to get it, how to
* leave, and the commits since the previous beta (or stable release) grouped
* by their feat:/fix: prefix.
*
* node scripts/generate-beta-body.cjs --version 26.11.0-beta.2 \
* --branch dev-26.11.0 --sha <head> [--prev <ref>]
*/
const { execFileSync } = require("child_process");
const REPO = "Termix-SSH/Termix";
const MAX_COMMITS = 150;
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (!argv[i].startsWith("--")) continue;
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
args[argv[i].slice(2)] = true;
} else {
args[argv[i].slice(2)] = next;
i++;
}
}
return args;
}
/** Commit subjects sorted into features, fixes and everything else. */
function groupCommits(subjects) {
const groups = { features: [], fixes: [], other: [] };
for (const subject of subjects) {
const text = subject.trim();
if (!text) continue;
const match = /^(\w+)(\([^)]*\))?!?:\s*(.+)$/.exec(text);
const type = match?.[1].toLowerCase();
const message = match ? match[3] : text;
if (type === "feat") groups.features.push(message);
else if (type === "fix") groups.fixes.push(message);
else groups.other.push(message);
}
return groups;
}
/** "26.11.0-beta.2" reads as "26.11.0 Beta 2". */
function betaTitle(version) {
const match = /^(.+)-beta\.(\d+)$/.exec(version);
return match ? `${match[1]} Beta ${match[2]}` : version;
}
function renderBody({ version, branch, sha, prev, subjects, repo = REPO }) {
const shown = subjects.slice(0, MAX_COMMITS);
const groups = groupCommits(shown);
const list = (items) => items.map((item) => `- ${item}`).join("\n");
const sections = [
["Features", groups.features],
["Fixes", groups.fixes],
["Other changes", groups.other],
]
.filter(([, items]) => items.length > 0)
.map(([title, items]) => `### ${title}\n\n${list(items)}`);
let changes;
if (!prev) {
changes = "First beta of this version.";
} else if (sections.length === 0) {
changes = "No changes since the last release.";
} else {
changes = sections.join("\n\n");
if (subjects.length > MAX_COMMITS) {
changes += `\n\n...and ${subjects.length - MAX_COMMITS} more.`;
}
changes += `\n\n[Full diff](https://github.com/${repo}/compare/${prev}...${sha})`;
}
return `> [!WARNING]
> This is a beta of the next Termix release, built from \`${branch}\`. It can have bugs, so keep a backup and don't use it where you can't afford problems.
**Found a bug or have feedback?** [Send beta feedback](https://github.com/${repo}/issues/new?template=beta_feedback.yml&termix-version=${encodeURIComponent(version)}). It is the fastest way to get it fixed before the stable release.
### How to get it
- **Docker:** \`ghcr.io/termix-ssh/termix:beta\` follows every beta. \`ghcr.io/termix-ssh/termix:${version}\` stays on this one.
- **Desktop:** download an installer from the assets below.
- **Going back:** switch to the \`latest\` image or a stable installer. Keep a backup of your data folder, since a beta can change the database.
## Changes in ${betaTitle(version)}
${changes}
`;
}
function readSubjects(prev, sha) {
if (!prev) return [];
const out = execFileSync(
"git",
[
"log",
"--no-merges",
"--format=%s",
`${prev}..${sha}`,
"--",
".",
":!package-lock.json",
],
{ encoding: "utf8" },
);
return out.split("\n").filter(Boolean);
}
if (require.main === module) {
const args = parseArgs(process.argv.slice(2));
if (!args.version || !args.branch || !args.sha) {
console.error(
"generate-beta-body: --version, --branch and --sha are required",
);
process.exit(1);
}
const prev = typeof args.prev === "string" ? args.prev : "";
process.stdout.write(
renderBody({
version: args.version,
branch: args.branch,
sha: args.sha,
prev,
subjects: readSubjects(prev, args.sha),
}),
);
}
module.exports = { betaTitle, groupCommits, renderBody };
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const {
betaTitle,
groupCommits,
renderBody,
} = require("./generate-beta-body.cjs");
describe("generate-beta-body", () => {
it("groups commits by their prefix", () => {
expect(
groupCommits([
"feat: add beta channel",
"fix(sync): keep links",
"chore: bump deps",
"Plain message",
]),
).toEqual({
features: ["add beta channel"],
fixes: ["keep links"],
other: ["bump deps", "Plain message"],
});
});
it("names the beta", () => {
expect(betaTitle("26.11.0-beta.2")).toBe("26.11.0 Beta 2");
});
it("links feedback with the version and lists the changes", () => {
const body = renderBody({
version: "26.11.0-beta.2",
branch: "dev-26.11.0",
sha: "abc",
prev: "v26.11.0-beta.1",
subjects: ["feat: one", "fix: two"],
});
expect(body).toContain("termix-version=26.11.0-beta.2");
expect(body).toContain("## Changes in 26.11.0 Beta 2");
expect(body).toContain("### Features\n\n- one");
expect(body).toContain("### Fixes\n\n- two");
expect(body).toContain("compare/v26.11.0-beta.1...abc");
expect(body).not.toContain("—");
});
it("says so when there is nothing to compare against", () => {
const body = renderBody({
version: "26.11.0-beta.1",
branch: "dev-26.11.0",
sha: "abc",
prev: "",
subjects: [],
});
expect(body).toContain("First beta of this version.");
});
});
+8 -8
View File
@@ -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.
`;
}
+1 -1
View File
@@ -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")');
});
+73 -99
View File
@@ -1,5 +1,5 @@
import sharp from "sharp";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@@ -10,124 +10,98 @@ const iconsDir = join(publicDir, "icons");
mkdirSync(iconsDir, { recursive: true });
const svgBuffer = readFileSync(join(publicDir, "icon.svg"));
const svgBuffer = readFileSync(join(publicDir, "icon-square.svg"));
const render = (size) =>
sharp(svgBuffer, { density: 72 * Math.max(1, size / 1024) })
.resize(size, size)
.png({ compressionLevel: 9 })
.toBuffer();
const pngSizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024];
const png = Object.fromEntries(
await Promise.all(pngSizes.map(async (s) => [s, await render(s)])),
);
console.log("Generating PNG icons...");
await Promise.all(
pngSizes.map((size) =>
sharp(svgBuffer)
.resize(size, size)
.png()
.toFile(join(iconsDir, `${size}x${size}.png`))
.then(() => console.log(` ✓ icons/${size}x${size}.png`)),
for (const size of pngSizes) {
writeFileSync(join(iconsDir, `${size}x${size}.png`), png[size]);
}
writeFileSync(join(publicDir, "icon.png"), png[1024]);
writeFileSync(join(publicDir, "full-icon.png"), png[1024]);
writeFileSync(join(publicDir, "icon-mac.png"), png[512]);
writeFileSync(
join(publicDir, "favicon.ico"),
buildIco(
[16, 32, 48].map((s) => png[s]),
[16, 32, 48],
),
);
// icon.png (1024x1024) for Linux electron-builder
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(publicDir, "icon.png"));
console.log(" ✓ icon.png");
// icon-mac.png (512x512) for macOS
await sharp(svgBuffer)
.resize(512, 512)
.png()
.toFile(join(publicDir, "icon-mac.png"));
console.log(" ✓ icon-mac.png");
// full-icon.png (1024x1024) used in app UI
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(publicDir, "full-icon.png"));
console.log(" ✓ full-icon.png");
// favicon.ico — embed 16, 32, 48 px layers
console.log("Generating favicon.ico...");
const icoSizes = [16, 32, 48];
const icoBuffers = await Promise.all(
icoSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()),
const winSizes = [16, 24, 32, 48, 64, 128, 256];
writeFileSync(
join(publicDir, "icon.ico"),
buildIco(
winSizes.map((s) => png[s]),
winSizes,
),
);
writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes));
console.log(" ✓ favicon.ico");
// 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(
winSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()),
writeFileSync(
join(publicDir, "icon.icns"),
buildIcns([
["icp4", png[16]],
["icp5", png[32]],
["icp6", png[64]],
["ic07", png[128]],
["ic08", png[256]],
["ic09", png[512]],
["ic10", png[1024]],
["ic11", png[32]],
["ic12", png[64]],
["ic13", png[256]],
["ic14", png[512]],
]),
);
writeFileSync(join(publicDir, "icon.ico"), buildIco(winBuffers, winSizes));
console.log(" ✓ icon.ico");
// icons/icon.ico and icons/icon.icns placeholders (stubs pointing to source)
// electron-builder generates .icns; copy the 1024 PNG as icons/icon.png for reference
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(iconsDir, "icon.ico").replace("icon.ico", "1024x1024.png"));
// Copy icon.ico and icon.icns into icons/ as well
import { copyFileSync } from "fs";
copyFileSync(join(publicDir, "icon.ico"), join(iconsDir, "icon.ico"));
console.log(" ✓ icons/icon.ico");
copyFileSync(join(publicDir, "icon.icns"), join(iconsDir, "icon.icns"));
console.log(
"\nDone! Note: icon.icns requires macOS tools (iconutil). Use electron-builder on macOS to generate it.",
);
console.log("Icons generated from public/icon-square.svg");
/**
* Builds a minimal ICO file from PNG buffers.
* @param {Buffer[]} pngBuffers
* @param {number[]} sizes
* @returns {Buffer}
*/
function buildIco(pngBuffers, sizes) {
const count = pngBuffers.length;
const headerSize = 6;
const dirEntrySize = 16;
const dirSize = headerSize + count * dirEntrySize;
let offset = dirSize;
let offset = headerSize + pngBuffers.length * dirEntrySize;
const entries = pngBuffers.map((buf, i) => {
const size = sizes[i];
const entry = {
size,
width: size > 255 ? 0 : size,
height: size > 255 ? 0 : size,
buf,
offset,
};
const entry = { size: sizes[i], buf, offset };
offset += buf.length;
return entry;
});
const totalSize = offset;
const ico = Buffer.alloc(totalSize);
// ICO header
ico.writeUInt16LE(0, 0); // reserved
ico.writeUInt16LE(1, 2); // type: 1 = ICO
ico.writeUInt16LE(count, 4); // image count
// Directory entries
const ico = Buffer.alloc(offset);
ico.writeUInt16LE(0, 0);
ico.writeUInt16LE(1, 2);
ico.writeUInt16LE(entries.length, 4);
entries.forEach((e, i) => {
const base = headerSize + i * dirEntrySize;
ico.writeUInt8(e.width, base); // width (0 = 256)
ico.writeUInt8(e.height, base + 1); // height (0 = 256)
ico.writeUInt8(0, base + 2); // color count
ico.writeUInt8(0, base + 3); // reserved
ico.writeUInt16LE(1, base + 4); // planes
ico.writeUInt16LE(32, base + 6); // bit count
ico.writeUInt32LE(e.buf.length, base + 8); // size of image data
ico.writeUInt32LE(e.offset, base + 12); // offset of image data
ico.writeUInt8(e.size > 255 ? 0 : e.size, base);
ico.writeUInt8(e.size > 255 ? 0 : e.size, base + 1);
ico.writeUInt16LE(1, base + 4);
ico.writeUInt16LE(32, base + 6);
ico.writeUInt32LE(e.buf.length, base + 8);
ico.writeUInt32LE(e.offset, base + 12);
e.buf.copy(ico, e.offset);
});
// Image data
entries.forEach((e) => e.buf.copy(ico, e.offset));
return ico;
}
function buildIcns(chunks) {
const parts = chunks.map(([type, data]) => {
const head = Buffer.alloc(8);
head.write(type, 0, "ascii");
head.writeUInt32BE(data.length + 8, 4);
return Buffer.concat([head, data]);
});
const body = Buffer.concat(parts);
const head = Buffer.alloc(8);
head.write("icns", 0, "ascii");
head.writeUInt32BE(body.length + 8, 4);
return Buffer.concat([head, body]);
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
// Writes openapi.json for core from the @openapi blocks in src/backend.
// The docs site runs the same builder on each release.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildOpenApi } from "@termix-ssh/plugin-sdk/openapi";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pkg = JSON.parse(
fs.readFileSync(path.join(root, "package.json"), "utf8"),
);
const tags = JSON.parse(
fs.readFileSync(
path.join(root, "src/backend/utils/openapi-tags.json"),
"utf8",
),
);
const out = process.argv[2] ?? path.join(root, "openapi.json");
const spec = await buildOpenApi({
title: "Termix API",
version: pkg.version,
description: "The core Termix API. Each plugin documents its own routes.",
files: [path.join(root, "src/backend/**/!(*.test).ts")],
tags,
});
fs.writeFileSync(out, `${JSON.stringify(spec, null, 2)}\n`);
console.log(
`wrote ${path.relative(root, out)} (${Object.keys(spec.paths ?? {}).length} paths)`,
);
+43 -78
View File
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { changelogSection } = require("./lib/changelog-section.cjs");
function parseArgs(argv) {
const args = {};
@@ -24,37 +25,10 @@ function fail(message) {
process.exit(1);
}
function extractSection(notes, name) {
const pattern = new RegExp(
`<!--\\s*${name}\\s*-->([\\s\\S]*?)<!--\\s*/${name}\\s*-->`,
);
const match = notes.match(pattern);
if (!match) {
fail(`missing <!-- ${name} --> section in release notes`);
}
const value = match[1].trim();
if (!value) {
fail(`empty <!-- ${name} --> section in release notes`);
}
return value;
}
function youtubeId(raw) {
const value = raw.trim();
let match = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
if (match) return match[1];
match = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
if (match) return match[1];
match = value.match(/embed\/([A-Za-z0-9_-]+)/);
if (match) return match[1];
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
fail(`could not parse a YouTube video id from "${value}"`);
}
function buildTable(version, mobileVersion) {
const tag = `release-${version}-tag`;
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/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}`;
@@ -63,11 +37,31 @@ function buildTable(version, mobileVersion) {
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");
}
function buildBody({ version, mobileVersion, mobileTag, changelog }) {
const notes = changelogSection(changelog, version);
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
const donateAlert = [
"> [!TIP]",
"> Termix is free and always will be. If it's useful to you, consider [donating](https://donate.termix.site/donate/) to support development.",
].join("\n");
return [
donateAlert,
"",
notes,
"",
"### Downloads",
"",
buildTable(version, mobileVersion, mobileTag),
].join("\n");
}
@@ -75,54 +69,25 @@ function main() {
const args = parseArgs(process.argv.slice(2));
const version = args.version;
const mobileVersion = args["mobile-version"];
const notesPath = args.notes || "RELEASE_NOTES.md";
const mobileTag =
typeof args["mobile-tag"] === "string" ? args["mobile-tag"] : undefined;
const changelogPath = path.resolve(args.changelog || "CHANGELOG.md");
if (!version || version === true) fail("--version is required");
if (!mobileVersion || mobileVersion === true)
fail("--mobile-version is required");
const resolvedNotes = path.resolve(notesPath);
if (!fs.existsSync(resolvedNotes)) {
fail(`release notes file not found: ${resolvedNotes}`);
if (!fs.existsSync(changelogPath)) {
fail(`changelog not found: ${changelogPath}`);
}
const notes = fs.readFileSync(resolvedNotes, "utf8");
const summary = extractSection(notes, "SUMMARY");
const youtube = extractSection(notes, "YOUTUBE");
const updateLog = extractSection(notes, "UPDATE_LOG");
const bugFixes = extractSection(notes, "BUG_FIXES");
const videoId = youtubeId(youtube);
const embed = [
`<a href="https://youtu.be/${videoId}">`,
` <img src="./docs/repo-images/YouTube.png" alt="YouTube" width="500">`,
`</a>`,
].join("\n");
const table = buildTable(version, mobileVersion);
const donateAlert = [
"> [!TIP]",
"> Termix is free and always will be. If it's useful to you, consider [donating](https://donate.termix.site/donate/) to support development.",
].join("\n");
const body = [
donateAlert,
"",
summary,
"",
embed,
"",
table,
"",
"Update Log:",
updateLog,
"",
"Bug Fixes:",
bugFixes,
].join("\n");
process.stdout.write(body + "\n");
const changelog = fs.readFileSync(changelogPath, "utf8");
process.stdout.write(
buildBody({ version, mobileVersion, mobileTag, changelog }) + "\n",
);
}
main();
if (require.main === module) {
main();
}
module.exports = { buildBody };
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { buildBody } = require("./generate-release-body.cjs");
const CHANGELOG = [
"# Changelog",
"",
"## 26.11.0",
"",
"### Fixed",
"- A bug",
"",
"## 26.10.0",
"",
"### Added",
"- Plugins",
"",
].join("\n");
describe("buildBody", () => {
it("uses the version's changelog section and v tags for downloads", () => {
const body = buildBody({
version: "26.10.0",
mobileVersion: "1.2.0",
changelog: CHANGELOG,
});
expect(body).toContain("### Added\n- Plugins");
expect(body).not.toContain("A bug");
expect(body).toContain("/releases/download/v26.10.0/termix_");
expect(body).toContain(
"Mobile/releases/download/v1.2.0/termix_android.apk",
);
});
it("works for the real changelog", () => {
const fs = require("fs");
const body = buildBody({
version: require("../package.json").version,
mobileVersion: "1.0.0",
changelog: fs.readFileSync("CHANGELOG.md", "utf8"),
});
expect(body.length).toBeGreaterThan(0);
});
});
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env node
/**
* Builds src/ui/settings/settings-search-index.ts: the i18n keys each core
* settings section shows, so the Settings search can match on any label in
* a section without mounting it. Run it after changing a settings section.
*
* --check fails when the committed file is out of date.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar");
const OUT = path.join(
ROOT,
"src",
"ui",
"settings",
"settings-search-index.ts",
);
function keysIn(text) {
return [
...new Set([...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => m[1])),
];
}
function read(file) {
return fs.readFileSync(path.join(SIDEBAR, file), "utf8");
}
/** The body of one exported or local function, by name. */
function functionBody(source, name) {
const start = source.search(new RegExp(`function ${name}\\b`));
if (start < 0) throw new Error(`No function ${name}`);
const next = source.slice(start + 1).search(/\n(export )?function \w+/);
return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
}
/** Profile sections, split on their AccordionSection ids. */
function profileSections() {
const source = read("UserProfilePanel.tsx");
const lines = source.split("\n");
const starts = [];
lines.forEach((line, i) => {
if (/^\s{6}<AccordionSection\b/.test(line)) starts.push(i);
});
const out = {};
starts.forEach((start, k) => {
const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
const chunk = lines.slice(start, end).join("\n");
const id = (chunk.match(/\bid="([^"]+)"/) || [])[1];
if (id) out[id] = keysIn(chunk);
});
out.interface = [
...new Set([
...(out.interface ?? []),
...keysIn(read("InterfacePresetSettings.tsx")),
]),
];
out.security = [
...new Set([
...(out.security ?? []),
...keysIn(read("AuthEnrollmentSections.tsx")),
...keysIn(functionBody(source, "PasswordChangeSection")),
]),
];
out["api-keys"] = [
...new Set([
...(out["api-keys"] ?? []),
...keysIn(functionBody(source, "NewApiKeyDialog")),
]),
];
out.keybindings = keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", "KeybindingsSettings.tsx"),
"utf8",
),
);
return out;
}
function adminSections() {
const settings = read("AdminSettingsSections.tsx");
const management = read("AdminManagementSections.tsx");
return {
"admin-general": [
...new Set([
...keysIn(functionBody(settings, "AdminGeneralSettingsSection")),
...keysIn(read("AdminPluginDeveloperMode.tsx")),
]),
],
"admin-users": [
...new Set([
...keysIn(functionBody(management, "AdminUsersSection")),
...keysIn(read("AdminUserDialogs.tsx")),
]),
],
"admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")),
"admin-roles": keysIn(functionBody(management, "AdminRolesSection")),
"admin-host-defaults": [
...new Set([
...keysIn(functionBody(settings, "AdminHostDefaultsSection")),
...keysIn(read("AdminHostTags.tsx")),
]),
],
"admin-branding": keysIn(read("AdminBrandingSection.tsx")),
"admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")),
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
updates: keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", "UpdatesSettings.tsx"),
"utf8",
),
),
};
}
function render() {
const index = { ...profileSections(), ...adminSections() };
const body = Object.entries(index)
.sort(([a], [b]) => a.localeCompare(b))
.map(
([id, keys]) =>
` ${JSON.stringify(id)}: [\n${keys
.sort()
.map((key) => ` ${JSON.stringify(key)},`)
.join("\n")}\n ],`,
)
.join("\n");
return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {\n${body}\n};\n`;
}
const next = render();
if (process.argv.includes("--check")) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, "utf8") : "";
if (current !== next) {
console.error(
"src/ui/settings/settings-search-index.ts is out of date. Run: node scripts/generate-settings-search-index.cjs",
);
process.exit(1);
}
} else {
fs.writeFileSync(OUT, next);
console.log(`Wrote ${path.relative(ROOT, OUT)}`);
}
-40
View File
@@ -1,40 +0,0 @@
/**
* Installs the native dependencies bundled plugins declare. esbuild leaves a
* nativeDependencies package out of the plugin bundle, so the server's own
* node_modules has to have it. Run in the production image after npm ci.
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const plugins = path.resolve(root, process.argv[2] ?? "plugins");
const wanted = new Map();
for (const id of fs.existsSync(plugins) ? fs.readdirSync(plugins) : []) {
const manifestPath = path.join(plugins, id, "manifest.json");
const pkgPath = path.join(plugins, id, "package.json");
if (!fs.existsSync(manifestPath) || !fs.existsSync(pkgPath)) continue;
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
for (const name of manifest.nativeDependencies ?? []) {
const range = pkg.dependencies?.[name];
if (!range) {
console.error(`${id}: native dependency ${name} has no version`);
process.exit(1);
}
wanted.set(name, range);
}
}
if (wanted.size === 0) process.exit(0);
const specs = [...wanted].map(([name, range]) => `${name}@${range}`);
console.log(`Installing native plugin dependencies: ${specs.join(" ")}`);
const result = spawnSync(
"npm",
["install", "--no-save", "--omit=dev", "--no-audit", "--no-fund", ...specs],
{ cwd: root, stdio: "inherit", shell: process.platform === "win32" },
);
process.exit(result.status ?? 1);
+7
View File
@@ -15,6 +15,13 @@ describe("latestDevBranch", () => {
expect(latestDevBranch(["dev-2.10.0", "dev-2.9.0"])).toBe("dev-2.10.0");
});
it("ranks calendar versions above the old semver ones", () => {
expect(
latestDevBranch(["dev-2.9.1", "dev-26.10.0", "dev-26.9.0", "dev-3.0.0"]),
).toBe("dev-26.10.0");
expect(latestDevBranch(["dev-26.12.0", "dev-27.1.0"])).toBe("dev-27.1.0");
});
it("ignores main and non-dev branches", () => {
expect(latestDevBranch(["main", "feature/x", "dev-1.0.0"])).toBe(
"dev-1.0.0",
+225 -38
View File
@@ -2,12 +2,19 @@
* Reads docker/bundled-plugins.json: which plugins ship in the image and
* where each comes from.
*
* { "id": "x", "source": "workspace" } built from plugins/x
* { "id": "x", "source": "tmxplug", "url": "...", "sha256": "..." }
* { "id": "x", "source": "tmxplug", "path": "...", "sha256": "..." }
*
* A tmxplug entry is pinned by sha256 in this file, which is reviewed like
* any other change, so the build does not need the registry signature.
*
* An entry can also carry "onboarding": { "recommended": true | "desktop",
* "consent": true, "enabledByEnv": ["NAME"] }. The first-run plugin picker
* keeps recommended plugins by default ("desktop" only in the desktop app),
* and shows consent ones (like telemetry) in their own opt-in section.
* enabledByEnv counts the plugin as recommended when any of those
* environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked.
*/
const crypto = require("node:crypto");
@@ -15,9 +22,55 @@ const fs = require("node:fs");
const path = require("node:path");
const ID = /^[a-z0-9][a-z0-9-]*$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
const SHA256 = /^[0-9a-f]{64}$/;
function parseBundledPlugins(raw, workspaceIds) {
function parseOnboarding(value, id, problems) {
if (value === undefined) return null;
if (!value || typeof value !== "object" || Array.isArray(value)) {
problems.push(`${id} has an onboarding field that is not an object`);
return null;
}
for (const key of Object.keys(value)) {
if (!ONBOARDING_KEYS.has(key)) {
problems.push(`${id} has an unknown onboarding field "${key}"`);
return null;
}
}
const { recommended = false, consent = false } = value;
if (
recommended !== true &&
recommended !== false &&
recommended !== "desktop"
) {
problems.push(
`${id} onboarding.recommended must be true, false or "desktop"`,
);
return null;
}
if (typeof consent !== "boolean") {
problems.push(`${id} onboarding.consent must be a boolean`);
return null;
}
const { enabledByEnv } = value;
if (enabledByEnv === undefined) return { recommended, consent };
if (
!Array.isArray(enabledByEnv) ||
enabledByEnv.length === 0 ||
!enabledByEnv.every(
(name) => typeof name === "string" && ENV_NAME.test(name),
)
) {
problems.push(
`${id} onboarding.enabledByEnv must be a list of environment variable names`,
);
return null;
}
return { recommended, consent, enabledByEnv };
}
function parseBundledPlugins(raw) {
const problems = [];
const list = raw && Array.isArray(raw.plugins) ? raw.plugins : null;
if (!list) {
@@ -41,15 +94,7 @@ function parseBundledPlugins(raw, workspaceIds) {
}
seen.add(entry.id);
if (entry.source === "workspace") {
if (!workspaceIds.includes(entry.id)) {
problems.push(
`${entry.id} is a workspace plugin but plugins/${entry.id} does not exist`,
);
continue;
}
plugins.push({ id: entry.id, source: "workspace" });
} else if (entry.source === "tmxplug") {
if (entry.source === "tmxplug") {
const hasUrl =
typeof entry.url === "string" && entry.url.startsWith("https://");
const hasPath = typeof entry.path === "string" && entry.path.length > 0;
@@ -63,51 +108,29 @@ function parseBundledPlugins(raw, workspaceIds) {
problems.push(`${entry.id} needs a lowercase hex sha256`);
continue;
}
if (workspaceIds.includes(entry.id)) {
problems.push(
`${entry.id} comes from a .tmxplug, so delete plugins/${entry.id}`,
);
continue;
}
const before = problems.length;
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
if (problems.length > before) continue;
plugins.push({
id: entry.id,
source: "tmxplug",
url: hasUrl ? entry.url : null,
path: hasPath ? entry.path : null,
sha256: entry.sha256,
onboarding,
});
} else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
}
}
for (const id of workspaceIds) {
if (!seen.has(id)) {
problems.push(
`plugins/${id} is not listed in docker/bundled-plugins.json`,
);
}
}
return { plugins, problems };
}
function loadBundledPlugins(root) {
const configPath = path.join(root, "docker", "bundled-plugins.json");
const pluginsDir = path.join(root, "plugins");
const workspaceIds = fs.existsSync(pluginsDir)
? fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) =>
fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")),
)
.map((entry) => entry.name)
.sort()
: [];
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
const { plugins, problems } = parseBundledPlugins(raw, workspaceIds);
const { plugins, problems } = parseBundledPlugins(raw);
if (problems.length > 0) {
throw new Error(`docker/bundled-plugins.json:\n ${problems.join("\n ")}`);
}
@@ -187,8 +210,172 @@ async function extractArtifact(buffer, outDir, id) {
}
}
/**
* What dist/plugins/bundled-index.json holds: the onboarding defaults of each
* staged plugin, read by the backend when it seeds a fresh install.
*/
function buildBundledIndex(plugins, stagedIds) {
const staged = new Set(stagedIds);
const out = {};
for (const plugin of plugins) {
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
out[plugin.id] = { onboarding: plugin.onboarding };
}
return { version: 1, plugins: out };
}
/**
* Pins the newest registry version of each wanted plugin. `ids` is the list
* to pin, or null for every plugin in the index. `current` is the existing
* pin list, so fields the registry does not know (onboarding) carry over.
*/
function pinsFromIndex(index, ids, current = []) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry]));
const wanted = ids ?? plugins.map((plugin) => plugin.id);
return [...wanted].sort().map((id) => {
const latest = byId.get(id)?.versions?.[0];
if (!latest) throw new Error(`${id} is not in the registry index`);
const onboarding = currentById.get(id)?.onboarding;
return {
id,
source: "tmxplug",
url: latest.url,
sha256: latest.sha256,
...(onboarding ? { onboarding } : {}),
};
});
}
const LOCAL_FILES = [
"manifest.json",
"dist",
"locales",
"migrations",
"README.md",
"CHANGELOG.md",
];
/**
* Built plugin repos under dir (each with manifest.json and dist/), by id.
* Used in development to stage local plugin builds instead of the pins.
*/
function findLocalPluginBuilds(dir) {
const builds = new Map();
if (!dir || !fs.existsSync(dir)) return builds;
for (const name of fs.readdirSync(dir)) {
const repo = path.join(dir, name);
const manifestPath = path.join(repo, "manifest.json");
if (!fs.existsSync(manifestPath)) continue;
if (!fs.existsSync(path.join(repo, "dist"))) continue;
try {
const { id } = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (typeof id === "string" && ID.test(id)) builds.set(id, repo);
} catch {
// not a plugin repo
}
}
return builds;
}
const BUILD_INPUTS = [
"src",
"locales",
"migrations",
"manifest.json",
"package.json",
];
function newestMtime(target) {
if (!fs.existsSync(target)) return 0;
const stat = fs.statSync(target);
if (!stat.isDirectory()) return stat.mtimeMs;
let newest = 0;
for (const name of fs.readdirSync(target)) {
newest = Math.max(newest, newestMtime(path.join(target, name)));
}
return newest;
}
/** True when a plugin repo has never been built or its sources changed since. */
/** The SDK CLI in this checkout, which dev builds local plugins with. */
const LOCAL_SDK_CLI = path.join(
__dirname,
"..",
"..",
"packages",
"plugin-sdk",
"cli",
);
/**
* True when the build is missing, a source is newer than it, or the SDK CLI
* that builds it changed since (sdkCli, when given).
*/
function isLocalBuildStale(repo, sdkCli) {
const built = newestMtime(path.join(repo, "dist"));
if (!built) return true;
if (sdkCli && newestMtime(sdkCli) > built) return true;
return BUILD_INPUTS.some(
(entry) => newestMtime(path.join(repo, entry)) > built,
);
}
/**
* The command that builds a plugin repo with this checkout's SDK instead of
* the published one in its node_modules, so SDK edits apply right away.
*/
function localSdkBuildCommand() {
return [process.execPath, [path.join(LOCAL_SDK_CLI, "index.mjs"), "build"]];
}
/**
* Builds each plugin repo whose build is missing or out of date. ids limits
* it to those plugins; null builds every one.
*/
function rebuildLocalPlugins(dir, ids, run) {
const rebuilt = [];
if (!dir || !fs.existsSync(dir)) return rebuilt;
for (const name of fs.readdirSync(dir)) {
const repo = path.join(dir, name);
const manifestPath = path.join(repo, "manifest.json");
if (!fs.existsSync(manifestPath)) continue;
if (!fs.existsSync(path.join(repo, "node_modules"))) continue;
let id;
try {
({ id } = JSON.parse(fs.readFileSync(manifestPath, "utf8")));
} catch {
continue;
}
if ((ids && !ids.has(id)) || !isLocalBuildStale(repo, LOCAL_SDK_CLI))
continue;
run(repo, id);
rebuilt.push(id);
}
return rebuilt;
}
/** Copies a local plugin build into the staging folder. */
function copyLocalBuild(repo, destination) {
fs.mkdirSync(destination, { recursive: true });
for (const entry of LOCAL_FILES) {
const from = path.join(repo, entry);
if (fs.existsSync(from)) {
fs.cpSync(from, path.join(destination, entry), { recursive: true });
}
}
}
module.exports = {
findLocalPluginBuilds,
isLocalBuildStale,
localSdkBuildCommand,
rebuildLocalPlugins,
copyLocalBuild,
parseBundledPlugins,
buildBundledIndex,
pinsFromIndex,
loadBundledPlugins,
checkSha256,
fetchArtifact,
+24
View File
@@ -0,0 +1,24 @@
/**
* The Markdown under one version's "## x.y.z" heading in CHANGELOG.md.
* The release scripts run before the SDK is built, so this is a small copy
* of changelogSection from packages/plugin-sdk/src/changelog.ts; lint checks
* the whole file with the real parser.
*/
function changelogSection(markdown, version) {
const lines = markdown.replace(/\r/g, "").split("\n");
const heading =
/^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+.+?)?\s*$/;
let start = -1;
let fence = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith("```")) fence = !fence;
if (fence || !/^##\s/.test(line)) continue;
if (start >= 0) return lines.slice(start, i).join("\n").trim();
const match = heading.exec(line.trim());
if (match && match[1] === version) start = i + 1;
}
return start >= 0 ? lines.slice(start).join("\n").trim() : null;
}
module.exports = { changelogSection };
+109
View File
@@ -0,0 +1,109 @@
export const NETWORK = "termix-dev";
export const APP_CONTAINER = "termix-dev";
export const APP_IMAGE = "termix:dev";
/** Side containers npm run dev can run. hostPort is what the local backend uses. */
export const SERVICES = {
postgres: {
container: "termix-dev-postgres",
image: "postgres:16",
hostPort: 55432,
port: 5432,
env: {
POSTGRES_USER: "termix",
POSTGRES_PASSWORD: "termix",
POSTGRES_DB: "termix",
},
volume: ["termix-dev-postgres", "/var/lib/postgresql/data"],
// Over TCP, so the server that only runs during first-time setup does
// not count as ready.
ready: ["pg_isready", "-h", "127.0.0.1", "-U", "termix", "-d", "termix"],
},
mysql: {
container: "termix-dev-mysql",
image: "mysql:8",
hostPort: 33306,
port: 3306,
env: { MYSQL_ROOT_PASSWORD: "termix", MYSQL_DATABASE: "termix" },
volume: ["termix-dev-mysql", "/var/lib/mysql"],
ready: ["mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-ptermix"],
},
guacd: {
container: "termix-dev-guacd",
image: "guacamole/guacd:1.6.0",
hostPort: 4822,
port: 4822,
env: {},
volume: null,
ready: null,
},
};
function envArgs(env) {
return Object.entries(env).flatMap(([key, value]) => [
"-e",
`${key}=${value}`,
]);
}
export function serviceRunArgs(service) {
return [
"run",
"-d",
"--name",
service.container,
"--network",
NETWORK,
"-p",
`${service.hostPort}:${service.port}`,
...envArgs(service.env),
...(service.volume
? ["-v", `${service.volume[0]}:${service.volume[1]}`]
: []),
service.image,
];
}
/**
* Environment that points a backend at the side containers. inDocker means
* the backend runs in the dev network and reaches them by name.
*/
export function serviceEnv(options, inDocker) {
const env = {};
const address = (service) =>
inDocker
? `${service.container}:${service.port}`
: `127.0.0.1:${service.hostPort}`;
if (options.db === "postgres") {
env.DATABASE_DIALECT = "postgres";
env.DATABASE_URL = `postgres://termix:termix@${address(SERVICES.postgres)}/termix`;
} else if (options.db === "mysql") {
env.DATABASE_DIALECT = "mysql";
env.DATABASE_URL = `mysql://root:termix@${address(SERVICES.mysql)}/termix`;
}
if (options.guacd) {
const guacd = SERVICES.guacd;
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
}
return env;
}
export function appRunArgs({ port, env, dataMount, pluginsDir }) {
return [
"run",
"-d",
"--name",
APP_CONTAINER,
"--network",
NETWORK,
"-p",
`${port}:8080`,
...envArgs({ PORT: "8080", NODE_ENV: "development", ...env }),
"-v",
`${dataMount}:/app/data`,
"-v",
`${pluginsDir}:/app/dist/plugins`,
APP_IMAGE,
];
}
+212
View File
@@ -0,0 +1,212 @@
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
const { isLocalBuildStale } = createRequire(import.meta.url)(
"./bundled-plugins.cjs",
);
const ID = /^[a-z0-9][a-z0-9-]*$/;
const SOURCE_DIRS = new Set(["src", "locales", "migrations"]);
const SOURCE_FILES = new Set(["manifest.json", "package.json"]);
/** The error count from tsc --watch's "Found N errors" line, else null. */
export function parseTscStatus(line) {
const match = /Found (\d+) errors?\b.*Watching for file changes/.exec(line);
return match ? Number(match[1]) : null;
}
/** True when a changed path (relative to a plugin repo) feeds its build. */
export function isPluginSourceChange(filename) {
if (!filename) return false;
const parts = String(filename).replaceAll("\\", "/").split("/");
if (parts.length === 1) return SOURCE_FILES.has(parts[0]);
return SOURCE_DIRS.has(parts[0]);
}
/**
* Whether a watcher event in a plugin repo is an edit worth a rebuild.
*
* On Windows, fs.watch also reports a folder or file being read (its last
* access time changing, which NTFS writes lazily), so reading a repo looks
* like editing it. Folder events are ignored, and a file event only counts
* while its sources are newer than the last build. A path that no longer
* exists was deleted, which always counts.
*/
export function isPluginEdit(repo, filename, isStale = isLocalBuildStale) {
if (!isPluginSourceChange(filename)) return false;
let stat;
try {
stat = fs.statSync(path.join(repo, String(filename)));
} catch {
return true;
}
if (stat.isDirectory()) return false;
return isStale(repo);
}
/** Plugin repos under dir that can be built (manifest.json and node_modules). */
export function findBuildablePlugins(dir) {
const plugins = [];
if (!dir || !fs.existsSync(dir)) return plugins;
for (const name of fs.readdirSync(dir)) {
const repo = path.join(dir, name);
if (!fs.existsSync(path.join(repo, "node_modules"))) continue;
try {
const { id } = JSON.parse(
fs.readFileSync(path.join(repo, "manifest.json"), "utf8"),
);
if (typeof id === "string" && ID.test(id)) plugins.push({ id, repo });
} catch {
// not a plugin repo
}
}
return plugins.sort((a, b) => a.id.localeCompare(b.id));
}
/** Runs tasks one after another; a failed task does not stop the next. */
export function createSerialQueue() {
let tail = Promise.resolve();
return (task) => {
const run = tail.then(task, task);
tail = run.catch(() => {});
return run;
};
}
/**
* Calls fn(key) once a key has gone quiet for ms. A key that changes again
* while fn is still running for it runs once more afterwards.
*/
export function createDebouncer(ms, fn) {
const timers = new Map();
const running = new Set();
const again = new Set();
const fire = async (key) => {
timers.delete(key);
if (running.has(key)) {
again.add(key);
return;
}
running.add(key);
try {
await fn(key);
} finally {
running.delete(key);
if (again.delete(key)) trigger(key);
}
};
const trigger = (key) => {
clearTimeout(timers.get(key));
timers.set(
key,
setTimeout(() => void fire(key), ms),
);
};
trigger.cancel = () => {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
};
return trigger;
}
export const TARGETS = ["web", "electron", "docker"];
export const DATABASES = ["sqlite", "postgres", "mysql"];
export const HELP = `Usage: npm run dev -- [target] [options]
(npm run dev:electron and npm run dev:docker pick the target)
Targets
web Backend and Vite on http://localhost:5173 (default)
electron Backend, Vite and the desktop app
docker Build the production image and run it with your local plugins
Options
--plugins <dir> Plugin repos to build and watch (default ../Termix-Plugins)
--no-local Use only the plugins pinned in docker/bundled-plugins.json
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
--guacd Run guacd in Docker for remote desktop
--fresh Start from empty data (first run, onboarding)
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
electron, plus -<engine> for postgres or mysql)
--port <n> docker target: port on this machine (default 8081)
--no-cache docker target: build the image without the layer cache
-h, --help Show this
Keys while running
r restart the backend (docker: rebuild the image)
p rebuild every local plugin
o open the app in a browser
q quit`;
/** Reads npm run dev arguments. Throws on anything it does not know. */
export function parseDevArgs(argv) {
const options = {
target: "web",
pluginsDir: null,
local: true,
db: "sqlite",
guacd: false,
fresh: false,
data: null,
port: 8081,
noCache: false,
help: false,
};
const value = (i, name) => {
const next = argv[i + 1];
if (!next || next.startsWith("--"))
throw new Error(`${name} needs a value`);
return next;
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (TARGETS.includes(arg)) options.target = arg;
else if (arg === "-h" || arg === "--help") options.help = true;
else if (arg === "--no-local") options.local = false;
else if (arg === "--guacd") options.guacd = true;
else if (arg === "--fresh") options.fresh = true;
else if (arg === "--no-cache") options.noCache = true;
else if (arg === "--plugins") options.pluginsDir = value(i++, arg);
else if (arg === "--data") options.data = value(i++, arg);
else if (arg === "--db") {
options.db = value(i++, arg).toLowerCase();
if (!DATABASES.includes(options.db)) {
throw new Error(`--db must be one of ${DATABASES.join(", ")}`);
}
} else if (arg === "--port") {
options.port = Number(value(i++, arg));
if (!Number.isInteger(options.port) || options.port < 1) {
throw new Error("--port must be a port number");
}
} else throw new Error(`Unknown option ${arg}. Run with --help.`);
}
if (options.fresh && options.data) {
throw new Error("--fresh and --data cannot be used together");
}
return options;
}
/**
* The data folder the local backend uses, or null for its own default. The
* desktop, each database engine and fresh runs get their own, so a desktop's
* local profile or another engine's keys never land in the everyday data.
*/
export function dataDirFor(options) {
if (options.data) return options.data;
const suffix = [
options.target === "electron" ? "desktop" : null,
options.db === "sqlite" ? null : options.db,
]
.filter(Boolean)
.map((part) => `-${part}`)
.join("");
if (options.fresh) return `db/data-fresh${suffix}`;
return suffix ? `db/data${suffix}` : null;
}
export function needsDocker(options) {
return (
options.target === "docker" || options.db !== "sqlite" || options.guacd
);
}
+2 -2
View File
@@ -32,8 +32,8 @@ export const SHARED_VENDOR_MODULES = SHARED_VENDORS.map(
/** SDK entries that are implemented by, or hold state shared with, core. */
export const SHARED_SDK_MODULES = [
"@termix/plugin-sdk/frontend",
"@termix/plugin-sdk/ui",
"@termix-ssh/plugin-sdk/frontend",
"@termix-ssh/plugin-sdk/ui",
];
export function sharedModules() {
-124
View File
@@ -1,124 +0,0 @@
/**
* Every SQL statement a 2.8 install's Postgres or MySQL database runs on its
* way to 2.9.0, in the order it runs them: core's drizzle migrations added
* since release-2.8.0-tag, then each bundled plugin's own migrations in the
* order the loader activates them. The data copies that run after that
* (src/backend/upgrade/) are TypeScript, not SQL, and are covered by the
* upgrade test and scripts/upgrade-check.sh.
*
* Snapshot-tested by scripts/upgrade-sql.test.ts, so any change to the
* upgrade path shows up as a diff in review.
*
* Usage: node scripts/lib/upgrade-sql.mjs postgres|mysql
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
/** The last core migration release-2.8.0-tag shipped, per dialect. */
export const LAST_28_MIGRATION = {
postgres: "0024_flimsy_forge",
mysql: "0023_even_beast",
};
/** Plugin ids in the order the loader activates them: dependencies first. */
export function pluginActivationOrder(root = ROOT) {
const dir = path.join(root, "plugins");
const manifests = new Map(
fs
.readdirSync(dir, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(dir, entry.name, "manifest.json")),
)
.map((entry) => [
entry.name,
JSON.parse(
fs.readFileSync(path.join(dir, entry.name, "manifest.json"), "utf8"),
),
])
.sort(([a], [b]) => a.localeCompare(b)),
);
const order = [];
const done = new Set();
const visit = (id) => {
if (done.has(id) || !manifests.has(id)) return;
done.add(id);
const manifest = manifests.get(id);
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
visit(dependency);
}
for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) {
visit(dependency);
}
order.push(id);
};
for (const id of manifests.keys()) visit(id);
return order;
}
function section(title, sql) {
return `-- ==== ${title} ====\n${sql.trim()}\n`;
}
export function buildUpgradeSql(dialect, root = ROOT) {
if (!(dialect in LAST_28_MIGRATION)) {
throw new Error(`unsupported dialect "${dialect}"`);
}
const parts = [
`-- Termix 2.8.0 to 2.9.0 on ${dialect}, generated by scripts/lib/upgrade-sql.mjs\n`,
];
const coreDir = path.join(root, "drizzle", dialect);
const journal = JSON.parse(
fs.readFileSync(path.join(coreDir, "meta", "_journal.json"), "utf8"),
);
const tags = journal.entries.map((entry) => entry.tag);
const last = tags.indexOf(LAST_28_MIGRATION[dialect]);
if (last === -1) {
throw new Error(
`${LAST_28_MIGRATION[dialect]} is not in the ${dialect} journal`,
);
}
for (const tag of tags.slice(last + 1)) {
parts.push(
section(
`core ${tag}`,
fs.readFileSync(path.join(coreDir, `${tag}.sql`), "utf8"),
),
);
}
for (const id of pluginActivationOrder(root)) {
const dir = path.join(root, "plugins", id, "migrations", dialect);
if (!fs.existsSync(dir)) continue;
for (const file of fs
.readdirSync(dir)
.filter((name) => name.endsWith(".sql"))
.sort()) {
parts.push(
section(
`plugin ${id} ${file}`,
fs.readFileSync(path.join(dir, file), "utf8"),
),
);
}
}
return parts.join("\n").replace(/\r\n/g, "\n");
}
if (
process.argv[1] &&
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
) {
process.stdout.write(buildUpgradeSql(process.argv[2] ?? "postgres"));
}
-161
View File
@@ -1,161 +0,0 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
const GuacamoleLite = require("../node_modules/guacamole-lite/lib/Server.js");
type PatchedGuacdClient = {
connectionSettings: Record<string, unknown>;
nextArgumentStreamIndex: number;
sendInstruction: ReturnType<typeof vi.fn>;
sendHandshakeReply: (serverHandshake: string[]) => void;
sendRequiredArguments: (params: string[]) => void;
};
function createPatchedClient(
connectionSettings: Record<string, unknown>,
): PatchedGuacdClient {
return Object.assign(Object.create(GuacdClient.prototype), {
connectionSettings,
logger: { log: vi.fn() },
nextArgumentStreamIndex: 0,
sendInstruction: vi.fn(),
});
}
describe("patch-guacamole-lite", () => {
it("rejects a malformed token without starting or retaining a connection", async () => {
const server = Object.assign(Object.create(GuacamoleLite.prototype), {
connectionsCount: 0,
clientOptions: {
crypt: {
cypher: "AES-256-CBC",
key: Buffer.alloc(32, 7),
},
log: {
level: 0,
stdLog: vi.fn(),
errorLog: vi.fn(),
},
},
callbacks: {
processConnectionSettings: vi.fn(),
},
extractGuacdOptions: vi.fn(async () => ({
guacdOptions: { host: "127.0.0.1", port: 4822 },
connectionInfo: null,
isJoin: false,
targetSessionId: null,
})),
activeConnections: new Map(),
emit: vi.fn(),
});
const webSocket = {
OPEN: 1,
readyState: 1,
send: vi.fn(),
close: vi.fn(),
on: vi.fn(),
removeAllListeners: vi.fn(),
};
await server.newConnection(webSocket, {
url: "/guacamole/websocket/?token=not-an-encrypted-token",
});
expect(webSocket.close).toHaveBeenCalledOnce();
expect(server.activeConnections.size).toBe(0);
expect(server.emit).not.toHaveBeenCalledWith("open", expect.anything());
});
it("handles guacd dynamic argument requests", () => {
const guacdClientPath = path.join(
process.cwd(),
"node_modules",
"guacamole-lite",
"lib",
"GuacdClient.js",
);
const content = fs.readFileSync(guacdClientPath, "utf8");
expect(content).toContain("sendRequiredArguments(params)");
expect(content).toContain("opcode === 'required' || opcode === 'require'");
expect(content).toContain("this.sendInstruction(['argv'");
expect(content).toContain("this.sendInstruction(['blob'");
expect(content).toContain("this.sendInstruction(['end'");
});
it("keeps required-argument support when guacd offers a future 1.x protocol", () => {
const client = createPatchedClient({
hostname: "192.0.2.10",
port: 5900,
password: "secret",
width: 1280,
height: 720,
dpi: 96,
});
client.sendHandshakeReply(["VERSION_1_6_0", "hostname", "port"]);
expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"name",
"guacamole-lite",
]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"connect",
"VERSION_1_5_0",
"192.0.2.10",
5900,
]);
});
it("sends name instruction for VERSION_1_1_0 to fix guacd 1.6.0 VNC drops", () => {
const client = createPatchedClient({
hostname: "192.0.2.10",
port: 5900,
password: "secret",
width: 1280,
height: 720,
dpi: 96,
});
client.sendHandshakeReply(["VERSION_1_1_0", "hostname", "port"]);
expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"name",
"guacamole-lite",
]);
expect(client.sendInstruction).toHaveBeenCalledWith([
"connect",
"VERSION_1_1_0",
"192.0.2.10",
5900,
]);
});
it("answers required credentials through argument value streams", () => {
const client = createPatchedClient({
username: "",
password: "secret",
});
client.sendRequiredArguments(["username", "password"]);
expect(
client.sendInstruction.mock.calls.map(([instruction]) => instruction),
).toEqual([
["argv", 0, "text/plain", "username"],
["blob", 0, ""],
["end", 0],
["argv", 1, "text/plain", "password"],
["blob", 1, Buffer.from("secret", "utf8").toString("base64")],
["end", 1],
]);
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* Points docker/bundled-plugins.json at the newest version of each plugin in
* the official registry index. Run it again after a plugin is released or
* re-released.
*
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
* node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
*/
const fs = require("node:fs");
const path = require("node:path");
const {
loadBundledPlugins,
pinsFromIndex,
} = require("./lib/bundled-plugins.cjs");
const INDEX_URL =
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json";
const root = path.resolve(__dirname, "..");
const configPath = path.join(root, "docker", "bundled-plugins.json");
async function main() {
const all = process.argv.includes("--all");
const response = await fetch(INDEX_URL);
if (!response.ok) {
throw new Error(`registry index download failed with ${response.status}`);
}
const index = await response.json();
const current = loadBundledPlugins(root);
const ids = all ? null : current.map((plugin) => plugin.id);
const plugins = pinsFromIndex(index, ids, current);
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
console.log(
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
);
}
main().catch((error) => {
console.error(error?.message ?? error);
process.exit(1);
});
+74
View File
@@ -7,6 +7,10 @@ import {
build,
lowerPluginLayers,
} from "../packages/plugin-sdk/cli/commands/build.mjs";
import {
classNamesInCss,
splitByCoreClasses,
} from "../packages/plugin-sdk/cli/lib/css-classes.mjs";
const cleanups: Array<() => void> = [];
@@ -102,6 +106,76 @@ describe("plugin CSS layers", () => {
);
});
it("can leave a plugin's own classes in core's utilities layer", () => {
expect(
lowerPluginLayers(
"@layer properties{a{b:c}}@layer utilities{.w-px{width:1px}}",
{ utilities: false },
),
).toBe(
"@layer termix-plugin-properties{a{b:c}}@layer utilities{.w-px{width:1px}}",
);
});
it("reads class names from selectors, not declarations", () => {
const names = classNamesInCss(
String.raw`.w-28{width:calc(var(--spacing) * 28)}@media (width>=48rem){.md\:w-44{width:0.5rem}}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.top-1\/2{top:50%}.\32 xl\:flex{display:flex}`,
);
expect([...names].sort()).toEqual(
[
"2xl:flex",
"group",
"group-hover:opacity-100",
"md:w-44",
"top-1/2",
"w-28",
].sort(),
);
});
it("splits a plugin's classes by whether core ships them", () => {
expect(
splitByCoreClasses(
["w-28", "md:w-44", "hidden"],
new Set(["w-28", "hidden"]),
),
).toEqual({ shared: ["w-28", "hidden"], own: ["md:w-44"] });
});
it("keeps variants core ships with the plugin's own classes", () => {
expect(
splitByCoreClasses(
["max-h-56", "md:max-h-none", "flex-col", "md:flex-row"],
new Set(["md:max-h-none", "flex-col", "md:flex-row"]),
),
).toEqual({
shared: ["flex-col"],
own: ["max-h-56", "md:max-h-none", "md:flex-row"],
});
});
it("keeps classes core ships below core and the plugin's own beside core's", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
// Core's class list comes from scanning this repo, this file included,
// so the plugin-only class is put together here instead of written out.
const width = ["w-", "[321px]"].join("");
const dir = fixturePlugin();
fs.mkdirSync(path.join(dir, "src", "frontend"), { recursive: true });
fs.writeFileSync(
path.join(dir, "src", "frontend", "index.tsx"),
`export const a = <div className="hidden md:${width}" />;\n`,
);
await build({ cwd: dir });
const css = fs.readFileSync(path.join(dir, "dist", "frontend.css"), "utf8");
const lowered = css.slice(css.indexOf("@layer termix-plugin-utilities"));
const own = css.slice(css.lastIndexOf("@layer utilities{"));
expect(lowered).toMatch(/^@layer termix-plugin-utilities\{[^@]*\.hidden\{/);
expect(own).toContain(String.raw`.md\:w-\[321px\]`);
expect(own).not.toContain(".hidden{");
});
it("is ordered in core's CSS between base and core's utilities", () => {
const css = fs.readFileSync(
path.join(__dirname, "..", "src", "ui", "index.css"),
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import {
changelogSection,
parseChangelog,
parseReleaseNotes,
validateChangelog,
} from "../packages/plugin-sdk/src/changelog.ts";
const FILE = [
"# Changelog",
"",
"## 1.1.0 - 2026-10-06",
"",
"Second release.",
"",
"### Added",
"- Something new",
" that wraps",
"* Another thing",
"",
"### Fixed",
"- Something broken",
"",
"## 1.0.0",
"",
"### Added",
"- The first version",
"",
].join("\n");
describe("parseChangelog", () => {
it("reads releases, summaries and sections", () => {
const { changelog, problems } = parseChangelog(FILE);
expect(problems).toEqual([]);
expect(changelog.releases).toEqual([
{
version: "1.1.0",
date: "2026-10-06",
summary: "Second release.",
changes: [
{ type: "added", text: "Something new\nthat wraps" },
{ type: "added", text: "Another thing" },
{ type: "fixed", text: "Something broken" },
],
},
{
version: "1.0.0",
changes: [{ type: "added", text: "The first version" }],
},
]);
});
it("accepts Keep a Changelog brackets, CRLF and calendar versions", () => {
const { changelog, problems } = parseChangelog(
"## [26.10.0] - 2026-10-06\r\n\r\n### Changed\r\n- A thing\r\n",
);
expect(problems).toEqual([]);
expect(changelog.releases[0]).toMatchObject({
version: "26.10.0",
date: "2026-10-06",
});
});
it("orders a prerelease below its release", () => {
const md = (a: string, b: string) =>
`## ${a}\n### Added\n- x\n## ${b}\n### Added\n- y\n`;
expect(parseChangelog(md("2.0.0", "2.0.0-beta.1")).problems).toEqual([]);
expect(parseChangelog(md("2.0.0-beta.1", "2.0.0")).problems).toContain(
"releases must be listed newest first",
);
});
it("flags bad headings, dates, sections, stray text and duplicates", () => {
const { problems } = parseChangelog(
[
"## v1",
"## 1.0.0 - October 1",
"### Improved",
"- x",
"### Added",
"loose text",
"- y",
"## 1.0.0",
"### Added",
"- z",
].join("\n"),
);
expect(problems).toEqual(
expect.arrayContaining([
'line 1: "## v1" should look like "## 1.2.0 - 2026-10-06"',
'1.0.0 has a date "October 1", use YYYY-MM-DD',
expect.stringContaining('unknown section "Improved"'),
expect.stringContaining("that is not a list item: loose text"),
"1.0.0 is listed twice",
]),
);
});
it("flags a release with no changes", () => {
const { problems } = parseChangelog("## 1.0.0\n\nJust a summary.\n");
expect(problems).toContain("1.0.0 has no changes listed");
});
it("flags an Unreleased section", () => {
const { changelog, problems } = parseChangelog(
"## Unreleased\n### Added\n- x\n\n## 1.0.0\n### Added\n- y\n",
);
expect(problems).toEqual([
'line 1: drop "## Unreleased", notes go under the version they ship in',
]);
expect(changelog.releases.map((r) => r.version)).toEqual(["1.0.0"]);
});
it("ignores headings inside code fences", () => {
const { changelog, problems } = parseChangelog(
"## 1.0.0\n\n```\n## not a heading\n```\n\n### Added\n- x\n",
);
expect(problems).toEqual([]);
expect(changelog.releases).toHaveLength(1);
});
});
describe("validateChangelog", () => {
it("passes when the newest release is the manifest version", () => {
expect(validateChangelog(FILE, "1.1.0")).toEqual([]);
});
it("flags a manifest version with no notes", () => {
expect(validateChangelog(FILE, "1.2.0")).toContain(
"the newest release is 1.1.0, but the manifest version is 1.2.0",
);
});
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',
]);
});
});
describe("changelogSection", () => {
it("returns the Markdown under a version's heading", () => {
expect(changelogSection(FILE, "1.1.0")).toBe(
[
"Second release.",
"",
"### Added",
"- Something new",
" that wraps",
"* Another thing",
"",
"### Fixed",
"- Something broken",
].join("\n"),
);
expect(changelogSection(FILE, "1.0.0")).toBe(
"### Added\n- The first version",
);
});
it("is null for a version that is not there", () => {
expect(changelogSection(FILE, "9.9.9")).toBeNull();
});
});
describe("parseReleaseNotes", () => {
it("parses a section body on its own", () => {
expect(
parseReleaseNotes("Summary.\n\n### Security\n- Patched a hole"),
).toEqual({
summary: "Summary.",
changes: [{ type: "security", text: "Patched a hole" }],
problems: [],
});
});
});
+17 -20
View File
@@ -24,13 +24,8 @@ function fixture() {
path.join(root, "node_modules"),
"dir",
);
fs.mkdirSync(path.join(root, "scripts"));
fs.copyFileSync(
path.join(repo, "scripts/apply-plugin-patches.cjs"),
path.join(root, "scripts/apply-plugin-patches.cjs"),
);
const plugin = path.join(root, "plugins/fixture");
fs.mkdirSync(path.join(plugin, "patches"), { recursive: true });
const plugin = path.join(root, "fixture");
fs.mkdirSync(plugin);
fs.writeFileSync(
path.join(plugin, "manifest.json"),
JSON.stringify({ id: "fixture" }),
@@ -39,10 +34,6 @@ function fixture() {
path.join(plugin, "package.json"),
JSON.stringify({ name: "fixture" }),
);
fs.writeFileSync(
path.join(plugin, "patches/check.cjs"),
'require("node:fs").writeFileSync("patched", "ok");',
);
const run = (...args: string[]) =>
spawnSync(process.execPath, args, { cwd: plugin, encoding: "utf8" });
return {
@@ -54,15 +45,6 @@ function fixture() {
}
describe("plugin CLI before the SDK is built", () => {
it("runs the postinstall plugin patches without SDK dist files", () => {
const { root, plugin, run } = fixture();
expect(fs.existsSync(path.join(root, "packages/plugin-sdk/dist"))).toBe(
false,
);
const result = run(path.join(root, "scripts/apply-plugin-patches.cjs"));
expect(result.status, result.stderr).toBe(0);
expect(fs.readFileSync(path.join(plugin, "patched"), "utf8")).toBe("ok");
});
it("prints help without SDK dist files", () => {
const { cli, run } = fixture();
const result = run(cli, "--help");
@@ -77,3 +59,18 @@ describe("plugin CLI before the SDK is built", () => {
expect(result.stderr).not.toContain("ERR_MODULE_NOT_FOUND");
});
});
describe("termix-plugin test", () => {
it("applies the plugin's patches before running vitest", () => {
const { cli, plugin, run } = fixture();
fs.mkdirSync(path.join(plugin, "patches"));
fs.writeFileSync(
path.join(plugin, "patches", "mark.cjs"),
'require("node:fs").writeFileSync("patched.txt", "yes");',
);
run(cli, "test");
expect(fs.readFileSync(path.join(plugin, "patched.txt"), "utf8")).toBe(
"yes",
);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
isSourceChange,
pushPlugin,
resolveDevOptions,
} from "../packages/plugin-sdk/cli/commands/dev.mjs";
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length) cleanups.pop()!();
});
function json(status: number, body: unknown) {
return new Response(JSON.stringify(body), { status });
}
function tempFile(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugin-dev-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
const file = path.join(dir, "demo-1.0.0.tmxplug");
fs.writeFileSync(file, "archive");
return file;
}
describe("termix-plugin dev options", () => {
it("reads flags first, then the environment", () => {
expect(
resolveDevOptions(["--server", "http://a:8080/", "--key", "tmx_1"], {}),
).toEqual({ server: "http://a:8080", key: "tmx_1", once: false });
expect(
resolveDevOptions(["--once"], {
TERMIX_SERVER_URL: "http://b",
TERMIX_API_KEY: "tmx_2",
}),
).toEqual({ server: "http://b", key: "tmx_2", once: true });
expect(resolveDevOptions([], { TERMIX_API_KEY: "tmx_3" }).server).toBe(
"http://localhost:30001",
);
});
it("needs an API key", () => {
expect(() => resolveDevOptions([], {})).toThrow(/API key/);
});
it("watches sources only", () => {
expect(isSourceChange("src/frontend/index.tsx")).toBe(true);
expect(isSourceChange("README.md")).toBe(true);
expect(isSourceChange("dist/frontend.js")).toBe(false);
});
});
describe("pushPlugin", () => {
it("uploads the file then installs it with the capabilities it asks for", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(
json(200, { token: "t".repeat(32), capabilities: ["kv:own"] }),
)
.mockResolvedValueOnce(
json(200, { id: "demo", version: "1.0.0", state: "active" }),
);
const { result } = await pushPlugin(
tempFile(),
{ server: "http://s", key: "tmx_k" },
fetchImpl,
);
expect(result.state).toBe("active");
const [uploadUrl, upload] = fetchImpl.mock.calls[0];
expect(uploadUrl).toBe("http://s/plugins/upload");
expect(upload.headers.Authorization).toBe("Bearer tmx_k");
expect(upload.headers["Content-Type"]).toBe("application/octet-stream");
const [installUrl, install] = fetchImpl.mock.calls[1];
expect(installUrl).toBe(
`http://s/plugins/upload/${"t".repeat(32)}/install`,
);
expect(JSON.parse(install.body)).toEqual({ capabilities: ["kv:own"] });
});
it("explains a server with developer mode off", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
json(403, {
error: "Installing from a file needs developer mode",
code: "DEVELOPER_MODE_OFF",
}),
);
await expect(
pushPlugin(tempFile(), { server: "http://s", key: "tmx_k" }, fetchImpl),
).rejects.toThrow(
/403 Installing from a file needs developer mode\n.*developer mode/,
);
});
});
+51
View File
@@ -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/);
});
});
-121
View File
@@ -1,121 +0,0 @@
const fs = require("fs");
const path = require("path");
function extractYoutubeId(notes) {
const match = notes.match(
/<!--\s*YOUTUBE\s*-->([\s\S]*?)<!--\s*\/YOUTUBE\s*-->/,
);
if (!match || !match[1].trim()) {
throw new Error(
"missing or empty <!-- YOUTUBE --> section in release notes",
);
}
return parseYoutubeId(match[1].trim());
}
function parseYoutubeId(raw) {
const value = raw.trim();
let m = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
if (m) return m[1];
m = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
if (m) return m[1];
m = value.match(/embed\/([A-Za-z0-9_-]+)/);
if (m) return m[1];
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
throw new Error(`could not parse a YouTube video id from "${value}"`);
}
async function getAccessToken({ clientId, clientSecret, refreshToken }) {
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!res.ok) {
throw new Error(
`token exchange failed (${res.status}): ${await res.text()}`,
);
}
const json = await res.json();
return json.access_token;
}
async function getVideoStatus(accessToken, videoId) {
const res = await fetch(
`https://www.googleapis.com/youtube/v3/videos?part=status&id=${videoId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
if (!res.ok)
throw new Error(`videos.list failed (${res.status}): ${await res.text()}`);
const json = await res.json();
return json.items?.[0]?.status?.privacyStatus ?? null;
}
async function setVideoPublic(accessToken, videoId) {
const status = await getVideoStatus(accessToken, videoId);
if (status === "public") {
console.log(`Video ${videoId} is already public, skipping.`);
return;
}
const res = await fetch(
"https://www.googleapis.com/youtube/v3/videos?part=status",
{
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: videoId,
status: { privacyStatus: "public" },
}),
},
);
if (!res.ok) {
throw new Error(
`videos.update failed (${res.status}): ${await res.text()}`,
);
}
return res.json();
}
async function main() {
const clientId = process.env.YOUTUBE_CLIENT_ID;
const clientSecret = process.env.YOUTUBE_CLIENT_SECRET;
const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN;
if (!clientId || !clientSecret || !refreshToken) {
throw new Error(
"YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, and YOUTUBE_REFRESH_TOKEN are required",
);
}
const notesPath = path.resolve(
process.env.RELEASE_NOTES || "RELEASE_NOTES.md",
);
const notes = fs.readFileSync(notesPath, "utf8");
const videoId = extractYoutubeId(notes);
const accessToken = await getAccessToken({
clientId,
clientSecret,
refreshToken,
});
await setVideoPublic(accessToken, videoId);
console.log(`Set YouTube video ${videoId} to public.`);
}
module.exports = { extractYoutubeId, parseYoutubeId };
if (require.main === module) {
main().catch((err) => {
console.error(`publish-youtube: ${err.message}`);
process.exit(1);
});
}
-55
View File
@@ -1,55 +0,0 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { extractYoutubeId, parseYoutubeId } = require("./publish-youtube.cjs");
describe("parseYoutubeId", () => {
it("parses a youtu.be short link", () => {
expect(parseYoutubeId("https://youtu.be/At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
});
it("parses a watch?v= link", () => {
expect(parseYoutubeId("https://www.youtube.com/watch?v=At8iDk6-Q_s")).toBe(
"At8iDk6-Q_s",
);
});
it("parses an embed link", () => {
expect(parseYoutubeId("https://www.youtube.com/embed/At8iDk6-Q_s")).toBe(
"At8iDk6-Q_s",
);
});
it("accepts a bare id", () => {
expect(parseYoutubeId("At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
});
it("throws on garbage", () => {
expect(() => parseYoutubeId("https://example.com/")).toThrow();
});
});
describe("extractYoutubeId", () => {
it("pulls the id from a release notes YOUTUBE section", () => {
const notes = [
"<!-- SUMMARY -->",
"stuff",
"<!-- /SUMMARY -->",
"<!-- YOUTUBE -->",
"https://youtu.be/At8iDk6-Q_s",
"<!-- /YOUTUBE -->",
].join("\n");
expect(extractYoutubeId(notes)).toBe("At8iDk6-Q_s");
});
it("throws when the YOUTUBE section is missing", () => {
expect(() => extractYoutubeId("no section here")).toThrow(/YOUTUBE/);
});
it("throws when the YOUTUBE section is empty", () => {
expect(() =>
extractYoutubeId("<!-- YOUTUBE -->\n\n<!-- /YOUTUBE -->"),
).toThrow(/empty/);
});
});
-85
View File
@@ -1,85 +0,0 @@
/**
* Runs every plugin's own vitest suite.
*
* Core's `npm run test` covers src/ only. A plugin's tests live beside it and
* run under its own config, so a plugin that moves out of this repo keeps a
* working suite.
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const source = path.join(root, "plugins");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
const pluginIds = fs
.readdirSync(source, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) =>
fs.existsSync(path.join(source, entry.name, "vitest.config.ts")),
)
.map((entry) => entry.name);
const failed = [];
const skipped = [];
let ran = 0;
// A vitest fork that dies mid-run (seen on Windows under memory pressure)
// fails the suite without any test failing. That is retried once; a real
// test failure is not.
const WORKER_CRASH = /Worker exited unexpectedly|Worker forks emitted error/;
function runSuite(pluginDir) {
const result = spawnSync(process.execPath, [cli, "test"], {
cwd: pluginDir,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
process.stdout.write(result.stdout ?? "");
process.stderr.write(result.stderr ?? "");
return result;
}
for (const id of pluginIds) {
const pluginDir = path.join(source, id);
const hasTests = ["tests/backend", "tests/frontend"].some((dir) => {
const full = path.join(pluginDir, dir);
return (
fs.existsSync(full) &&
fs
.readdirSync(full, { recursive: true })
.some(
(name) =>
String(name).endsWith(".test.ts") ||
String(name).endsWith(".test.tsx"),
)
);
});
if (!hasTests) {
skipped.push(id);
continue;
}
ran++;
let result = runSuite(pluginDir);
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
if (result.status !== 0 && WORKER_CRASH.test(output)) {
console.warn(`\n${id}: a test worker crashed, running the suite again`);
result = runSuite(pluginDir);
}
if (result.status !== 0) failed.push(id);
}
if (skipped.length > 0) {
console.warn(`\nNo tests found for: ${skipped.join(", ")}`);
}
if (failed.length > 0) {
console.error(`\nPlugin tests failed: ${failed.join(", ")}`);
process.exit(1);
}
console.log(`\nPlugin tests passed for ${ran} plugin(s).`);
-35
View File
@@ -1,35 +0,0 @@
# Throwaway Postgres and MySQL servers for scripts/upgrade-check.sh. Data
# lives in tmpfs, so every run starts from an empty server.
name: termix-upgrade-check
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: termix
POSTGRES_PASSWORD: termix
POSTGRES_DB: termix
ports:
- "55433:5432"
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U termix -d termix"]
interval: 2s
timeout: 5s
retries: 30
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: termix
MYSQL_DATABASE: termix
ports:
- "33307:3306"
tmpfs:
- /var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ptermix"]
interval: 2s
timeout: 5s
retries: 60
+193
View File
@@ -0,0 +1,193 @@
/**
* Builds a 2.9.1 database on a real Postgres or MySQL server, seeds the rows
* 26.10.0 rewrites or drops, then runs the real startup over it and checks
* the result. 2.9.1 is the oldest database this version opens.
*
* Run through scripts/upgrade-check.sh, which starts the servers:
* tsx scripts/upgrade-check.mjs <postgres|mysql> <url> <legacy drizzle dir>
*
* Wipes the target database first.
*/
import path from "path";
import { fileURLToPath, pathToFileURL } from "url";
import { getTableColumns, getTableName, is, sql, Table } from "drizzle-orm";
const [dialect, url, legacyRoot] = process.argv.slice(2);
if (!["postgres", "mysql"].includes(dialect) || !url || !legacyRoot) {
console.error(
"usage: tsx scripts/upgrade-check.mjs <postgres|mysql> <url> <legacy drizzle dir>",
);
process.exit(2);
}
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const load = (file) => import(pathToFileURL(path.join(ROOT, file)).href);
const { drizzle } = await import(
dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2"
);
const db = drizzle(url);
const rows = (result) =>
Array.isArray(result) ? result[0] : (result.rows ?? []);
// Written with Postgres quoting; MySQL gets backticks.
const exec = (text) =>
db.execute(sql.raw(dialect === "postgres" ? text : text.replace(/"/g, "`")));
const one = async (text) => rows(await exec(text))[0];
if (dialect === "postgres") {
await exec(`DROP SCHEMA public CASCADE`);
await exec(`CREATE SCHEMA public`);
await exec(`DROP SCHEMA IF EXISTS drizzle CASCADE`);
} else {
const tables = rows(
await exec(
`SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE()`,
),
);
await exec(`SET FOREIGN_KEY_CHECKS = 0`);
for (const { name } of tables) await exec(`DROP TABLE IF EXISTS "${name}"`);
await exec(`SET FOREIGN_KEY_CHECKS = 1`);
}
const { migrate } =
dialect === "postgres"
? await import("drizzle-orm/node-postgres/migrator")
: await import("drizzle-orm/mysql2/migrator");
await migrate(db, { migrationsFolder: path.join(legacyRoot, dialect) });
console.log(`${dialect}: 2.9.1 schema built`);
const yes = dialect === "postgres" ? "true" : "1";
await exec(
`INSERT INTO "users" ("id", "username", "password_hash", "is_admin", "is_oidc", "oidc_identifier", "client_id") VALUES ('u1', 'alice', 'x', ${yes}, ${yes}, 'sub-1', 'cid')`,
);
await exec(
`INSERT INTO "plugins" ("id", "name", "version", "state", "manifest_json") VALUES ('snippets', 'Snippets', '1.0.0', 'enabled', '{}')`,
);
await exec(
`INSERT INTO "ssh_credentials" ("id", "user_id", "name", "auth_type", "username") VALUES (1, 'u1', 'cred', 'password', '$oidc.preferred_username')`,
);
await exec(
`INSERT INTO "ssh_folders" ("id", "user_id", "name", "credential_id") VALUES (1, 'u1', 'f', 1)`,
);
await exec(
`INSERT INTO "ssh_data" ("id", "user_id", "name", "ip", "port", "username", "auth_type", "enable_rdp", "folder") VALUES (1, 'u1', 'h', '10.0.0.1', 22, '$oidc.preferred_username', 'password', ${yes}, 'f')`,
);
// Its JSON quotes must not become backticks.
await db.execute(
sql`UPDATE ssh_data SET terminal_config = ${JSON.stringify({ sudoPassword: "s3cret" })} WHERE id = 1`,
);
await exec(
`INSERT INTO "sessions" ("id", "user_id", "jwt_token", "device_type", "device_info", "expires_at", "oidc_sub") VALUES ('s1', 'u1', 'jwt', 'web', 'ua', '2099-01-01', 'sub')`,
);
await exec(
`INSERT INTO "settings" ("key", "value") VALUES ('host_defaults', '{}'), ('allow_registration', 'true'), ('guac_url', 'x')`,
);
await exec(
`INSERT INTO "user_preferences" ("user_id", "terminal_macros", "rdp_defaults") VALUES ('u1', '[]', '{}')`,
);
process.env.DATABASE_DIALECT = dialect;
process.env.DATABASE_URL = url;
process.env.DRIZZLE_MIGRATIONS_DIR = path.join(ROOT, "drizzle");
const { initializeDatabase } = await load("src/backend/database/db/index.ts");
await initializeDatabase();
let failures = 0;
const check = (label, ok, detail) => {
if (!ok) failures++;
console.log(
` ${ok ? "ok " : "FAIL"} ${label}${ok ? "" : ` ${JSON.stringify(detail)}`}`,
);
};
const schemaName = dialect === "postgres" ? "current_schema()" : "DATABASE()";
const columns = new Set(
rows(
await exec(
`SELECT table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = ${schemaName}`,
),
).map((row) => `${row.t}.${row.c}`),
);
const { RETIRED_COLUMNS, REMOTE_RETIRED_COLUMNS } = await load(
"src/backend/database/db/retired-columns.ts",
);
const leftover = [RETIRED_COLUMNS, REMOTE_RETIRED_COLUMNS].flatMap((group) =>
Object.entries(group).flatMap(([table, names]) =>
names.map((name) => `${table}.${name}`).filter((key) => columns.has(key)),
),
);
check("retired columns are gone", leftover.length === 0, leftover);
const schema = await load(
`src/backend/database/db/schema.${dialect === "postgres" ? "pg" : "mysql"}.ts`,
);
const missing = Object.values(schema)
.filter((value) => is(value, Table))
.flatMap((table) =>
Object.values(getTableColumns(table))
.map((column) => `${getTableName(table)}.${column.name}`)
.filter((key) => !columns.has(key)),
);
check("every schema column exists", missing.length === 0, missing);
check(
"user kept",
(await one(`SELECT "username" FROM "users" WHERE "id" = 'u1'`))?.username ===
"alice",
);
const host = await one(
`SELECT "username", "sudo_password" FROM "ssh_data" WHERE "id" = 1`,
);
check(
"host username placeholder rewritten",
host?.username === "$external.username",
host,
);
check(
"sudo password moved out of terminal_config",
host?.sudo_password === "s3cret",
host,
);
const credential = await one(
`SELECT "username" FROM "ssh_credentials" WHERE "id" = 1`,
);
check(
"credential username placeholder rewritten",
credential?.username === "$external.username",
credential,
);
check(
"folder kept",
!!(await one(`SELECT "id" FROM "ssh_folders" WHERE "id" = 1`)),
);
check(
"session kept",
!!(await one(`SELECT "id" FROM "sessions" WHERE "id" = 's1'`)),
);
const keys = rows(await exec(`SELECT "key" FROM "settings"`)).map(
(row) => row.key,
);
check(
"retired settings deleted",
!keys.includes("host_defaults") && !keys.includes("guac_url"),
keys,
);
check("other settings kept", keys.includes("allow_registration"), keys);
check(
"system roles seeded",
rows(await exec(`SELECT "id" FROM "roles"`)).length > 0,
);
try {
await initializeDatabase();
check("a second start changes nothing", true);
} catch (error) {
check("a second start changes nothing", false, String(error));
}
console.log(
`\n${dialect}: ${failures ? `${failures} check(s) failed` : "all checks passed"}`,
);
process.exit(failures ? 1 : 0);
Executable → Regular
+45 -23
View File
@@ -1,15 +1,16 @@
#!/usr/bin/env bash
# Upgrades the 2.8 fixture install to 2.9.0 on real Postgres and MySQL
# servers and runs the same checks the SQLite upgrade test runs.
# Upgrades a 2.9.1 database to this tree on real Postgres and MySQL servers,
# then runs the dialect-aware backend tests against both.
#
# Needs Docker and a built tree (npm run build). Run from anywhere:
# Needs Docker and the 2.9.1 tag (git fetch --tags). Run from anywhere:
# bash scripts/upgrade-check.sh # both engines
# bash scripts/upgrade-check.sh postgres # one engine
# bash scripts/upgrade-check.sh mysql # one engine
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE=(docker compose -f "$ROOT/scripts/upgrade-check.compose.yml")
TAG="release-2.8.0-tag"
TAG="release-2.9.1-tag"
PG="termix-upgrade-check-pg"
MY="termix-upgrade-check-mysql"
if [ "$#" -gt 0 ]; then
DIALECTS=("$@")
else
@@ -18,43 +19,64 @@ fi
cd "$ROOT"
if [ ! -f dist/plugins/snippets/dist/backend.js ]; then
echo "dist/plugins is missing. Run npm run build first." >&2
exit 1
fi
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG is missing. Run git fetch --tags first." >&2
exit 1
fi
LEGACY="$(mktemp -d)"
WORK="$(mktemp -d)"
cleanup() {
"${COMPOSE[@]}" down -v >/dev/null 2>&1 || true
rm -rf "$LEGACY"
docker rm -f "$PG" "$MY" >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
# 2.8's own drizzle migrations build the 2.8 schema on each server.
git archive "$TAG" drizzle | tar -x -C "$LEGACY"
git archive "$TAG" drizzle | tar -x -C "$WORK"
mkdir -p "$WORK/data"
"${COMPOSE[@]}" up -d --wait
docker rm -f "$PG" "$MY" >/dev/null 2>&1 || true
docker run -d --name "$PG" -e POSTGRES_USER=termix -e POSTGRES_PASSWORD=termix \
-e POSTGRES_DB=termix -p 55433:5432 postgres:16 >/dev/null
docker run -d --name "$MY" -e MYSQL_ROOT_PASSWORD=termix -e MYSQL_DATABASE=termix \
-p 33307:3306 mysql:8 >/dev/null
wait_for() {
for _ in $(seq 1 60); do
if "$@" >/dev/null 2>&1; then return 0; fi
sleep 2
done
echo "Timed out waiting for: $*" >&2
exit 1
}
wait_for docker exec "$PG" pg_isready -U termix -d termix
wait_for docker exec "$MY" mysql -uroot -ptermix -e "SELECT 1"
docker exec "$PG" psql -U termix -c "CREATE DATABASE termix_test" >/dev/null
docker exec "$MY" mysql -uroot -ptermix -e "CREATE DATABASE termix_test" 2>/dev/null
TESTS=$(grep -rl "test-support" src/backend/tests --include="*.test.ts")
status=0
for dialect in "${DIALECTS[@]}"; do
case "$dialect" in
postgres) url="postgres://termix:termix@127.0.0.1:55433/termix" ;;
mysql) url="mysql://root:termix@127.0.0.1:33307/termix" ;;
postgres) base="postgres://termix:termix@127.0.0.1:55433" ;;
mysql) base="mysql://root:termix@127.0.0.1:33307" ;;
*)
echo "Unknown dialect $dialect (postgres or mysql)" >&2
exit 2
;;
esac
echo
echo "== 2.8 to 2.9.0 on $dialect"
if ! TEST_DIALECT="$dialect" TEST_DATABASE_URL="$url" \
UPGRADE_LEGACY_MIGRATIONS_DIR="$LEGACY/drizzle" \
npx vitest run src/backend/tests/plugins/upgrade-fixture.test.ts \
--project backend; then
echo "== 2.9.1 to this tree on $dialect"
if ! DATA_DIR="$WORK/data" npx tsx scripts/upgrade-check.mjs \
"$dialect" "$base/termix" "$WORK/drizzle"; then
status=1
fi
echo
echo "== dialect tests on $dialect"
# One file at a time: they share the database and wipe it between tests.
# shellcheck disable=SC2086
if ! TEST_DIALECT="$dialect" TEST_DATABASE_URL="$base/termix_test" \
npx vitest run --project backend --no-file-parallelism $TESTS; then
status=1
fi
done
-44
View File
@@ -1,44 +0,0 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { LEGACY_TABLE_OWNERS } from "../packages/plugin-sdk/src/db";
import { buildUpgradeSql } from "./lib/upgrade-sql.mjs";
const SNAPSHOTS = path.resolve(
__dirname,
"../src/backend/tests/fixtures/upgrade",
);
const RENAME = {
postgres: /^ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)";$/gm,
mysql: /^RENAME TABLE `([a-z0-9_]+)` TO `(p_[a-z0-9_]+)`;$/gm,
};
describe.each(["postgres", "mysql"] as const)(
"the 2.8 to 2.9.0 upgrade SQL on %s",
(dialect) => {
const sql = buildUpgradeSql(dialect);
const statements = sql.replace(/^--.*$/gm, "");
it("matches the reviewed snapshot", async () => {
await expect(sql).toMatchFileSnapshot(
path.join(SNAPSHOTS, `upgrade-${dialect}.sql`),
);
});
it("adopts every legacy table by renaming it, never by dropping it", () => {
const renamed = new Set(
[...sql.matchAll(RENAME[dialect])].map((match) => match[1]),
);
for (const table of Object.keys(LEGACY_TABLE_OWNERS)) {
expect(renamed, table).toContain(table);
expect(statements).not.toMatch(
new RegExp(`DROP TABLE[^;]*[\`"]${table}[\`"]`, "i"),
);
}
});
it("never drops a column", () => {
expect(statements).not.toMatch(/DROP COLUMN/i);
});
},
);
-94
View File
@@ -1,94 +0,0 @@
/**
* Validates plugin manifests against the SDK contract.
*
* node scripts/validate-plugin-manifest.cjs # every bundled manifest
* node scripts/validate-plugin-manifest.cjs path/to/manifest.json
*
* The rules live in packages/plugin-sdk/src/manifest.ts, which the server
* uses too, so there is one implementation rather than a copy here that
* drifts. This file only finds the manifests and prints the result.
*/
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const root = path.resolve(__dirname, "..");
const sdkEntry = path.join(
root,
"packages",
"plugin-sdk",
"dist",
"manifest.js",
);
async function loadValidator() {
if (!fs.existsSync(sdkEntry)) {
console.error(
`The plugin SDK is not built yet (${path.relative(root, sdkEntry)} is missing).\n` +
`Run: npm run build:sdk`,
);
process.exit(1);
}
return import(pathToFileURL(sdkEntry).href);
}
function bundledManifests() {
const pluginsDir = path.join(root, "plugins");
if (!fs.existsSync(pluginsDir)) return [];
return fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(pluginsDir, entry.name, "manifest.json"))
.filter((file) => fs.existsSync(file));
}
async function main() {
const { parseManifest } = await loadValidator();
const argument = process.argv[2];
const files = argument
? [path.resolve(process.cwd(), argument)]
: bundledManifests();
if (files.length === 0) {
console.log("No plugin manifests found.");
return;
}
let failed = 0;
for (const file of files) {
const relative = path.relative(root, file);
let raw;
try {
raw = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
console.error(`${relative}: not valid JSON: ${error.message}`);
failed += 1;
continue;
}
const { errors } = parseManifest(raw);
if (errors.length > 0) {
console.error(`${relative}:`);
for (const error of errors) console.error(` - ${error}`);
failed += 1;
continue;
}
console.log(`ok ${relative}`);
}
if (failed > 0) {
console.error(`\n${failed} manifest(s) failed validation.`);
process.exit(1);
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
-97
View File
@@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import { execFileSync } from "node:child_process";
import path from "node:path";
const VALIDATOR = path.join(__dirname, "validate-plugin-manifest.cjs");
const FIXTURES = path.join(__dirname, "__fixtures__", "manifests");
function runValidator(fixture?: string): { status: number; output: string } {
const args = fixture
? [VALIDATOR, path.join(FIXTURES, fixture)]
: [VALIDATOR];
try {
const output = execFileSync("node", args, { encoding: "utf8" });
return { status: 0, output };
} catch (err) {
const error = err as { status: number; stdout: string; stderr: string };
return { status: error.status, output: error.stdout + error.stderr };
}
}
describe("validate-plugin-manifest.cjs", () => {
it("accepts a valid manifest", () => {
const { status, output } = runValidator("valid.json");
expect(status).toBe(0);
expect(output).toContain("ok");
});
it("accepts a manifest declaring services", () => {
expect(runValidator("valid-services.json").status).toBe(0);
});
it("accepts a manifest declaring actions", () => {
expect(runValidator("valid-actions.json").status).toBe(0);
});
it("rejects a manifest missing a required field", () => {
const { status, output } = runValidator("invalid-missing-field.json");
expect(status).toBe(1);
expect(output).toContain("id");
});
it("rejects an id that is not a lowercase slug", () => {
const { status, output } = runValidator("invalid-bad-id.json");
expect(status).toBe(1);
expect(output).toContain("id");
});
it("rejects an unknown category", () => {
expect(runValidator("invalid-unknown-category.json").status).toBe(1);
});
// The dotted spelling was the v1 form. Capabilities are colon-separated now
// so they can never be confused with RBAC permissions.
it("rejects a dotted capability name", () => {
const { status, output } = runValidator("invalid-unknown-capability.json");
expect(status).toBe(1);
expect(output).toContain("hosts.read");
});
it("rejects a service permission the plugin does not declare", () => {
expect(runValidator("invalid-service-permission.json").status).toBe(1);
});
it("rejects an action permission the plugin does not declare", () => {
expect(runValidator("invalid-action-permission.json").status).toBe(1);
});
it("rejects an unknown top-level field", () => {
const { status, output } = runValidator("invalid-unknown-field.json");
expect(status).toBe(1);
expect(output).toContain("sudoEverything");
});
it("rejects an unknown field nested inside contributes", () => {
const { status, output } = runValidator(
"invalid-nested-unknown-field.json",
);
expect(status).toBe(1);
expect(output).toContain("extra");
});
// A manifest must not be able to name a core namespace, which is how a
// plugin could otherwise gate a route on admin authority it never had.
it("rejects a permission name outside the plugin's own namespace", () => {
const { status, output } = runValidator(
"invalid-role-default-escalation.json",
);
expect(status).toBe(1);
expect(output).toContain("admin.users.manage");
});
it("validates every bundled manifest when given no argument", () => {
const { status, output } = runValidator();
expect(status).toBe(0);
expect(output).toContain("ssh-terminal");
});
});
+5 -5
View File
@@ -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");
+5 -29
View File
@@ -4,10 +4,8 @@
* - Every shared module (React, i18next and the SDK) becomes an entry chunk,
* reached through a stable shim at dist/shared/<name>.js.
* - index.html gets an import map pointing those bare specifiers at the shims,
* so a plugin bundle's `import "react"` lands on the shell's React.
* - In `vite dev`, src/ui/plugin-host/workspace-plugins.ts is replaced with
* import.meta.glob calls, so the monorepo's plugins load through Vite with
* HMR, and the map points at dev-server module URLs instead.
* so a plugin bundle's `import "react"` lands on the shell's React. In
* `vite dev` the map points at dev-server module URLs instead.
*/
import fs from "node:fs";
@@ -72,16 +70,14 @@ function sourceFacade(file) {
export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
let command = "build";
let shared = [];
const workspaceModule = path
.join(repoRoot, "src", "ui", "plugin-host", "workspace-plugins.ts")
.replaceAll("\\", "/");
const facadeSource = (specifier) => {
if (SHARED_VENDOR_MODULES.includes(specifier)) {
return vendorFacade(repoRoot, specifier);
}
if (specifier === "@termix/plugin-sdk/ui") return sourceFacade(sdkUiEntry);
if (specifier === "@termix/plugin-sdk/frontend") {
if (specifier === "@termix-ssh/plugin-sdk/ui")
return sourceFacade(sdkUiEntry);
if (specifier === "@termix-ssh/plugin-sdk/frontend") {
return sourceFacade(sdkFrontendEntry);
}
throw new Error(`termix-plugin-host: ${specifier} is not a shared module`);
@@ -119,26 +115,6 @@ export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
if (id.startsWith(VIRTUAL_PREFIX)) {
return facadeSource(id.slice(VIRTUAL_PREFIX.length));
}
if (
command === "serve" &&
id.replaceAll("\\", "/").split("?")[0] === workspaceModule
) {
return [
'const frontends = import.meta.glob("/plugins/*/src/frontend/index.{tsx,ts}");',
'const locales = import.meta.glob("/plugins/*/locales/**/*.json", { import: "default" });',
"export const workspaceFrontends = {};",
"export const workspaceLocales = {};",
"for (const [file, load] of Object.entries(frontends)) {",
' workspaceFrontends[file.split("/")[2]] = load;',
"}",
"for (const [file, load] of Object.entries(locales)) {",
' const parts = file.split("/");',
" const id = parts[2];",
' const name = parts[parts.length - 1].replace(/\\.json$/, "");',
" (workspaceLocales[id] ??= {})[name] = load;",
"}",
].join("\n");
}
return null;
},