From 72e8bc5a011c71e70b399e7a526aa5f27fbc9cf8 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 06:39:47 -0700 Subject: [PATCH 01/14] docs(wasm): document interactive-mode host contract Document the WASM interactive-mode host contract (async-only requirement, registerPromptHandler/submitPromptResponse/cancelPrompt, prompt-request shape, and password masking) in WASM_README.md and the frontend INTEGRATION_GUIDE.md. Guard the legacy synchronous executeMegaportCommand entrypoint: a command that requests a prompt while running under it now returns a clear error instead of deadlocking the JS event loop. Extend interactive.test.ts with password-masking, cancel, and async-only assertions, and add a Go test for the sync-path guard. ESD-1584 --- CHANGELOG.md | 1 + WASM_README.md | 69 ++++++++ frontend-integration/INTEGRATION_GUIDE.md | 52 ++++++ .../__tests__/interactive.test.ts | 159 ++++++++++++++++++ internal/wasm/prompts.go | 20 +++ internal/wasm/prompts_test.go | 43 +++++ main_wasm.go | 5 + 7 files changed, 349 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dedd2ac7..827a8dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ workflow (scripts/update-changelog.sh). Don't hand-edit them or add entries unde ## [Unreleased] ### Fixed +- WASM interactive mode: the legacy synchronous `executeMegaportCommand` entrypoint now returns a clear "interactive mode requires the async entrypoint" error when a command requests a prompt, instead of deadlocking the browser event loop. Documented the interactive-mode host contract in `WASM_README.md` and `frontend-integration/INTEGRATION_GUIDE.md` (ESD-1584) - require Cisco FMC fields only when not managing locally on the `mve buy` and `mve validate` flags and JSON paths, matching the validator (ESD-1571) - `mve buy` and `mve validate` now apply `resourceTags` from JSON input, and interactive `mve buy` now prompts for tags, matching MCR. Previously the JSON path silently dropped the documented `resourceTags` field and interactive mode never asked. The JSON path shares the same value and empty-key validation as the flags path, so non-string values and empty keys return a usage error before the order is placed diff --git a/WASM_README.md b/WASM_README.md index dffed01c..4985ad3c 100644 --- a/WASM_README.md +++ b/WASM_README.md @@ -71,6 +71,75 @@ token, or API proxy. Credentials and tokens live only in browser memory and are cleared on reload or when the page calls `clearAuthCredentials`. +## 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)`**, +never the legacy synchronous **`executeMegaportCommand(command)`**. + +The sync entrypoint runs the command inline on the JS→WASM call, so a prompt would block +the event loop and the host could never deliver a response. If a command requests a prompt +under the sync entrypoint it now fails fast with an error telling you to use the async +entrypoint, rather than hanging. + +### 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`; the command receives a "cancelled by user" error. | + +### 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 prompts (port, mcr, vxc, ...), else "" +} +``` + +Mask the input when `type === "password"`: render an `` or otherwise +hide the characters. Both the password prompt and secret-resource prompts set this type, so +keying off it covers every secret the CLI asks for. + +### 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 5 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 f460ac93..ee8ef7f1 100644 --- a/frontend-integration/INTEGRATION_GUIDE.md +++ b/frontend-integration/INTEGRATION_GUIDE.md @@ -263,6 +263,58 @@ 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. You must register +a handler or interactive commands will hang waiting for a response. + +**Always run interactive commands through `execute()`** (the composable prefers the async +entrypoint under the hood). The legacy synchronous entrypoint runs the command inline and +cannot deliver a prompt response, so a command that prompts under it fails fast with an +"interactive mode requires the async entrypoint" error instead of hanging. + +### 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)` (the command then receives a "cancelled by user" error). + +Mask the input when `type === "password"`, for example with an ``. +Both password prompts and secret-resource prompts set this type. + +```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 5 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..fac9d3f6 100644 --- a/frontend-integration/__tests__/interactive.test.ts +++ b/frontend-integration/__tests__/interactive.test.ts @@ -1,6 +1,36 @@ import { describe, it, expect, beforeEach, 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) { + throw new Error('WASM did not become ready in time'); + } + return { wrapper, composable: composableInstance }; +}; + describe('Interactive Mode', () => { beforeEach(() => { vi.clearAllMocks(); @@ -447,6 +477,135 @@ 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 }); + }); + + it('treats secret-resource prompts as password type', () => { + let registeredHandler: ((request: any) => void) | null = null; + (window as any).registerPromptHandler = vi.fn((handler: any) => { + registeredHandler = handler; + return true; + }); + + const { registerPromptHandler } = useMegaportWASM(); + const handler = vi.fn(); + registerPromptHandler(handler); + + // wasmSecretResourcePrompt sends type "password" with a resourceType set. + registeredHandler!({ + id: 'p3', + message: 'Enter API secret:', + type: 'password', + resourceType: 'servicekey', + }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ type: 'password', resourceType: 'servicekey' }) + ); + }); + }); + + 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', () => { + 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; + }); + + 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. + const guardError = + 'interactive mode requires the async entrypoint: run this command through executeMegaportCommandAsync, not the synchronous executeMegaportCommand, which cannot deliver prompt responses'; + const syncMock = vi.fn(() => ({ error: 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.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/internal/wasm/prompts.go b/internal/wasm/prompts.go index f196d6b7..618b217f 100644 --- a/internal/wasm/prompts.go +++ b/internal/wasm/prompts.go @@ -22,8 +22,24 @@ var ( // promptCounter generates unique IDs for each prompt promptCounter atomic.Int64 + + // syncExecutionDepth is > 0 while a command runs under the legacy synchronous + // entrypoint. Prompting there blocks the JS event loop, which can never + // deliver a response, so PromptForInput fails fast instead of deadlocking. + syncExecutionDepth atomic.Int32 ) +// BeginSyncExecution marks the start of a synchronous command execution so that +// any prompt request fails fast rather than deadlocking the JS event loop. +func BeginSyncExecution() { + syncExecutionDepth.Add(1) +} + +// EndSyncExecution marks the end of a synchronous command execution. +func EndSyncExecution() { + syncExecutionDepth.Add(-1) +} + // PromptRequest represents a pending prompt waiting for user input type PromptRequest struct { ID string @@ -51,6 +67,10 @@ func RegisterPromptCallback(callback js.Value) { // PromptForInput requests input from the user via JavaScript // This function blocks until the JavaScript side provides a response func PromptForInput(message string, promptType string, resourceType string) (string, error) { + if syncExecutionDepth.Load() > 0 { + return "", fmt.Errorf("interactive mode requires the async entrypoint: run this command through executeMegaportCommandAsync, not the synchronous executeMegaportCommand, which cannot deliver prompt responses") + } + promptCallbackMu.RLock() cb := promptCallback promptCallbackMu.RUnlock() diff --git a/internal/wasm/prompts_test.go b/internal/wasm/prompts_test.go index 3116967a..50a82019 100644 --- a/internal/wasm/prompts_test.go +++ b/internal/wasm/prompts_test.go @@ -4,6 +4,7 @@ package wasm import ( "fmt" + "strings" "sync" "syscall/js" "testing" @@ -140,6 +141,48 @@ func TestPromptForInput(t *testing.T) { } } +func TestPromptForInputSyncGuard(t *testing.T) { + // A registered callback would otherwise let the prompt proceed; the sync + // guard must short-circuit before it is ever consulted. + fn := js.FuncOf(func(this js.Value, args []js.Value) interface{} { return nil }) + promptCallback = fn.Value + defer func() { promptCallback = js.Undefined() }() + + BeginSyncExecution() + _, err := PromptForInput("Enter name:", "text", "") + EndSyncExecution() + + if err == nil { + t.Fatal("expected an error when prompting under the sync entrypoint, got nil") + } + if !strings.Contains(err.Error(), "async entrypoint") { + t.Errorf("expected the error to name the async entrypoint, got %q", err.Error()) + } + + // Once the sync execution has ended, prompting works again. + go func() { + time.Sleep(50 * time.Millisecond) + pendingMutex.Lock() + var promptID string + for id := range pendingPrompts { + promptID = id + break + } + pendingMutex.Unlock() + if promptID != "" { + submitPromptResponse(js.Undefined(), []js.Value{js.ValueOf(promptID), js.ValueOf("ok")}) + } + }() + + resp, err := PromptForInput("Enter name:", "text", "") + if err != nil { + t.Fatalf("unexpected error after sync execution ended: %v", err) + } + if resp != "ok" { + t.Errorf("expected response %q, got %q", "ok", resp) + } +} + func TestSubmitPromptResponse(t *testing.T) { tests := []struct { name string diff --git a/main_wasm.go b/main_wasm.go index 83fea50c..7cfe97dc 100644 --- a/main_wasm.go +++ b/main_wasm.go @@ -49,6 +49,11 @@ func executeMegaportCommand(this js.Value, args []js.Value) interface{} { // Ensure Cobra gets all our commands megaport.EnsureRootCommandOutput(wasm.WasmOutputBuffer) + // Mark the sync entrypoint active so a command that requests a prompt fails + // fast with a clear error instead of deadlocking the JS event loop. + wasm.BeginSyncExecution() + defer wasm.EndSyncExecution() + megaport.ExecuteWithArgs(originalArgs) return map[string]interface{}{ From b58731537cb7996335dbeb348fe1f89cb2fdea6f Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 06:49:37 -0700 Subject: [PATCH 02/14] docs(wasm): address review round 1 (accuracy + tighter tests) - Reconcile docs/CHANGELOG with actual behavior: value prompts return the guard error, but confirmations are treated as declined (wasmConfirmPrompt swallows the error to false), so the sync entrypoint no longer deadlocks either way. - Fix cancel error string in docs to match code ("prompt cancelled by user"). - Add "password" to the PromptType field comment; note the guard counter is process-global and fails safe. - Drop the tautological secret-resource TS test that only asserted its own fixture. --- CHANGELOG.md | 2 +- WASM_README.md | 10 ++++---- frontend-integration/INTEGRATION_GUIDE.md | 7 +++--- .../__tests__/interactive.test.ts | 24 ------------------- internal/wasm/prompts.go | 5 +++- 5 files changed, 15 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 827a8dac..6da15a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ workflow (scripts/update-changelog.sh). Don't hand-edit them or add entries unde ## [Unreleased] ### Fixed -- WASM interactive mode: the legacy synchronous `executeMegaportCommand` entrypoint now returns a clear "interactive mode requires the async entrypoint" error when a command requests a prompt, instead of deadlocking the browser event loop. Documented the interactive-mode host contract in `WASM_README.md` and `frontend-integration/INTEGRATION_GUIDE.md` (ESD-1584) +- WASM interactive mode: the legacy synchronous `executeMegaportCommand` entrypoint no longer deadlocks the browser event loop when a command prompts. A value prompt now fails fast with a clear "interactive mode requires the async entrypoint" error and a confirmation is treated as declined. Documented the interactive-mode host contract in `WASM_README.md` and `frontend-integration/INTEGRATION_GUIDE.md` (ESD-1584) - require Cisco FMC fields only when not managing locally on the `mve buy` and `mve validate` flags and JSON paths, matching the validator (ESD-1571) - `mve buy` and `mve validate` now apply `resourceTags` from JSON input, and interactive `mve buy` now prompts for tags, matching MCR. Previously the JSON path silently dropped the documented `resourceTags` field and interactive mode never asked. The JSON path shares the same value and empty-key validation as the flags path, so non-string values and empty keys return a usage error before the order is placed diff --git a/WASM_README.md b/WASM_README.md index 4985ad3c..ad33a09b 100644 --- a/WASM_README.md +++ b/WASM_README.md @@ -84,9 +84,11 @@ Run any command that may prompt through **`executeMegaportCommandAsync(command, never the legacy synchronous **`executeMegaportCommand(command)`**. The sync entrypoint runs the command inline on the JS→WASM call, so a prompt would block -the event loop and the host could never deliver a response. If a command requests a prompt -under the sync entrypoint it now fails fast with an error telling you to use the async -entrypoint, rather than hanging. +the event loop and the host could never deliver a response. Under the sync entrypoint the +CLI no longer hangs on a prompt: a command that prompts for a value (text, password, or +resource input) fails fast with an error telling you to use the async entrypoint, and a +yes/no confirmation is treated as declined. Either way, run interactive commands through +the async entrypoint so prompts work as intended. ### Host functions @@ -96,7 +98,7 @@ The WASM registers these on `window` at startup: |---|---| | `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`; the command receives a "cancelled by user" error. | +| `cancelPrompt(id)` | Cancel the prompt `id`; the command receives a "prompt cancelled by user" error. | ### Prompt request shape diff --git a/frontend-integration/INTEGRATION_GUIDE.md b/frontend-integration/INTEGRATION_GUIDE.md index ee8ef7f1..a8671aa3 100644 --- a/frontend-integration/INTEGRATION_GUIDE.md +++ b/frontend-integration/INTEGRATION_GUIDE.md @@ -271,8 +271,9 @@ a handler or interactive commands will hang waiting for a response. **Always run interactive commands through `execute()`** (the composable prefers the async entrypoint under the hood). The legacy synchronous entrypoint runs the command inline and -cannot deliver a prompt response, so a command that prompts under it fails fast with an -"interactive mode requires the async entrypoint" error instead of hanging. +cannot deliver a prompt response. It no longer hangs: a command that prompts for a value +fails fast with an "interactive mode requires the async entrypoint" error, and a yes/no +confirmation is treated as declined. Neither is what you want, so keep to `execute()`. ### Lifecycle @@ -281,7 +282,7 @@ cannot deliver a prompt response, so a command that prompts under it fails fast 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)` (the command then receives a "cancelled by user" error). + `window.cancelPrompt(id)` (the command then receives a "prompt cancelled by user" error). Mask the input when `type === "password"`, for example with an ``. Both password prompts and secret-resource prompts set this type. diff --git a/frontend-integration/__tests__/interactive.test.ts b/frontend-integration/__tests__/interactive.test.ts index fac9d3f6..99ac6a24 100644 --- a/frontend-integration/__tests__/interactive.test.ts +++ b/frontend-integration/__tests__/interactive.test.ts @@ -498,30 +498,6 @@ describe('Interactive Mode', () => { expect(masked).toEqual({ p1: false, p2: true }); }); - - it('treats secret-resource prompts as password type', () => { - let registeredHandler: ((request: any) => void) | null = null; - (window as any).registerPromptHandler = vi.fn((handler: any) => { - registeredHandler = handler; - return true; - }); - - const { registerPromptHandler } = useMegaportWASM(); - const handler = vi.fn(); - registerPromptHandler(handler); - - // wasmSecretResourcePrompt sends type "password" with a resourceType set. - registeredHandler!({ - id: 'p3', - message: 'Enter API secret:', - type: 'password', - resourceType: 'servicekey', - }); - - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ type: 'password', resourceType: 'servicekey' }) - ); - }); }); describe('Cancel Lifecycle', () => { diff --git a/internal/wasm/prompts.go b/internal/wasm/prompts.go index 618b217f..50776e5f 100644 --- a/internal/wasm/prompts.go +++ b/internal/wasm/prompts.go @@ -26,6 +26,9 @@ var ( // syncExecutionDepth is > 0 while a command runs under the legacy synchronous // entrypoint. Prompting there blocks the JS event loop, which can never // deliver a response, so PromptForInput fails fast instead of deadlocking. + // It is process-global: mixing sync and async execution is unsupported (the + // docs tell integrators to always use the async entrypoint), and it fails + // safe: a stray rejection errors out rather than deadlocking. syncExecutionDepth atomic.Int32 ) @@ -44,7 +47,7 @@ func EndSyncExecution() { type PromptRequest struct { ID string Message string - PromptType string // "text", "confirm", "resource" + PromptType string // "text", "confirm", "password", "resource" ResourceType string // for resource prompts (port, mcr, vxc, etc.) ResponseChan chan string ErrorChan chan error From 562def52553e9c632825ee9d1df0035bf3a9738a Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 06:54:33 -0700 Subject: [PATCH 03/14] docs(wasm): correct password-masking guidance (review round 2) Some secret-bearing inputs (partner auth/service/shared keys, MVE registration/secret keys) are prompted as type "resource", not "password". Drop the inaccurate "covers every secret" claim and tell integrators not to rely on the password type alone to mask every secret. Note secret-resource prompts also carry a resourceType. --- WASM_README.md | 9 ++++++--- frontend-integration/INTEGRATION_GUIDE.md | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/WASM_README.md b/WASM_README.md index ad33a09b..fa7845d8 100644 --- a/WASM_README.md +++ b/WASM_README.md @@ -109,13 +109,16 @@ Your handler receives a single object: 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 prompts (port, mcr, vxc, ...), else "" + 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. Both the password prompt and secret-resource prompts set this type, so -keying off it covers every secret the CLI asks for. +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 diff --git a/frontend-integration/INTEGRATION_GUIDE.md b/frontend-integration/INTEGRATION_GUIDE.md index a8671aa3..00ea599d 100644 --- a/frontend-integration/INTEGRATION_GUIDE.md +++ b/frontend-integration/INTEGRATION_GUIDE.md @@ -285,7 +285,10 @@ confirmation is treated as declined. Neither is what you want, so keep to `execu `window.cancelPrompt(id)` (the command then receives a "prompt cancelled by user" error). Mask the input when `type === "password"`, for example with an ``. -Both password prompts and secret-resource prompts set this type. +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'; From 125e0500f51ee580e897c916f3e9e03b76c86aa7 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 07:05:18 -0700 Subject: [PATCH 04/14] fix: address Copilot review feedback on sync guard and tests Clamp EndSyncExecution at 0 with a CAS loop so a stray or duplicate call can't drive the counter negative and re-enable prompting under the sync entrypoint. Reset pendingPrompts and release the JS function wrapper in the sync-guard test to avoid cross-test state leaking. Shorten the hardcoded guard-error string in the frontend test to a stable substring instead of the full message. --- .../__tests__/interactive.test.ts | 3 +-- internal/wasm/prompts.go | 16 ++++++++++++++-- internal/wasm/prompts_test.go | 6 ++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/frontend-integration/__tests__/interactive.test.ts b/frontend-integration/__tests__/interactive.test.ts index 99ac6a24..1ea2c5c3 100644 --- a/frontend-integration/__tests__/interactive.test.ts +++ b/frontend-integration/__tests__/interactive.test.ts @@ -565,8 +565,7 @@ describe('Interactive Mode', () => { 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. - const guardError = - 'interactive mode requires the async entrypoint: run this command through executeMegaportCommandAsync, not the synchronous executeMegaportCommand, which cannot deliver prompt responses'; + const guardError = 'interactive mode requires the async entrypoint'; const syncMock = vi.fn(() => ({ error: guardError })); delete (window as any).executeMegaportCommandAsync; delete (global as any).executeMegaportCommandAsync; diff --git a/internal/wasm/prompts.go b/internal/wasm/prompts.go index 50776e5f..c8056a0d 100644 --- a/internal/wasm/prompts.go +++ b/internal/wasm/prompts.go @@ -38,9 +38,21 @@ func BeginSyncExecution() { syncExecutionDepth.Add(1) } -// EndSyncExecution marks the end of a synchronous command execution. +// EndSyncExecution marks the end of a synchronous command execution. It clamps +// at 0 so a stray or duplicate call can't drive the counter negative, which +// would let a subsequent BeginSyncExecution leave prompting incorrectly +// enabled under the sync entrypoint. func EndSyncExecution() { - syncExecutionDepth.Add(-1) + for { + cur := syncExecutionDepth.Load() + if cur <= 0 { + syncExecutionDepth.Store(0) + return + } + if syncExecutionDepth.CompareAndSwap(cur, cur-1) { + return + } + } } // PromptRequest represents a pending prompt waiting for user input diff --git a/internal/wasm/prompts_test.go b/internal/wasm/prompts_test.go index 50a82019..b78d59ab 100644 --- a/internal/wasm/prompts_test.go +++ b/internal/wasm/prompts_test.go @@ -142,9 +142,15 @@ func TestPromptForInput(t *testing.T) { } func TestPromptForInputSyncGuard(t *testing.T) { + // Ensure no stale state from other tests can cause this test to hang. + pendingMutex.Lock() + pendingPrompts = make(map[string]*PromptRequest) + pendingMutex.Unlock() + // A registered callback would otherwise let the prompt proceed; the sync // guard must short-circuit before it is ever consulted. fn := js.FuncOf(func(this js.Value, args []js.Value) interface{} { return nil }) + defer fn.Release() promptCallback = fn.Value defer func() { promptCallback = js.Undefined() }() From 49db8751e912ba4e27e7df1b64667b29023b72b3 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 07:32:39 -0700 Subject: [PATCH 05/14] fix: address Copilot round 2 review feedback Correct INTEGRATION_GUIDE.md: an unregistered prompt handler fails fast, it does not hang (the 5-minute timeout only applies once a handler is registered and a prompt goes unanswered). Unmount the test wrapper before throwing in createReadyComposable so a WASM-not-ready failure doesn't leave a mounted component behind. Restore fetch/WebAssembly/Go globals and the executeMegaportCommand mocks in an afterEach for the Async-Only Entrypoint tests so they can't leak into later tests in the file. --- frontend-integration/INTEGRATION_GUIDE.md | 5 +++-- .../__tests__/interactive.test.ts | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend-integration/INTEGRATION_GUIDE.md b/frontend-integration/INTEGRATION_GUIDE.md index 00ea599d..5c7c8709 100644 --- a/frontend-integration/INTEGRATION_GUIDE.md +++ b/frontend-integration/INTEGRATION_GUIDE.md @@ -266,8 +266,9 @@ 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. You must register -a handler or interactive commands will hang waiting for a response. +secrets). The WASM has no stdin, so it asks the host page for each value. If no handler is +registered, a prompt fails immediately with a "prompt callback not registered" error; once a +handler is registered, a prompt left unanswered times out after 5 minutes. **Always run interactive commands through `execute()`** (the composable prefers the async entrypoint under the hood). The legacy synchronous entrypoint runs the command inline and diff --git a/frontend-integration/__tests__/interactive.test.ts b/frontend-integration/__tests__/interactive.test.ts index 1ea2c5c3..6993cf22 100644 --- a/frontend-integration/__tests__/interactive.test.ts +++ b/frontend-integration/__tests__/interactive.test.ts @@ -1,4 +1,4 @@ -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'; @@ -26,6 +26,7 @@ const createReadyComposable = async () => { 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 }; @@ -527,6 +528,11 @@ describe('Interactive Mode', () => { }); 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; @@ -543,6 +549,16 @@ describe('Interactive Mode', () => { } 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: '' }) From 3fc9d13ef3eef302f75fceb05cda9a007f603922 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 07:39:04 -0700 Subject: [PATCH 06/14] fix: take promptCallbackMu in the sync-guard test Match the production locking discipline for promptCallback so a future refactor or parallelized test run can't reintroduce a data race on this package-level field. --- internal/wasm/prompts_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/wasm/prompts_test.go b/internal/wasm/prompts_test.go index b78d59ab..d89dda07 100644 --- a/internal/wasm/prompts_test.go +++ b/internal/wasm/prompts_test.go @@ -151,8 +151,14 @@ func TestPromptForInputSyncGuard(t *testing.T) { // guard must short-circuit before it is ever consulted. fn := js.FuncOf(func(this js.Value, args []js.Value) interface{} { return nil }) defer fn.Release() + promptCallbackMu.Lock() promptCallback = fn.Value - defer func() { promptCallback = js.Undefined() }() + promptCallbackMu.Unlock() + defer func() { + promptCallbackMu.Lock() + promptCallback = js.Undefined() + promptCallbackMu.Unlock() + }() BeginSyncExecution() _, err := PromptForInput("Enter name:", "text", "") From 4341bdc4cbd57c70b7f6d0231d3a717770c1e8cf Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 08:21:35 -0700 Subject: [PATCH 07/14] fix: correct ResourceType field comment wasmSecretResourcePrompt sends secret-resource prompts as PromptType "password" while still populating ResourceType, so the field isn't limited to "resource" prompts as the comment implied. --- internal/wasm/prompts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/wasm/prompts.go b/internal/wasm/prompts.go index c8056a0d..38cfe5bf 100644 --- a/internal/wasm/prompts.go +++ b/internal/wasm/prompts.go @@ -60,7 +60,7 @@ type PromptRequest struct { ID string Message string PromptType string // "text", "confirm", "password", "resource" - ResourceType string // for resource prompts (port, mcr, vxc, etc.) + 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 } From 6d52f538c50cd63df405e0ae5183a7d94d4c0ec0 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 08:30:49 -0700 Subject: [PATCH 08/14] fix: qualify cancelPrompt docs for confirmation prompts wasmConfirmPrompt discards the PromptForInput error and returns false, so cancelling a confirmation prompt is a decline, not an error. Only value prompts surface cancellation as an error string. --- WASM_README.md | 2 +- frontend-integration/INTEGRATION_GUIDE.md | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/WASM_README.md b/WASM_README.md index fa7845d8..4851abf2 100644 --- a/WASM_README.md +++ b/WASM_README.md @@ -98,7 +98,7 @@ The WASM registers these on `window` at startup: |---|---| | `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`; the command receives a "prompt cancelled by user" error. | +| `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 diff --git a/frontend-integration/INTEGRATION_GUIDE.md b/frontend-integration/INTEGRATION_GUIDE.md index 5c7c8709..b39f10ea 100644 --- a/frontend-integration/INTEGRATION_GUIDE.md +++ b/frontend-integration/INTEGRATION_GUIDE.md @@ -267,8 +267,9 @@ const checkVlan = async () => { 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. If no handler is -registered, a prompt fails immediately with a "prompt callback not registered" error; once a -handler is registered, a prompt left unanswered times out after 5 minutes. +registered, a value prompt fails immediately with a "prompt callback not registered" error +and a confirmation is treated as declined; once a handler is registered, a prompt left +unanswered times out after 5 minutes. **Always run interactive commands through `execute()`** (the composable prefers the async entrypoint under the hood). The legacy synchronous entrypoint runs the command inline and @@ -283,7 +284,8 @@ confirmation is treated as declined. Neither is what you want, so keep to `execu 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)` (the command then receives a "prompt cancelled by user" error). + `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 From efd66b1a88838c1dd8a9a55f51c81fe09b2b91c9 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 08:41:41 -0700 Subject: [PATCH 09/14] fix: remove race and align mock contract in interactive-mode tests TestPromptForInputSyncGuard submitted its response after a fixed sleep, so a slow scheduler could leave the prompt unregistered and hang the test until the 5-minute timeout. Poll for the prompt ID instead. The sync-entrypoint guard test mocked executeMegaportCommand returning an `error` field, but the real WASM implementation always returns `output`; align the mock and assertion with that contract. --- .../__tests__/interactive.test.ts | 7 ++++--- internal/wasm/prompts_test.go | 21 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/frontend-integration/__tests__/interactive.test.ts b/frontend-integration/__tests__/interactive.test.ts index 6993cf22..624ff084 100644 --- a/frontend-integration/__tests__/interactive.test.ts +++ b/frontend-integration/__tests__/interactive.test.ts @@ -580,9 +580,10 @@ describe('Interactive Mode', () => { 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 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(() => ({ error: guardError })); + const syncMock = vi.fn(() => ({ output: guardError })); delete (window as any).executeMegaportCommandAsync; delete (global as any).executeMegaportCommandAsync; (window as any).executeMegaportCommand = syncMock; @@ -591,7 +592,7 @@ describe('Interactive Mode', () => { const result = await composable.execute('vxc buy --interactive'); expect(syncMock).toHaveBeenCalledTimes(1); - expect(result.error).toContain('async entrypoint'); + expect(result.output || result.error).toContain('async entrypoint'); wrapper.unmount(); }); diff --git a/internal/wasm/prompts_test.go b/internal/wasm/prompts_test.go index d89dda07..71823a0f 100644 --- a/internal/wasm/prompts_test.go +++ b/internal/wasm/prompts_test.go @@ -171,16 +171,23 @@ func TestPromptForInputSyncGuard(t *testing.T) { t.Errorf("expected the error to name the async entrypoint, got %q", err.Error()) } - // Once the sync execution has ended, prompting works again. + // Once the sync execution has ended, prompting works again. Poll for the + // prompt to appear instead of sleeping a fixed duration: a fixed sleep + // that fires before PromptForInput registers the prompt would submit + // nothing and leave the test hanging until the 5-minute prompt timeout. go func() { - time.Sleep(50 * time.Millisecond) - pendingMutex.Lock() var promptID string - for id := range pendingPrompts { - promptID = id - break + for i := 0; i < 100 && promptID == ""; i++ { + pendingMutex.Lock() + for id := range pendingPrompts { + promptID = id + break + } + pendingMutex.Unlock() + if promptID == "" { + time.Sleep(10 * time.Millisecond) + } } - pendingMutex.Unlock() if promptID != "" { submitPromptResponse(js.Undefined(), []js.Value{js.ValueOf(promptID), js.ValueOf("ok")}) } From 39ea407f1845d170063b1f4cd045ade5cc2a6086 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 08:48:01 -0700 Subject: [PATCH 10/14] fix: respond from the callback instead of polling in sync guard test The previous fix polled pendingPrompts for up to 1s, which still had a window where the test could hang if the prompt wasn't registered by then. Respond from the prompt callback itself, using the id it's called with, since PromptForInput always registers the prompt before invoking the callback. --- internal/wasm/prompts_test.go | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/internal/wasm/prompts_test.go b/internal/wasm/prompts_test.go index 71823a0f..75b40d7a 100644 --- a/internal/wasm/prompts_test.go +++ b/internal/wasm/prompts_test.go @@ -171,27 +171,21 @@ func TestPromptForInputSyncGuard(t *testing.T) { t.Errorf("expected the error to name the async entrypoint, got %q", err.Error()) } - // Once the sync execution has ended, prompting works again. Poll for the - // prompt to appear instead of sleeping a fixed duration: a fixed sleep - // that fires before PromptForInput registers the prompt would submit - // nothing and leave the test hanging until the 5-minute prompt timeout. - go func() { - var promptID string - for i := 0; i < 100 && promptID == ""; i++ { - pendingMutex.Lock() - for id := range pendingPrompts { - promptID = id - break - } - pendingMutex.Unlock() - if promptID == "" { - time.Sleep(10 * time.Millisecond) - } - } - if promptID != "" { - submitPromptResponse(js.Undefined(), []js.Value{js.ValueOf(promptID), js.ValueOf("ok")}) + // Once the sync execution has ended, prompting works again. Respond from + // the callback itself, using the request's own id, instead of polling + // pendingPrompts: PromptForInput registers the prompt before invoking the + // callback, so there's no window where the id could be missed. + respond := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if len(args) > 0 { + promptID := args[0].Get("id").String() + go submitPromptResponse(js.Undefined(), []js.Value{js.ValueOf(promptID), js.ValueOf("ok")}) } - }() + return nil + }) + defer respond.Release() + promptCallbackMu.Lock() + promptCallback = respond.Value + promptCallbackMu.Unlock() resp, err := PromptForInput("Enter name:", "text", "") if err != nil { From 7b236b1479a1b037f5db298ee820f7b814d1d225 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Mon, 6 Jul 2026 08:51:17 -0700 Subject: [PATCH 11/14] fix: make EndSyncExecution's clamp-to-zero race-free The cur <= 0 branch stored 0 unconditionally, so a concurrent BeginSyncExecution could increment the counter between the load and the store, and this call would clobber it back to 0, incorrectly re-enabling prompting while a sync execution is still in progress. Compute the clamped next value and commit it with the same CAS used for the normal decrement, so a concurrent change always aborts the attempt and retries against the fresh value. --- internal/wasm/prompts.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/wasm/prompts.go b/internal/wasm/prompts.go index 38cfe5bf..cbe3c9b4 100644 --- a/internal/wasm/prompts.go +++ b/internal/wasm/prompts.go @@ -45,11 +45,11 @@ func BeginSyncExecution() { func EndSyncExecution() { for { cur := syncExecutionDepth.Load() - if cur <= 0 { - syncExecutionDepth.Store(0) - return + next := cur - 1 + if next < 0 { + next = 0 } - if syncExecutionDepth.CompareAndSwap(cur, cur-1) { + if syncExecutionDepth.CompareAndSwap(cur, next) { return } } From 8b75b1b1798d1c66c743cd3bd395241673c4a484 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Tue, 7 Jul 2026 06:17:19 -0700 Subject: [PATCH 12/14] docs(wasm): fix prompt failure-mode and password type in host contract The Interactive Commands section claimed a missing handler fails immediately, but useMegaportWASM registers a default no-op handler at init, so composable callers who forget to override it hit the 5-minute timeout instead. Split the two paths: composable (no-op default, hangs to timeout) vs raw WASM globals (immediate 'prompt callback not registered' error). Also bring the MegaportPromptRequest TypeScript definition in step with the Go struct and guide: add 'password' to the type union and note resourceType is set for both resource and secret (password) prompts. --- frontend-integration/INTEGRATION_GUIDE.md | 16 ++++++++++++---- frontend-integration/types/megaport-wasm.d.ts | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend-integration/INTEGRATION_GUIDE.md b/frontend-integration/INTEGRATION_GUIDE.md index b39f10ea..414a744b 100644 --- a/frontend-integration/INTEGRATION_GUIDE.md +++ b/frontend-integration/INTEGRATION_GUIDE.md @@ -266,10 +266,18 @@ 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. If no handler is -registered, a value prompt fails immediately with a "prompt callback not registered" error -and a confirmation is treated as declined; once a handler is registered, a prompt left -unanswered times out after 5 minutes. +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 5 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 5 minutes. **Always run interactive commands through `execute()`** (the composable prefers the async entrypoint under the hood). The legacy synchronous entrypoint runs the command inline and diff --git a/frontend-integration/types/megaport-wasm.d.ts b/frontend-integration/types/megaport-wasm.d.ts index 0fb1a4c3..e1fd9b99 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 } /** From a8f890e2d857cf30dbe009ef37911bb7dcefad8f Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Tue, 7 Jul 2026 16:51:38 -0700 Subject: [PATCH 13/14] chore: remove hand-edited changelog entry Changelog entries are now auto-generated at release time. --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da15a21..446e555d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,19 @@ workflow (scripts/update-changelog.sh). Don't hand-edit them or add entries unde ## [Unreleased] +### Added +- `servicekeys create` and `servicekeys update` now support `--json`/`--json-file` and `--interactive` input modes, matching the input-mode contract used elsewhere in the CLI. The update path's merge behavior (unset fields keep their current value rather than being cleared) is preserved across all three input modes (ESD-1591) + +### Changed +- **Breaking (WASM):** `window.executeMegaportCommand` no longer executes commands. A synchronous call blocked the JS event loop while the CLI waited on the browser's fetch transport, hanging the tab, and bypassed the mutex that guards the shared output buffers. It is kept as a deprecated stub for one release and always returns `{ error: "synchronous execution is not supported; use executeMegaportCommandAsync" }`. Use `window.executeMegaportCommandAsync` instead (ESD-1598) + ### Fixed -- WASM interactive mode: the legacy synchronous `executeMegaportCommand` entrypoint no longer deadlocks the browser event loop when a command prompts. A value prompt now fails fast with a clear "interactive mode requires the async entrypoint" error and a confirmation is treated as declined. Documented the interactive-mode host contract in `WASM_README.md` and `frontend-integration/INTEGRATION_GUIDE.md` (ESD-1584) +- `partners list` filters (`--product-name`, `--connect-type`, `--company-name`, `--diversity-zone`) now match case-insensitive substrings instead of requiring an exact match, matching the documented partial-match behavior (ESD-1590) +- default JSON-created users to active when `active` is omitted, matching the flags and interactive paths; an explicit `active: false` is still honored (ESD-1592) +- repair the `js/wasm` build of `./...` (the WASM config manager was missing `GetProfile`, which `auth status` needs) and enforce the full wasm build, vet, test compilation, and wasm-tagged linting in CI so wasm code stops rotting silently (ESD-1578) - require Cisco FMC fields only when not managing locally on the `mve buy` and `mve validate` flags and JSON paths, matching the validator (ESD-1571) - `mve buy` and `mve validate` now apply `resourceTags` from JSON input, and interactive `mve buy` now prompts for tags, matching MCR. Previously the JSON path silently dropped the documented `resourceTags` field and interactive mode never asked. The JSON path shares the same value and empty-key validation as the flags path, so non-string values and empty keys return a usage error before the order is placed +- abort the browser fetch when `req.Context()` is cancelled or the WASM transport timeout fires, instead of leaving it running behind a dead command; also drop the forbidden `Accept-Encoding` header the browser silently stripped (ESD-1594) ## [v1.0.0-beta.1] - 2026-07-01 From 74925825f12e3866a494e1c661d5b51afe438bd8 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Thu, 9 Jul 2026 17:32:47 -0700 Subject: [PATCH 14/14] docs(wasm): correct prompt timeout to 10 minutes in host contract docs --- WASM_README.md | 2 +- frontend-integration/INTEGRATION_GUIDE.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/WASM_README.md b/WASM_README.md index 06d2a9a9..1f24d948 100644 --- a/WASM_README.md +++ b/WASM_README.md @@ -159,7 +159,7 @@ executeMegaportCommandAsync('vxc buy --interactive', (result) => { }); ``` -A prompt left unanswered times out after 5 minutes and the command receives an 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 diff --git a/frontend-integration/INTEGRATION_GUIDE.md b/frontend-integration/INTEGRATION_GUIDE.md index b40358df..cff51d34 100644 --- a/frontend-integration/INTEGRATION_GUIDE.md +++ b/frontend-integration/INTEGRATION_GUIDE.md @@ -309,13 +309,13 @@ 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 5 minutes (confirmations then resolve to declined), rather than failing + 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 5 minutes. +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 @@ -360,7 +360,7 @@ registerPromptHandler((request) => { const result = await execute('vxc buy --interactive'); ``` -An unanswered prompt times out after 5 minutes and the command receives an error. +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