diff --git a/.gitignore b/.gitignore index 1678d67..5347e6a 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 402cfd8..e3e0392 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-lock.json b/package-lock.json index d4c1a04..cb27571 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@oclif/core": "^4.13.5", "@polymorfa/sdk": "0.1.0-dev.20261004024706", "jq-wasm": "3.0.0-jq-1.8.2", - "zod": "4.2.0" + "zod": "4.6.7" }, "bin": { "polymorfa": "bin/run.js" @@ -23,6 +23,7 @@ "@eslint/js": "^9.39.5", "@oclif/test": "^4.1.22", "@types/node": "^24.0.0", + "esbuild": "^0.28.2", "eslint": "^9.39.5", "prettier": "^3.9.6", "tsx": "^4.20.6", @@ -3913,9 +3914,10 @@ } }, "node_modules/zod": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.0.tgz", - "integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 780699f..d34de54 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 .", @@ -35,12 +36,13 @@ "@oclif/core": "^4.13.5", "@polymorfa/sdk": "0.1.0-dev.20261004024706", "jq-wasm": "3.0.0-jq-1.8.2", - "zod": "4.2.0" + "zod": "4.6.7" }, "devDependencies": { "@eslint/js": "^9.39.5", "@oclif/test": "^4.1.22", "@types/node": "^24.0.0", + "esbuild": "^0.28.2", "eslint": "^9.39.5", "prettier": "^3.9.6", "tsx": "^4.20.6", diff --git a/scripts/build-browser.mjs b/scripts/build-browser.mjs new file mode 100644 index 0000000..9759b10 --- /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 0000000..e55ba46 --- /dev/null +++ b/scripts/gen-browser-registry.mjs @@ -0,0 +1,41 @@ +// 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' +import {format, resolveConfig} from 'prettier' + +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')} +} +` +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 new file mode 100644 index 0000000..a6080af --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,308 @@ +/** + * 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' +import {BrowserUnsupportedError} from './stubs/unsupported.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. 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 + /** 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 { + 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 (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[] = [] + 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 === '"' && + DOUBLE_QUOTE_ESCAPES.has(line[index + 1] ?? '') + ) + 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, +} + +type Listener = (...args: unknown[]) => void +type WriteCallback = (error?: Error | null) => void + +interface EmitterLike { + listeners: (event: string) => Listener[] + on: (event: string, listener: Listener) => unknown + 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: 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() + +/** + * 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 { + 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, + out: proc.stdout.write, + err: proc.stderr.write, + fetch: globalThis.fetch, + } + // 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)) + } + // 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 + new (argv: string[], config: unknown): {_run: () => Promise} + } + 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} + 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} + } 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) + }) + proc.env = saved.env + proc.exitCode = saved.exitCode + proc.stdout.write = saved.out + proc.stderr.write = saved.err + globalThis.fetch = saved.fetch + } +} diff --git a/src/browser/registry.gen.ts b/src/browser/registry.gen.ts new file mode 100644 index 0000000..ba216b3 --- /dev/null +++ b/src/browser/registry.gen.ts @@ -0,0 +1,486 @@ +// Generated by scripts/gen-browser-registry.mjs. Do not edit. +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/src/browser/stubs/buffer-global.ts b/src/browser/stubs/buffer-global.ts new file mode 100644 index 0000000..bd5619d --- /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 0000000..148a5df --- /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 0000000..4a37983 --- /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 0000000..ce0b834 --- /dev/null +++ b/src/browser/stubs/events.ts @@ -0,0 +1,71 @@ +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 { + // 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 + } + addListener(name: string | symbol, listener: Listener): this { + return this.on(name, listener) + } + once(name: string | symbol, listener: Listener): this { + const wrapped: Wrapped = (...args) => { + this.off(name, wrapped) + listener(...args) + } + 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 { + 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 { + 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) ?? [])] + // 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 + } + /** The listeners as registered, like Node: `once` listeners unwrapped. */ + listeners(name: string | symbol): Listener[] { + return (this.#listeners.get(name) ?? []).map( + (entry: Wrapped) => entry.listener ?? entry, + ) + } + 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 0000000..3d68b94 --- /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 0000000..b620c11 --- /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 0000000..ae5e63b --- /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 0000000..0408f61 --- /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 0000000..9998d50 --- /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 0000000..df24614 --- /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 0000000..2974e4c --- /dev/null +++ b/src/browser/stubs/process.ts @@ -0,0 +1,69 @@ +// 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 => {} + +/** 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', + title: 'polymorfa', + platform: 'linux', + arch: 'wasm', + pid: 1, + version: 'v22.0.0', + versions: {node: '22.0.0'}, + 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, + 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 0000000..70e94d9 --- /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 0000000..bd5b697 --- /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 0000000..d81b078 --- /dev/null +++ b/test/browser/run.test.ts @@ -0,0 +1,382 @@ +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 {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' + +// 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' + +/** + * 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, + options: {env?: Record; signal?: AbortSignal} = {}, +) { + 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: 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, + 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('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({ + 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('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('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('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() + 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, + 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('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) + 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( + 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(browserPath.join('/a', 'b', '../c', 'd.txt')).toBe('/a/c/d.txt') + expect([ + 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(browserPath.resolve('/a', 'b')).toBe('/a/b') + }) +})