From 86dc3de01216f85db4ba594de68df61e0de5eec9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 04:45:44 +0200 Subject: [PATCH 1/7] 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 7573c888..51a02a56 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 4913847bf440f502b79a433f54960574e3e0833d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 05:09:54 +0200 Subject: [PATCH 2/7] 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 103b3cf76f04db71bd5eb225eb19f48f778885eb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 06:20:45 +0200 Subject: [PATCH 3/7] 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 | 25 ++++--- package.json | 65 +++++++++-------- 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, 298 insertions(+), 98 deletions(-) diff --git a/package-lock.json b/package-lock.json index b368c232..fd46807f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,28 +1,24 @@ { - "name": "@polymorfa/cli", - "version": "0.1.0-dev.0", "lockfileVersion": 3, - "requires": true, + "name": "@polymorfa/cli", "packages": { "": { - "name": "@polymorfa/cli", - "version": "0.1.0-dev.0", - "license": "MIT", + "bin": { + "polymorfa": "bin/run.js" + }, "dependencies": { "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/sdk": "0.1.0-dev.20261004024706", "jq-wasm": "3.0.0-jq-1.8.2", - "zod": "^4.6.6" - }, - "bin": { - "polymorfa": "bin/run.js" + "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", @@ -32,7 +28,10 @@ }, "engines": { "node": ">=22" - } + }, + "license": "MIT", + "name": "@polymorfa/cli", + "version": "0.1.0-dev.0" }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", @@ -3921,5 +3920,7 @@ "url": "https://github.com/sponsors/colinhacks" } } - } + }, + "requires": true, + "version": "0.1.0-dev.0" } diff --git a/package.json b/package.json index 51a02a56..7987cb7e 100644 --- a/package.json +++ b/package.json @@ -1,49 +1,21 @@ { - "name": "@polymorfa/cli", - "version": "0.1.0-dev.0", - "description": "The official command-line interface for Polymorfa", - "type": "module", - "license": "MIT", - "engines": { - "node": ">=22" - }, "bin": { "polymorfa": "./bin/run.js" }, - "files": [ - "bin/run.js", - "dist", - "skills", - "README.md", - "LICENSE" - ], - "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 .", - "lint": "eslint .", - "prepare": "npm run build", - "test": "vitest run", - "typecheck": "tsc -p tsconfig.json --noEmit", - "verify": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build && node --test scripts/cli-reference.test.mjs && node scripts/cli-reference.mjs --check && npm pack --dry-run --ignore-scripts && npm run audit:prod", - "docs:reference": "npm run build && node scripts/cli-reference.mjs", - "docs:reference:check": "npm run build && node scripts/cli-reference.mjs --check" - }, "dependencies": { "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/sdk": "0.1.0-dev.20261004024706", "jq-wasm": "3.0.0-jq-1.8.2", - "zod": "^4.6.6" + "zod": "4.6.7" }, + "description": "The official command-line interface for Polymorfa", "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", @@ -51,6 +23,18 @@ "typescript-eslint": "^8.67.0", "vitest": "3.2.7" }, + "engines": { + "node": ">=22" + }, + "files": [ + "bin/run.js", + "dist", + "skills", + "README.md", + "LICENSE" + ], + "license": "MIT", + "name": "@polymorfa/cli", "oclif": { "bin": "polymorfa", "dirname": "polymorfa", @@ -350,5 +334,22 @@ "repository": { "type": "git", "url": "git+https://github.com/polymorfa/cli.git" - } + }, + "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 .", + "lint": "eslint .", + "prepare": "npm run build", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "verify": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build && node --test scripts/cli-reference.test.mjs && node scripts/cli-reference.mjs --check && npm pack --dry-run --ignore-scripts && npm run audit:prod", + "docs:reference": "npm run build && node scripts/cli-reference.mjs", + "docs:reference:check": "npm run build && node scripts/cli-reference.mjs --check" + }, + "type": "module", + "version": "0.1.0-dev.0" } 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 dc780834824be94e36df7642417021bf5ed7ea2c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 06:31:26 +0200 Subject: [PATCH 4/7] 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) From ae827188d429b1fc15a778107e39036a50d4c81a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 06:43:18 +0200 Subject: [PATCH 5/7] fix: qualify browser CLI on current main --- .../cli90-current-main-rebase-20261028.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 internal/agent-sessions/cli90-current-main-rebase-20261028.md diff --git a/internal/agent-sessions/cli90-current-main-rebase-20261028.md b/internal/agent-sessions/cli90-current-main-rebase-20261028.md new file mode 100644 index 00000000..cc3da59f --- /dev/null +++ b/internal/agent-sessions/cli90-current-main-rebase-20261028.md @@ -0,0 +1,11 @@ +# CLI90 current-main rebase + +Collaborator: purpshell. Agent: gpt-6.1-sol, xhigh, through T3 Code. Thread: 7793bd59-7f5e-4ad0-bcf2-8da9ea202931. This is a hand-authored selected checkpoint with a full-transcript capture gap. No credentials, customer content or hidden reasoning is included. + +The user authorized guarded rebases of existing PRs. Old fde26bbee054101b20f71f9525d75acb4719a5c0 on 58e91a18d1a4afc108eb3119db0830340bb2a926 was actually rebased with --rebase-merges onto live main a9a19cd68eecf7f33eae495665c1f389b6906a5e. Rebased source: 826b87f9751f0ae6c7896b6744fabde0af2ab064. Durable backup: backup/rebase-20261028-cli90-fde26bbe. Old patch, bundle, index, author records, range-diff and each conflict stage are retained privately. Only divergent root Zod constraint fields were selected semantically at the authored 4.6.7 floor; current main and authored lock graph values otherwise merge field by field. No install or dependency download ran. + +The browser registry was regenerated from actual command files. Agent setup is not present in this accepted main source. CLI85 must land before the next combined registry refresh; this head does not claim its unpublished parent is accepted. + +Fresh serial Node 24 checks passed: full CLI suite, formatting, lint, typecheck, Node build, browser build, release metadata, package dry-run, production audit and an emitted browser module recording the real text-send command against a synthetic in-memory transport. No request reached a network API. New hosted checks and review remain required; old-head evidence does not qualify this head. A real browser, native agent installers and package publication were not checked in this task. + +Impact: CLI runner/source and browser registry are qualified against the same accepted main revision. SDK package/API contract pins are unchanged; opaque-key support still depends on the pinned SDK, and client-token acceptance here is synthetic. Docs888 retains its existing source-bound browser bundle until its sole Opus owner accepts a new binding. API/Admin authority, runners, Temporal, audit, Inspector, Console, Connect, Help Center and marketing runtime are unchanged by this CLI-only delta. ZK custody, QuickLink and the browser pairing tunnel are unaffected: no credential-exchange or pairing code changed, and the recorder performs only a synthetic message request. Feature/registry/environment availability remains gated, staging deferred, Release owns merge. Roadmap records were not synchronized. From 17e601d4447a342bbe9734f7410f6f85c63f605f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 08:36:54 +0200 Subject: [PATCH 6/7] fix: reject local agent setup before browser loading --- README.md | 9 ++ .../cli90-accepted-cli85-20261028.md | 17 ++ package.json | 5 +- scripts/build-browser.mjs | 9 +- scripts/gen-browser-registry.mjs | 94 +++++++++--- scripts/verify-browser.mjs | 145 ++++++++++++++++++ src/browser/command-support.ts | 4 + src/browser/index.ts | 10 +- src/browser/registry.gen.ts | 12 +- test/browser/agent-setup.test.ts | 76 +++++++++ 10 files changed, 351 insertions(+), 30 deletions(-) create mode 100644 internal/agent-sessions/cli90-accepted-cli85-20261028.md create mode 100644 scripts/verify-browser.mjs create mode 100644 src/browser/command-support.ts create mode 100644 test/browser/agent-setup.test.ts diff --git a/README.md b/README.md index 8e5a2d4b..55f947ff 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,15 @@ verify` rebuilds the CLI and fails when the committed reference is stale. Regene it whenever public command metadata or the package version changes. No timestamp or local path is included. +## Browser build + +Build the CLI module for a browser, then check the emitted artifact: + + npm run build:browser + npm run verify:browser + +The registry comes from the real command files. Commands needing the installed CLI remain in that inventory with an unsupported reason. Agent setup is refused before loading its implementation or reading local client configuration; the installer is excluded from the bundle. Verification checks the emitted dependency graph and runs a synthetic text send with no network access. Package and Docs publication remain separate steps. + ## Authentication profiles Until the backend exposes a revocable CLI device-authorization flow, login diff --git a/internal/agent-sessions/cli90-accepted-cli85-20261028.md b/internal/agent-sessions/cli90-accepted-cli85-20261028.md new file mode 100644 index 00000000..623900ea --- /dev/null +++ b/internal/agent-sessions/cli90-accepted-cli85-20261028.md @@ -0,0 +1,17 @@ +# CLI #90 integration after accepted CLI #85 + +Collaborator: purpshell. Agent: gpt-6.1-sol, xhigh, T3 Code. Thread: 7793bd59-7f5e-4ad0-bcf2-8da9ea202931. Selected hand-authored evidence is recorded here with a full-transcript capture gap; no credentials, customer content or hidden reasoning is included. + +## Source and preservation + +Release accepted CLI85 main 637f9991342279133bb000a7db01574bcffaf174, source 541f9a01. CLI90 old 8b42b62cc18391567da0d786beff34c529b73d3c on base a9a19cd68eecf7f33eae495665c1f389b6906a5e was actually rebased with --rebase-merges onto live main 637f9991342279133bb000a7db01574bcffaf174. Rebased source: ae827188d429b1fc15a778107e39036a50d4c81a. The four feature commits and prior checkpoint retain their authored history/dates; original patches, bundle, index, conflict stages and range-diff are retained under backup/rebase-20261028-cli90-8b42b62c-after-cli85. JSON fields were reconciled semantically; the fixed Zod 4.6.7 floor, current graph and both script families remain. Installed dependencies match lock version/resolved/integrity, with foreign optional platforms explicitly excluded. No install ran. + +## Verification + +The pre-load tripwire failed before the guard and passed after shared unsupported metadata was applied. Loader/module, filesystem/configuration and installer tripwires prevent local effects. An independent artifact check failed while the old producer still bundled installer modules, then passed when the genuine producer used the same literal typed policy. Unsupported command IDs stay in the inventory with rejecting loaders. No generated inventory was hand-edited or old count hard-coded. The emitted artifact refuses setup before environment/fetch access, excludes installer/native module inputs, and preserves the real text-send POST/path/body/output using synthetic fetch only. + +Fresh serial Node 24: 1164 tests in 118 files passed; format/lint/types/build/reference/metadata/browser artifact/pack/audit and installed Actionlint on both unchanged workflows passed. The targeted artifact build/recorder runs again after commit because its embedded Git identity changes. Exact metrics, artifact input graph and final source proof are retained privately and linked from the PR. Natural rewritten-head hosted jobs and review remain required; old-head green is not reused. + +## Impact and gates + +CLI source/producers and its README change. Accepted CLI85 process/helper/consent/native fixture and both workflows remain byte-identical. API and all six SDK wire/version/package pins are unchanged; the existing opaque-key SDK limitation remains. Docs #888 keeps its older source-bound bundle until its sole Opus owner accepts a new one. No Admin/MCP, runner, Temporal, audit, Inspector, Console, Connect, Help Center, marketing, ZK custody, QuickLink or browser pairing tunnel runtime changed. No UI/browser action, real installer, network API, registry, provider, migration or deployment effect occurred. Release owns merge; staging/availability gates remain and trackers were not synchronized. diff --git a/package.json b/package.json index 7987cb7e..0a9df267 100644 --- a/package.json +++ b/package.json @@ -346,9 +346,10 @@ "prepare": "npm run build", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit", - "verify": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build && node --test scripts/cli-reference.test.mjs && node scripts/cli-reference.mjs --check && npm pack --dry-run --ignore-scripts && npm run audit:prod", + "verify": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build && node --test scripts/cli-reference.test.mjs && node scripts/cli-reference.mjs --check && npm run verify:browser && npm pack --dry-run --ignore-scripts && npm run audit:prod", "docs:reference": "npm run build && node scripts/cli-reference.mjs", - "docs:reference:check": "npm run build && node scripts/cli-reference.mjs --check" + "docs:reference:check": "npm run build && node scripts/cli-reference.mjs --check", + "verify:browser": "node scripts/verify-browser.mjs" }, "type": "module", "version": "0.1.0-dev.0" diff --git a/scripts/build-browser.mjs b/scripts/build-browser.mjs index 9759b104..4a0203d9 100644 --- a/scripts/build-browser.mjs +++ b/scripts/build-browser.mjs @@ -2,7 +2,7 @@ // 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 {URL, fileURLToPath} from 'node:url' import {execFileSync} from 'node:child_process' import {readFileSync, writeFileSync, rmSync} from 'node:fs' import {builtinModules} from 'node:module' @@ -12,7 +12,7 @@ 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 + fileURLToPath(new URL(`../src/browser/stubs/${name}.ts`, import.meta.url)) const NODE = { fs: stub('fs'), 'fs/promises': stub('fs-promises'), @@ -92,6 +92,11 @@ const result = await build({ metafile: true, logLevel: 'warning', }) +if (process.env.BROWSER_METAFILE) + writeFileSync( + process.env.BROWSER_METAFILE, + JSON.stringify(result.metafile, null, 2) + '\n', + ) const bytes = Object.values(result.metafile.outputs).reduce( (sum, output) => sum + output.bytes, 0, diff --git a/scripts/gen-browser-registry.mjs b/scripts/gen-browser-registry.mjs index e55ba467..8e9491ec 100644 --- a/scripts/gen-browser-registry.mjs +++ b/scripts/gen-browser-registry.mjs @@ -1,16 +1,50 @@ -// 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. +// Generate the actual command inventory; local-only commands get rejecting loaders. import console from 'node:console' -import {URL} from 'node:url' -import {readdirSync, statSync, writeFileSync} from 'node:fs' +import {URL, fileURLToPath} from 'node:url' +import {readFileSync, readdirSync, statSync, writeFileSync} from 'node:fs' import {join, relative} from 'node:path' import {format, resolveConfig} from 'prettier' +import ts from 'typescript' -const root = new URL('../src/commands/', import.meta.url).pathname +const root = fileURLToPath(new URL('../src/commands/', import.meta.url)) +const policyPath = fileURLToPath( + new URL('../src/browser/command-support.ts', import.meta.url), +) +const parsed = ts.createSourceFile( + policyPath, + readFileSync(policyPath, 'utf8'), + ts.ScriptTarget.Latest, + true, +) +const declaration = parsed.statements + .filter(ts.isVariableStatement) + .flatMap((statement) => [...statement.declarationList.declarations]) + .find( + (value) => + ts.isIdentifier(value.name) && value.name.text === 'UNSUPPORTED_COMMANDS', + ) +if ( + !declaration?.initializer || + !ts.isObjectLiteralExpression(declaration.initializer) +) + throw new Error('Browser command policy must be a literal object') +const unsupported = new Map() +for (const property of declaration.initializer.properties) { + if ( + !ts.isPropertyAssignment(property) || + !ts.isStringLiteral(property.name) || + !ts.isStringLiteral(property.initializer) || + !property.initializer.text + ) + throw new Error( + 'Browser command policy must contain string IDs and reasons', + ) + unsupported.set(property.name.text, property.initializer.text) +} const files = [] -const walk = (dir) => { - for (const name of readdirSync(dir).sort()) { - const path = join(dir, name) +const walk = (directory) => { + for (const name of readdirSync(directory).sort()) { + const path = join(directory, name) if (statSync(path).isDirectory()) walk(path) else if ( name.endsWith('.ts') && @@ -21,21 +55,43 @@ const walk = (dir) => { } } walk(root) +const ids = new Set() const entries = files.map((file) => { - const rel = relative(root, file).replace(/\.ts$/, '') + const rel = relative(root, file).replaceAll('\\', '/').replace(/\.ts$/, '') const id = rel.split('/').join(' ') - return ` ${JSON.stringify(id)}: () => import('../commands/${rel}.js'),` + ids.add(id) + const reason = unsupported.get(id) + return ( + ' ' + + JSON.stringify(id) + + ': ' + + (reason + ? '() => Promise.reject(new BrowserUnsupportedError(' + + JSON.stringify(reason) + + '))' + : "() => 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')} -} -` +for (const id of unsupported.keys()) + if (!ids.has(id)) + throw new Error('Unsupported command is absent from real inventory: ' + id) +const out = + '// Generated by scripts/gen-browser-registry.mjs. Do not edit.\n' + + "import {BrowserUnsupportedError} from './stubs/unsupported.js'\n" + + 'export const COMMAND_LOADERS: Readonly Promise<{default: unknown}>>> = {\n' + + entries.join('\n') + + '\n}\n' 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)) ?? {} +const options = (await resolveConfig(fileURLToPath(target))) ?? {} writeFileSync( target, - await format(out, {...options, filepath: target.pathname}), + await format(out, {...options, filepath: fileURLToPath(target)}), +) +console.log( + 'registry: ' + + files.length + + ' commands; ' + + unsupported.size + + ' local/unsupported loaders excluded', ) -console.log(`registry: ${files.length} commands`) diff --git a/scripts/verify-browser.mjs b/scripts/verify-browser.mjs new file mode 100644 index 00000000..855e8322 --- /dev/null +++ b/scripts/verify-browser.mjs @@ -0,0 +1,145 @@ +// Build through the real producers and inspect/run the actual emitted module. +import assert from 'node:assert/strict' +import process from 'node:process' +import {execFileSync} from 'node:child_process' +import {mkdtempSync, mkdirSync, readFileSync} from 'node:fs' +import {homedir} from 'node:os' +import {join} from 'node:path' +import {builtinModules} from 'node:module' +import {URL, pathToFileURL} from 'node:url' +import console from 'node:console' +const root = join( + homedir(), + '.polymorfa-agent-work', + 'cli-browser-artifact-checks', +) +mkdirSync(root, {recursive: true}) +const work = mkdtempSync(join(root, 'check-')) +const metaPath = join(work, 'metafile.json') +execFileSync(process.execPath, ['scripts/gen-browser-registry.mjs'], { + stdio: 'inherit', +}) +execFileSync(process.execPath, ['scripts/build-browser.mjs'], { + stdio: 'inherit', + env: {...process.env, BROWSER_METAFILE: metaPath}, +}) +const metadata = JSON.parse(readFileSync(metaPath, 'utf8')) +const inputs = Object.keys(metadata.inputs).map((path) => + path.replaceAll('\\', '/'), +) +for (const local of [ + 'src/commands/agent/setup.ts', + 'src/services/agent-setup.ts', +]) + assert.ok( + !inputs.some((path) => path.endsWith(local)), + local + ' was bundled', + ) +for (const nativePackage of [ + 'node_modules/@napi-rs/keyring/', + 'node_modules/@modelcontextprotocol/server/', +]) + assert.ok( + !inputs.some((path) => path.includes(nativePackage)), + nativePackage + ' was bundled', + ) +const nativeBuiltins = new Set( + builtinModules.map((value) => value.replace(/^node:/, '')), +) +for (const output of Object.values(metadata.outputs)) + for (const item of output.imports) + assert.ok( + !( + item.external && + (item.path.startsWith('node:') || nativeBuiltins.has(item.path)) + ), + 'Native Node import escaped browser stubbing: ' + item.path, + ) +const manifest = JSON.parse(readFileSync('dist-browser/manifest.json', 'utf8')) +const commit = execFileSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', +}).trim() +assert.equal(manifest.commit, commit) +const actual = await import( + pathToFileURL(join(process.cwd(), 'dist-browser/polymorfa-cli.js')).href +) +assert.equal(actual.CLI_COMMIT, commit) +assert.ok(actual.COMMAND_IDS.includes('agent setup')) +const beforeFetch = globalThis.fetch +const refused = [] +const refusal = await actual.runCommand(['agent', 'setup', '--yes'], { + get env() { + throw new Error('local refusal touched caller credentials/configuration') + }, + get fetch() { + throw new Error('local refusal touched fetch') + }, + write: (stream, text) => refused.push({stream, text}), +}) +assert.deepEqual(refusal, {exitCode: 1, command: 'agent setup'}) +assert.ok( + refused.some( + (value) => + value.stream === 'stderr' && + value.text.includes('configures coding agents'), + ), +) +assert.equal(globalThis.fetch, beforeFetch) +const requests = [] +const output = [] +const sent = await actual.runCommand( + [ + 'message', + 'send', + 'text', + '--session', + '739182640518203', + '--to', + '+15550002222', + '--body', + 'Hello from Polymorfa', + ], + { + env: {POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_' + 'fixture'.repeat(6)}, + fetch: async (input, init = {}) => { + requests.push({ + path: new URL(String(input)).pathname, + method: init.method, + body: JSON.parse(init.body), + }) + return new globalThis.Response( + JSON.stringify({ + success: true, + data: {id: '3EB0FIXTURE', status: 'sent'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ) + }, + write: (stream, text) => output.push({stream, text}), + }, +) +assert.deepEqual(sent, {exitCode: 0, command: 'message send text'}) +assert.equal(requests.length, 1) +assert.equal(requests[0].method, 'POST') +assert.equal(requests[0].path, '/messaging/739182640518203/messages/send') +assert.equal(requests[0].body.content.text, 'Hello from Polymorfa') +assert.equal( + output + .filter((value) => value.stream === 'stdout') + .map((value) => value.text) + .join(''), + 'Sent message 3EB0FIXTURE to +15550002222.\n', +) +assert.equal(globalThis.fetch, beforeFetch) +console.log( + JSON.stringify({ + commit, + commands: actual.COMMAND_IDS.length, + localInstallerBundled: false, + agentSetup: 'refused before environment/fetch', + send: {method: requests[0].method, path: requests[0].path}, + files: manifest.files, + bytes: manifest.bytes, + evidence: work, + }), +) diff --git a/src/browser/command-support.ts b/src/browser/command-support.ts new file mode 100644 index 00000000..9e95b496 --- /dev/null +++ b/src/browser/command-support.ts @@ -0,0 +1,4 @@ +export const UNSUPPORTED_COMMANDS: Readonly> = { + 'calls media': 'streams call audio as binary PCM', + 'agent setup': 'configures coding agents on the local computer', +} diff --git a/src/browser/index.ts b/src/browser/index.ts index a6080af5..2ac46e44 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 {UNSUPPORTED_COMMANDS} from './command-support.js' import {BrowserUnsupportedError} from './stubs/unsupported.js' declare const __CLI_VERSION__: string | undefined @@ -38,11 +39,6 @@ export interface BrowserIo { 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 { readonly exitCode: number /** The command the words resolved to, or null when none matched. */ @@ -220,7 +216,9 @@ async function runNow( io.write('stderr', `Unknown command: polymorfa ${words.join(' ')}\n`) return {exitCode: 2, command: null} } - const unsupported = UNSUPPORTED[resolved.id] + const unsupported = Object.hasOwn(UNSUPPORTED_COMMANDS, resolved.id) + ? UNSUPPORTED_COMMANDS[resolved.id] + : undefined if (unsupported) { io.write('stderr', `${new BrowserUnsupportedError(unsupported).message}\n`) return {exitCode: 1, command: resolved.id} diff --git a/src/browser/registry.gen.ts b/src/browser/registry.gen.ts index ba216b34..d2d15878 100644 --- a/src/browser/registry.gen.ts +++ b/src/browser/registry.gen.ts @@ -1,7 +1,14 @@ // Generated by scripts/gen-browser-registry.mjs. Do not edit. +import {BrowserUnsupportedError} from './stubs/unsupported.js' export const COMMAND_LOADERS: Readonly< Record Promise<{default: unknown}>> > = { + 'agent setup': () => + Promise.reject( + new BrowserUnsupportedError( + 'configures coding agents on the local computer', + ), + ), 'analytics get': () => import('../commands/analytics/get.js'), 'analytics metrics': () => import('../commands/analytics/metrics.js'), api: () => import('../commands/api.js'), @@ -118,7 +125,10 @@ export const COMMAND_LOADERS: Readonly< '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 media': () => + Promise.reject( + new BrowserUnsupportedError('streams call audio as binary PCM'), + ), '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'), diff --git a/test/browser/agent-setup.test.ts b/test/browser/agent-setup.test.ts new file mode 100644 index 00000000..93ccc499 --- /dev/null +++ b/test/browser/agent-setup.test.ts @@ -0,0 +1,76 @@ +import {afterEach, expect, test, vi} from 'vitest' +import {COMMAND_LOADERS} from '../../src/browser/registry.gen.js' +import {runCommand} from '../../src/browser/index.js' + +const tripwires = vi.hoisted(() => ({ + probe: vi.fn(() => { + throw new Error('local filesystem/client config probe') + }), + spawn: vi.fn(() => { + throw new Error('local installer process') + }), + module: vi.fn(() => { + throw new Error('local installer module evaluated') + }), +})) +vi.mock('node:fs/promises', () => ({ + access: tripwires.probe, + stat: tripwires.probe, + readFile: tripwires.probe, +})) +vi.mock('node:child_process', () => ({ + spawn: tripwires.spawn, + execFile: tripwires.spawn, +})) +vi.mock('../../src/services/agent-setup.js', () => { + tripwires.module() + return {} +}) +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +test.each([ + ['agent', 'setup', '--yes'], + ['agent', 'setup', '--dry-run'], +])( + 'refuses %j before loading or probing local installer code', + async (...words) => { + const loader = vi + .spyOn(COMMAND_LOADERS, 'agent setup') + .mockRejectedValue(new Error('installer loader must not be called')) + const fetch = vi.fn() + const output: string[] = [] + const result = await runCommand(words, { + env: {}, + fetch, + write: (_stream, text) => output.push(text), + }) + expect(result).toEqual({exitCode: 1, command: 'agent setup'}) + expect(output.join('')).toContain('needs the installed Polymorfa CLI') + expect(output.join('')).toContain('configures coding agents') + expect(loader).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + expect(tripwires.probe).not.toHaveBeenCalled() + expect(tripwires.spawn).not.toHaveBeenCalled() + expect(tripwires.module).not.toHaveBeenCalled() + }, +) +test('the real generated inventory and local-only module tripwires need no caller environment', async () => { + const output: string[] = [] + const result = await runCommand(['agent', 'setup'], { + get env(): never { + throw new Error('caller credentials/configuration read') + }, + get fetch(): never { + throw new Error('fetch touched before local refusal') + }, + write: (_stream, text) => output.push(text), + }) + expect(result).toEqual({exitCode: 1, command: 'agent setup'}) + expect(output.join('')).toContain('configures coding agents') + expect(tripwires.probe).not.toHaveBeenCalled() + expect(tripwires.spawn).not.toHaveBeenCalled() + expect(tripwires.module).not.toHaveBeenCalled() +}) From 664166eb730e9c7b010c7b66b3b1978c8020adbe Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 08:46:05 +0200 Subject: [PATCH 7/7] fix: clean browser verification metadata without home storage --- scripts/verify-browser.mjs | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/scripts/verify-browser.mjs b/scripts/verify-browser.mjs index 855e8322..500db3e7 100644 --- a/scripts/verify-browser.mjs +++ b/scripts/verify-browser.mjs @@ -2,28 +2,25 @@ import assert from 'node:assert/strict' import process from 'node:process' import {execFileSync} from 'node:child_process' -import {mkdtempSync, mkdirSync, readFileSync} from 'node:fs' -import {homedir} from 'node:os' +import {readFileSync, rmSync} from 'node:fs' import {join} from 'node:path' import {builtinModules} from 'node:module' import {URL, pathToFileURL} from 'node:url' import console from 'node:console' -const root = join( - homedir(), - '.polymorfa-agent-work', - 'cli-browser-artifact-checks', -) -mkdirSync(root, {recursive: true}) -const work = mkdtempSync(join(root, 'check-')) -const metaPath = join(work, 'metafile.json') -execFileSync(process.execPath, ['scripts/gen-browser-registry.mjs'], { - stdio: 'inherit', -}) -execFileSync(process.execPath, ['scripts/build-browser.mjs'], { - stdio: 'inherit', - env: {...process.env, BROWSER_METAFILE: metaPath}, -}) -const metadata = JSON.parse(readFileSync(metaPath, 'utf8')) +const metaPath = join(process.cwd(), 'dist-browser', '.verify-metafile.json') +let metadata +try { + execFileSync(process.execPath, ['scripts/gen-browser-registry.mjs'], { + stdio: 'inherit', + }) + execFileSync(process.execPath, ['scripts/build-browser.mjs'], { + stdio: 'inherit', + env: {...process.env, BROWSER_METAFILE: metaPath}, + }) + metadata = JSON.parse(readFileSync(metaPath, 'utf8')) +} finally { + rmSync(metaPath, {force: true}) +} const inputs = Object.keys(metadata.inputs).map((path) => path.replaceAll('\\', '/'), ) @@ -140,6 +137,5 @@ console.log( send: {method: requests[0].method, path: requests[0].path}, files: manifest.files, bytes: manifest.bytes, - evidence: work, }), )