@termix-ssh/plugin-sdk 1.0.0

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.
Files changed (236) hide show
  1. package/README.md +52 -0
  2. package/cli/commands/build.mjs +246 -0
  3. package/cli/commands/migrations.mjs +248 -0
  4. package/cli/commands/pack.mjs +42 -0
  5. package/cli/commands/sign.mjs +83 -0
  6. package/cli/commands/test.mjs +33 -0
  7. package/cli/commands/validate.mjs +215 -0
  8. package/cli/index.mjs +65 -0
  9. package/cli/lib/changelog.mjs +126 -0
  10. package/cli/lib/externals.mjs +46 -0
  11. package/cli/lib/plugin-dir.mjs +29 -0
  12. package/cli/lib/signing.mjs +86 -0
  13. package/cli/lib/static-url-imports.mjs +54 -0
  14. package/cli/lib/tmxplug.mjs +106 -0
  15. package/cli/patches/guacamole-common-js-flush.cjs +76 -0
  16. package/cli/patches/xterm-android-ime.cjs +130 -0
  17. package/client.d.ts +13 -0
  18. package/dist/backend.d.ts +1777 -0
  19. package/dist/backend.js +84 -0
  20. package/dist/capabilities.d.ts +38 -0
  21. package/dist/capabilities.js +71 -0
  22. package/dist/db.d.ts +112 -0
  23. package/dist/db.js +189 -0
  24. package/dist/ddl.d.ts +76 -0
  25. package/dist/ddl.js +678 -0
  26. package/dist/desktop.d.ts +346 -0
  27. package/dist/desktop.js +5 -0
  28. package/dist/frontend.d.ts +1206 -0
  29. package/dist/frontend.js +388 -0
  30. package/dist/host/chunk-F3SDZDDG.js +6017 -0
  31. package/dist/host/chunk-XGWE7BTJ.js +90 -0
  32. package/dist/host/chunk-XH5NXL6Y.js +56314 -0
  33. package/dist/host/chunk-YC2MAKSP.js +14 -0
  34. package/dist/host/linked-server-FZGWQW32.js +12 -0
  35. package/dist/host/rbac-api-6KDMBVDZ.js +52 -0
  36. package/dist/host/testing-host.js +414 -0
  37. package/dist/host/theme.css +91 -0
  38. package/dist/host/types/src/types/auth-protocols.d.ts +10 -0
  39. package/dist/host/types/src/types/connection-log.d.ts +13 -0
  40. package/dist/host/types/src/types/credential-sidebar-preferences.d.ts +38 -0
  41. package/dist/host/types/src/types/host-defaults.d.ts +60 -0
  42. package/dist/host/types/src/types/host-sidebar-preferences.d.ts +55 -0
  43. package/dist/host/types/src/types/index.d.ts +298 -0
  44. package/dist/host/types/src/types/keybindings.d.ts +27 -0
  45. package/dist/host/types/src/types/ui-preferences.d.ts +102 -0
  46. package/dist/host/types/src/types/ui-types.d.ts +260 -0
  47. package/dist/host/types/src/ui/api/admin-user-data-api.d.ts +11 -0
  48. package/dist/host/types/src/ui/api/auth-methods-api.d.ts +63 -0
  49. package/dist/host/types/src/ui/api/credential-sidebar-preferences-api.d.ts +3 -0
  50. package/dist/host/types/src/ui/api/credentials-api.d.ts +56 -0
  51. package/dist/host/types/src/ui/api/dashboard-api.d.ts +23 -0
  52. package/dist/host/types/src/ui/api/external-account-api.d.ts +8 -0
  53. package/dist/host/types/src/ui/api/host-sidebar-preferences-api.d.ts +3 -0
  54. package/dist/host/types/src/ui/api/host-status-api.d.ts +11 -0
  55. package/dist/host/types/src/ui/api/open-tabs-api.d.ts +74 -0
  56. package/dist/host/types/src/ui/api/plugins-api.d.ts +128 -0
  57. package/dist/host/types/src/ui/api/rbac-api.d.ts +167 -0
  58. package/dist/host/types/src/ui/api/settings-api.d.ts +22 -0
  59. package/dist/host/types/src/ui/api/ssh-host-management-api.d.ts +65 -0
  60. package/dist/host/types/src/ui/api/sync-api.d.ts +93 -0
  61. package/dist/host/types/src/ui/api/system-status-api.d.ts +49 -0
  62. package/dist/host/types/src/ui/api/ui-preferences-api.d.ts +8 -0
  63. package/dist/host/types/src/ui/api/user-management-api.d.ts +80 -0
  64. package/dist/host/types/src/ui/auth/Auth.d.ts +12 -0
  65. package/dist/host/types/src/ui/auth/auth-bits.d.ts +11 -0
  66. package/dist/host/types/src/ui/auth/silent-signin.d.ts +2 -0
  67. package/dist/host/types/src/ui/components/FullScreenAppWrapper.d.ts +9 -0
  68. package/dist/host/types/src/ui/components/alert-dialog.d.ts +14 -0
  69. package/dist/host/types/src/ui/components/alert.d.ts +9 -0
  70. package/dist/host/types/src/ui/components/badge.d.ts +9 -0
  71. package/dist/host/types/src/ui/components/button.d.ts +13 -0
  72. package/dist/host/types/src/ui/components/card-grid/CardGridCanvas.d.ts +22 -0
  73. package/dist/host/types/src/ui/components/card-grid/layout-utils.d.ts +26 -0
  74. package/dist/host/types/src/ui/components/card-grid/types.d.ts +29 -0
  75. package/dist/host/types/src/ui/components/card-masonry.d.ts +16 -0
  76. package/dist/host/types/src/ui/components/card.d.ts +11 -0
  77. package/dist/host/types/src/ui/components/charts/BarSeries.d.ts +16 -0
  78. package/dist/host/types/src/ui/components/charts/LineChart.d.ts +17 -0
  79. package/dist/host/types/src/ui/components/charts/RadialGauge.d.ts +18 -0
  80. package/dist/host/types/src/ui/components/charts/Sparkline.d.ts +22 -0
  81. package/dist/host/types/src/ui/components/charts/StatRow.d.ts +15 -0
  82. package/dist/host/types/src/ui/components/charts/geometry.d.ts +20 -0
  83. package/dist/host/types/src/ui/components/charts/index.d.ts +4 -0
  84. package/dist/host/types/src/ui/components/checkbox.d.ts +4 -0
  85. package/dist/host/types/src/ui/components/connection/ConnectionGate.d.ts +56 -0
  86. package/dist/host/types/src/ui/components/connection/ConnectionLogPanel.d.ts +9 -0
  87. package/dist/host/types/src/ui/components/connection/ConnectionScreen.d.ts +34 -0
  88. package/dist/host/types/src/ui/components/connection/connection-status.d.ts +1 -0
  89. package/dist/host/types/src/ui/components/data-view.d.ts +32 -0
  90. package/dist/host/types/src/ui/components/dialog.d.ts +17 -0
  91. package/dist/host/types/src/ui/components/dropdown-menu.d.ts +29 -0
  92. package/dist/host/types/src/ui/components/empty-state.d.ts +19 -0
  93. package/dist/host/types/src/ui/components/form-fields.d.ts +115 -0
  94. package/dist/host/types/src/ui/components/input.d.ts +3 -0
  95. package/dist/host/types/src/ui/components/kbd.d.ts +12 -0
  96. package/dist/host/types/src/ui/components/label.d.ts +4 -0
  97. package/dist/host/types/src/ui/components/manager-card.d.ts +25 -0
  98. package/dist/host/types/src/ui/components/meter.d.ts +14 -0
  99. package/dist/host/types/src/ui/components/metric-card.d.ts +18 -0
  100. package/dist/host/types/src/ui/components/panel-layout.d.ts +83 -0
  101. package/dist/host/types/src/ui/components/password-input.d.ts +4 -0
  102. package/dist/host/types/src/ui/components/popover.d.ts +7 -0
  103. package/dist/host/types/src/ui/components/scroll-area.d.ts +5 -0
  104. package/dist/host/types/src/ui/components/section-card.d.ts +29 -0
  105. package/dist/host/types/src/ui/components/select.d.ts +15 -0
  106. package/dist/host/types/src/ui/components/select2.d.ts +8 -0
  107. package/dist/host/types/src/ui/components/separator.d.ts +4 -0
  108. package/dist/host/types/src/ui/components/sheet.d.ts +14 -0
  109. package/dist/host/types/src/ui/components/sidebar.d.ts +69 -0
  110. package/dist/host/types/src/ui/components/skeleton.d.ts +2 -0
  111. package/dist/host/types/src/ui/components/slider.d.ts +4 -0
  112. package/dist/host/types/src/ui/components/sonner.d.ts +4 -0
  113. package/dist/host/types/src/ui/components/surface/prompt-styles.d.ts +4 -0
  114. package/dist/host/types/src/ui/components/surface/surface-scope.d.ts +79 -0
  115. package/dist/host/types/src/ui/components/switch.d.ts +4 -0
  116. package/dist/host/types/src/ui/components/tab-strip.d.ts +19 -0
  117. package/dist/host/types/src/ui/components/tabs.d.ts +7 -0
  118. package/dist/host/types/src/ui/components/textarea.d.ts +4 -0
  119. package/dist/host/types/src/ui/components/theme-provider.d.ts +14 -0
  120. package/dist/host/types/src/ui/components/tooltip.d.ts +7 -0
  121. package/dist/host/types/src/ui/contexts/BrandingContext.d.ts +15 -0
  122. package/dist/host/types/src/ui/contexts/UiPreferencesContext.d.ts +38 -0
  123. package/dist/host/types/src/ui/contexts/branding-document.d.ts +10 -0
  124. package/dist/host/types/src/ui/dashboard/dashboard-cards-registry.d.ts +30 -0
  125. package/dist/host/types/src/ui/hooks/use-action-slot.d.ts +10 -0
  126. package/dist/host/types/src/ui/hooks/use-adaptive-polling.d.ts +5 -0
  127. package/dist/host/types/src/ui/hooks/use-mobile.d.ts +1 -0
  128. package/dist/host/types/src/ui/hooks/use-permissions.d.ts +29 -0
  129. package/dist/host/types/src/ui/hooks/use-status-color-scheme.d.ts +12 -0
  130. package/dist/host/types/src/ui/hooks/use-sync-status.d.ts +4 -0
  131. package/dist/host/types/src/ui/hooks/useSshAuthProviders.d.ts +17 -0
  132. package/dist/host/types/src/ui/i18n/i18n.d.ts +52 -0
  133. package/dist/host/types/src/ui/lib/ServerStatusContext.d.ts +33 -0
  134. package/dist/host/types/src/ui/lib/adaptive-polling.d.ts +29 -0
  135. package/dist/host/types/src/ui/lib/adaptive-resource-budget.d.ts +39 -0
  136. package/dist/host/types/src/ui/lib/app-keyboard-shortcuts.d.ts +8 -0
  137. package/dist/host/types/src/ui/lib/base-path.d.ts +1 -0
  138. package/dist/host/types/src/ui/lib/clipboard-provider.d.ts +9 -0
  139. package/dist/host/types/src/ui/lib/clipboard.d.ts +20 -0
  140. package/dist/host/types/src/ui/lib/connection-origin.d.ts +47 -0
  141. package/dist/host/types/src/ui/lib/db-health-monitor.d.ts +34 -0
  142. package/dist/host/types/src/ui/lib/device-id.d.ts +1 -0
  143. package/dist/host/types/src/ui/lib/electron.d.ts +1 -0
  144. package/dist/host/types/src/ui/lib/embedded-server-status.d.ts +15 -0
  145. package/dist/host/types/src/ui/lib/error-message.d.ts +9 -0
  146. package/dist/host/types/src/ui/lib/frontend-logger.d.ts +67 -0
  147. package/dist/host/types/src/ui/lib/global-shortcut-handler.d.ts +3 -0
  148. package/dist/host/types/src/ui/lib/host-defaults-context.d.ts +48 -0
  149. package/dist/host/types/src/ui/lib/hosts-request-cache.d.ts +9 -0
  150. package/dist/host/types/src/ui/lib/keybinding-match.d.ts +3 -0
  151. package/dist/host/types/src/ui/lib/linked-server.d.ts +10 -0
  152. package/dist/host/types/src/ui/lib/local-adaptive-engine.d.ts +40 -0
  153. package/dist/host/types/src/ui/lib/permissions.d.ts +16 -0
  154. package/dist/host/types/src/ui/lib/plugin-i18n.d.ts +11 -0
  155. package/dist/host/types/src/ui/lib/plugin-icon.d.ts +11 -0
  156. package/dist/host/types/src/ui/lib/plugin-transport.d.ts +50 -0
  157. package/dist/host/types/src/ui/lib/registry.d.ts +29 -0
  158. package/dist/host/types/src/ui/lib/rem.d.ts +7 -0
  159. package/dist/host/types/src/ui/lib/remote-server-api.d.ts +11 -0
  160. package/dist/host/types/src/ui/lib/server-status-store.d.ts +37 -0
  161. package/dist/host/types/src/ui/lib/sync-events.d.ts +2 -0
  162. package/dist/host/types/src/ui/lib/sync-status.d.ts +6 -0
  163. package/dist/host/types/src/ui/lib/tab-jump-hotkey.d.ts +4 -0
  164. package/dist/host/types/src/ui/lib/theme.d.ts +21 -0
  165. package/dist/host/types/src/ui/lib/ttl-request-cache.d.ts +10 -0
  166. package/dist/host/types/src/ui/lib/usage-color.d.ts +5 -0
  167. package/dist/host/types/src/ui/lib/utils.d.ts +2 -0
  168. package/dist/host/types/src/ui/lib/visible-interval.d.ts +5 -0
  169. package/dist/host/types/src/ui/lib/widget-title.d.ts +8 -0
  170. package/dist/host/types/src/ui/lib/ws-auth.d.ts +1 -0
  171. package/dist/host/types/src/ui/main-axios.d.ts +170 -0
  172. package/dist/host/types/src/ui/plugin-host/PluginViewPlaceholder.d.ts +13 -0
  173. package/dist/host/types/src/ui/plugin-host/app.d.ts +18 -0
  174. package/dist/host/types/src/ui/plugin-host/auth-registry.d.ts +50 -0
  175. package/dist/host/types/src/ui/plugin-host/bridge.d.ts +26 -0
  176. package/dist/host/types/src/ui/plugin-host/component-registry.d.ts +24 -0
  177. package/dist/host/types/src/ui/plugin-host/desktop.d.ts +7 -0
  178. package/dist/host/types/src/ui/plugin-host/extension-registry.d.ts +16 -0
  179. package/dist/host/types/src/ui/plugin-host/host-scope.d.ts +26 -0
  180. package/dist/host/types/src/ui/plugin-host/loader.d.ts +52 -0
  181. package/dist/host/types/src/ui/plugin-host/plugin-store.d.ts +40 -0
  182. package/dist/host/types/src/ui/plugin-host/scope.d.ts +20 -0
  183. package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +99 -0
  184. package/dist/host/types/src/ui/plugin-host/shell-bridge.d.ts +67 -0
  185. package/dist/host/types/src/ui/plugin-host/view-ownership.d.ts +31 -0
  186. package/dist/host/types/src/ui/settings/HostPluginSections.d.ts +31 -0
  187. package/dist/host/types/src/ui/settings/SettingsFields.d.ts +22 -0
  188. package/dist/host/types/src/ui/settings/host-feature-tabs.d.ts +11 -0
  189. package/dist/host/types/src/ui/settings/settings-components.d.ts +32 -0
  190. package/dist/host/types/src/ui/settings/settings-fields-util.d.ts +11 -0
  191. package/dist/host/types/src/ui/shell/ActionSlot.d.ts +41 -0
  192. package/dist/host/types/src/ui/shell/TabContext.d.ts +30 -0
  193. package/dist/host/types/src/ui/shell/action-registry.d.ts +99 -0
  194. package/dist/host/types/src/ui/shell/keybinding-registry.d.ts +62 -0
  195. package/dist/host/types/src/ui/shell/palette-registry.d.ts +55 -0
  196. package/dist/host/types/src/ui/shell/panel-registry.d.ts +24 -0
  197. package/dist/host/types/src/ui/shell/tab-registry.d.ts +92 -0
  198. package/dist/host/types/src/ui/sidebar/HostManagerTabs.d.ts +65 -0
  199. package/dist/host/types/src/ui/sidebar/host-contributions.d.ts +72 -0
  200. package/dist/host/types/src/ui/sidebar/host-defaults/catalog.d.ts +17 -0
  201. package/dist/host/types/src/ui/sidebar/host-protocols.d.ts +49 -0
  202. package/dist/host/types/src/ui/sidebar/quick-connect-host.d.ts +21 -0
  203. package/dist/host/types/src/ui/sidebar/rail-items.d.ts +103 -0
  204. package/dist/host/types/src/ui/ssh/connection-log/ConnectionLogContext.d.ts +17 -0
  205. package/dist/host/types/src/ui/ssh/dialogs/BrowserSignInDialog.d.ts +18 -0
  206. package/dist/host/types/src/ui/ssh/dialogs/HostKeyVerificationDialog.d.ts +17 -0
  207. package/dist/host/types/src/ui/ssh/dialogs/PassphraseDialog.d.ts +15 -0
  208. package/dist/host/types/src/ui/ssh/dialogs/SSHAuthDialog.d.ts +20 -0
  209. package/dist/host/types/src/ui/ssh/dialogs/TOTPDialog.d.ts +13 -0
  210. package/dist/host/ui.d.ts +1 -0
  211. package/dist/host/ui.js +31754 -0
  212. package/dist/host/use-permissions-NULZL43J.js +18 -0
  213. package/dist/host-commands.d.ts +89 -0
  214. package/dist/host-commands.js +387 -0
  215. package/dist/manifest.d.ts +461 -0
  216. package/dist/manifest.js +1499 -0
  217. package/dist/settings.d.ts +43 -0
  218. package/dist/settings.js +168 -0
  219. package/dist/ssh-certs.d.ts +25 -0
  220. package/dist/ssh-certs.js +234 -0
  221. package/dist/ssh-options.d.ts +23 -0
  222. package/dist/ssh-options.js +1 -0
  223. package/dist/table-builder.d.ts +26 -0
  224. package/dist/table-builder.js +87 -0
  225. package/dist/testing/safe-ports.d.ts +10 -0
  226. package/dist/testing/safe-ports.js +59 -0
  227. package/dist/testing/setup.d.ts +7 -0
  228. package/dist/testing/setup.js +146 -0
  229. package/dist/testing.d.ts +537 -0
  230. package/dist/testing.js +1451 -0
  231. package/dist/vitest-preset.d.ts +54 -0
  232. package/dist/vitest-preset.js +96 -0
  233. package/package.json +143 -0
  234. package/schema/changelog.schema.json +67 -0
  235. package/schema/manifest.schema.json +1160 -0
  236. package/tsconfig.plugin.json +31 -0
@@ -0,0 +1,1451 @@
1
+ /**
2
+ * Test helpers for plugin authors.
3
+ *
4
+ * createMockCtx is the one to reach for: it enforces capabilities the way the
5
+ * real runtime does. createFakeContext stays for tests that only need a
6
+ * context-shaped object and do not care about the gates.
7
+ *
8
+ * The vitest config these run under comes from @termix-ssh/plugin-sdk/vitest-preset.
9
+ */
10
+ import { LoginMethodError, PLUGIN_HOST_INPUT_KEYS, PluginCapabilityError, } from "./backend.js";
11
+ export function createFakeProcess(pid = 4242) {
12
+ const stdout = [];
13
+ const stderr = [];
14
+ const killed = [];
15
+ let resolveExit = () => { };
16
+ const exited = new Promise((resolve) => {
17
+ resolveExit = resolve;
18
+ });
19
+ return {
20
+ pid,
21
+ onStdout: (listener) => void stdout.push(listener),
22
+ onStderr: (listener) => void stderr.push(listener),
23
+ exited,
24
+ kill: (signal = "SIGTERM") => {
25
+ killed.push(signal);
26
+ resolveExit({ code: null, signal });
27
+ },
28
+ emitStdout: (chunk) => stdout.forEach((listener) => listener(chunk)),
29
+ emitStderr: (chunk) => stderr.forEach((listener) => listener(chunk)),
30
+ exit: (code, signal = null) => resolveExit({ code, signal }),
31
+ killed,
32
+ };
33
+ }
34
+ const CORE_PERMISSION_GROUPS = new Set([
35
+ "hosts",
36
+ "snippets",
37
+ "credentials",
38
+ "admin",
39
+ ]);
40
+ /**
41
+ * A short name takes the plugin's prefix. An id already under this plugin or
42
+ * a core group is used as given, like src/backend/plugins/rbac.ts does.
43
+ */
44
+ function qualifyPermission(pluginId, permission) {
45
+ const head = permission.split(".")[0];
46
+ if (head === pluginId || CORE_PERMISSION_GROUPS.has(head))
47
+ return permission;
48
+ return `${pluginId}.${permission}`;
49
+ }
50
+ /** How a settings row is keyed in the doubles. Mirrors the real unique index. */
51
+ function settingsKey(scope, scopeId, key) {
52
+ return `${scope}:${scopeId ?? ""}:${key}`;
53
+ }
54
+ function noopLogger() {
55
+ return {
56
+ debug: () => { },
57
+ info: () => { },
58
+ warn: () => { },
59
+ error: () => { },
60
+ };
61
+ }
62
+ const HOST_INPUT_KEYS = new Set(PLUGIN_HOST_INPUT_KEYS);
63
+ /** Refuses a key ctx.hosts does not take, the way core does. */
64
+ function checkHostInput(input) {
65
+ const unknown = Object.keys(input ?? {}).filter((key) => !HOST_INPUT_KEYS.has(key));
66
+ if (unknown.length > 0) {
67
+ throw new Error(`ctx.hosts does not take ${unknown.join(", ")}`);
68
+ }
69
+ }
70
+ function emptyHostRecord(id, userId) {
71
+ return {
72
+ id,
73
+ userId,
74
+ syncId: null,
75
+ name: null,
76
+ ip: "",
77
+ port: 22,
78
+ username: "",
79
+ authType: "password",
80
+ credentialId: null,
81
+ overrideCredentialUsername: false,
82
+ connectionType: "ssh",
83
+ tags: null,
84
+ folder: null,
85
+ parentHostId: null,
86
+ pin: false,
87
+ notes: null,
88
+ jumpHosts: [],
89
+ enableSsh: true,
90
+ sshPort: null,
91
+ statusCheckEnabled: true,
92
+ statusCheckInterval: null,
93
+ connectionOrigin: null,
94
+ sshOptions: {},
95
+ pluginSettings: {},
96
+ status: null,
97
+ localOnly: false,
98
+ createdAt: null,
99
+ updatedAt: null,
100
+ };
101
+ }
102
+ function applyHostInput(record, input) {
103
+ const { tags, pluginSettings: _settings, forceKeyboardInteractive: _fki, ...rest } = input;
104
+ const next = { ...record };
105
+ for (const [key, value] of Object.entries(rest)) {
106
+ if (value !== undefined)
107
+ next[key] = value;
108
+ }
109
+ if (tags !== undefined) {
110
+ next.tags = Array.isArray(tags) ? tags.join(",") : tags;
111
+ }
112
+ if (next.jumpHosts === null)
113
+ next.jumpHosts = [];
114
+ if (next.sshOptions === null)
115
+ next.sshOptions = {};
116
+ return next;
117
+ }
118
+ /**
119
+ * A context double good enough to call activate() against in a unit test.
120
+ * Nothing here is capability-checked: a test asserting a capability gate
121
+ * should use the real runtime, not this.
122
+ */
123
+ export function createFakeContext(options = {}) {
124
+ const pluginId = options.pluginId ?? "test-plugin";
125
+ const disposals = [];
126
+ const emitted = [];
127
+ const kv = new Map();
128
+ const tables = [];
129
+ const syncEntities = [];
130
+ const wsRoutes = [];
131
+ const httpRouters = [];
132
+ const listeners = new Map();
133
+ const settings = new Map();
134
+ const coreSettings = new Map(Object.entries(options.coreSettings ?? {}));
135
+ const settingsListeners = new Map();
136
+ const settingsValidators = new Set();
137
+ const sshConnections = [];
138
+ const hostShares = [];
139
+ const desktopWindows = [];
140
+ const externalClientLaunches = [];
141
+ const credentialReads = [];
142
+ const createdSshKeys = [];
143
+ const notifications = [];
144
+ const notifyHub = { current: null };
145
+ const fetches = [];
146
+ const processRuns = [];
147
+ const binaries = [];
148
+ const tls = {
149
+ writes: [],
150
+ reloads: 0,
151
+ calls: [],
152
+ challenges: new Map(),
153
+ renewers: 0,
154
+ };
155
+ let tlsState = options.tlsStatus ?? {
156
+ enabled: true,
157
+ certificate: null,
158
+ };
159
+ const secretStore = new Map();
160
+ const audits = [];
161
+ const activities = [];
162
+ const trackedSessions = [];
163
+ const interactions = [];
164
+ const statusReports = [];
165
+ const statusPorts = new Map();
166
+ const hostStatuses = new Map(Object.entries(options.hostStatuses ?? {}).map(([id, entry]) => [
167
+ Number(id),
168
+ entry,
169
+ ]));
170
+ const droppedPools = [];
171
+ const scheduled = [];
172
+ const schedule = (kind, ms, fn) => {
173
+ const job = { kind, ms, fn, stopped: false };
174
+ scheduled.push(job);
175
+ const stop = () => {
176
+ job.stopped = true;
177
+ };
178
+ disposals.push(stop);
179
+ return stop;
180
+ };
181
+ const hostsById = new Map((options.hosts ?? []).map((h) => [h.id, h]));
182
+ const hostRecordsById = new Map();
183
+ let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1;
184
+ const services = new Map(Object.entries(options.services ?? {}));
185
+ const serviceKey = (service, name) => name ? `${service}#${name}` : service;
186
+ const registryProviders = new Map();
187
+ const auth = {
188
+ sshAuthProviders: [],
189
+ keyboardInteractiveHandlers: [],
190
+ loginMethods: [],
191
+ secondFactors: [],
192
+ enrollments: new Set(),
193
+ secretResolvers: new Map(),
194
+ completedLogins: [],
195
+ revokedSessions: [],
196
+ loginFailures: new Map(),
197
+ };
198
+ const sshClient = options.sshClient ?? {};
199
+ let actor = options.actor;
200
+ const held = options.permissions
201
+ ? new Set(options.permissions.map((permission) => qualifyPermission(pluginId, permission)))
202
+ : null;
203
+ const holds = (permission) => held === null || held.has(qualifyPermission(pluginId, permission));
204
+ // Like the runtime, a route can only require a permission the manifest
205
+ // declares. Checked when the test hands in a manifest.
206
+ const declaredPermissions = options.manifest?.contributes?.permissions
207
+ ? new Set(options.manifest.contributes.permissions.map((entry) => qualifyPermission(pluginId, entry.name)))
208
+ : null;
209
+ for (const [key, value] of Object.entries(options.settings ?? {})) {
210
+ settings.set(settingsKey("admin", undefined, key), value);
211
+ }
212
+ const writeSetting = (scope, scopeId, key, value) => {
213
+ settings.set(settingsKey(scope, scopeId, key), value);
214
+ for (const listener of settingsListeners.get(key) ?? [])
215
+ listener(value);
216
+ };
217
+ const manifest = {
218
+ id: pluginId,
219
+ name: pluginId,
220
+ version: "1.0.0",
221
+ description: "",
222
+ author: { name: "test" },
223
+ license: "MIT",
224
+ category: "Productivity",
225
+ engine: { termix: ">=2.9.0", api: "1" },
226
+ capabilities: [],
227
+ ...options.manifest,
228
+ };
229
+ const disposables = {
230
+ add: (dispose) => disposals.push(dispose),
231
+ };
232
+ const ctx = {
233
+ pluginId,
234
+ manifest,
235
+ log: noopLogger(),
236
+ events: {
237
+ emit: (topic, payload) => {
238
+ emitted.push({ topic, payload });
239
+ for (const listener of listeners.get(topic) ?? [])
240
+ listener(payload);
241
+ },
242
+ on: (topic, listener) => {
243
+ let set = listeners.get(topic);
244
+ if (!set) {
245
+ set = new Set();
246
+ listeners.set(topic, set);
247
+ }
248
+ set.add(listener);
249
+ return () => set.delete(listener);
250
+ },
251
+ },
252
+ kv: {
253
+ get: async (key) => (kv.has(key) ? kv.get(key) : null),
254
+ set: async (key, value) => {
255
+ kv.set(key, value);
256
+ },
257
+ delete: async (key) => kv.delete(key),
258
+ list: async () => [...kv.keys()],
259
+ },
260
+ files: {
261
+ // A fake path; a test that needs a real folder uses the OS temp dir
262
+ // itself, not this double.
263
+ dataDir: async () => `/tmp/${pluginId}`,
264
+ },
265
+ db: options.db
266
+ ? {
267
+ ...options.db,
268
+ define: async (definition) => {
269
+ tables.push(definition);
270
+ return options.db.define(definition);
271
+ },
272
+ client: () => options.db.client(),
273
+ refs: () => options.db.refs(),
274
+ persist: () => options.db.persist(),
275
+ dialect: options.db.dialect,
276
+ }
277
+ : {
278
+ // No engine behind this one. Pass createTestDb().database as `db`
279
+ // for a test that needs real SQL.
280
+ define: async (definition) => {
281
+ tables.push(definition);
282
+ return undefined;
283
+ },
284
+ client: async () => {
285
+ throw new Error("createFakeContext has no database: pass createTestDb().database as db");
286
+ },
287
+ refs: async () => {
288
+ throw new Error("createFakeContext has no database: pass createTestDb().database as db");
289
+ },
290
+ persist: async () => { },
291
+ dialect: "sqlite",
292
+ },
293
+ sync: {
294
+ registerEntity: (entity) => {
295
+ syncEntities.push(entity);
296
+ },
297
+ },
298
+ registry: {
299
+ provide: (key, value) => {
300
+ assertOwnRegistryKey(pluginId, key);
301
+ registryProviders.set(key, value);
302
+ },
303
+ consume: (key) => registryProviders.get(key),
304
+ revoke: (key, value) => {
305
+ assertOwnRegistryKey(pluginId, key);
306
+ if (!registryProviders.has(key))
307
+ return false;
308
+ if (value !== undefined && registryProviders.get(key) !== value) {
309
+ return false;
310
+ }
311
+ return registryProviders.delete(key);
312
+ },
313
+ },
314
+ services: {
315
+ provide: (service, implementation, provideOptions) => {
316
+ services.set(serviceKey(service, provideOptions?.name), implementation);
317
+ },
318
+ // What this context provided or the test seeded through `services`.
319
+ // Like the real handle, a missing provider is an empty object, which a
320
+ // consumer's `"method" in handle` probe reads as absent.
321
+ get: (service, getOptions) => (services.get(serviceKey(service, getOptions?.provider)) ??
322
+ {}),
323
+ providers: (service) => [...services.keys()].flatMap((key) => key === service
324
+ ? [""]
325
+ : key.startsWith(`${service}#`)
326
+ ? [key.slice(service.length + 1)]
327
+ : []),
328
+ },
329
+ secrets: {
330
+ get: async (key) => secretStore.get(`${actor ?? ""}:${key}`) ?? null,
331
+ set: async (key, value) => {
332
+ if (value === null)
333
+ secretStore.delete(`${actor ?? ""}:${key}`);
334
+ else
335
+ secretStore.set(`${actor ?? ""}:${key}`, value);
336
+ },
337
+ delete: async (key) => {
338
+ secretStore.delete(`${actor ?? ""}:${key}`);
339
+ },
340
+ seal: async (value) => `sealed:${Buffer.from(value, "utf8").toString("base64")}`,
341
+ unseal: async (sealed) => sealed?.startsWith("sealed:")
342
+ ? Buffer.from(sealed.slice(7), "base64").toString("utf8")
343
+ : null,
344
+ offer: () => { },
345
+ withdraw: () => false,
346
+ getShared: async () => null,
347
+ },
348
+ http: {
349
+ // No express here: a test that needs a real router should mount the
350
+ // plugin's own route module against its own express app.
351
+ router: (routerOptions) => {
352
+ httpRouters.push(routerOptions);
353
+ return (options.router ? options.router() : undefined);
354
+ },
355
+ baseUrl: () => options.baseUrl ?? "https://termix.test",
356
+ },
357
+ ws: {
358
+ route: (path, handler, wsOptions) => {
359
+ wsRoutes.push({ path, raw: false, handler, options: wsOptions });
360
+ },
361
+ upgrade: (path, handler, wsOptions) => {
362
+ wsRoutes.push({
363
+ path,
364
+ raw: true,
365
+ upgrade: handler,
366
+ options: wsOptions,
367
+ });
368
+ },
369
+ },
370
+ rbac: {
371
+ // Enforces options.permissions for the acting user, or passes
372
+ // everything when none were given. The runtime's own resolution against
373
+ // other plugins' namespaces is covered by core's tests, not here.
374
+ has: async (permission) => holds(permission),
375
+ hasFor: async (_userId, permission) => holds(permission),
376
+ require: (permission) => {
377
+ if (declaredPermissions &&
378
+ !declaredPermissions.has(qualifyPermission(pluginId, permission))) {
379
+ throw new Error(`Plugin ${pluginId} cannot require permission "${permission}": it is not declared in contributes.permissions`);
380
+ }
381
+ return ((_req, res, next) => {
382
+ if (holds(permission)) {
383
+ next();
384
+ return;
385
+ }
386
+ res.status(403).json({
387
+ error: "Insufficient permissions",
388
+ required: qualifyPermission(pluginId, permission),
389
+ });
390
+ });
391
+ },
392
+ },
393
+ // Nothing here is capability-checked, per the module doc; createMockCtx
394
+ // gates this against options.capabilities.
395
+ capabilities: {
396
+ has: async () => true,
397
+ require: async () => { },
398
+ },
399
+ settings: {
400
+ get: async (key) => settings.get(settingsKey("admin", undefined, key)),
401
+ set: async (key, value) => writeSetting("admin", undefined, key, value),
402
+ getUser: async (userId, key) => settings.get(settingsKey("user", userId, key)),
403
+ setUser: async (userId, key, value) => writeSetting("user", userId, key, value),
404
+ getHost: async (hostId, key) => settings.get(settingsKey("host", String(hostId), key)),
405
+ getHostFor: async (hostId, _userId, key) => settings.get(settingsKey("host", String(hostId), key)),
406
+ // Host defaults live in core; the mock stands in the user's own
407
+ // setting of the same key, so a test can set one with setUser.
408
+ getHostDefault: async (userId, key) => settings.get(settingsKey("user", userId, key)),
409
+ setHost: async (hostId, key, value, options) => options?.inherit
410
+ ? void settings.delete(settingsKey("host", String(hostId), key))
411
+ : writeSetting("host", String(hostId), key, value),
412
+ listHostValues: async (key) => {
413
+ const found = [];
414
+ for (const [storedKey, value] of settings) {
415
+ const [scope, scopeId, name] = storedKey.split(":");
416
+ if (scope !== "host" || name !== key || value === undefined)
417
+ continue;
418
+ const hostId = Number(scopeId);
419
+ found.push({
420
+ hostId,
421
+ userId: hostRecordsById.get(hostId)?.userId ?? actor ?? "",
422
+ value: value,
423
+ });
424
+ }
425
+ return found;
426
+ },
427
+ getAll: async (scope, scopeId) => {
428
+ const prefix = `${scope}:${scopeId ?? ""}:`;
429
+ const all = {};
430
+ for (const [storedKey, value] of settings) {
431
+ if (storedKey.startsWith(prefix)) {
432
+ all[storedKey.slice(prefix.length)] = value;
433
+ }
434
+ }
435
+ return all;
436
+ },
437
+ onChange: (key, listener) => {
438
+ let set = settingsListeners.get(key);
439
+ if (!set) {
440
+ set = new Set();
441
+ settingsListeners.set(key, set);
442
+ }
443
+ set.add(listener);
444
+ const unsubscribe = () => {
445
+ set.delete(listener);
446
+ };
447
+ disposals.push(unsubscribe);
448
+ return unsubscribe;
449
+ },
450
+ onValidate: (scope, validator) => {
451
+ const entry = { scope, validator };
452
+ settingsValidators.add(entry);
453
+ const unsubscribe = () => {
454
+ settingsValidators.delete(entry);
455
+ };
456
+ disposals.push(unsubscribe);
457
+ return unsubscribe;
458
+ },
459
+ readCore: async (key) => coreSettings.get(key) ?? null,
460
+ },
461
+ disposables,
462
+ hosts: {
463
+ list: async () => [...hostsById.values()],
464
+ get: async (hostId) => hostsById.get(hostId) ?? null,
465
+ checkAccess: async (hostId) => {
466
+ const found = hostsById.get(hostId);
467
+ if (!found)
468
+ return { hasAccess: false, isOwner: false, isShared: false };
469
+ const isOwner = found.userId === actor;
470
+ return {
471
+ hasAccess: true,
472
+ isOwner,
473
+ isShared: !isOwner,
474
+ permissionLevel: "manage",
475
+ };
476
+ },
477
+ create: async (host) => {
478
+ checkHostInput(host);
479
+ const id = nextHostId++;
480
+ const record = applyHostInput(emptyHostRecord(id, actor ?? "unknown"), host);
481
+ hostRecordsById.set(id, record);
482
+ return record;
483
+ },
484
+ update: async (hostId, patch) => {
485
+ checkHostInput(patch);
486
+ const existing = hostRecordsById.get(hostId);
487
+ if (!existing)
488
+ return null;
489
+ const updated = applyHostInput(existing, patch);
490
+ hostRecordsById.set(hostId, updated);
491
+ return updated;
492
+ },
493
+ delete: async (hostId) => {
494
+ const existed = hostRecordsById.delete(hostId) || hostsById.delete(hostId);
495
+ return existed;
496
+ },
497
+ listOwned: async () => [
498
+ ...hostRecordsById.values(),
499
+ ],
500
+ share: async (hostId, targets, permissionLevel, durationHours) => {
501
+ hostShares.push({ hostId, targets, permissionLevel, durationHours });
502
+ return { hostId, shared: true };
503
+ },
504
+ listUsers: async () => options.shareableUsers ?? [],
505
+ listRoles: async () => options.shareableRoles ?? [],
506
+ trackSession: (hostId) => {
507
+ trackedSessions.push(hostId);
508
+ let active = true;
509
+ return () => {
510
+ if (!active)
511
+ return;
512
+ active = false;
513
+ const index = trackedSessions.indexOf(hostId);
514
+ if (index >= 0)
515
+ trackedSessions.splice(index, 1);
516
+ };
517
+ },
518
+ recordActivity: async (hostId, type, hostName) => {
519
+ activities.push({ hostId, type, hostName });
520
+ },
521
+ status: {
522
+ get: async (hostId) => hostStatuses.get(hostId) ?? null,
523
+ check: async (hostId) => hostStatuses.get(hostId) ?? null,
524
+ reportLogin: (hostId, outcome) => {
525
+ statusReports.push({ hostId, ...outcome });
526
+ },
527
+ registerPort: (connectionType, resolve) => {
528
+ statusPorts.set(connectionType, resolve);
529
+ return () => {
530
+ statusPorts.delete(connectionType);
531
+ };
532
+ },
533
+ },
534
+ },
535
+ schedule: {
536
+ every: (intervalMs, fn, scheduleOptions) => {
537
+ const stop = schedule("every", intervalMs, fn);
538
+ if (scheduleOptions?.runNow)
539
+ void fn();
540
+ return stop;
541
+ },
542
+ after: (delayMs, fn) => schedule("after", delayMs, fn),
543
+ },
544
+ audit: {
545
+ record: async (entry) => {
546
+ audits.push({ ...entry });
547
+ },
548
+ },
549
+ ssh: {
550
+ connect: async (host, connectOptions) => {
551
+ sshConnections.push({ host, options: connectOptions });
552
+ return {
553
+ client: sshClient,
554
+ jumpClient: null,
555
+ host: (typeof host === "number"
556
+ ? { id: host, ip: "", port: 22, username: "" }
557
+ : host),
558
+ dispose: () => { },
559
+ };
560
+ },
561
+ withConnection: async (host, connectOptions, fn) => {
562
+ sshConnections.push({ host, pool: connectOptions.pool });
563
+ return fn(sshClient);
564
+ },
565
+ jumpChain: async () => ({
566
+ client: sshClient,
567
+ jumpClient: null,
568
+ host: { id: 0, ip: "", port: 22, username: "" },
569
+ dispose: () => { },
570
+ }),
571
+ dropPooled: (pool, hostId) => {
572
+ droppedPools.push({ pool, hostId });
573
+ },
574
+ poolKey: (pool, host) => `${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}`,
575
+ resolveHost: async (hostId, resolveOptions) => (options.sshHosts ?? []).find((host) => resolveOptions?.syncId
576
+ ? host.syncId === resolveOptions.syncId
577
+ : host.id === hostId) ?? null,
578
+ prepare: async () => ({
579
+ config: {},
580
+ outcome: { status: "ready" },
581
+ authType: null,
582
+ }),
583
+ openTransport: async () => ({ jumpClient: null, via: "direct" }),
584
+ startInteraction: async (interaction, request) => {
585
+ interactions.push({ action: "start", interaction, request });
586
+ },
587
+ cancelInteraction: async (interaction, request) => {
588
+ interactions.push({ action: "cancel", interaction, request });
589
+ },
590
+ classifyKeyboardInteractive: ({ prompts }, host) => ({
591
+ kind: "auto",
592
+ responses: prompts.map((p) => /password/i.test(p.prompt) && typeof host.password === "string"
593
+ ? host.password
594
+ : ""),
595
+ }),
596
+ autoResponses: (prompts, password) => prompts.map((p) => /password/i.test(p.prompt) && password ? password : ""),
597
+ requiresSecret: (authType) => ["password", "key", "credential", "agent"].includes(authType),
598
+ // Matches core: the built-in types, plus any registered provider that
599
+ // does not opt out. An unknown type cannot connect unattended.
600
+ supportsBackground: (authType) => {
601
+ if (["password", "key", "credential", "agent"].includes(authType)) {
602
+ return true;
603
+ }
604
+ const provider = auth.sshAuthProviders.find((candidate) => candidate.type === authType);
605
+ return !!provider && provider.supportsBackground !== false;
606
+ },
607
+ },
608
+ auth: {
609
+ registerSshAuthProvider: (provider) => {
610
+ auth.sshAuthProviders.push(provider);
611
+ },
612
+ registerKeyboardInteractiveHandler: (handler) => {
613
+ auth.keyboardInteractiveHandlers.push(handler);
614
+ },
615
+ registerLoginMethod: (method) => {
616
+ auth.loginMethods.push(method);
617
+ },
618
+ registerSecondFactor: (factor) => {
619
+ auth.secondFactors.push(factor);
620
+ },
621
+ recordEnrollment: async (userId, factorId) => {
622
+ if (options.refuseEnrollment) {
623
+ throw new LoginMethodError(options.refuseEnrollment, 409);
624
+ }
625
+ auth.enrollments.add(`${userId}:${factorId}`);
626
+ },
627
+ removeEnrollment: async (userId, factorId) => {
628
+ auth.enrollments.delete(`${userId}:${factorId}`);
629
+ },
630
+ // Stands in for core's pipeline: answers with the identity as JSON, or
631
+ // redirects back with the error the way core does.
632
+ completeRedirectLogin: async (methodId, req, res) => {
633
+ const response = res;
634
+ const method = auth.loginMethods.find((candidate) => candidate.id === methodId);
635
+ if (!method?.callback) {
636
+ throw new Error(`No registered redirect method "${methodId}"`);
637
+ }
638
+ try {
639
+ const identity = await method.callback(req);
640
+ auth.completedLogins.push({ methodId, identity });
641
+ response.status(200).json({ completed: methodId, identity });
642
+ }
643
+ catch (error) {
644
+ const returnTo = error.returnTo;
645
+ const message = error.message;
646
+ if (returnTo) {
647
+ const url = new URL(returnTo);
648
+ url.searchParams.set("error", error.code ?? message);
649
+ response.redirect(url.toString());
650
+ return;
651
+ }
652
+ response
653
+ .status(error.status ?? 500)
654
+ .json({ error: message });
655
+ }
656
+ },
657
+ revokeSessions: async (match) => {
658
+ if (!match.sub && !match.sid)
659
+ return 0;
660
+ auth.revokedSessions.push(match);
661
+ return 1;
662
+ },
663
+ loginRateLimit: {
664
+ isLocked: async (ip, key) => {
665
+ const failures = auth.loginFailures.get(`${ip}|${key}`) ?? 0;
666
+ return failures >= (options.loginAttemptLimit ?? 5)
667
+ ? { locked: true, remainingTime: 60 }
668
+ : { locked: false };
669
+ },
670
+ recordFailure: async (ip, key) => {
671
+ const id = `${ip}|${key}`;
672
+ auth.loginFailures.set(id, (auth.loginFailures.get(id) ?? 0) + 1);
673
+ },
674
+ },
675
+ countLinkedUsers: async (provider) => options.linkedUsers?.[provider] ?? 0,
676
+ },
677
+ desktop: {
678
+ openIsolatedWindow: async (request) => {
679
+ desktopWindows.push(request);
680
+ return { success: true };
681
+ },
682
+ launchExternalClient: async (request) => {
683
+ externalClientLaunches.push(request);
684
+ return { success: true };
685
+ },
686
+ available: () => options.desktopAvailable ?? false,
687
+ },
688
+ credentials: {
689
+ listSshKeys: async () => [
690
+ ...(options.sshKeyCredentials ?? []),
691
+ ...createdSshKeys.map((key) => ({
692
+ id: key.id,
693
+ name: key.name,
694
+ username: key.username ?? null,
695
+ publicKey: key.publicKey,
696
+ })),
697
+ ],
698
+ createSshKey: async (input) => {
699
+ const id = Math.max(0, ...(options.sshKeyCredentials ?? []).map((key) => key.id), ...createdSshKeys.map((key) => key.id)) + 1;
700
+ createdSshKeys.push({ ...input, id });
701
+ return { id };
702
+ },
703
+ resolveHostProtocol: async (hostId, protocol) => {
704
+ credentialReads.push({ hostId, protocol });
705
+ return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
706
+ },
707
+ registerSecretResolver: (scheme, resolve) => {
708
+ auth.secretResolvers.set(scheme, resolve);
709
+ disposals.push(() => {
710
+ auth.secretResolvers.delete(scheme);
711
+ });
712
+ },
713
+ },
714
+ notify: {
715
+ channels: async () => {
716
+ if (!actor)
717
+ throw new Error("ctx.notify needs an acting user");
718
+ return options.notificationChannels ?? [];
719
+ },
720
+ send: async (notification) => {
721
+ if (!notification.audience && !actor) {
722
+ throw new Error("ctx.notify needs an acting user or an audience");
723
+ }
724
+ notifications.push({ actor, notification });
725
+ const channelIds = notification.channelIds ?? [];
726
+ const selected = (options.notificationChannels ?? []).filter((channel) => channelIds.includes(channel.id) && channel.enabled);
727
+ return { recipients: 1, delivered: selected.length, failures: [] };
728
+ },
729
+ serve: (hub) => {
730
+ if (notifyHub.current)
731
+ throw new Error("A hub is already running");
732
+ notifyHub.current = hub;
733
+ const revoke = () => {
734
+ if (notifyHub.current === hub)
735
+ notifyHub.current = null;
736
+ };
737
+ disposals.push(revoke);
738
+ return revoke;
739
+ },
740
+ },
741
+ fetch: async (url, init) => {
742
+ fetches.push({ url, init });
743
+ if (!options.fetch)
744
+ throw new Error("ctx.fetch is not stubbed");
745
+ return options.fetch(url, init);
746
+ },
747
+ process: {
748
+ run: async (file, args, runOptions) => {
749
+ processRuns.push({ file, args, options: runOptions });
750
+ if (!options.process?.run) {
751
+ throw new Error("ctx.process.run is not stubbed");
752
+ }
753
+ return options.process.run(file, args, runOptions);
754
+ },
755
+ ensureBinary: async (spec) => {
756
+ binaries.push(spec);
757
+ if (options.process?.ensureBinary) {
758
+ return options.process.ensureBinary(spec);
759
+ }
760
+ return `/tmp/${pluginId}/bin/${spec.name}`;
761
+ },
762
+ },
763
+ plugins: {
764
+ list: async () => options.installedPlugins ?? [
765
+ {
766
+ id: pluginId,
767
+ version: "1.0.0",
768
+ source: "bundled",
769
+ state: "active",
770
+ },
771
+ ],
772
+ },
773
+ system: {
774
+ tlsStatus: async () => {
775
+ tls.calls.push("tlsStatus");
776
+ return {
777
+ ...tlsState,
778
+ renewal: tls.renewers > 0 ? { pluginId, pluginName: pluginId } : null,
779
+ };
780
+ },
781
+ writeTlsCertificate: async (certificatePem, privateKeyPem) => {
782
+ tls.calls.push("writeTlsCertificate");
783
+ const info = options.validateTls
784
+ ? options.validateTls(certificatePem, privateKeyPem)
785
+ : {
786
+ subject: "CN=fake",
787
+ issuer: "CN=fake-ca",
788
+ names: [],
789
+ notBefore: new Date().toISOString(),
790
+ notAfter: new Date(Date.now() + 90 * 86_400_000).toISOString(),
791
+ selfSigned: false,
792
+ fingerprint: "00",
793
+ };
794
+ tls.writes.push({ certificatePem, privateKeyPem });
795
+ tlsState = { ...tlsState, certificate: info };
796
+ return info;
797
+ },
798
+ reloadTls: async () => {
799
+ tls.calls.push("reloadTls");
800
+ tls.reloads++;
801
+ return { applied: true, message: "reloaded" };
802
+ },
803
+ publishHttpChallenge: async (token, content) => {
804
+ tls.calls.push("publishHttpChallenge");
805
+ tls.challenges.set(token, content);
806
+ let live = true;
807
+ const remove = () => {
808
+ if (!live)
809
+ return;
810
+ live = false;
811
+ tls.challenges.delete(token);
812
+ };
813
+ disposals.push(remove);
814
+ return remove;
815
+ },
816
+ registerTlsRenewer: async () => {
817
+ tls.calls.push("registerTlsRenewer");
818
+ tls.renewers++;
819
+ let live = true;
820
+ const remove = () => {
821
+ if (!live)
822
+ return;
823
+ live = false;
824
+ tls.renewers--;
825
+ };
826
+ disposals.push(remove);
827
+ return remove;
828
+ },
829
+ },
830
+ asUser: async (userId, fn) => {
831
+ const previous = actor;
832
+ actor = userId;
833
+ try {
834
+ return await fn();
835
+ }
836
+ finally {
837
+ actor = previous;
838
+ }
839
+ },
840
+ currentActor: () => actor,
841
+ };
842
+ return {
843
+ ctx,
844
+ validateSettings: async (scope, values, context = {}) => {
845
+ const errors = {};
846
+ for (const entry of settingsValidators) {
847
+ if (entry.scope !== scope)
848
+ continue;
849
+ Object.assign(errors, (await entry.validator(values, context)) ?? {});
850
+ }
851
+ return errors;
852
+ },
853
+ disposals,
854
+ emitted,
855
+ kv,
856
+ tables,
857
+ syncEntities,
858
+ wsRoutes,
859
+ httpRouters,
860
+ settings,
861
+ coreSettings,
862
+ sshConnections,
863
+ hostShares,
864
+ auth,
865
+ desktopWindows,
866
+ externalClientLaunches,
867
+ credentialReads,
868
+ createdSshKeys,
869
+ notifications,
870
+ notifyHub,
871
+ fetches,
872
+ processRuns,
873
+ binaries,
874
+ tls,
875
+ secretStore,
876
+ audits,
877
+ activities,
878
+ trackedSessions,
879
+ interactions,
880
+ statusReports,
881
+ statusPorts,
882
+ hostStatuses,
883
+ droppedPools,
884
+ scheduled,
885
+ runScheduled: async () => {
886
+ for (const job of scheduled.filter((candidate) => !candidate.stopped)) {
887
+ if (job.kind === "after")
888
+ job.stopped = true;
889
+ await job.fn();
890
+ }
891
+ },
892
+ setActor: (userId) => {
893
+ actor = userId;
894
+ },
895
+ actAs: async (userId, fn) => {
896
+ const previous = actor;
897
+ actor = userId;
898
+ try {
899
+ return await fn();
900
+ }
901
+ finally {
902
+ actor = previous;
903
+ }
904
+ },
905
+ services,
906
+ };
907
+ }
908
+ /**
909
+ * A context double that gates on capabilities the way src/backend/plugins/ctx.ts
910
+ * does, so a test can prove a plugin fails closed without a running server.
911
+ *
912
+ * The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
913
+ * ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
914
+ * topic outside the plugin's own namespace, settings:read-core on
915
+ * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
916
+ * notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
917
+ * ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
918
+ * ungated. As the SDK grows a member, add its gate here in the same shape.
919
+ */
920
+ export function createMockCtx(options = {}) {
921
+ const granted = new Set(options.capabilities ?? []);
922
+ const checked = [];
923
+ const base = createFakeContext({
924
+ pluginId: options.pluginId,
925
+ actor: options.actor,
926
+ settings: options.settings,
927
+ coreSettings: options.coreSettings,
928
+ sshClient: options.sshClient,
929
+ db: options.db,
930
+ router: options.router,
931
+ permissions: options.permissions,
932
+ hosts: options.hosts,
933
+ shareableUsers: options.shareableUsers,
934
+ shareableRoles: options.shareableRoles,
935
+ sshHosts: options.sshHosts,
936
+ services: options.services,
937
+ protocolTargets: options.protocolTargets,
938
+ sshKeyCredentials: options.sshKeyCredentials,
939
+ desktopAvailable: options.desktopAvailable,
940
+ hostStatuses: options.hostStatuses,
941
+ notificationChannels: options.notificationChannels,
942
+ fetch: options.fetch,
943
+ process: options.process,
944
+ tlsStatus: options.tlsStatus,
945
+ installedPlugins: options.installedPlugins,
946
+ validateTls: options.validateTls,
947
+ refuseEnrollment: options.refuseEnrollment,
948
+ baseUrl: options.baseUrl,
949
+ linkedUsers: options.linkedUsers,
950
+ loginAttemptLimit: options.loginAttemptLimit,
951
+ manifest: {
952
+ capabilities: options.capabilities ?? [],
953
+ ...options.manifest,
954
+ },
955
+ });
956
+ const { ctx, kv } = base;
957
+ const pluginId = ctx.pluginId;
958
+ for (const [key, value] of Object.entries(options.kv ?? {})) {
959
+ kv.set(key, value);
960
+ }
961
+ const require = (capability) => {
962
+ checked.push(capability);
963
+ if (!granted.has(capability)) {
964
+ throw new PluginCapabilityError(pluginId, capability);
965
+ }
966
+ };
967
+ const guardedKv = ctx.kv;
968
+ const guardedDb = ctx.db;
969
+ const gatedCtx = {
970
+ ...ctx,
971
+ asUser: async (userId, fn) => {
972
+ require("users:impersonate");
973
+ return ctx.asUser(userId, fn);
974
+ },
975
+ db: {
976
+ define: async (definition) => {
977
+ require("db:own");
978
+ return guardedDb.define(definition);
979
+ },
980
+ client: async () => {
981
+ require("db:own");
982
+ return guardedDb.client();
983
+ },
984
+ refs: async () => {
985
+ require("db:core-refs");
986
+ return guardedDb.refs();
987
+ },
988
+ persist: async (persistOptions) => {
989
+ require("db:own");
990
+ return guardedDb.persist(persistOptions);
991
+ },
992
+ dialect: guardedDb.dialect,
993
+ },
994
+ kv: {
995
+ get: async (key) => {
996
+ require("kv:own");
997
+ return guardedKv.get(key);
998
+ },
999
+ set: async (key, value) => {
1000
+ require("kv:own");
1001
+ return guardedKv.set(key, value);
1002
+ },
1003
+ delete: async (key) => {
1004
+ require("kv:own");
1005
+ return guardedKv.delete(key);
1006
+ },
1007
+ list: async () => {
1008
+ require("kv:own");
1009
+ return guardedKv.list();
1010
+ },
1011
+ },
1012
+ files: {
1013
+ dataDir: async () => {
1014
+ require("files:own");
1015
+ return ctx.files.dataDir();
1016
+ },
1017
+ },
1018
+ events: {
1019
+ emit: (topic, payload) => {
1020
+ if (!topic.startsWith(`plugin.${pluginId}.`) &&
1021
+ !granted.has("events:core")) {
1022
+ throw new Error(`Plugin ${pluginId} may only emit topics under "plugin.${pluginId}.". ` +
1023
+ `Declare the events:core capability to emit core topics.`);
1024
+ }
1025
+ ctx.events.emit(topic, payload);
1026
+ },
1027
+ on: (topic, listener) => {
1028
+ if (!topic.startsWith("plugin.") && !granted.has("events:core")) {
1029
+ throw new Error(`Plugin ${pluginId} may only listen to "plugin.*" topics. ` +
1030
+ `Declare the events:core capability to listen to core topics.`);
1031
+ }
1032
+ return ctx.events.on(topic, listener);
1033
+ },
1034
+ },
1035
+ http: {
1036
+ router: (routerOptions) => {
1037
+ require("network:serve");
1038
+ return ctx.http.router(routerOptions);
1039
+ },
1040
+ baseUrl: (req) => ctx.http.baseUrl(req),
1041
+ },
1042
+ ws: {
1043
+ route: (path, handler, wsOptions) => {
1044
+ require("network:serve");
1045
+ ctx.ws.route(path, handler, wsOptions);
1046
+ },
1047
+ upgrade: (path, handler, wsOptions) => {
1048
+ require("network:serve");
1049
+ ctx.ws.upgrade(path, handler, wsOptions);
1050
+ },
1051
+ },
1052
+ settings: {
1053
+ // Reading and writing a plugin's OWN settings is ungated, exactly as in
1054
+ // the real runtime. Only readCore reaches outside the plugin.
1055
+ ...ctx.settings,
1056
+ listHostValues: async (key) => {
1057
+ require("hosts:read");
1058
+ return ctx.settings.listHostValues(key);
1059
+ },
1060
+ readCore: async (key) => {
1061
+ require("settings:read-core");
1062
+ return ctx.settings.readCore(key);
1063
+ },
1064
+ },
1065
+ hosts: {
1066
+ list: async () => {
1067
+ require("hosts:read");
1068
+ return ctx.hosts.list();
1069
+ },
1070
+ get: async (hostId) => {
1071
+ require("hosts:read");
1072
+ return ctx.hosts.get(hostId);
1073
+ },
1074
+ checkAccess: async (hostId, level) => {
1075
+ require("hosts:read");
1076
+ return ctx.hosts.checkAccess(hostId, level);
1077
+ },
1078
+ create: async (host) => {
1079
+ require("hosts:write");
1080
+ return ctx.hosts.create(host);
1081
+ },
1082
+ update: async (hostId, patch) => {
1083
+ require("hosts:write");
1084
+ return ctx.hosts.update(hostId, patch);
1085
+ },
1086
+ delete: async (hostId) => {
1087
+ require("hosts:write");
1088
+ return ctx.hosts.delete(hostId);
1089
+ },
1090
+ listOwned: async () => {
1091
+ require("hosts:write");
1092
+ return ctx.hosts.listOwned();
1093
+ },
1094
+ share: async (hostId, targets, permissionLevel, durationHours) => {
1095
+ require("hosts:write");
1096
+ return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
1097
+ },
1098
+ listUsers: async () => {
1099
+ require("hosts:write");
1100
+ return ctx.hosts.listUsers();
1101
+ },
1102
+ listRoles: async () => {
1103
+ require("hosts:write");
1104
+ return ctx.hosts.listRoles();
1105
+ },
1106
+ trackSession: (hostId) => {
1107
+ require("hosts:read");
1108
+ return ctx.hosts.trackSession(hostId);
1109
+ },
1110
+ recordActivity: async (hostId, type, hostName) => {
1111
+ require("hosts:read");
1112
+ return ctx.hosts.recordActivity(hostId, type, hostName);
1113
+ },
1114
+ status: {
1115
+ get: async (hostId) => {
1116
+ require("hosts:read");
1117
+ return ctx.hosts.status.get(hostId);
1118
+ },
1119
+ check: async (hostId) => {
1120
+ require("hosts:read");
1121
+ return ctx.hosts.status.check(hostId);
1122
+ },
1123
+ reportLogin: (hostId, outcome) => {
1124
+ require("hosts:read");
1125
+ ctx.hosts.status.reportLogin(hostId, outcome);
1126
+ },
1127
+ registerPort: (connectionType, resolve) => {
1128
+ require("hosts:read");
1129
+ return ctx.hosts.status.registerPort(connectionType, resolve);
1130
+ },
1131
+ },
1132
+ },
1133
+ ssh: {
1134
+ ...ctx.ssh,
1135
+ connect: async (host, connectOptions) => {
1136
+ require("ssh:connect");
1137
+ require("credentials:use");
1138
+ return ctx.ssh.connect(host, connectOptions);
1139
+ },
1140
+ withConnection: async (host, connectOptions, fn) => {
1141
+ require("ssh:connect");
1142
+ require("credentials:use");
1143
+ return ctx.ssh.withConnection(host, connectOptions, fn);
1144
+ },
1145
+ prepare: async (host, prepareOptions) => {
1146
+ require("ssh:connect");
1147
+ require("credentials:use");
1148
+ return ctx.ssh.prepare(host, prepareOptions);
1149
+ },
1150
+ openTransport: async (host, config, transportOptions) => {
1151
+ require("ssh:connect");
1152
+ return ctx.ssh.openTransport(host, config, transportOptions);
1153
+ },
1154
+ resolveHost: async (hostId, resolveOptions) => {
1155
+ require("ssh:connect");
1156
+ require("credentials:use");
1157
+ return ctx.ssh.resolveHost(hostId, resolveOptions);
1158
+ },
1159
+ startInteraction: async (interaction, request) => {
1160
+ require("ssh:connect");
1161
+ require("credentials:use");
1162
+ return ctx.ssh.startInteraction(interaction, request);
1163
+ },
1164
+ cancelInteraction: async (interaction, request) => {
1165
+ require("ssh:connect");
1166
+ return ctx.ssh.cancelInteraction(interaction, request);
1167
+ },
1168
+ jumpChain: async (jumpHosts, chainOptions) => {
1169
+ require("ssh:connect");
1170
+ require("credentials:use");
1171
+ return ctx.ssh.jumpChain(jumpHosts, chainOptions);
1172
+ },
1173
+ },
1174
+ auth: {
1175
+ registerSshAuthProvider: (provider) => {
1176
+ require("auth:provide");
1177
+ ctx.auth.registerSshAuthProvider(provider);
1178
+ },
1179
+ registerKeyboardInteractiveHandler: (handler) => {
1180
+ require("auth:provide");
1181
+ ctx.auth.registerKeyboardInteractiveHandler(handler);
1182
+ },
1183
+ registerLoginMethod: (method) => {
1184
+ require("auth:provide");
1185
+ ctx.auth.registerLoginMethod(method);
1186
+ },
1187
+ registerSecondFactor: (factor) => {
1188
+ require("auth:provide");
1189
+ ctx.auth.registerSecondFactor(factor);
1190
+ },
1191
+ recordEnrollment: async (userId, factorId) => {
1192
+ require("auth:provide");
1193
+ return ctx.auth.recordEnrollment(userId, factorId);
1194
+ },
1195
+ removeEnrollment: async (userId, factorId) => {
1196
+ require("auth:provide");
1197
+ return ctx.auth.removeEnrollment(userId, factorId);
1198
+ },
1199
+ completeRedirectLogin: async (methodId, req, res) => {
1200
+ require("auth:provide");
1201
+ return ctx.auth.completeRedirectLogin(methodId, req, res);
1202
+ },
1203
+ revokeSessions: async (match) => {
1204
+ require("auth:provide");
1205
+ return ctx.auth.revokeSessions(match);
1206
+ },
1207
+ loginRateLimit: {
1208
+ isLocked: async (ip, key) => {
1209
+ require("auth:provide");
1210
+ return ctx.auth.loginRateLimit.isLocked(ip, key);
1211
+ },
1212
+ recordFailure: async (ip, key) => {
1213
+ require("auth:provide");
1214
+ return ctx.auth.loginRateLimit.recordFailure(ip, key);
1215
+ },
1216
+ },
1217
+ countLinkedUsers: async (provider) => {
1218
+ require("auth:provide");
1219
+ return ctx.auth.countLinkedUsers(provider);
1220
+ },
1221
+ },
1222
+ desktop: {
1223
+ openIsolatedWindow: async (request) => {
1224
+ require("desktop:window");
1225
+ return ctx.desktop.openIsolatedWindow(request);
1226
+ },
1227
+ launchExternalClient: async (request) => {
1228
+ require("desktop:window");
1229
+ return ctx.desktop.launchExternalClient(request);
1230
+ },
1231
+ available: () => ctx.desktop.available(),
1232
+ },
1233
+ credentials: {
1234
+ listSshKeys: async () => {
1235
+ require("credentials:use");
1236
+ return ctx.credentials.listSshKeys();
1237
+ },
1238
+ createSshKey: async (input) => {
1239
+ require("credentials:write");
1240
+ return ctx.credentials.createSshKey(input);
1241
+ },
1242
+ resolveHostProtocol: async (hostId, protocol) => {
1243
+ require("credentials:read");
1244
+ const declared = ctx.manifest.contributes?.protocols;
1245
+ if (declared && !declared.some((entry) => entry.id === protocol)) {
1246
+ throw new Error(`Plugin ${pluginId} did not declare protocol "${protocol}" in contributes.protocols`);
1247
+ }
1248
+ return ctx.credentials.resolveHostProtocol(hostId, protocol);
1249
+ },
1250
+ registerSecretResolver: (scheme, resolve) => {
1251
+ require("auth:provide");
1252
+ ctx.credentials.registerSecretResolver(scheme, resolve);
1253
+ },
1254
+ },
1255
+ notify: {
1256
+ channels: async () => {
1257
+ require("notify:send");
1258
+ return ctx.notify.channels();
1259
+ },
1260
+ send: async (notification) => {
1261
+ require("notify:send");
1262
+ return ctx.notify.send(notification);
1263
+ },
1264
+ serve: (hub) => {
1265
+ require("notify:hub");
1266
+ return ctx.notify.serve(hub);
1267
+ },
1268
+ },
1269
+ fetch: async (url, init) => {
1270
+ require("network:outbound");
1271
+ return ctx.fetch(url, init);
1272
+ },
1273
+ process: {
1274
+ run: async (file, args, runOptions) => {
1275
+ require("process:spawn");
1276
+ return ctx.process.run(file, args, runOptions);
1277
+ },
1278
+ ensureBinary: async (spec) => {
1279
+ require("process:spawn");
1280
+ return ctx.process.ensureBinary(spec);
1281
+ },
1282
+ },
1283
+ plugins: {
1284
+ list: async () => {
1285
+ require("plugins:read");
1286
+ return ctx.plugins.list();
1287
+ },
1288
+ },
1289
+ system: {
1290
+ tlsStatus: async () => {
1291
+ require("system:tls");
1292
+ return ctx.system.tlsStatus();
1293
+ },
1294
+ writeTlsCertificate: async (certificatePem, privateKeyPem) => {
1295
+ require("system:tls");
1296
+ return ctx.system.writeTlsCertificate(certificatePem, privateKeyPem);
1297
+ },
1298
+ reloadTls: async () => {
1299
+ require("system:tls");
1300
+ return ctx.system.reloadTls();
1301
+ },
1302
+ publishHttpChallenge: async (token, content) => {
1303
+ require("system:tls");
1304
+ return ctx.system.publishHttpChallenge(token, content);
1305
+ },
1306
+ registerTlsRenewer: async () => {
1307
+ require("system:tls");
1308
+ return ctx.system.registerTlsRenewer();
1309
+ },
1310
+ },
1311
+ secrets: {
1312
+ ...ctx.secrets,
1313
+ get: async (key) => {
1314
+ require("secrets:own");
1315
+ return ctx.secrets.get(key);
1316
+ },
1317
+ set: async (key, value) => {
1318
+ require("secrets:own");
1319
+ return ctx.secrets.set(key, value);
1320
+ },
1321
+ delete: async (key) => {
1322
+ require("secrets:own");
1323
+ return ctx.secrets.delete(key);
1324
+ },
1325
+ seal: async (value) => {
1326
+ require("secrets:own");
1327
+ return ctx.secrets.seal(value);
1328
+ },
1329
+ unseal: async (sealed) => {
1330
+ require("secrets:own");
1331
+ return ctx.secrets.unseal(sealed);
1332
+ },
1333
+ },
1334
+ capabilities: {
1335
+ has: async (capability) => granted.has(capability),
1336
+ require: async (capability) => {
1337
+ require(capability);
1338
+ },
1339
+ },
1340
+ };
1341
+ return { ...base, ctx: gatedCtx, checked };
1342
+ }
1343
+ /**
1344
+ * An in-memory SQLite database with a plugin's own migrations applied.
1345
+ *
1346
+ * Core's users, ssh_data, roles and user_roles exist as minimal stubs with
1347
+ * foreign keys on, so a refUser or refHost column cascades exactly as it
1348
+ * does on the server, and ctx.db.refs() has something real to join against.
1349
+ * The migrations are read from <pluginDir>/migrations/sqlite and split with
1350
+ * the same splitter the server's runner uses.
1351
+ */
1352
+ export async function createTestDb(pluginDir, options = {}) {
1353
+ const [{ default: Database }, { drizzle }, fs, path, ddl, builder] = await Promise.all([
1354
+ import("better-sqlite3"),
1355
+ import("drizzle-orm/better-sqlite3"),
1356
+ import("node:fs"),
1357
+ import("node:path"),
1358
+ import("./ddl.js"),
1359
+ import("./table-builder.js"),
1360
+ ]);
1361
+ const sqlite = new Database(":memory:");
1362
+ sqlite.exec("PRAGMA foreign_keys = ON");
1363
+ sqlite.exec("CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '', is_admin INTEGER NOT NULL DEFAULT 0)");
1364
+ sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)");
1365
+ sqlite.exec("CREATE TABLE roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '')");
1366
+ sqlite.exec("CREATE TABLE user_roles (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE)");
1367
+ const handle = drizzle(sqlite);
1368
+ // The manifest id, not the folder name: a plugin's own repo is rarely
1369
+ // named after its id.
1370
+ const manifestPath = path.join(pluginDir, "manifest.json");
1371
+ const pluginId = (fs.existsSync(manifestPath) &&
1372
+ JSON.parse(fs.readFileSync(manifestPath, "utf8")).id) ||
1373
+ path.basename(pluginDir);
1374
+ const applied = [];
1375
+ let persisted = 0;
1376
+ await options.before?.(sqlite);
1377
+ const migrate = async () => {
1378
+ const dir = path.join(pluginDir, "migrations", "sqlite");
1379
+ if (!fs.existsSync(dir))
1380
+ return [];
1381
+ const files = fs
1382
+ .readdirSync(dir)
1383
+ .filter((file) => /^\d{4}_[a-z0-9_]+\.sql$/.test(file))
1384
+ .sort();
1385
+ const now = [];
1386
+ for (const file of files) {
1387
+ const id = file.replace(/\.sql$/, "");
1388
+ if (applied.includes(id))
1389
+ continue;
1390
+ const source = fs.readFileSync(path.join(dir, file), "utf8");
1391
+ for (const statement of ddl.splitStatements(source)) {
1392
+ sqlite.exec(statement);
1393
+ }
1394
+ applied.push(id);
1395
+ now.push(id);
1396
+ }
1397
+ return now;
1398
+ };
1399
+ if (!options.skipMigrations)
1400
+ await migrate();
1401
+ const refs = {
1402
+ users: builder.buildRefTable("users", {
1403
+ id: "text",
1404
+ username: "text",
1405
+ isAdmin: "boolean",
1406
+ }),
1407
+ hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
1408
+ roles: builder.buildRefTable("roles", {
1409
+ id: "integer",
1410
+ name: "text",
1411
+ displayName: "text",
1412
+ }),
1413
+ userRoles: builder.buildRefTable("user_roles", {
1414
+ id: "integer",
1415
+ userId: "text",
1416
+ roleId: "integer",
1417
+ }),
1418
+ };
1419
+ const database = {
1420
+ define: async (definition) => builder.buildTable(pluginId, definition),
1421
+ client: async () => handle,
1422
+ refs: async () => refs,
1423
+ persist: async () => {
1424
+ persisted += 1;
1425
+ },
1426
+ dialect: "sqlite",
1427
+ };
1428
+ return {
1429
+ sqlite,
1430
+ drizzle: handle,
1431
+ database,
1432
+ get persisted() {
1433
+ return persisted;
1434
+ },
1435
+ applied,
1436
+ migrate,
1437
+ close: () => sqlite.close(),
1438
+ };
1439
+ }
1440
+ const TEST_HOST = "@termix-ssh/plugin-host/testing";
1441
+ export async function renderWithApp(plugin, options = {}) {
1442
+ // A variable specifier keeps bundlers from resolving it at build time; the
1443
+ // test runner resolves it through its alias.
1444
+ const host = (await import(/* @vite-ignore */ TEST_HOST));
1445
+ return host.renderPlugin(plugin, options);
1446
+ }
1447
+ function assertOwnRegistryKey(pluginId, key) {
1448
+ if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) {
1449
+ throw new Error(`Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`);
1450
+ }
1451
+ }