mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
v26.10.0 (#1573)
* 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:
3656 files changed
+116210
-460999
No files matched your search
@@ -339,6 +339,9 @@ export interface PluginRouterOptions {
|
||||
* and matched exactly ("/callback", not "/plugin-api/<id>/callback"). A
|
||||
* ":name" segment matches one segment, and a trailing "/*" matches any
|
||||
* number of further segments, for a proxied page ("/chooser/:id/*").
|
||||
* An entry only opens the route registered with that same path, for the
|
||||
* methods it was registered with, so a literal route beside a ":param"
|
||||
* ("/webhook/config" next to "/webhook/:token") still needs a login.
|
||||
*
|
||||
* For the handful of routes an unauthenticated third party has to reach: an
|
||||
* OIDC callback, an inbound webhook. Every entry is audited when the router
|
||||
@@ -373,7 +376,7 @@ export interface PluginHttp {
|
||||
/**
|
||||
* The public base URL a request came in on: origin plus the install's base
|
||||
* path, no trailing slash. Honours forwarded headers from a trusted proxy,
|
||||
* BASE_PATH and OIDC_FORCE_HTTPS. For building absolute callback URLs.
|
||||
* BASE_PATH and EXTERNAL_FORCE_HTTPS. For building absolute callback URLs.
|
||||
*/
|
||||
baseUrl: (req: unknown) => string;
|
||||
}
|
||||
@@ -1368,10 +1371,9 @@ export type PluginVerifiedIdentity =
|
||||
sid?: string | null;
|
||||
};
|
||||
/**
|
||||
* The values a 2.8 install kept on the user row (`oidc_identifier`,
|
||||
* `sso_provider_id`). Only for a plugin that took over a 2.8 login
|
||||
* method: core finds pre-2.9 accounts by `identifier` and keeps writing
|
||||
* both for new users so a downgrade still works.
|
||||
* The identifier a 2.8 install kept for the user. Only for a plugin
|
||||
* that took over a 2.8 login method: core also checks `identifier`
|
||||
* against `allowedUsers`. `providerRowId` is no longer read.
|
||||
*/
|
||||
legacy?: { identifier: string; providerRowId?: number | null };
|
||||
/**
|
||||
@@ -1860,8 +1862,9 @@ export interface PluginBinarySpec {
|
||||
/** Hex SHA-256 the file must have. */
|
||||
sha256: string;
|
||||
/**
|
||||
* Paths checked before downloading, such as a copy baked into the Docker
|
||||
* image. One whose checksum matches is used in place.
|
||||
* Absolute paths checked before downloading, such as a copy baked into the
|
||||
* Docker image. Only a regular file named `name` is considered, and one
|
||||
* whose checksum matches is used in place.
|
||||
*/
|
||||
prebuilt?: readonly string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* CHANGELOG.md: every release of a plugin (or of core) in one Markdown file.
|
||||
*
|
||||
* # Changelog
|
||||
*
|
||||
* ## 1.1.0
|
||||
*
|
||||
* An optional summary paragraph.
|
||||
*
|
||||
* ### Added
|
||||
* - Something new
|
||||
*
|
||||
* ### Fixed
|
||||
* - Something broken
|
||||
*
|
||||
* Releases are listed newest first, and the newest one has to be the
|
||||
* manifest version. Notes go straight under the version they ship in, there
|
||||
* is no Unreleased section.
|
||||
*/
|
||||
|
||||
import semver from "semver";
|
||||
|
||||
export const CHANGE_TYPES = [
|
||||
"added",
|
||||
"changed",
|
||||
"fixed",
|
||||
"removed",
|
||||
"deprecated",
|
||||
"security",
|
||||
] as const;
|
||||
|
||||
export type ChangeType = (typeof CHANGE_TYPES)[number];
|
||||
|
||||
export interface ChangelogChange {
|
||||
type: ChangeType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ChangelogRelease {
|
||||
version: string;
|
||||
date?: string;
|
||||
summary?: string;
|
||||
changes: ChangelogChange[];
|
||||
}
|
||||
|
||||
export interface Changelog {
|
||||
/** Newest first, as written. */
|
||||
releases: ChangelogRelease[];
|
||||
}
|
||||
|
||||
export interface ReleaseNotes {
|
||||
summary?: string;
|
||||
changes: ChangelogChange[];
|
||||
}
|
||||
|
||||
const RELEASE_HEADING =
|
||||
/^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+(.+?))?\s*$/;
|
||||
const UNRELEASED_HEADING = /^##\s+\[?unreleased\]?\s*$/i;
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const BULLET = /^[-*]\s+(.*)$/;
|
||||
const COMMENT = /^<!--.*-->$/;
|
||||
|
||||
/**
|
||||
* Parses the body under one release heading: an optional summary, then
|
||||
* `### Added` style sections of bullets. Indented lines continue the bullet
|
||||
* above them.
|
||||
*/
|
||||
export function parseReleaseNotes(
|
||||
body: string,
|
||||
where = "release",
|
||||
): ReleaseNotes & { problems: string[] } {
|
||||
const problems: string[] = [];
|
||||
const summary: string[] = [];
|
||||
const changes: ChangelogChange[] = [];
|
||||
let section: ChangeType | null = null;
|
||||
let current: ChangelogChange | null = null;
|
||||
let fence = false;
|
||||
|
||||
for (const raw of body.replace(/\r/g, "").split("\n")) {
|
||||
const line = raw.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("```")) fence = !fence;
|
||||
|
||||
if (!fence && trimmed.startsWith("### ")) {
|
||||
const name = trimmed.slice(4).trim().toLowerCase();
|
||||
if ((CHANGE_TYPES as readonly string[]).includes(name)) {
|
||||
section = name as ChangeType;
|
||||
} else {
|
||||
problems.push(
|
||||
`${where} has an unknown section "${trimmed.slice(4).trim()}", use one of ${CHANGE_TYPES.map(title).join(", ")}`,
|
||||
);
|
||||
section = null;
|
||||
}
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!section) {
|
||||
if (trimmed || summary.length > 0) summary.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed || COMMENT.test(trimmed)) continue;
|
||||
const bullet = fence ? null : BULLET.exec(line);
|
||||
if (bullet) {
|
||||
current = { type: section, text: bullet[1].trim() };
|
||||
changes.push(current);
|
||||
} else if (current && /^\s/.test(line)) {
|
||||
current.text += `\n${trimmed}`;
|
||||
} else {
|
||||
problems.push(
|
||||
`${where} has text under "### ${title(section)}" that is not a list item: ${trimmed}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
if (!change.text) {
|
||||
problems.push(`${where} has an empty list item`);
|
||||
}
|
||||
}
|
||||
|
||||
const text = summary.join("\n").trim();
|
||||
return { ...(text ? { summary: text } : {}), changes, problems };
|
||||
}
|
||||
|
||||
/** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
|
||||
export function parseChangelog(markdown: string): {
|
||||
changelog: Changelog;
|
||||
problems: string[];
|
||||
} {
|
||||
const problems: string[] = [];
|
||||
const changelog: Changelog = { releases: [] };
|
||||
const lines = markdown.replace(/\r/g, "").split("\n");
|
||||
|
||||
type Block = { heading: string; line: number; body: string[] };
|
||||
const blocks: Block[] = [];
|
||||
let fence = false;
|
||||
lines.forEach((line, i) => {
|
||||
if (line.trim().startsWith("```")) fence = !fence;
|
||||
if (!fence && /^##\s/.test(line)) {
|
||||
blocks.push({ heading: line.trim(), line: i + 1, body: [] });
|
||||
} else if (blocks.length > 0) {
|
||||
blocks[blocks.length - 1].body.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
blocks.forEach((block) => {
|
||||
if (UNRELEASED_HEADING.test(block.heading)) {
|
||||
problems.push(
|
||||
`line ${block.line}: drop "## Unreleased", notes go under the version they ship in`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const match = RELEASE_HEADING.exec(block.heading);
|
||||
if (!match) {
|
||||
problems.push(
|
||||
`line ${block.line}: "${block.heading}" should look like "## 1.2.0 - 2026-10-06"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const [, version, date] = match;
|
||||
if (date !== undefined && !DATE.test(date)) {
|
||||
problems.push(`${version} has a date "${date}", use YYYY-MM-DD`);
|
||||
}
|
||||
if (seen.has(version)) problems.push(`${version} is listed twice`);
|
||||
seen.add(version);
|
||||
|
||||
const notes = parseReleaseNotes(block.body.join("\n"), version);
|
||||
problems.push(...notes.problems);
|
||||
if (notes.changes.length === 0) {
|
||||
problems.push(`${version} has no changes listed`);
|
||||
}
|
||||
changelog.releases.push({
|
||||
version,
|
||||
...(date && DATE.test(date) ? { date } : {}),
|
||||
...(notes.summary ? { summary: notes.summary } : {}),
|
||||
changes: notes.changes,
|
||||
});
|
||||
});
|
||||
|
||||
const versions = changelog.releases.map((r) => r.version);
|
||||
for (let i = 1; i < versions.length; i++) {
|
||||
if (semver.lt(versions[i - 1], versions[i])) {
|
||||
problems.push("releases must be listed newest first");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { changelog, problems };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
|
||||
* be that version, so a release never ships without its notes. A beta like
|
||||
* 1.1.0-beta.2 gets its notes from git, so it only needs the newest release
|
||||
* to be 1.1.0 or older.
|
||||
*/
|
||||
export function validateChangelog(
|
||||
markdown: string,
|
||||
manifestVersion?: string,
|
||||
): string[] {
|
||||
const { changelog, problems } = parseChangelog(markdown);
|
||||
if (changelog.releases.length === 0) {
|
||||
problems.push('no releases found, add a "## 1.0.0" heading');
|
||||
return problems;
|
||||
}
|
||||
const newest = changelog.releases[0].version;
|
||||
const base = manifestVersion
|
||||
? semver.valid(semver.coerce(manifestVersion))
|
||||
: null;
|
||||
const betaOk =
|
||||
!!manifestVersion &&
|
||||
!!semver.prerelease(manifestVersion) &&
|
||||
!!base &&
|
||||
!!semver.valid(newest) &&
|
||||
semver.lte(newest, base);
|
||||
if (manifestVersion && newest !== manifestVersion && !betaOk) {
|
||||
problems.push(
|
||||
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
|
||||
);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Markdown under one version's heading, as written, for a release body.
|
||||
* Null when the version is not in the file.
|
||||
*/
|
||||
export function changelogSection(
|
||||
markdown: string,
|
||||
version: string,
|
||||
): string | null {
|
||||
const lines = markdown.replace(/\r/g, "").split("\n");
|
||||
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 = RELEASE_HEADING.exec(line.trim());
|
||||
if (match && match[1] === version) start = i + 1;
|
||||
}
|
||||
return start >= 0 ? lines.slice(start).join("\n").trim() : null;
|
||||
}
|
||||
|
||||
function title(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ function builder(column: PluginColumn): Builder {
|
||||
export const id = () =>
|
||||
builder({ type: "id", primaryKey: true, notNull: true });
|
||||
|
||||
/** Unbounded string. Never indexable: MySQL cannot index TEXT. */
|
||||
/** Unbounded string, LONGTEXT on MySQL. Never indexable: MySQL cannot index TEXT. */
|
||||
export const text = () => builder({ type: "text" });
|
||||
|
||||
/** Bounded string. Indexable on every engine. */
|
||||
|
||||
@@ -80,11 +80,13 @@ function columnType(
|
||||
// though it was declared unbounded. defineTable refuses to index a
|
||||
// text/json column, so in practice this only catches a primary key.
|
||||
if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`;
|
||||
// MySQL TEXT stops at 64KB, so free text, json and secrets go long.
|
||||
if (dialect === "mysql" && column.type !== "timestamp") return "longtext";
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
/** Column types that are TEXT on MySQL when nothing keys them. */
|
||||
/** Column types that are TEXT or LONGTEXT on MySQL when nothing keys them. */
|
||||
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
|
||||
"text",
|
||||
"json",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Docs links and env var entries from a manifest. Kept apart from manifest.ts
|
||||
* so the browser can use them without pulling in semver.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A docs link, or null for anything that is not an https URL. Core only ever
|
||||
* opens it in a new tab, but a javascript: or file: link should never get there.
|
||||
*/
|
||||
export function pluginDocsUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== "https:") return null;
|
||||
return url.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Joins a page and anchor onto a plugin's docs URL. */
|
||||
export function pluginDocsPage(
|
||||
base: string,
|
||||
page?: string,
|
||||
anchor?: string,
|
||||
): string {
|
||||
const clean = (page ?? "").replace(/^\/+|\/+$/g, "");
|
||||
const url = clean ? `${base}/${clean}` : base;
|
||||
return anchor ? `${url}#${anchor.replace(/^#/, "")}` : url;
|
||||
}
|
||||
|
||||
export const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
export const MAX_ENV_DESCRIPTION_LENGTH = 300;
|
||||
|
||||
/**
|
||||
* An environment variable a plugin reads. Only shown in docs and on the
|
||||
* plugin's page, so people know what they can set.
|
||||
*/
|
||||
export interface PluginEnvVar {
|
||||
name: string;
|
||||
description: string;
|
||||
/** The value used when it is not set, as written in a compose file. */
|
||||
default?: string;
|
||||
required?: boolean;
|
||||
/** Holds a password or key. */
|
||||
secret?: boolean;
|
||||
}
|
||||
|
||||
/** The usable env entries of a manifest. Bad ones are dropped. */
|
||||
export function pluginEnvVars(value: unknown): PluginEnvVar[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(v): v is PluginEnvVar =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
typeof v.name === "string" &&
|
||||
ENV_NAME_PATTERN.test(v.name) &&
|
||||
typeof v.description === "string",
|
||||
);
|
||||
}
|
||||
@@ -210,6 +210,21 @@ export interface ShellApi {
|
||||
openRailView: (id: string) => void;
|
||||
/** Closes a rail view wherever it is shown. */
|
||||
closeRailView: (id: string) => void;
|
||||
/**
|
||||
* Asks a yes or no question over the main area, for code that runs outside
|
||||
* a panel or tab (a keybinding, a toolbar action). Inside one, prefer
|
||||
* useConfirm from @termix-ssh/plugin-sdk/ui, which asks over that surface.
|
||||
*/
|
||||
confirm?: (options: ConfirmRequest) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ConfirmRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Red confirm button. Defaults to true. */
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export interface TabsApi extends Pick<
|
||||
@@ -260,6 +275,11 @@ export interface RailItemContribution {
|
||||
/** Desktop app only. */
|
||||
electronOnly?: boolean;
|
||||
separatorAfter?: boolean;
|
||||
/**
|
||||
* Which band of the rail it sits in: things you keep (objects), things you
|
||||
* do (tools) or the instance itself (system). Default "tools".
|
||||
*/
|
||||
group?: "objects" | "tools" | "system";
|
||||
/** Hidden without being unregistered, e.g. while a feature is switched off. */
|
||||
hidden?: boolean;
|
||||
/**
|
||||
@@ -293,8 +313,11 @@ export interface PanelProps {
|
||||
/** Whether the panel is the one currently shown. */
|
||||
active: boolean;
|
||||
shell: ShellApi;
|
||||
/** Tells the shell the panel is editing, which widens the sidebar. */
|
||||
setEditing: (editing: boolean) => void;
|
||||
/**
|
||||
* Tells the shell the panel is editing, which widens the sidebar. "wide"
|
||||
* widens it further for tables. InlineView does this on its own.
|
||||
*/
|
||||
setEditing: (editing: boolean | "wide") => void;
|
||||
/** Type of the focused tab, if any. */
|
||||
activeTabType?: string;
|
||||
/** Where the panel is rendered. */
|
||||
@@ -323,6 +346,8 @@ export interface TabHandle {
|
||||
/** Repaint only. The shell calls it every time the tab becomes active. */
|
||||
refresh?: () => void;
|
||||
notifyResize?: () => void;
|
||||
/** Asked before the tab closes; resolve false to keep it open. */
|
||||
confirmClose?: () => boolean | Promise<boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -629,6 +654,11 @@ export interface DashboardCardContribution {
|
||||
defaultHeight?: number;
|
||||
/** Which column a preset places this card in by default. Defaults to "main". */
|
||||
defaultPanel?: "main" | "side";
|
||||
/**
|
||||
* "framed" draws a titled header over the card, "bare" lets it run edge to
|
||||
* edge, for a strip of numbers. Defaults to "framed".
|
||||
*/
|
||||
frame?: "framed" | "bare";
|
||||
component: ComponentType<DashboardCardProps>;
|
||||
}
|
||||
|
||||
@@ -664,7 +694,11 @@ export interface ActionSlotDefinition {
|
||||
export interface SlotContribution {
|
||||
actionId: string;
|
||||
titleKey: string;
|
||||
/** Secondary text, for slots that show one (onboarding cards). */
|
||||
/**
|
||||
* Secondary text, for slots that show one. The onboarding.* slots that
|
||||
* used it are deprecated: use registerOnboardingStep. The feature and
|
||||
* workflow card slots are no longer shown.
|
||||
*/
|
||||
descriptionKey?: string;
|
||||
icon?: IconComponent;
|
||||
kind?: "button" | "component" | string;
|
||||
@@ -675,6 +709,65 @@ export interface SlotContribution {
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type OnboardingMode = "full" | "partial" | "rerun";
|
||||
|
||||
/**
|
||||
* Where a plugin step sits in onboarding. "setup" comes right after the
|
||||
* appearance steps, for things that need doing before the feature works
|
||||
* (like pointing at a guacd server). "explore" (the default) comes after
|
||||
* that, "security" after the account security step. Onboarding is for
|
||||
* choices, not for explaining features.
|
||||
*/
|
||||
export type OnboardingSection = "setup" | "explore" | "security";
|
||||
|
||||
export interface OnboardingStepProps {
|
||||
/**
|
||||
* "full" is a new user's first run, "partial" shows only steps they have
|
||||
* not seen yet (a plugin installed later, a step whose version went up),
|
||||
* "rerun" is the user running setup again from settings.
|
||||
*/
|
||||
mode: OnboardingMode;
|
||||
isAdmin: boolean;
|
||||
isDesktop: boolean;
|
||||
/** Blocks or allows Next. Steps can continue by default. */
|
||||
setCanContinue: (ok: boolean) => void;
|
||||
/**
|
||||
* Runs when Next is pressed, before moving on. Return false to stay on the
|
||||
* step (to show an error, say). Pass null to clear it.
|
||||
*/
|
||||
setBeforeNext: (fn: (() => boolean | Promise<boolean>) | null) => void;
|
||||
/**
|
||||
* False on a first run, which shows before the app shell exists: app.tabs
|
||||
* and the shell callbacks do nothing until it is true.
|
||||
*/
|
||||
shellReady: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingStepContribution {
|
||||
/** Unique within the plugin. Lowercase letters, digits and dashes. */
|
||||
id: string;
|
||||
/**
|
||||
* Starts at 1. Raise it when the step changes enough that people who saw
|
||||
* it should see it again; they get just this step, not all of onboarding.
|
||||
*/
|
||||
version?: number;
|
||||
titleKey: string;
|
||||
descriptionKey?: string;
|
||||
icon?: IconComponent;
|
||||
component: ComponentType<OnboardingStepProps>;
|
||||
/** "admin" shows it only to users who can manage plugins. */
|
||||
audience?: "all" | "admin";
|
||||
/** A permission the user must hold, like registerAction's. */
|
||||
permission?: string;
|
||||
section?: OnboardingSection;
|
||||
order?: number;
|
||||
when?: (context: {
|
||||
isAdmin: boolean;
|
||||
isDesktop: boolean;
|
||||
mode: OnboardingMode;
|
||||
}) => boolean;
|
||||
}
|
||||
|
||||
export interface SshAuthEditorProps {
|
||||
form: Record<string, unknown>;
|
||||
setField: (key: string, value: unknown) => void;
|
||||
@@ -795,6 +888,16 @@ export interface DesktopApi {
|
||||
onRemoteServerChange: (listener: () => void) => Disposer;
|
||||
}
|
||||
|
||||
/** Links into this plugin's docs, from the manifest's docs field. */
|
||||
export interface DocsApi {
|
||||
/** The docs link, or null when the manifest has none. */
|
||||
readonly url: string | null;
|
||||
/** A page of the docs ("setup"), with an optional anchor. */
|
||||
page: (page?: string, anchor?: string) => string | null;
|
||||
/** Opens a page in a new tab. Does nothing without a docs link. */
|
||||
open: (page?: string, anchor?: string) => void;
|
||||
}
|
||||
|
||||
export interface TermixAppInfo {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -864,10 +967,17 @@ export interface TermixApp extends TermixAppInfo {
|
||||
slotId: string,
|
||||
contribution: SlotContribution,
|
||||
) => Disposer;
|
||||
/**
|
||||
* Adds a step to onboarding. New users see it in their first run; people
|
||||
* who already finished onboarding see just this step the next time they
|
||||
* open Termix (or right away, if they install the plugin mid-session).
|
||||
* Needs plugin API 1.1.
|
||||
*/
|
||||
registerOnboardingStep: (step: OnboardingStepContribution) => Disposer;
|
||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||
/**
|
||||
* Offers a component to other plugins and to core by id, rendered where
|
||||
* they choose with `PluginComponent` from @termix/plugin-sdk/ui or
|
||||
* they choose with `PluginComponent` from @termix-ssh/plugin-sdk/ui or
|
||||
* `usePluginComponent`. The id should start with a name the plugin owns
|
||||
* ("terminal.view"). The props are the owner's contract; document them.
|
||||
*/
|
||||
@@ -909,8 +1019,16 @@ export interface TermixApp extends TermixAppInfo {
|
||||
options?: { origin?: unknown },
|
||||
) => Promise<PluginWsTarget | null>;
|
||||
tabs: TabsApi;
|
||||
/**
|
||||
* Asks a yes or no question over the main area. For code that runs outside
|
||||
* a panel or tab; inside one, useConfirm from @termix-ssh/plugin-sdk/ui asks
|
||||
* over that surface instead.
|
||||
*/
|
||||
confirm: (options: ConfirmRequest) => Promise<boolean>;
|
||||
/** The desktop app, when the frontend runs in it. */
|
||||
desktop: DesktopApi;
|
||||
/** This plugin's docs. Needs plugin API 1.2. */
|
||||
docs: DocsApi;
|
||||
/**
|
||||
* Fires after this plugin's settings are saved from the settings screen or
|
||||
* the host editor. Disposed automatically.
|
||||
@@ -985,6 +1103,11 @@ export interface SshAuthTypeInfo {
|
||||
*/
|
||||
export interface PluginHostBridge {
|
||||
usePluginId: () => string;
|
||||
useDocsUrl: (
|
||||
pluginId: string,
|
||||
page?: string,
|
||||
anchor?: string,
|
||||
) => string | null;
|
||||
useTranslation: (pluginId: string) => {
|
||||
t: TranslateFn;
|
||||
language: string;
|
||||
@@ -1154,7 +1277,7 @@ let host: PluginHostBridge | null = null;
|
||||
export function __setPluginHost(bridge: PluginHostBridge | null): void {
|
||||
if (host && bridge !== host) {
|
||||
throw new Error(
|
||||
"@termix/plugin-sdk/frontend: the plugin host is already set",
|
||||
"@termix-ssh/plugin-sdk/frontend: the plugin host is already set",
|
||||
);
|
||||
}
|
||||
host = bridge;
|
||||
@@ -1173,7 +1296,7 @@ function requireHost(): PluginHostBridge {
|
||||
if (!host && fallback) return fallback;
|
||||
if (!host) {
|
||||
throw new Error(
|
||||
"@termix/plugin-sdk/frontend: no plugin host. Hooks only work inside Termix or renderWithApp().",
|
||||
"@termix-ssh/plugin-sdk/frontend: no plugin host. Hooks only work inside Termix or renderWithApp().",
|
||||
);
|
||||
}
|
||||
return host;
|
||||
@@ -1184,6 +1307,15 @@ export function usePluginId(): string {
|
||||
return requireHost().usePluginId();
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of this plugin's docs, or null when the manifest has no docs link.
|
||||
* Pass it to PanelShell's docs prop or use DocsLink. Needs plugin API 1.2.
|
||||
*/
|
||||
export function useDocsUrl(page?: string, anchor?: string): string | null {
|
||||
const bridge = requireHost();
|
||||
return bridge.useDocsUrl(bridge.usePluginId(), page, anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation bound to this plugin's namespace. Keys the plugin does not
|
||||
* define fall back to core's shared strings (common.*, hosts.*).
|
||||
@@ -1333,6 +1465,31 @@ export function usePluginUiPreferences<
|
||||
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid or list and row density for one of this plugin's views, kept in its
|
||||
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those
|
||||
* keys in contributes.uiPresets so each preset level can pick a default.
|
||||
*/
|
||||
export function usePluginPanelView(prefix = ""): {
|
||||
view: "grid" | "list";
|
||||
density: "comfortable" | "compact";
|
||||
compact: boolean;
|
||||
setView: (view: "grid" | "list") => void;
|
||||
setDensity: (density: "comfortable" | "compact") => void;
|
||||
} {
|
||||
const { values, set } = usePluginUiPreferences();
|
||||
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
|
||||
const density =
|
||||
values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
|
||||
return {
|
||||
view,
|
||||
density,
|
||||
compact: density === "compact",
|
||||
setView: (next) => set(`${prefix}viewMode`, next),
|
||||
setDensity: (next) => set(`${prefix}density`, next),
|
||||
};
|
||||
}
|
||||
|
||||
export function useTabs(): TabsApi {
|
||||
return requireHost().useTabs();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function execCommand(
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
|
||||
@@ -12,6 +12,21 @@
|
||||
|
||||
import semver from "semver";
|
||||
import { isKnownCapability } from "./capabilities.js";
|
||||
import {
|
||||
ENV_NAME_PATTERN,
|
||||
MAX_ENV_DESCRIPTION_LENGTH,
|
||||
pluginDocsUrl,
|
||||
type PluginEnvVar,
|
||||
} from "./docs.js";
|
||||
|
||||
export {
|
||||
ENV_NAME_PATTERN,
|
||||
MAX_ENV_DESCRIPTION_LENGTH,
|
||||
pluginDocsPage,
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
type PluginEnvVar,
|
||||
} from "./docs.js";
|
||||
|
||||
/**
|
||||
* The plugin API this build implements. A minor bump adds to the API and
|
||||
@@ -21,7 +36,7 @@ import { isKnownCapability } from "./capabilities.js";
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.0.0";
|
||||
export const PLUGIN_API_VERSION = "1.2.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -49,6 +64,63 @@ export function isTermixCompatible(
|
||||
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
|
||||
}
|
||||
|
||||
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
"youtube.com",
|
||||
"www.youtube.com",
|
||||
"m.youtube.com",
|
||||
"youtu.be",
|
||||
"youtube-nocookie.com",
|
||||
"www.youtube-nocookie.com",
|
||||
]);
|
||||
|
||||
export const MAX_FEATURES = 20;
|
||||
export const MAX_FEATURE_LENGTH = 160;
|
||||
|
||||
/**
|
||||
* The usable feature lines of a manifest or registry entry. Anything that is
|
||||
* not a short non-empty string is dropped, so an index cannot flood the page.
|
||||
*/
|
||||
export function pluginFeatures(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(
|
||||
(f): f is string =>
|
||||
typeof f === "string" &&
|
||||
f.trim().length > 0 &&
|
||||
f.length <= MAX_FEATURE_LENGTH,
|
||||
)
|
||||
.map((f) => f.trim())
|
||||
.slice(0, MAX_FEATURES);
|
||||
}
|
||||
|
||||
/**
|
||||
* The video id of a YouTube link, or null for anything else. Only the id is
|
||||
* ever used, so a manifest can never point the embed at another site.
|
||||
*/
|
||||
export function youtubeVideoId(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password) return null;
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (!YOUTUBE_HOSTS.has(host)) return null;
|
||||
|
||||
let id: string | null | undefined;
|
||||
if (host === "youtu.be") {
|
||||
id = url.pathname.slice(1);
|
||||
} else if (url.pathname === "/watch") {
|
||||
id = url.searchParams.get("v");
|
||||
} else {
|
||||
id = /^\/(?:embed|shorts|live)\/([^/]+)\/?$/.exec(url.pathname)?.[1];
|
||||
}
|
||||
return id && YOUTUBE_ID.test(id) ? id : null;
|
||||
}
|
||||
|
||||
export const PLUGIN_CATEGORIES = [
|
||||
"Terminal",
|
||||
"Files & Transfer",
|
||||
@@ -81,6 +153,7 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [
|
||||
"termix",
|
||||
"plugin",
|
||||
"plugins",
|
||||
"onboarding",
|
||||
"users",
|
||||
"system",
|
||||
"sdk",
|
||||
@@ -275,11 +348,6 @@ export interface PluginSettingsField {
|
||||
group?: string;
|
||||
/** Registered component id. Required when type is "custom". */
|
||||
component?: string;
|
||||
/**
|
||||
* Deprecated and ignored: host defaults cover every host field now. Still
|
||||
* accepted so an older manifest loads. Removed in 3.0.0.
|
||||
*/
|
||||
defaultFrom?: string;
|
||||
/**
|
||||
* Host scope only: whether the field can have a host default (server,
|
||||
* user or folder) that hosts follow until they set their own. On by
|
||||
@@ -439,7 +507,8 @@ export interface PluginProtocolContribution {
|
||||
defaultPort?: number;
|
||||
/**
|
||||
* What the owner's login falls back to from the host's own SSH login
|
||||
* when the protocol login leaves it empty.
|
||||
* when the protocol login leaves it empty. The password only falls back
|
||||
* while the host signs in to SSH with a password.
|
||||
*/
|
||||
hostLoginFallback?: Array<"username" | "password">;
|
||||
}
|
||||
@@ -543,6 +612,14 @@ export interface PluginManifest {
|
||||
repository?: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
/** A YouTube link shown at the top of the plugin's page. */
|
||||
video?: string;
|
||||
/** Short lines listing what the plugin does, shown on its page. */
|
||||
features?: string[];
|
||||
/** Where the plugin's docs live. https only. */
|
||||
docs?: string;
|
||||
/** Environment variables the plugin reads. */
|
||||
env?: PluginEnvVar[];
|
||||
engine: PluginEngine;
|
||||
/** Catalog capability ids. */
|
||||
capabilities: string[];
|
||||
@@ -594,6 +671,10 @@ const ALLOWED_TOP_LEVEL = new Set([
|
||||
...REQUIRED_TOP_LEVEL,
|
||||
"repository",
|
||||
"icon",
|
||||
"video",
|
||||
"features",
|
||||
"docs",
|
||||
"env",
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"provides",
|
||||
@@ -644,7 +725,6 @@ const ALLOWED_SETTINGS_FIELD = [
|
||||
"group",
|
||||
"component",
|
||||
"hidden",
|
||||
"defaultFrom",
|
||||
"defaultable",
|
||||
"defaultLevels",
|
||||
"personal",
|
||||
@@ -751,6 +831,18 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
|
||||
if ("repository" in m) requireString(m.repository, "repository", errors);
|
||||
if ("icon" in m) requireString(m.icon, "icon", errors);
|
||||
if ("video" in m && youtubeVideoId(m.video) === null) {
|
||||
errors.push(
|
||||
`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`,
|
||||
);
|
||||
}
|
||||
validateFeatures(m.features, errors);
|
||||
if ("docs" in m && pluginDocsUrl(m.docs) === null) {
|
||||
errors.push(
|
||||
`Field "docs" must be an https URL, got: ${JSON.stringify(m.docs)}`,
|
||||
);
|
||||
}
|
||||
validateEnv(m.env, errors);
|
||||
for (const field of ["backend", "frontend", "locales"] as const) {
|
||||
if (field in m && requireString(m[field], field, errors)) {
|
||||
requireRelativePath(m[field] as string, field, errors);
|
||||
@@ -786,6 +878,76 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateFeatures(features: unknown, errors: string[]): void {
|
||||
if (features === undefined) return;
|
||||
if (!Array.isArray(features)) {
|
||||
errors.push('Field "features" must be an array of strings');
|
||||
return;
|
||||
}
|
||||
if (features.length > MAX_FEATURES) {
|
||||
errors.push(`Field "features" may list at most ${MAX_FEATURES} items`);
|
||||
}
|
||||
features.forEach((feature, i) => {
|
||||
if (
|
||||
typeof feature !== "string" ||
|
||||
feature.trim().length === 0 ||
|
||||
feature.length > MAX_FEATURE_LENGTH
|
||||
) {
|
||||
errors.push(
|
||||
`Field "features[${i}]" must be a non-empty string of at most ${MAX_FEATURE_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateEnv(env: unknown, errors: string[]): void {
|
||||
if (env === undefined) return;
|
||||
if (!Array.isArray(env)) {
|
||||
errors.push('Field "env" must be an array');
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
env.forEach((entry, i) => {
|
||||
const where = `env[${i}]`;
|
||||
if (!isPlainObject(entry)) {
|
||||
errors.push(`Field "${where}" must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
entry,
|
||||
["name", "description", "default", "required", "secret"],
|
||||
where,
|
||||
errors,
|
||||
);
|
||||
if (typeof entry.name !== "string" || !ENV_NAME_PATTERN.test(entry.name)) {
|
||||
errors.push(
|
||||
`Field "${where}.name" must be an uppercase name like MY_PLUGIN_URL, got: ${JSON.stringify(entry.name)}`,
|
||||
);
|
||||
} else if (seen.has(entry.name)) {
|
||||
errors.push(`Field "${where}.name" repeats ${entry.name}`);
|
||||
} else {
|
||||
seen.add(entry.name);
|
||||
}
|
||||
if (
|
||||
typeof entry.description !== "string" ||
|
||||
entry.description.trim().length === 0 ||
|
||||
entry.description.length > MAX_ENV_DESCRIPTION_LENGTH
|
||||
) {
|
||||
errors.push(
|
||||
`Field "${where}.description" must be a non-empty string of at most ${MAX_ENV_DESCRIPTION_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
if ("default" in entry && typeof entry.default !== "string") {
|
||||
errors.push(`Field "${where}.default" must be a string`);
|
||||
}
|
||||
for (const flag of ["required", "secret"] as const) {
|
||||
if (flag in entry && typeof entry[flag] !== "boolean") {
|
||||
errors.push(`Field "${where}.${flag}" must be true or false`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateAuthor(author: unknown, errors: string[]): void {
|
||||
if (author === undefined) return;
|
||||
if (!isPlainObject(author)) {
|
||||
@@ -1614,9 +1776,6 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
host.fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
const fieldAt = `${at}.fields[${index}]`;
|
||||
if ("defaultFrom" in raw && typeof raw.defaultFrom !== "string") {
|
||||
errors.push(`${fieldAt}.defaultFrom must be a string`);
|
||||
}
|
||||
for (const flag of ["defaultable", "personal"]) {
|
||||
if (flag in raw && typeof raw[flag] !== "boolean") {
|
||||
errors.push(`${fieldAt}.${flag} must be a boolean`);
|
||||
@@ -1670,7 +1829,6 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
for (const key of [
|
||||
"defaultFrom",
|
||||
"defaultable",
|
||||
"defaultLevels",
|
||||
"personal",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Builds an OpenAPI spec from the @openapi JSDoc blocks above routes. Core and
|
||||
* every plugin use this, so the docs site renders them all the same way.
|
||||
*/
|
||||
|
||||
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
|
||||
|
||||
export interface OpenApiTag {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface BuildOpenApiOptions {
|
||||
title: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
/** Source files or globs holding @openapi blocks. */
|
||||
files: string[];
|
||||
tags?: OpenApiTag[];
|
||||
}
|
||||
|
||||
export type OpenApiSpec = Record<string, unknown> & {
|
||||
paths?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** How every Termix route is signed in to. */
|
||||
export const TERMIX_SECURITY_SCHEMES = {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
description:
|
||||
"Session token sent in the Authorization header as Bearer and the token.",
|
||||
},
|
||||
cookieAuth: {
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "jwt",
|
||||
description:
|
||||
"Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
|
||||
},
|
||||
apiKeyAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "API key starting with tmx_",
|
||||
description:
|
||||
"API key sent in the Authorization header as Bearer and the key, which starts with tmx_. It acts as the key owner and cannot act as anyone else.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function buildOpenApi(
|
||||
options: BuildOpenApiOptions,
|
||||
): Promise<OpenApiSpec> {
|
||||
const spec = (await swaggerJSDoc({
|
||||
definition: {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: options.title,
|
||||
version: options.version,
|
||||
...(options.description ? { description: options.description } : {}),
|
||||
},
|
||||
servers: [{ url: "/", description: "Your Termix server" }],
|
||||
components: {
|
||||
securitySchemes: TERMIX_SECURITY_SCHEMES,
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
details: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ bearerAuth: [] }, { cookieAuth: [] }, { apiKeyAuth: [] }],
|
||||
...(options.tags?.length ? { tags: options.tags } : {}),
|
||||
},
|
||||
apis: options.files.map((f) => f.replace(/\\/g, "/")),
|
||||
failOnErrors: true,
|
||||
} as Parameters<typeof swaggerJSDoc>[0])) as OpenApiSpec;
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** Whether a spec documents any route. */
|
||||
export function hasOpenApiPaths(spec: OpenApiSpec): boolean {
|
||||
return Object.keys(spec.paths ?? {}).length > 0;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* real runtime does. createFakeContext stays for tests that only need a
|
||||
* context-shaped object and do not care about the gates.
|
||||
*
|
||||
* The vitest config these run under comes from @termix/plugin-sdk/vitest-preset.
|
||||
* The vitest config these run under comes from @termix-ssh/plugin-sdk/vitest-preset.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -2080,7 +2080,7 @@ export async function createTestDb(
|
||||
* so a test sees exactly what the shell would: which rail items, tabs, host
|
||||
* actions and cards it registered, and each of those rendered with the SDK
|
||||
* hooks working. It is implemented by the Termix host, which is where the
|
||||
* registries live; the vitest preset points "@termix/plugin-host/testing" at
|
||||
* registries live; the vitest preset points "@termix-ssh/plugin-host/testing" at
|
||||
* core's implementation.
|
||||
*/
|
||||
export interface RenderWithAppOptions {
|
||||
@@ -2139,6 +2139,8 @@ export interface RenderedPluginApp {
|
||||
paletteGroups: () => string[];
|
||||
keybindingActions: () => string[];
|
||||
keybindingDefaults: () => string[];
|
||||
/** Onboarding step ids, without the plugin prefix. */
|
||||
onboardingSteps: () => string[];
|
||||
};
|
||||
/** A registered palette group's items, as the palette loads them on open. */
|
||||
loadPaletteGroup: (
|
||||
@@ -2160,6 +2162,19 @@ export interface RenderedPluginApp {
|
||||
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
|
||||
renderDashboardCard: (id: string) => HTMLElement;
|
||||
/**
|
||||
* Renders an onboarding step with OnboardingStepProps for a first run,
|
||||
* overridable. Read what the step set with the returned `canContinue()`
|
||||
* and `next()` (runs its beforeNext, true when it has none).
|
||||
*/
|
||||
renderOnboardingStep: (
|
||||
id: string,
|
||||
props?: Record<string, unknown>,
|
||||
) => {
|
||||
element: HTMLElement;
|
||||
canContinue: () => boolean;
|
||||
next: () => Promise<boolean>;
|
||||
};
|
||||
/** Renders one component of a contributed extension, "view" by default. */
|
||||
renderExtension: (
|
||||
pointId: string,
|
||||
@@ -2205,7 +2220,7 @@ interface PluginTestHost {
|
||||
) => Promise<RenderedPluginApp>;
|
||||
}
|
||||
|
||||
const TEST_HOST = "@termix/plugin-host/testing";
|
||||
const TEST_HOST = "@termix-ssh/plugin-host/testing";
|
||||
|
||||
export async function renderWithApp(
|
||||
plugin: FrontendPluginModule,
|
||||
|
||||
@@ -10,6 +10,15 @@ import { installSafePorts } from "./safe-ports.js";
|
||||
|
||||
installSafePorts();
|
||||
|
||||
// Radix measures some controls (Checkbox, Switch) and jsdom has no observer.
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && !window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
@@ -76,6 +85,7 @@ const offline = () =>
|
||||
Promise.reject(new Error("No plugin API in a unit test; mock it"));
|
||||
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
|
||||
usePluginId: () => "test-plugin",
|
||||
useDocsUrl: () => null,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
language: "en",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* A plugin's vitest.config.ts is one line:
|
||||
*
|
||||
* import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
* import { pluginVitestConfig } from "@termix-ssh/plugin-sdk/vitest-preset";
|
||||
* export default pluginVitestConfig(import.meta.url);
|
||||
*
|
||||
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
|
||||
@@ -56,21 +56,21 @@ export function pluginVitestConfig(
|
||||
const alias: Record<string, string> = {
|
||||
...(repoRoot
|
||||
? {
|
||||
"@termix/plugin-sdk/frontend": path.join(
|
||||
"@termix-ssh/plugin-sdk/frontend": path.join(
|
||||
repoRoot,
|
||||
"packages",
|
||||
"plugin-sdk",
|
||||
"src",
|
||||
"frontend.ts",
|
||||
),
|
||||
"@termix/plugin-sdk/ui": path.join(
|
||||
"@termix-ssh/plugin-sdk/ui": path.join(
|
||||
repoRoot,
|
||||
"src",
|
||||
"ui",
|
||||
"plugin-host",
|
||||
"sdk-ui.ts",
|
||||
),
|
||||
"@termix/plugin-host/testing": path.join(
|
||||
"@termix-ssh/plugin-host/testing": path.join(
|
||||
repoRoot,
|
||||
"src",
|
||||
"ui",
|
||||
@@ -85,18 +85,23 @@ export function pluginVitestConfig(
|
||||
: {
|
||||
// Outside a Termix checkout: the UI kit resolves through the
|
||||
// package, and the test host is the copy the SDK ships.
|
||||
"@termix/plugin-host/testing": fileURLToPath(
|
||||
"@termix-ssh/plugin-host/testing": fileURLToPath(
|
||||
new URL("./host/testing-host.js", import.meta.url),
|
||||
),
|
||||
}),
|
||||
...options.alias,
|
||||
};
|
||||
|
||||
// Forks workers on Windows abort now and then with "Worker exited
|
||||
// unexpectedly". Threads don't.
|
||||
const pool = "threads" as const;
|
||||
|
||||
return {
|
||||
resolve: { alias },
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
pool,
|
||||
projects: [
|
||||
{
|
||||
resolve: { alias },
|
||||
@@ -104,6 +109,7 @@ export function pluginVitestConfig(
|
||||
name: `${pluginId}:backend`,
|
||||
root: pluginDir,
|
||||
environment: "node",
|
||||
pool,
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
include: ["tests/backend/**/*.test.ts"],
|
||||
@@ -115,12 +121,13 @@ export function pluginVitestConfig(
|
||||
name: `${pluginId}:frontend`,
|
||||
root: pluginDir,
|
||||
environment: "jsdom",
|
||||
pool,
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
// Run the SDK through Vite rather than plain Node, so its import
|
||||
// of the test host sees the alias above and the plugin and the
|
||||
// host share one copy of @termix/plugin-sdk/frontend.
|
||||
server: { deps: { inline: [/@termix\/plugin-sdk/] } },
|
||||
// host share one copy of @termix-ssh/plugin-sdk/frontend.
|
||||
server: { deps: { inline: [/@termix-ssh\/plugin-sdk/] } },
|
||||
include: ["tests/frontend/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user