From 8062762d35d15f4f6122ca65f34d2f37f3241172 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 04:45:44 +0200 Subject: [PATCH 1/4] feat: run CLI commands in a browser Add a browser build of the CLI (npm run build:browser) that runs the same command classes as the installed CLI from a list of words, with the environment, fetch and output writer passed in. Node built-ins and local-only packages resolve to browser stand-ins; commands that need files, the keychain, a terminal or a local server say they need the installed CLI. Each command loads on demand. The docs use it for their Shell, so its output, flags and errors match the installed CLI. --- .gitignore | 1 + eslint.config.js | 1 + package.json | 1 + scripts/build-browser.mjs | 105 ++++++++ scripts/gen-browser-registry.mjs | 34 +++ src/browser/index.ts | 185 ++++++++++++++ src/browser/registry.gen.ts | 384 +++++++++++++++++++++++++++++ src/browser/stubs/buffer-global.ts | 2 + src/browser/stubs/buffer.ts | 115 +++++++++ src/browser/stubs/crypto.ts | 20 ++ src/browser/stubs/events.ts | 51 ++++ src/browser/stubs/fs-promises.ts | 49 ++++ src/browser/stubs/fs.ts | 77 ++++++ src/browser/stubs/misc.ts | 77 ++++++ src/browser/stubs/os.ts | 25 ++ src/browser/stubs/packages.ts | 47 ++++ src/browser/stubs/path.ts | 79 ++++++ src/browser/stubs/process.ts | 75 ++++++ src/browser/stubs/unavailable.ts | 38 +++ src/browser/stubs/unsupported.ts | 35 +++ test/browser/run.test.ts | 197 +++++++++++++++ 21 files changed, 1598 insertions(+) create mode 100644 scripts/build-browser.mjs create mode 100644 scripts/gen-browser-registry.mjs create mode 100644 src/browser/index.ts create mode 100644 src/browser/registry.gen.ts create mode 100644 src/browser/stubs/buffer-global.ts create mode 100644 src/browser/stubs/buffer.ts create mode 100644 src/browser/stubs/crypto.ts create mode 100644 src/browser/stubs/events.ts create mode 100644 src/browser/stubs/fs-promises.ts create mode 100644 src/browser/stubs/fs.ts create mode 100644 src/browser/stubs/misc.ts create mode 100644 src/browser/stubs/os.ts create mode 100644 src/browser/stubs/packages.ts create mode 100644 src/browser/stubs/path.ts create mode 100644 src/browser/stubs/process.ts create mode 100644 src/browser/stubs/unavailable.ts create mode 100644 src/browser/stubs/unsupported.ts create mode 100644 test/browser/run.test.ts diff --git a/.gitignore b/.gitignore index 1678d672..5347e6ad 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage/ .DS_Store .release/ +dist-browser/ diff --git a/eslint.config.js b/eslint.config.js index 402cfd8c..e3e0392e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,6 +8,7 @@ export default tseslint.config( '.release/**', 'coverage/**', 'dist/**', + 'dist-browser/**', 'node_modules/**', ], }, diff --git a/package.json b/package.json index 780699f0..9fb53a7e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "scripts": { "audit:prod": "npm audit --omit=dev", "build": "npm run clean && tsc -p tsconfig.build.json", + "build:browser": "node scripts/gen-browser-registry.mjs && node scripts/build-browser.mjs", "clean": "node scripts/clean-dist.js", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/scripts/build-browser.mjs b/scripts/build-browser.mjs new file mode 100644 index 00000000..9759b104 --- /dev/null +++ b/scripts/build-browser.mjs @@ -0,0 +1,105 @@ +// Builds dist-browser/: the CLI's commands as a browser module (src/browser/index.ts). +// Node built-ins and local-only packages resolve to src/browser/stubs; each command loads on demand. +import console from 'node:console' +import process from 'node:process' +import {URL} from 'node:url' +import {execFileSync} from 'node:child_process' +import {readFileSync, writeFileSync, rmSync} from 'node:fs' +import {builtinModules} from 'node:module' +import {build} from 'esbuild' + +const pkg = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) +const stub = (name) => + new URL(`../src/browser/stubs/${name}.ts`, import.meta.url).pathname +const NODE = { + fs: stub('fs'), + 'fs/promises': stub('fs-promises'), + path: stub('path'), + 'path/posix': stub('path'), + os: stub('os'), + crypto: stub('crypto'), + events: stub('events'), + buffer: stub('buffer'), + child_process: stub('unavailable'), + net: stub('unavailable'), + dns: stub('unavailable'), + 'dns/promises': stub('unavailable'), + http: stub('unavailable'), + https: stub('unavailable'), + readline: stub('unavailable'), + 'readline/promises': stub('unavailable'), +} +const PACKAGES = new Set([ + '@napi-rs/keyring', + '@modelcontextprotocol/server', + '@modelcontextprotocol/server/stdio', + 'jq-wasm', + '@polymorfa/sdk/node', +]) +const escape = (value) => value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') +const builtins = new RegExp(`^(${builtinModules.map(escape).join('|')})$`) + +const stubs = { + name: 'browser-stubs', + setup(builder) { + builder.onResolve({filter: /^node:/}, (args) => ({ + path: NODE[args.path.slice(5)] ?? stub('misc'), + })) + builder.onResolve({filter: builtins}, (args) => ({ + path: NODE[args.path] ?? stub('misc'), + })) + builder.onResolve( + { + filter: + /^(@napi-rs\/keyring|@modelcontextprotocol\/server(\/stdio)?|jq-wasm|@polymorfa\/sdk\/node)$/, + }, + (args) => + PACKAGES.has(args.path) ? {path: stub('packages')} : undefined, + ) + }, +} + +const commit = execFileSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', +}).trim() +rmSync(new URL('../dist-browser', import.meta.url), { + recursive: true, + force: true, +}) +const result = await build({ + entryPoints: ['src/browser/index.ts'], + bundle: true, + splitting: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + outdir: 'dist-browser', + entryNames: 'polymorfa-cli', + chunkNames: 'chunks/[name]-[hash]', + inject: [stub('process'), stub('buffer-global')], + define: { + __CLI_VERSION__: JSON.stringify(pkg.version), + __CLI_COMMIT__: JSON.stringify(commit), + global: 'globalThis', + }, + plugins: [stubs], + mainFields: ['browser', 'module', 'main'], + conditions: ['browser', 'import', 'default'], + minify: process.env.BROWSER_MINIFY !== '0', + legalComments: 'linked', + metafile: true, + logLevel: 'warning', +}) +const bytes = Object.values(result.metafile.outputs).reduce( + (sum, output) => sum + output.bytes, + 0, +) +writeFileSync( + new URL('../dist-browser/manifest.json', import.meta.url), + `${JSON.stringify({package: pkg.name, version: pkg.version, commit, entry: 'polymorfa-cli.js', files: Object.keys(result.metafile.outputs).length, bytes}, null, 2)}\n`, +) +console.log( + `dist-browser: ${Object.keys(result.metafile.outputs).length} files, ${(bytes / 1024 / 1024).toFixed(2)} MiB, commit ${commit.slice(0, 10)}`, +) diff --git a/scripts/gen-browser-registry.mjs b/scripts/gen-browser-registry.mjs new file mode 100644 index 00000000..f65a68af --- /dev/null +++ b/scripts/gen-browser-registry.mjs @@ -0,0 +1,34 @@ +// Writes src/browser/registry.gen.ts: every command id mapped to a lazy import of its module. +// A command's id is its path under src/commands, with directories joined by spaces. +import console from 'node:console' +import {URL} from 'node:url' +import {readdirSync, statSync, writeFileSync} from 'node:fs' +import {join, relative} from 'node:path' + +const root = new URL('../src/commands/', import.meta.url).pathname +const files = [] +const walk = (dir) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name) + if (statSync(path).isDirectory()) walk(path) + else if ( + name.endsWith('.ts') && + !name.endsWith('.test.ts') && + !name.endsWith('.d.ts') + ) + files.push(path) + } +} +walk(root) +const entries = files.map((file) => { + const rel = relative(root, file).replace(/\.ts$/, '') + const id = rel.split('/').join(' ') + return ` ${JSON.stringify(id)}: () => import('../commands/${rel}.js'),` +}) +const out = `// Generated by scripts/gen-browser-registry.mjs. Do not edit. +export const COMMAND_LOADERS: Readonly Promise<{default: unknown}>>> = { +${entries.join('\n')} +} +` +writeFileSync(new URL('../src/browser/registry.gen.ts', import.meta.url), out) +console.log(`registry: ${files.length} commands`) diff --git a/src/browser/index.ts b/src/browser/index.ts new file mode 100644 index 00000000..befd3c33 --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,185 @@ +/** + * The Polymorfa CLI in a browser: run a command from its words, with a + * credential, fetch and output writer passed in. It runs the same command + * classes as the installed CLI, so output, flags and errors match; anything + * that needs files, the keychain, a terminal or a local server fails with a + * message naming the installed CLI. + */ +import {COMMAND_LOADERS} from './registry.gen.js' + +declare const __CLI_VERSION__: string | undefined +declare const __CLI_COMMIT__: string | undefined + +export const CLI_VERSION = + typeof __CLI_VERSION__ === 'string' ? __CLI_VERSION__ : '0.0.0-dev' +export const CLI_COMMIT = + typeof __CLI_COMMIT__ === 'string' ? __CLI_COMMIT__ : 'unknown' +export const COMMAND_IDS: readonly string[] = + Object.keys(COMMAND_LOADERS).sort() + +export interface BrowserIo { + /** Environment for the command: `POLYMORFA_PROJECT_TOKEN` or `POLYMORFA_API_KEY`, and optionally `POLYMORFA_BASE_URL`. */ + readonly env: Readonly> + /** The fetch the SDK uses for this command. */ + readonly fetch: typeof fetch + readonly write: (stream: 'stdout' | 'stderr', text: string) => void +} + +export interface RunResult { + readonly exitCode: number + /** The command the words resolved to, or null when none matched. */ + readonly command: string | null +} + +/** The longest command id the words start with, and the words after it. */ +export function resolveCommand( + words: readonly string[], +): {id: string; rest: string[]} | null { + for (let length = Math.min(words.length, 4); length > 0; length--) { + const id = words.slice(0, length).join(' ') + if (id in COMMAND_LOADERS) return {id, rest: words.slice(length)} + } + return null +} + +/** Split a command line into words the way a POSIX shell quotes them. */ +export function splitWords(line: string): string[] { + const words: string[] = [] + let current = '' + let quote: '"' | "'" | null = null + let started = false + for (let index = 0; index < line.length; index++) { + const char = line[index]! + if (quote) { + if (char === quote) quote = null + else if (char === '\\' && quote === '"' && index + 1 < line.length) + current += line[++index] + else current += char + } else if (char === '"' || char === "'") { + quote = char + started = true + } else if (char === '\\' && index + 1 < line.length) { + current += line[++index] + started = true + } else if (/\s/.test(char)) { + if (started) { + words.push(current) + current = '' + started = false + } + } else { + current += char + started = true + } + } + if (quote) throw new SyntaxError('Unclosed quote') + if (started) words.push(current) + return words +} + +const config = { + bin: 'polymorfa', + name: '@polymorfa/cli', + version: CLI_VERSION, + userAgent: `@polymorfa/cli/${CLI_VERSION} browser`, + pjson: { + name: '@polymorfa/cli', + version: CLI_VERSION, + oclif: {bin: 'polymorfa', topicSeparator: ' '}, + }, + topicSeparator: ' ', + platform: 'linux', + arch: 'wasm', + windows: false, + shell: 'sh', + debug: 0, + root: '/', + home: '/home/browser', + configDir: '/home/browser/.config/polymorfa', + dataDir: '/home/browser/.local/share/polymorfa', + cacheDir: '/home/browser/.cache/polymorfa', + runHook: () => Promise.resolve({failures: [], successes: []}), + scopedEnvVar: () => undefined, + scopedEnvVarKey: (key: string) => `POLYMORFA_${key}`, + scopedEnvVarKeys: (key: string) => [`POLYMORFA_${key}`], + findCommand: () => undefined, + plugins: new Map(), + commands: [], + commandIDs: COMMAND_IDS, +} + +interface ProcessLike { + env: Record + exitCode?: number | string | undefined + stdout: {write: (chunk: unknown) => boolean} + stderr: {write: (chunk: unknown) => boolean} +} + +let running = false + +/** Run one command. Only one runs at a time; the caller waits for the result before the next. */ +export async function runCommand( + words: readonly string[], + io: BrowserIo, +): Promise { + if (running) throw new Error('A command is already running.') + running = true + // The bare `process`: in the browser build it is the injected stand-in, under Node the real one. + const proc = process as unknown as ProcessLike + const saved = { + env: proc.env, + exitCode: proc.exitCode, + out: proc.stdout.write, + err: proc.stderr.write, + fetch: globalThis.fetch, + } + proc.env = {...io.env} + proc.exitCode = undefined + proc.stdout.write = (chunk: unknown) => { + io.write('stdout', String(chunk)) + return true + } + proc.stderr.write = (chunk: unknown) => { + io.write('stderr', String(chunk)) + return true + } + globalThis.fetch = io.fetch + const resolved = resolveCommand(words) + try { + if (!resolved) { + io.write('stderr', `Unknown command: polymorfa ${words.join(' ')}\n`) + return {exitCode: 2, command: null} + } + const module = await COMMAND_LOADERS[resolved.id]!() + // Each module's default export is a concrete oclif command class. + const CommandClass = module.default as { + id?: string + new (argv: string[], config: unknown): {_run: () => Promise} + } + CommandClass.id = resolved.id + const command = new CommandClass(resolved.rest, config) + await command._run() + const code = Number(proc.exitCode ?? 0) + return {exitCode: Number.isFinite(code) ? code : 1, command: resolved.id} + } catch (error) { + const exit = (error as {oclif?: {exit?: unknown}}).oclif?.exit + if (typeof exit === 'number') + return {exitCode: exit, command: resolved?.id ?? null} + const debug = + io.env.POLYMORFA_BROWSER_DEBUG === '1' && error instanceof Error + ? `\n${error.stack ?? ''}` + : '' + io.write( + 'stderr', + `${error instanceof Error ? error.message : String(error)}${debug}\n`, + ) + return {exitCode: 1, command: resolved?.id ?? null} + } finally { + proc.env = saved.env + proc.exitCode = saved.exitCode + proc.stdout.write = saved.out + proc.stderr.write = saved.err + globalThis.fetch = saved.fetch + running = false + } +} diff --git a/src/browser/registry.gen.ts b/src/browser/registry.gen.ts new file mode 100644 index 00000000..a1c0a379 --- /dev/null +++ b/src/browser/registry.gen.ts @@ -0,0 +1,384 @@ +// Generated by scripts/gen-browser-registry.mjs. Do not edit. +export const COMMAND_LOADERS: Readonly Promise<{default: unknown}>>> = { + "analytics get": () => import('../commands/analytics/get.js'), + "analytics metrics": () => import('../commands/analytics/metrics.js'), + "api": () => import('../commands/api.js'), + "audience create": () => import('../commands/audience/create.js'), + "audience members add": () => import('../commands/audience/members/add.js'), + "audience members list": () => import('../commands/audience/members/list.js'), + "audience members remove": () => import('../commands/audience/members/remove.js'), + "audit-log list": () => import('../commands/audit-log/list.js'), + "auth list": () => import('../commands/auth/list.js'), + "auth login": () => import('../commands/auth/login.js'), + "auth logout": () => import('../commands/auth/logout.js'), + "auth status": () => import('../commands/auth/status.js'), + "auth use": () => import('../commands/auth/use.js'), + "bansafe claim list": () => import('../commands/bansafe/claim/list.js'), + "bansafe claim view": () => import('../commands/bansafe/claim/view.js'), + "bansafe collection list": () => import('../commands/bansafe/collection/list.js'), + "bansafe enforcement list": () => import('../commands/bansafe/enforcement/list.js'), + "bansafe finding list": () => import('../commands/bansafe/finding/list.js'), + "bansafe health history": () => import('../commands/bansafe/health/history.js'), + "bansafe health list": () => import('../commands/bansafe/health/list.js'), + "bansafe health view": () => import('../commands/bansafe/health/view.js'), + "bansafe health-action list": () => import('../commands/bansafe/health-action/list.js'), + "bansafe health-policy update": () => import('../commands/bansafe/health-policy/update.js'), + "bansafe health-policy view": () => import('../commands/bansafe/health-policy/view.js'), + "bansafe incident list": () => import('../commands/bansafe/incident/list.js'), + "bansafe incident report": () => import('../commands/bansafe/incident/report.js'), + "bansafe incident retract": () => import('../commands/bansafe/incident/retract.js'), + "bansafe insurance-evidence update": () => import('../commands/bansafe/insurance-evidence/update.js'), + "bansafe insurance-evidence view": () => import('../commands/bansafe/insurance-evidence/view.js'), + "bansafe safe-mode update": () => import('../commands/bansafe/safe-mode/update.js'), + "bansafe safe-mode view": () => import('../commands/bansafe/safe-mode/view.js'), + "bansafe signal list": () => import('../commands/bansafe/signal/list.js'), + "bansafe telemetry history": () => import('../commands/bansafe/telemetry/history.js'), + "bansafe telemetry view": () => import('../commands/bansafe/telemetry/view.js'), + "bansafe warmup update": () => import('../commands/bansafe/warmup/update.js'), + "bansafe warmup view": () => import('../commands/bansafe/warmup/view.js'), + "billing controls get": () => import('../commands/billing/controls/get.js'), + "billing controls set": () => import('../commands/billing/controls/set.js'), + "billing limits list": () => import('../commands/billing/limits/list.js'), + "billing limits set": () => import('../commands/billing/limits/set.js'), + "billing priority list": () => import('../commands/billing/priority/list.js'), + "billing priority reorder": () => import('../commands/billing/priority/reorder.js'), + "billing priority set": () => import('../commands/billing/priority/set.js'), + "business accounts view": () => import('../commands/business/accounts/view.js'), + "business catalog cart set": () => import('../commands/business/catalog/cart/set.js'), + "business catalog create": () => import('../commands/business/catalog/create.js'), + "business catalog view": () => import('../commands/business/catalog/view.js'), + "business collection appeal": () => import('../commands/business/collection/appeal.js'), + "business collection create": () => import('../commands/business/collection/create.js'), + "business collection delete": () => import('../commands/business/collection/delete.js'), + "business collection list": () => import('../commands/business/collection/list.js'), + "business collection reorder": () => import('../commands/business/collection/reorder.js'), + "business collection update": () => import('../commands/business/collection/update.js'), + "business collection view": () => import('../commands/business/collection/view.js'), + "business compliance replace": () => import('../commands/business/compliance/replace.js'), + "business compliance view": () => import('../commands/business/compliance/view.js'), + "business eligibility view": () => import('../commands/business/eligibility/view.js'), + "business order view": () => import('../commands/business/order/view.js'), + "business product appeal": () => import('../commands/business/product/appeal.js'), + "business product create": () => import('../commands/business/product/create.js'), + "business product delete": () => import('../commands/business/product/delete.js'), + "business product replace": () => import('../commands/business/product/replace.js'), + "business product view": () => import('../commands/business/product/view.js'), + "business product visibility set": () => import('../commands/business/product/visibility/set.js'), + "business profile cover delete": () => import('../commands/business/profile/cover/delete.js'), + "business profile cover set": () => import('../commands/business/profile/cover/set.js'), + "business profile update": () => import('../commands/business/profile/update.js'), + "business profile view": () => import('../commands/business/profile/view.js'), + "call reject": () => import('../commands/call/reject.js'), + "calls accept": () => import('../commands/calls/accept.js'), + "calls check": () => import('../commands/calls/check.js'), + "calls end": () => import('../commands/calls/end.js'), + "calls export": () => import('../commands/calls/export.js'), + "calls hand": () => import('../commands/calls/hand.js'), + "calls invite": () => import('../commands/calls/invite.js'), + "calls leave": () => import('../commands/calls/leave.js'), + "calls link create": () => import('../commands/calls/link/create.js'), + "calls link preview": () => import('../commands/calls/link/preview.js'), + "calls list": () => import('../commands/calls/list.js'), + "calls media": () => import('../commands/calls/media.js'), + "calls opt-outs add": () => import('../commands/calls/opt-outs/add.js'), + "calls opt-outs import": () => import('../commands/calls/opt-outs/import.js'), + "calls opt-outs list": () => import('../commands/calls/opt-outs/list.js'), + "calls opt-outs remove": () => import('../commands/calls/opt-outs/remove.js'), + "calls permission get": () => import('../commands/calls/permission/get.js'), + "calls permission request": () => import('../commands/calls/permission/request.js'), + "calls place-group": () => import('../commands/calls/place-group.js'), + "calls place": () => import('../commands/calls/place.js'), + "calls policy get": () => import('../commands/calls/policy/get.js'), + "calls policy set": () => import('../commands/calls/policy/set.js'), + "calls react": () => import('../commands/calls/react.js'), + "calls reject": () => import('../commands/calls/reject.js'), + "calls retention get": () => import('../commands/calls/retention/get.js'), + "calls retention set": () => import('../commands/calls/retention/set.js'), + "calls ring": () => import('../commands/calls/ring.js'), + "calls settings get": () => import('../commands/calls/settings/get.js'), + "calls settings set": () => import('../commands/calls/settings/set.js'), + "calls stats": () => import('../commands/calls/stats.js'), + "calls view": () => import('../commands/calls/view.js'), + "calls watch": () => import('../commands/calls/watch.js'), + "campaign analytics": () => import('../commands/campaign/analytics.js'), + "campaign conversions record": () => import('../commands/campaign/conversions/record.js'), + "campaign conversions report": () => import('../commands/campaign/conversions/report.js'), + "campaign create": () => import('../commands/campaign/create.js'), + "campaign enroll": () => import('../commands/campaign/enroll.js'), + "campaign launch": () => import('../commands/campaign/launch.js'), + "campaign list": () => import('../commands/campaign/list.js'), + "campaign pause": () => import('../commands/campaign/pause.js'), + "campaign recipients add": () => import('../commands/campaign/recipients/add.js'), + "campaign recipients list": () => import('../commands/campaign/recipients/list.js'), + "campaign requeue": () => import('../commands/campaign/requeue.js'), + "campaign resume": () => import('../commands/campaign/resume.js'), + "campaign segments count": () => import('../commands/campaign/segments/count.js'), + "campaign segments create": () => import('../commands/campaign/segments/create.js'), + "campaign segments delete": () => import('../commands/campaign/segments/delete.js'), + "campaign segments get": () => import('../commands/campaign/segments/get.js'), + "campaign segments list": () => import('../commands/campaign/segments/list.js'), + "campaign segments update": () => import('../commands/campaign/segments/update.js'), + "campaign settings frequency-cap get": () => import('../commands/campaign/settings/frequency-cap/get.js'), + "campaign settings frequency-cap remove": () => import('../commands/campaign/settings/frequency-cap/remove.js'), + "campaign settings frequency-cap set": () => import('../commands/campaign/settings/frequency-cap/set.js'), + "campaign stop": () => import('../commands/campaign/stop.js'), + "campaign update": () => import('../commands/campaign/update.js'), + "campaign view": () => import('../commands/campaign/view.js'), + "channel create": () => import('../commands/channel/create.js'), + "channel delete": () => import('../commands/channel/delete.js'), + "channel follow": () => import('../commands/channel/follow.js'), + "channel list": () => import('../commands/channel/list.js'), + "channel live-updates subscribe": () => import('../commands/channel/live-updates/subscribe.js'), + "channel message list": () => import('../commands/channel/message/list.js'), + "channel message mark-viewed": () => import('../commands/channel/message/mark-viewed.js'), + "channel message react": () => import('../commands/channel/message/react.js'), + "channel message updates": () => import('../commands/channel/message/updates.js'), + "channel mute": () => import('../commands/channel/mute.js'), + "channel unfollow": () => import('../commands/channel/unfollow.js'), + "channel unmute": () => import('../commands/channel/unmute.js'), + "channel view": () => import('../commands/channel/view.js'), + "chat list": () => import('../commands/chat/list.js'), + "chat message list": () => import('../commands/chat/message/list.js'), + "chat message media": () => import('../commands/chat/message/media.js'), + "chat message view": () => import('../commands/chat/message/view.js'), + "chat view": () => import('../commands/chat/view.js'), + "cloud catalog list": () => import('../commands/cloud/catalog/list.js'), + "cloud catalog products": () => import('../commands/cloud/catalog/products.js'), + "cloud credentials get": () => import('../commands/cloud/credentials/get.js'), + "cloud credentials reauthorize": () => import('../commands/cloud/credentials/reauthorize.js'), + "cloud group create": () => import('../commands/cloud/group/create.js'), + "cloud group delete": () => import('../commands/cloud/group/delete.js'), + "cloud group get": () => import('../commands/cloud/group/get.js'), + "cloud group invite-link": () => import('../commands/cloud/group/invite-link.js'), + "cloud group join-requests approve": () => import('../commands/cloud/group/join-requests/approve.js'), + "cloud group join-requests list": () => import('../commands/cloud/group/join-requests/list.js'), + "cloud group join-requests reject": () => import('../commands/cloud/group/join-requests/reject.js'), + "cloud group list": () => import('../commands/cloud/group/list.js'), + "cloud group pin": () => import('../commands/cloud/group/pin.js'), + "cloud group remove-participants": () => import('../commands/cloud/group/remove-participants.js'), + "cloud group update": () => import('../commands/cloud/group/update.js'), + "cloud marketing status": () => import('../commands/cloud/marketing/status.js'), + "cloud pricing": () => import('../commands/cloud/pricing.js'), + "cloud service-window": () => import('../commands/cloud/service-window.js'), + "cloud template create": () => import('../commands/cloud/template/create.js'), + "cloud template delete": () => import('../commands/cloud/template/delete.js'), + "cloud template edit": () => import('../commands/cloud/template/edit.js'), + "cloud template get": () => import('../commands/cloud/template/get.js'), + "cloud template list": () => import('../commands/cloud/template/list.js'), + "completion suggest": () => import('../commands/completion/suggest.js'), + "completion": () => import('../commands/completion.js'), + "contact block": () => import('../commands/contact/block.js'), + "contact blocklist": () => import('../commands/contact/blocklist.js'), + "contact business-profile": () => import('../commands/contact/business-profile.js'), + "contact check": () => import('../commands/contact/check.js'), + "contact devices": () => import('../commands/contact/devices.js'), + "contact info": () => import('../commands/contact/info.js'), + "contact list": () => import('../commands/contact/list.js'), + "contact picture": () => import('../commands/contact/picture.js'), + "contact unblock": () => import('../commands/contact/unblock.js'), + "contact view": () => import('../commands/contact/view.js'), + "customer archive": () => import('../commands/customer/archive.js'), + "customer create": () => import('../commands/customer/create.js'), + "customer enable": () => import('../commands/customer/enable.js'), + "customer event list": () => import('../commands/customer/event/list.js'), + "customer list": () => import('../commands/customer/list.js'), + "customer number list": () => import('../commands/customer/number/list.js'), + "customer number transfer": () => import('../commands/customer/number/transfer.js'), + "customer pairing-link create": () => import('../commands/customer/pairing-link/create.js'), + "customer pairing-link list": () => import('../commands/customer/pairing-link/list.js'), + "customer pairing-link revoke": () => import('../commands/customer/pairing-link/revoke.js'), + "customer restore": () => import('../commands/customer/restore.js'), + "customer status": () => import('../commands/customer/status.js'), + "customer update": () => import('../commands/customer/update.js'), + "customer view": () => import('../commands/customer/view.js'), + "docs open": () => import('../commands/docs/open.js'), + "docs search": () => import('../commands/docs/search.js'), + "doctor": () => import('../commands/doctor.js'), + "event list": () => import('../commands/event/list.js'), + "event replay": () => import('../commands/event/replay.js'), + "event stream": () => import('../commands/event/stream.js'), + "event view": () => import('../commands/event/view.js'), + "flow create": () => import('../commands/flow/create.js'), + "flow delete": () => import('../commands/flow/delete.js'), + "flow deprecate": () => import('../commands/flow/deprecate.js'), + "flow discard": () => import('../commands/flow/discard.js'), + "flow endpoint delete": () => import('../commands/flow/endpoint/delete.js'), + "flow endpoint get": () => import('../commands/flow/endpoint/get.js'), + "flow endpoint receipts": () => import('../commands/flow/endpoint/receipts.js'), + "flow endpoint set": () => import('../commands/flow/endpoint/set.js'), + "flow get": () => import('../commands/flow/get.js'), + "flow key custody": () => import('../commands/flow/key/custody.js'), + "flow key get": () => import('../commands/flow/key/get.js'), + "flow key register": () => import('../commands/flow/key/register.js'), + "flow key rotate": () => import('../commands/flow/key/rotate.js'), + "flow list": () => import('../commands/flow/list.js'), + "flow publish": () => import('../commands/flow/publish.js'), + "flow receipts": () => import('../commands/flow/receipts.js'), + "flow sync": () => import('../commands/flow/sync.js'), + "flow update": () => import('../commands/flow/update.js'), + "flow upload": () => import('../commands/flow/upload.js'), + "function create": () => import('../commands/function/create.js'), + "function delete": () => import('../commands/function/delete.js'), + "function deployment create": () => import('../commands/function/deployment/create.js'), + "function deployment list": () => import('../commands/function/deployment/list.js'), + "function deployment promote": () => import('../commands/function/deployment/promote.js'), + "function deployment view": () => import('../commands/function/deployment/view.js'), + "function invocation create": () => import('../commands/function/invocation/create.js'), + "function invocation list": () => import('../commands/function/invocation/list.js'), + "function invocation view": () => import('../commands/function/invocation/view.js'), + "function list": () => import('../commands/function/list.js'), + "function secret create": () => import('../commands/function/secret/create.js'), + "function secret list": () => import('../commands/function/secret/list.js'), + "function secret revoke": () => import('../commands/function/secret/revoke.js'), + "function update": () => import('../commands/function/update.js'), + "function view": () => import('../commands/function/view.js'), + "group capabilities": () => import('../commands/group/capabilities.js'), + "group create": () => import('../commands/group/create.js'), + "group description": () => import('../commands/group/description.js'), + "group invite-link": () => import('../commands/group/invite-link.js'), + "group list": () => import('../commands/group/list.js'), + "group participants add": () => import('../commands/group/participants/add.js'), + "group participants demote": () => import('../commands/group/participants/demote.js'), + "group participants list": () => import('../commands/group/participants/list.js'), + "group participants promote": () => import('../commands/group/participants/promote.js'), + "group participants remove": () => import('../commands/group/participants/remove.js'), + "group settings": () => import('../commands/group/settings.js'), + "group subject": () => import('../commands/group/subject.js'), + "group view": () => import('../commands/group/view.js'), + "hybrid-link pause": () => import('../commands/hybrid-link/pause.js'), + "hybrid-link resume": () => import('../commands/hybrid-link/resume.js'), + "hybrid-link state": () => import('../commands/hybrid-link/state.js'), + "identity resolve": () => import('../commands/identity/resolve.js'), + "init": () => import('../commands/init.js'), + "key list": () => import('../commands/key/list.js'), + "key revoke": () => import('../commands/key/revoke.js'), + "label chat list": () => import('../commands/label/chat/list.js'), + "label chat replace": () => import('../commands/label/chat/replace.js'), + "label create": () => import('../commands/label/create.js'), + "label delete": () => import('../commands/label/delete.js'), + "label list": () => import('../commands/label/list.js'), + "label update": () => import('../commands/label/update.js'), + "link": () => import('../commands/link.js'), + "listen": () => import('../commands/listen.js'), + "logs list": () => import('../commands/logs/list.js'), + "logs tail": () => import('../commands/logs/tail.js'), + "mcp doctor": () => import('../commands/mcp/doctor.js'), + "mcp install": () => import('../commands/mcp/install.js'), + "mcp serve": () => import('../commands/mcp/serve.js'), + "mcp status": () => import('../commands/mcp/status.js'), + "mcp tools": () => import('../commands/mcp/tools.js'), + "mcp uninstall": () => import('../commands/mcp/uninstall.js'), + "media download": () => import('../commands/media/download.js'), + "media persist": () => import('../commands/media/persist.js'), + "media view": () => import('../commands/media/view.js'), + "member list": () => import('../commands/member/list.js'), + "message delete": () => import('../commands/message/delete.js'), + "message edit": () => import('../commands/message/edit.js'), + "message mark-seen": () => import('../commands/message/mark-seen.js'), + "message operation-status": () => import('../commands/message/operation-status.js'), + "message react": () => import('../commands/message/react.js'), + "message send audio": () => import('../commands/message/send/audio.js'), + "message send buttons": () => import('../commands/message/send/buttons.js'), + "message send contact": () => import('../commands/message/send/contact.js'), + "message send document": () => import('../commands/message/send/document.js'), + "message send image": () => import('../commands/message/send/image.js'), + "message send list": () => import('../commands/message/send/list.js'), + "message send location": () => import('../commands/message/send/location.js'), + "message send order-status": () => import('../commands/message/send/order-status.js'), + "message send order": () => import('../commands/message/send/order.js'), + "message send poll": () => import('../commands/message/send/poll.js'), + "message send template": () => import('../commands/message/send/template.js'), + "message send text": () => import('../commands/message/send/text.js'), + "message send video": () => import('../commands/message/send/video.js'), + "message star": () => import('../commands/message/star.js'), + "message typing": () => import('../commands/message/typing.js'), + "operation cancel": () => import('../commands/operation/cancel.js'), + "operation list": () => import('../commands/operation/list.js'), + "operation view": () => import('../commands/operation/view.js'), + "operation wait": () => import('../commands/operation/wait.js'), + "operation watch": () => import('../commands/operation/watch.js'), + "optout settings update": () => import('../commands/optout/settings/update.js'), + "optout settings view": () => import('../commands/optout/settings/view.js'), + "presence chat subscribe": () => import('../commands/presence/chat/subscribe.js'), + "presence chat view": () => import('../commands/presence/chat/view.js'), + "presence set": () => import('../commands/presence/set.js'), + "presence view": () => import('../commands/presence/view.js'), + "privacy disappearing-timer set": () => import('../commands/privacy/disappearing-timer/set.js'), + "privacy set": () => import('../commands/privacy/set.js'), + "privacy view": () => import('../commands/privacy/view.js'), + "profile name set": () => import('../commands/profile/name/set.js'), + "profile picture delete": () => import('../commands/profile/picture/delete.js'), + "profile picture set": () => import('../commands/profile/picture/set.js'), + "profile status set": () => import('../commands/profile/status/set.js'), + "profile view": () => import('../commands/profile/view.js'), + "project current": () => import('../commands/project/current.js'), + "project list": () => import('../commands/project/list.js'), + "project use": () => import('../commands/project/use.js'), + "project-token list": () => import('../commands/project-token/list.js'), + "quick-reply create": () => import('../commands/quick-reply/create.js'), + "quick-reply delete": () => import('../commands/quick-reply/delete.js'), + "quick-reply list": () => import('../commands/quick-reply/list.js'), + "quick-reply replace": () => import('../commands/quick-reply/replace.js'), + "quicklink availability": () => import('../commands/quicklink/availability.js'), + "quicklink cancel": () => import('../commands/quicklink/cancel.js'), + "quicklink create": () => import('../commands/quicklink/create.js'), + "quicklink view": () => import('../commands/quicklink/view.js'), + "routing-policy get": () => import('../commands/routing-policy/get.js'), + "routing-policy set": () => import('../commands/routing-policy/set.js'), + "security-incident acknowledge": () => import('../commands/security-incident/acknowledge.js'), + "security-incident list": () => import('../commands/security-incident/list.js'), + "session capabilities": () => import('../commands/session/capabilities.js'), + "session configuration get": () => import('../commands/session/configuration/get.js'), + "session configuration update": () => import('../commands/session/configuration/update.js'), + "session delete": () => import('../commands/session/delete.js'), + "session hybrid-merge-candidates": () => import('../commands/session/hybrid-merge-candidates.js'), + "session list": () => import('../commands/session/list.js'), + "session status": () => import('../commands/session/status.js'), + "session stop": () => import('../commands/session/stop.js'), + "session tier confirm": () => import('../commands/session/tier/confirm.js'), + "session tier quote": () => import('../commands/session/tier/quote.js'), + "session tier status": () => import('../commands/session/tier/status.js'), + "session view": () => import('../commands/session/view.js'), + "session-ban list": () => import('../commands/session-ban/list.js'), + "sip-trunk create": () => import('../commands/sip-trunk/create.js'), + "sip-trunk delete": () => import('../commands/sip-trunk/delete.js'), + "sip-trunk endpoint": () => import('../commands/sip-trunk/endpoint.js'), + "sip-trunk list": () => import('../commands/sip-trunk/list.js'), + "sip-trunk rotate-credentials": () => import('../commands/sip-trunk/rotate-credentials.js'), + "sip-trunk update": () => import('../commands/sip-trunk/update.js'), + "sip-trunk view": () => import('../commands/sip-trunk/view.js'), + "template submit": () => import('../commands/template/submit.js'), + "testing history-fixture create": () => import('../commands/testing/history-fixture/create.js'), + "trigger": () => import('../commands/trigger.js'), + "usage gates": () => import('../commands/usage/gates.js'), + "usage records": () => import('../commands/usage/records.js'), + "usage summary": () => import('../commands/usage/summary.js'), + "user security-code": () => import('../commands/user/security-code.js'), + "version": () => import('../commands/version.js'), + "voice audio delete": () => import('../commands/voice/audio/delete.js'), + "voice audio list": () => import('../commands/voice/audio/list.js'), + "voice audio preview": () => import('../commands/voice/audio/preview.js'), + "voice audio tts": () => import('../commands/voice/audio/tts.js'), + "voice audio update": () => import('../commands/voice/audio/update.js'), + "voice audio upload": () => import('../commands/voice/audio/upload.js'), + "voice audio view": () => import('../commands/voice/audio/view.js'), + "voice credential add": () => import('../commands/voice/credential/add.js'), + "voice credential delete": () => import('../commands/voice/credential/delete.js'), + "voice credential list": () => import('../commands/voice/credential/list.js'), + "voice credential verify": () => import('../commands/voice/credential/verify.js'), + "voice credential view": () => import('../commands/voice/credential/view.js'), + "webhook create": () => import('../commands/webhook/create.js'), + "webhook delete": () => import('../commands/webhook/delete.js'), + "webhook delivery attempt list": () => import('../commands/webhook/delivery/attempt/list.js'), + "webhook delivery attempt view": () => import('../commands/webhook/delivery/attempt/view.js'), + "webhook delivery list": () => import('../commands/webhook/delivery/list.js'), + "webhook delivery retry": () => import('../commands/webhook/delivery/retry.js'), + "webhook delivery view": () => import('../commands/webhook/delivery/view.js'), + "webhook list": () => import('../commands/webhook/list.js'), + "webhook secret rotate": () => import('../commands/webhook/secret/rotate.js'), + "webhook test": () => import('../commands/webhook/test.js'), + "webhook update": () => import('../commands/webhook/update.js'), + "webhook view": () => import('../commands/webhook/view.js'), + "widget update": () => import('../commands/widget/update.js'), + "widget view": () => import('../commands/widget/view.js'), +} diff --git a/src/browser/stubs/buffer-global.ts b/src/browser/stubs/buffer-global.ts new file mode 100644 index 00000000..bd5619da --- /dev/null +++ b/src/browser/stubs/buffer-global.ts @@ -0,0 +1,2 @@ +// Injected so bare `Buffer` references resolve in the browser build. +export {Buffer} from './buffer.js' diff --git a/src/browser/stubs/buffer.ts b/src/browser/stubs/buffer.ts new file mode 100644 index 00000000..148a5df7 --- /dev/null +++ b/src/browser/stubs/buffer.ts @@ -0,0 +1,115 @@ +// The part of Node's Buffer the CLI uses: from, toString, byteLength, concat, isBuffer and alloc. +type Encoding = + | 'utf8' + | 'utf-8' + | 'base64' + | 'base64url' + | 'hex' + | 'latin1' + | 'binary' + | 'ascii' + +export interface Buffer extends Uint8Array { + toString(encoding?: Encoding): string + toJSON(): {type: 'Buffer'; data: number[]} +} + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +function fromBase64(text: string): Uint8Array { + const normal = text.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '') + const binary = atob(normal.padEnd(Math.ceil(normal.length / 4) * 4, '=')) + return Uint8Array.from(binary, (char) => char.charCodeAt(0)) +} + +function toBase64(bytes: Uint8Array): string { + let binary = '' + for (let index = 0; index < bytes.length; index += 0x8000) + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)) + return btoa(binary) +} + +// A Uint8Array with Node's encodings. Slices of it stay plain Uint8Arrays. +const proto = Object.create(Uint8Array.prototype, { + toString: { + value(this: Uint8Array, encoding: Encoding = 'utf8'): string { + if (encoding === 'base64') return toBase64(this) + if (encoding === 'base64url') + return toBase64(this) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + if (encoding === 'hex') + return Array.from(this, (byte) => + byte.toString(16).padStart(2, '0'), + ).join('') + if ( + encoding === 'latin1' || + encoding === 'binary' || + encoding === 'ascii' + ) + return String.fromCharCode(...this) + return decoder.decode(this) + }, + }, + toJSON: { + value(this: Uint8Array): {type: 'Buffer'; data: number[]} { + return {type: 'Buffer', data: Array.from(this)} + }, + }, +}) as object + +function wrap(bytes: Uint8Array): Buffer { + Object.setPrototypeOf(bytes, proto) + return bytes as Buffer +} + +function from( + value: string | ArrayLike | ArrayBuffer | Uint8Array, + encoding: Encoding = 'utf8', +): Buffer { + if (typeof value === 'string') { + const bytes = + encoding === 'base64' || encoding === 'base64url' + ? fromBase64(value) + : encoding === 'hex' + ? Uint8Array.from(value.match(/../g) ?? [], (pair) => + Number.parseInt(pair, 16), + ) + : encoding === 'latin1' || + encoding === 'binary' || + encoding === 'ascii' + ? Uint8Array.from(value, (char) => char.charCodeAt(0) & 0xff) + : encoder.encode(value) + return wrap(bytes) + } + if (value instanceof ArrayBuffer) return wrap(new Uint8Array(value.slice(0))) + return wrap(Uint8Array.from(value as ArrayLike)) +} + +export const Buffer = { + from, + alloc: (size: number, fill = 0): Buffer => + wrap(new Uint8Array(size).fill(fill)), + byteLength: ( + value: string | Uint8Array | ArrayBuffer, + encoding: Encoding = 'utf8', + ): number => + typeof value === 'string' ? from(value, encoding).length : value.byteLength, + concat(list: readonly Uint8Array[], total?: number): Buffer { + const length = total ?? list.reduce((sum, item) => sum + item.length, 0) + const out = new Uint8Array(length) + let offset = 0 + for (const item of list) { + out.set(item.subarray(0, Math.max(0, length - offset)), offset) + offset += item.length + if (offset >= length) break + } + return wrap(out) + }, + isBuffer: (value: unknown): value is Buffer => + value instanceof Uint8Array && Object.getPrototypeOf(value) === proto, +} + +export default {Buffer} diff --git a/src/browser/stubs/crypto.ts b/src/browser/stubs/crypto.ts new file mode 100644 index 00000000..4a379836 --- /dev/null +++ b/src/browser/stubs/crypto.ts @@ -0,0 +1,20 @@ +import {Buffer} from './buffer.js' +import {unsupported} from './unsupported.js' + +export const randomUUID = (): string => globalThis.crypto.randomUUID() +export const randomBytes = (size: number): Buffer => { + const bytes = new Uint8Array(size) + globalThis.crypto.getRandomValues(bytes) + return Buffer.from(bytes) +} +export const createHmac = (): never => unsupported('signs data locally') +export const createHash = (): never => unsupported('hashes local data') +export const timingSafeEqual = (a: Uint8Array, b: Uint8Array): boolean => + a.length === b.length && a.every((byte, index) => byte === b[index]) +export default { + randomUUID, + randomBytes, + createHmac, + createHash, + timingSafeEqual, +} diff --git a/src/browser/stubs/events.ts b/src/browser/stubs/events.ts new file mode 100644 index 00000000..e7e1b388 --- /dev/null +++ b/src/browser/stubs/events.ts @@ -0,0 +1,51 @@ +type Listener = (...args: unknown[]) => void +export class EventEmitter { + #listeners = new Map() + on(name: string | symbol, listener: Listener): this { + this.#listeners.set(name, [...(this.#listeners.get(name) ?? []), listener]) + return this + } + addListener(name: string | symbol, listener: Listener): this { + return this.on(name, listener) + } + once(name: string | symbol, listener: Listener): this { + const wrapped: Listener = (...args) => { + this.off(name, wrapped) + listener(...args) + } + return this.on(name, wrapped) + } + off(name: string | symbol, listener: Listener): this { + this.#listeners.set( + name, + (this.#listeners.get(name) ?? []).filter((entry) => entry !== listener), + ) + return this + } + removeListener(name: string | symbol, listener: Listener): this { + return this.off(name, listener) + } + removeAllListeners(name?: string | symbol): this { + if (name === undefined) this.#listeners.clear() + else this.#listeners.delete(name) + return this + } + emit(name: string | symbol, ...args: unknown[]): boolean { + const list = this.#listeners.get(name) ?? [] + for (const listener of list) listener(...args) + return list.length > 0 + } + listeners(name: string | symbol): Listener[] { + return [...(this.#listeners.get(name) ?? [])] + } + listenerCount(name: string | symbol): number { + return this.#listeners.get(name)?.length ?? 0 + } + setMaxListeners(): this { + return this + } +} +export const once = (emitter: EventEmitter, name: string): Promise => + new Promise((resolve) => emitter.once(name, (...args) => resolve(args))) +export default EventEmitter +Object.assign(EventEmitter, {EventEmitter, once}) diff --git a/src/browser/stubs/fs-promises.ts b/src/browser/stubs/fs-promises.ts new file mode 100644 index 00000000..3d68b940 --- /dev/null +++ b/src/browser/stubs/fs-promises.ts @@ -0,0 +1,49 @@ +import {rejectNotFound, rejectUnsupported} from './unsupported.js' + +// Reads report a missing file, so optional files (profiles, project links) are simply absent. +export const readFile = (path: unknown): Promise => rejectNotFound(path) +export const stat = (path: unknown): Promise => rejectNotFound(path) +export const lstat = (path: unknown): Promise => rejectNotFound(path) +export const access = (path: unknown): Promise => rejectNotFound(path) +export const readdir = (path: unknown): Promise => rejectNotFound(path) +export const open = (): Promise => + rejectUnsupported('reads or writes local files') +export const writeFile = (): Promise => + rejectUnsupported('writes local files') +export const mkdir = (): Promise => + rejectUnsupported('writes local files') +export const rename = (): Promise => + rejectUnsupported('writes local files') +export const unlink = (): Promise => + rejectUnsupported('writes local files') +export const link = (): Promise => + rejectUnsupported('writes local files') +export const rmdir = (): Promise => + rejectUnsupported('writes local files') +export const rm = (): Promise => rejectUnsupported('writes local files') +export const appendFile = (): Promise => + rejectUnsupported('writes local files') +export const chmod = (): Promise => + rejectUnsupported('writes local files') +export const constants = {F_OK: 0, R_OK: 4, W_OK: 2, X_OK: 1} +export const realpath = (path: string): Promise => Promise.resolve(path) +export type FileHandle = never +export default { + constants, + realpath, + readFile, + stat, + lstat, + access, + readdir, + open, + writeFile, + mkdir, + rename, + unlink, + link, + rmdir, + rm, + appendFile, + chmod, +} diff --git a/src/browser/stubs/fs.ts b/src/browser/stubs/fs.ts new file mode 100644 index 00000000..b620c11e --- /dev/null +++ b/src/browser/stubs/fs.ts @@ -0,0 +1,77 @@ +import promises from './fs-promises.js' +import {notFound, unsupported} from './unsupported.js' + +export {promises} +export const constants = { + F_OK: 0, + R_OK: 4, + W_OK: 2, + X_OK: 1, + O_RDONLY: 0, + O_WRONLY: 1, + O_RDWR: 2, + O_CREAT: 64, + O_EXCL: 128, + O_TRUNC: 512, + O_APPEND: 1024, + O_NOFOLLOW: 131072, +} +export const existsSync = (): boolean => false +export const readFileSync = (path: unknown): never => notFound(path) +export const statSync = (path: unknown): never => notFound(path) +export const lstatSync = (path: unknown): never => notFound(path) +export const readdirSync = (path: unknown): never => notFound(path) +export const realpathSync = Object.assign((path: string): string => path, { + native: (path: string): string => path, +}) +export const writeFileSync = (): never => unsupported('writes local files') +export const mkdirSync = (): never => unsupported('writes local files') +export const createWriteStream = (): never => unsupported('writes local files') +export const createReadStream = (): never => unsupported('reads local files') +type Callback = (error: Error | null, value?: unknown) => void +const missing = (path: unknown, callback?: Callback): void => { + queueMicrotask(() => + callback?.( + Object.assign( + new Error(`ENOENT: no such file or directory, '${String(path)}'`), + {code: 'ENOENT'}, + ), + ), + ) +} +export const readdir = (path: unknown, ...rest: unknown[]): void => + missing(path, rest.at(-1) as Callback) +export const stat = (path: unknown, ...rest: unknown[]): void => + missing(path, rest.at(-1) as Callback) +export const realpath = Object.assign( + (path: string, ...rest: unknown[]): void => { + const callback = rest.at(-1) as Callback + queueMicrotask(() => callback?.(null, path)) + }, + { + native: (path: string, callback: Callback): void => { + queueMicrotask(() => callback(null, path)) + }, + }, +) +export class ReadStream {} +export class WriteStream {} +export default { + promises, + constants, + readdir, + stat, + realpath, + existsSync, + readFileSync, + statSync, + lstatSync, + readdirSync, + realpathSync, + writeFileSync, + mkdirSync, + createWriteStream, + createReadStream, + ReadStream, + WriteStream, +} diff --git a/src/browser/stubs/misc.ts b/src/browser/stubs/misc.ts new file mode 100644 index 00000000..ae5e63b3 --- /dev/null +++ b/src/browser/stubs/misc.ts @@ -0,0 +1,77 @@ +// Small parts of url, util, tty, stream and module that libraries reach for at load time. +export const fileURLToPath = (url: string | URL): string => + new URL(String(url)).pathname +export const pathToFileURL = (path: string): URL => new URL(`file://${path}`) +export const isatty = (): boolean => false +export const inspect = Object.assign( + (value: unknown): string => { + if (typeof value === 'string') return value + try { + return JSON.stringify(value, null, 2) ?? String(value) + } catch { + return String(value) + } + }, + {custom: Symbol.for('nodejs.util.inspect.custom'), defaultOptions: {}}, +) +export const format = (template: unknown, ...args: unknown[]): string => { + let index = 0 + const text = + typeof template === 'string' + ? template.replace(/%[sdifjoO%]/g, (token) => + token === '%%' + ? '%' + : index < args.length + ? inspect(args[index++]) + : token, + ) + : inspect(template) + return [text, ...args.slice(index).map((arg) => inspect(arg))].join(' ') +} +export const promisify = unknown>(fn: T): T => + fn +export const inherits = ( + child: {prototype: object}, + parent: {prototype: object}, +): void => { + Object.setPrototypeOf(child.prototype, parent.prototype) +} +export const deprecate = (fn: T): T => fn +export const debuglog = (): (() => void) => () => {} +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') +export const stripVTControlCharacters = (value: string): string => + value.replace(ANSI, '') +export const types = { + isPromise: (value: unknown): boolean => value instanceof Promise, +} +export const createRequire = (): ((id: string) => never) => (id: string) => { + throw new Error(`Cannot load ${id} in the browser`) +} +export class Readable {} +export class Writable {} +export class Transform {} +export class PassThrough {} +export const pipeline = async (): Promise => {} +export const TextEncoder = globalThis.TextEncoder +export const TextDecoder = globalThis.TextDecoder +export default { + fileURLToPath, + pathToFileURL, + isatty, + inspect, + format, + promisify, + inherits, + deprecate, + debuglog, + stripVTControlCharacters, + types, + createRequire, + Readable, + Writable, + Transform, + PassThrough, + pipeline, + TextEncoder, + TextDecoder, +} diff --git a/src/browser/stubs/os.ts b/src/browser/stubs/os.ts new file mode 100644 index 00000000..0408f611 --- /dev/null +++ b/src/browser/stubs/os.ts @@ -0,0 +1,25 @@ +export const homedir = (): string => '/home/browser' +export const tmpdir = (): string => '/tmp' +export const platform = (): string => 'linux' +export const type = (): string => 'Browser' +export const release = (): string => '' +export const arch = (): string => 'wasm' +export const hostname = (): string => 'browser' +export const EOL = '\n' +export const cpus = (): unknown[] => [] +export const userInfo = (): {username: string; homedir: string} => ({ + username: 'browser', + homedir: homedir(), +}) +export default { + homedir, + tmpdir, + platform, + type, + release, + arch, + hostname, + EOL, + cpus, + userInfo, +} diff --git a/src/browser/stubs/packages.ts b/src/browser/stubs/packages.ts new file mode 100644 index 00000000..9998d50f --- /dev/null +++ b/src/browser/stubs/packages.ts @@ -0,0 +1,47 @@ +// Packages with native or local-only parts: the system keychain, the MCP stdio server and jq. +import {rejectUnsupported, unsupported} from './unsupported.js' + +export class AsyncEntry { + constructor() {} + getPassword(): Promise { + return rejectUnsupported('reads the system keychain') + } + setPassword(): Promise { + return rejectUnsupported('writes the system keychain') + } + deletePassword(): Promise { + return rejectUnsupported('writes the system keychain') + } +} +export class Entry extends AsyncEntry {} +export class McpServer { + constructor() { + unsupported('starts a local MCP server') + } +} +export class StdioServerTransport { + constructor() { + unsupported('starts a local MCP server') + } +} +export const serveStdio = (): never => unsupported('starts a local MCP server') +export const json = (): never => unsupported('runs jq locally') +export class JqError extends Error {} +export const writeStreamToFile = (): Promise => + rejectUnsupported('writes local files') +export const readFileAsBlob = (): Promise => + rejectUnsupported('reads local files') +export const downloadWhatsAppMediaToFile = (): Promise => + rejectUnsupported('writes local files') +export default { + AsyncEntry, + Entry, + McpServer, + StdioServerTransport, + serveStdio, + json, + JqError, + writeStreamToFile, + readFileAsBlob, + downloadWhatsAppMediaToFile, +} diff --git a/src/browser/stubs/path.ts b/src/browser/stubs/path.ts new file mode 100644 index 00000000..df246141 --- /dev/null +++ b/src/browser/stubs/path.ts @@ -0,0 +1,79 @@ +// POSIX paths, enough for joining and naming files the browser never touches. +function normalize(path: string): string { + const absolute = path.startsWith('/') + const parts: string[] = [] + for (const part of path.split('/')) { + if (!part || part === '.') continue + if (part === '..') parts.pop() + else parts.push(part) + } + return (absolute ? '/' : '') + parts.join('/') || (absolute ? '/' : '.') +} +export const sep = '/' +export const delimiter = ':' +export const join = (...parts: string[]): string => + normalize(parts.filter(Boolean).join('/')) +export const resolve = (...parts: string[]): string => { + let out = '/' + for (const part of parts) out = part.startsWith('/') ? part : `${out}/${part}` + return normalize(out) +} +export const dirname = (path: string): string => { + const index = path.replace(/\/+$/, '').lastIndexOf('/') + return index <= 0 ? (path.startsWith('/') ? '/' : '.') : path.slice(0, index) +} +export const basename = (path: string, ext?: string): string => { + const base = path.replace(/\/+$/, '').split('/').pop() ?? '' + return ext && base.endsWith(ext) ? base.slice(0, -ext.length) : base +} +export const extname = (path: string): string => { + const base = basename(path) + const index = base.lastIndexOf('.') + return index <= 0 ? '' : base.slice(index) +} +export const isAbsolute = (path: string): boolean => path.startsWith('/') +export const relative = (from: string, to: string): string => { + const a = resolve(from).split('/').filter(Boolean) + const b = resolve(to).split('/').filter(Boolean) + while (a.length && b.length && a[0] === b[0]) { + a.shift() + b.shift() + } + return [...a.map(() => '..'), ...b].join('/') +} +export const parse = ( + path: string, +): {root: string; dir: string; base: string; ext: string; name: string} => { + const base = basename(path) + const ext = extname(path) + return { + root: path.startsWith('/') ? '/' : '', + dir: dirname(path), + base, + ext, + name: ext ? base.slice(0, -ext.length) : base, + } +} +export const format = (parts: { + dir?: string + base?: string + name?: string + ext?: string +}): string => + join(parts.dir ?? '', parts.base ?? `${parts.name ?? ''}${parts.ext ?? ''}`) +export const posix = { + sep, + delimiter, + join, + resolve, + dirname, + basename, + extname, + isAbsolute, + relative, + parse, + format, + normalize, +} +export {normalize} +export default posix diff --git a/src/browser/stubs/process.ts b/src/browser/stubs/process.ts new file mode 100644 index 00000000..5b54c633 --- /dev/null +++ b/src/browser/stubs/process.ts @@ -0,0 +1,75 @@ +// Injected as the global `process` in the browser build. The runtime swaps env and the writers per command. +const noop = (): void => {} +const stream = (): { + write: (chunk: unknown) => boolean + isTTY: boolean + columns: number + on: typeof noop + once: typeof noop + off: typeof noop + end: typeof noop +} => ({ + write: () => true, + isTTY: false, + columns: 100, + on: noop, + once: noop, + off: noop, + end: noop, +}) + +export const process = { + env: {} as Record, + argv: ['node', 'polymorfa'], + execArgv: [] as string[], + execPath: '/usr/bin/node', + title: 'polymorfa', + platform: 'linux', + arch: 'wasm', + pid: 1, + version: 'v22.0.0', + versions: {node: '22.0.0'} as Record, + release: {name: 'browser'}, + exitCode: undefined as number | undefined, + stdout: stream(), + stderr: stream(), + stdin: { + isTTY: false, + on: noop, + once: noop, + off: noop, + setRawMode: noop, + resume: noop, + pause: noop, + }, + cwd: (): string => '/', + chdir: noop, + on: noop, + once: noop, + off: noop, + removeListener: noop, + listeners: (): unknown[] => [], + emit: (): boolean => false, + emitWarning: noop, + nextTick: (fn: (...args: unknown[]) => void, ...args: unknown[]): void => + queueMicrotask(() => fn(...args)), + hrtime: Object.assign( + (previous?: [number, number]): [number, number] => { + const now = performance.now() + const seconds = Math.floor(now / 1000) + const nanos = Math.floor((now % 1000) * 1e6) + return previous + ? [seconds - previous[0], nanos - previous[1]] + : [seconds, nanos] + }, + {bigint: (): bigint => BigInt(Math.floor(performance.now() * 1e6))}, + ), + memoryUsage: (): {rss: number; heapUsed: number} => ({rss: 0, heapUsed: 0}), + exit: (code?: number): never => { + throw Object.assign(new Error(`EEXIT: ${code ?? 0}`), { + oclif: {exit: code ?? 0}, + }) + }, + umask: (): number => 0o22, + getuid: (): number => 1000, +} diff --git a/src/browser/stubs/unavailable.ts b/src/browser/stubs/unavailable.ts new file mode 100644 index 00000000..70e94d96 --- /dev/null +++ b/src/browser/stubs/unavailable.ts @@ -0,0 +1,38 @@ +// Child processes, sockets, DNS, HTTP servers and terminal prompts: none exist in a browser. +import {rejectUnsupported, unsupported} from './unsupported.js' + +export const spawn = (): never => unsupported('starts a local process') +export const exec = (): never => unsupported('starts a local process') +export const execFile = (): never => unsupported('starts a local process') +export const lookup = (): Promise => + rejectUnsupported('resolves host names locally') +export const request = (): never => + unsupported('opens a local network connection') +export const get = (): never => unsupported('opens a local network connection') +export const createServer = (): never => unsupported('starts a local server') +export const createInterface = (): never => + unsupported('asks for input in a terminal') +export const isIP = (value: string): number => + /^\d{1,3}(\.\d{1,3}){3}$/.test(value) ? 4 : value.includes(':') ? 6 : 0 +export class BlockList { + addAddress(): void {} + addRange(): void {} + addSubnet(): void {} + check(): boolean { + return false + } +} +export class Agent {} +export default { + spawn, + exec, + execFile, + lookup, + request, + get, + createServer, + createInterface, + isIP, + BlockList, + Agent, +} diff --git a/src/browser/stubs/unsupported.ts b/src/browser/stubs/unsupported.ts new file mode 100644 index 00000000..bd5b6979 --- /dev/null +++ b/src/browser/stubs/unsupported.ts @@ -0,0 +1,35 @@ +/** Thrown when a command needs something only the installed CLI has: files, a terminal, sockets or child processes. */ +export class BrowserUnsupportedError extends Error { + readonly code = 'BROWSER_UNSUPPORTED' + constructor(what: string) { + super( + `This command needs the installed Polymorfa CLI: it ${what}. Install it with npm install --global @polymorfa/cli@dev.`, + ) + this.name = 'BrowserUnsupportedError' + } +} + +export function unsupported(what: string): never { + throw new BrowserUnsupportedError(what) +} + +/** A rejected promise, for asynchronous APIs the browser cannot serve. */ +export function rejectUnsupported(what: string): Promise { + return Promise.reject(new BrowserUnsupportedError(what)) +} + +/** A missing file, as Node reports it, so callers that probe for optional files carry on. */ +export function notFound(path: unknown): never { + throw missing(path) +} + +export function rejectNotFound(path: unknown): Promise { + return Promise.reject(missing(path)) +} + +function missing(path: unknown): Error { + return Object.assign( + new Error(`ENOENT: no such file or directory, '${String(path)}'`), + {code: 'ENOENT', errno: -2, path: String(path)}, + ) +} diff --git a/test/browser/run.test.ts b/test/browser/run.test.ts new file mode 100644 index 00000000..8371f888 --- /dev/null +++ b/test/browser/run.test.ts @@ -0,0 +1,197 @@ +import {readdirSync} from 'node:fs' +import {join, relative} from 'node:path' + +import {describe, expect, it} from 'vitest' + +import { + COMMAND_IDS, + resolveCommand, + runCommand, + splitWords, +} from '../../src/browser/index.js' +import {Buffer as BrowserBuffer} from '../../src/browser/stubs/buffer.js' +import { + join, + dirname, + basename, + extname, + resolve, +} from '../../src/browser/stubs/path.js' + +// A client token fixture: the SDK checks only the prefix of client tokens. +const CLIENT_TOKEN = `pmfa_ct_${'fixture'.repeat(6)}` + +function run(line: string, respond: (url: URL, init: RequestInit) => Response) { + const output: {stream: 'stdout' | 'stderr'; text: string}[] = [] + const requests: {method: string; url: URL; authorization: string | null}[] = + [] + const fetchFixture = ( + input: RequestInfo | URL, + init: RequestInit = {}, + ): Promise => { + const url = new URL(input instanceof Request ? input.url : String(input)) + requests.push({ + method: init.method ?? 'GET', + url, + authorization: new Headers(init.headers).get('authorization'), + }) + return Promise.resolve(respond(url, init)) + } + return runCommand(splitWords(line), { + env: {POLYMORFA_CLIENT_TOKEN: CLIENT_TOKEN}, + fetch: fetchFixture, + write: (stream, text) => output.push({stream, text}), + }).then((result) => ({ + ...result, + output, + requests, + stdout: output + .filter((entry) => entry.stream === 'stdout') + .map((entry) => entry.text) + .join(''), + stderr: output + .filter((entry) => entry.stream === 'stderr') + .map((entry) => entry.text) + .join(''), + })) +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_fixture', + }, + }) + +describe('browser command runner', () => { + it('splits words like a POSIX shell', () => { + expect( + splitWords(`message send text --body "Hello there" --to '+1 555' a\\ b`), + ).toEqual([ + 'message', + 'send', + 'text', + '--body', + 'Hello there', + '--to', + '+1 555', + 'a b', + ]) + expect(() => splitWords('api "open')).toThrow(SyntaxError) + }) + + it('resolves the longest command and keeps the rest as arguments', () => { + expect(COMMAND_IDS).toContain('message send text') + expect(resolveCommand(['message', 'send', 'text', '--to', 'x'])).toEqual({ + id: 'message send text', + rest: ['--to', 'x'], + }) + expect(resolveCommand(['nope'])).toBeNull() + }) + + it('runs the real command and prints what the installed CLI prints', async () => { + const result = await run( + 'message send text --session 739182640518203 --to +15550002222 --body "Hello from Polymorfa"', + () => json({success: true, data: {id: '3EB0FIXTURE', status: 'sent'}}), + ) + expect(result).toMatchObject({exitCode: 0, command: 'message send text'}) + expect(result.stdout).toBe('Sent message 3EB0FIXTURE to +15550002222.\n') + expect(result.requests).toHaveLength(1) + expect(result.requests[0]).toMatchObject({ + method: 'POST', + authorization: `Bearer ${CLIENT_TOKEN}`, + }) + expect(result.requests[0]!.url.pathname).toBe( + '/messaging/739182640518203/messages/send', + ) + }) + + it('prints the CLI JSON shape with --json', async () => { + const result = await run( + 'message send text --session 739182640518203 --to +15550002222 --body hi --json', + () => json({success: true, data: {id: '3EB0FIXTURE', status: 'sent'}}), + ) + expect(JSON.parse(result.stdout)).toEqual({ + data: {id: '3EB0FIXTURE', status: 'sent'}, + metadata: {attempts: 1, status: 200, requestId: 'req_fixture'}, + }) + }) + + it('reports usage errors and unknown commands with the CLI exit codes', async () => { + const missing = await run( + 'message send text --session 739182640518203', + () => json({}), + ) + expect(missing.exitCode).toBe(2) + expect(missing.stderr).toContain('Missing required flag body') + const unknown = await run('nope nope', () => json({})) + expect(unknown).toMatchObject({exitCode: 2, command: null}) + expect(unknown.stderr).toBe('Unknown command: polymorfa nope nope\n') + }) + + it('restores the process and fetch after each command', async () => { + const before = { + fetch: globalThis.fetch, + write: Reflect.get(process.stdout, 'write') as unknown, + env: process.env, + } + await run('nope', () => json({})) + expect(globalThis.fetch).toBe(before.fetch) + expect(Reflect.get(process.stdout, 'write')).toBe(before.write) + expect(process.env).toBe(before.env) + }) +}) + +describe('browser command registry', () => { + it('lists every command file, so regenerate it after adding a command', () => { + const root = join(import.meta.dirname, '../../src/commands') + const ids: string[] = [] + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, {withFileTypes: true})) { + const path = join(dir, entry.name) + if (entry.isDirectory()) walk(path) + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) + ids.push( + relative(root, path).replace(/\.ts$/, '').split('/').join(' '), + ) + } + } + walk(root) + expect([...COMMAND_IDS].sort()).toEqual(ids.sort()) + }) +}) + +describe('browser stand-ins', () => { + it('encodes and decodes like Node for the methods the CLI uses', () => { + const value = 'héllo, wörld' + expect(BrowserBuffer.from(value).toString('base64')).toBe( + Buffer.from(value).toString('base64'), + ) + expect( + BrowserBuffer.from( + Buffer.from(value).toString('base64'), + 'base64', + ).toString(), + ).toBe(value) + expect(BrowserBuffer.byteLength(value)).toBe(Buffer.byteLength(value)) + expect( + BrowserBuffer.concat([ + BrowserBuffer.from('ab'), + BrowserBuffer.from('cd'), + ]).toString(), + ).toBe('abcd') + expect(BrowserBuffer.from('ff00', 'hex').toString('hex')).toBe('ff00') + }) + + it('joins and names POSIX paths', () => { + expect(join('/a', 'b', '../c', 'd.txt')).toBe('/a/c/d.txt') + expect([ + dirname('/a/b/c.json'), + basename('/a/b/c.json'), + extname('/a/b/c.json'), + ]).toEqual(['/a/b', 'c.json', '.json']) + expect(resolve('/a', 'b')).toBe('/a/b') + }) +}) From 33033e11c82345ee118e485ce016c96a095bcdd8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 05:09:54 +0200 Subject: [PATCH 2/4] fix: queue browser commands and format the generated registry A second command started while one runs now waits for it instead of throwing. The fetch passed in must not call globalThis.fetch, which points at it while a command runs. --- scripts/gen-browser-registry.mjs | 9 +- src/browser/index.ts | 27 +- src/browser/registry.gen.ts | 866 +++++++++++++++++-------------- test/browser/run.test.ts | 43 +- 4 files changed, 543 insertions(+), 402 deletions(-) diff --git a/scripts/gen-browser-registry.mjs b/scripts/gen-browser-registry.mjs index f65a68af..e55ba467 100644 --- a/scripts/gen-browser-registry.mjs +++ b/scripts/gen-browser-registry.mjs @@ -4,6 +4,7 @@ import console from 'node:console' import {URL} from 'node:url' import {readdirSync, statSync, writeFileSync} from 'node:fs' import {join, relative} from 'node:path' +import {format, resolveConfig} from 'prettier' const root = new URL('../src/commands/', import.meta.url).pathname const files = [] @@ -30,5 +31,11 @@ export const COMMAND_LOADERS: Readonly Promise<{default: un ${entries.join('\n')} } ` -writeFileSync(new URL('../src/browser/registry.gen.ts', import.meta.url), out) +const target = new URL('../src/browser/registry.gen.ts', import.meta.url) +// Formatted like the rest of the source, so the format check passes. +const options = (await resolveConfig(target.pathname)) ?? {} +writeFileSync( + target, + await format(out, {...options, filepath: target.pathname}), +) console.log(`registry: ${files.length} commands`) diff --git a/src/browser/index.ts b/src/browser/index.ts index befd3c33..843d9cb5 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -20,7 +20,11 @@ export const COMMAND_IDS: readonly string[] = export interface BrowserIo { /** Environment for the command: `POLYMORFA_PROJECT_TOKEN` or `POLYMORFA_API_KEY`, and optionally `POLYMORFA_BASE_URL`. */ readonly env: Readonly> - /** The fetch the SDK uses for this command. */ + /** + * The fetch the SDK uses for this command. While the command runs, + * `globalThis.fetch` is this function, so it must call a fetch bound before + * the command started, never `globalThis.fetch`. + */ readonly fetch: typeof fetch readonly write: (stream: 'stdout' | 'stderr', text: string) => void } @@ -115,15 +119,25 @@ interface ProcessLike { stderr: {write: (chunk: unknown) => boolean} } -let running = false +let queue: Promise = Promise.resolve() -/** Run one command. Only one runs at a time; the caller waits for the result before the next. */ -export async function runCommand( +/** + * Run one command. Commands share the page's `process` and `fetch`, so only + * one runs at a time: a command started while another runs waits for it. + */ +export function runCommand( + words: readonly string[], + io: BrowserIo, +): Promise { + const result = queue.then(() => runNow(words, io)) + queue = result.catch(() => undefined) + return result +} + +async function runNow( words: readonly string[], io: BrowserIo, ): Promise { - if (running) throw new Error('A command is already running.') - running = true // The bare `process`: in the browser build it is the injected stand-in, under Node the real one. const proc = process as unknown as ProcessLike const saved = { @@ -180,6 +194,5 @@ export async function runCommand( proc.stdout.write = saved.out proc.stderr.write = saved.err globalThis.fetch = saved.fetch - running = false } } diff --git a/src/browser/registry.gen.ts b/src/browser/registry.gen.ts index a1c0a379..ba216b34 100644 --- a/src/browser/registry.gen.ts +++ b/src/browser/registry.gen.ts @@ -1,384 +1,486 @@ // Generated by scripts/gen-browser-registry.mjs. Do not edit. -export const COMMAND_LOADERS: Readonly Promise<{default: unknown}>>> = { - "analytics get": () => import('../commands/analytics/get.js'), - "analytics metrics": () => import('../commands/analytics/metrics.js'), - "api": () => import('../commands/api.js'), - "audience create": () => import('../commands/audience/create.js'), - "audience members add": () => import('../commands/audience/members/add.js'), - "audience members list": () => import('../commands/audience/members/list.js'), - "audience members remove": () => import('../commands/audience/members/remove.js'), - "audit-log list": () => import('../commands/audit-log/list.js'), - "auth list": () => import('../commands/auth/list.js'), - "auth login": () => import('../commands/auth/login.js'), - "auth logout": () => import('../commands/auth/logout.js'), - "auth status": () => import('../commands/auth/status.js'), - "auth use": () => import('../commands/auth/use.js'), - "bansafe claim list": () => import('../commands/bansafe/claim/list.js'), - "bansafe claim view": () => import('../commands/bansafe/claim/view.js'), - "bansafe collection list": () => import('../commands/bansafe/collection/list.js'), - "bansafe enforcement list": () => import('../commands/bansafe/enforcement/list.js'), - "bansafe finding list": () => import('../commands/bansafe/finding/list.js'), - "bansafe health history": () => import('../commands/bansafe/health/history.js'), - "bansafe health list": () => import('../commands/bansafe/health/list.js'), - "bansafe health view": () => import('../commands/bansafe/health/view.js'), - "bansafe health-action list": () => import('../commands/bansafe/health-action/list.js'), - "bansafe health-policy update": () => import('../commands/bansafe/health-policy/update.js'), - "bansafe health-policy view": () => import('../commands/bansafe/health-policy/view.js'), - "bansafe incident list": () => import('../commands/bansafe/incident/list.js'), - "bansafe incident report": () => import('../commands/bansafe/incident/report.js'), - "bansafe incident retract": () => import('../commands/bansafe/incident/retract.js'), - "bansafe insurance-evidence update": () => import('../commands/bansafe/insurance-evidence/update.js'), - "bansafe insurance-evidence view": () => import('../commands/bansafe/insurance-evidence/view.js'), - "bansafe safe-mode update": () => import('../commands/bansafe/safe-mode/update.js'), - "bansafe safe-mode view": () => import('../commands/bansafe/safe-mode/view.js'), - "bansafe signal list": () => import('../commands/bansafe/signal/list.js'), - "bansafe telemetry history": () => import('../commands/bansafe/telemetry/history.js'), - "bansafe telemetry view": () => import('../commands/bansafe/telemetry/view.js'), - "bansafe warmup update": () => import('../commands/bansafe/warmup/update.js'), - "bansafe warmup view": () => import('../commands/bansafe/warmup/view.js'), - "billing controls get": () => import('../commands/billing/controls/get.js'), - "billing controls set": () => import('../commands/billing/controls/set.js'), - "billing limits list": () => import('../commands/billing/limits/list.js'), - "billing limits set": () => import('../commands/billing/limits/set.js'), - "billing priority list": () => import('../commands/billing/priority/list.js'), - "billing priority reorder": () => import('../commands/billing/priority/reorder.js'), - "billing priority set": () => import('../commands/billing/priority/set.js'), - "business accounts view": () => import('../commands/business/accounts/view.js'), - "business catalog cart set": () => import('../commands/business/catalog/cart/set.js'), - "business catalog create": () => import('../commands/business/catalog/create.js'), - "business catalog view": () => import('../commands/business/catalog/view.js'), - "business collection appeal": () => import('../commands/business/collection/appeal.js'), - "business collection create": () => import('../commands/business/collection/create.js'), - "business collection delete": () => import('../commands/business/collection/delete.js'), - "business collection list": () => import('../commands/business/collection/list.js'), - "business collection reorder": () => import('../commands/business/collection/reorder.js'), - "business collection update": () => import('../commands/business/collection/update.js'), - "business collection view": () => import('../commands/business/collection/view.js'), - "business compliance replace": () => import('../commands/business/compliance/replace.js'), - "business compliance view": () => import('../commands/business/compliance/view.js'), - "business eligibility view": () => import('../commands/business/eligibility/view.js'), - "business order view": () => import('../commands/business/order/view.js'), - "business product appeal": () => import('../commands/business/product/appeal.js'), - "business product create": () => import('../commands/business/product/create.js'), - "business product delete": () => import('../commands/business/product/delete.js'), - "business product replace": () => import('../commands/business/product/replace.js'), - "business product view": () => import('../commands/business/product/view.js'), - "business product visibility set": () => import('../commands/business/product/visibility/set.js'), - "business profile cover delete": () => import('../commands/business/profile/cover/delete.js'), - "business profile cover set": () => import('../commands/business/profile/cover/set.js'), - "business profile update": () => import('../commands/business/profile/update.js'), - "business profile view": () => import('../commands/business/profile/view.js'), - "call reject": () => import('../commands/call/reject.js'), - "calls accept": () => import('../commands/calls/accept.js'), - "calls check": () => import('../commands/calls/check.js'), - "calls end": () => import('../commands/calls/end.js'), - "calls export": () => import('../commands/calls/export.js'), - "calls hand": () => import('../commands/calls/hand.js'), - "calls invite": () => import('../commands/calls/invite.js'), - "calls leave": () => import('../commands/calls/leave.js'), - "calls link create": () => import('../commands/calls/link/create.js'), - "calls link preview": () => import('../commands/calls/link/preview.js'), - "calls list": () => import('../commands/calls/list.js'), - "calls media": () => import('../commands/calls/media.js'), - "calls opt-outs add": () => import('../commands/calls/opt-outs/add.js'), - "calls opt-outs import": () => import('../commands/calls/opt-outs/import.js'), - "calls opt-outs list": () => import('../commands/calls/opt-outs/list.js'), - "calls opt-outs remove": () => import('../commands/calls/opt-outs/remove.js'), - "calls permission get": () => import('../commands/calls/permission/get.js'), - "calls permission request": () => import('../commands/calls/permission/request.js'), - "calls place-group": () => import('../commands/calls/place-group.js'), - "calls place": () => import('../commands/calls/place.js'), - "calls policy get": () => import('../commands/calls/policy/get.js'), - "calls policy set": () => import('../commands/calls/policy/set.js'), - "calls react": () => import('../commands/calls/react.js'), - "calls reject": () => import('../commands/calls/reject.js'), - "calls retention get": () => import('../commands/calls/retention/get.js'), - "calls retention set": () => import('../commands/calls/retention/set.js'), - "calls ring": () => import('../commands/calls/ring.js'), - "calls settings get": () => import('../commands/calls/settings/get.js'), - "calls settings set": () => import('../commands/calls/settings/set.js'), - "calls stats": () => import('../commands/calls/stats.js'), - "calls view": () => import('../commands/calls/view.js'), - "calls watch": () => import('../commands/calls/watch.js'), - "campaign analytics": () => import('../commands/campaign/analytics.js'), - "campaign conversions record": () => import('../commands/campaign/conversions/record.js'), - "campaign conversions report": () => import('../commands/campaign/conversions/report.js'), - "campaign create": () => import('../commands/campaign/create.js'), - "campaign enroll": () => import('../commands/campaign/enroll.js'), - "campaign launch": () => import('../commands/campaign/launch.js'), - "campaign list": () => import('../commands/campaign/list.js'), - "campaign pause": () => import('../commands/campaign/pause.js'), - "campaign recipients add": () => import('../commands/campaign/recipients/add.js'), - "campaign recipients list": () => import('../commands/campaign/recipients/list.js'), - "campaign requeue": () => import('../commands/campaign/requeue.js'), - "campaign resume": () => import('../commands/campaign/resume.js'), - "campaign segments count": () => import('../commands/campaign/segments/count.js'), - "campaign segments create": () => import('../commands/campaign/segments/create.js'), - "campaign segments delete": () => import('../commands/campaign/segments/delete.js'), - "campaign segments get": () => import('../commands/campaign/segments/get.js'), - "campaign segments list": () => import('../commands/campaign/segments/list.js'), - "campaign segments update": () => import('../commands/campaign/segments/update.js'), - "campaign settings frequency-cap get": () => import('../commands/campaign/settings/frequency-cap/get.js'), - "campaign settings frequency-cap remove": () => import('../commands/campaign/settings/frequency-cap/remove.js'), - "campaign settings frequency-cap set": () => import('../commands/campaign/settings/frequency-cap/set.js'), - "campaign stop": () => import('../commands/campaign/stop.js'), - "campaign update": () => import('../commands/campaign/update.js'), - "campaign view": () => import('../commands/campaign/view.js'), - "channel create": () => import('../commands/channel/create.js'), - "channel delete": () => import('../commands/channel/delete.js'), - "channel follow": () => import('../commands/channel/follow.js'), - "channel list": () => import('../commands/channel/list.js'), - "channel live-updates subscribe": () => import('../commands/channel/live-updates/subscribe.js'), - "channel message list": () => import('../commands/channel/message/list.js'), - "channel message mark-viewed": () => import('../commands/channel/message/mark-viewed.js'), - "channel message react": () => import('../commands/channel/message/react.js'), - "channel message updates": () => import('../commands/channel/message/updates.js'), - "channel mute": () => import('../commands/channel/mute.js'), - "channel unfollow": () => import('../commands/channel/unfollow.js'), - "channel unmute": () => import('../commands/channel/unmute.js'), - "channel view": () => import('../commands/channel/view.js'), - "chat list": () => import('../commands/chat/list.js'), - "chat message list": () => import('../commands/chat/message/list.js'), - "chat message media": () => import('../commands/chat/message/media.js'), - "chat message view": () => import('../commands/chat/message/view.js'), - "chat view": () => import('../commands/chat/view.js'), - "cloud catalog list": () => import('../commands/cloud/catalog/list.js'), - "cloud catalog products": () => import('../commands/cloud/catalog/products.js'), - "cloud credentials get": () => import('../commands/cloud/credentials/get.js'), - "cloud credentials reauthorize": () => import('../commands/cloud/credentials/reauthorize.js'), - "cloud group create": () => import('../commands/cloud/group/create.js'), - "cloud group delete": () => import('../commands/cloud/group/delete.js'), - "cloud group get": () => import('../commands/cloud/group/get.js'), - "cloud group invite-link": () => import('../commands/cloud/group/invite-link.js'), - "cloud group join-requests approve": () => import('../commands/cloud/group/join-requests/approve.js'), - "cloud group join-requests list": () => import('../commands/cloud/group/join-requests/list.js'), - "cloud group join-requests reject": () => import('../commands/cloud/group/join-requests/reject.js'), - "cloud group list": () => import('../commands/cloud/group/list.js'), - "cloud group pin": () => import('../commands/cloud/group/pin.js'), - "cloud group remove-participants": () => import('../commands/cloud/group/remove-participants.js'), - "cloud group update": () => import('../commands/cloud/group/update.js'), - "cloud marketing status": () => import('../commands/cloud/marketing/status.js'), - "cloud pricing": () => import('../commands/cloud/pricing.js'), - "cloud service-window": () => import('../commands/cloud/service-window.js'), - "cloud template create": () => import('../commands/cloud/template/create.js'), - "cloud template delete": () => import('../commands/cloud/template/delete.js'), - "cloud template edit": () => import('../commands/cloud/template/edit.js'), - "cloud template get": () => import('../commands/cloud/template/get.js'), - "cloud template list": () => import('../commands/cloud/template/list.js'), - "completion suggest": () => import('../commands/completion/suggest.js'), - "completion": () => import('../commands/completion.js'), - "contact block": () => import('../commands/contact/block.js'), - "contact blocklist": () => import('../commands/contact/blocklist.js'), - "contact business-profile": () => import('../commands/contact/business-profile.js'), - "contact check": () => import('../commands/contact/check.js'), - "contact devices": () => import('../commands/contact/devices.js'), - "contact info": () => import('../commands/contact/info.js'), - "contact list": () => import('../commands/contact/list.js'), - "contact picture": () => import('../commands/contact/picture.js'), - "contact unblock": () => import('../commands/contact/unblock.js'), - "contact view": () => import('../commands/contact/view.js'), - "customer archive": () => import('../commands/customer/archive.js'), - "customer create": () => import('../commands/customer/create.js'), - "customer enable": () => import('../commands/customer/enable.js'), - "customer event list": () => import('../commands/customer/event/list.js'), - "customer list": () => import('../commands/customer/list.js'), - "customer number list": () => import('../commands/customer/number/list.js'), - "customer number transfer": () => import('../commands/customer/number/transfer.js'), - "customer pairing-link create": () => import('../commands/customer/pairing-link/create.js'), - "customer pairing-link list": () => import('../commands/customer/pairing-link/list.js'), - "customer pairing-link revoke": () => import('../commands/customer/pairing-link/revoke.js'), - "customer restore": () => import('../commands/customer/restore.js'), - "customer status": () => import('../commands/customer/status.js'), - "customer update": () => import('../commands/customer/update.js'), - "customer view": () => import('../commands/customer/view.js'), - "docs open": () => import('../commands/docs/open.js'), - "docs search": () => import('../commands/docs/search.js'), - "doctor": () => import('../commands/doctor.js'), - "event list": () => import('../commands/event/list.js'), - "event replay": () => import('../commands/event/replay.js'), - "event stream": () => import('../commands/event/stream.js'), - "event view": () => import('../commands/event/view.js'), - "flow create": () => import('../commands/flow/create.js'), - "flow delete": () => import('../commands/flow/delete.js'), - "flow deprecate": () => import('../commands/flow/deprecate.js'), - "flow discard": () => import('../commands/flow/discard.js'), - "flow endpoint delete": () => import('../commands/flow/endpoint/delete.js'), - "flow endpoint get": () => import('../commands/flow/endpoint/get.js'), - "flow endpoint receipts": () => import('../commands/flow/endpoint/receipts.js'), - "flow endpoint set": () => import('../commands/flow/endpoint/set.js'), - "flow get": () => import('../commands/flow/get.js'), - "flow key custody": () => import('../commands/flow/key/custody.js'), - "flow key get": () => import('../commands/flow/key/get.js'), - "flow key register": () => import('../commands/flow/key/register.js'), - "flow key rotate": () => import('../commands/flow/key/rotate.js'), - "flow list": () => import('../commands/flow/list.js'), - "flow publish": () => import('../commands/flow/publish.js'), - "flow receipts": () => import('../commands/flow/receipts.js'), - "flow sync": () => import('../commands/flow/sync.js'), - "flow update": () => import('../commands/flow/update.js'), - "flow upload": () => import('../commands/flow/upload.js'), - "function create": () => import('../commands/function/create.js'), - "function delete": () => import('../commands/function/delete.js'), - "function deployment create": () => import('../commands/function/deployment/create.js'), - "function deployment list": () => import('../commands/function/deployment/list.js'), - "function deployment promote": () => import('../commands/function/deployment/promote.js'), - "function deployment view": () => import('../commands/function/deployment/view.js'), - "function invocation create": () => import('../commands/function/invocation/create.js'), - "function invocation list": () => import('../commands/function/invocation/list.js'), - "function invocation view": () => import('../commands/function/invocation/view.js'), - "function list": () => import('../commands/function/list.js'), - "function secret create": () => import('../commands/function/secret/create.js'), - "function secret list": () => import('../commands/function/secret/list.js'), - "function secret revoke": () => import('../commands/function/secret/revoke.js'), - "function update": () => import('../commands/function/update.js'), - "function view": () => import('../commands/function/view.js'), - "group capabilities": () => import('../commands/group/capabilities.js'), - "group create": () => import('../commands/group/create.js'), - "group description": () => import('../commands/group/description.js'), - "group invite-link": () => import('../commands/group/invite-link.js'), - "group list": () => import('../commands/group/list.js'), - "group participants add": () => import('../commands/group/participants/add.js'), - "group participants demote": () => import('../commands/group/participants/demote.js'), - "group participants list": () => import('../commands/group/participants/list.js'), - "group participants promote": () => import('../commands/group/participants/promote.js'), - "group participants remove": () => import('../commands/group/participants/remove.js'), - "group settings": () => import('../commands/group/settings.js'), - "group subject": () => import('../commands/group/subject.js'), - "group view": () => import('../commands/group/view.js'), - "hybrid-link pause": () => import('../commands/hybrid-link/pause.js'), - "hybrid-link resume": () => import('../commands/hybrid-link/resume.js'), - "hybrid-link state": () => import('../commands/hybrid-link/state.js'), - "identity resolve": () => import('../commands/identity/resolve.js'), - "init": () => import('../commands/init.js'), - "key list": () => import('../commands/key/list.js'), - "key revoke": () => import('../commands/key/revoke.js'), - "label chat list": () => import('../commands/label/chat/list.js'), - "label chat replace": () => import('../commands/label/chat/replace.js'), - "label create": () => import('../commands/label/create.js'), - "label delete": () => import('../commands/label/delete.js'), - "label list": () => import('../commands/label/list.js'), - "label update": () => import('../commands/label/update.js'), - "link": () => import('../commands/link.js'), - "listen": () => import('../commands/listen.js'), - "logs list": () => import('../commands/logs/list.js'), - "logs tail": () => import('../commands/logs/tail.js'), - "mcp doctor": () => import('../commands/mcp/doctor.js'), - "mcp install": () => import('../commands/mcp/install.js'), - "mcp serve": () => import('../commands/mcp/serve.js'), - "mcp status": () => import('../commands/mcp/status.js'), - "mcp tools": () => import('../commands/mcp/tools.js'), - "mcp uninstall": () => import('../commands/mcp/uninstall.js'), - "media download": () => import('../commands/media/download.js'), - "media persist": () => import('../commands/media/persist.js'), - "media view": () => import('../commands/media/view.js'), - "member list": () => import('../commands/member/list.js'), - "message delete": () => import('../commands/message/delete.js'), - "message edit": () => import('../commands/message/edit.js'), - "message mark-seen": () => import('../commands/message/mark-seen.js'), - "message operation-status": () => import('../commands/message/operation-status.js'), - "message react": () => import('../commands/message/react.js'), - "message send audio": () => import('../commands/message/send/audio.js'), - "message send buttons": () => import('../commands/message/send/buttons.js'), - "message send contact": () => import('../commands/message/send/contact.js'), - "message send document": () => import('../commands/message/send/document.js'), - "message send image": () => import('../commands/message/send/image.js'), - "message send list": () => import('../commands/message/send/list.js'), - "message send location": () => import('../commands/message/send/location.js'), - "message send order-status": () => import('../commands/message/send/order-status.js'), - "message send order": () => import('../commands/message/send/order.js'), - "message send poll": () => import('../commands/message/send/poll.js'), - "message send template": () => import('../commands/message/send/template.js'), - "message send text": () => import('../commands/message/send/text.js'), - "message send video": () => import('../commands/message/send/video.js'), - "message star": () => import('../commands/message/star.js'), - "message typing": () => import('../commands/message/typing.js'), - "operation cancel": () => import('../commands/operation/cancel.js'), - "operation list": () => import('../commands/operation/list.js'), - "operation view": () => import('../commands/operation/view.js'), - "operation wait": () => import('../commands/operation/wait.js'), - "operation watch": () => import('../commands/operation/watch.js'), - "optout settings update": () => import('../commands/optout/settings/update.js'), - "optout settings view": () => import('../commands/optout/settings/view.js'), - "presence chat subscribe": () => import('../commands/presence/chat/subscribe.js'), - "presence chat view": () => import('../commands/presence/chat/view.js'), - "presence set": () => import('../commands/presence/set.js'), - "presence view": () => import('../commands/presence/view.js'), - "privacy disappearing-timer set": () => import('../commands/privacy/disappearing-timer/set.js'), - "privacy set": () => import('../commands/privacy/set.js'), - "privacy view": () => import('../commands/privacy/view.js'), - "profile name set": () => import('../commands/profile/name/set.js'), - "profile picture delete": () => import('../commands/profile/picture/delete.js'), - "profile picture set": () => import('../commands/profile/picture/set.js'), - "profile status set": () => import('../commands/profile/status/set.js'), - "profile view": () => import('../commands/profile/view.js'), - "project current": () => import('../commands/project/current.js'), - "project list": () => import('../commands/project/list.js'), - "project use": () => import('../commands/project/use.js'), - "project-token list": () => import('../commands/project-token/list.js'), - "quick-reply create": () => import('../commands/quick-reply/create.js'), - "quick-reply delete": () => import('../commands/quick-reply/delete.js'), - "quick-reply list": () => import('../commands/quick-reply/list.js'), - "quick-reply replace": () => import('../commands/quick-reply/replace.js'), - "quicklink availability": () => import('../commands/quicklink/availability.js'), - "quicklink cancel": () => import('../commands/quicklink/cancel.js'), - "quicklink create": () => import('../commands/quicklink/create.js'), - "quicklink view": () => import('../commands/quicklink/view.js'), - "routing-policy get": () => import('../commands/routing-policy/get.js'), - "routing-policy set": () => import('../commands/routing-policy/set.js'), - "security-incident acknowledge": () => import('../commands/security-incident/acknowledge.js'), - "security-incident list": () => import('../commands/security-incident/list.js'), - "session capabilities": () => import('../commands/session/capabilities.js'), - "session configuration get": () => import('../commands/session/configuration/get.js'), - "session configuration update": () => import('../commands/session/configuration/update.js'), - "session delete": () => import('../commands/session/delete.js'), - "session hybrid-merge-candidates": () => import('../commands/session/hybrid-merge-candidates.js'), - "session list": () => import('../commands/session/list.js'), - "session status": () => import('../commands/session/status.js'), - "session stop": () => import('../commands/session/stop.js'), - "session tier confirm": () => import('../commands/session/tier/confirm.js'), - "session tier quote": () => import('../commands/session/tier/quote.js'), - "session tier status": () => import('../commands/session/tier/status.js'), - "session view": () => import('../commands/session/view.js'), - "session-ban list": () => import('../commands/session-ban/list.js'), - "sip-trunk create": () => import('../commands/sip-trunk/create.js'), - "sip-trunk delete": () => import('../commands/sip-trunk/delete.js'), - "sip-trunk endpoint": () => import('../commands/sip-trunk/endpoint.js'), - "sip-trunk list": () => import('../commands/sip-trunk/list.js'), - "sip-trunk rotate-credentials": () => import('../commands/sip-trunk/rotate-credentials.js'), - "sip-trunk update": () => import('../commands/sip-trunk/update.js'), - "sip-trunk view": () => import('../commands/sip-trunk/view.js'), - "template submit": () => import('../commands/template/submit.js'), - "testing history-fixture create": () => import('../commands/testing/history-fixture/create.js'), - "trigger": () => import('../commands/trigger.js'), - "usage gates": () => import('../commands/usage/gates.js'), - "usage records": () => import('../commands/usage/records.js'), - "usage summary": () => import('../commands/usage/summary.js'), - "user security-code": () => import('../commands/user/security-code.js'), - "version": () => import('../commands/version.js'), - "voice audio delete": () => import('../commands/voice/audio/delete.js'), - "voice audio list": () => import('../commands/voice/audio/list.js'), - "voice audio preview": () => import('../commands/voice/audio/preview.js'), - "voice audio tts": () => import('../commands/voice/audio/tts.js'), - "voice audio update": () => import('../commands/voice/audio/update.js'), - "voice audio upload": () => import('../commands/voice/audio/upload.js'), - "voice audio view": () => import('../commands/voice/audio/view.js'), - "voice credential add": () => import('../commands/voice/credential/add.js'), - "voice credential delete": () => import('../commands/voice/credential/delete.js'), - "voice credential list": () => import('../commands/voice/credential/list.js'), - "voice credential verify": () => import('../commands/voice/credential/verify.js'), - "voice credential view": () => import('../commands/voice/credential/view.js'), - "webhook create": () => import('../commands/webhook/create.js'), - "webhook delete": () => import('../commands/webhook/delete.js'), - "webhook delivery attempt list": () => import('../commands/webhook/delivery/attempt/list.js'), - "webhook delivery attempt view": () => import('../commands/webhook/delivery/attempt/view.js'), - "webhook delivery list": () => import('../commands/webhook/delivery/list.js'), - "webhook delivery retry": () => import('../commands/webhook/delivery/retry.js'), - "webhook delivery view": () => import('../commands/webhook/delivery/view.js'), - "webhook list": () => import('../commands/webhook/list.js'), - "webhook secret rotate": () => import('../commands/webhook/secret/rotate.js'), - "webhook test": () => import('../commands/webhook/test.js'), - "webhook update": () => import('../commands/webhook/update.js'), - "webhook view": () => import('../commands/webhook/view.js'), - "widget update": () => import('../commands/widget/update.js'), - "widget view": () => import('../commands/widget/view.js'), +export const COMMAND_LOADERS: Readonly< + Record Promise<{default: unknown}>> +> = { + 'analytics get': () => import('../commands/analytics/get.js'), + 'analytics metrics': () => import('../commands/analytics/metrics.js'), + api: () => import('../commands/api.js'), + 'audience create': () => import('../commands/audience/create.js'), + 'audience members add': () => import('../commands/audience/members/add.js'), + 'audience members list': () => import('../commands/audience/members/list.js'), + 'audience members remove': () => + import('../commands/audience/members/remove.js'), + 'audit-log list': () => import('../commands/audit-log/list.js'), + 'auth list': () => import('../commands/auth/list.js'), + 'auth login': () => import('../commands/auth/login.js'), + 'auth logout': () => import('../commands/auth/logout.js'), + 'auth status': () => import('../commands/auth/status.js'), + 'auth use': () => import('../commands/auth/use.js'), + 'bansafe claim list': () => import('../commands/bansafe/claim/list.js'), + 'bansafe claim view': () => import('../commands/bansafe/claim/view.js'), + 'bansafe collection list': () => + import('../commands/bansafe/collection/list.js'), + 'bansafe enforcement list': () => + import('../commands/bansafe/enforcement/list.js'), + 'bansafe finding list': () => import('../commands/bansafe/finding/list.js'), + 'bansafe health history': () => + import('../commands/bansafe/health/history.js'), + 'bansafe health list': () => import('../commands/bansafe/health/list.js'), + 'bansafe health view': () => import('../commands/bansafe/health/view.js'), + 'bansafe health-action list': () => + import('../commands/bansafe/health-action/list.js'), + 'bansafe health-policy update': () => + import('../commands/bansafe/health-policy/update.js'), + 'bansafe health-policy view': () => + import('../commands/bansafe/health-policy/view.js'), + 'bansafe incident list': () => import('../commands/bansafe/incident/list.js'), + 'bansafe incident report': () => + import('../commands/bansafe/incident/report.js'), + 'bansafe incident retract': () => + import('../commands/bansafe/incident/retract.js'), + 'bansafe insurance-evidence update': () => + import('../commands/bansafe/insurance-evidence/update.js'), + 'bansafe insurance-evidence view': () => + import('../commands/bansafe/insurance-evidence/view.js'), + 'bansafe safe-mode update': () => + import('../commands/bansafe/safe-mode/update.js'), + 'bansafe safe-mode view': () => + import('../commands/bansafe/safe-mode/view.js'), + 'bansafe signal list': () => import('../commands/bansafe/signal/list.js'), + 'bansafe telemetry history': () => + import('../commands/bansafe/telemetry/history.js'), + 'bansafe telemetry view': () => + import('../commands/bansafe/telemetry/view.js'), + 'bansafe warmup update': () => import('../commands/bansafe/warmup/update.js'), + 'bansafe warmup view': () => import('../commands/bansafe/warmup/view.js'), + 'billing controls get': () => import('../commands/billing/controls/get.js'), + 'billing controls set': () => import('../commands/billing/controls/set.js'), + 'billing limits list': () => import('../commands/billing/limits/list.js'), + 'billing limits set': () => import('../commands/billing/limits/set.js'), + 'billing priority list': () => import('../commands/billing/priority/list.js'), + 'billing priority reorder': () => + import('../commands/billing/priority/reorder.js'), + 'billing priority set': () => import('../commands/billing/priority/set.js'), + 'business accounts view': () => + import('../commands/business/accounts/view.js'), + 'business catalog cart set': () => + import('../commands/business/catalog/cart/set.js'), + 'business catalog create': () => + import('../commands/business/catalog/create.js'), + 'business catalog view': () => import('../commands/business/catalog/view.js'), + 'business collection appeal': () => + import('../commands/business/collection/appeal.js'), + 'business collection create': () => + import('../commands/business/collection/create.js'), + 'business collection delete': () => + import('../commands/business/collection/delete.js'), + 'business collection list': () => + import('../commands/business/collection/list.js'), + 'business collection reorder': () => + import('../commands/business/collection/reorder.js'), + 'business collection update': () => + import('../commands/business/collection/update.js'), + 'business collection view': () => + import('../commands/business/collection/view.js'), + 'business compliance replace': () => + import('../commands/business/compliance/replace.js'), + 'business compliance view': () => + import('../commands/business/compliance/view.js'), + 'business eligibility view': () => + import('../commands/business/eligibility/view.js'), + 'business order view': () => import('../commands/business/order/view.js'), + 'business product appeal': () => + import('../commands/business/product/appeal.js'), + 'business product create': () => + import('../commands/business/product/create.js'), + 'business product delete': () => + import('../commands/business/product/delete.js'), + 'business product replace': () => + import('../commands/business/product/replace.js'), + 'business product view': () => import('../commands/business/product/view.js'), + 'business product visibility set': () => + import('../commands/business/product/visibility/set.js'), + 'business profile cover delete': () => + import('../commands/business/profile/cover/delete.js'), + 'business profile cover set': () => + import('../commands/business/profile/cover/set.js'), + 'business profile update': () => + import('../commands/business/profile/update.js'), + 'business profile view': () => import('../commands/business/profile/view.js'), + 'call reject': () => import('../commands/call/reject.js'), + 'calls accept': () => import('../commands/calls/accept.js'), + 'calls check': () => import('../commands/calls/check.js'), + 'calls end': () => import('../commands/calls/end.js'), + 'calls export': () => import('../commands/calls/export.js'), + 'calls hand': () => import('../commands/calls/hand.js'), + 'calls invite': () => import('../commands/calls/invite.js'), + 'calls leave': () => import('../commands/calls/leave.js'), + 'calls link create': () => import('../commands/calls/link/create.js'), + 'calls link preview': () => import('../commands/calls/link/preview.js'), + 'calls list': () => import('../commands/calls/list.js'), + 'calls media': () => import('../commands/calls/media.js'), + 'calls opt-outs add': () => import('../commands/calls/opt-outs/add.js'), + 'calls opt-outs import': () => import('../commands/calls/opt-outs/import.js'), + 'calls opt-outs list': () => import('../commands/calls/opt-outs/list.js'), + 'calls opt-outs remove': () => import('../commands/calls/opt-outs/remove.js'), + 'calls permission get': () => import('../commands/calls/permission/get.js'), + 'calls permission request': () => + import('../commands/calls/permission/request.js'), + 'calls place-group': () => import('../commands/calls/place-group.js'), + 'calls place': () => import('../commands/calls/place.js'), + 'calls policy get': () => import('../commands/calls/policy/get.js'), + 'calls policy set': () => import('../commands/calls/policy/set.js'), + 'calls react': () => import('../commands/calls/react.js'), + 'calls reject': () => import('../commands/calls/reject.js'), + 'calls retention get': () => import('../commands/calls/retention/get.js'), + 'calls retention set': () => import('../commands/calls/retention/set.js'), + 'calls ring': () => import('../commands/calls/ring.js'), + 'calls settings get': () => import('../commands/calls/settings/get.js'), + 'calls settings set': () => import('../commands/calls/settings/set.js'), + 'calls stats': () => import('../commands/calls/stats.js'), + 'calls view': () => import('../commands/calls/view.js'), + 'calls watch': () => import('../commands/calls/watch.js'), + 'campaign analytics': () => import('../commands/campaign/analytics.js'), + 'campaign conversions record': () => + import('../commands/campaign/conversions/record.js'), + 'campaign conversions report': () => + import('../commands/campaign/conversions/report.js'), + 'campaign create': () => import('../commands/campaign/create.js'), + 'campaign enroll': () => import('../commands/campaign/enroll.js'), + 'campaign launch': () => import('../commands/campaign/launch.js'), + 'campaign list': () => import('../commands/campaign/list.js'), + 'campaign pause': () => import('../commands/campaign/pause.js'), + 'campaign recipients add': () => + import('../commands/campaign/recipients/add.js'), + 'campaign recipients list': () => + import('../commands/campaign/recipients/list.js'), + 'campaign requeue': () => import('../commands/campaign/requeue.js'), + 'campaign resume': () => import('../commands/campaign/resume.js'), + 'campaign segments count': () => + import('../commands/campaign/segments/count.js'), + 'campaign segments create': () => + import('../commands/campaign/segments/create.js'), + 'campaign segments delete': () => + import('../commands/campaign/segments/delete.js'), + 'campaign segments get': () => import('../commands/campaign/segments/get.js'), + 'campaign segments list': () => + import('../commands/campaign/segments/list.js'), + 'campaign segments update': () => + import('../commands/campaign/segments/update.js'), + 'campaign settings frequency-cap get': () => + import('../commands/campaign/settings/frequency-cap/get.js'), + 'campaign settings frequency-cap remove': () => + import('../commands/campaign/settings/frequency-cap/remove.js'), + 'campaign settings frequency-cap set': () => + import('../commands/campaign/settings/frequency-cap/set.js'), + 'campaign stop': () => import('../commands/campaign/stop.js'), + 'campaign update': () => import('../commands/campaign/update.js'), + 'campaign view': () => import('../commands/campaign/view.js'), + 'channel create': () => import('../commands/channel/create.js'), + 'channel delete': () => import('../commands/channel/delete.js'), + 'channel follow': () => import('../commands/channel/follow.js'), + 'channel list': () => import('../commands/channel/list.js'), + 'channel live-updates subscribe': () => + import('../commands/channel/live-updates/subscribe.js'), + 'channel message list': () => import('../commands/channel/message/list.js'), + 'channel message mark-viewed': () => + import('../commands/channel/message/mark-viewed.js'), + 'channel message react': () => import('../commands/channel/message/react.js'), + 'channel message updates': () => + import('../commands/channel/message/updates.js'), + 'channel mute': () => import('../commands/channel/mute.js'), + 'channel unfollow': () => import('../commands/channel/unfollow.js'), + 'channel unmute': () => import('../commands/channel/unmute.js'), + 'channel view': () => import('../commands/channel/view.js'), + 'chat list': () => import('../commands/chat/list.js'), + 'chat message list': () => import('../commands/chat/message/list.js'), + 'chat message media': () => import('../commands/chat/message/media.js'), + 'chat message view': () => import('../commands/chat/message/view.js'), + 'chat view': () => import('../commands/chat/view.js'), + 'cloud catalog list': () => import('../commands/cloud/catalog/list.js'), + 'cloud catalog products': () => + import('../commands/cloud/catalog/products.js'), + 'cloud credentials get': () => import('../commands/cloud/credentials/get.js'), + 'cloud credentials reauthorize': () => + import('../commands/cloud/credentials/reauthorize.js'), + 'cloud group create': () => import('../commands/cloud/group/create.js'), + 'cloud group delete': () => import('../commands/cloud/group/delete.js'), + 'cloud group get': () => import('../commands/cloud/group/get.js'), + 'cloud group invite-link': () => + import('../commands/cloud/group/invite-link.js'), + 'cloud group join-requests approve': () => + import('../commands/cloud/group/join-requests/approve.js'), + 'cloud group join-requests list': () => + import('../commands/cloud/group/join-requests/list.js'), + 'cloud group join-requests reject': () => + import('../commands/cloud/group/join-requests/reject.js'), + 'cloud group list': () => import('../commands/cloud/group/list.js'), + 'cloud group pin': () => import('../commands/cloud/group/pin.js'), + 'cloud group remove-participants': () => + import('../commands/cloud/group/remove-participants.js'), + 'cloud group update': () => import('../commands/cloud/group/update.js'), + 'cloud marketing status': () => + import('../commands/cloud/marketing/status.js'), + 'cloud pricing': () => import('../commands/cloud/pricing.js'), + 'cloud service-window': () => import('../commands/cloud/service-window.js'), + 'cloud template create': () => import('../commands/cloud/template/create.js'), + 'cloud template delete': () => import('../commands/cloud/template/delete.js'), + 'cloud template edit': () => import('../commands/cloud/template/edit.js'), + 'cloud template get': () => import('../commands/cloud/template/get.js'), + 'cloud template list': () => import('../commands/cloud/template/list.js'), + 'completion suggest': () => import('../commands/completion/suggest.js'), + completion: () => import('../commands/completion.js'), + 'contact block': () => import('../commands/contact/block.js'), + 'contact blocklist': () => import('../commands/contact/blocklist.js'), + 'contact business-profile': () => + import('../commands/contact/business-profile.js'), + 'contact check': () => import('../commands/contact/check.js'), + 'contact devices': () => import('../commands/contact/devices.js'), + 'contact info': () => import('../commands/contact/info.js'), + 'contact list': () => import('../commands/contact/list.js'), + 'contact picture': () => import('../commands/contact/picture.js'), + 'contact unblock': () => import('../commands/contact/unblock.js'), + 'contact view': () => import('../commands/contact/view.js'), + 'customer archive': () => import('../commands/customer/archive.js'), + 'customer create': () => import('../commands/customer/create.js'), + 'customer enable': () => import('../commands/customer/enable.js'), + 'customer event list': () => import('../commands/customer/event/list.js'), + 'customer list': () => import('../commands/customer/list.js'), + 'customer number list': () => import('../commands/customer/number/list.js'), + 'customer number transfer': () => + import('../commands/customer/number/transfer.js'), + 'customer pairing-link create': () => + import('../commands/customer/pairing-link/create.js'), + 'customer pairing-link list': () => + import('../commands/customer/pairing-link/list.js'), + 'customer pairing-link revoke': () => + import('../commands/customer/pairing-link/revoke.js'), + 'customer restore': () => import('../commands/customer/restore.js'), + 'customer status': () => import('../commands/customer/status.js'), + 'customer update': () => import('../commands/customer/update.js'), + 'customer view': () => import('../commands/customer/view.js'), + 'docs open': () => import('../commands/docs/open.js'), + 'docs search': () => import('../commands/docs/search.js'), + doctor: () => import('../commands/doctor.js'), + 'event list': () => import('../commands/event/list.js'), + 'event replay': () => import('../commands/event/replay.js'), + 'event stream': () => import('../commands/event/stream.js'), + 'event view': () => import('../commands/event/view.js'), + 'flow create': () => import('../commands/flow/create.js'), + 'flow delete': () => import('../commands/flow/delete.js'), + 'flow deprecate': () => import('../commands/flow/deprecate.js'), + 'flow discard': () => import('../commands/flow/discard.js'), + 'flow endpoint delete': () => import('../commands/flow/endpoint/delete.js'), + 'flow endpoint get': () => import('../commands/flow/endpoint/get.js'), + 'flow endpoint receipts': () => + import('../commands/flow/endpoint/receipts.js'), + 'flow endpoint set': () => import('../commands/flow/endpoint/set.js'), + 'flow get': () => import('../commands/flow/get.js'), + 'flow key custody': () => import('../commands/flow/key/custody.js'), + 'flow key get': () => import('../commands/flow/key/get.js'), + 'flow key register': () => import('../commands/flow/key/register.js'), + 'flow key rotate': () => import('../commands/flow/key/rotate.js'), + 'flow list': () => import('../commands/flow/list.js'), + 'flow publish': () => import('../commands/flow/publish.js'), + 'flow receipts': () => import('../commands/flow/receipts.js'), + 'flow sync': () => import('../commands/flow/sync.js'), + 'flow update': () => import('../commands/flow/update.js'), + 'flow upload': () => import('../commands/flow/upload.js'), + 'function create': () => import('../commands/function/create.js'), + 'function delete': () => import('../commands/function/delete.js'), + 'function deployment create': () => + import('../commands/function/deployment/create.js'), + 'function deployment list': () => + import('../commands/function/deployment/list.js'), + 'function deployment promote': () => + import('../commands/function/deployment/promote.js'), + 'function deployment view': () => + import('../commands/function/deployment/view.js'), + 'function invocation create': () => + import('../commands/function/invocation/create.js'), + 'function invocation list': () => + import('../commands/function/invocation/list.js'), + 'function invocation view': () => + import('../commands/function/invocation/view.js'), + 'function list': () => import('../commands/function/list.js'), + 'function secret create': () => + import('../commands/function/secret/create.js'), + 'function secret list': () => import('../commands/function/secret/list.js'), + 'function secret revoke': () => + import('../commands/function/secret/revoke.js'), + 'function update': () => import('../commands/function/update.js'), + 'function view': () => import('../commands/function/view.js'), + 'group capabilities': () => import('../commands/group/capabilities.js'), + 'group create': () => import('../commands/group/create.js'), + 'group description': () => import('../commands/group/description.js'), + 'group invite-link': () => import('../commands/group/invite-link.js'), + 'group list': () => import('../commands/group/list.js'), + 'group participants add': () => + import('../commands/group/participants/add.js'), + 'group participants demote': () => + import('../commands/group/participants/demote.js'), + 'group participants list': () => + import('../commands/group/participants/list.js'), + 'group participants promote': () => + import('../commands/group/participants/promote.js'), + 'group participants remove': () => + import('../commands/group/participants/remove.js'), + 'group settings': () => import('../commands/group/settings.js'), + 'group subject': () => import('../commands/group/subject.js'), + 'group view': () => import('../commands/group/view.js'), + 'hybrid-link pause': () => import('../commands/hybrid-link/pause.js'), + 'hybrid-link resume': () => import('../commands/hybrid-link/resume.js'), + 'hybrid-link state': () => import('../commands/hybrid-link/state.js'), + 'identity resolve': () => import('../commands/identity/resolve.js'), + init: () => import('../commands/init.js'), + 'key list': () => import('../commands/key/list.js'), + 'key revoke': () => import('../commands/key/revoke.js'), + 'label chat list': () => import('../commands/label/chat/list.js'), + 'label chat replace': () => import('../commands/label/chat/replace.js'), + 'label create': () => import('../commands/label/create.js'), + 'label delete': () => import('../commands/label/delete.js'), + 'label list': () => import('../commands/label/list.js'), + 'label update': () => import('../commands/label/update.js'), + link: () => import('../commands/link.js'), + listen: () => import('../commands/listen.js'), + 'logs list': () => import('../commands/logs/list.js'), + 'logs tail': () => import('../commands/logs/tail.js'), + 'mcp doctor': () => import('../commands/mcp/doctor.js'), + 'mcp install': () => import('../commands/mcp/install.js'), + 'mcp serve': () => import('../commands/mcp/serve.js'), + 'mcp status': () => import('../commands/mcp/status.js'), + 'mcp tools': () => import('../commands/mcp/tools.js'), + 'mcp uninstall': () => import('../commands/mcp/uninstall.js'), + 'media download': () => import('../commands/media/download.js'), + 'media persist': () => import('../commands/media/persist.js'), + 'media view': () => import('../commands/media/view.js'), + 'member list': () => import('../commands/member/list.js'), + 'message delete': () => import('../commands/message/delete.js'), + 'message edit': () => import('../commands/message/edit.js'), + 'message mark-seen': () => import('../commands/message/mark-seen.js'), + 'message operation-status': () => + import('../commands/message/operation-status.js'), + 'message react': () => import('../commands/message/react.js'), + 'message send audio': () => import('../commands/message/send/audio.js'), + 'message send buttons': () => import('../commands/message/send/buttons.js'), + 'message send contact': () => import('../commands/message/send/contact.js'), + 'message send document': () => import('../commands/message/send/document.js'), + 'message send image': () => import('../commands/message/send/image.js'), + 'message send list': () => import('../commands/message/send/list.js'), + 'message send location': () => import('../commands/message/send/location.js'), + 'message send order-status': () => + import('../commands/message/send/order-status.js'), + 'message send order': () => import('../commands/message/send/order.js'), + 'message send poll': () => import('../commands/message/send/poll.js'), + 'message send template': () => import('../commands/message/send/template.js'), + 'message send text': () => import('../commands/message/send/text.js'), + 'message send video': () => import('../commands/message/send/video.js'), + 'message star': () => import('../commands/message/star.js'), + 'message typing': () => import('../commands/message/typing.js'), + 'operation cancel': () => import('../commands/operation/cancel.js'), + 'operation list': () => import('../commands/operation/list.js'), + 'operation view': () => import('../commands/operation/view.js'), + 'operation wait': () => import('../commands/operation/wait.js'), + 'operation watch': () => import('../commands/operation/watch.js'), + 'optout settings update': () => + import('../commands/optout/settings/update.js'), + 'optout settings view': () => import('../commands/optout/settings/view.js'), + 'presence chat subscribe': () => + import('../commands/presence/chat/subscribe.js'), + 'presence chat view': () => import('../commands/presence/chat/view.js'), + 'presence set': () => import('../commands/presence/set.js'), + 'presence view': () => import('../commands/presence/view.js'), + 'privacy disappearing-timer set': () => + import('../commands/privacy/disappearing-timer/set.js'), + 'privacy set': () => import('../commands/privacy/set.js'), + 'privacy view': () => import('../commands/privacy/view.js'), + 'profile name set': () => import('../commands/profile/name/set.js'), + 'profile picture delete': () => + import('../commands/profile/picture/delete.js'), + 'profile picture set': () => import('../commands/profile/picture/set.js'), + 'profile status set': () => import('../commands/profile/status/set.js'), + 'profile view': () => import('../commands/profile/view.js'), + 'project current': () => import('../commands/project/current.js'), + 'project list': () => import('../commands/project/list.js'), + 'project use': () => import('../commands/project/use.js'), + 'project-token list': () => import('../commands/project-token/list.js'), + 'quick-reply create': () => import('../commands/quick-reply/create.js'), + 'quick-reply delete': () => import('../commands/quick-reply/delete.js'), + 'quick-reply list': () => import('../commands/quick-reply/list.js'), + 'quick-reply replace': () => import('../commands/quick-reply/replace.js'), + 'quicklink availability': () => + import('../commands/quicklink/availability.js'), + 'quicklink cancel': () => import('../commands/quicklink/cancel.js'), + 'quicklink create': () => import('../commands/quicklink/create.js'), + 'quicklink view': () => import('../commands/quicklink/view.js'), + 'routing-policy get': () => import('../commands/routing-policy/get.js'), + 'routing-policy set': () => import('../commands/routing-policy/set.js'), + 'security-incident acknowledge': () => + import('../commands/security-incident/acknowledge.js'), + 'security-incident list': () => + import('../commands/security-incident/list.js'), + 'session capabilities': () => import('../commands/session/capabilities.js'), + 'session configuration get': () => + import('../commands/session/configuration/get.js'), + 'session configuration update': () => + import('../commands/session/configuration/update.js'), + 'session delete': () => import('../commands/session/delete.js'), + 'session hybrid-merge-candidates': () => + import('../commands/session/hybrid-merge-candidates.js'), + 'session list': () => import('../commands/session/list.js'), + 'session status': () => import('../commands/session/status.js'), + 'session stop': () => import('../commands/session/stop.js'), + 'session tier confirm': () => import('../commands/session/tier/confirm.js'), + 'session tier quote': () => import('../commands/session/tier/quote.js'), + 'session tier status': () => import('../commands/session/tier/status.js'), + 'session view': () => import('../commands/session/view.js'), + 'session-ban list': () => import('../commands/session-ban/list.js'), + 'sip-trunk create': () => import('../commands/sip-trunk/create.js'), + 'sip-trunk delete': () => import('../commands/sip-trunk/delete.js'), + 'sip-trunk endpoint': () => import('../commands/sip-trunk/endpoint.js'), + 'sip-trunk list': () => import('../commands/sip-trunk/list.js'), + 'sip-trunk rotate-credentials': () => + import('../commands/sip-trunk/rotate-credentials.js'), + 'sip-trunk update': () => import('../commands/sip-trunk/update.js'), + 'sip-trunk view': () => import('../commands/sip-trunk/view.js'), + 'template submit': () => import('../commands/template/submit.js'), + 'testing history-fixture create': () => + import('../commands/testing/history-fixture/create.js'), + trigger: () => import('../commands/trigger.js'), + 'usage gates': () => import('../commands/usage/gates.js'), + 'usage records': () => import('../commands/usage/records.js'), + 'usage summary': () => import('../commands/usage/summary.js'), + 'user security-code': () => import('../commands/user/security-code.js'), + version: () => import('../commands/version.js'), + 'voice audio delete': () => import('../commands/voice/audio/delete.js'), + 'voice audio list': () => import('../commands/voice/audio/list.js'), + 'voice audio preview': () => import('../commands/voice/audio/preview.js'), + 'voice audio tts': () => import('../commands/voice/audio/tts.js'), + 'voice audio update': () => import('../commands/voice/audio/update.js'), + 'voice audio upload': () => import('../commands/voice/audio/upload.js'), + 'voice audio view': () => import('../commands/voice/audio/view.js'), + 'voice credential add': () => import('../commands/voice/credential/add.js'), + 'voice credential delete': () => + import('../commands/voice/credential/delete.js'), + 'voice credential list': () => import('../commands/voice/credential/list.js'), + 'voice credential verify': () => + import('../commands/voice/credential/verify.js'), + 'voice credential view': () => import('../commands/voice/credential/view.js'), + 'webhook create': () => import('../commands/webhook/create.js'), + 'webhook delete': () => import('../commands/webhook/delete.js'), + 'webhook delivery attempt list': () => + import('../commands/webhook/delivery/attempt/list.js'), + 'webhook delivery attempt view': () => + import('../commands/webhook/delivery/attempt/view.js'), + 'webhook delivery list': () => import('../commands/webhook/delivery/list.js'), + 'webhook delivery retry': () => + import('../commands/webhook/delivery/retry.js'), + 'webhook delivery view': () => import('../commands/webhook/delivery/view.js'), + 'webhook list': () => import('../commands/webhook/list.js'), + 'webhook secret rotate': () => import('../commands/webhook/secret/rotate.js'), + 'webhook test': () => import('../commands/webhook/test.js'), + 'webhook update': () => import('../commands/webhook/update.js'), + 'webhook view': () => import('../commands/webhook/view.js'), + 'widget update': () => import('../commands/widget/update.js'), + 'widget view': () => import('../commands/widget/view.js'), } diff --git a/test/browser/run.test.ts b/test/browser/run.test.ts index 8371f888..4f0f5c6b 100644 --- a/test/browser/run.test.ts +++ b/test/browser/run.test.ts @@ -10,13 +10,7 @@ import { splitWords, } from '../../src/browser/index.js' import {Buffer as BrowserBuffer} from '../../src/browser/stubs/buffer.js' -import { - join, - dirname, - basename, - extname, - resolve, -} from '../../src/browser/stubs/path.js' +import * as browserPath from '../../src/browser/stubs/path.js' // A client token fixture: the SDK checks only the prefix of client tokens. const CLIENT_TOKEN = `pmfa_ct_${'fixture'.repeat(6)}` @@ -131,6 +125,31 @@ describe('browser command runner', () => { expect(unknown.stderr).toBe('Unknown command: polymorfa nope nope\n') }) + it('runs commands started together one after another', async () => { + const order: string[] = [] + const respond = (url: URL) => { + order.push(url.pathname) + return json({success: true, data: {id: '3EB0FIXTURE', status: 'sent'}}) + } + const [first, second] = await Promise.all([ + run( + 'message send text --session 739182640518203 --to +15550002222 --body one', + respond, + ), + run( + 'message send text --session 739182640518204 --to +15550002222 --body two', + respond, + ), + ]) + expect([first.exitCode, second.exitCode]).toEqual([0, 0]) + expect(first.stdout).toBe('Sent message 3EB0FIXTURE to +15550002222.\n') + expect(second.stdout).toBe('Sent message 3EB0FIXTURE to +15550002222.\n') + expect(order).toEqual([ + '/messaging/739182640518203/messages/send', + '/messaging/739182640518204/messages/send', + ]) + }) + it('restores the process and fetch after each command', async () => { const before = { fetch: globalThis.fetch, @@ -186,12 +205,12 @@ describe('browser stand-ins', () => { }) it('joins and names POSIX paths', () => { - expect(join('/a', 'b', '../c', 'd.txt')).toBe('/a/c/d.txt') + expect(browserPath.join('/a', 'b', '../c', 'd.txt')).toBe('/a/c/d.txt') expect([ - dirname('/a/b/c.json'), - basename('/a/b/c.json'), - extname('/a/b/c.json'), + browserPath.dirname('/a/b/c.json'), + browserPath.basename('/a/b/c.json'), + browserPath.extname('/a/b/c.json'), ]).toEqual(['/a/b', 'c.json', '.json']) - expect(resolve('/a', 'b')).toBe('/a/b') + expect(browserPath.resolve('/a', 'b')).toBe('/a/b') }) }) From 84ed9ba99bb215506df336fb82960224c4f77dbe Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 06:20:45 +0200 Subject: [PATCH 3/4] fix: browser runner review findings and the zod advisory - Write callbacks run, as Node's do once a chunk is flushed, so commands that wait for them (listen) continue. UTF-8 byte chunks are decoded; other bytes stop the command with the installed-CLI message, and calls media, whose output is binary PCM, is refused before it runs. - BrowserIo takes an optional signal. Aborting calls the SIGINT listeners the running command registered, its existing stop path; the shared process and fetch are restored only after the command returns, and listeners it left behind are removed. - The browser process and its streams are emitters with removeListener; a once() listener can be removed by its function. - Inside double quotes, a backslash escapes only $, `, ", \ and newline. - Commands resolve by own property, so "constructor" is unknown. - esbuild is a declared dev dependency. - zod 4.6.7 fixes GHSA-mrhx-c7xc-m3w5, which failed the production audit. --- package-lock.json | 10 ++- package.json | 3 +- src/browser/index.ts | 138 +++++++++++++++++++++++++++++------ src/browser/stubs/events.ts | 16 +++- src/browser/stubs/process.ts | 48 ++++++------ test/browser/run.test.ts | 104 +++++++++++++++++++++++++- 6 files changed, 260 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4c1a04c..cb275710 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@oclif/core": "^4.13.5", "@polymorfa/sdk": "0.1.0-dev.20261004024706", "jq-wasm": "3.0.0-jq-1.8.2", - "zod": "4.2.0" + "zod": "4.6.7" }, "bin": { "polymorfa": "bin/run.js" @@ -23,6 +23,7 @@ "@eslint/js": "^9.39.5", "@oclif/test": "^4.1.22", "@types/node": "^24.0.0", + "esbuild": "^0.28.2", "eslint": "^9.39.5", "prettier": "^3.9.6", "tsx": "^4.20.6", @@ -3913,9 +3914,10 @@ } }, "node_modules/zod": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.0.tgz", - "integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 9fb53a7e..d34de545 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,13 @@ "@oclif/core": "^4.13.5", "@polymorfa/sdk": "0.1.0-dev.20261004024706", "jq-wasm": "3.0.0-jq-1.8.2", - "zod": "4.2.0" + "zod": "4.6.7" }, "devDependencies": { "@eslint/js": "^9.39.5", "@oclif/test": "^4.1.22", "@types/node": "^24.0.0", + "esbuild": "^0.28.2", "eslint": "^9.39.5", "prettier": "^3.9.6", "tsx": "^4.20.6", diff --git a/src/browser/index.ts b/src/browser/index.ts index 843d9cb5..15ad5101 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -6,6 +6,7 @@ * message naming the installed CLI. */ import {COMMAND_LOADERS} from './registry.gen.js' +import {BrowserUnsupportedError} from './stubs/unsupported.js' declare const __CLI_VERSION__: string | undefined declare const __CLI_COMMIT__: string | undefined @@ -26,7 +27,20 @@ export interface BrowserIo { * the command started, never `globalThis.fetch`. */ readonly fetch: typeof fetch + /** Receives the command's output. Byte chunks arrive decoded as UTF-8. */ readonly write: (stream: 'stdout' | 'stderr', text: string) => void + /** + * Stops a running command the way Ctrl-C does: aborting it calls the + * `SIGINT` listeners the command registered, which is how following + * commands such as `logs tail` and `event stream` stop. The next command + * starts only after this one has returned. + */ + readonly signal?: AbortSignal +} + +/** Commands whose output the browser cannot show, with the reason. */ +const UNSUPPORTED: Readonly> = { + 'calls media': 'streams call audio as binary PCM', } export interface RunResult { @@ -41,11 +55,16 @@ export function resolveCommand( ): {id: string; rest: string[]} | null { for (let length = Math.min(words.length, 4); length > 0; length--) { const id = words.slice(0, length).join(' ') - if (id in COMMAND_LOADERS) return {id, rest: words.slice(length)} + if (Object.hasOwn(COMMAND_LOADERS, id)) + return {id, rest: words.slice(length)} } return null } +// Inside double quotes a POSIX shell treats a backslash as an escape only +// before these characters; elsewhere it is literal, so `"C:\new"` keeps it. +const DOUBLE_QUOTE_ESCAPES = new Set(['$', '`', '"', '\\', '\n']) + /** Split a command line into words the way a POSIX shell quotes them. */ export function splitWords(line: string): string[] { const words: string[] = [] @@ -56,7 +75,11 @@ export function splitWords(line: string): string[] { const char = line[index]! if (quote) { if (char === quote) quote = null - else if (char === '\\' && quote === '"' && index + 1 < line.length) + else if ( + char === '\\' && + quote === '"' && + DOUBLE_QUOTE_ESCAPES.has(line[index + 1] ?? '') + ) current += line[++index] else current += char } else if (char === '"' || char === "'") { @@ -112,11 +135,64 @@ const config = { commandIDs: COMMAND_IDS, } -interface ProcessLike { +type Listener = (...args: unknown[]) => void +type WriteCallback = (error?: Error | null) => void + +interface EmitterLike { + listeners: (event: string) => Listener[] + removeListener: (event: string, listener: Listener) => unknown +} + +interface StreamLike extends EmitterLike { + write: (chunk: unknown, ...rest: unknown[]) => boolean +} + +interface ProcessLike extends EmitterLike { env: Record exitCode?: number | string | undefined - stdout: {write: (chunk: unknown) => boolean} - stderr: {write: (chunk: unknown) => boolean} + stdout: StreamLike + stderr: StreamLike +} + +const utf8 = new TextDecoder('utf-8', {fatal: true}) + +/** A chunk as text. Bytes must be UTF-8; anything else needs a real terminal. */ +function textOf(chunk: unknown): string { + if (typeof chunk === 'string') return chunk + if (chunk instanceof Uint8Array) { + try { + return utf8.decode(chunk) + } catch { + throw new BrowserUnsupportedError('writes binary data to its output') + } + } + return String(chunk) +} + +/** + * A stream `write` that hands text to the page and calls the write callback, + * as Node does once a chunk is flushed. Commands such as `listen` wait for it. + */ +function writer(io: BrowserIo, stream: 'stdout' | 'stderr') { + return (chunk: unknown, ...rest: unknown[]): boolean => { + io.write(stream, textOf(chunk)) + const done = rest.find( + (value): value is WriteCallback => typeof value === 'function', + ) + if (done) queueMicrotask(() => done(null)) + return true + } +} + +/** Listeners a command may leave on the shared process and its streams. */ +function trackedListeners(proc: ProcessLike): [EmitterLike, string][] { + return [ + [proc, 'SIGINT'], + [proc, 'SIGTERM'], + [proc.stdout, 'error'], + [proc.stdout, 'drain'], + [proc.stderr, 'error'], + ] } let queue: Promise = Promise.resolve() @@ -138,8 +214,22 @@ async function runNow( words: readonly string[], io: BrowserIo, ): Promise { + const resolved = resolveCommand(words) + if (!resolved) { + io.write('stderr', `Unknown command: polymorfa ${words.join(' ')}\n`) + return {exitCode: 2, command: null} + } + const unsupported = UNSUPPORTED[resolved.id] + if (unsupported) { + io.write('stderr', `${new BrowserUnsupportedError(unsupported).message}\n`) + return {exitCode: 1, command: resolved.id} + } + // Interrupted before it started, as a terminal reports Ctrl-C. + if (io.signal?.aborted) return {exitCode: 130, command: resolved.id} // The bare `process`: in the browser build it is the injected stand-in, under Node the real one. const proc = process as unknown as ProcessLike + const tracked = trackedListeners(proc) + const before = tracked.map(([emitter, event]) => emitter.listeners(event)) const saved = { env: proc.env, exitCode: proc.exitCode, @@ -147,23 +237,23 @@ async function runNow( err: proc.stderr.write, fetch: globalThis.fetch, } - proc.env = {...io.env} - proc.exitCode = undefined - proc.stdout.write = (chunk: unknown) => { - io.write('stdout', String(chunk)) - return true + // Only the listeners this command added: a host process's own stay untouched. + const added = (index: number): Listener[] => { + const [emitter, event] = tracked[index]! + return emitter + .listeners(event) + .filter((listener) => !before[index]!.includes(listener)) } - proc.stderr.write = (chunk: unknown) => { - io.write('stderr', String(chunk)) - return true + const interrupt = () => { + for (const listener of added(0)) listener('SIGINT') } + proc.env = {...io.env} + proc.exitCode = undefined + proc.stdout.write = writer(io, 'stdout') + proc.stderr.write = writer(io, 'stderr') globalThis.fetch = io.fetch - const resolved = resolveCommand(words) + io.signal?.addEventListener('abort', interrupt) try { - if (!resolved) { - io.write('stderr', `Unknown command: polymorfa ${words.join(' ')}\n`) - return {exitCode: 2, command: null} - } const module = await COMMAND_LOADERS[resolved.id]!() // Each module's default export is a concrete oclif command class. const CommandClass = module.default as { @@ -172,13 +262,14 @@ async function runNow( } CommandClass.id = resolved.id const command = new CommandClass(resolved.rest, config) + // The shared state is restored only after the command returns, even when + // interrupted, so it never runs against the next command's globals. await command._run() const code = Number(proc.exitCode ?? 0) return {exitCode: Number.isFinite(code) ? code : 1, command: resolved.id} } catch (error) { const exit = (error as {oclif?: {exit?: unknown}}).oclif?.exit - if (typeof exit === 'number') - return {exitCode: exit, command: resolved?.id ?? null} + if (typeof exit === 'number') return {exitCode: exit, command: resolved.id} const debug = io.env.POLYMORFA_BROWSER_DEBUG === '1' && error instanceof Error ? `\n${error.stack ?? ''}` @@ -187,8 +278,13 @@ async function runNow( 'stderr', `${error instanceof Error ? error.message : String(error)}${debug}\n`, ) - return {exitCode: 1, command: resolved?.id ?? null} + return {exitCode: 1, command: resolved.id} } finally { + io.signal?.removeEventListener('abort', interrupt) + tracked.forEach(([emitter, event], index) => { + for (const listener of added(index)) + emitter.removeListener(event, listener) + }) proc.env = saved.env proc.exitCode = saved.exitCode proc.stdout.write = saved.out diff --git a/src/browser/stubs/events.ts b/src/browser/stubs/events.ts index e7e1b388..18c48e8d 100644 --- a/src/browser/stubs/events.ts +++ b/src/browser/stubs/events.ts @@ -1,4 +1,6 @@ type Listener = (...args: unknown[]) => void +// A `once` wrapper remembers its listener, so removing the listener removes it. +type Wrapped = Listener & {listener?: Listener} export class EventEmitter { #listeners = new Map() on(name: string | symbol, listener: Listener): this { @@ -9,16 +11,19 @@ export class EventEmitter { return this.on(name, listener) } once(name: string | symbol, listener: Listener): this { - const wrapped: Listener = (...args) => { + const wrapped: Wrapped = (...args) => { this.off(name, wrapped) listener(...args) } + wrapped.listener = listener return this.on(name, wrapped) } off(name: string | symbol, listener: Listener): this { this.#listeners.set( name, - (this.#listeners.get(name) ?? []).filter((entry) => entry !== listener), + (this.#listeners.get(name) ?? []).filter( + (entry: Wrapped) => entry !== listener && entry.listener !== listener, + ), ) return this } @@ -31,12 +36,15 @@ export class EventEmitter { return this } emit(name: string | symbol, ...args: unknown[]): boolean { - const list = this.#listeners.get(name) ?? [] + const list = [...(this.#listeners.get(name) ?? [])] for (const listener of list) listener(...args) return list.length > 0 } + /** The listeners as registered, like Node: `once` listeners unwrapped. */ listeners(name: string | symbol): Listener[] { - return [...(this.#listeners.get(name) ?? [])] + return (this.#listeners.get(name) ?? []).map( + (entry: Wrapped) => entry.listener ?? entry, + ) } listenerCount(name: string | symbol): number { return this.#listeners.get(name)?.length ?? 0 diff --git a/src/browser/stubs/process.ts b/src/browser/stubs/process.ts index 5b54c633..2974e4c0 100644 --- a/src/browser/stubs/process.ts +++ b/src/browser/stubs/process.ts @@ -1,25 +1,25 @@ // Injected as the global `process` in the browser build. The runtime swaps env and the writers per command. +// Process and its streams are emitters, so commands can add and remove listeners such as SIGINT. +import {EventEmitter} from './events.js' + const noop = (): void => {} -const stream = (): { - write: (chunk: unknown) => boolean - isTTY: boolean - columns: number - on: typeof noop - once: typeof noop - off: typeof noop - end: typeof noop -} => ({ - write: () => true, - isTTY: false, - columns: 100, - on: noop, - once: noop, - off: noop, - end: noop, -}) -export const process = { - env: {} as Record, +/** Standard output or error: an emitter whose writes succeed and call back, as Node's do once flushed. */ +const stream = () => + Object.assign(new EventEmitter(), { + write: (_chunk: unknown, ...rest: unknown[]): boolean => { + const done = rest.find((value) => typeof value === 'function') as + ((error?: Error | null) => void) | undefined + if (done) queueMicrotask(() => done(null)) + return true + }, + isTTY: false, + columns: 100, + end: noop, + }) + +export const process = Object.assign(new EventEmitter(), { + env: {}, argv: ['node', 'polymorfa'], execArgv: [] as string[], execPath: '/usr/bin/node', @@ -28,7 +28,7 @@ export const process = { arch: 'wasm', pid: 1, version: 'v22.0.0', - versions: {node: '22.0.0'} as Record, + versions: {node: '22.0.0'}, release: {name: 'browser'}, exitCode: undefined as number | undefined, stdout: stream(), @@ -44,12 +44,6 @@ export const process = { }, cwd: (): string => '/', chdir: noop, - on: noop, - once: noop, - off: noop, - removeListener: noop, - listeners: (): unknown[] => [], - emit: (): boolean => false, emitWarning: noop, nextTick: (fn: (...args: unknown[]) => void, ...args: unknown[]): void => queueMicrotask(() => fn(...args)), @@ -72,4 +66,4 @@ export const process = { }, umask: (): number => 0o22, getuid: (): number => 1000, -} +}) diff --git a/test/browser/run.test.ts b/test/browser/run.test.ts index 4f0f5c6b..b660d614 100644 --- a/test/browser/run.test.ts +++ b/test/browser/run.test.ts @@ -10,12 +10,20 @@ import { splitWords, } from '../../src/browser/index.js' import {Buffer as BrowserBuffer} from '../../src/browser/stubs/buffer.js' +import {process as browserProcess} from '../../src/browser/stubs/process.js' import * as browserPath from '../../src/browser/stubs/path.js' // A client token fixture: the SDK checks only the prefix of client tokens. const CLIENT_TOKEN = `pmfa_ct_${'fixture'.repeat(6)}` +// A well-formed team key fixture for Platform commands. Not a real key. +const API_KEY = `pmfa_${'A'.repeat(72)}` +const PROJECT = '11111111-1111-4111-8111-111111111111' -function run(line: string, respond: (url: URL, init: RequestInit) => Response) { +function run( + line: string, + respond: (url: URL, init: RequestInit) => Response, + options: {env?: Record; signal?: AbortSignal} = {}, +) { const output: {stream: 'stdout' | 'stderr'; text: string}[] = [] const requests: {method: string; url: URL; authorization: string | null}[] = [] @@ -32,9 +40,10 @@ function run(line: string, respond: (url: URL, init: RequestInit) => Response) { return Promise.resolve(respond(url, init)) } return runCommand(splitWords(line), { - env: {POLYMORFA_CLIENT_TOKEN: CLIENT_TOKEN}, + env: options.env ?? {POLYMORFA_CLIENT_TOKEN: CLIENT_TOKEN}, fetch: fetchFixture, write: (stream, text) => output.push({stream, text}), + ...(options.signal ? {signal: options.signal} : {}), }).then((result) => ({ ...result, output, @@ -76,6 +85,22 @@ describe('browser command runner', () => { expect(() => splitWords('api "open')).toThrow(SyntaxError) }) + it('keeps backslashes inside double quotes unless they escape a special character', () => { + expect( + splitWords(String.raw`message send text --body "C:\new\path"`), + ).toEqual(['message', 'send', 'text', '--body', String.raw`C:\new\path`]) + expect(splitWords(String.raw`"a\"b \\ \$x \q"`)).toEqual([ + String.raw`a"b \ $x \q`, + ]) + }) + + it('resolves only real commands, not inherited object keys', async () => { + expect(resolveCommand(['constructor'])).toBeNull() + const result = await run('constructor', () => json({})) + expect(result).toMatchObject({exitCode: 2, command: null}) + expect(result.stderr).toBe('Unknown command: polymorfa constructor\n') + }) + it('resolves the longest command and keeps the rest as arguments', () => { expect(COMMAND_IDS).toContain('message send text') expect(resolveCommand(['message', 'send', 'text', '--to', 'x'])).toEqual({ @@ -150,6 +175,64 @@ describe('browser command runner', () => { ]) }) + it('calls write callbacks and decodes UTF-8 bytes while a command runs', async () => { + let flushed = false + const result = await run( + 'message send text --session 739182640518203 --to +15550002222 --body hi', + () => { + process.stdout.write(new TextEncoder().encode('héllo\n'), () => { + flushed = true + }) + return json({success: true, data: {id: '3EB0FIXTURE', status: 'sent'}}) + }, + ) + expect(flushed).toBe(true) + expect(result.stdout).toBe( + 'héllo\nSent message 3EB0FIXTURE to +15550002222.\n', + ) + }) + + it('refuses a command whose output is binary audio before it runs', async () => { + const result = await run('calls media --session 739182640518203', () => + json({}), + ) + expect(result).toMatchObject({exitCode: 1, command: 'calls media'}) + expect(result.stderr).toContain('streams call audio as binary PCM') + expect(result.requests).toHaveLength(0) + }) + + it('stops a following command on abort, cleans up, then runs the next', async () => { + const before = process.listeners('SIGINT').length + const controller = new AbortController() + const tail = run( + `logs tail --project ${PROJECT} --backfill 0 --poll-interval 1 --json`, + () => { + setTimeout(() => controller.abort(), 0) + return json({ + data: [], + page: {nextCursor: null, hasMore: false, followCursor: 'c1'}, + }) + }, + {env: {POLYMORFA_API_KEY: API_KEY}, signal: controller.signal}, + ) + const next = run('nope', () => json({})) + const result = await tail + expect(result).toMatchObject({exitCode: 0, command: 'logs tail'}) + expect(result.requests.length).toBeGreaterThan(0) + expect((await next).exitCode).toBe(2) + expect(process.listeners('SIGINT')).toHaveLength(before) + }) + + it('reports a command interrupted before it starts', async () => { + const controller = new AbortController() + controller.abort() + const result = await run('version', () => json({}), { + signal: controller.signal, + }) + expect(result).toMatchObject({exitCode: 130, command: 'version'}) + expect(result.stdout).toBe('') + }) + it('restores the process and fetch after each command', async () => { const before = { fetch: globalThis.fetch, @@ -183,6 +266,23 @@ describe('browser command registry', () => { }) describe('browser stand-ins', () => { + it('lets commands add and remove process and stream listeners', () => { + const stop = () => undefined + browserProcess.once('SIGINT', stop) + expect(browserProcess.listeners('SIGINT')).toEqual([stop]) + browserProcess.removeListener('SIGINT', stop) + expect(browserProcess.listeners('SIGINT')).toEqual([]) + const closed = () => undefined + browserProcess.stdout.on('error', closed) + browserProcess.stdout.removeListener('error', closed) + expect(browserProcess.stdout.listeners('error')).toEqual([]) + let flushed = false + browserProcess.stdout.write('x', () => { + flushed = true + }) + return Promise.resolve().then(() => expect(flushed).toBe(true)) + }) + it('encodes and decodes like Node for the methods the CLI uses', () => { const value = 'héllo, wörld' expect(BrowserBuffer.from(value).toString('base64')).toBe( From fde26bbee054101b20f71f9525d75acb4719a5c0 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 06:31:26 +0200 Subject: [PATCH 4/4] fix: browser runner handles late aborts; emitter matches Node - A command interrupted while its code loads never starts (exit 130). An abort before the command registers its SIGINT listener reaches it when it is added, through newListener, which Node's process emits and the browser emitter now does too. - removeListener removes only the most recent registration of a listener, and an unhandled error event is thrown, as in Node. --- src/browser/index.ts | 14 ++++++++ src/browser/stubs/events.ts | 24 ++++++++++---- test/browser/run.test.ts | 66 +++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/src/browser/index.ts b/src/browser/index.ts index 15ad5101..a6080af5 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -140,6 +140,7 @@ type WriteCallback = (error?: Error | null) => void interface EmitterLike { listeners: (event: string) => Listener[] + on: (event: string, listener: Listener) => unknown removeListener: (event: string, listener: Listener) => unknown } @@ -244,17 +245,29 @@ async function runNow( .listeners(event) .filter((listener) => !before[index]!.includes(listener)) } + // An abort before the command registers its SIGINT listener still reaches + // it: the listener is called as soon as the command adds it. + let interrupted = false const interrupt = () => { + interrupted = true for (const listener of added(0)) listener('SIGINT') } + const lateListener = (event: unknown, listener: unknown) => { + if (interrupted && event === 'SIGINT' && typeof listener === 'function') + queueMicrotask(() => (listener as Listener)('SIGINT')) + } proc.env = {...io.env} proc.exitCode = undefined proc.stdout.write = writer(io, 'stdout') proc.stderr.write = writer(io, 'stderr') globalThis.fetch = io.fetch io.signal?.addEventListener('abort', interrupt) + proc.on('newListener', lateListener) try { const module = await COMMAND_LOADERS[resolved.id]!() + // Interrupted while its code loaded: it never starts. + if (interrupted || io.signal?.aborted) + return {exitCode: 130, command: resolved.id} // Each module's default export is a concrete oclif command class. const CommandClass = module.default as { id?: string @@ -281,6 +294,7 @@ async function runNow( return {exitCode: 1, command: resolved.id} } finally { io.signal?.removeEventListener('abort', interrupt) + proc.removeListener('newListener', lateListener) tracked.forEach(([emitter, event], index) => { for (const listener of added(index)) emitter.removeListener(event, listener) diff --git a/src/browser/stubs/events.ts b/src/browser/stubs/events.ts index 18c48e8d..ce0b8346 100644 --- a/src/browser/stubs/events.ts +++ b/src/browser/stubs/events.ts @@ -4,6 +4,9 @@ type Wrapped = Listener & {listener?: Listener} export class EventEmitter { #listeners = new Map() on(name: string | symbol, listener: Listener): this { + // Like Node: announced before it is added, with a `once` listener unwrapped. + if (this.listenerCount('newListener') > 0) + this.emit('newListener', name, (listener as Wrapped).listener ?? listener) this.#listeners.set(name, [...(this.#listeners.get(name) ?? []), listener]) return this } @@ -18,13 +21,16 @@ export class EventEmitter { wrapped.listener = listener return this.on(name, wrapped) } + /** Removes the most recently added registration of `listener` only, like Node. */ off(name: string | symbol, listener: Listener): this { - this.#listeners.set( - name, - (this.#listeners.get(name) ?? []).filter( - (entry: Wrapped) => entry !== listener && entry.listener !== listener, - ), - ) + const list: Wrapped[] = [...(this.#listeners.get(name) ?? [])] + for (let index = list.length - 1; index >= 0; index--) { + if (list[index] === listener || list[index]!.listener === listener) { + list.splice(index, 1) + break + } + } + this.#listeners.set(name, list) return this } removeListener(name: string | symbol, listener: Listener): this { @@ -37,6 +43,12 @@ export class EventEmitter { } emit(name: string | symbol, ...args: unknown[]): boolean { const list = [...(this.#listeners.get(name) ?? [])] + // An `error` nobody handles is thrown, as Node does, never dropped. + if (name === 'error' && list.length === 0) { + throw args[0] instanceof Error + ? args[0] + : new Error(`Unhandled error. (${String(args[0])})`) + } for (const listener of list) listener(...args) return list.length > 0 } diff --git a/test/browser/run.test.ts b/test/browser/run.test.ts index b660d614..d81b078a 100644 --- a/test/browser/run.test.ts +++ b/test/browser/run.test.ts @@ -10,6 +10,7 @@ import { splitWords, } from '../../src/browser/index.js' import {Buffer as BrowserBuffer} from '../../src/browser/stubs/buffer.js' +import {EventEmitter as BrowserEmitter} from '../../src/browser/stubs/events.js' import {process as browserProcess} from '../../src/browser/stubs/process.js' import * as browserPath from '../../src/browser/stubs/path.js' @@ -19,6 +20,31 @@ const CLIENT_TOKEN = `pmfa_ct_${'fixture'.repeat(6)}` const API_KEY = `pmfa_${'A'.repeat(72)}` const PROJECT = '11111111-1111-4111-8111-111111111111' +/** + * A signal that reports or fires an abort on its second `aborted` read: the + * runner reads it once before loading the command and once after. + */ +function abortOnSecondRead(mode: 'report' | 'fire'): AbortSignal { + let reads = 0 + let onAbort: (() => void) | undefined + return { + get aborted() { + reads += 1 + if (reads !== 2) return false + if (mode === 'report') return true + onAbort?.() + return false + }, + addEventListener: (_event: string, listener: () => void) => { + onAbort = listener + }, + removeEventListener: () => undefined, + } as unknown as AbortSignal +} + +const pollEmpty = () => + json({data: [], page: {nextCursor: null, hasMore: false, followCursor: 'c1'}}) + function run( line: string, respond: (url: URL, init: RequestInit) => Response, @@ -223,6 +249,30 @@ describe('browser command runner', () => { expect(process.listeners('SIGINT')).toHaveLength(before) }) + it('does not start a command interrupted while its code loads', async () => { + const result = await run( + `logs tail --project ${PROJECT} --poll-interval 1`, + pollEmpty, + { + env: {POLYMORFA_API_KEY: API_KEY}, + signal: abortOnSecondRead('report'), + }, + ) + expect(result).toMatchObject({exitCode: 130, command: 'logs tail'}) + expect(result.requests).toHaveLength(0) + }) + + it('stops a command interrupted before it registers its Ctrl-C handler', async () => { + const before = process.listeners('SIGINT').length + const result = await run( + `logs tail --project ${PROJECT} --backfill 0 --poll-interval 1 --json`, + pollEmpty, + {env: {POLYMORFA_API_KEY: API_KEY}, signal: abortOnSecondRead('fire')}, + ) + expect(result).toMatchObject({exitCode: 0, command: 'logs tail'}) + expect(process.listeners('SIGINT')).toHaveLength(before) + }) + it('reports a command interrupted before it starts', async () => { const controller = new AbortController() controller.abort() @@ -266,6 +316,22 @@ describe('browser command registry', () => { }) describe('browser stand-ins', () => { + it('removes one registration at a time and throws unhandled errors, like Node', () => { + const emitter = new BrowserEmitter() + const calls: string[] = [] + const listener = () => calls.push('called') + emitter.on('tick', listener) + emitter.on('tick', listener) + emitter.removeListener('tick', listener) + emitter.emit('tick') + expect(calls).toEqual(['called']) + expect(() => emitter.emit('error', new Error('boom'))).toThrow('boom') + const announced: unknown[] = [] + emitter.on('newListener', (event: unknown) => announced.push(event)) + emitter.once('SIGINT', listener) + expect(announced).toEqual(['SIGINT']) + }) + it('lets commands add and remove process and stream listeners', () => { const stop = () => undefined browserProcess.once('SIGINT', stop)