diff --git a/README.md b/README.md index 49177db..ccf8f56 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ The supported lifecycle contract is synchronous: - `renderOnLoad()` optionally returns an on-load string - the SDK serializes those values for host transport separately +DOM helper rule: + +- if `render()` uses SDK DOM helpers that generate goober-backed classes/styles, wrap the final helper output with `renderHTML(...)` +- this is mandatory for styled DOM-helper output, because helper-generated class names are only useful when the extracted CSS is emitted with the markup +- returning raw DOM-helper markup without `renderHTML(...)` can leave the UI unstyled even though class names are present +- DOM helpers emit raw HTML attribute names such as `class`, `for`, and `readonly`; JSX-style aliases such as `className`, `htmlFor`, and `readOnly` are accepted as input for compatibility and normalized to HTML output +- if both the HTML form and JSX-style alias are provided for the same attribute, the HTML form wins explicitly (`class` over `className`, `for` over `htmlFor`, `readonly` over `readOnly`) + Plugin metadata is also part of the host contract. In particular, `metadata.icon` must be a valid BlueprintJS v6 icon name because FDO uses BlueprintJS v6 in the host application. For the detailed render/runtime contract, see [docs/RENDER_RUNTIME_CONTRACT.md](./docs/RENDER_RUNTIME_CONTRACT.md). @@ -130,15 +138,17 @@ new MyPlugin(); ### Example Usage -See `examples/fixtures/minimal-plugin.fixture.ts` for the primary minimal plugin scaffold. +See `examples/fixtures/minimal-plugin.fixture.ts` for the primary minimal plugin scaffold: metadata, `init()`, and `render()` only, with no extra bridge or UI-helper concepts mixed in. See `examples/01-basic-plugin.ts` for the teaching-oriented basic lifecycle example. -See `examples/dom_elements_plugin.ts` for comprehensive examples of using the new DOM element creation capabilities including tables, media, semantic HTML, lists, and form controls. +See `examples/05-advanced-dom-plugin.ts` for the numbered DOM-helper learning example. See `examples/08-privileged-actions-plugin.ts` for the low-level host privileged action request flow using `requestPrivilegedAction(...)` with correlation IDs and stable response envelope handling. See `examples/09-operator-plugin.ts` for a curated operator helper example for a known tool family built on scoped host process execution. +See `examples/10-system-file-plugin.ts` for the next logical low-level filesystem example after `/etc/hosts`: a scoped `system.fs.mutate` flow targeting another system file (`/etc/motd`) through the same host-mediated privileged transport path. + For public SDK guidance, follow the fixture-first and curated-helper-first recommendation order documented in [docs/OPERATOR_PLUGIN_PATTERNS.md](./docs/OPERATOR_PLUGIN_PATTERNS.md). The SDK also provides curated operator presets for common DevOps/SRE tooling such as Docker, kubectl, Helm, Terraform, Ansible, AWS CLI, gcloud, Azure CLI, Podman, Kustomize, GitHub CLI, Git, Vault, and Nomad, while still supporting generic custom scopes for host-specific tools. @@ -157,6 +167,8 @@ Core capabilities: - `storage.json` - required for `PluginRegistry.useStore("json")` - `sudo.prompt` - required for `runWithSudo(...)` +- `system.clipboard.read` - required for host-mediated clipboard reads +- `system.clipboard.write` - required for host-mediated clipboard writes - `system.hosts.write` - required for host-mediated hosts updates and scoped privileged fs API - `system.fs.scope.` - host-defined scoped permission for `system.fs.mutate` - `system.process.exec` - required for host-mediated process execution @@ -164,6 +176,8 @@ Core capabilities: Privileged action contracts: +- `system.clipboard.read` +- `system.clipboard.write` - `system.hosts.write` - `system.fs.mutate` - `system.process.exec` @@ -171,6 +185,8 @@ Privileged action contracts: Public helpers exported from root package: - `validateHostPrivilegedActionRequest(...)` +- `createClipboardReadActionRequest(...)` +- `createClipboardWriteActionRequest(...)` - `createHostsWriteActionRequest(...)` - `createFilesystemMutateActionRequest(...)` - `createFilesystemScopeCapability(...)` @@ -178,6 +194,10 @@ Public helpers exported from root package: - `createProcessScopeCapability(...)` - `createPrivilegedActionBackendRequest(...)` - `requestPrivilegedAction(...)` +- `createClipboardReadRequest(...)` +- `createClipboardWriteRequest(...)` +- `requestClipboardRead(...)` +- `requestClipboardWrite(...)` - `createScopedProcessExecActionRequest(...)` - `requestScopedProcessExec(...)` - `createProcessCapabilityBundle(...)` @@ -233,6 +253,14 @@ Preferred pattern: - expect workflow step results to expose typed process outcome data (`command`, `args`, `exitCode`, `stdout`, `stderr`, `durationMs`) rather than opaque blobs - implement `declareCapabilities()` for operator plugins so declared vs granted capabilities can be compared during preflight and diagnostics +Rule of thumb for many commands: + +- one command: `requestOperatorTool(...)` +- many independent inspections gathered by one backend method: loop in backend code +- one named troubleshooting runbook or inspect/act sequence: `requestScopedWorkflow(...)` + +For example, an AWS troubleshooting plugin that needs 10+ CLI calls should not default to ten UI actions or raw shell chaining. Keep orchestration in backend code, declare capabilities for `aws-cli`, and promote the sequence to `requestScopedWorkflow(...)` once it becomes one logical operator run rather than a bag of unrelated commands. + Examples of suitable process scopes: - `system.process.scope.docker-cli` diff --git a/docs/EXAMPLES_AND_FIXTURES.md b/docs/EXAMPLES_AND_FIXTURES.md index c2aac2d..8f029c7 100644 --- a/docs/EXAMPLES_AND_FIXTURES.md +++ b/docs/EXAMPLES_AND_FIXTURES.md @@ -8,20 +8,24 @@ If you are starting a new plugin, prefer the fixture set under `examples/fixture 1. `examples/fixtures/minimal-plugin.fixture.ts` Use when you need the smallest valid plugin scaffold. + It is intentionally plain: metadata, `init()`, and `render()` only, with no extra bridge or UI-helper concepts mixed in. 2. `examples/fixtures/error-handling-plugin.fixture.ts` Use when you need deterministic render fallback behavior. + It demonstrates `@handleError`, real `UI_MESSAGE` handler invocation, and runtime-safe fallback UI without extra UI abstraction. 3. `examples/fixtures/storage-plugin.fixture.ts` Use when you need plugin-scoped storage with graceful JSON-store handling. -4. `examples/fixtures/advanced-ui-plugin.fixture.ts` - Use when you need richer UI composition with DOM helpers. -5. `examples/fixtures/operator-kubernetes-plugin.fixture.ts` + It demonstrates backend storage handlers, session fallback when JSON storage is unavailable, and UI_MESSAGE-driven storage actions instead of a static snapshot only. +4. `examples/fixtures/operator-kubernetes-plugin.fixture.ts` Use when building a `kubectl`-style operator plugin. -6. `examples/fixtures/operator-terraform-plugin.fixture.ts` + It demonstrates the current curated operator pattern: declare preset capabilities, build validated operator/workflow envelopes in backend code, fetch them through `UI_MESSAGE`, and send them through the host privileged-action path. +5. `examples/fixtures/operator-terraform-plugin.fixture.ts` Use when building a Terraform preview/apply plugin. -7. `examples/fixtures/operator-custom-tool-plugin.fixture.ts` + It demonstrates the same curated operator pattern for Terraform: declare preset capabilities, build validated plan/workflow envelopes in backend code, fetch them through `UI_MESSAGE`, and send them through the host privileged-action path. +6. `examples/fixtures/operator-custom-tool-plugin.fixture.ts` Use when you need a host-specific scoped tool that is not covered by a curated operator preset. + It demonstrates the generic scoped-process pattern: declare the broad execution capability plus a narrow custom process scope, build a validated scoped-process envelope in backend code, fetch it through `UI_MESSAGE`, and send it through the host privileged-action path. -The numbered examples under `examples/01-...` to `examples/09-...` are learning references. They are not the default production starting point. +The numbered examples under `examples/01-...` to `examples/10-...` are learning references. They are not the default production starting point. ## Production-Grade Rules @@ -29,6 +33,26 @@ Use these rules for examples, fixtures, and AI-generated plugin scaffolds: - Keep backend orchestration in plugin methods and registered handlers. - Keep `renderOnLoad()` thin and UI-focused. +- If `render()` uses styled SDK DOM-helper output, wrap the final helper markup with `renderHTML(...)`. +- Treat `renderHTML(...)` as mandatory for styled DOM-helper output because it emits the extracted goober CSS alongside the markup. +- Treat DOM helpers as the preferred SDK pattern for general structured UI. +- Use plain markup intentionally only when the example is isolating a different lesson, such as injected libraries or the iframe/backend boundary. +- The numbered learning examples are an intentional progression: + - `01` plain markup for the minimal lifecycle contract + - `02` plain markup for handlers, `renderOnLoad()`, and `UI_MESSAGE` + - `03` plain markup for storage and backend/UI separation + - `04` plain markup for quick actions and side-panel routing + - `05` DOM helpers directly, including the `renderHTML(...)` rule + - `06` plain markup for error handling and runtime-safe fallback behavior + - `07` plain markup for injected iframe libraries and browser-only helpers + - `08` plain markup for low-level privileged-action transport and response handling + - `09` plain markup for the curated operator-helper path, declared preset capabilities, and host-mediated execution + - `10` plain markup for generic scoped filesystem mutation when a plugin needs to edit a system file other than `/etc/hosts` +- Even plain markup examples must remain JSX-compatible for the FDO host transform. +- Escape JSX-sensitive code samples and prefer JSX-safe tags such as `
`. +- Prefer descriptive text over raw guard-sensitive capability/runtime tokens in `render()` when the literal token is not required for the lesson. +- Example: in `09`, describe the broad capability plus narrow scope instead of embedding raw `system.process.*` strings in the rendered UI, because host fail-fast guards may conservatively flag those tokens. +- Do not embed raw JSON snapshots directly in `render()`; use a safe placeholder and load the structured data after iframe initialization. - For UI-to-backend calls, use the real bridge contract: ```ts @@ -55,6 +79,7 @@ The examples surface is considered stable only when all of the following stay tr - public example docs do not reference local-only or internal planning files - public example docs do not reference stale docs domains - `createBackendReq(...)` examples reflect the real `UI_MESSAGE` handler pattern +- DOM-helper examples that rely on helper-generated styles call `renderHTML(...)` - the canonical fixtures remain the primary recommended starting point ## Canonical Operator Patterns diff --git a/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md b/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md index f53d93e..95cca17 100644 --- a/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md +++ b/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md @@ -8,6 +8,8 @@ Allow narrowly scoped privileged operations without granting plugins broad files ## Current Actions +- `system.clipboard.read` +- `system.clipboard.write` - `system.hosts.write` - `system.fs.mutate` - `system.process.exec` @@ -17,14 +19,39 @@ Validated by SDK helper: - `validateHostPrivilegedActionRequest(...)` - helpers for developer UX: + - `createClipboardReadActionRequest(request)` + - `createClipboardWriteActionRequest(request)` - `createFilesystemScopeCapability(scopeId)` - `createHostsWriteActionRequest(request)` - `createFilesystemMutateActionRequest(request)` - `createProcessScopeCapability(scopeId)` - `createProcessExecActionRequest(request)` + - `createClipboardReadRequest(reason?)` + - `createClipboardWriteRequest(text, reason?)` + - `requestClipboardRead(reasonOrOptions?, options?)` + - `requestClipboardWrite(text, reasonOrOptions?, options?)` ## Request Shape +```ts +{ + action: "system.clipboard.read", + payload: { + reason?: string + } +} +``` + +```ts +{ + action: "system.clipboard.write", + payload: { + text: string, + reason?: string + } +} +``` + ```ts { action: "system.hosts.write", @@ -150,6 +177,10 @@ const payload = createPrivilegedActionBackendRequest(request, { ## Capability Requirement +- For `system.clipboard.read`, host should require: + - broad feature capability: `system.clipboard.read` +- For `system.clipboard.write`, host should require: + - broad feature capability: `system.clipboard.write` - Host should only execute this action when capability `system.hosts.write` is granted for that plugin. - For `system.fs.mutate`, host should require both: - broad feature capability: `system.hosts.write` (or host-defined equivalent for privileged FS API) @@ -163,6 +194,11 @@ const payload = createPrivilegedActionBackendRequest(request, { ## Security Requirements For Hosts +- use the Electron clipboard API only in the trusted host process or another explicitly trusted host boundary +- do not grant plugins raw clipboard access without host mediation +- consider clipboard read more sensitive than clipboard write and gate it independently +- log/audit clipboard reads with plugin identity, correlation id, and reason when available +- log/audit clipboard writes with plugin identity, correlation id, and reason when available - enforce explicit user confirmation for non-dry-run writes - avoid shell interpolation; write through structured file logic - constrain writes to `/etc/hosts` only diff --git a/docs/INJECTED_LIBRARIES.md b/docs/INJECTED_LIBRARIES.md index 905be62..451958f 100644 --- a/docs/INJECTED_LIBRARIES.md +++ b/docs/INJECTED_LIBRARIES.md @@ -17,6 +17,51 @@ If you need a new UI library in iframe code, it must be added by FDO host inject Backend/plugin runtime code can still import npm dependencies that are bundled with the plugin artifact. +## Teaching Boundary: Injected Libraries vs DOM Helpers + +SDK DOM helpers are still a best practice for general SDK-native structured UI because they give authors a typed, reusable way to build consistent markup and styling. + +But this injected-libraries guide serves a different purpose: + +- teach iframe-only host-injected globals +- teach browser-only runtime behavior +- teach the `UI_MESSAGE` backend bridge pattern + +So examples in this guide may intentionally use plain markup plus `renderOnLoad()` wiring instead of DOM helpers, in order to keep the lesson focused. + +Use this rule: + +- general SDK-native structured UI: DOM helpers are preferred +- injected-library/runtime-boundary teaching: plain markup is acceptable when it keeps the example focused + +If you do use styled DOM helpers in `render()`, `renderHTML(...)` remains mandatory so the extracted goober CSS is emitted with the markup. + +## Important Boundary: JSX-Compatible Markup + +Even when you use plain markup in injected-library examples, remember that `render()` output is consumed by the FDO host transform, not inserted as unconstrained raw HTML. + +That means the safest mental model is: + +- plain markup is acceptable here for teaching focus +- but it still needs to be JSX-compatible for the host render pipeline + +Examples of what to prefer: + +- `
` instead of `
` +- inline element styles or DOM-helper styling instead of raw ` +
function greet(name) { return "Hello"; }
+ `; +} +``` + +Another practical example: + +```ts +render(): string { + return ` +

Declared capabilities: broad host tool execution plus the narrow Docker CLI scope.

+ `; +} +``` + +Prefer that over embedding raw display text such as: + +```ts +render(): string { + return ` +

["system.process.exec", "system.process.scope.docker-cli"]

+ `; +} +``` + +Even though that second example is only display text, some host fail-fast guards may conservatively match the `process.` token and reject the render source. + +For JSON/result panels, prefer this shape: + +```ts +render(): string { + return ` +
Snapshot will load after initialization...
+ `; +} + +renderOnLoad(): string { + return ` + (() => { + const output = document.getElementById("result-box"); + // fetch data through UI_MESSAGE and then: + output.textContent = JSON.stringify({ ok: true }, null, 2); + })(); + `; +} +``` + +Do not rely on embedding raw JSON directly in `render()`: + +```ts +render(): string { + return ` +
{ "ok": true }
+ `; +} +``` + The SDK now keeps that separation explicit: - `render()` returns the plugin's UI string @@ -101,6 +221,13 @@ You should treat them as: - not proof that the host inserts raw HTML directly - not proof that every browser/runtime feature is available everywhere +If helper-generated output uses goober-backed styles/classes, `renderHTML(...)` is mandatory on the final render output. Without it, the helper-generated class names can be present while the extracted CSS is missing from the returned UI string. + +Practical rule: + +- helper-composed styled output: `return helper.renderHTML(content)` +- plain manual JSX-like markup with no helper-generated styling: return the markup directly + ## Trusted Markup vs Safe Text The DOM builder supports two different content models: @@ -172,6 +299,7 @@ If you use `errorUIRenderer` or any custom render fallback: - Use `render()` to provide UI for the iframe host pipeline. - Use injected `window.*` helpers only from UI-facing code paths. - Use SDK DOM helpers when they match the existing workspace style. +- When DOM helpers generate styled output, call `renderHTML(...)` on the final helper markup before returning from `render()`. - Do not move UI-runtime assumptions into backend/bootstrap code. ## Guidance For AI Tools diff --git a/docs/SAFE_PLUGIN_AUTHORING.md b/docs/SAFE_PLUGIN_AUTHORING.md index 71cd6cc..4c9411b 100644 --- a/docs/SAFE_PLUGIN_AUTHORING.md +++ b/docs/SAFE_PLUGIN_AUTHORING.md @@ -16,6 +16,50 @@ Do not assume iframe-only globals are available in backend/bootstrap paths. - Treat `DOM.createElement(..., ...children)` children as trusted JSX-like markup fragments. - For untrusted/user-provided text, use `DOMText` helpers (`createText`, `createPText`, `createSpanText`, etc.) so JSX-sensitive characters are escaped. - Do not pass unsanitized user input as raw child markup into generic DOM helpers. +- DOM helpers emit raw HTML attributes in the final string. Compatibility aliases such as `className`, `htmlFor`, and `readOnly` are accepted on input and normalized to `class`, `for`, and `readonly` in output. +- When both forms are provided, the native HTML form wins explicitly: `class` over `className`, `for` over `htmlFor`, and `readonly` over `readOnly`. + +## DOM Helper Rule: `renderHTML()` Is Mandatory For Styled Helper Output + +If your `render()` method uses SDK DOM helpers and expects goober-backed styling/classes to appear in the output, you must wrap the final helper markup with `renderHTML(...)`. + +Why this is mandatory: + +- DOM helpers generate class names through goober +- those class names are not enough by themselves +- the extracted CSS must be emitted into the render output alongside the markup +- `renderHTML(...)` is the SDK helper that emits that CSS and the expected script placeholder boundary + +Do this: + +```ts +render(): string { + const semantic = new DOMSemantic(); + const text = new DOMText(); + + const content = semantic.createMain([ + text.createHText(1, "Styled helper UI"), + ]); + + return semantic.renderHTML(content); +} +``` + +Do not do this for styled helper output: + +```ts +render(): string { + const semantic = new DOMSemantic(); + return semantic.createMain([/* ... */]); +} +``` + +Best practices: + +- build the full helper-composed UI first, then call `renderHTML(...)` once on the final root content +- use the same helper instance for composition and `renderHTML(...)` when practical +- keep `renderHTML(...)` in `render()`, not in `renderOnLoad()` +- if you are returning plain manual JSX-like markup with no DOM-helper styling/classes, `renderHTML(...)` is not required ## Logging In Plugins @@ -73,6 +117,10 @@ Privileged SDK features are capability-gated. The host should grant capabilities required for `PluginRegistry.useStore("json")` - `sudo.prompt`: required for `runWithSudo(...)` +- `system.clipboard.read`: + required for host-mediated clipboard reads +- `system.clipboard.write`: + required for host-mediated clipboard writes - `system.hosts.write`: reserved for host-mediated `/etc/hosts` updates (do not implement direct filesystem writes in plugins) - `system.fs.scope.`: @@ -166,6 +214,35 @@ declareCapabilities() { This allows hosts to compare declared capabilities with granted capabilities during preflight, before a user reaches a rare or deep action path. +## Many-Command Troubleshooting Best Practice + +If a plugin needs to run many tool commands, do not default to: + +- ten separate UI actions +- raw shell chaining +- ad hoc orchestration in iframe code + +Use this rule of thumb: + +- one command: `requestOperatorTool(...)` +- several independent commands gathered by one backend method: loop in backend code +- one named troubleshooting or inspect/act runbook: `requestScopedWorkflow(...)` + +For example, an AWS troubleshooting plugin may need to run many `aws` CLI commands. Best practice is: + +- declare capabilities via `declareCapabilities()` +- use `createOperatorToolCapabilityPreset("aws-cli")` +- keep execution in backend methods and registered handlers +- use a backend loop only when the commands are independent inspections +- switch to `requestScopedWorkflow(...)` when the sequence is one logical operator run with ordered steps, shared summary, and step-level diagnostics + +Avoid: + +- `sh -c` +- shell interpolation +- unstructured command concatenation +- repeating the same low-level request code in many UI handlers + ## Error-Path Safety - Keep render error fallbacks simple and runtime-safe diff --git a/examples/05-advanced-dom-plugin.ts b/examples/05-advanced-dom-plugin.ts index 319805d..de50ffe 100644 --- a/examples/05-advanced-dom-plugin.ts +++ b/examples/05-advanced-dom-plugin.ts @@ -116,7 +116,7 @@ export default class AdvancedDOMPlugin extends FDO_SDK implements FDOInterface { } ); - return semantic.createMain( + const content = semantic.createMain( [ semantic.createHeader([ text.createHText(1, this._metadata.name), @@ -158,6 +158,8 @@ export default class AdvancedDOMPlugin extends FDO_SDK implements FDOInterface { }, } ); + + return semantic.renderHTML(content); } catch (error) { const normalizedError = error instanceof Error ? error : new Error(String(error)); const safeMessage = normalizedError.message diff --git a/examples/06-error-handling-plugin.ts b/examples/06-error-handling-plugin.ts index bcc85e1..e776725 100644 --- a/examples/06-error-handling-plugin.ts +++ b/examples/06-error-handling-plugin.ts @@ -1,7 +1,28 @@ +/** + * Example 6: Error handling with @handleError and runtime-safe render fallback. + * + * This example demonstrates how to use the SDK error-handling decorator without + * mixing backend error logic, iframe event wiring, and DOM-helper composition. + * + * Compatible with SDK v1.x + * + * Learning Objectives: + * - Use `@handleError()` on `init()`, backend handlers, and `render()` + * - Keep handler failures inside structured backend responses + * - Trigger backend handlers from iframe UI via `UI_MESSAGE` + * - Provide a render fallback that remains safe in mixed host/runtime conditions + * - Understand the difference between a handled backend error and a render failure + * + * Expected Output: + * When this plugin runs in the FDO application, it will: + * 1. Display controls to trigger a successful and a failing backend handler + * 2. Show handler results in the UI without crashing the plugin + * 3. Demonstrate that `@handleError()` converts backend failures into structured responses + * 4. Provide a render fallback if the render path itself throws + * 5. Log the underlying error details through the SDK logger + */ + import { - DOMButton, - DOMNested, - DOMText, FDO_SDK, FDOInterface, PluginMetadata, @@ -11,21 +32,19 @@ import { declare global { interface Window { - fdoSDK: { - sendMessage: (handler: string, data: any) => Promise; - }; + createBackendReq: (type: string, data?: any) => Promise; } } /** - * Example 6: Error handling with @handleError and runtime-safe render fallback. + * ErrorHandlingPlugin demonstrates runtime-safe error handling patterns. */ export default class ErrorHandlingPlugin extends FDO_SDK implements FDOInterface { private readonly _metadata: PluginMetadata = { name: "Error Handling Example", version: "1.0.0", author: "FDO SDK Team", - description: "Demonstrates @handleError usage across init/render/handlers", + description: "Demonstrates @handleError usage across init, render, and backend handlers", icon: "warning-sign", }; @@ -61,80 +80,117 @@ export default class ErrorHandlingPlugin extends FDO_SDK implements FDOInterface @handleError({ returnErrorUI: true, - errorUIRenderer: (error: Error) => { - const text = new DOMText(); - const nested = new DOMNested(); - return nested.createBlockDiv( - [ - text.createHText(2, "Plugin Error"), - text.createPText(error.message), - ], - { - style: { - padding: "16px", - border: "1px solid #d22", - borderRadius: "6px", - backgroundColor: "#fff7f7", - }, - } - ); - }, + errorUIRenderer: (error: Error) => ` +
+

Plugin Error

+

${error.message}

+
+ `, }) render(): string { - const text = new DOMText(); - const nested = new DOMNested(); - const button = new DOMButton(); - - return nested.createBlockDiv( - [ - text.createHText(1, this._metadata.name), - text.createPText(this._metadata.description), - nested.createBlockDiv( - [ - button.createButton( - "Trigger Success Handler", - () => { - void window.fdoSDK.sendMessage("simulateSuccess", { source: "ui" }); - }, - { - style: { - marginRight: "8px", - padding: "8px 12px", - borderRadius: "4px", - border: "1px solid #1f8a3d", - color: "#1f8a3d", - backgroundColor: "#eefaf2", - cursor: "pointer", - }, - } - ), - button.createButton( - "Trigger Error Handler", - () => { - void window.fdoSDK.sendMessage("simulateError", {}); - }, - { - style: { - padding: "8px 12px", - borderRadius: "4px", - border: "1px solid #b32020", - color: "#b32020", - backgroundColor: "#fff2f2", - cursor: "pointer", - }, - } - ), - ], - { style: { marginTop: "12px" } } - ), - ], - { - style: { - padding: "20px", - fontFamily: "system-ui, -apple-system, Segoe UI, sans-serif", - }, + return ` +
+

${this._metadata.name}

+

${this._metadata.description}

+ +
+

Backend Handler Outcomes

+

Use these buttons to trigger a successful handler and a failing handler protected by @handleError().

+
+ + +
+

+        
+ +
+

What This Example Teaches

+
    +
  • init(): register handlers under decorator protection
  • +
  • backend handlers: return structured success or handled error responses
  • +
  • render(): stay synchronous and provide a safe fallback path
  • +
  • iframe UI: use UI_MESSAGE for handler invocation
  • +
+
+
+ `; + } + + renderOnLoad(): string { + return ` + () => { + const callHandler = (handler, content = {}) => + window.createBackendReq("UI_MESSAGE", { handler, content }); + + const successButton = document.getElementById("trigger-success-handler"); + const errorButton = document.getElementById("trigger-error-handler"); + const output = document.getElementById("error-handling-result"); + if (!successButton || !errorButton || !output) return; + + const runHandler = async (handler, content = {}) => { + output.textContent = "Running..."; + try { + const result = await callHandler(handler, content); + output.textContent = JSON.stringify(result, null, 2); + } catch (error) { + output.textContent = JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }, null, 2); + } + }; + + successButton.addEventListener("click", () => { + void runHandler("simulateSuccess", { source: "ui" }); + }); + + errorButton.addEventListener("click", () => { + void runHandler("simulateError", {}); + }); } - ); + `; } } diff --git a/examples/07-injected-libraries-demo.ts b/examples/07-injected-libraries-demo.ts index 46b1515..0b7b32d 100644 --- a/examples/07-injected-libraries-demo.ts +++ b/examples/07-injected-libraries-demo.ts @@ -1,17 +1,26 @@ /** * Example plugin demonstrating the use of injected libraries and helper functions - * - * This example shows how to use: - * - Pure CSS for responsive layouts - * - Notyf for notifications - * - Highlight.js for code syntax highlighting - * - FontAwesome icons - * - ACE Editor for code editing - * - Split Grid for resizable panels - * - Window helper functions (createBackendReq, waitForElement, etc.) + * + * Learning objectives: + * - Use host-injected iframe libraries without importing them in plugin backend code + * - Keep browser-only logic in renderOnLoad() + * - Route iframe UI actions to backend handlers through UI_MESSAGE + * - Treat this example as an injected-libraries/runtime-boundary lesson, not a DOM-helper lesson + * + * Important: + * - DOM helpers are still a best practice for general SDK-native structured UI + * - This example intentionally uses plain markup so the focus stays on injected globals and iframe runtime behavior */ -import { FDO_SDK, FDOInterface, PluginMetadata, PluginRegistry } from "@anikitenko/fdo-sdk"; +import { + createClipboardReadRequest, + createClipboardWriteRequest, + FDO_SDK, + FDOInterface, + PluginMetadata, + PluginCapability, + PluginRegistry, +} from "@anikitenko/fdo-sdk"; export default class InjectedLibrariesDemoPlugin extends FDO_SDK implements FDOInterface { private readonly _metadata: PluginMetadata = { @@ -26,6 +35,10 @@ export default class InjectedLibrariesDemoPlugin extends FDO_SDK implements FDOI return this._metadata; } + declareCapabilities(): PluginCapability[] { + return ["system.clipboard.read", "system.clipboard.write"]; + } + init(): void { this.log("InjectedLibrariesDemoPlugin initialized!"); PluginRegistry.registerHandler("demo.getPluginInfo", async (content?: unknown) => { @@ -36,6 +49,7 @@ export default class InjectedLibrariesDemoPlugin extends FDO_SDK implements FDOI typeof (content as { id?: unknown }).id === "string" ? (content as { id: string }).id : "unknown-plugin"; + return { pluginId, pluginName: this.metadata.name, @@ -43,147 +57,123 @@ export default class InjectedLibrariesDemoPlugin extends FDO_SDK implements FDOI runtime: "backend-handler", }; }); + + PluginRegistry.registerHandler("demo.getClipboardWriteRequest", async (content?: unknown) => { + const text = + typeof content === "object" && + content !== null && + "text" in content && + typeof (content as { text?: unknown }).text === "string" + ? (content as { text: string }).text + : ""; + + if (!text) { + throw new Error("Clipboard demo requires a non-empty text payload."); + } + + return createClipboardWriteRequest( + text, + "copy editor content from injected libraries demo" + ); + }); + + PluginRegistry.registerHandler("demo.getClipboardReadRequest", async () => { + return createClipboardReadRequest("read clipboard content from injected libraries demo"); + }); } render(): string { return ` - - -
+

Injected Libraries Demo

- - -
+ +

1. Pure CSS Responsive Grid

- Column 1
+ Column 1
Full width on mobile, half on tablet, third on desktop
- Column 2
+ Column 2
Responsive grid using Pure CSS
- Column 3
+ Column 3
No extra CSS needed!
- - -
+ +

2. Notyf Notifications

- - - +
+ + + +
- - -
+ +

3. FontAwesome Icons

-
- - - - - - - - +
+ + + + + + + +
- - -
+ +

4. Syntax Highlighting with Highlight.js

// JavaScript code with syntax highlighting
 const message = "Hello, World!";
 
-function greet(name) {
+function greet(name) {
     return \`Hello, \${name}!\`;
-}
+}
 
-class Plugin extends FDO_SDK {
-    init() {
+class Plugin extends FDO_SDK {
+    init() {
         this.log("Plugin initialized");
-    }
-}
+    }
+}
 
- - -
+ +

5. ACE Code Editor

-
- +
+
+ + +
- - -
+ +

6. Resizable Panels with Split Grid

-
-
+
+

Left Panel

Drag the center gutter to resize panels.

    @@ -192,69 +182,83 @@ class Plugin extends FDO_SDK {
  • Snap to grid support
-
-
+
+

Right Panel

Perfect for creating split-view layouts!

-
Split({
-    columnGutters: [{
+                            
Split({
+    columnGutters: [{
         track: 1,
         element: document.querySelector('.gutter-col-1')
-    }]
-});
+ }] +});
- - -
+ +

7. Window Helper Functions

- - - -
- Output will appear here... +
+ + +
+
Output will appear here...
- - + + setOutput(\`Injected Libraries Demo loaded successfully.
\${JSON.stringify({
+                    Notyf: typeof Notyf !== "undefined",
+                    hljs: typeof hljs !== "undefined",
+                    ace: typeof ace !== "undefined",
+                    Split: typeof Split !== "undefined"
+                }, null, 2)}
\`); + })(); `; } } diff --git a/examples/08-privileged-actions-plugin.ts b/examples/08-privileged-actions-plugin.ts index 8a650f7..90b7bca 100644 --- a/examples/08-privileged-actions-plugin.ts +++ b/examples/08-privileged-actions-plugin.ts @@ -1,95 +1,128 @@ import { createFilesystemMutateActionRequest, + createFilesystemScopeCapability, + createPrivilegedActionBackendRequest, FDOInterface, FDO_SDK, - isPrivilegedActionSuccessResponse, - requestPrivilegedAction, + PluginCapability, + PluginRegistry, + PluginMetadata, } from "@anikitenko/fdo-sdk"; export class PrivilegedActionsPlugin extends FDO_SDK implements FDOInterface { - get metadata() { - return { - name: "PrivilegedActionsPlugin", - version: "1.0.0", - author: "FDO Team", - description: "Demonstrates the low-level host privileged action request flow with correlation IDs.", - icon: "shield", - }; + private readonly _metadata: PluginMetadata = { + name: "PrivilegedActionsPlugin", + version: "1.0.0", + author: "FDO Team", + description: "Demonstrates the low-level host privileged action request flow with correlation IDs.", + icon: "shield", + }; + + get metadata(): PluginMetadata { + return this._metadata; + } + + declareCapabilities(): PluginCapability[] { + return [ + "system.hosts.write", + createFilesystemScopeCapability("etc-hosts"), + ]; } init(): void { - // Host handler name is host-defined. Keep it stable and document it in host integration docs. this.log("PrivilegedActionsPlugin initialized"); + + PluginRegistry.registerHandler("privileged.buildDryRunRequest", async () => { + const request = createFilesystemMutateActionRequest({ + action: "system.fs.mutate", + payload: { + scope: "etc-hosts", + dryRun: true, + reason: "preview managed hosts block update", + operations: [ + { + type: "writeFile", + path: "/etc/hosts", + content: "# managed by fdo plugin", + encoding: "utf8", + }, + ], + }, + }); + + return createPrivilegedActionBackendRequest(request, { + correlationIdPrefix: "etc-hosts", + }); + }); } render(): string { - return `
-

Privileged Actions Demo

-

This file demonstrates the low-level transport helper path.

-

Prefer curated operator fixtures and requestOperatorTool(...) first when a known tool family fits.

-

Click to request a dry-run scoped filesystem mutation in host runtime.

- -

-        
`; + return ` +
+

Privileged Actions Demo

+

This example teaches the low-level host privileged action path.

+

Prefer curated operator fixtures and requestOperatorTool(...) first when a known tool family fits.

+

This example intentionally stays low-level: backend code builds a validated privileged-action envelope, and the iframe sends that envelope to the host privileged-action handler.

+

Declared capabilities: system.hosts.write and system.fs.scope.etc-hosts.

+ +
Result will appear here...
+
+ `; } renderOnLoad(): string { - const request = createFilesystemMutateActionRequest({ - action: "system.fs.mutate", - payload: { - scope: "etc-hosts", - dryRun: true, - reason: "preview managed hosts block update", - operations: [ - { - type: "writeFile", - path: "/etc/hosts", - content: "# managed by fdo plugin", - encoding: "utf8", - }, - ], - }, - }); - return ` - () => { - const btn = document.getElementById("run-privileged-action"); + (() => { + const button = document.getElementById("run-privileged-action"); const resultBox = document.getElementById("result-box"); - if (!btn || !resultBox) return; - btn.addEventListener("click", async () => { - let correlationId = "unknown"; + if (!button || !resultBox) { + return; + } + + const setResult = (value) => { + resultBox.textContent = typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + }; + + button.addEventListener("click", async () => { + setResult("Building privileged-action envelope..."); + try { - const response = await (${requestPrivilegedAction.toString()})(${JSON.stringify(request)}, { - correlationIdPrefix: "etc-hosts", + const envelope = await window.createBackendReq("UI_MESSAGE", { + handler: "privileged.buildDryRunRequest", + content: {}, }); - correlationId = response.correlationId; - if (${isPrivilegedActionSuccessResponse.toString()}(response)) { - resultBox.textContent = JSON.stringify({ + const typedEnvelope = envelope; + const response = await window.createBackendReq("requestPrivilegedAction", typedEnvelope); + + if (response && response.ok) { + setResult({ status: "ok", correlationId: response.correlationId, result: response.result ?? null, - }, null, 2); + }); return; } - resultBox.textContent = JSON.stringify({ + setResult({ status: "error", - correlationId: response?.correlationId ?? correlationId, + correlationId: response?.correlationId ?? typedEnvelope?.correlationId ?? "unknown", error: response?.error ?? "Unknown host error", code: response?.code ?? "UNKNOWN", - }, null, 2); + }); } catch (error) { - resultBox.textContent = JSON.stringify({ + const message = error instanceof Error ? error.message : String(error); + setResult({ status: "error", - correlationId, - error: error instanceof Error ? error.message : String(error), + error: message, code: "IPC_FAILURE", - }, null, 2); + }); } }); - } + })(); `; } } diff --git a/examples/09-operator-plugin.ts b/examples/09-operator-plugin.ts index 7194391..d26b3bf 100644 --- a/examples/09-operator-plugin.ts +++ b/examples/09-operator-plugin.ts @@ -1,12 +1,13 @@ import { + createOperatorToolActionRequest, createOperatorToolCapabilityPreset, + createPrivilegedActionBackendRequest, FDOInterface, FDO_SDK, getOperatorToolPreset, - isPrivilegedActionErrorResponse, - isPrivilegedActionSuccessResponse, + PluginCapability, PluginMetadata, - requestOperatorTool, + PluginRegistry, } from "@anikitenko/fdo-sdk"; export default class OperatorPluginExample extends FDO_SDK implements FDOInterface { @@ -14,7 +15,7 @@ export default class OperatorPluginExample extends FDO_SDK implements FDOInterfa name: "Operator Plugin Example", version: "1.0.0", author: "FDO SDK", - description: "Demonstrates Docker/Kubernetes style operator flows through scoped host process execution.", + description: "Demonstrates the curated operator helper path for a known tool family.", icon: "console", }; @@ -22,83 +23,97 @@ export default class OperatorPluginExample extends FDO_SDK implements FDOInterfa return this._metadata; } + declareCapabilities(): PluginCapability[] { + return createOperatorToolCapabilityPreset("docker-cli"); + } + init(): void { this.info("Operator plugin example initialized", { logDirectory: this.getLogDirectory(), preset: getOperatorToolPreset("docker-cli"), requestedCapabilities: createOperatorToolCapabilityPreset("docker-cli"), }); + + PluginRegistry.registerHandler("operator.buildDockerStatusRequest", async () => { + const request = createOperatorToolActionRequest("docker-cli", { + command: "/usr/local/bin/docker", + args: ["ps", "--format", "json"], + timeoutMs: 5000, + dryRun: true, + reason: "preview running containers for dashboard", + }); + + return createPrivilegedActionBackendRequest(request, { + correlationIdPrefix: "docker-cli", + }); + }); } render(): string { return `

Operator Plugin Example

-

This file demonstrates the curated operator helper path for a known tool family.

-

Preferred capability bundle: ${JSON.stringify(createOperatorToolCapabilityPreset("docker-cli"))}

-

Preferred request helper: requestOperatorTool("docker-cli", ...)

- -

+                

This example teaches the curated operator helper path for a known tool family.

+

Declared capabilities: broad host tool execution plus the narrow Docker CLI scope.

+

Preferred request builder: createOperatorToolActionRequest("docker-cli", ...)

+

Preferred runtime path: backend builds the curated operator request, iframe sends the envelope to the host privileged-action handler.

+ +
Result will appear here...
`; } renderOnLoad(): string { return ` - () => { + (() => { const button = document.getElementById("run-docker-status"); const output = document.getElementById("operator-result-box"); - if (!button || !output) return; + + if (!button || !output) { + return; + } + + const setOutput = (value) => { + output.textContent = typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + }; button.addEventListener("click", async () => { - let correlationId = "unknown"; + setOutput("Building curated operator envelope..."); + try { - const response = await (${requestOperatorTool.toString()})("docker-cli", { - command: "/usr/local/bin/docker", - args: ["ps", "--format", "json"], - timeoutMs: 5000, - dryRun: true, - reason: "preview running containers for dashboard", - }, { - correlationIdPrefix: "docker-cli", + const envelope = await window.createBackendReq("UI_MESSAGE", { + handler: "operator.buildDockerStatusRequest", + content: {}, }); - correlationId = response.correlationId; - if (${isPrivilegedActionSuccessResponse.toString()}(response)) { - output.textContent = JSON.stringify({ + const response = await window.createBackendReq("requestPrivilegedAction", envelope); + + if (response && response.ok) { + setOutput({ status: "ok", correlationId: response.correlationId, result: response.result ?? null, - }, null, 2); + }); return; } - if (${isPrivilegedActionErrorResponse.toString()}(response)) { - output.textContent = JSON.stringify({ - status: "error", - correlationId: response.correlationId, - error: response.error, - code: response.code ?? "UNKNOWN", - }, null, 2); - return; - } - - output.textContent = JSON.stringify({ + setOutput({ status: "error", - correlationId: response?.correlationId ?? correlationId, - error: "Invalid privileged action response envelope", - code: "INVALID_RESPONSE", - }, null, 2); + correlationId: response?.correlationId ?? envelope?.correlationId ?? "unknown", + error: response?.error ?? "Unknown host error", + code: response?.code ?? "UNKNOWN", + }); } catch (error) { - output.textContent = JSON.stringify({ + setOutput({ status: "error", - correlationId, error: error instanceof Error ? error.message : String(error), code: "IPC_FAILURE", - }, null, 2); + }); } }); - } + })(); `; } } diff --git a/examples/10-system-file-plugin.ts b/examples/10-system-file-plugin.ts new file mode 100644 index 0000000..b6e6dfa --- /dev/null +++ b/examples/10-system-file-plugin.ts @@ -0,0 +1,146 @@ +import { + createFilesystemMutateActionRequest, + createFilesystemScopeCapability, + createPrivilegedActionBackendRequest, + FDOInterface, + FDO_SDK, + PluginCapability, + PluginMetadata, + PluginRegistry, +} from "@anikitenko/fdo-sdk"; + +/** + * Learning example 10: generic system file mutation. + * + * Why this exists: + * - 08 teaches the low-level privileged transport path using /etc/hosts + * - this example is the next logical move for a developer who needs a different system file + * - it demonstrates the generic system.fs.mutate contract with a narrow filesystem scope + * + * This example uses /etc/motd as a simple text-file target and keeps the action in dry-run mode. + */ +export default class SystemFilePlugin extends FDO_SDK implements FDOInterface { + private static readonly HANDLER = "systemFile.v1.buildMotdDryRunRequest"; + + private readonly _metadata: PluginMetadata = { + name: "SystemFilePlugin", + version: "1.0.0", + author: "FDO Team", + description: "Demonstrates low-level scoped filesystem mutation for a system file other than /etc/hosts.", + icon: "document-share", + }; + + private readonly scopeId = "etc-motd"; + + get metadata(): PluginMetadata { + return this._metadata; + } + + declareCapabilities(): PluginCapability[] { + return [ + "system.hosts.write", + createFilesystemScopeCapability(this.scopeId), + ]; + } + + init(): void { + this.info("System file plugin initialized", { + declaredCapabilities: this.declareCapabilities(), + scopeId: this.scopeId, + handler: SystemFilePlugin.HANDLER, + }); + + PluginRegistry.registerHandler(SystemFilePlugin.HANDLER, async () => { + const request = createFilesystemMutateActionRequest({ + action: "system.fs.mutate", + payload: { + scope: this.scopeId, + dryRun: true, + reason: "preview managed motd banner update", + operations: [ + { + type: "appendFile", + path: "/etc/motd", + content: "\nManaged by FDO SDK example plugin\n", + encoding: "utf8", + }, + ], + }, + }); + + return createPrivilegedActionBackendRequest(request, { + correlationIdPrefix: "etc-motd", + }); + }); + } + + render(): string { + return ` +
+

Generic System File Mutation Demo

+

This example is the next logical step after 08-privileged-actions-plugin.ts.

+

It teaches the same low-level privileged transport path, but uses the generic system.fs.mutate contract for a different scoped system file.

+

Target file: /etc/motd. Scope: system.fs.scope.etc-motd.

+

Backend code builds the validated privileged-action envelope, and the iframe sends that envelope to the host privileged-action handler.

+ +
Result will appear here...
+
+ `; + } + + renderOnLoad(): string { + return ` + (() => { + const button = document.getElementById("run-system-file-action"); + const resultBox = document.getElementById("system-file-result"); + + if (!button || !resultBox) { + return; + } + + const setResult = (value) => { + resultBox.textContent = typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + }; + + button.addEventListener("click", async () => { + setResult("Building privileged-action envelope..."); + + try { + const envelope = await window.createBackendReq("UI_MESSAGE", { + handler: "${SystemFilePlugin.HANDLER}", + content: {}, + }); + + const response = await window.createBackendReq("requestPrivilegedAction", envelope); + + if (response && response.ok) { + setResult({ + status: "ok", + correlationId: response.correlationId, + result: response.result ?? null, + }); + return; + } + + setResult({ + status: "error", + correlationId: response?.correlationId ?? envelope?.correlationId ?? "unknown", + error: response?.error ?? "Unknown host error", + code: response?.code ?? "UNKNOWN", + }); + } catch (error) { + setResult({ + status: "error", + error: error instanceof Error ? error.message : String(error), + code: "IPC_FAILURE", + }); + } + }); + })(); + `; + } +} + +new SystemFilePlugin(); diff --git a/examples/README.md b/examples/README.md index e228ebf..5e5e482 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,18 +10,22 @@ Primary authoring entry points: 1. **fixtures/minimal-plugin.fixture.ts** Pattern: smallest valid plugin scaffold with stable lifecycle behavior. + Teaching intent: metadata, `init()`, and `render()` only, with no extra runtime concepts mixed in. 2. **fixtures/error-handling-plugin.fixture.ts** Pattern: deterministic `@handleError` behavior with safe render fallback UI. + Teaching intent: init-time handler registration, real `UI_MESSAGE` calls, and a render fallback that remains safe in mixed runtime conditions. 3. **fixtures/storage-plugin.fixture.ts** Pattern: plugin-scoped default/json store usage with graceful JSON-store unavailability handling. -4. **fixtures/advanced-ui-plugin.fixture.ts** - Pattern: advanced semantic/table/action UI composition with DOM helper classes. -5. **fixtures/operator-kubernetes-plugin.fixture.ts** + Teaching intent: backend storage handlers, session fallback when JSON storage is unavailable, and real UI_MESSAGE-driven storage actions. +4. **fixtures/operator-kubernetes-plugin.fixture.ts** Pattern: curated `kubectl` operator preset for cluster-console style plugins, including inspect/act workflow modeling. -6. **fixtures/operator-terraform-plugin.fixture.ts** + Teaching intent: declared preset capabilities, backend-built operator/workflow envelopes, and host-mediated execution through the privileged-action boundary. +5. **fixtures/operator-terraform-plugin.fixture.ts** Pattern: curated `terraform` operator preset for plan/apply style plugins, including preview/apply workflow modeling. -7. **fixtures/operator-custom-tool-plugin.fixture.ts** + Teaching intent: declared preset capabilities, backend-built plan/workflow envelopes, and host-mediated execution through the privileged-action boundary. +6. **fixtures/operator-custom-tool-plugin.fixture.ts** Pattern: generic scoped process execution for host-specific/internal tools not covered by curated presets. + Teaching intent: broad execution plus a narrow custom process scope, backend-built scoped-process envelopes, and host-mediated execution through the privileged-action boundary. Use the operator fixtures for production-oriented DevOps/SRE/plugin authoring work. Use the non-operator fixtures for lifecycle, error-handling, storage, and UI composition baselines. @@ -44,17 +48,13 @@ The examples are still numbered to indicate learning progression: 4. **04-ui-extensions-plugin.ts** - Quick actions and side panel integration 5. **05-advanced-dom-plugin.ts** - Advanced DOM generation with styling 6. **06-error-handling-plugin.ts** - Error handling and debugging techniques -7. **07-injected-libraries-demo.ts** - Demonstrates all automatically injected libraries and helper functions +7. **07-injected-libraries-demo.ts** - Demonstrates host-injected iframe libraries, browser-only helpers, the `UI_MESSAGE` bridge pattern, and host-mediated clipboard read/write flows 8. **08-privileged-actions-plugin.ts** - Low-level host privileged action flow using `requestPrivilegedAction(...)` with correlation ids and stable response handling -9. **09-operator-plugin.ts** - Curated operator helper example for a known tool family using `requestOperatorTool(...)` +9. **09-operator-plugin.ts** - Curated operator helper example for a known tool family using operator request builders, declared preset capabilities, and host-mediated execution +10. **10-system-file-plugin.ts** - Generic scoped filesystem mutation for a system file other than `/etc/hosts`, using `system.fs.mutate` and the same low-level privileged transport pattern as `08` For operator-style plugins, prefer host-mediated `system.process.exec` with a narrow scope such as `system.process.scope.docker-cli`, `system.process.scope.kubectl`, `system.process.scope.terraform`, or another explicit tool-family scope rather than raw shell execution. -## Additional Examples - -- **dom_elements_plugin.ts** - Comprehensive examples of DOM element creation -- **metadata-template.ts** - Template for plugin metadata structure - ## Privileged Action Envelope Pattern For host-mediated privileged operations, use a stable response envelope and correlation IDs. @@ -132,7 +132,24 @@ For detailed guidance on stable fixtures, public docs, and authoring expectation - **Solution**: Check that `render()` returns a valid HTML string - **Solution**: Verify DOM helper classes are used correctly (example 05) - **Solution**: Check browser console for JavaScript errors -- **Solution**: Ensure `renderHTML()` is called when using DOM helpers +- **Solution**: Ensure `renderHTML()` is called when DOM helpers generate styled output. This is mandatory for helper-generated goober CSS to be emitted with the markup. +- **Solution**: Do not embed raw JSON snapshots directly in `render()`. For result panels, render a placeholder first and populate it after iframe initialization through backend/UI calls. + +### Choosing DOM Helpers vs Plain Markup + +- **Rule**: DOM helpers are the preferred SDK pattern for general structured UI +- **Exception**: Some teaching examples intentionally use plain markup to isolate a different lesson, such as iframe-injected libraries or the backend bridge contract +- **Learning path from `01` to `09`**: + - `01` uses plain markup to teach the minimal lifecycle contract + - `02` uses plain markup to teach handlers, `renderOnLoad()`, and `UI_MESSAGE` + - `03` uses plain markup to teach storage and backend/UI separation + - `04` uses plain markup to teach quick actions and side-panel routing + - `05` teaches DOM helpers directly + - `06` uses plain markup to keep error handling and fallback behavior clear + - `07` uses plain markup to teach injected libraries and iframe helpers + - `08` uses plain markup to teach low-level privileged-action transport + - `09` uses plain markup to teach the curated operator-helper path without mixing in unrelated UI abstraction + - `10` uses plain markup to teach generic scoped filesystem mutation for non-hosts system files ### Message Handlers Not Working diff --git a/examples/dom_elements_plugin.ts b/examples/dom_elements_plugin.ts deleted file mode 100644 index b0ae812..0000000 --- a/examples/dom_elements_plugin.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { FDO_SDK, FDOInterface, PluginMetadata, DOMTable, DOMMedia, DOMSemantic, DOMNested, DOMInput } from "@anikitenko/fdo-sdk"; - -/** - * Example plugin demonstrating the new DOM element creation capabilities. - * This plugin showcases tables, media elements, semantic HTML5 structures, - * ordered/definition lists, and select dropdowns. - */ -export default class DOMElementsExamplePlugin extends FDO_SDK implements FDOInterface { - private readonly _metadata: PluginMetadata = { - name: "DOM Elements Example", - version: "1.0.0", - author: "FDO SDK Team", - description: "Example plugin demonstrating new DOM element creation capabilities", - icon: "widget" - }; - - get metadata(): PluginMetadata { - return this._metadata; - } - - init(): void { - this.log("DOM Elements Example Plugin initialized!"); - } - - render(): string { - try { - const domTable = new DOMTable(); - const domMedia = new DOMMedia(); - const domSemantic = new DOMSemantic(); - const domNested = new DOMNested(); - const domInput = new DOMInput("example-select", {}); - - const tableHeader1 = domTable.createTableHeader(["Name"], {}, undefined, { scope: "col" }); - const tableHeader2 = domTable.createTableHeader(["Age"], {}, undefined, { scope: "col" }); - const tableHeader3 = domTable.createTableHeader(["Role"], {}, undefined, { scope: "col" }); - const headerRow = domTable.createTableRow([tableHeader1, tableHeader2, tableHeader3]); - const thead = domTable.createTableHead([headerRow]); - - const cell1 = domTable.createTableCell(["John Doe"]); - const cell2 = domTable.createTableCell(["30"]); - const cell3 = domTable.createTableCell(["Developer"]); - const dataRow1 = domTable.createTableRow([cell1, cell2, cell3]); - - const cell4 = domTable.createTableCell(["Jane Smith"]); - const cell5 = domTable.createTableCell(["28"]); - const cell6 = domTable.createTableCell(["Designer"]); - const dataRow2 = domTable.createTableRow([cell4, cell5, cell6]); - - const tbody = domTable.createTableBody([dataRow1, dataRow2]); - const caption = domTable.createCaption(["Employee Directory"]); - const table = domTable.createTable([caption, thead, tbody], { classes: ["employee-table"] }); - - const image = domMedia.createImage( - "/assets/logo.png", - "Company Logo", - { classes: ["logo-image"] }, - undefined, - { width: "200", height: "100", loading: "lazy" } - ); - - const header = domSemantic.createHeader(["

Welcome to FDO SDK

"]); - const nav = domSemantic.createNav([ - "Home", - "Documentation", - "Examples" - ]); - const article = domSemantic.createArticle([ - "

New DOM Elements

", - "

This plugin demonstrates the new DOM element creation capabilities.

" - ]); - const aside = domSemantic.createAside(["

Quick Links

  • API Reference
"]); - const footer = domSemantic.createFooter(["

© 2025 FDO SDK

"]); - - const listItem1 = domNested.createListItem(["Install the SDK"]); - const listItem2 = domNested.createListItem(["Create your plugin"]); - const listItem3 = domNested.createListItem(["Build and test"]); - const orderedList = domNested.createOrderedList([listItem1, listItem2, listItem3], { classes: ["steps-list"] }); - - const term1 = domNested.createDefinitionTerm(["FDO"]); - const desc1 = domNested.createDefinitionDescription(["FlexDevOps - A desktop application framework"]); - const term2 = domNested.createDefinitionTerm(["SDK"]); - const desc2 = domNested.createDefinitionDescription(["Software Development Kit"]); - const definitionList = domNested.createDefinitionList([term1, desc1, term2, desc2], { classes: ["glossary"] }); - - const option1 = new DOMInput("", {}).createOption("Select an option", "", true); - const option2 = new DOMInput("", {}).createOption("Option A", "a"); - const option3 = new DOMInput("", {}).createOption("Option B", "b"); - const option4 = new DOMInput("", {}).createOption("Option C", "c"); - const select = domInput.createSelect([option1, option2, option3, option4], () => { - const selected = document.getElementById("example-select"); - const statusNode = document.getElementById("example-select-status"); - if (selected && statusNode) { - statusNode.textContent = `Selected: ${(selected as HTMLSelectElement).value || "none"}`; - } - }); - - const groupOpt1 = new DOMInput("", {}).createOption("Item 1", "1"); - const groupOpt2 = new DOMInput("", {}).createOption("Item 2", "2"); - const optgroup1 = new DOMInput("", {}).createOptgroup("Group 1", [groupOpt1, groupOpt2]); - - const groupOpt3 = new DOMInput("", {}).createOption("Item 3", "3"); - const groupOpt4 = new DOMInput("", {}).createOption("Item 4", "4"); - const optgroup2 = new DOMInput("", {}).createOptgroup("Group 2", [groupOpt3, groupOpt4]); - - const groupedSelect = new DOMInput("grouped-select", {}).createSelect([optgroup1, optgroup2]); - - const mainContent = domSemantic.createMain([ - header, - nav, - "

Example 1: Data Table

", - table, - "

Example 2: Image

", - image, - "

Example 3: Semantic Structure

", - article, - aside, - "

Example 4: Ordered List

", - orderedList, - "

Example 5: Definition List

", - definitionList, - "

Example 6: Select Dropdown

", - select, - "

Selected: none

", - "

Example 7: Grouped Select

", - groupedSelect, - footer - ]); - - return mainContent; - } catch (error) { - this.error(error as Error); - return ` -
-

Error rendering plugin

-

DOM elements example failed to render. Check plugin logs for details.

-
- `; - } - } -} - -new DOMElementsExamplePlugin(); diff --git a/examples/fixtures/advanced-ui-plugin.fixture.ts b/examples/fixtures/advanced-ui-plugin.fixture.ts deleted file mode 100644 index 2c609d1..0000000 --- a/examples/fixtures/advanced-ui-plugin.fixture.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - DOMButton, - DOMNested, - DOMSemantic, - DOMTable, - DOMText, - FDOInterface, - FDO_SDK, - PluginMetadata, -} from "@anikitenko/fdo-sdk"; - -/** - * Scenario fixture: Advanced UI composition. - * Pattern intent: semantic layout + table + action controls with DOM helper classes. - */ -export default class AdvancedUIFixturePlugin extends FDO_SDK implements FDOInterface { - private readonly _metadata: PluginMetadata = { - name: "Fixture: Advanced UI", - version: "1.0.0", - author: "FDO SDK Team", - description: "Reference fixture for advanced DOM-helper UI composition", - icon: "layout", - }; - - get metadata(): PluginMetadata { - return this._metadata; - } - - init(): void { - this.info("Advanced UI fixture initialized"); - } - - render(): string { - try { - const text = new DOMText(); - const semantic = new DOMSemantic(); - const nested = new DOMNested(); - const table = new DOMTable(); - const button = new DOMButton(); - - const statusTable = table.createTable( - [ - table.createTableHead([ - table.createTableRow([ - table.createTableHeader(["Signal"]), - table.createTableHeader(["Value"]), - ]), - ]), - table.createTableBody([ - table.createTableRow([table.createTableCell(["Plugin"]), table.createTableCell([this._metadata.name])]), - table.createTableRow([table.createTableCell(["Status"]), table.createTableCell(["Healthy"])]), - ]), - ], - { - style: { - width: "100%", - borderCollapse: "collapse", - }, - } - ); - - return semantic.createMain( - [ - semantic.createHeader([ - text.createHText(1, this._metadata.name), - text.createPText(this._metadata.description), - ]), - semantic.createSection([statusTable], { style: { marginTop: "12px" } }), - semantic.createFooter( - [ - nested.createBlockDiv( - [ - button.createButton("Refresh", () => {}, { - style: { - padding: "8px 12px", - border: "1px solid #2c5cc5", - color: "#2c5cc5", - borderRadius: "4px", - cursor: "pointer", - }, - }), - ], - { style: { marginTop: "12px" } } - ), - ], - { style: { marginTop: "8px" } } - ), - ], - { style: { padding: "16px" } } - ); - } catch (error) { - this.error(error as Error); - return ` -
-

Fixture render fallback

-

Advanced UI fixture failed to render. Check plugin logs for details.

-
- `; - } - } -} - -new AdvancedUIFixturePlugin(); diff --git a/examples/fixtures/error-handling-plugin.fixture.ts b/examples/fixtures/error-handling-plugin.fixture.ts index 26bc64c..5adef99 100644 --- a/examples/fixtures/error-handling-plugin.fixture.ts +++ b/examples/fixtures/error-handling-plugin.fixture.ts @@ -2,7 +2,13 @@ import { FDOInterface, FDO_SDK, PluginMetadata, PluginRegistry, handleError } fr /** * Scenario fixture: Error-safe lifecycle and handler behavior. - * Pattern intent: deterministic fallback behavior for init/render/handler failures. + * Pattern intent: deterministic fallback behavior for init, backend handlers, and render failures. + * + * Why this fixture exists: + * - safe render fallback UI + * - explicit backend handler registration in init() + * - real iframe-to-backend handler invocation through UI_MESSAGE + * - minimal reusable pattern for resilient plugins */ export default class ErrorHandlingFixturePlugin extends FDO_SDK implements FDOInterface { private readonly _metadata: PluginMetadata = { @@ -19,8 +25,17 @@ export default class ErrorHandlingFixturePlugin extends FDO_SDK implements FDOIn @handleError({ errorMessage: "Fixture init failed" }) init(): void { - PluginRegistry.registerHandler("fixture:ok", (data: unknown) => ({ success: true, data })); - PluginRegistry.registerHandler("fixture:fail", () => { + this.info("Error handling fixture initialized", { + plugin: this.metadata.name, + version: this.metadata.version, + }); + + PluginRegistry.registerHandler("fixture.ok", (data: unknown) => ({ + ok: true, + data, + })); + + PluginRegistry.registerHandler("fixture.fail", () => { throw new Error("Intentional fixture handler failure"); }); } @@ -28,7 +43,7 @@ export default class ErrorHandlingFixturePlugin extends FDO_SDK implements FDOIn @handleError({ returnErrorUI: true, errorUIRenderer: (error: Error) => ` -
+

Plugin Error

${error.message}

@@ -36,12 +51,61 @@ export default class ErrorHandlingFixturePlugin extends FDO_SDK implements FDOIn }) render(): string { return ` -
-

${this._metadata.name}

-

Trigger "fixture:fail" from UI/backend to validate safe fallback behavior.

+
+

${this.metadata.name}

+

Use this fixture when your plugin needs deterministic init, handler, and render fallback behavior.

+

It keeps the pattern small: backend handlers in init(), safe fallback UI in render(), and iframe calls through UI_MESSAGE.

+
+ + +
+
Result will appear here...
`; } + + renderOnLoad(): string { + return ` + (() => { + const successButton = document.getElementById("fixture-ok-button"); + const failureButton = document.getElementById("fixture-fail-button"); + const output = document.getElementById("fixture-error-output"); + + if (!successButton || !failureButton || !output) { + return; + } + + const callHandler = (handler, content = {}) => + window.createBackendReq("UI_MESSAGE", { handler, content }); + + const setOutput = (value) => { + output.textContent = typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + }; + + const runHandler = async (handler, content = {}) => { + setOutput("Running..."); + try { + const result = await callHandler(handler, content); + setOutput(result); + } catch (error) { + setOutput({ + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + successButton.addEventListener("click", () => { + void runHandler("fixture.ok", { source: "fixture-ui" }); + }); + + failureButton.addEventListener("click", () => { + void runHandler("fixture.fail", {}); + }); + })(); + `; + } } new ErrorHandlingFixturePlugin(); diff --git a/examples/fixtures/minimal-plugin.fixture.ts b/examples/fixtures/minimal-plugin.fixture.ts index 56ac68d..9973506 100644 --- a/examples/fixtures/minimal-plugin.fixture.ts +++ b/examples/fixtures/minimal-plugin.fixture.ts @@ -2,7 +2,13 @@ import { FDOInterface, FDO_SDK, PluginMetadata } from "@anikitenko/fdo-sdk"; /** * Scenario fixture: Minimal plugin baseline. - * Pattern intent: smallest valid plugin with predictable lifecycle behavior. + * Pattern intent: smallest production-grade scaffold with predictable lifecycle behavior. + * + * Why this fixture exists: + * - clean metadata shape + * - explicit init/render lifecycle + * - no DOM-helper or bridge complexity + * - safe first customization point for new plugins */ export default class MinimalFixturePlugin extends FDO_SDK implements FDOInterface { private readonly _metadata: PluginMetadata = { @@ -18,14 +24,18 @@ export default class MinimalFixturePlugin extends FDO_SDK implements FDOInterfac } init(): void { - this.info("Minimal fixture initialized"); + this.info("Minimal fixture initialized", { + plugin: this.metadata.name, + version: this.metadata.version, + }); } render(): string { return ` -
-

${this._metadata.name}

-

Use this fixture as the baseline for new plugins.

+
+

${this.metadata.name}

+

Use this fixture as the smallest stable starting point for new plugins.

+

Customize metadata first, then add handlers, storage, UI helpers, or operator flows only when your plugin actually needs them.

`; } diff --git a/examples/fixtures/operator-custom-tool-plugin.fixture.ts b/examples/fixtures/operator-custom-tool-plugin.fixture.ts index 7f156d8..0c1103a 100644 --- a/examples/fixtures/operator-custom-tool-plugin.fixture.ts +++ b/examples/fixtures/operator-custom-tool-plugin.fixture.ts @@ -1,17 +1,29 @@ import { FDOInterface, FDO_SDK, + PluginCapability, PluginMetadata, + PluginRegistry, createProcessCapabilityBundle, + createPrivilegedActionBackendRequest, createScopedProcessExecActionRequest, - requestScopedProcessExec, } from "@anikitenko/fdo-sdk"; /** * Scenario fixture: Custom internal operator tool. * Pattern intent: generic scoped helper flow for host-specific tools that are not in the curated preset set. + * + * Why this fixture exists: + * - declares the narrow custom process scope up front + * - builds a validated scoped-process envelope in backend code + * - uses UI_MESSAGE from the iframe to fetch that envelope + * - sends the envelope through the host privileged-action path */ export default class OperatorCustomToolFixturePlugin extends FDO_SDK implements FDOInterface { + private static readonly HANDLERS = { + PREVIEW_STATUS: "customToolFixture.v2.previewRunnerStatus", + } as const; + private readonly _metadata: PluginMetadata = { name: "Fixture: Custom Operator Tool", version: "1.0.0", @@ -26,39 +38,91 @@ export default class OperatorCustomToolFixturePlugin extends FDO_SDK implements return this._metadata; } - init(): void { - const request = createScopedProcessExecActionRequest(this.scopeId, { - command: "/usr/local/bin/internal-runner", - args: ["status"], - timeoutMs: 3000, - dryRun: true, - reason: "preview internal runner status", - }); + declareCapabilities(): PluginCapability[] { + return createProcessCapabilityBundle(this.scopeId); + } + init(): void { this.info("Custom operator fixture initialized", { scopeId: this.scopeId, + declaredCapabilities: this.declareCapabilities(), requestedCapabilities: createProcessCapabilityBundle(this.scopeId), - request, + handlers: OperatorCustomToolFixturePlugin.HANDLERS, }); + + PluginRegistry.registerHandler( + OperatorCustomToolFixturePlugin.HANDLERS.PREVIEW_STATUS, + async () => this.buildPreviewRunnerStatusEnvelope() + ); } render(): string { return ` -
+

${this._metadata.name}

-

Use generic scoped helpers when the tool family is host-specific.

+

Fixture handler version: customToolFixture.v2.*

+

Use generic scoped process execution when the tool family is host-specific and not covered by a curated operator preset.

+

This fixture declares the broad execution capability plus the narrow ${this.scopeId} process scope.

+

Backend code builds the validated envelope. The iframe asks for that envelope through UI_MESSAGE and sends it to the host privileged-action handler.

+
+ +
+
Result will appear here...
`; } - async previewRunnerStatus(): Promise { - return requestScopedProcessExec(this.scopeId, { + renderOnLoad(): string { + return ` + (() => { + const previewStatusButton = document.getElementById("custom-tool-preview-status"); + const output = document.getElementById("custom-tool-result"); + + if (!previewStatusButton || !output) { + return; + } + + const setOutput = (value) => { + output.textContent = typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + }; + + const runEnvelopeHandler = async (handler) => { + setOutput("Building request envelope..."); + try { + const envelope = await window.createBackendReq("UI_MESSAGE", { + handler, + content: {}, + }); + const response = await window.createBackendReq("requestPrivilegedAction", envelope); + setOutput(response); + } catch (error) { + setOutput({ + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + previewStatusButton.addEventListener("click", () => { + void runEnvelopeHandler("${OperatorCustomToolFixturePlugin.HANDLERS.PREVIEW_STATUS}"); + }); + })(); + `; + } + + private buildPreviewRunnerStatusEnvelope() { + const request = createScopedProcessExecActionRequest(this.scopeId, { command: "/usr/local/bin/internal-runner", args: ["status"], timeoutMs: 3000, dryRun: true, reason: "preview internal runner status", }); + + return createPrivilegedActionBackendRequest(request, { + correlationIdPrefix: "custom-tool", + }); } } diff --git a/examples/fixtures/operator-kubernetes-plugin.fixture.ts b/examples/fixtures/operator-kubernetes-plugin.fixture.ts index 13deecf..451d3a7 100644 --- a/examples/fixtures/operator-kubernetes-plugin.fixture.ts +++ b/examples/fixtures/operator-kubernetes-plugin.fixture.ts @@ -1,20 +1,32 @@ import { + createOperatorToolActionRequest, + createOperatorToolCapabilityPreset, + createPrivilegedActionBackendRequest, + createScopedWorkflowRequest, FDOInterface, FDO_SDK, + getOperatorToolPreset, + PluginCapability, PluginMetadata, PluginRegistry, - createOperatorToolCapabilityPreset, - getOperatorToolPreset, - createScopedWorkflowRequest, - requestOperatorTool, - requestScopedWorkflow, } from "@anikitenko/fdo-sdk"; /** * Scenario fixture: Kubernetes operator console. * Pattern intent: curated operator preset for a known DevOps/SRE tool family. + * + * Why this fixture exists: + * - declares the curated kubectl capability preset up front + * - builds validated operator/workflow envelopes in backend code + * - uses UI_MESSAGE from the iframe to fetch those envelopes + * - sends the envelopes through the host privileged-action path */ export default class OperatorKubernetesFixturePlugin extends FDO_SDK implements FDOInterface { + private static readonly HANDLERS = { + PREVIEW_OBJECTS: "kubectlFixture.v2.previewClusterObjects", + INSPECT_RESTART: "kubectlFixture.v2.inspectAndRestartWorkflow", + } as const; + private readonly _metadata: PluginMetadata = { name: "Fixture: Kubernetes Operator", version: "1.0.0", @@ -27,7 +39,7 @@ export default class OperatorKubernetesFixturePlugin extends FDO_SDK implements return this._metadata; } - declareCapabilities() { + declareCapabilities(): PluginCapability[] { return createOperatorToolCapabilityPreset("kubectl"); } @@ -37,70 +49,92 @@ export default class OperatorKubernetesFixturePlugin extends FDO_SDK implements preset, declaredCapabilities: this.declareCapabilities(), requestedCapabilities: createOperatorToolCapabilityPreset("kubectl"), + handlers: OperatorKubernetesFixturePlugin.HANDLERS, }); - PluginRegistry.registerHandler("kubectl.previewClusterObjects", async () => this.previewClusterObjects()); - PluginRegistry.registerHandler("kubectl.inspectAndRestartWorkflow", async () => this.inspectAndRestartWorkflow()); + PluginRegistry.registerHandler( + OperatorKubernetesFixturePlugin.HANDLERS.PREVIEW_OBJECTS, + async () => this.buildPreviewObjectsEnvelope() + ); + PluginRegistry.registerHandler( + OperatorKubernetesFixturePlugin.HANDLERS.INSPECT_RESTART, + async () => this.buildInspectRestartWorkflowEnvelope() + ); } render(): string { return ` -
-

${this._metadata.name}

-

Recommended host capability bundle: ${JSON.stringify(createOperatorToolCapabilityPreset("kubectl"))}

-

Preferred request helper: requestOperatorTool("kubectl", ...)

-

For inspect/act flows, prefer requestScopedWorkflow(...) over plugin-private orchestration.

-
- - +
+

${this.metadata.name}

+

Fixture handler version: kubectlFixture.v2.*

+

Use curated operator presets for known tool families such as kubectl.

+

Single-action preview uses a curated operator request. Inspect/act uses a scoped workflow request.

+

Backend code builds the validated envelope. The iframe asks for that envelope through UI_MESSAGE and sends it to the host privileged-action handler.

+
+ +
-

+        
Result will appear here...
`; } renderOnLoad(): string { return ` - () => { + (() => { const previewObjectsButton = document.getElementById("kubectl-preview-objects"); const inspectRestartWorkflowButton = document.getElementById("kubectl-inspect-restart-workflow"); const output = document.getElementById("kubectl-workflow-result"); - if (!previewObjectsButton || !inspectRestartWorkflowButton || !output) return; - const runHandler = async (handler) => { - output.textContent = "Running..."; + if (!previewObjectsButton || !inspectRestartWorkflowButton || !output) { + return; + } + + const setOutput = (value) => { + output.textContent = typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + }; + + const runEnvelopeHandler = async (handler) => { + setOutput("Building request envelope..."); try { - const result = await window.createBackendReq("UI_MESSAGE", { + const envelope = await window.createBackendReq("UI_MESSAGE", { handler, content: {}, }); - output.textContent = JSON.stringify(result, null, 2); + const response = await window.createBackendReq("requestPrivilegedAction", envelope); + setOutput(response); } catch (error) { - output.textContent = JSON.stringify({ + setOutput({ error: error instanceof Error ? error.message : String(error), - }, null, 2); + }); } }; previewObjectsButton.addEventListener("click", () => { - void runHandler("kubectl.previewClusterObjects"); + void runEnvelopeHandler("${OperatorKubernetesFixturePlugin.HANDLERS.PREVIEW_OBJECTS}"); }); inspectRestartWorkflowButton.addEventListener("click", () => { - void runHandler("kubectl.inspectAndRestartWorkflow"); + void runEnvelopeHandler("${OperatorKubernetesFixturePlugin.HANDLERS.INSPECT_RESTART}"); }); - } + })(); `; } - async previewClusterObjects(): Promise { - return requestOperatorTool("kubectl", { + private buildPreviewObjectsEnvelope() { + const request = createOperatorToolActionRequest("kubectl", { command: "/usr/local/bin/kubectl", args: ["get", "pods", "--all-namespaces", "-o", "json"], timeoutMs: 5000, dryRun: true, reason: "preview cluster workload inventory", }); + + return createPrivilegedActionBackendRequest(request, { + correlationIdPrefix: "kubectl", + }); } buildInspectActWorkflow() { @@ -138,38 +172,9 @@ export default class OperatorKubernetesFixturePlugin extends FDO_SDK implements }); } - async inspectAndRestartWorkflow(): Promise { - return requestScopedWorkflow("kubectl", { - kind: "process-sequence", - title: "Inspect and restart deployment", - summary: "Inspect deployment state before running a scoped rollout restart", - dryRun: true, - steps: [ - { - id: "inspect-deployment", - title: "Inspect deployment", - phase: "inspect", - command: "/usr/local/bin/kubectl", - args: ["get", "deployment", "api", "-n", "default", "-o", "json"], - timeoutMs: 5000, - reason: "inspect deployment state before restart", - onError: "abort", - }, - { - id: "restart-deployment", - title: "Restart deployment", - phase: "mutate", - command: "/usr/local/bin/kubectl", - args: ["rollout", "restart", "deployment/api", "-n", "default"], - timeoutMs: 5000, - reason: "restart deployment after inspection", - onError: "abort", - }, - ], - confirmation: { - message: "Restart deployment api in namespace default?", - requiredForStepIds: ["restart-deployment"], - }, + private buildInspectRestartWorkflowEnvelope() { + return createPrivilegedActionBackendRequest(this.buildInspectActWorkflow(), { + correlationIdPrefix: "kubectl", }); } } diff --git a/examples/fixtures/operator-terraform-plugin.fixture.ts b/examples/fixtures/operator-terraform-plugin.fixture.ts index a6344b6..dc8fd96 100644 --- a/examples/fixtures/operator-terraform-plugin.fixture.ts +++ b/examples/fixtures/operator-terraform-plugin.fixture.ts @@ -1,20 +1,32 @@ import { + createPrivilegedActionBackendRequest, FDOInterface, FDO_SDK, + getOperatorToolPreset, + PluginCapability, PluginMetadata, PluginRegistry, createOperatorToolActionRequest, createOperatorToolCapabilityPreset, createScopedWorkflowRequest, - requestOperatorTool, - requestScopedWorkflow, } from "@anikitenko/fdo-sdk"; /** * Scenario fixture: Infrastructure plan console. * Pattern intent: curated operator preset for Terraform-style plan/apply workflows. + * + * Why this fixture exists: + * - declares the curated terraform capability preset up front + * - builds validated operator/workflow envelopes in backend code + * - uses UI_MESSAGE from the iframe to fetch those envelopes + * - sends the envelopes through the host privileged-action path */ export default class OperatorTerraformFixturePlugin extends FDO_SDK implements FDOInterface { + private static readonly HANDLERS = { + PREVIEW_PLAN: "terraformFixture.v2.previewPlan", + PREVIEW_APPLY_WORKFLOW: "terraformFixture.v2.previewApplyWorkflow", + } as const; + private readonly _metadata: PluginMetadata = { name: "Fixture: Terraform Operator", version: "1.0.0", @@ -27,86 +39,101 @@ export default class OperatorTerraformFixturePlugin extends FDO_SDK implements F return this._metadata; } - declareCapabilities() { + declareCapabilities(): PluginCapability[] { return createOperatorToolCapabilityPreset("terraform"); } init(): void { - const request = createOperatorToolActionRequest("terraform", { - command: "/usr/local/bin/terraform", - args: ["plan", "-input=false"], - timeoutMs: 10000, - dryRun: true, - reason: "preview infrastructure plan", - }); - + const preset = getOperatorToolPreset("terraform"); this.info("Terraform operator fixture initialized", { + preset, declaredCapabilities: this.declareCapabilities(), requestedCapabilities: createOperatorToolCapabilityPreset("terraform"), - request, + handlers: OperatorTerraformFixturePlugin.HANDLERS, }); - PluginRegistry.registerHandler("terraform.previewPlan", async () => this.previewPlan()); - PluginRegistry.registerHandler("terraform.previewApplyWorkflow", async () => this.previewAndApplyWorkflow()); + PluginRegistry.registerHandler( + OperatorTerraformFixturePlugin.HANDLERS.PREVIEW_PLAN, + async () => this.buildPreviewPlanEnvelope() + ); + PluginRegistry.registerHandler( + OperatorTerraformFixturePlugin.HANDLERS.PREVIEW_APPLY_WORKFLOW, + async () => this.buildPreviewApplyWorkflowEnvelope() + ); } render(): string { return ` -
+

${this._metadata.name}

-

Use curated capability and request helpers for known tool families.

-

For multi-step preview/apply flows, prefer requestScopedWorkflow(...) over plugin-private orchestration.

-
- - +

Fixture handler version: terraformFixture.v2.*

+

Use curated operator presets for known tool families such as Terraform.

+

Single-action preview uses a curated operator request. Preview/apply uses a scoped workflow request.

+

Backend code builds the validated envelope. The iframe asks for that envelope through UI_MESSAGE and sends it to the host privileged-action handler.

+
+ +
-

+        
Result will appear here...
`; } renderOnLoad(): string { return ` - () => { + (() => { const previewPlanButton = document.getElementById("terraform-preview-plan"); const previewApplyWorkflowButton = document.getElementById("terraform-preview-apply-workflow"); const output = document.getElementById("terraform-workflow-result"); - if (!previewPlanButton || !previewApplyWorkflowButton || !output) return; + if (!previewPlanButton || !previewApplyWorkflowButton || !output) { + return; + } - const runHandler = async (handler) => { - output.textContent = "Running..."; + const setOutput = (value) => { + output.textContent = typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + }; + + const runEnvelopeHandler = async (handler) => { + setOutput("Building request envelope..."); try { - const result = await window.createBackendReq("UI_MESSAGE", { + const envelope = await window.createBackendReq("UI_MESSAGE", { handler, content: {}, }); - output.textContent = JSON.stringify(result, null, 2); + const response = await window.createBackendReq("requestPrivilegedAction", envelope); + setOutput(response); } catch (error) { - output.textContent = JSON.stringify({ + setOutput({ error: error instanceof Error ? error.message : String(error), - }, null, 2); + }); } }; previewPlanButton.addEventListener("click", () => { - void runHandler("terraform.previewPlan"); + void runEnvelopeHandler("${OperatorTerraformFixturePlugin.HANDLERS.PREVIEW_PLAN}"); }); previewApplyWorkflowButton.addEventListener("click", () => { - void runHandler("terraform.previewApplyWorkflow"); + void runEnvelopeHandler("${OperatorTerraformFixturePlugin.HANDLERS.PREVIEW_APPLY_WORKFLOW}"); }); - } + })(); `; } - async previewPlan(): Promise { - return requestOperatorTool("terraform", { + private buildPreviewPlanEnvelope() { + const request = createOperatorToolActionRequest("terraform", { command: "/usr/local/bin/terraform", args: ["plan", "-input=false"], timeoutMs: 10000, dryRun: true, reason: "preview infrastructure plan", }); + + return createPrivilegedActionBackendRequest(request, { + correlationIdPrefix: "terraform", + }); } buildPreviewApplyWorkflow() { @@ -144,38 +171,9 @@ export default class OperatorTerraformFixturePlugin extends FDO_SDK implements F }); } - async previewAndApplyWorkflow(): Promise { - return requestScopedWorkflow("terraform", { - kind: "process-sequence", - title: "Terraform preview and apply", - summary: "Preview infrastructure changes before apply", - dryRun: true, - steps: [ - { - id: "plan", - title: "Generate plan", - phase: "preview", - command: "/usr/local/bin/terraform", - args: ["plan", "-input=false"], - timeoutMs: 10000, - reason: "preview infrastructure plan", - onError: "abort", - }, - { - id: "apply", - title: "Apply plan", - phase: "apply", - command: "/usr/local/bin/terraform", - args: ["apply", "-input=false", "tfplan"], - timeoutMs: 10000, - reason: "apply approved infrastructure plan", - onError: "abort", - }, - ], - confirmation: { - message: "Apply infrastructure changes?", - requiredForStepIds: ["apply"], - }, + private buildPreviewApplyWorkflowEnvelope() { + return createPrivilegedActionBackendRequest(this.buildPreviewApplyWorkflow(), { + correlationIdPrefix: "terraform", }); } } diff --git a/examples/fixtures/storage-plugin.fixture.ts b/examples/fixtures/storage-plugin.fixture.ts index 2253603..f0e1b04 100644 --- a/examples/fixtures/storage-plugin.fixture.ts +++ b/examples/fixtures/storage-plugin.fixture.ts @@ -2,9 +2,21 @@ import { FDOInterface, FDO_SDK, PluginMetadata, PluginRegistry, StoreType } from /** * Scenario fixture: Scoped storage usage. - * Pattern intent: in-memory state with optional JSON persistence when host root is configured. + * Pattern intent: in-memory session data plus optional JSON persistence when host storage is configured. + * + * Why this fixture exists: + * - demonstrates `default` vs `json` store roles + * - keeps JSON-store fallback explicit + * - uses backend handlers plus UI_MESSAGE for real storage interactions + * - gives authors a production-grade starting point for preference/state plugins */ export default class StorageFixturePlugin extends FDO_SDK implements FDOInterface { + private static readonly HANDLERS = { + GET_SNAPSHOT: "storageFixture.v2.getSnapshot", + SAVE_PREFERENCE: "storageFixture.v2.savePreference", + RECORD_ACTION: "storageFixture.v2.recordAction", + } as const; + private readonly _metadata: PluginMetadata = { name: "Fixture: Storage", version: "1.0.0", @@ -14,36 +26,204 @@ export default class StorageFixturePlugin extends FDO_SDK implements FDOInterfac }; private sessionStore: StoreType = PluginRegistry.useStore("default"); - private jsonStore?: StoreType; + private persistentStore?: StoreType; + private jsonStoreAvailable = false; + private readonly THEME_KEY = "theme"; + private readonly LAST_VISIT_KEY = "lastVisitAt"; + private readonly VISITS_KEY = "visits"; + private readonly LAST_ACTION_KEY = "lastAction"; get metadata(): PluginMetadata { return this._metadata; } init(): void { - const visitCount = (this.sessionStore.get("visits") ?? 0) + 1; - this.sessionStore.set("visits", visitCount); + const visitCount = (this.sessionStore.get(this.VISITS_KEY) ?? 0) + 1; + this.sessionStore.set(this.VISITS_KEY, visitCount); + this.sessionStore.set(this.LAST_ACTION_KEY, "fixture-initialized"); try { - this.jsonStore = PluginRegistry.useStore("json"); - this.jsonStore.set("lastVisitAt", new Date().toISOString()); + this.persistentStore = PluginRegistry.useStore("json"); + this.jsonStoreAvailable = true; } catch (error) { - this.warn("JSON store unavailable for fixture (expected without configured storage root).", { error }); + this.jsonStoreAvailable = false; + this.warn("JSON store unavailable for storage fixture. Falling back to default store only.", { error }); + } + + if (this.persistentStore) { + this.persistentStore.set(this.LAST_VISIT_KEY, new Date().toISOString()); } + + this.info("Storage fixture initialized", { + plugin: this.metadata.name, + jsonStoreAvailable: this.jsonStoreAvailable, + visitCount, + }); + + PluginRegistry.registerHandler(StorageFixturePlugin.HANDLERS.GET_SNAPSHOT, () => this.getSnapshot()); + PluginRegistry.registerHandler(StorageFixturePlugin.HANDLERS.SAVE_PREFERENCE, (data: unknown) => this.savePreference(data)); + PluginRegistry.registerHandler(StorageFixturePlugin.HANDLERS.RECORD_ACTION, (data: unknown) => this.recordAction(data)); } - render(): string { - const visits = this.sessionStore.get("visits") ?? 0; - const persisted = this.jsonStore?.get("lastVisitAt"); + private getSnapshot() { + const persistedTheme = this.jsonStoreAvailable + ? this.persistentStore?.get(this.THEME_KEY) ?? "not set" + : this.sessionStore.get(this.THEME_KEY) ?? "not set (session fallback)"; + + return { + jsonStoreAvailable: this.jsonStoreAvailable, + storageMode: this.jsonStoreAvailable ? "persistent-json" : "session-fallback", + visits: this.sessionStore.get(this.VISITS_KEY) ?? 0, + lastAction: this.sessionStore.get(this.LAST_ACTION_KEY) ?? "none", + theme: persistedTheme, + lastVisitAt: this.persistentStore?.get(this.LAST_VISIT_KEY) ?? "JSON store not configured", + }; + } + + private savePreference(data: unknown) { + const theme = + typeof data === "object" && + data !== null && + "theme" in data && + typeof (data as { theme?: unknown }).theme === "string" + ? (data as { theme: string }).theme + : ""; + + if (!theme) { + return { + ok: false, + error: "Theme is required.", + }; + } + + if (!this.persistentStore) { + this.sessionStore.set(this.THEME_KEY, theme); + this.sessionStore.set(this.LAST_ACTION_KEY, `saved-theme-session:${theme}`); + return { + ok: true, + theme, + persistence: "session-fallback", + message: "JSON store is unavailable in this host runtime. Theme was saved to session storage only.", + }; + } + + this.persistentStore.set(this.THEME_KEY, theme); + this.sessionStore.set(this.LAST_ACTION_KEY, `saved-theme:${theme}`); + + return { + ok: true, + theme, + persistence: "persistent-json", + }; + } + + private recordAction(data: unknown) { + const action = + typeof data === "object" && + data !== null && + "action" in data && + typeof (data as { action?: unknown }).action === "string" + ? (data as { action: string }).action + : "manual-action"; + this.sessionStore.set(this.LAST_ACTION_KEY, action); + + return { + ok: true, + action, + }; + } + + render(): string { return ` -
-

${this._metadata.name}

-

Session visits: ${visits}

-

Last persisted visit: ${persisted ?? "JSON store not configured"}

+
+

${this.metadata.name}

+

Fixture handler version: storageFixture.v2.*

+

Use this fixture when your plugin needs plugin-scoped state plus optional JSON persistence.

+

Start by clicking Refresh Snapshot to inspect current session state and JSON-store availability.

+

Save Theme: dark always works. If JSON storage is unavailable, the fixture saves the theme to session storage and labels that as a non-persistent fallback.

+

Record Session Action always writes to the in-memory session store and should succeed even when JSON storage is unavailable.

+
+ + + +
+
Snapshot will load after initialization...
`; } + + renderOnLoad(): string { + return ` + (() => { + const refreshButton = document.getElementById("storage-refresh"); + const saveThemeButton = document.getElementById("storage-save-theme"); + const recordActionButton = document.getElementById("storage-record-action"); + const output = document.getElementById("storage-output"); + + if (!refreshButton || !saveThemeButton || !recordActionButton || !output) { + return; + } + + const callHandler = (handler, content = {}) => + window.createBackendReq("UI_MESSAGE", { handler, content }); + + const setOutput = (value) => { + output.textContent = typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + }; + + const refresh = async () => { + try { + const snapshot = await callHandler("${StorageFixturePlugin.HANDLERS.GET_SNAPSHOT}", {}); + setOutput(snapshot); + } catch (error) { + setOutput({ + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + refreshButton.addEventListener("click", () => { + void refresh(); + }); + + saveThemeButton.addEventListener("click", async () => { + setOutput("Saving theme..."); + try { + const result = await callHandler("${StorageFixturePlugin.HANDLERS.SAVE_PREFERENCE}", { theme: "dark" }); + if (result && result.ok) { + await refresh(); + return; + } + + setOutput(result); + } catch (error) { + setOutput({ + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + recordActionButton.addEventListener("click", async () => { + setOutput("Recording action..."); + try { + await callHandler("${StorageFixturePlugin.HANDLERS.RECORD_ACTION}", { + action: "storage-fixture-ui-click", + }); + await refresh(); + } catch (error) { + setOutput({ + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + void refresh(); + })(); + `; + } } new StorageFixturePlugin(); diff --git a/examples/metadata-template.ts b/examples/metadata-template.ts deleted file mode 100644 index f4b57b0..0000000 --- a/examples/metadata-template.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Plugin Metadata Template - * - * This template provides a reusable structure for plugin metadata. - * Copy this template when creating your own plugin and customize the values. - * - * Compatible with SDK v1.x - */ - -import { PluginMetadata } from "@anikitenko/fdo-sdk"; - -/** - * Example metadata structure for a plugin. - * All fields are required by the FDOInterface. - */ -export const exampleMetadata: PluginMetadata = { - name: "Example Plugin", - - version: "1.0.0", - - author: "Your Name", - - description: "A brief description of what this plugin does", - - icon: "cog" -}; - -/** - * Usage example: - * - * import { exampleMetadata } from "./metadata-template"; - * - * class MyPlugin extends FDO_SDK implements FDOInterface { - * private readonly _metadata: PluginMetadata = { - * ...exampleMetadata, - * name: "My Custom Plugin", - * description: "My plugin description" - * }; - * - * get metadata(): PluginMetadata { - * return this._metadata; - * } - * } - */ diff --git a/src/DOM.ts b/src/DOM.ts index 011a6e1..1a32679 100644 --- a/src/DOM.ts +++ b/src/DOM.ts @@ -8,6 +8,14 @@ setup(null); export class DOM { private readonly selfCloseTag: boolean + private static readonly ATTRIBUTE_ALIAS_PRIORITY: Record = { + class: 2, + className: 1, + for: 2, + htmlFor: 1, + readonly: 2, + readOnly: 1, + }; static readonly DEFAULT_OPTIONS = { classes: [] as string[], style: {} as Record, @@ -23,6 +31,31 @@ export class DOM { this.selfCloseTag = selfCloseTag ?? false } + private runWithSSRCompatibleGoober(operation: () => T): T { + const globalObject: any = globalThis as any; + const hadWindow = Object.prototype.hasOwnProperty.call(globalObject, "window"); + const originalWindow = globalObject.window; + const shouldMaskWindow = + typeof globalObject.window === "object" && + globalObject.window !== null && + typeof globalObject.document === "undefined"; + + if (!shouldMaskWindow) { + return operation(); + } + + try { + globalObject.window = undefined; + return operation(); + } finally { + if (hadWindow) { + globalObject.window = originalWindow; + } else { + delete globalObject.window; + } + } + } + /** * Creates a style using goober’s css function. * @@ -40,7 +73,7 @@ export class DOM { * }); */ public createClassFromStyle(styleObj: Record): string { - return gooberCss({...styleObj}) + return this.runWithSSRCompatibleGoober(() => gooberCss({...styleObj})) } /** @@ -62,27 +95,30 @@ export class DOM { * `); */ public createStyleKeyframe(keyframe: string): string { - return keyframes`${keyframe}` + return this.runWithSSRCompatibleGoober(() => keyframes`${keyframe}`) } /** - * Renders an element to an HTML string with CSS. - * @param element - The html element to render. + * Renders helper-composed markup to a raw HTML string with extracted CSS. + * DOM helpers accept some JSX-style aliases for compatibility, but the emitted + * attributes and style output must remain valid HTML for production rendering. + * + * @param element - The HTML fragment to render. * @returns {string} - The final HTML string with Goober's styles. * @example const html = renderHTML('
Hello, World!
'); * @uiSkip */ public renderHTML(element: string): string { - const cssText = extractCss(); - return `${element}` + const cssText = this.runWithSSRCompatibleGoober(() => extractCss()); + return `${element}` } /** * Creates a generic HTML element. * @param tag - The HTML tag name (e.g., 'div', 'button', 'p') * @param props - An object of attributes and event listeners - * @param children - Trusted JSX-like child fragments. Use DOMText helpers for untrusted/user text. - * @returns {string} - A virtual DOM element + * @param children - Trusted HTML child fragments. Use DOMText helpers for untrusted/user text. + * @returns {string} - A raw HTML element string * @example const div = createElement('div', { className: 'container' }, 'Hello, World!'); * @uiName Create element */ @@ -142,20 +178,21 @@ export class DOM { */ public createAttributes(props: Record): string { const booleanAttrs = new Set([ - "checked", "disabled", "readonly", "readOnly", "required", "autoplay", "controls", + "checked", "disabled", "readonly", "required", "autoplay", "controls", "hidden", "multiple", "selected", "default", "open", "loop" ]) + const normalizedProps = this.normalizeAttributeEntries( + Object.entries(props).filter(([key, value]) => key !== "customAttributes" && !(key.startsWith("on") && typeof value === "function")) + ); - return Object.entries(props) - .filter(([key, value]) => key !== "customAttributes" && !(key.startsWith("on") && typeof value === "function")) + return Object.entries(normalizedProps) .map(([key, value]) => { - const normalizedKey = this.normalizeJSXPropName(key); if (typeof value === "boolean") { - return booleanAttrs.has(normalizedKey) - ? (value ? normalizedKey : "") // render as `checked` if true, skip if false - : `${normalizedKey}="${this.escapeJSXAttributeValue(value)}"` // custom boolean-like (e.g., data-*) as string + return booleanAttrs.has(key) + ? (value ? key : "") // render as `checked` if true, skip if false + : `${key}="${this.escapeJSXAttributeValue(value)}"` // custom boolean-like (e.g., data-*) as string } - return `${normalizedKey}="${this.escapeJSXAttributeValue(value)}"` + return `${key}="${this.escapeJSXAttributeValue(value)}"` }) .filter(Boolean) .join(" ") @@ -171,7 +208,7 @@ export class DOM { public createOnAttributes(props: Record): string { return Object.entries(props) .filter(([key, value]) => (key.startsWith("on") && typeof value === "function")) - .map(([key, value]) => `${this.normalizeJSXPropName(key)}={${value.toString()}}`) + .map(([key, value]) => `${this.normalizeHTMLAttributeName(key)}={${value.toString()}}`) .join(' ').trim(); } @@ -200,8 +237,10 @@ export class DOM { return props; } - for (const [attr, value] of Object.entries(options.customAttributes)) { - props[this.normalizeJSXPropName(attr)] = value; + const normalizedCustomAttributes = this.normalizeAttributeEntries(Object.entries(options.customAttributes)); + + for (const [attr, value] of Object.entries(normalizedCustomAttributes)) { + props[attr] = value; } return props; @@ -235,23 +274,46 @@ export class DOM { } /** - * Normalizes known HTML aliases into JSX prop naming. + * Normalizes known attribute aliases into emitted HTML attribute naming. + * Accept JSX-style aliases for compatibility, but always emit raw HTML names. * @param key - Raw attribute/property name. - * @returns {string} - JSX-normalized prop name. + * @returns {string} - HTML-normalized attribute name. * @uiSkip */ - protected normalizeJSXPropName(key: string): string { - if (key === "class") { - return "className"; + protected normalizeHTMLAttributeName(key: string): string { + if (key === "class" || key === "className") { + return "class"; } - if (key === "for") { - return "htmlFor"; + if (key === "for" || key === "htmlFor") { + return "for"; } - if (key === "readonly") { - return "readOnly"; + if (key === "readonly" || key === "readOnly") { + return "readonly"; } return key; } + + private normalizeAttributeEntries(entries: Array<[string, any]>): Record { + const normalized: Record = {}; + + entries.forEach(([key, value], index) => { + const normalizedKey = this.normalizeHTMLAttributeName(key); + const priority = DOM.ATTRIBUTE_ALIAS_PRIORITY[key] ?? 0; + const existing = normalized[normalizedKey]; + + if (!existing || priority > existing.priority || (priority === existing.priority && index >= existing.index)) { + normalized[normalizedKey] = { + value, + priority, + index, + }; + } + }); + + return Object.fromEntries( + Object.entries(normalized).map(([key, entry]) => [key, entry.value]) + ); + } } export default DOM diff --git a/src/index.ts b/src/index.ts index 9906111..2644ca3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,8 @@ export { requireWorkflowProcessCapabilities, } from "./utils/capabilities"; export { + createClipboardReadActionRequest, + createClipboardWriteActionRequest, createFilesystemMutateActionRequest, createFilesystemScopeCapability, createHostsWriteActionRequest, @@ -36,6 +38,12 @@ export { createWorkflowRunActionRequest, validatePrivilegedActionRequest, } from "./utils/privilegedActions"; +export { + createClipboardReadRequest, + createClipboardWriteRequest, + requestClipboardRead, + requestClipboardWrite, +} from "./utils/clipboardTooling"; export { createPrivilegedActionCorrelationId, isPrivilegedActionErrorResponse, diff --git a/src/types.ts b/src/types.ts index 09015db..b8ffc34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,6 +41,8 @@ export type ProcessScopeCapability = `system.process.scope.${string}`; export type PluginCapability = | "storage.json" | "sudo.prompt" + | "system.clipboard.read" + | "system.clipboard.write" | "system.hosts.write" | "system.process.exec" | FilesystemScopeCapability @@ -64,11 +66,28 @@ export type FilesystemMutationOperation = | { type: "remove"; path: string; recursive?: boolean; force?: boolean }; export type HostPrivilegedAction = + | "system.clipboard.read" + | "system.clipboard.write" | "system.hosts.write" | "system.fs.mutate" | "system.process.exec" | "system.workflow.run"; +export type ClipboardReadActionRequest = { + action: "system.clipboard.read"; + payload: { + reason?: string; + }; +}; + +export type ClipboardWriteActionRequest = { + action: "system.clipboard.write"; + payload: { + text: string; + reason?: string; + }; +}; + export type HostsWriteActionRequest = { action: "system.hosts.write"; payload: { @@ -183,6 +202,8 @@ export type ScopedWorkflowResult = { }; export type HostPrivilegedActionRequest = + | ClipboardReadActionRequest + | ClipboardWriteActionRequest | HostsWriteActionRequest | FilesystemMutateActionRequest | ProcessExecActionRequest @@ -245,6 +266,7 @@ export type OperatorToolPresetDefinition = { export type CapabilityCategory = | "storage" | "sudo" + | "clipboard" | "hosts" | "filesystem-scope" | "process" diff --git a/src/utils/capabilities.ts b/src/utils/capabilities.ts index f6c3364..fd678b0 100644 --- a/src/utils/capabilities.ts +++ b/src/utils/capabilities.ts @@ -127,6 +127,24 @@ export function describeCapability(capability: PluginCapability | string): Capab }; } + if (capability === "system.clipboard.write") { + return { + capability, + label: "Clipboard Write", + description: "Allows the plugin to request host-mediated clipboard writes.", + category: "clipboard", + }; + } + + if (capability === "system.clipboard.read") { + return { + capability, + label: "Clipboard Read", + description: "Allows the plugin to request host-mediated clipboard reads.", + category: "clipboard", + }; + } + if (capability === "system.hosts.write") { return { capability, diff --git a/src/utils/clipboardTooling.ts b/src/utils/clipboardTooling.ts new file mode 100644 index 0000000..7de72ce --- /dev/null +++ b/src/utils/clipboardTooling.ts @@ -0,0 +1,67 @@ +import { + ClipboardReadActionRequest, + ClipboardWriteActionRequest, + PrivilegedActionResponse, + RequestPrivilegedActionOptions, +} from "../types"; +import { requestPrivilegedAction } from "./privilegedTransport"; +import { createClipboardReadActionRequest, createClipboardWriteActionRequest } from "./privilegedActions"; + +export function createClipboardReadRequest(reason?: string): ClipboardReadActionRequest { + return createClipboardReadActionRequest({ + action: "system.clipboard.read", + payload: { + ...(reason ? { reason } : {}), + }, + }); +} + +export function createClipboardWriteRequest( + text: string, + reason?: string +): ClipboardWriteActionRequest { + return createClipboardWriteActionRequest({ + action: "system.clipboard.write", + payload: { + text, + ...(reason ? { reason } : {}), + }, + }); +} + +export function requestClipboardWrite( + text: string, + reasonOrOptions?: string | RequestPrivilegedActionOptions, + options?: RequestPrivilegedActionOptions +): Promise> { + const reason = typeof reasonOrOptions === "string" ? reasonOrOptions : undefined; + const resolvedOptions = (typeof reasonOrOptions === "object" && reasonOrOptions !== null) + ? reasonOrOptions + : options; + + return requestPrivilegedAction( + createClipboardWriteRequest(text, reason), + { + correlationIdPrefix: "clipboard", + ...resolvedOptions, + } + ); +} + +export function requestClipboardRead( + reasonOrOptions?: string | RequestPrivilegedActionOptions, + options?: RequestPrivilegedActionOptions +): Promise> { + const reason = typeof reasonOrOptions === "string" ? reasonOrOptions : undefined; + const resolvedOptions = (typeof reasonOrOptions === "object" && reasonOrOptions !== null) + ? reasonOrOptions + : options; + + return requestPrivilegedAction( + createClipboardReadRequest(reason), + { + correlationIdPrefix: "clipboard", + ...resolvedOptions, + } + ); +} diff --git a/src/utils/contracts.ts b/src/utils/contracts.ts index 53d374b..6170624 100644 --- a/src/utils/contracts.ts +++ b/src/utils/contracts.ts @@ -27,9 +27,13 @@ export interface PluginInitPayload { const KNOWN_PLUGIN_CAPABILITIES = new Set([ "storage.json", "sudo.prompt", + "system.clipboard.read", + "system.clipboard.write", "system.hosts.write", "system.process.exec", ]); +const HOST_PRIVILEGED_ACTION_SYSTEM_CLIPBOARD_READ = "system.clipboard.read"; +const HOST_PRIVILEGED_ACTION_SYSTEM_CLIPBOARD_WRITE = "system.clipboard.write"; const HOST_PRIVILEGED_ACTION_SYSTEM_HOSTS_WRITE = "system.hosts.write"; const HOST_PRIVILEGED_ACTION_SYSTEM_FS_MUTATE = "system.fs.mutate"; const HOST_PRIVILEGED_ACTION_SYSTEM_PROCESS_EXEC = "system.process.exec"; @@ -290,6 +294,44 @@ function validateHostsWriteActionRequest(payload: Record): Host return payload as HostPrivilegedActionRequest; } +function validateClipboardReadActionRequest(payload: Record): HostPrivilegedActionRequest { + if (!isRecord(payload.payload)) { + throw new Error('Host privileged action "payload" must be an object.'); + } + + const candidatePayload = payload.payload as Record; + + if ( + candidatePayload.reason !== undefined + && (typeof candidatePayload.reason !== "string" || candidatePayload.reason.trim().length === 0) + ) { + throw new Error('Host privileged action payload field "reason" must be a non-empty string when provided.'); + } + + return payload as HostPrivilegedActionRequest; +} + +function validateClipboardWriteActionRequest(payload: Record): HostPrivilegedActionRequest { + if (!isRecord(payload.payload)) { + throw new Error('Host privileged action "payload" must be an object.'); + } + + const candidatePayload = payload.payload as Record; + + if (typeof candidatePayload.text !== "string" || candidatePayload.text.length === 0) { + throw new Error('Host privileged action payload field "text" must be a non-empty string.'); + } + + if ( + candidatePayload.reason !== undefined + && (typeof candidatePayload.reason !== "string" || candidatePayload.reason.trim().length === 0) + ) { + throw new Error('Host privileged action payload field "reason" must be a non-empty string when provided.'); + } + + return payload as HostPrivilegedActionRequest; +} + function validateFilesystemMutateActionRequest(payload: Record): FilesystemMutateActionRequest { if (!isRecord(payload.payload)) { throw new Error('Host privileged action "payload" must be an object.'); @@ -615,6 +657,14 @@ export function validateHostPrivilegedActionRequest(payload: unknown): HostPrivi return validateHostsWriteActionRequest(payload); } + if (payload.action === HOST_PRIVILEGED_ACTION_SYSTEM_CLIPBOARD_READ) { + return validateClipboardReadActionRequest(payload); + } + + if (payload.action === HOST_PRIVILEGED_ACTION_SYSTEM_CLIPBOARD_WRITE) { + return validateClipboardWriteActionRequest(payload); + } + if (payload.action === HOST_PRIVILEGED_ACTION_SYSTEM_FS_MUTATE) { return validateFilesystemMutateActionRequest(payload); } @@ -628,6 +678,6 @@ export function validateHostPrivilegedActionRequest(payload: unknown): HostPrivi } throw new Error( - `Host privileged action "action" must be "${HOST_PRIVILEGED_ACTION_SYSTEM_HOSTS_WRITE}", "${HOST_PRIVILEGED_ACTION_SYSTEM_FS_MUTATE}", "${HOST_PRIVILEGED_ACTION_SYSTEM_PROCESS_EXEC}", or "${HOST_PRIVILEGED_ACTION_SYSTEM_WORKFLOW_RUN}".` + `Host privileged action "action" must be "${HOST_PRIVILEGED_ACTION_SYSTEM_CLIPBOARD_READ}", "${HOST_PRIVILEGED_ACTION_SYSTEM_CLIPBOARD_WRITE}", "${HOST_PRIVILEGED_ACTION_SYSTEM_HOSTS_WRITE}", "${HOST_PRIVILEGED_ACTION_SYSTEM_FS_MUTATE}", "${HOST_PRIVILEGED_ACTION_SYSTEM_PROCESS_EXEC}", or "${HOST_PRIVILEGED_ACTION_SYSTEM_WORKFLOW_RUN}".` ); } diff --git a/src/utils/privilegedActions.ts b/src/utils/privilegedActions.ts index 3fc4c94..0e52cf2 100644 --- a/src/utils/privilegedActions.ts +++ b/src/utils/privilegedActions.ts @@ -1,4 +1,6 @@ import { + ClipboardReadActionRequest, + ClipboardWriteActionRequest, FilesystemMutateActionRequest, HostPrivilegedActionRequest, HostsWriteActionRequest, @@ -38,6 +40,14 @@ export function createHostsWriteActionRequest(request: HostsWriteActionRequest): return validateHostPrivilegedActionRequest(request) as HostsWriteActionRequest; } +export function createClipboardReadActionRequest(request: ClipboardReadActionRequest): ClipboardReadActionRequest { + return validateHostPrivilegedActionRequest(request) as ClipboardReadActionRequest; +} + +export function createClipboardWriteActionRequest(request: ClipboardWriteActionRequest): ClipboardWriteActionRequest { + return validateHostPrivilegedActionRequest(request) as ClipboardWriteActionRequest; +} + export function createFilesystemMutateActionRequest( request: FilesystemMutateActionRequest ): FilesystemMutateActionRequest { diff --git a/tests/DOM.test.ts b/tests/DOM.test.ts index 6fecbe7..3ccedb1 100644 --- a/tests/DOM.test.ts +++ b/tests/DOM.test.ts @@ -74,6 +74,36 @@ describe("DOM", () => { expect(className).toBe("mocked-class"); }); + test("createClassFromStyle should mask window when document is unavailable", () => { + const originalWindow = (globalThis as { window?: unknown }).window; + const originalDocument = (globalThis as { document?: unknown }).document; + (globalThis as { window?: unknown }).window = {}; + delete (globalThis as { document?: unknown }).document; + + const gooberMock = gooberCss as vi.MockedFunction; + gooberMock.mockImplementation(() => { + if (typeof (globalThis as { window?: unknown }).window === "object" && + typeof (globalThis as { document?: unknown }).document === "undefined") { + throw new ReferenceError("document is not defined"); + } + return "mocked-class"; + }); + + expect(() => (dom as any).createClassFromStyle({ color: "red" })).not.toThrow(); + + if (typeof originalWindow === "undefined") { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = originalWindow; + } + + if (typeof originalDocument === "undefined") { + delete (globalThis as { document?: unknown }).document; + } else { + (globalThis as { document?: unknown }).document = originalDocument; + } + }); + test("flattenChildren should flatten deeply nested arrays", () => { const children = [[["text1"], "text2"], [[null, "text3"]], undefined]; const result = (dom as any).flattenChildren(children); @@ -123,9 +153,10 @@ describe("DOM", () => { expect(extractCss).toHaveBeenCalled(); // Check the structure of the output - expect(result).toContain(""); // Style tag with extracted CSS + expect(result).toContain(""); // Style tag with extracted CSS expect(result).toContain("
Hello
"); // Original element expect(result).toContain(''); // Script placeholder + expect(result).not.toContain("{`"); // Verify the order of elements const styleIndex = result.indexOf("
Hello
`); + expect(result).toBe(`
Hello
`); + }); + + test("renderHTML should keep an explicit empty style tag when no CSS is extracted", () => { + const extractCssMock = extractCss as vi.MockedFunction; + extractCssMock.mockReturnValueOnce(""); + + const result = dom.renderHTML("
Empty CSS
"); + + expect(result).toBe(`
Empty CSS
`); }); test("createAttributes should return attributes without event handlers", () => { @@ -144,7 +184,7 @@ describe("DOM", () => { const attributes = (dom as any).createAttributes(props); expect(attributes.toString()).toContain(`id=\"test\"`); - expect(attributes.toString()).toContain(`className=\"box\"`); + expect(attributes.toString()).toContain(`class=\"box\"`); expect(attributes.toString()).not.toContain(`onClick`); }); @@ -152,7 +192,7 @@ describe("DOM", () => { const props = { id: "test", class: "box", onClick: vi.fn() }; const attributes = (dom as any).createAttributes(props); - expect(attributes).toBe(`id="test" className="box"`); + expect(attributes).toBe(`id="test" class="box"`); }); test("createAttributes should handle boolean attributes correctly", () => { @@ -169,7 +209,7 @@ describe("DOM", () => { expect(attributes).toContain("checked"); expect(attributes).not.toContain("disabled"); expect(attributes).toContain("required"); - expect(attributes).not.toContain("readOnly"); + expect(attributes).not.toContain("readonly"); expect(attributes).toContain("hidden"); expect(attributes).toContain(`data-custom="true"`); // Custom boolean attributes use string format }); @@ -183,7 +223,54 @@ describe("DOM", () => { expect(attributes).toContain( `title="x"y'z & <tag> {value} </tag>"` ); - expect(attributes).toContain(`htmlFor="field-id"`); + expect(attributes).toContain(`for="field-id"`); + }); + + test("createAttributes should normalize JSX-style aliases to HTML attributes", () => { + const attributes = (dom as any).createAttributes({ + className: "box", + htmlFor: "field-id", + readOnly: true, + }); + + expect(attributes).toContain(`class="box"`); + expect(attributes).toContain(`for="field-id"`); + expect(attributes).toContain(`readonly`); + expect(attributes).not.toContain(`className=`); + expect(attributes).not.toContain(`htmlFor=`); + expect(attributes).not.toContain(`readOnly`); + }); + + test("createAttributes should not emit duplicate attributes for HTML and JSX aliases", () => { + const attributes = (dom as any).createAttributes({ + class: "html-class", + className: "jsx-class", + for: "first", + htmlFor: "second", + readonly: false, + readOnly: true, + }); + + expect(attributes).toContain(`class="html-class"`); + expect(attributes).toContain(`for="first"`); + expect(attributes.match(/\bclass=/g)).toHaveLength(1); + expect(attributes.match(/\bfor=/g)).toHaveLength(1); + expect(attributes).not.toContain(`readonly`); + }); + + test("createAttributes should prefer native HTML aliases over JSX aliases explicitly", () => { + const attributes = (dom as any).createAttributes({ + className: "jsx-class", + class: "html-class", + htmlFor: "jsx-field", + for: "html-field", + readOnly: true, + readonly: false, + }); + + expect(attributes).toContain(`class="html-class"`); + expect(attributes).toContain(`for="html-field"`); + expect(attributes).not.toContain(`readonly`); }); test("createOnAttributes should return only event handlers as stringified functions", () => { @@ -199,7 +286,7 @@ describe("DOM", () => { test("createElement should create elements with various configurations", () => { // Test case 1: Element with both attributes and children const element1 = dom.createElement("div", { id: "test", "class": "box" }, "Content"); - expect(element1.toString()).toBe(`
Content
`); + expect(element1.toString()).toBe(`
Content
`); // Test case 2: Element with no attributes const element2 = dom.createElement("span", {}, "Hello"); @@ -211,7 +298,7 @@ describe("DOM", () => { // Test case 4: Element with both regular attributes and event handlers const element4 = dom.createElement("button", { onClick: () => {}, className: "mock"}, "Content"); - expect(element4.toString()).toBe(``); + expect(element4.toString()).toBe(``); }); test("should create keyframe and return the generated class name", () => { @@ -246,7 +333,7 @@ describe("DOM", () => { }, "Content"); // Verify the element has the keyframe class - expect(element.toString()).toBe(``); + expect(element.toString()).toBe(``); }); test("should create keyframe with complex animation steps", () => { diff --git a/tests/DOMButton.test.ts b/tests/DOMButton.test.ts index af736f0..14597a7 100644 --- a/tests/DOMButton.test.ts +++ b/tests/DOMButton.test.ts @@ -19,7 +19,7 @@ describe("DOMButton", () => { const button = domButton.createButton(label, mockOnClick, options, "test-button"); - expect(button.toString()).toBe(``); + expect(button.toString()).toBe(``); }); it("should apply default class when disableDefaultClass is not set", () => { diff --git a/tests/DOMInput.test.ts b/tests/DOMInput.test.ts index fd2dbcb..56cb028 100644 --- a/tests/DOMInput.test.ts +++ b/tests/DOMInput.test.ts @@ -18,18 +18,18 @@ describe("DOMInput", () => { it("should crate an input", () => { const input = domInput.createInput("text"); - expect(input).toBe(``) + expect(input).toBe(``) }) it("should create an input with a value", () => { const withValue = new DOMInput("input", {}, {value: "value"}) const input = withValue.createInput("text"); - expect(input).toBe(``) + expect(input).toBe(``) }) it("should create a textarea", () => { const textarea = domInput.createTextarea(); - expect(textarea).toBe(`