@termix-ssh/plugin-sdk 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ ## 1.0.2
4
+
5
+ ### Added
6
+
7
+ - `features` manifest field: up to 20 short lines about what the plugin does, shown on its page. `pluginFeatures()` reads them
8
+
9
+ ### Changed
10
+
11
+ - `parseChangelog()` and `validateChangelog()` flag a `## Unreleased` section. Notes go under the version they ship in
12
+
13
+ ### Removed
14
+
15
+ - `Changelog.unreleased`
16
+
17
+ ## 1.0.1
18
+
19
+ ### Added
20
+
21
+ - Plugin API 1.1
22
+ - `app.registerOnboardingStep()` adds a step to onboarding, with `OnboardingStepProps` and `OnboardingStepContribution`. Needs `engine.api` 1.1
23
+ - `video` manifest field: a YouTube link shown at the top of the plugin's page, read with `youtubeVideoId()`
24
+ - `@termix-ssh/plugin-sdk/changelog` parses and checks a plugin's `CHANGELOG.md`
25
+ - `termix-plugin changelog` prints one version's notes from `CHANGELOG.md`
26
+ - `termix-plugin dev` installs a plugin on a running server and reinstalls it on every change. Needs developer mode and an admin API key
27
+ - `renderOnboardingStep()` and `registered.onboardingSteps()` in `renderWithApp()`
28
+ - `AddButton`, `FormFooter`, `ListRow`, `ListRowAction`, `ListRowFolder`, `ListBadge` and `PanelList` in `@termix-ssh/plugin-sdk/ui`
29
+ - `PanelSearch` takes `disabled`, `title`, `onEnter` and `inputRef`, and `SurfaceScope` takes `onClose`
30
+
31
+ ### Changed
32
+
33
+ - Release notes moved from `CHANGELOG.json` to `CHANGELOG.md`, which `pack` now ships. `validate` flags a leftover `CHANGELOG.json`
34
+ - `ConnectionScreen` dropped `errorMessage` and `retryLabel` and gained `onClose`. `ConnectionLogPanel` dropped `position`
35
+ - Plugin tests run in worker threads, since forked workers crash now and then on Windows
36
+ - The test setup stubs `ResizeObserver` for Radix controls under jsdom
37
+ - `onboarding` is a reserved plugin id
38
+
39
+ ### Deprecated
40
+
41
+ - The `onboarding.*` action slots. Use `registerOnboardingStep()`
42
+
43
+ ### Removed
44
+
45
+ - `schema/changelog.schema.json`
46
+
47
+ ## 1.0.0
48
+
49
+ ### Added
50
+
51
+ - Backend, frontend, database, manifest, capability and settings types for plugin API 1.0
52
+ - Testing helpers and a Vitest preset for plugin tests
53
+ - The `@termix-ssh/plugin-sdk/ui` component set
54
+ - The `termix-plugin` CLI to build, validate, test, pack, sign and write migrations
package/README.md CHANGED
@@ -48,7 +48,7 @@ in place of the flags. Reload the page to pick up frontend changes.
48
48
 
49
49
  ## Support
50
50
 
51
- To report a bug or request a feature, open a [support ticket](https://github.com/Termix-SSH/Support/issues/new/choose). You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
51
+ Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
52
52
 
53
53
  For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server.
54
54
 
@@ -3,12 +3,7 @@
3
3
  *
4
4
  * # Changelog
5
5
  *
6
- * ## Unreleased
7
- *
8
- * ### Added
9
- * - Something merged but not shipped yet
10
- *
11
- * ## 1.1.0 - 2026-10-06
6
+ * ## 1.1.0
12
7
  *
13
8
  * An optional summary paragraph.
14
9
  *
@@ -19,7 +14,8 @@
19
14
  * - Something broken
20
15
  *
21
16
  * Releases are listed newest first, and the newest one has to be the
22
- * manifest version. The date is optional until the version ships.
17
+ * manifest version. Notes go straight under the version they ship in, there
18
+ * is no Unreleased section.
23
19
  */
24
20
  export declare const CHANGE_TYPES: readonly ["added", "changed", "fixed", "removed", "deprecated", "security"];
25
21
  export type ChangeType = (typeof CHANGE_TYPES)[number];
@@ -34,7 +30,6 @@ export interface ChangelogRelease {
34
30
  changes: ChangelogChange[];
35
31
  }
36
32
  export interface Changelog {
37
- unreleased: ChangelogChange[];
38
33
  /** Newest first, as written. */
39
34
  releases: ChangelogRelease[];
40
35
  }
package/dist/changelog.js CHANGED
@@ -3,12 +3,7 @@
3
3
  *
4
4
  * # Changelog
5
5
  *
6
- * ## Unreleased
7
- *
8
- * ### Added
9
- * - Something merged but not shipped yet
10
- *
11
- * ## 1.1.0 - 2026-10-06
6
+ * ## 1.1.0
12
7
  *
13
8
  * An optional summary paragraph.
14
9
  *
@@ -19,7 +14,8 @@
19
14
  * - Something broken
20
15
  *
21
16
  * Releases are listed newest first, and the newest one has to be the
22
- * manifest version. The date is optional until the version ships.
17
+ * manifest version. Notes go straight under the version they ship in, there
18
+ * is no Unreleased section.
23
19
  */
24
20
  import semver from "semver";
25
21
  export const CHANGE_TYPES = [
@@ -94,7 +90,7 @@ export function parseReleaseNotes(body, where = "release") {
94
90
  /** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
95
91
  export function parseChangelog(markdown) {
96
92
  const problems = [];
97
- const changelog = { unreleased: [], releases: [] };
93
+ const changelog = { releases: [] };
98
94
  const lines = markdown.replace(/\r/g, "").split("\n");
99
95
  const blocks = [];
100
96
  let fence = false;
@@ -109,13 +105,9 @@ export function parseChangelog(markdown) {
109
105
  }
110
106
  });
111
107
  const seen = new Set();
112
- blocks.forEach((block, i) => {
108
+ blocks.forEach((block) => {
113
109
  if (UNRELEASED_HEADING.test(block.heading)) {
114
- if (i !== 0)
115
- problems.push(`"## Unreleased" has to come first`);
116
- const notes = parseReleaseNotes(block.body.join("\n"), "Unreleased");
117
- problems.push(...notes.problems);
118
- changelog.unreleased = notes.changes;
110
+ problems.push(`line ${block.line}: drop "## Unreleased", notes go under the version they ship in`);
119
111
  return;
120
112
  }
121
113
  const match = RELEASE_HEADING.exec(block.heading);
@@ -5037,7 +5037,7 @@ async function updateRole(roleId, roleData) {
5037
5037
  try {
5038
5038
  const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
5039
5039
  if (roleData.permissions) {
5040
- const { notifyPermissionsChanged } = await import("./use-permissions-SKWXHWDJ.js");
5040
+ const { notifyPermissionsChanged } = await import("./use-permissions-5CZEA3P4.js");
5041
5041
  notifyPermissionsChanged();
5042
5042
  }
5043
5043
  return response.data;
@@ -5587,7 +5587,7 @@ function createRemoteOriginApiInstance(path) {
5587
5587
  });
5588
5588
  instance.interceptors.request.use(
5589
5589
  async (config) => {
5590
- const { getLinkedSession: getLinkedSession2 } = await import("./linked-server-BD573BEG.js");
5590
+ const { getLinkedSession: getLinkedSession2 } = await import("./linked-server-DW5P3NLZ.js");
5591
5591
  const linked = await getLinkedSession2();
5592
5592
  config.baseURL = linked ? `${linked.serverUrl}${path}` : "http://no-server-configured";
5593
5593
  if (config.headers.set) {
@@ -2,7 +2,7 @@ const __termixNoGlob = () => ({});
2
2
  import {
3
3
  hasPermission,
4
4
  usePermissions
5
- } from "./chunk-3KG44G55.js";
5
+ } from "./chunk-XKMRKQFN.js";
6
6
  import {
7
7
  PRESETS,
8
8
  SYNC_CHANGED_EVENT,
@@ -38,7 +38,7 @@ import {
38
38
  usePluginStore,
39
39
  useTabTypes,
40
40
  websocketAuthProtocols
41
- } from "./chunk-COGRSHKO.js";
41
+ } from "./chunk-LKYAVM4T.js";
42
42
  import {
43
43
  __export,
44
44
  define_import_meta_env_default
@@ -51082,7 +51082,7 @@ var en_default = {
51082
51082
  empty: "No cards. Use Add below to place cards."
51083
51083
  },
51084
51084
  auth: {
51085
- tagline: "Self-hosted SSH and remote desktop management",
51085
+ tagline: "Self-hosted, plugin-based server management",
51086
51086
  loginTitle: "Welcome back",
51087
51087
  registerTitle: "Create Account",
51088
51088
  forgotPassword: "Forgot Password?",
@@ -51216,7 +51216,7 @@ var en_default = {
51216
51216
  title: "Dashboard",
51217
51217
  loading: "Loading dashboard...",
51218
51218
  github: "GitHub",
51219
- support: "Support",
51219
+ reportBug: "Report a bug",
51220
51220
  discord: "Discord",
51221
51221
  docs: "Docs",
51222
51222
  donate: "Donate",
@@ -51732,10 +51732,6 @@ var en_default = {
51732
51732
  twoFaOn: "On",
51733
51733
  twoFaOff: "Off",
51734
51734
  versionLabel: "Version",
51735
- betaProgramTitle: "Beta Program",
51736
- betaProgramDescription: "Try upcoming features early with the weekly rolling :beta Docker tag. Unstable, not for production.",
51737
- betaProgramFeedback: "Found a bug? Report it here.",
51738
- betaProgramLearnMore: "Learn More",
51739
51735
  cliTitle: "Termix CLI",
51740
51736
  cliDescription: "Open terminals, run commands and move files from your own shell.",
51741
51737
  cliLearnMore: "Learn More",
@@ -51870,7 +51866,13 @@ var en_default = {
51870
51866
  linkedProfileDescription: "This device uses your account on the server and keeps a copy of your data, so it still works offline.",
51871
51867
  manageSync: "Manage sync",
51872
51868
  setUpSync: "Link to a server",
51873
- settingsTabsAndShortcuts: "Tabs and Shortcuts"
51869
+ settingsTabsAndShortcuts: "Tabs and Shortcuts",
51870
+ onBetaTitle: "You are on a beta",
51871
+ onBetaDescription: "Thanks for testing. Tell us what breaks.",
51872
+ tryBetaTitle: "Try new features early",
51873
+ tryBetaDescription: "Join the beta program in Updates.",
51874
+ sendBetaFeedback: "Send beta feedback",
51875
+ openUpdates: "Updates"
51874
51876
  }
51875
51877
  }
51876
51878
  },
@@ -52152,8 +52154,9 @@ var en_default = {
52152
52154
  releaseNotes: "Release notes",
52153
52155
  noReleaseNotes: "No notes for this version.",
52154
52156
  viewRelease: "View on GitHub",
52155
- showAllReleases: "Show all {{count}}",
52156
- showFewerReleases: "Show fewer",
52157
+ showOlderReleases: "Older releases ({{count}})",
52158
+ hideOlderReleases: "Hide older releases",
52159
+ showMoreChanges: "Show {{count}} more",
52157
52160
  videoTitle: "{{name}} video",
52158
52161
  changeTypes: {
52159
52162
  added: "Added",
@@ -52165,6 +52168,7 @@ var en_default = {
52165
52168
  },
52166
52169
  installedBadge: "Installed",
52167
52170
  incompatible: "Needs a newer Termix",
52171
+ features: "Features",
52168
52172
  whatItCanDo: "What it can do",
52169
52173
  addsToTermix: "Adds to Termix",
52170
52174
  addsToTermixHint: "Uninstall and every one of these goes away.",
@@ -52209,7 +52213,23 @@ var en_default = {
52209
52213
  unverifiedHint: "Installed from a file. Termix did not check or sign it.",
52210
52214
  reportIssue: "Report an issue",
52211
52215
  reportTo: "Report to {{author}}",
52212
- openSettings: "Open its settings"
52216
+ requestFeature: "Request a feature",
52217
+ openSettings: "Open its settings",
52218
+ beta: {
52219
+ badge: "Beta",
52220
+ badgeHint: "This is a beta version. It can have bugs.",
52221
+ try: "Try beta {{version}}",
52222
+ confirmTitle: "Use betas for {{name}}?",
52223
+ confirmBody: "Betas get new features and fixes first, but they can have bugs. You can switch back to stable at any time.",
52224
+ confirmInstall: "Install {{version}}",
52225
+ confirmSwitch: "Use betas",
52226
+ leftWhileOnBeta: "{{name}} stays on {{version}} until a stable release is newer.",
52227
+ channel: "Beta versions",
52228
+ channelHint: "Get new versions early. They can have bugs.",
52229
+ waitingForStable: "Back on stable. Stays on {{version}} until a stable release is newer.",
52230
+ versionLabel: "{{version}} (beta)",
52231
+ sendFeedback: "Send beta feedback"
52232
+ }
52213
52233
  }
52214
52234
  },
52215
52235
  settings: {
@@ -52248,7 +52268,39 @@ var en_default = {
52248
52268
  instanceSettings: "Server settings",
52249
52269
  nothingHere: "Nothing to set here.",
52250
52270
  searchPage: "Search this page",
52251
- noPageMatches: "Nothing on this page matches."
52271
+ noPageMatches: "Nothing on this page matches.",
52272
+ updates: {
52273
+ title: "Updates",
52274
+ betaProgram: "Beta program",
52275
+ betaIntro: "Betas are early builds of the next Termix version. They come out on Tuesdays and Fridays when there is something new to try. They can have bugs, and your feedback is what makes the stable release better.",
52276
+ termixBetas: "Termix betas",
52277
+ termixBetasHint: "Tell me about new betas, not only stable releases.",
52278
+ runningBetaBuild: "You are running a beta build. To leave, switch back to a stable image or installer.",
52279
+ howToInstall: "How to get the beta",
52280
+ desktopInstall: "Download the beta installer and install it over this one.",
52281
+ downloadBeta: "Download {{version}}",
52282
+ dockerInstall: "Change your image to the beta tag and restart the container:",
52283
+ copy: "Copy",
52284
+ copied: "Copied",
52285
+ copyFailed: "Could not copy",
52286
+ learnMore: "Learn more about betas",
52287
+ feedback: "Feedback",
52288
+ feedbackHint: "Found a bug or have an idea? It goes straight to the developers with your version filled in.",
52289
+ sendFeedback: "Send beta feedback",
52290
+ releases: "Releases",
52291
+ thisVersion: "This version",
52292
+ newestStable: "Newest stable",
52293
+ newestBeta: "Newest beta",
52294
+ noRelease: "None right now",
52295
+ pluginBetas: "Plugin betas",
52296
+ pluginBetasIntro: "Each plugin can use betas on its own. To try one, open it and press Try beta.",
52297
+ openPlugins: "Open Plugins",
52298
+ waitingForStable: "Back on stable. Stays on {{version}} until a stable release is newer.",
52299
+ backToStable: "Back to stable",
52300
+ allPluginsBeta: "Use betas for all plugins",
52301
+ allPluginsStable: "All plugins back to stable",
52302
+ saveFailed: "Could not save the change"
52303
+ }
52252
52304
  },
52253
52305
  managerCard: {
52254
52306
  refresh: "Refresh",
@@ -52593,7 +52645,9 @@ i18n.use(localeBackend).use(Browser).use(initReactI18next).init({
52593
52645
  escapeValue: false
52594
52646
  },
52595
52647
  react: {
52596
- useSuspense: false
52648
+ useSuspense: false,
52649
+ // Repaint when a plugin enabled later loads its strings.
52650
+ bindI18n: "languageChanged loaded"
52597
52651
  }
52598
52652
  });
52599
52653
  async function changeAppLanguage(language) {
@@ -53072,7 +53126,7 @@ function createPluginApi(pluginId) {
53072
53126
  get(target, property, receiver) {
53073
53127
  const value = Reflect.get(target, property, receiver);
53074
53128
  if (typeof value !== "function") return value;
53075
- if (property === "get" || property === "delete" || property === "head" || property === "options" || property === "post" || property === "put" || property === "patch") {
53129
+ if (property === "get" || property === "delete" || property === "head" || property === "options" || property === "post" || property === "put" || property === "patch" || property === "postForm" || property === "putForm" || property === "patchForm") {
53076
53130
  return (url, ...rest) => value.call(
53077
53131
  target,
53078
53132
  `${prefix}${url.startsWith("/") ? url : `/${url}`}`,
@@ -53448,6 +53502,7 @@ async function getPlugins() {
53448
53502
  const response = await rbacApi.get("/plugins");
53449
53503
  return Array.isArray(response.data) ? response.data : [];
53450
53504
  }
53505
+ var PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
53451
53506
 
53452
53507
  // src/ui/plugin-host/scope.tsx
53453
53508
  import {
@@ -53912,13 +53967,22 @@ var pluginHostBridge = {
53912
53967
  useEffect34(() => {
53913
53968
  let cancelled = false;
53914
53969
  if (scope === "host" && !Number.isFinite(numericHostId)) return;
53915
- SETTINGS_READERS[scope](pluginId, numericHostId).then((values) => {
53970
+ const read = () => SETTINGS_READERS[scope](pluginId, numericHostId).then((values) => {
53916
53971
  if (!cancelled) setState({ values, loaded: true });
53917
53972
  }).catch(() => {
53918
53973
  if (!cancelled) setState((prev) => ({ ...prev, loaded: true }));
53919
53974
  });
53975
+ void read();
53976
+ const onChanged = (event) => {
53977
+ const detail = event.detail;
53978
+ if (detail?.pluginId !== pluginId || detail.scope !== scope) return;
53979
+ if (scope === "host" && detail.hostId !== numericHostId) return;
53980
+ void read();
53981
+ };
53982
+ window.addEventListener(PLUGIN_SETTINGS_CHANGED_EVENT, onChanged);
53920
53983
  return () => {
53921
53984
  cancelled = true;
53985
+ window.removeEventListener(PLUGIN_SETTINGS_CHANGED_EVENT, onChanged);
53922
53986
  };
53923
53987
  }, [pluginId, scope, numericHostId]);
53924
53988
  const save = useCallback21(
@@ -54797,6 +54861,7 @@ export {
54797
54861
  isRedactedSecret,
54798
54862
  isFieldActive,
54799
54863
  getPlugins,
54864
+ PLUGINS_CHANGED_EVENT,
54800
54865
  useAreaPreferences,
54801
54866
  getPanel,
54802
54867
  listPanels,
@@ -21,7 +21,7 @@ var inFlight = null;
21
21
  function load() {
22
22
  if (cache) return Promise.resolve(cache);
23
23
  if (inFlight) return inFlight;
24
- inFlight = import("./rbac-api-7GHDJLU4.js").then(({ getMyPermissions }) => getMyPermissions()).then((result) => {
24
+ inFlight = import("./rbac-api-JKISXZEP.js").then(({ getMyPermissions }) => getMyPermissions()).then((result) => {
25
25
  cache = {
26
26
  permissions: result.permissions ?? [],
27
27
  isAdmin: !!result.isAdmin
@@ -3,7 +3,7 @@ import {
3
3
  SYNC_CHANGED_EVENT,
4
4
  getLinkedSession,
5
5
  notifySyncChanged
6
- } from "./chunk-COGRSHKO.js";
6
+ } from "./chunk-LKYAVM4T.js";
7
7
  import "./chunk-YC2MAKSP.js";
8
8
  export {
9
9
  SYNC_CHANGED_EVENT,
@@ -23,7 +23,7 @@ import {
23
23
  shareHost,
24
24
  updateHostAccess,
25
25
  updateRole
26
- } from "./chunk-COGRSHKO.js";
26
+ } from "./chunk-LKYAVM4T.js";
27
27
  import "./chunk-YC2MAKSP.js";
28
28
  export {
29
29
  assignRoleToUser,
@@ -40,16 +40,16 @@ import {
40
40
  setShellCallbacks,
41
41
  setShellHosts,
42
42
  setShellLayoutProvider
43
- } from "./chunk-7GL25GFS.js";
43
+ } from "./chunk-SS43S6VI.js";
44
44
  import {
45
45
  setPermissionsForTesting
46
- } from "./chunk-3KG44G55.js";
46
+ } from "./chunk-XKMRKQFN.js";
47
47
  import {
48
48
  getTabType,
49
49
  listTabTypes,
50
50
  setFrontendState,
51
51
  setPluginSummaries
52
- } from "./chunk-COGRSHKO.js";
52
+ } from "./chunk-LKYAVM4T.js";
53
53
  import "./chunk-YC2MAKSP.js";
54
54
 
55
55
  // src/ui/plugin-host/testing-host.tsx
@@ -130,18 +130,24 @@ export interface PluginSummary {
130
130
  registryId?: string | null;
131
131
  autoUpdate?: boolean;
132
132
  pinnedVersion?: string | null;
133
+ channel?: PluginChannel;
133
134
  description?: string;
134
135
  author?: string;
135
136
  repository?: string;
136
137
  /** A YouTube video id from the manifest. Admins only. */
137
138
  videoId?: string;
139
+ /** What the plugin does, from the manifest. Admins only. */
140
+ features?: string[];
138
141
  signedBy?: string | null;
139
142
  }
140
143
  export declare function getPlugins(): Promise<PluginSummary[]>;
141
144
  /** Fired after any change to installed plugins; the loader resyncs on it. */
142
145
  export declare const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
146
+ export type PluginChannel = "stable" | "beta";
143
147
  export interface RegistryPluginVersion {
144
148
  version: string;
149
+ /** A beta, offered only to plugins on the beta channel. */
150
+ prerelease?: boolean;
145
151
  compatible: boolean;
146
152
  capabilities: string[];
147
153
  publishedAt?: string;
@@ -158,8 +164,13 @@ export interface RegistryPluginEntry {
158
164
  repository?: string;
159
165
  icon?: string;
160
166
  videoId?: string;
167
+ features?: string[];
161
168
  versions: RegistryPluginVersion[];
169
+ /** Newest release on the plugin's channel. */
162
170
  latestVersion: string | null;
171
+ /** Newest beta, when one is newer than stable. */
172
+ latestBeta?: string | null;
173
+ channel?: PluginChannel;
163
174
  installed: boolean;
164
175
  installedVersion: string | null;
165
176
  updateAvailable: boolean;
@@ -254,9 +265,14 @@ export declare function retryPlugin(pluginId: string): Promise<void>;
254
265
  export declare function setPluginOptions(pluginId: string, options: {
255
266
  autoUpdate?: boolean;
256
267
  pinned?: boolean;
268
+ channel?: PluginChannel;
257
269
  }): Promise<{
258
270
  autoUpdate: boolean;
259
271
  pinnedVersion: string | null;
272
+ channel: PluginChannel;
273
+ }>;
274
+ export declare function setAllPluginChannels(channel: PluginChannel): Promise<{
275
+ changed: string[];
260
276
  }>;
261
277
  export declare function getPluginData(pluginId: string): Promise<PluginDataSummary>;
262
278
  export declare function getPluginChangelog(pluginId: string): Promise<ChangelogRelease[]>;
@@ -277,6 +293,7 @@ export interface PluginUploadPreview {
277
293
  version: string;
278
294
  description: string;
279
295
  author: string;
296
+ features?: string[];
280
297
  capabilities: string[];
281
298
  replaces: string | null;
282
299
  }
@@ -29,6 +29,7 @@ export interface ReleasesRSSResponse {
29
29
  export declare function getReleasesRSS(perPage?: number): Promise<ReleasesRSSResponse>;
30
30
  export interface VersionInfo {
31
31
  status?: "up_to_date" | "requires_update" | "beta" | "unknown";
32
+ channel?: "stable" | "beta";
32
33
  /** Same value as remoteVersion; the endpoint sends both. */
33
34
  version?: string;
34
35
  localVersion?: string;
@@ -46,4 +47,27 @@ export interface VersionInfo {
46
47
  }
47
48
  export declare function releaseUrlFrom(info: VersionInfo | null | undefined): string;
48
49
  export declare function getVersionInfo(checkRemote?: boolean): Promise<VersionInfo>;
50
+ export type UpdateChannel = "stable" | "beta";
51
+ export interface UpdateChannelState {
52
+ channel: UpdateChannel;
53
+ /** The admin's choice; a beta build reports beta whatever it is. */
54
+ stored: UpdateChannel;
55
+ runningBeta: boolean;
56
+ }
57
+ export declare function getUpdateChannel(): Promise<UpdateChannelState>;
58
+ export declare function setUpdateChannel(channel: UpdateChannel): Promise<UpdateChannelState>;
59
+ export interface ReleaseInfo {
60
+ version: string;
61
+ tagName: string;
62
+ name: string;
63
+ url: string;
64
+ publishedAt: string;
65
+ prerelease: boolean;
66
+ notes: string;
67
+ }
68
+ export declare function getChannelReleases(): Promise<{
69
+ localVersion: string | null;
70
+ stable: ReleaseInfo | null;
71
+ beta: ReleaseInfo | null;
72
+ }>;
49
73
  export declare function getDatabaseHealth(): Promise<Record<string, unknown>>;
@@ -0,0 +1,6 @@
1
+ import { type PluginSummary } from "../api/plugins-api";
2
+ /**
3
+ * Every installed plugin, read again whenever one is enabled, disabled,
4
+ * installed or removed, so what a plugin adds shows up without a reload.
5
+ */
6
+ export declare function useInstalledPlugins(): PluginSummary[];
@@ -9,3 +9,13 @@ import type { PluginSettingsField } from "../api/plugins-api";
9
9
  export declare function isFieldShown(field: PluginSettingsField, values: Record<string, unknown>): boolean;
10
10
  /** Whether a plugin has any field the generic form would ever draw. */
11
11
  export declare function hasVisibleFields(fields: PluginSettingsField[]): boolean;
12
+ /** Whether Settings lists a page for this plugin, by the same rule it uses. */
13
+ export declare function hasSettingsPage(plugin: {
14
+ enabled: boolean;
15
+ contributes?: {
16
+ settings?: {
17
+ user?: PluginSettingsField[];
18
+ admin?: PluginSettingsField[];
19
+ };
20
+ } | null;
21
+ }, isAdmin: boolean): boolean;
package/dist/host/ui.js CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  LoaderCircle,
36
36
  Overlay2,
37
37
  PANEL,
38
+ PLUGINS_CHANGED_EVENT,
38
39
  PROMPT_BUTTON,
39
40
  PROMPT_DESTRUCTIVE_BUTTON,
40
41
  PROMPT_PRIMARY_BUTTON,
@@ -136,8 +137,8 @@ import {
136
137
  useSurfaceKind,
137
138
  useTheme,
138
139
  verifySecondFactor
139
- } from "./chunk-7GL25GFS.js";
140
- import "./chunk-3KG44G55.js";
140
+ } from "./chunk-SS43S6VI.js";
141
+ import "./chunk-XKMRKQFN.js";
141
142
  import {
142
143
  TabProvider,
143
144
  buildOriginWsUrl,
@@ -176,7 +177,7 @@ import {
176
177
  useTabs,
177
178
  useTabsSafe,
178
179
  verifyPasswordResetCode
179
- } from "./chunk-COGRSHKO.js";
180
+ } from "./chunk-LKYAVM4T.js";
180
181
  import "./chunk-YC2MAKSP.js";
181
182
 
182
183
  // src/ui/components/alert.tsx
@@ -1328,7 +1329,7 @@ function Select22({
1328
1329
  "aria-expanded": open,
1329
1330
  "aria-required": required,
1330
1331
  className: cn(
1331
- "border-input bg-background text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
1332
+ "border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
1332
1333
  className
1333
1334
  ),
1334
1335
  onKeyDown: (event) => {
@@ -3216,7 +3217,7 @@ function ConnectionMark({
3216
3217
  }
3217
3218
 
3218
3219
  // src/ui/components/FullScreenAppWrapper.tsx
3219
- import { useEffect as useEffect11, useState as useState14 } from "react";
3220
+ import { useEffect as useEffect10, useState as useState13 } from "react";
3220
3221
 
3221
3222
  // src/ui/components/sidebar.tsx
3222
3223
  import { useTranslation as useTranslation8 } from "react-i18next";
@@ -3462,7 +3463,7 @@ function isInElectronWebView() {
3462
3463
  }
3463
3464
 
3464
3465
  // src/ui/auth/Auth.tsx
3465
- import { useState as useState13, useEffect as useEffect10, useCallback as useCallback6, useRef as useRef4 } from "react";
3466
+ import { useState as useState12, useEffect as useEffect9, useCallback as useCallback6, useRef as useRef4 } from "react";
3466
3467
  import { toast as toast2 } from "sonner";
3467
3468
  import { useTranslation as useTranslation11 } from "react-i18next";
3468
3469
 
@@ -3492,9 +3493,33 @@ function PluginIcon({
3492
3493
  }
3493
3494
 
3494
3495
  // src/ui/settings/HostPluginSections.tsx
3495
- import { useEffect as useEffect7, useMemo as useMemo4, useState as useState10 } from "react";
3496
+ import { useMemo as useMemo4 } from "react";
3496
3497
  import { useTranslation as useTranslation10 } from "react-i18next";
3497
3498
 
3499
+ // src/ui/hooks/use-installed-plugins.ts
3500
+ import { useEffect as useEffect7, useState as useState10 } from "react";
3501
+ function useInstalledPlugins() {
3502
+ const [plugins, setPlugins] = useState10([]);
3503
+ useEffect7(() => {
3504
+ let cancelled = false;
3505
+ let request = 0;
3506
+ const load = () => {
3507
+ const current = ++request;
3508
+ void getPlugins().then((loaded) => {
3509
+ if (!cancelled && current === request) setPlugins(loaded);
3510
+ }).catch(() => {
3511
+ });
3512
+ };
3513
+ load();
3514
+ window.addEventListener(PLUGINS_CHANGED_EVENT, load);
3515
+ return () => {
3516
+ cancelled = true;
3517
+ window.removeEventListener(PLUGINS_CHANGED_EVENT, load);
3518
+ };
3519
+ }, []);
3520
+ return plugins;
3521
+ }
3522
+
3498
3523
  // src/ui/settings/SettingsFields.tsx
3499
3524
  import { useTranslation as useTranslation9 } from "react-i18next";
3500
3525
  import { Fragment as Fragment4, jsx as jsx35, jsxs as jsxs23 } from "react/jsx-runtime";
@@ -3734,17 +3759,7 @@ function hasVisibleFields(fields) {
3734
3759
  // src/ui/settings/HostPluginSections.tsx
3735
3760
  import { jsx as jsx36, jsxs as jsxs24 } from "react/jsx-runtime";
3736
3761
  function usePluginHostSections() {
3737
- const [plugins, setPlugins] = useState10([]);
3738
- useEffect7(() => {
3739
- let cancelled = false;
3740
- void getPlugins().then((loaded) => {
3741
- if (!cancelled) setPlugins(loaded);
3742
- }).catch(() => {
3743
- });
3744
- return () => {
3745
- cancelled = true;
3746
- };
3747
- }, []);
3762
+ const plugins = useInstalledPlugins();
3748
3763
  return useMemo4(
3749
3764
  () => plugins.filter((plugin) => {
3750
3765
  if (!plugin.enabled) return false;
@@ -3859,7 +3874,7 @@ function HostPluginSection({
3859
3874
  }
3860
3875
 
3861
3876
  // src/ui/sidebar/host-defaults/catalog.ts
3862
- import { useEffect as useEffect8, useMemo as useMemo5, useState as useState11 } from "react";
3877
+ import { useMemo as useMemo5 } from "react";
3863
3878
 
3864
3879
  // src/types/host-defaults.ts
3865
3880
  var HOST_DEFAULTS_LEVELS = [
@@ -4446,9 +4461,9 @@ import {
4446
4461
  createContext as createContext4,
4447
4462
  useCallback as useCallback5,
4448
4463
  useContext as useContext4,
4449
- useEffect as useEffect9,
4464
+ useEffect as useEffect8,
4450
4465
  useMemo as useMemo6,
4451
- useState as useState12
4466
+ useState as useState11
4452
4467
  } from "react";
4453
4468
  import { jsx as jsx38 } from "react/jsx-runtime";
4454
4469
  var DEFAULT_APP_NAME = "Termix";
@@ -4609,7 +4624,7 @@ function PasswordInput2({
4609
4624
  disabled,
4610
4625
  id
4611
4626
  }) {
4612
- const [show, setShow] = useState13(false);
4627
+ const [show, setShow] = useState12(false);
4613
4628
  return /* @__PURE__ */ jsxs26("div", { className: "relative", children: [
4614
4629
  /* @__PURE__ */ jsx40(
4615
4630
  Input,
@@ -4662,22 +4677,22 @@ function Auth({ onLogin }) {
4662
4677
  const { t: t2 } = useTranslation11();
4663
4678
  const branding = useBranding();
4664
4679
  const localDesktopAuth = isElectron() && !isInElectronWebView();
4665
- const [view, setView] = useState13("login");
4666
- const [loading, setLoading] = useState13(false);
4667
- const [username, setUsername] = useState13("");
4668
- const [password, setPassword] = useState13("");
4669
- const [confirmPassword, setConfirmPassword] = useState13("");
4670
- const [rememberMe, setRememberMe] = useState13(() => {
4680
+ const [view, setView] = useState12("login");
4681
+ const [loading, setLoading] = useState12(false);
4682
+ const [username, setUsername] = useState12("");
4683
+ const [password, setPassword] = useState12("");
4684
+ const [confirmPassword, setConfirmPassword] = useState12("");
4685
+ const [rememberMe, setRememberMe] = useState12(() => {
4671
4686
  try {
4672
4687
  return localStorage.getItem("rememberMe") === "true";
4673
4688
  } catch {
4674
4689
  return false;
4675
4690
  }
4676
4691
  });
4677
- const [pendingToken, setPendingToken] = useState13("");
4678
- const [secondFactors, setSecondFactors] = useState13([]);
4679
- const [activeFactorId, setActiveFactorId] = useState13("");
4680
- const [authPluginsLoading, setAuthPluginsLoading] = useState13(true);
4692
+ const [pendingToken, setPendingToken] = useState12("");
4693
+ const [secondFactors, setSecondFactors] = useState12([]);
4694
+ const [activeFactorId, setActiveFactorId] = useState12("");
4695
+ const [authPluginsLoading, setAuthPluginsLoading] = useState12(true);
4681
4696
  const retryAuthPlugins = async () => {
4682
4697
  setAuthPluginsLoading(true);
4683
4698
  try {
@@ -4694,12 +4709,12 @@ function Auth({ onLogin }) {
4694
4709
  labelKey: ui.titleKey
4695
4710
  }));
4696
4711
  const currentFactorId = activeFactorId || shownFactors[0]?.id || "";
4697
- const [resetStep, setResetStep] = useState13("email");
4698
- const [resetCode, setResetCode] = useState13("");
4699
- const [newPassword, setNewPassword] = useState13("");
4700
- const [confirmNewPassword, setConfirmNewPassword] = useState13("");
4701
- const [resetTempToken, setResetTempToken] = useState13("");
4702
- const [language2, setLanguage] = useState13(
4712
+ const [resetStep, setResetStep] = useState12("email");
4713
+ const [resetCode, setResetCode] = useState12("");
4714
+ const [newPassword, setNewPassword] = useState12("");
4715
+ const [confirmNewPassword, setConfirmNewPassword] = useState12("");
4716
+ const [resetTempToken, setResetTempToken] = useState12("");
4717
+ const [language2, setLanguage] = useState12(
4703
4718
  () => normalizeLanguageCode(localStorage.getItem("i18nextLng"))
4704
4719
  );
4705
4720
  function handleLanguageChange(code2) {
@@ -4707,11 +4722,11 @@ function Auth({ onLogin }) {
4707
4722
  void changeAppLanguage(language3).then((language4) => setLanguage(language4)).catch(() => {
4708
4723
  });
4709
4724
  }
4710
- const [registrationAllowed, setRegistrationAllowed] = useState13(true);
4711
- const [passwordLoginAllowed, setPasswordLoginAllowed] = useState13(true);
4712
- const [passwordResetAllowed, setPasswordResetAllowed] = useState13(true);
4713
- const [authMethods, setAuthMethods] = useState13([]);
4714
- const [authMethodsLoaded, setAuthMethodsLoaded] = useState13(false);
4725
+ const [registrationAllowed, setRegistrationAllowed] = useState12(true);
4726
+ const [passwordLoginAllowed, setPasswordLoginAllowed] = useState12(true);
4727
+ const [passwordResetAllowed, setPasswordResetAllowed] = useState12(true);
4728
+ const [authMethods, setAuthMethods] = useState12([]);
4729
+ const [authMethodsLoaded, setAuthMethodsLoaded] = useState12(false);
4715
4730
  const inlineMethodIds = new Set(
4716
4731
  loginMethodUIs.filter((ui) => ui.placement === "inline").map((ui) => ui.id)
4717
4732
  );
@@ -4723,19 +4738,19 @@ function Auth({ onLogin }) {
4723
4738
  );
4724
4739
  const silentSigninHandledRef = useRef4(false);
4725
4740
  const proxySigninHandledRef = useRef4(false);
4726
- const [firstUser, setFirstUser] = useState13(false);
4727
- const [dbConnectionFailed, setDbConnectionFailed] = useState13(false);
4728
- const [dbHealthChecking, setDbHealthChecking] = useState13(
4741
+ const [firstUser, setFirstUser] = useState12(false);
4742
+ const [dbConnectionFailed, setDbConnectionFailed] = useState12(false);
4743
+ const [dbHealthChecking, setDbHealthChecking] = useState12(
4729
4744
  () => !localDesktopAuth || !hasDesktopManualLogout()
4730
4745
  );
4731
- const [webviewAuthSuccess, setWebviewAuthSuccess] = useState13(false);
4732
- const [desktopManualLogoutActive, setDesktopManualLogoutActive] = useState13(
4746
+ const [webviewAuthSuccess, setWebviewAuthSuccess] = useState12(false);
4747
+ const [desktopManualLogoutActive, setDesktopManualLogoutActive] = useState12(
4733
4748
  () => localDesktopAuth && hasDesktopManualLogout()
4734
4749
  );
4735
- const [desktopAutoSessionDone, setDesktopAutoSessionDone] = useState13(!localDesktopAuth || hasDesktopManualLogout() ? true : null);
4736
- const [desktopAutoSessionRetries, setDesktopAutoSessionRetries] = useState13(0);
4737
- const [embeddedServerFailure, setEmbeddedServerFailure] = useState13(null);
4738
- useEffect10(() => {
4750
+ const [desktopAutoSessionDone, setDesktopAutoSessionDone] = useState12(!localDesktopAuth || hasDesktopManualLogout() ? true : null);
4751
+ const [desktopAutoSessionRetries, setDesktopAutoSessionRetries] = useState12(0);
4752
+ const [embeddedServerFailure, setEmbeddedServerFailure] = useState12(null);
4753
+ useEffect9(() => {
4739
4754
  if (proxySigninHandledRef.current || isElectron()) return;
4740
4755
  proxySigninHandledRef.current = true;
4741
4756
  requestTrustedProxyLogin().then((result) => {
@@ -4749,13 +4764,13 @@ function Auth({ onLogin }) {
4749
4764
  }).catch(() => {
4750
4765
  });
4751
4766
  }, [onLogin]);
4752
- useEffect10(() => {
4767
+ useEffect9(() => {
4753
4768
  try {
4754
4769
  localStorage.setItem("rememberMe", rememberMe.toString());
4755
4770
  } catch {
4756
4771
  }
4757
4772
  }, [rememberMe]);
4758
- useEffect10(() => {
4773
+ useEffect9(() => {
4759
4774
  if (!isInElectronWebView()) return;
4760
4775
  const handleExternalBrowserResult = (event) => {
4761
4776
  if (event.source !== window.parent) return;
@@ -4775,7 +4790,7 @@ function Auth({ onLogin }) {
4775
4790
  window.addEventListener("message", handleExternalBrowserResult);
4776
4791
  return () => window.removeEventListener("message", handleExternalBrowserResult);
4777
4792
  }, [t2]);
4778
- useEffect10(() => {
4793
+ useEffect9(() => {
4779
4794
  if (localDesktopAuth) {
4780
4795
  setAuthMethodsLoaded(true);
4781
4796
  return;
@@ -4789,7 +4804,7 @@ function Auth({ onLogin }) {
4789
4804
  }).finally(() => setAuthPluginsLoading(false));
4790
4805
  getLoginMethods().then((methods) => setAuthMethods(methods)).catch(() => setAuthMethods([])).finally(() => setAuthMethodsLoaded(true));
4791
4806
  }, [localDesktopAuth]);
4792
- useEffect10(() => {
4807
+ useEffect9(() => {
4793
4808
  if (desktopAutoSessionDone !== true) return;
4794
4809
  if (localDesktopAuth) {
4795
4810
  setDbHealthChecking(false);
@@ -4827,7 +4842,7 @@ function Auth({ onLogin }) {
4827
4842
  if (retryTimer) clearTimeout(retryTimer);
4828
4843
  };
4829
4844
  }, [desktopAutoSessionDone, localDesktopAuth]);
4830
- useEffect10(() => {
4845
+ useEffect9(() => {
4831
4846
  if (desktopAutoSessionDone !== null) return;
4832
4847
  if (embeddedServerFailure) return;
4833
4848
  let cancelled = false;
@@ -4875,13 +4890,13 @@ function Auth({ onLogin }) {
4875
4890
  embeddedServerFailure,
4876
4891
  onLogin
4877
4892
  ]);
4878
- useEffect10(() => {
4893
+ useEffect9(() => {
4879
4894
  if (!authMethodsLoaded) return;
4880
4895
  if (!passwordLoginAllowed && externalMethods.length > 0 && view === "login") {
4881
4896
  setView("external");
4882
4897
  }
4883
4898
  }, [authMethodsLoaded, passwordLoginAllowed, externalMethods.length, view]);
4884
- useEffect10(() => {
4899
+ useEffect9(() => {
4885
4900
  const urlParams = new URLSearchParams(window.location.search);
4886
4901
  const success = urlParams.get("success");
4887
4902
  const error = urlParams.get("error");
@@ -5292,7 +5307,7 @@ function Auth({ onLogin }) {
5292
5307
  }
5293
5308
  return null;
5294
5309
  }
5295
- useEffect10(() => {
5310
+ useEffect9(() => {
5296
5311
  if (!authMethodsLoaded || silentSigninHandledRef.current) return;
5297
5312
  const autoStart = externalMethods.filter((method) => method.kind === "redirect").flatMap(
5298
5313
  (method) => method.instances.filter((instance) => instance.autoStart).map((instance) => ({ method, instance }))
@@ -5922,12 +5937,12 @@ var FullScreenAppWrapper = ({
5922
5937
  children
5923
5938
  }) => {
5924
5939
  const { t: t2 } = useTranslation12();
5925
- const [hostConfig, setHostConfig] = useState14(null);
5926
- const [loading, setLoading] = useState14(true);
5927
- const [isAuthenticated, setIsAuthenticated] = useState14(false);
5928
- const [authLoading, setAuthLoading] = useState14(true);
5929
- const [, setIsAdmin] = useState14(false);
5930
- useEffect11(() => {
5940
+ const [hostConfig, setHostConfig] = useState13(null);
5941
+ const [loading, setLoading] = useState13(true);
5942
+ const [isAuthenticated, setIsAuthenticated] = useState13(false);
5943
+ const [authLoading, setAuthLoading] = useState13(true);
5944
+ const [, setIsAdmin] = useState13(false);
5945
+ useEffect10(() => {
5931
5946
  const handleSessionExpired = () => {
5932
5947
  setIsAuthenticated(false);
5933
5948
  setIsAdmin(false);
@@ -5936,7 +5951,7 @@ var FullScreenAppWrapper = ({
5936
5951
  dbHealthMonitor.on("session-expired", handleSessionExpired);
5937
5952
  return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
5938
5953
  }, []);
5939
- useEffect11(() => {
5954
+ useEffect10(() => {
5940
5955
  let cancelled = false;
5941
5956
  let retryTimer = null;
5942
5957
  const maxAttempts = 10;
@@ -5993,7 +6008,7 @@ var FullScreenAppWrapper = ({
5993
6008
  if (retryTimer) clearTimeout(retryTimer);
5994
6009
  };
5995
6010
  }, []);
5996
- useEffect11(() => {
6011
+ useEffect10(() => {
5997
6012
  const fetchHost = async () => {
5998
6013
  if (!hostId || !isAuthenticated) {
5999
6014
  setLoading(false);
@@ -6056,7 +6071,7 @@ var FullScreenAppWrapper = ({
6056
6071
  };
6057
6072
 
6058
6073
  // src/ui/ssh/dialogs/TOTPDialog.tsx
6059
- import { useEffect as useEffect12, useState as useState15 } from "react";
6074
+ import { useEffect as useEffect11, useState as useState14 } from "react";
6060
6075
  import { useTranslation as useTranslation13 } from "react-i18next";
6061
6076
  import { jsx as jsx43, jsxs as jsxs28 } from "react/jsx-runtime";
6062
6077
  var CODE_INPUT = "rounded-none bg-muted/50 border-border text-center text-sm tracking-widest";
@@ -6070,13 +6085,14 @@ function TOTPDialog({
6070
6085
  backgroundColor
6071
6086
  }) {
6072
6087
  const { t: t2 } = useTranslation13();
6073
- const [pushSubmitted, setPushSubmitted] = useState15(false);
6074
- useEffect12(() => {
6088
+ const [pushSubmitted, setPushSubmitted] = useState14(false);
6089
+ useEffect11(() => {
6075
6090
  if (isOpen && !waiting) {
6076
6091
  setPushSubmitted(false);
6077
6092
  }
6078
6093
  }, [isOpen, waiting]);
6079
6094
  if (!isOpen) return null;
6095
+ const isPassword = mode === "password";
6080
6096
  const isPush = mode === "push";
6081
6097
  const isMenu = mode === "menu";
6082
6098
  const isTotp = !isPush && !isMenu && mode !== "password";
@@ -6092,11 +6108,10 @@ function TOTPDialog({
6092
6108
  const input = e.currentTarget.elements.namedItem(
6093
6109
  "totpCode"
6094
6110
  );
6095
- if (input?.value.trim()) {
6096
- onSubmit(input.value.trim());
6097
- }
6111
+ const value = isPassword ? input?.value : input?.value.trim();
6112
+ if (value) onSubmit(value);
6098
6113
  };
6099
- const title = isPush ? t2("sshAuth.mfaPushRequired") : isMenu ? t2("sshAuth.mfaPromptRequired") : t2("sshAuth.totpRequired");
6114
+ const title = isPush ? t2("sshAuth.mfaPushRequired") : isPassword ? t2("common.password") : isMenu ? t2("sshAuth.mfaPromptRequired") : t2("sshAuth.totpRequired");
6100
6115
  const label = prompt || (isTotp ? t2("sshAuth.totpCodeLabel") : void 0);
6101
6116
  const cancel = /* @__PURE__ */ jsx43("button", { type: "button", onClick: onCancel, className: PROMPT_BUTTON, children: t2("common.cancel") });
6102
6117
  return /* @__PURE__ */ jsx43(
@@ -6115,7 +6130,18 @@ function TOTPDialog({
6115
6130
  ] }),
6116
6131
  /* @__PURE__ */ jsx43("div", { className: "flex justify-end gap-2", children: cancel })
6117
6132
  ] }) : /* @__PURE__ */ jsxs28("form", { onSubmit: handleSubmit, className: "flex flex-col gap-3", children: [
6118
- isPush ? null : isMenu ? /* @__PURE__ */ jsx43(
6133
+ isPush ? null : isPassword ? /* @__PURE__ */ jsx43(
6134
+ Input,
6135
+ {
6136
+ id: "totpCode",
6137
+ name: "totpCode",
6138
+ type: "password",
6139
+ autoComplete: "current-password",
6140
+ autoFocus: true,
6141
+ placeholder: t2("placeholders.enterPassword"),
6142
+ className: "rounded-none bg-muted/50 border-border text-sm"
6143
+ }
6144
+ ) : isMenu ? /* @__PURE__ */ jsx43(
6119
6145
  Input,
6120
6146
  {
6121
6147
  id: "totpCode",
@@ -6151,7 +6177,7 @@ function TOTPDialog({
6151
6177
  ),
6152
6178
  /* @__PURE__ */ jsxs28("div", { className: "flex justify-end gap-2", children: [
6153
6179
  cancel,
6154
- /* @__PURE__ */ jsx43("button", { type: "submit", className: PROMPT_PRIMARY_BUTTON, children: isPush ? t2("sshAuth.mfaSendRequest") : t2("sshAuth.totpVerify") })
6180
+ /* @__PURE__ */ jsx43("button", { type: "submit", className: PROMPT_PRIMARY_BUTTON, children: isPush ? t2("sshAuth.mfaSendRequest") : isPassword ? t2("common.connect") : t2("sshAuth.totpVerify") })
6155
6181
  ] })
6156
6182
  ] })
6157
6183
  }
@@ -6159,7 +6185,7 @@ function TOTPDialog({
6159
6185
  }
6160
6186
 
6161
6187
  // src/ui/ssh/dialogs/SSHAuthDialog.tsx
6162
- import { useState as useState17 } from "react";
6188
+ import { useState as useState16 } from "react";
6163
6189
 
6164
6190
  // src/ui/components/tabs.tsx
6165
6191
  import { jsx as jsx44 } from "react/jsx-runtime";
@@ -6257,7 +6283,7 @@ function _objectWithoutPropertiesLoose(r, e) {
6257
6283
  import React10, { useRef as useRef5, forwardRef as forwardRef3, useImperativeHandle, useCallback as useCallback7 } from "react";
6258
6284
 
6259
6285
  // node_modules/@uiw/react-codemirror/esm/useCodeMirror.js
6260
- import { useEffect as useEffect13, useLayoutEffect as useLayoutEffect2, useState as useState16 } from "react";
6286
+ import { useEffect as useEffect12, useLayoutEffect as useLayoutEffect2, useState as useState15 } from "react";
6261
6287
 
6262
6288
  // node_modules/@marijn/find-cluster-break/src/index.js
6263
6289
  var rangeFrom = [];
@@ -29312,13 +29338,13 @@ var TYPING_TIMOUT = 200;
29312
29338
  var emptyExtensions = [];
29313
29339
  function useCodeMirror(props) {
29314
29340
  var value = props.value, selection2 = props.selection, onChange = props.onChange, onStatistics = props.onStatistics, onCreateEditor = props.onCreateEditor, onUpdate = props.onUpdate, _props$extensions = props.extensions, extensions = _props$extensions === void 0 ? emptyExtensions : _props$extensions, autoFocus = props.autoFocus, _props$theme = props.theme, theme2 = _props$theme === void 0 ? "light" : _props$theme, _props$height = props.height, height = _props$height === void 0 ? null : _props$height, _props$minHeight = props.minHeight, minHeight = _props$minHeight === void 0 ? null : _props$minHeight, _props$maxHeight = props.maxHeight, maxHeight = _props$maxHeight === void 0 ? null : _props$maxHeight, _props$width = props.width, width = _props$width === void 0 ? null : _props$width, _props$minWidth = props.minWidth, minWidth = _props$minWidth === void 0 ? null : _props$minWidth, _props$maxWidth = props.maxWidth, maxWidth = _props$maxWidth === void 0 ? null : _props$maxWidth, _props$placeholder = props.placeholder, placeholderStr = _props$placeholder === void 0 ? "" : _props$placeholder, _props$editable = props.editable, editable2 = _props$editable === void 0 ? true : _props$editable, _props$readOnly = props.readOnly, readOnly2 = _props$readOnly === void 0 ? false : _props$readOnly, _props$indentWithTab = props.indentWithTab, defaultIndentWithTab = _props$indentWithTab === void 0 ? true : _props$indentWithTab, _props$basicSetup = props.basicSetup, defaultBasicSetup = _props$basicSetup === void 0 ? true : _props$basicSetup, root = props.root, initialState = props.initialState;
29315
- var _useState = useState16(), container = _useState[0], setContainer = _useState[1];
29316
- var _useState2 = useState16(), view = _useState2[0], setView = _useState2[1];
29317
- var _useState3 = useState16(), state = _useState3[0], setState = _useState3[1];
29318
- var typingLatch = useState16(() => ({
29341
+ var _useState = useState15(), container = _useState[0], setContainer = _useState[1];
29342
+ var _useState2 = useState15(), view = _useState2[0], setView = _useState2[1];
29343
+ var _useState3 = useState15(), state = _useState3[0], setState = _useState3[1];
29344
+ var typingLatch = useState15(() => ({
29319
29345
  current: null
29320
29346
  }))[0];
29321
- var pendingUpdate = useState16(() => ({
29347
+ var pendingUpdate = useState15(() => ({
29322
29348
  current: null
29323
29349
  }))[0];
29324
29350
  var defaultThemeOption = getDimensionTheme(height, minHeight, maxHeight, width, minWidth, maxWidth);
@@ -29384,12 +29410,12 @@ function useCodeMirror(props) {
29384
29410
  }
29385
29411
  };
29386
29412
  }, [container, state]);
29387
- useEffect13(() => {
29413
+ useEffect12(() => {
29388
29414
  if (props.container) {
29389
29415
  setContainer(props.container);
29390
29416
  }
29391
29417
  }, [props.container]);
29392
- useEffect13(() => () => {
29418
+ useEffect12(() => () => {
29393
29419
  if (view) {
29394
29420
  view.destroy();
29395
29421
  setView(void 0);
@@ -29399,19 +29425,19 @@ function useCodeMirror(props) {
29399
29425
  typingLatch.current = null;
29400
29426
  }
29401
29427
  }, [view]);
29402
- useEffect13(() => {
29428
+ useEffect12(() => {
29403
29429
  if (autoFocus && view) {
29404
29430
  view.focus();
29405
29431
  }
29406
29432
  }, [autoFocus, view]);
29407
- useEffect13(() => {
29433
+ useEffect12(() => {
29408
29434
  if (view) {
29409
29435
  view.dispatch({
29410
29436
  effects: StateEffect.reconfigure.of(getExtensions)
29411
29437
  });
29412
29438
  }
29413
29439
  }, [theme2, extensions, height, minHeight, maxHeight, width, minWidth, maxWidth, placeholderStr, editable2, readOnly2, defaultIndentWithTab, defaultBasicSetup, onChange, onUpdate]);
29414
- useEffect13(() => {
29440
+ useEffect12(() => {
29415
29441
  if (value === void 0) {
29416
29442
  return;
29417
29443
  }
@@ -29509,11 +29535,11 @@ function SSHAuthDialog({
29509
29535
  backgroundColor
29510
29536
  }) {
29511
29537
  const { t: t2 } = useTranslation14();
29512
- const [authTab, setAuthTab] = useState17("password");
29513
- const [password, setPassword] = useState17("");
29514
- const [sshKey, setSshKey] = useState17("");
29515
- const [keyPassword, setKeyPassword] = useState17("");
29516
- const [loading, setLoading] = useState17(false);
29538
+ const [authTab, setAuthTab] = useState16("password");
29539
+ const [password, setPassword] = useState16("");
29540
+ const [sshKey, setSshKey] = useState16("");
29541
+ const [keyPassword, setKeyPassword] = useState16("");
29542
+ const [loading, setLoading] = useState16(false);
29517
29543
  if (!isOpen) return null;
29518
29544
  const hostDisplay = hostInfo.name ? `${hostInfo.name} (${hostInfo.username}@${hostInfo.ip}:${hostInfo.port})` : `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
29519
29545
  const getReasonMessage = () => {
@@ -29774,7 +29800,7 @@ function SSHAuthDialog({
29774
29800
  }
29775
29801
 
29776
29802
  // src/ui/ssh/dialogs/BrowserSignInDialog.tsx
29777
- import { useState as useState18 } from "react";
29803
+ import { useState as useState17 } from "react";
29778
29804
  import { useTranslation as useTranslation15 } from "react-i18next";
29779
29805
  import { toast as toast3 } from "sonner";
29780
29806
  import { jsx as jsx46, jsxs as jsxs30 } from "react/jsx-runtime";
@@ -29789,7 +29815,7 @@ function BrowserSignInDialog({
29789
29815
  backgroundColor
29790
29816
  }) {
29791
29817
  const { t: t2 } = useTranslation15();
29792
- const [copied, setCopied] = useState18(false);
29818
+ const [copied, setCopied] = useState17(false);
29793
29819
  if (!isOpen) return null;
29794
29820
  const handleCopyUrl = async () => {
29795
29821
  const ok = await copyToClipboard(url);
@@ -29966,7 +29992,7 @@ function ManagerSearch({
29966
29992
  }
29967
29993
 
29968
29994
  // src/ui/hooks/use-adaptive-polling.ts
29969
- import { useEffect as useEffect14, useRef as useRef6 } from "react";
29995
+ import { useEffect as useEffect13, useRef as useRef6 } from "react";
29970
29996
  function useAdaptivePolling(poll, policy, enabled = true, options = {}) {
29971
29997
  const pollRef = useRef6(poll);
29972
29998
  const errorRef = useRef6(options.onError);
@@ -29980,7 +30006,7 @@ function useAdaptivePolling(poll, policy, enabled = true, options = {}) {
29980
30006
  maxRequestDutyCycle
29981
30007
  } = policy;
29982
30008
  const runImmediately = options.runImmediately;
29983
- useEffect14(
30009
+ useEffect13(
29984
30010
  () => runAdaptivePolling(
29985
30011
  () => pollRef.current(),
29986
30012
  {
@@ -30137,7 +30163,7 @@ function PassphraseDialog({
30137
30163
  }
30138
30164
 
30139
30165
  // src/ui/ssh/dialogs/HostKeyVerificationDialog.tsx
30140
- import { useState as useState19 } from "react";
30166
+ import { useState as useState18 } from "react";
30141
30167
  import { useTranslation as useTranslation18 } from "react-i18next";
30142
30168
  import { Fragment as Fragment7, jsx as jsx51, jsxs as jsxs35 } from "react/jsx-runtime";
30143
30169
  var formatFingerprint = (fp) => fp.match(/.{1,2}/g)?.join(":") || fp;
@@ -30180,8 +30206,8 @@ function HostKeyVerificationDialog({
30180
30206
  backgroundColor
30181
30207
  }) {
30182
30208
  const { t: t2 } = useTranslation18();
30183
- const [copiedFingerprint, setCopiedFingerprint] = useState19(false);
30184
- const [copiedOldFingerprint, setCopiedOldFingerprint] = useState19(false);
30209
+ const [copiedFingerprint, setCopiedFingerprint] = useState18(false);
30210
+ const [copiedOldFingerprint, setCopiedOldFingerprint] = useState18(false);
30185
30211
  if (!isOpen) return null;
30186
30212
  const copyFingerprint = (text, isOld = false) => {
30187
30213
  copyToClipboard(text);
@@ -30762,7 +30788,7 @@ function createQuickConnectHost(input) {
30762
30788
  }
30763
30789
 
30764
30790
  // src/ui/components/list-kit.tsx
30765
- import { useState as useState20 } from "react";
30791
+ import { useState as useState19 } from "react";
30766
30792
  import { useTranslation as useTranslation19 } from "react-i18next";
30767
30793
  import { jsx as jsx53, jsxs as jsxs36 } from "react/jsx-runtime";
30768
30794
  var STRIPE = {
@@ -30928,7 +30954,7 @@ function ListRowFolder({
30928
30954
  className,
30929
30955
  children
30930
30956
  }) {
30931
- const [dragOver, setDragOver] = useState20(false);
30957
+ const [dragOver, setDragOver] = useState19(false);
30932
30958
  const empty2 = count === 0;
30933
30959
  return /* @__PURE__ */ jsxs36("div", { className: cn("border-b border-border/40", className), children: [
30934
30960
  /* @__PURE__ */ jsxs36(
@@ -31195,7 +31221,7 @@ import {
31195
31221
  useLayoutEffect as useLayoutEffect3,
31196
31222
  useMemo as useMemo7,
31197
31223
  useRef as useRef7,
31198
- useState as useState21
31224
+ useState as useState20
31199
31225
  } from "react";
31200
31226
  import { jsx as jsx55 } from "react/jsx-runtime";
31201
31227
  var COLUMN_STEPS = {
@@ -31226,7 +31252,7 @@ function CardMasonry({
31226
31252
  children
31227
31253
  }) {
31228
31254
  const ref = useRef7(null);
31229
- const [width, setWidth] = useState21(0);
31255
+ const [width, setWidth] = useState20(0);
31230
31256
  useLayoutEffect3(() => {
31231
31257
  const el = ref.current;
31232
31258
  if (!el) return;
@@ -31315,7 +31341,7 @@ function UsagePair({
31315
31341
  }
31316
31342
 
31317
31343
  // src/ui/components/form-fields.tsx
31318
- import { useState as useState22 } from "react";
31344
+ import { useState as useState21 } from "react";
31319
31345
  import { useTranslation as useTranslation20 } from "react-i18next";
31320
31346
  import { jsx as jsx57, jsxs as jsxs39 } from "react/jsx-runtime";
31321
31347
  function Field2({
@@ -31658,7 +31684,7 @@ import {
31658
31684
  useContext as useContext5,
31659
31685
  useMemo as useMemo8,
31660
31686
  useRef as useRef8,
31661
- useState as useState23
31687
+ useState as useState22
31662
31688
  } from "react";
31663
31689
  import { useConnectionRetry } from "@termix-ssh/plugin-sdk/frontend";
31664
31690
  import { jsx as jsx58, jsxs as jsxs40 } from "react/jsx-runtime";
@@ -31689,11 +31715,11 @@ function Gate({
31689
31715
  children
31690
31716
  }) {
31691
31717
  const { addLog, clearLogs } = useConnectionLog();
31692
- const [error, setError] = useState23(null);
31693
- const [unavailable, setUnavailable] = useState23(
31718
+ const [error, setError] = useState22(null);
31719
+ const [unavailable, setUnavailable] = useState22(
31694
31720
  null
31695
31721
  );
31696
- const [dropped, setDropped] = useState23(false);
31722
+ const [dropped, setDropped] = useState22(false);
31697
31723
  const maxAttempts = retry?.maxAttempts ?? 8;
31698
31724
  const gateRef = useRef8(null);
31699
31725
  const write = useCallback8(
@@ -7,7 +7,7 @@ import {
7
7
  resetPermissionsCache,
8
8
  setPermissionsForTesting,
9
9
  usePermissions
10
- } from "./chunk-3KG44G55.js";
10
+ } from "./chunk-XKMRKQFN.js";
11
11
  import "./chunk-YC2MAKSP.js";
12
12
  export {
13
13
  hasPermission,
@@ -27,6 +27,13 @@ export declare function isApiCompatible(range: string): boolean;
27
27
  * core (2.9.0-beta.1) counts as that release.
28
28
  */
29
29
  export declare function isTermixCompatible(range: string, coreVersion: string | null): boolean;
30
+ export declare const MAX_FEATURES = 20;
31
+ export declare const MAX_FEATURE_LENGTH = 160;
32
+ /**
33
+ * The usable feature lines of a manifest or registry entry. Anything that is
34
+ * not a short non-empty string is dropped, so an index cannot flood the page.
35
+ */
36
+ export declare function pluginFeatures(value: unknown): string[];
30
37
  /**
31
38
  * The video id of a YouTube link, or null for anything else. Only the id is
32
39
  * ever used, so a manifest can never point the embed at another site.
@@ -416,6 +423,8 @@ export interface PluginManifest {
416
423
  icon?: string;
417
424
  /** A YouTube link shown at the top of the plugin's page. */
418
425
  video?: string;
426
+ /** Short lines listing what the plugin does, shown on its page. */
427
+ features?: string[];
419
428
  engine: PluginEngine;
420
429
  /** Catalog capability ids. */
421
430
  capabilities: string[];
package/dist/manifest.js CHANGED
@@ -47,6 +47,22 @@ const YOUTUBE_HOSTS = new Set([
47
47
  "youtube-nocookie.com",
48
48
  "www.youtube-nocookie.com",
49
49
  ]);
50
+ export const MAX_FEATURES = 20;
51
+ export const MAX_FEATURE_LENGTH = 160;
52
+ /**
53
+ * The usable feature lines of a manifest or registry entry. Anything that is
54
+ * not a short non-empty string is dropped, so an index cannot flood the page.
55
+ */
56
+ export function pluginFeatures(value) {
57
+ if (!Array.isArray(value))
58
+ return [];
59
+ return value
60
+ .filter((f) => typeof f === "string" &&
61
+ f.trim().length > 0 &&
62
+ f.length <= MAX_FEATURE_LENGTH)
63
+ .map((f) => f.trim())
64
+ .slice(0, MAX_FEATURES);
65
+ }
50
66
  /**
51
67
  * The video id of a YouTube link, or null for anything else. Only the id is
52
68
  * ever used, so a manifest can never point the embed at another site.
@@ -190,6 +206,7 @@ const ALLOWED_TOP_LEVEL = new Set([
190
206
  "repository",
191
207
  "icon",
192
208
  "video",
209
+ "features",
193
210
  "dependencies",
194
211
  "optionalDependencies",
195
212
  "provides",
@@ -319,6 +336,7 @@ export function validateManifest(manifest) {
319
336
  if ("video" in m && youtubeVideoId(m.video) === null) {
320
337
  errors.push(`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`);
321
338
  }
339
+ validateFeatures(m.features, errors);
322
340
  for (const field of ["backend", "frontend", "locales"]) {
323
341
  if (field in m && requireString(m[field], field, errors)) {
324
342
  requireRelativePath(m[field], field, errors);
@@ -343,6 +361,24 @@ export function validateManifest(manifest) {
343
361
  validateContributes(m.contributes, typeof m.id === "string" ? m.id : undefined, errors);
344
362
  return errors;
345
363
  }
364
+ function validateFeatures(features, errors) {
365
+ if (features === undefined)
366
+ return;
367
+ if (!Array.isArray(features)) {
368
+ errors.push('Field "features" must be an array of strings');
369
+ return;
370
+ }
371
+ if (features.length > MAX_FEATURES) {
372
+ errors.push(`Field "features" may list at most ${MAX_FEATURES} items`);
373
+ }
374
+ features.forEach((feature, i) => {
375
+ if (typeof feature !== "string" ||
376
+ feature.trim().length === 0 ||
377
+ feature.length > MAX_FEATURE_LENGTH) {
378
+ errors.push(`Field "features[${i}]" must be a non-empty string of at most ${MAX_FEATURE_LENGTH} characters`);
379
+ }
380
+ });
381
+ }
346
382
  function validateAuthor(author, errors) {
347
383
  if (author === undefined)
348
384
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termix-ssh/plugin-sdk",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -14,7 +14,7 @@
14
14
  "node": ">=22"
15
15
  },
16
16
  "bin": {
17
- "termix-plugin": "./cli/index.mjs"
17
+ "termix-plugin": "cli/index.mjs"
18
18
  },
19
19
  "exports": {
20
20
  "./backend": {
@@ -94,6 +94,7 @@
94
94
  "schema",
95
95
  "tsconfig.plugin.json",
96
96
  "README.md",
97
+ "CHANGELOG.md",
97
98
  "client.d.ts"
98
99
  ],
99
100
  "scripts": {
@@ -79,6 +79,12 @@
79
79
  "pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
80
80
  "description": "A YouTube link shown at the top of the plugin's page."
81
81
  },
82
+ "features": {
83
+ "type": "array",
84
+ "maxItems": 20,
85
+ "items": { "type": "string", "minLength": 1, "maxLength": 160 },
86
+ "description": "Short lines listing what the plugin does, shown on its page."
87
+ },
82
88
  "engine": {
83
89
  "type": "object",
84
90
  "additionalProperties": false,