Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions WASM_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type="password">` 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.
Expand Down
66 changes: 66 additions & 0 deletions frontend-integration/INTEGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,72 @@ const checkVlan = async () => {
</script>
```

## 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 `<input type="password">`.
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:
Expand Down
153 changes: 152 additions & 1 deletion frontend-integration/__tests__/interactive.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<div>Test</div>',
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');
}
Comment thread
Copilot marked this conversation as resolved.
return { wrapper, composable: composableInstance };
};

describe('Interactive Mode', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -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<string, boolean> = {};
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;
Expand Down
4 changes: 2 additions & 2 deletions frontend-integration/types/megaport-wasm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
4 changes: 2 additions & 2 deletions internal/wasm/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading