diff --git a/WASM_README.md b/WASM_README.md index 02011fbf..1f24d948 100644 --- a/WASM_README.md +++ b/WASM_README.md @@ -96,6 +96,75 @@ It will be removed in a future release; new integrations should not call it. Full type definitions for the whole JS surface (auth, config file, prompts, telemetry) live in [`frontend-integration/types/megaport-wasm.d.ts`](frontend-integration/types/megaport-wasm.d.ts). +## Interactive Mode + +Some commands prompt for input (interactive `buy`/`update` flows, confirmations, secrets). +In the browser there is no stdin, so the WASM asks the host page for each value through a +small set of JavaScript functions. Wire these up or interactive commands will never +receive a response. + +### Async entrypoint is required + +Run any command that may prompt through **`executeMegaportCommandAsync(command, callback)`**. +The legacy synchronous **`executeMegaportCommand(command)`** is retired: it runs no command +at all and always returns `{ error: "synchronous execution is not supported; use +executeMegaportCommandAsync" }`. Use the async entrypoint so prompts work as intended. + +### Host functions + +The WASM registers these on `window` at startup: + +| Function | Purpose | +|---|---| +| `registerPromptHandler(cb)` | Register a callback the WASM invokes with each prompt request. | +| `submitPromptResponse(id, response)` | Reply to the prompt `id` with the user's input (a string). | +| `cancelPrompt(id)` | Cancel the prompt `id`. A value prompt fails with a "prompt cancelled by user" error; a confirmation is treated as declined. | + +### Prompt request shape + +Your handler receives a single object: + +```js +{ + id: "prompt_1_1700000000000000000", // unique id; echo it back in submit/cancel + message: "Enter port name:", // text to show the user + type: "text", // "text" | "confirm" | "password" | "resource" + resourceType: "port" // set for resource and secret-resource prompts (port, mcr, vxc, ...), else "" +} +``` + +Mask the input when `type === "password"`: render an `` or otherwise +hide the characters. Password prompts and secret-resource prompts (for example VXC/MVE +passwords and pre-shared keys) set this type. Note that some other secret-bearing inputs +(such as partner auth/service/shared keys and MVE registration keys) are currently sent as +`type === "resource"`, so don't rely on the password type alone if you want to mask every +possible secret. + +### Lifecycle + +```js +registerPromptHandler((request) => { + const masked = request.type === 'password'; + showPrompt(request.message, { masked }).then((answer) => { + if (answer === null) { + cancelPrompt(request.id); // user dismissed the prompt + } else { + submitPromptResponse(request.id, answer); + } + }); +}); + +executeMegaportCommandAsync('vxc buy --interactive', (result) => { + console.log(result.output || result.error); +}); +``` + +A prompt left unanswered times out after 10 minutes and the command receives an error. + +> **Output streaming:** interactive commands currently return their full output only when +> the command completes. Live, incremental output streaming is tracked separately and this +> section will document the subscription API once it lands. + ## Building The browser CLI is two pieces: the WASM binary and the Vue front end that hosts it. diff --git a/frontend-integration/INTEGRATION_GUIDE.md b/frontend-integration/INTEGRATION_GUIDE.md index 439b04ff..cff51d34 100644 --- a/frontend-integration/INTEGRATION_GUIDE.md +++ b/frontend-integration/INTEGRATION_GUIDE.md @@ -301,6 +301,72 @@ const checkVlan = async () => { ``` +## Interactive Commands + +Some CLI commands prompt for input (interactive `buy`/`update` flows, confirmations, and +secrets). The WASM has no stdin, so it asks the host page for each value. What happens when +you forget to wire up your own handler depends on how you drive the WASM: + +- **Via the composable:** `useMegaportWASM` registers a default no-op prompt handler at init, + so forgetting to override it with `registerPromptHandler()` means prompts hang until they + time out after 10 minutes (confirmations then resolve to declined), rather than failing + immediately. +- **Via the raw WASM globals:** if `registerPromptHandler` was never called, a value prompt + fails immediately with a "prompt callback not registered" error and a confirmation is + treated as declined. + +Once your own handler is registered, a prompt left unanswered times out after 10 minutes. + +**Always run interactive commands through `execute()`** (the composable uses the async +entrypoint under the hood). The legacy synchronous entrypoint is retired: it runs no +command at all and always returns a "synchronous execution is not supported" error, so it +can never deliver a prompt response. Keep to `execute()`. + +### Lifecycle + +1. **Register** a handler with `registerPromptHandler(cb)`. The WASM calls it for every + prompt. +2. **Receive** a request object: `{ id, message, type, resourceType }`, where `type` is + `"text"`, `"confirm"`, `"password"`, or `"resource"`. +3. **Reply** with `window.submitPromptResponse(id, response)` (a string), or dismiss it with + `window.cancelPrompt(id)`. A value prompt then fails with a "prompt cancelled by user" + error; a confirmation is treated as declined. + +Mask the input when `type === "password"`, for example with an ``. +Password prompts and secret-resource prompts (VXC/MVE passwords and pre-shared keys) set +this type. Some other secret-bearing inputs (partner auth/service/shared keys, MVE +registration keys) currently arrive as `type === "resource"`, so don't rely on the password +type alone to mask every secret. + +```typescript +import { useMegaportWASM } from '~/composables/megaport-cli/useMegaportWASM'; + +const { execute, registerPromptHandler } = useMegaportWASM(); + +registerPromptHandler((request) => { + const masked = request.type === 'password'; + + // Show your own UI (modal, inline terminal input, etc.) + promptUser(request.message, { masked }).then((answer) => { + if (answer === null) { + window.cancelPrompt(request.id); // user dismissed the prompt + } else { + window.submitPromptResponse(request.id, answer); + } + }); +}); + +// Interactive command; prompts drive the handler above. +const result = await execute('vxc buy --interactive'); +``` + +An unanswered prompt times out after 10 minutes and the command receives an error. +`MegaportTerminal.vue` shows a working inline-terminal prompt handler. + +> **Output streaming:** interactive commands currently resolve with their full output only +> once the command finishes. Live incremental streaming is tracked separately; this section +> will cover the subscription API once it lands. + ## Telemetry Track CLI usage: diff --git a/frontend-integration/__tests__/interactive.test.ts b/frontend-integration/__tests__/interactive.test.ts index 6a800587..624ff084 100644 --- a/frontend-integration/__tests__/interactive.test.ts +++ b/frontend-integration/__tests__/interactive.test.ts @@ -1,6 +1,37 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { defineComponent } from 'vue'; +import { mount } from '@vue/test-utils'; import { useMegaportWASM } from '../composables/useMegaportWASM'; +class MockGo { + run = vi.fn(); + importObject = {}; +} + +// Mount the composable inside a component so onMounted init runs, then wait for +// isReady so execute() can be exercised end to end. +const createReadyComposable = async () => { + let composableInstance: any; + const TestComponent = defineComponent({ + template: '