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: '
Test
', + setup() { + composableInstance = useMegaportWASM(); + return composableInstance; + }, + }); + const wrapper = mount(TestComponent); + + const start = Date.now(); + while (!composableInstance.isReady.value && Date.now() - start < 1000) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + if (!composableInstance.isReady.value) { + wrapper.unmount(); + throw new Error('WASM did not become ready in time'); + } + return { wrapper, composable: composableInstance }; +}; + describe('Interactive Mode', () => { beforeEach(() => { vi.clearAllMocks(); @@ -447,6 +478,126 @@ describe('Interactive Mode', () => { }); }); + describe('Password Masking', () => { + it('lets the handler mask input when the prompt type is password', () => { + let registeredHandler: ((request: any) => void) | null = null; + (window as any).registerPromptHandler = vi.fn((handler: any) => { + registeredHandler = handler; + return true; + }); + + const { registerPromptHandler } = useMegaportWASM(); + + const masked: Record = {}; + const maskingHandler = vi.fn((request: any) => { + masked[request.id] = request.type === 'password'; + }); + registerPromptHandler(maskingHandler); + + registeredHandler!({ id: 'p1', message: 'Enter name:', type: 'text' }); + registeredHandler!({ id: 'p2', message: 'Enter secret key:', type: 'password' }); + + expect(masked).toEqual({ p1: false, p2: true }); + }); + }); + + describe('Cancel Lifecycle', () => { + it('cancels via the request id and does not submit a response', () => { + let registeredHandler: ((request: any) => void) | null = null; + (window as any).registerPromptHandler = vi.fn((handler: any) => { + registeredHandler = handler; + return true; + }); + const mockSubmit = vi.fn(); + const mockCancel = vi.fn(); + (window as any).submitPromptResponse = mockSubmit; + (window as any).cancelPrompt = mockCancel; + + const { registerPromptHandler } = useMegaportWASM(); + + // Handler dismisses the prompt (e.g. user pressed Escape). + registerPromptHandler((request: any) => { + (window as any).cancelPrompt(request.id); + }); + + registeredHandler!({ id: 'p1', message: 'Continue? [y/N]', type: 'confirm' }); + + expect(mockCancel).toHaveBeenCalledWith('p1'); + expect(mockSubmit).not.toHaveBeenCalled(); + }); + }); + + describe('Async-Only Entrypoint', () => { + const originalFetch = global.fetch; + const originalWebAssembly = global.WebAssembly; + const originalGlobalGo = (global as any).Go; + const originalWindowGo = (window as any).Go; + + beforeEach(() => { + (global as any).Go = MockGo; + (window as any).Go = MockGo; + + global.fetch = vi.fn(() => + Promise.resolve({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + } as Response) + ); + + global.WebAssembly = { + instantiate: vi.fn(() => Promise.resolve({ instance: {}, module: {} })), + instantiateStreaming: vi.fn(), + } as any; + }); + + afterEach(() => { + global.fetch = originalFetch; + global.WebAssembly = originalWebAssembly; + (global as any).Go = originalGlobalGo; + (window as any).Go = originalWindowGo; + delete (window as any).executeMegaportCommandAsync; + delete (global as any).executeMegaportCommandAsync; + delete (window as any).executeMegaportCommand; + }); + + it('routes interactive commands through the async entrypoint, never the sync one', async () => { + const asyncMock = vi.fn((_cmd: string, cb: (r: any) => void) => + cb({ output: 'done', error: '' }) + ); + const syncMock = vi.fn(() => ({ output: 'sync', error: '' })); + (window as any).executeMegaportCommandAsync = asyncMock; + (window as any).executeMegaportCommand = syncMock; + + const { wrapper, composable } = await createReadyComposable(); + const result = await composable.execute('vxc buy --interactive'); + + expect(asyncMock).toHaveBeenCalledTimes(1); + expect(asyncMock.mock.calls[0][0]).toBe('vxc buy --interactive'); + expect(syncMock).not.toHaveBeenCalled(); + expect(result.output).toBe('done'); + + wrapper.unmount(); + }); + + it('surfaces the sync-entrypoint guard error instead of hanging', async () => { + // No async entrypoint, so the composable falls back to the sync one, which + // the WASM guard rejects when a command would prompt. The real + // executeMegaportCommand writes the failure into `output`, not `error`. + const guardError = 'interactive mode requires the async entrypoint'; + const syncMock = vi.fn(() => ({ output: guardError })); + delete (window as any).executeMegaportCommandAsync; + delete (global as any).executeMegaportCommandAsync; + (window as any).executeMegaportCommand = syncMock; + + const { wrapper, composable } = await createReadyComposable(); + const result = await composable.execute('vxc buy --interactive'); + + expect(syncMock).toHaveBeenCalledTimes(1); + expect(result.output || result.error).toContain('async entrypoint'); + + wrapper.unmount(); + }); + }); + describe('Prompt Message Formatting', () => { it('should handle prompts with HTML entities', () => { let registeredHandler: ((request: any) => void) | null = null; diff --git a/frontend-integration/types/megaport-wasm.d.ts b/frontend-integration/types/megaport-wasm.d.ts index 07360220..e904cb76 100644 --- a/frontend-integration/types/megaport-wasm.d.ts +++ b/frontend-integration/types/megaport-wasm.d.ts @@ -32,8 +32,8 @@ export interface MegaportBufferDump { export interface MegaportPromptRequest { id: string; message: string; - type: string; // "text", "confirm", "resource" - resourceType?: string; // for resource prompts + type: string; // "text" | "confirm" | "password" | "resource" + resourceType?: string; // set for "resource" and secret ("password") prompts } /** diff --git a/internal/wasm/prompts.go b/internal/wasm/prompts.go index 5f07ce68..35b523a6 100644 --- a/internal/wasm/prompts.go +++ b/internal/wasm/prompts.go @@ -39,8 +39,8 @@ var ( type PromptRequest struct { ID string Message string - PromptType string // "text", "confirm", "resource" - ResourceType string // for resource prompts (port, mcr, vxc, etc.) + PromptType string // "text", "confirm", "password", "resource" + ResourceType string // set for "resource" prompts and secret-resource prompts sent as "password" (port, mcr, vxc, etc.); empty otherwise ResponseChan chan string ErrorChan chan error }