diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..6b53cabf Binary files /dev/null and b/.DS_Store differ diff --git a/.changeset/README.md b/.changeset/README.md deleted file mode 100644 index 489fb041..00000000 --- a/.changeset/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Changesets - -Use `pnpm changeset` in feature branches to describe user-facing package changes. - -When changes land on `main`, the release workflow opens or updates a release PR. -Merging that release PR publishes changed packages to npm and creates GitHub -Releases from the generated changelogs. diff --git a/.changeset/config.json b/.changeset/config.json deleted file mode 100644 index 3671d8f8..00000000 --- a/.changeset/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", - "changelog": ["@changesets/changelog-github", { "repo": "beeper/pickle" }], - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": [] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3401acdb..b025ac9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,5 @@ name: CI -on: - push: - branches: - - "**" - - "!integrated/**" - - "!stl-preview-head/**" - - "!stl-preview-base/**" - - "!generated" - - "!codegen/**" - - "codegen/stl/**" - pull_request: - branches-ignore: - - "stl-preview-head/**" - - "stl-preview-base/**" +on: [push, pull_request] jobs: test: @@ -39,24 +26,3 @@ jobs: - name: Test run: bun run test - - # Binary/Homebrew packaging is intentionally disabled until binary releases - # are ready to ship. Re-enable this job with the package scripts already kept - # in packages/cli/package.json: - # - # package: - # name: package homebrew archive - # runs-on: macos-latest - # needs: test - # steps: - # - uses: actions/checkout@v6 - # - # - uses: oven-sh/setup-bun@v2 - # with: - # bun-version: 1.3.10 - # - # - name: Install dependencies - # run: bun install --frozen-lockfile - # - # - name: Package Homebrew archive - # run: bun run --filter beeper-cli pack:homebrew diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 6105b43e..fcc206a9 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -24,8 +24,21 @@ jobs: bun-version: 1.3.10 - name: Install dependencies run: bun install --frozen-lockfile - - name: Test - run: bun run test + - name: Check + run: bun run check + - name: Publish npm package + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc + if npm view "beeper-cli@${version}" version >/dev/null 2>&1; then + echo "beeper-cli@${version} is already published" + exit 0 + fi + npm version "${version}" --workspace beeper-cli --no-git-tag-version --allow-same-version + npm publish --workspace beeper-cli --access public - name: Publish GitHub release env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml deleted file mode 100644 index 095bb065..00000000 --- a/.github/workflows/release-doctor.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Release Doctor -on: - pull_request: - branches: - - main - workflow_dispatch: - -jobs: - release_doctor: - name: release doctor - runs-on: ubuntu-latest - if: github.repository == 'beeper/cli' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') - - steps: - - uses: actions/checkout@v6 - - - name: Check release environment - run: | - bash packages/cli/bin/check-release-environment diff --git a/.gitignore b/.gitignore index 13b643ec..bf7d3f55 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ node_modules/ /beeper-desktop-cli .upstream/ *.exe + +.claude/ diff --git a/INTENT.md b/INTENT.md new file mode 100644 index 00000000..5480a0e0 --- /dev/null +++ b/INTENT.md @@ -0,0 +1,179 @@ +# Intent + +This CLI should keep its existing feature set while being reorganized and made +consistent with OpenClaw product language and setup expectations. + +## Product Direction + +- Keep the interactive setup UX: guided steps, prompts, readiness checks, and + clear next actions. +- Align command output, setup/status wording, and user-facing concepts with + OpenClaw where that is the intended product direction. +- For send/message command ergonomics, use `gog` and `wacli` as the closer + references, not OpenClaw's generic `message` command. +- Use real OpenClaw behavior and documented contracts when adding OpenClaw + integration. Do not invent SDK layers or placeholder APIs. + +## Engineering Rules + +- Reorganize and simplify code without deleting working features unless the + feature is explicitly out of scope. +- Preserve existing command capabilities while changing internal structure or + output shape. +- Prefer direct modules and concrete functions over convenience barrels, + wrapper-only layers, duplicate types, and parallel command paths. +- Fold abstractions when they only rename another abstraction or hide a single + call without adding policy, validation, or ownership. +- Keep one coherent implementation for each concern: parsing, output, + configuration, setup state, auth, and API access. +- Do not keep compatibility-only aliases or contracts once product intent says + the new shape replaces them. + +## Guardrails + +- Before deleting a command, endpoint, helper, test, schema, or setup path, + confirm it is intentionally removed from the product scope. +- Treat "OpenClaw alignment" as output and architecture alignment first; it is + not permission to replace feature implementations with thin passthroughs. +- If intent is ambiguous, write down the product question before making the + change. + +## Not Done Yet + +These are known cleanup and alignment tasks that still need product judgment or +implementation. Treat this list as work to finish, not as a decision that every +item must be deleted. + +### Package Exports + +- Decide whether `packages/cli/package.json` should export anything beyond the + executable and `./package.json`. +- If there is no library API, keep exports minimal and do not add convenience + barrels. +- If a library API is needed, export only stable real source concerns, not + `commands`, generated schema, or internal helper modules by default. +- Remove any export path that exists only for tests, MCP mirroring, or legacy + convenience. + +### Command Surface + +- Inventory every command in `beeper --help` and mark it as one of: + keep, rename, fold into another command, hide/internalize, or delete. +- Preserve feature capability while doing that inventory. Do not remove working + account, chat, message, send, setup, or status behavior just to simplify the + list. +- Decide whether `api request`, `schema`, `mcp`, `watch`, `export`, and + `media download` are real product commands or unreleased implementation + utilities. +- Decide whether `contacts list` belongs in the main surface or should be + folded into chat/message resolution. +- Decide whether `install desktop`, `install server`, and + `targets runtime *` remain public commands or become setup-owned internals. + +### Aliases And Duplicate Entrypoints + +- Remove compatibility aliases only after choosing the canonical command. +- Decide whether `use target` and `targets use` should both exist; keep only one + if target selection remains a concept. +- Decide whether `remove target` and `targets remove` should both exist; keep + only one if target removal remains public. +- Decide whether `use account` and `accounts use` should both exist; keep only + one if default-account selection remains public. +- Decide whether `auth email start/response` should remain separate automation + commands or be folded into `setup --email --code`. +- Decide whether `send text/file/voice/sticker/react/presence` should stay as + separate user-facing commands. If changing them, prefer a `gog`/`wacli` style + shape over OpenClaw's generic message surface. + +### Send And Message Ergonomics + +- Use `wacli` as the closest reference for third-party chat sending: + `send text --to --message ` and + `send file --to --file --caption `. +- Use `gog` as the closest reference for content ergonomics: + `--body`, `--body-file -`, `--body-html`, clear send-vs-draft commands, and + explicit reply flags. +- Prefer explicit recipient and content flags for send commands. Do not rely on + positional magic for destructive or outbound actions. +- Require clear dry-run and confirmation behavior for outbound sends. In + interactive mode, ambiguous recipients or broad sends should ask before + sending. +- Keep search/list commands boring and scriptable: query flag or positional + query, `--limit`/`--max`, account/chat filters, and `--json`. +- Prefer `--json` plus `--no-input` for automation, matching `gog` and `wacli`. +- Decide whether multiline text should use `--message-file -` or a more + `gog`-like `--body-file -`; choose one term and apply it consistently. +- Do not use OpenClaw's generic `message send --channel --target --message` + shape as the primary ergonomic reference for this CLI. + +### Global Flags + +- Decide canonical behavior for `--json`; keep one structured output path. +- Decide whether `--plain` is useful or just a second output mode to delete. +- Decide whether `--events` is product behavior or debug plumbing. +- Decide whether `--wrap-untrusted` and safety profiles are still part of this + CLI once OpenClaw owns the trust model. +- Decide whether `--target` remains a public global flag or setup/status should + own endpoint selection. +- Ensure `--no-input` consistently means no prompts everywhere, especially + setup and account login. + +### Setup And OpenClaw Alignment + +- Keep interactive setup as the UX. Reorganize it around clear steps and + readiness states instead of deleting it. +- Rename user-facing setup concepts toward OpenClaw only where the underlying + behavior is real. +- Define how Beeper Desktop/Server setup maps to OpenClaw gateway/channel + setup. This is not done. +- Decide whether OpenClaw onboarding is invoked, embedded through a real SDK, + or used only as a model for output and step structure. +- If invoking OpenClaw, preserve existing Beeper-specific setup capabilities + unless product explicitly says they are replaced. +- Make non-interactive setup output match the same step/readiness model as + interactive setup. + +### Output Contracts + +- Define one JSON envelope for success, dry-run, readiness, setup actions, and + errors. +- Align names like `target`, `account`, `bridge`, `chat`, and `message` with + the intended OpenClaw vocabulary without breaking the feature semantics. +- Decide whether command output should expose raw Desktop API objects or + normalized CLI objects. +- Remove duplicate output paths such as JSON/plain/events if they represent the + same information. +- Make dry-run output consistent across setup, account add, chat mutation, + message send, export, install, and runtime commands. + +### Config And State + +- Decide whether the config model is one endpoint, many targets, or OpenClaw + gateway/channel config. +- If many targets remain, make target selection/removal/listing one coherent + system. +- If one endpoint is chosen, fold target registry code and aliases accordingly. +- Decide whether default account belongs in CLI config or should be derived from + OpenClaw/channel state. +- Remove config fields that exist only for unreleased legacy compatibility. + +### Internal Modules + +- Fold simple wrappers that only rename another module or function. +- Revisit `app-api.ts`, `target-status.ts`, `setup-login.ts`, + `cloudflare-tunnel.ts`, and `export.ts` after command-surface decisions. +- Keep modules split only when they own a real concern: parsing, output, setup, + auth, config/state, API access, install/runtime, or resolution. +- Avoid duplicated types between command handlers and lib modules. +- Import from the real source file; do not create barrels for convenience. + +### Tests And Verification + +- Update smoke tests after command-surface decisions so they assert the intended + public surface, not old accidental breadth. +- Keep focused tests for setup steps, account login, resolution, output + envelopes, and config migration/reset behavior. +- Remove tests only when the behavior they cover is intentionally removed or + covered better elsewhere. +- Keep `bun run typecheck`, `bun packages/cli/test/cli-smoke.ts`, and + `bun run check` green after each cleanup pass. diff --git a/README.md b/README.md index 6e4e5cc5..99e29095 100644 --- a/README.md +++ b/README.md @@ -1,320 +1,5 @@ -# beeper — One CLI for all your chats +# beeper -> Built for you and your agent. Batteries included. +This repository contains the `beeper-cli` package. -Talks to Beeper Desktop on this machine, to a Beeper Server you self-host, or -to either one running somewhere else. Send and receive across the chat -networks Beeper bridges, from one CLI shaped for scripts, agents, and humans -in a hurry. - -**Supported chat networks** (via Beeper's bridges): -WhatsApp · iMessage · Telegram · Discord · Signal · Instagram DMs · -Facebook Messenger · X (Twitter) DMs · LinkedIn · Slack · -Google Messages (RCS/SMS) · Google Chat · Matrix · IRC · Bluesky. -Run `beeper bridges list` for the live list on your target. - -Command manual: `beeper man` · CLI docs: `beeper docs` - -## Features - -- **Connects to your Beeper.** Local Beeper Desktop on this machine (default), a Beeper Server you install and manage via the CLI, or a remote Beeper Desktop or Beeper Server authorized over OAuth/PKCE — or a bearer token in CI. -- **Setup that does the work.** `beeper setup` finds Beeper Desktop, offers to launch it, adopts the session. `--server --install` installs and starts a headless server in one step. `--oauth` opens the browser. `--remote URL` does the rest. -- **Every chat, every network.** List, search, start, archive, pin, mute, rename, focus. Read, edit, delete, react. Send text, files, stickers, voice, typing indicators. Download media. Export to JSON or Markdown. -- **Verification first-class.** SAS/QR device verification, recovery-key unlock, `status`/`doctor` to reach an encrypted-ready target — without leaving the shell. -- **Agent-shaped automation.** `--json` everywhere, NDJSON `--events`, `watch` with WebSocket + outbound HMAC-signed webhooks, `rpc` over stdin/stdout, `man --json` tool manifests, raw `api get`/`post`/`request` for Beeper Client API endpoints we haven't wrapped yet. -- **Safe by default.** `--read-only` rejects every mutating command. Writes stay explicit. Plugins extend the CLI without forking it. - -## Install - -### Homebrew (recommended) - -```sh -brew install beeper/tap/cli -``` - -The installed command is `beeper`. - -### npm - -```sh -npx beeper-cli --help -npm install -g beeper-cli -``` - -The package name is `beeper-cli`; the installed command is `beeper`. - -### Build from source - -This repo is a Bun workspace. From the repo root: - -```sh -bun install -bun --filter @beeper/cli run build -bun --filter @beeper/cli run dev -- --help -``` - -For local CLI development inside `packages/cli`: - -```sh -bun run dev -- --help -``` - -Regenerate this README after command, flag, or argument changes: - -```sh -bun run readme -``` - -## Quick start - -The happy path: Beeper Desktop is already on this machine. `beeper setup` finds -it, offers to launch it if it's not running, and adopts the session. - -```text -$ beeper setup -Looking for Beeper Desktop… found, not running. -Launch it now? [Y/n] y -▎ Launched Beeper Desktop - next Run `beeper setup` again once it finishes starting. - -$ beeper setup -Use this Desktop session for CLI access? [Y/n] y -▎ Connected desktop - accounts whatsapp, telegram, imessage - endpoint http://127.0.0.1:23373 - -$ beeper chats list --limit 3 - 10313 Family 3 unread - 8951 Alice · - 7204 Eng standup 12 unread - -$ beeper messages search "flight" - 8951 Alice · "your flight is at 6:40, gate B23" 2d ago - 10313 Family · "what flight are you on?" 1w ago - -$ beeper send text --to Family --message "on my way" -▎ Sent Family - message "on my way" - at 2026-05-18T14:02:11Z - -$ beeper export --out ./beeper-export -▎ Exported ./beeper-export - chats 214 messages 38,901 attachments 1,205 -``` - -Recipients accept a numeric local chat ID, a full Beeper/Matrix chat ID, an -iMessage chat ID, an exact title, or search text. Ambiguous matches prompt in a -TTY; pass `--pick N` in scripts. - -## Connecting a target - -A *target* is the Beeper endpoint `beeper` talks to — local Beeper Desktop, -local Beeper Server, or a remote Beeper Desktop or Beeper Server. Pick one of -four paths. - -### 1. Local Beeper Desktop (default, recommended) - -If Beeper Desktop is installed and signed in here, `beeper setup` discovers it -on `http://127.0.0.1:23373` and adopts the existing session. If it's installed -but not running, `setup` offers to launch it. If it's not installed at all, -`--install` does that in one step. - -```text -$ beeper setup --desktop --install -▎ Installed Beeper Desktop (stable) -▎ Launched Beeper Desktop - next Sign in to Beeper Desktop, then re-run `beeper setup`. - -$ beeper setup -▎ Connected desktop - accounts whatsapp, telegram -``` - -Variants: `beeper setup --local` to skip discovery and force the local path; -`beeper install desktop --channel nightly` for the nightly channel. - -### 2. Local Beeper Server (self-hosted, managed by the CLI) - -For a headless long-running setup on this machine, install and adopt a local -Beeper Server. The CLI manages the process — `targets start/stop/restart/logs/enable`. - -```text -$ beeper setup --server --install -▎ Installed Beeper Server (stable) -▎ Started server on http://127.0.0.1:23373 - auth Opening browser to authorize this server… -▎ Connected server - accounts (none) - next Run `beeper accounts add` to connect a network. - -$ beeper accounts add -? Which bridge? whatsapp - Scan this QR code with WhatsApp on your phone: - ▄▄▄▄▄▄▄ ▄ ▄ ▄▄▄▄▄▄▄ - █ ███ █ ▄█▄ █ ███ █ - █ ███ █ ▀█▀ █ ███ █ - ▀▀▀▀▀▀▀ ▀ ▀ ▀▀▀▀▀▀▀ -▎ Connected whatsapp · +1•••4242 -``` - -Variants: `beeper install server`, `beeper install server --server-env staging`. - -### 3. Remote Desktop or Server via OAuth (PKCE) - -For a Beeper Desktop or Server running on another machine, authorize the CLI -through a browser-based OAuth/PKCE flow. - -```text -$ beeper setup --remote https://desktop.example.com -▎ Authorizing https://desktop.example.com - flow OAuth (PKCE) — opening browser… -▎ Connected remote (desktop.example.com) - accounts whatsapp, telegram, signal -``` - -Variants: `beeper setup --oauth` (PKCE against the default Beeper auth); -`beeper targets add remote work https://desktop.example.com --default` to -register additional remotes. - -### 4. Bearer token (non-interactive / CI) - -For agents, CI, and scripts, hand the CLI a bearer token directly — no -browser, no interactive prompts. - -```sh -BEEPER_ACCESS_TOKEN=... beeper chats list --json -BEEPER_ACCESS_TOKEN=... BEEPER_DESKTOP_BASE_URL=https://desktop.example.com \ - beeper messages list --chat 10313 --json -``` - -Once connected, `beeper accounts add` walks each chat-network bridge through -its own login — QR, code, OAuth, cookie, whatever the bridge requires — so -WhatsApp, Telegram, Discord, iMessage, and the rest show up under `accounts list`. - -## Documentation - -| Topic | Page | Commands | -| --- | --- | --- | -| **Setup + install** | [setup](docs/setup.md) · [auth](docs/auth.md) | `setup` · `install desktop` · `install server` · `verify` · `status` · `doctor` · `auth status` | -| **Targets** | [targets](docs/targets.md) | `targets list` · `targets add desktop` · `targets add server` · `targets add remote` · `targets use` · `targets status` · `targets logs` | -| **Bridges + accounts** | [accounts](docs/accounts.md) | `bridges list` · `bridges show` · `accounts list` · `accounts add` · `accounts show` · `accounts use` · `accounts remove` | -| **Chats** | [chats](docs/chats.md) | `chats list` · `chats search` · `chats show` · `chats start` · `chats archive` · `chats pin` · `chats mute` · `chats priority` · `chats remind` · `chats rename` · `chats draft` · `chats focus` | -| **Messages** | [messages](docs/messages.md) · [send](docs/send.md) · [presence](docs/presence.md) | `messages list` · `messages search` · `messages export` · `send text` · `send file` · `send sticker` · `send voice` · `send react` · `presence` | -| **Contacts + media** | [contacts](docs/contacts.md) · [media](docs/media.md) · [export](docs/export.md) | `contacts list` · `contacts search` · `media download` · `export` | -| **Automation** | [watch](docs/watch.md) · [rpc](docs/rpc.md) · [api](docs/api.md) | `watch` · `watch --webhook` · `rpc` · `man` · `api get` · `api post` · `api request` | -| **Maintenance** | [config](docs/config.md) · [update](docs/update.md) | `update` · `config` · `completion` · `docs` · `version` | - -Use `beeper docs` to open the CLI docs and `beeper man` to print the local -command manual. - -## Configuration - -Default Beeper Client API target: `http://127.0.0.1:23373`. CLI configuration is -stored under your user config dir; print it with `beeper config path`. - -**Global flags:** `--base-url`, `--target`, `--json`, `--events`, -`--full`, `--timeout`, `--read-only`, `--debug`, `--yes`, `--quiet`. - -**Environment overrides:** - -| Variable | Effect | -| --- | --- | -| `BEEPER_ACCESS_TOKEN` | Bearer token for the selected target. Overrides stored OAuth login. | -| `BEEPER_DESKTOP_BASE_URL` | Beeper Client API base URL (Desktop or Server). Defaults to `http://127.0.0.1:23373`. | -| `BEEPER_READONLY` | `1`/`true`/`yes`/`on` enables read-only mode globally. | -| `BEEPER_CLI_CONFIG_DIR` | Override config directory for testing or isolated profiles. | - -## Exit codes - -| Code | Meaning | -| --- | --- | -| `0` | Success. | -| `1` | Generic runtime error. | -| `2` | Usage error (parsing, validation, missing required flag/arg, read-only refusal). | -| `3` | Auth required (no stored token; sign in or set `BEEPER_ACCESS_TOKEN`). | -| `4` | Target/account not ready (`doctor` reports this when readiness is not `ready`). | -| `5` | Selector matched nothing (unknown target, account, chat, contact). | -| `6` | Ambiguous selector (multiple matches; pass an exact ID or `--pick N`). | - -JSON output preserves the same envelope on failure: `{"success":false,"data":null,"error":"...","exitCode":N}` written to stderr. - -## Addressing - -- Chat arguments accept numeric local chat IDs, full Beeper/Matrix chat IDs, iMessage chat IDs, exact titles, or search text. -- For scripts on the same target/profile, prefer the numeric local chat ID shown by `beeper chats list`; use the full Beeper/Matrix chat ID when the selector must work across targets or profiles. -- Numeric local chat IDs come from the selected Desktop database. Treat them as local to that target/profile. -- Ambiguous chat matches return numbered choices; pass `--pick N` to select one. -- Account arguments accept account IDs, network names, bridge type/id, or account user identity. -- Account filters can expand a network name to multiple matching accounts. -- `contacts search` and `chats start` can search across all accounts when `--account` is omitted. -- `contacts list` accepts the same account selectors as other account-scoped commands. - -## Output and scripting - -Most commands support: - -- app-like text by default, optimized for scanning chats, messages, contacts, accounts, and media -- `--json` for `{"success":true,"data":...,"error":null}` output on stdout -- `--events` for NDJSON lifecycle events on stderr from long-running commands -- `--read-only` to reject commands that modify Beeper or local CLI state -- `--full` to disable truncation -- `--debug` for SDK debug logging -- `--target` or `--base-url` to point at a different target - -`man --json` prints a compact command manifest for tools and agents. -`rpc` runs newline-delimited JSON command RPC over stdin/stdout. - -## Raw API access - -Raw Beeper Client API calls live under `api`, so scripts can reach a new -endpoint before a workflow command exists: - -```sh -beeper api get /v1/info -beeper api post /v1/messages/{chatID}/send --body '{"text":"hello"}' -beeper api request DELETE /v1/chats/abc/messages/def/reactions --body '{"reactionKey":"👍"}' -``` - -## Plugins - -Beeper CLI supports optional oclif plugins. List recommended Beeper plugins: - -```sh -beeper plugins available -``` - -Install a published plugin: - -```sh -beeper plugins install @beeper/cli-plugin-cloudflare -``` - -For plugin development, import from `@beeper/cli/plugin-sdk` and expose oclif -commands from your package. Link a local plugin while working on it: - -```sh -beeper plugins link ./packages/cli-plugin-cloudflare -beeper targets tunnel --help -``` - -First-party optional plugins: - -| Package | Adds | -| --- | --- | -| `@beeper/cli-plugin-cloudflare` | `targets tunnel` for exposing a selected Beeper target through Cloudflare Tunnel. | - - -## Full command reference - -The complete `beeper` command summary and per-command reference (every flag, -arg, and example) lives in [`packages/cli/README.md`](packages/cli/README.md). -For terminal-side reference, `beeper man` prints the same manual locally and -`beeper man --json` emits a tool manifest for agents. - -## Inspiration - -- [wacli](https://wacli.sh/) — scriptable WhatsApp CLI whose command-line product shape we borrow from. - -## License - -MIT — see [`packages/cli/LICENSE`](packages/cli/LICENSE). +Use [packages/cli/README.md](packages/cli/README.md) for install, command, safety-profile, and development notes. diff --git a/bun.lock b/bun.lock index 63c3b491..9dc2bba0 100644 --- a/bun.lock +++ b/bun.lock @@ -3,1420 +3,111 @@ "configVersion": 1, "workspaces": { "": { - "name": "desktop-api-cli-monorepo", - "devDependencies": { - "@changesets/changelog-github": "^0.6.0", - "@changesets/cli": "^2.31.0", - "@types/bun": "^1.3.3", - "@types/node": "^20.0.0", - "eslint": "^9.39.4", - "eslint-config-oclif": "^6.0.165", - "eslint-config-prettier": "^10.1.8", - "tsdown": "^0.21.10", - "typescript": "^5.7.2", - }, + "name": "beeper-cli-monorepo", }, "packages/cli": { - "name": "@beeper/cli", - "version": "0.6.1", + "name": "beeper-cli", + "version": "0.6.2", "bin": { - "beeper": "bin/run.js", + "beeper": "bin/cli.js", }, "dependencies": { "@beeper/desktop-api": "github:beeper/desktop-api-js#next", - "@oclif/core": "^4.11.2", - "@oclif/plugin-autocomplete": "^3.2.49", - "@oclif/plugin-help": "^6.2.48", - "@oclif/plugin-not-found": "^3.2.85", - "@oclif/plugin-plugins": "^5.4.67", - "@oclif/plugin-warn-if-update-available": "^3.1.49", - "figures": "^6.1.0", - "ink": "^7.0.3", - "ink-spinner": "^5.0.0", "qrcode": "1.5.4", - "react": "^19.2.6", "ws": "^8.20.1", + "yaml": "^2.9.0", }, "devDependencies": { "@types/bun": "^1.3.3", "@types/node": "^20.0.0", - "@types/react": "^19.2.14", + "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "typescript": "^5.7.2", }, }, - "packages/cli-plugin-cloudflare": { - "name": "@beeper/cli-plugin-cloudflare", - "version": "0.6.0", - "dependencies": { - "@beeper/cli": "workspace:*", - "@oclif/core": "^4.11.2", - }, - "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.7.2", - }, - }, - "packages/npm": { - "name": "beeper-cli", - "version": "0.6.1", - "bin": { - "beeper": "./bin/beeper.js", - }, - }, }, "trustedDependencies": [ "@beeper/desktop-api", ], "packages": { - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "6.2.3", "is-fullwidth-code-point": "5.1.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], - - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "7.28.5", "js-tokens": "4.0.0", "picocolors": "1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/generator": ["@babel/generator@8.0.0-rc.3", "", { "dependencies": { "@babel/parser": "8.0.0-rc.3", "@babel/types": "8.0.0-rc.3", "@jridgewell/gen-mapping": "0.3.13", "@jridgewell/trace-mapping": "0.3.31", "@types/jsesc": "2.5.1", "jsesc": "3.1.0" } }, "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.5", "", {}, "sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/parser": ["@babel/parser@8.0.0-rc.3", "", { "dependencies": { "@babel/types": "8.0.0-rc.3" }, "bin": "./bin/babel-parser.js" }, "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ=="], - - "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - - "@babel/types": ["@babel/types@8.0.0-rc.3", "", { "dependencies": { "@babel/helper-string-parser": "8.0.0-rc.5", "@babel/helper-validator-identifier": "8.0.0-rc.3" } }, "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q=="], - - "@beeper/cli": ["@beeper/cli@workspace:packages/cli"], - - "@beeper/cli-plugin-cloudflare": ["@beeper/cli-plugin-cloudflare@workspace:packages/cli-plugin-cloudflare"], - - "@beeper/desktop-api": ["@beeper/desktop-api@github:beeper/desktop-api-js#b9c1714", {}, "beeper-desktop-api-js-b9c1714", "sha512-Qlxz1R4ppJd6vPuzgKhJqEC2WpNPtdi6n9ONieX6S5mAC0wyWUjZP/SogeTcbDf8vQ2Sl+ET4lzRN4ZcKHkqww=="], - - "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "3.1.4", "@changesets/get-version-range-type": "0.4.0", "@changesets/git": "3.0.4", "@changesets/should-skip-package": "0.1.2", "@changesets/types": "6.1.0", "@manypkg/get-packages": "1.1.3", "detect-indent": "6.1.0", "fs-extra": "7.0.1", "lodash.startcase": "4.4.0", "outdent": "0.5.0", "prettier": "2.8.8", "resolve-from": "5.0.0", "semver": "7.8.0" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="], - - "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "0.2.0", "@changesets/get-dependents-graph": "2.1.4", "@changesets/should-skip-package": "0.1.2", "@changesets/types": "6.1.0", "@manypkg/get-packages": "1.1.3", "semver": "7.8.0" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="], - - "@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="], - - "@changesets/changelog-github": ["@changesets/changelog-github@0.6.0", "", { "dependencies": { "@changesets/get-github-info": "0.8.0", "@changesets/types": "6.1.0", "dotenv": "8.6.0" } }, "sha512-wA2/y4hR/A1K411cCT75rz0d46Iezxp1WYRFoFJDIUpkQ6oDBAIUiU7BZkDCmYgz0NBl94X1lgcZO+mHoiHnFg=="], - - "@changesets/cli": ["@changesets/cli@2.31.0", "", { "dependencies": { "@changesets/apply-release-plan": "7.1.1", "@changesets/assemble-release-plan": "6.0.10", "@changesets/changelog-git": "0.2.1", "@changesets/config": "3.1.4", "@changesets/errors": "0.2.0", "@changesets/get-dependents-graph": "2.1.4", "@changesets/get-release-plan": "4.0.16", "@changesets/git": "3.0.4", "@changesets/logger": "0.1.1", "@changesets/pre": "2.0.2", "@changesets/read": "0.6.7", "@changesets/should-skip-package": "0.1.2", "@changesets/types": "6.1.0", "@changesets/write": "0.4.0", "@inquirer/external-editor": "1.0.3", "@manypkg/get-packages": "1.1.3", "ansi-colors": "4.1.3", "enquirer": "2.4.1", "fs-extra": "7.0.1", "mri": "1.2.0", "package-manager-detector": "0.2.11", "picocolors": "1.1.1", "resolve-from": "5.0.0", "semver": "7.8.0", "spawndamnit": "3.0.1", "term-size": "2.2.1" }, "bin": { "changeset": "bin.js" } }, "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg=="], - - "@changesets/config": ["@changesets/config@3.1.4", "", { "dependencies": { "@changesets/errors": "0.2.0", "@changesets/get-dependents-graph": "2.1.4", "@changesets/logger": "0.1.1", "@changesets/should-skip-package": "0.1.2", "@changesets/types": "6.1.0", "@manypkg/get-packages": "1.1.3", "fs-extra": "7.0.1", "micromatch": "4.0.8" } }, "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q=="], - - "@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "0.1.7" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="], - - "@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.4", "", { "dependencies": { "@changesets/types": "6.1.0", "@manypkg/get-packages": "1.1.3", "picocolors": "1.1.1", "semver": "7.8.0" } }, "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg=="], - - "@changesets/get-github-info": ["@changesets/get-github-info@0.8.0", "", { "dependencies": { "dataloader": "1.4.0", "node-fetch": "2.7.0" } }, "sha512-cRnC+xdF0JIik7coko3iUP9qbnfi1iJQ3sAa6dE+Tx3+ET8bjFEm63PA4WEohgjYcmsOikPHWzPsMWWiZmntOQ=="], - - "@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.16", "", { "dependencies": { "@changesets/assemble-release-plan": "6.0.10", "@changesets/config": "3.1.4", "@changesets/pre": "2.0.2", "@changesets/read": "0.6.7", "@changesets/types": "6.1.0", "@manypkg/get-packages": "1.1.3" } }, "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g=="], - - "@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="], - - "@changesets/git": ["@changesets/git@3.0.4", "", { "dependencies": { "@changesets/errors": "0.2.0", "@manypkg/get-packages": "1.1.3", "is-subdir": "1.2.0", "micromatch": "4.0.8", "spawndamnit": "3.0.1" } }, "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw=="], - - "@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "1.1.1" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="], - - "@changesets/parse": ["@changesets/parse@0.4.3", "", { "dependencies": { "@changesets/types": "6.1.0", "js-yaml": "4.1.1" } }, "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A=="], - - "@changesets/pre": ["@changesets/pre@2.0.2", "", { "dependencies": { "@changesets/errors": "0.2.0", "@changesets/types": "6.1.0", "@manypkg/get-packages": "1.1.3", "fs-extra": "7.0.1" } }, "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug=="], - - "@changesets/read": ["@changesets/read@0.6.7", "", { "dependencies": { "@changesets/git": "3.0.4", "@changesets/logger": "0.1.1", "@changesets/parse": "0.4.3", "@changesets/types": "6.1.0", "fs-extra": "7.0.1", "p-filter": "2.1.0", "picocolors": "1.1.1" } }, "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA=="], - - "@changesets/should-skip-package": ["@changesets/should-skip-package@0.1.2", "", { "dependencies": { "@changesets/types": "6.1.0", "@manypkg/get-packages": "1.1.3" } }, "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw=="], - - "@changesets/types": ["@changesets/types@6.1.0", "", {}, "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA=="], - - "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "6.1.0", "fs-extra": "7.0.1", "human-id": "4.1.3", "prettier": "2.8.8" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "2.8.1" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "@es-joy/jsdoccomment": ["@es-joy/jsdoccomment@0.50.2", "", { "dependencies": { "@types/estree": "1.0.9", "@typescript-eslint/types": "8.59.3", "comment-parser": "1.4.1", "esquery": "1.7.0", "jsdoc-type-pratt-parser": "4.1.0" } }, "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA=="], - - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "3.4.3" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - - "@eslint/compat": ["@eslint/compat@1.4.1", "", { "dependencies": { "@eslint/core": "0.17.0" }, "optionalDependencies": { "eslint": "9.39.4" } }, "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w=="], - - "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "2.1.7", "debug": "4.4.3", "minimatch": "3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], - - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], - - "@eslint/css": ["@eslint/css@0.10.0", "", { "dependencies": { "@eslint/core": "0.14.0", "@eslint/css-tree": "3.6.9", "@eslint/plugin-kit": "0.3.5" } }, "sha512-pHoYRWS08oeU0qVez1pZCcbqHzoJnM5VMtrxH2nWDJ0ukq9DkwWV1BTY+PWK+eWBbndN9W0O9WjJTyAHsDoPOg=="], - - "@eslint/css-tree": ["@eslint/css-tree@3.6.9", "", { "dependencies": { "mdn-data": "2.23.0", "source-map-js": "1.2.1" } }, "sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA=="], - - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "6.15.0", "debug": "4.4.3", "espree": "10.4.0", "globals": "14.0.0", "ignore": "5.3.2", "import-fresh": "3.3.1", "js-yaml": "4.1.1", "minimatch": "3.1.5", "strip-json-comments": "3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], - - "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], - - "@eslint/json": ["@eslint/json@0.13.2", "", { "dependencies": { "@eslint/core": "0.15.2", "@eslint/plugin-kit": "0.3.5", "@humanwhocodes/momoa": "3.3.10", "natural-compare": "1.4.0" } }, "sha512-yWLyRE18rHgHXhWigRpiyv1LDPkvWtC6oa7QHXW7YdP6gosJoq7BiLZW2yCs9U7zN7X4U3ZeOJjepA10XAOIMw=="], - - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "0.17.0", "levn": "0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - - "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], - - "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "0.19.2", "@humanfs/types": "0.15.0", "@humanwhocodes/retry": "0.4.3" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], - - "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - - "@humanwhocodes/momoa": ["@humanwhocodes/momoa@3.3.10", "", {}, "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ=="], - - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - - "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], - - "@inquirer/checkbox": ["@inquirer/checkbox@4.3.2", "", { "dependencies": { "@inquirer/ansi": "1.0.2", "@inquirer/core": "10.3.2", "@inquirer/figures": "1.0.15", "@inquirer/type": "3.0.10", "yoctocolors-cjs": "2.1.3" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA=="], - - "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "10.3.2", "@inquirer/type": "3.0.10" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], - - "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "1.0.2", "@inquirer/figures": "1.0.15", "@inquirer/type": "3.0.10", "cli-width": "4.1.0", "mute-stream": "2.0.0", "signal-exit": "4.1.0", "wrap-ansi": "6.2.0", "yoctocolors-cjs": "2.1.3" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], - - "@inquirer/editor": ["@inquirer/editor@4.2.23", "", { "dependencies": { "@inquirer/core": "10.3.2", "@inquirer/external-editor": "1.0.3", "@inquirer/type": "3.0.10" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ=="], - - "@inquirer/expand": ["@inquirer/expand@4.0.23", "", { "dependencies": { "@inquirer/core": "10.3.2", "@inquirer/type": "3.0.10", "yoctocolors-cjs": "2.1.3" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew=="], - - "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "2.1.1", "iconv-lite": "0.7.2" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], - - "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], - - "@inquirer/input": ["@inquirer/input@4.3.1", "", { "dependencies": { "@inquirer/core": "10.3.2", "@inquirer/type": "3.0.10" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g=="], - - "@inquirer/number": ["@inquirer/number@3.0.23", "", { "dependencies": { "@inquirer/core": "10.3.2", "@inquirer/type": "3.0.10" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg=="], - - "@inquirer/password": ["@inquirer/password@4.0.23", "", { "dependencies": { "@inquirer/ansi": "1.0.2", "@inquirer/core": "10.3.2", "@inquirer/type": "3.0.10" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA=="], - - "@inquirer/prompts": ["@inquirer/prompts@7.10.1", "", { "dependencies": { "@inquirer/checkbox": "4.3.2", "@inquirer/confirm": "5.1.21", "@inquirer/editor": "4.2.23", "@inquirer/expand": "4.0.23", "@inquirer/input": "4.3.1", "@inquirer/number": "3.0.23", "@inquirer/password": "4.0.23", "@inquirer/rawlist": "4.1.11", "@inquirer/search": "3.2.2", "@inquirer/select": "4.4.2" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg=="], - - "@inquirer/rawlist": ["@inquirer/rawlist@4.1.11", "", { "dependencies": { "@inquirer/core": "10.3.2", "@inquirer/type": "3.0.10", "yoctocolors-cjs": "2.1.3" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw=="], - - "@inquirer/search": ["@inquirer/search@3.2.2", "", { "dependencies": { "@inquirer/core": "10.3.2", "@inquirer/figures": "1.0.15", "@inquirer/type": "3.0.10", "yoctocolors-cjs": "2.1.3" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA=="], - - "@inquirer/select": ["@inquirer/select@4.4.2", "", { "dependencies": { "@inquirer/ansi": "1.0.2", "@inquirer/core": "10.3.2", "@inquirer/figures": "1.0.15", "@inquirer/type": "3.0.10", "yoctocolors-cjs": "2.1.3" }, "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w=="], - - "@inquirer/type": ["@inquirer/type@3.0.10", "", { "optionalDependencies": { "@types/node": "20.19.41" } }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.2", "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "7.29.2", "@types/node": "12.20.55", "find-up": "4.1.0", "fs-extra": "8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], - - "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "7.29.2", "@changesets/types": "4.1.0", "@manypkg/find-root": "1.1.0", "fs-extra": "8.1.0", "globby": "11.1.0", "read-yaml-file": "1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], - - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "0.10.2" }, "peerDependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.20.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - - "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], - - "@oclif/core": ["@oclif/core@4.11.3", "", { "dependencies": { "ansi-escapes": "4.3.2", "ansis": "3.17.0", "clean-stack": "3.0.1", "cli-spinners": "2.9.2", "debug": "4.4.3", "ejs": "3.1.10", "get-package-type": "0.1.0", "indent-string": "4.0.0", "is-wsl": "2.2.0", "lilconfig": "3.1.3", "minimatch": "10.2.5", "semver": "7.8.0", "string-width": "4.2.3", "supports-color": "8.1.1", "tinyglobby": "0.2.16", "widest-line": "3.1.0", "wordwrap": "1.0.0", "wrap-ansi": "7.0.0" } }, "sha512-gQCSYAtUhJilGKaSaZhqejH9X1dDu+jWQjLmtGOgN/XcKaAEPPSeT2mu1UvlvtPox1/NNRdlBcUa8KRKo2HnJQ=="], - - "@oclif/plugin-autocomplete": ["@oclif/plugin-autocomplete@3.2.49", "", { "dependencies": { "@oclif/core": "4.11.3", "ansis": "3.17.0", "debug": "4.4.3", "ejs": "3.1.10" } }, "sha512-+rrAZ468bW/B9uVrn6sEnFYepy3M1N/BWht8mHzhFIFCIduPSoE+8MweROxZLOGBZrXGWt0iavuPQmy0eaXRfQ=="], - - "@oclif/plugin-help": ["@oclif/plugin-help@6.2.48", "", { "dependencies": { "@oclif/core": "4.11.3" } }, "sha512-nvGLBtUZUWrHfoAEDRsRZUHKVwptyZ6F+MErdVRLQBo3dja0GCZH8DE33dA7mBux2KOmbxGqop15gyud9HZYhQ=="], - - "@oclif/plugin-not-found": ["@oclif/plugin-not-found@3.2.85", "", { "dependencies": { "@inquirer/prompts": "7.10.1", "@oclif/core": "4.11.3", "ansis": "3.17.0", "fast-levenshtein": "3.0.0" } }, "sha512-Si18rRKWknlvQ5anmFbQz9oKBae5/l/Npreuf05xdoNWfOV1J97Z7cpzqBlHbldmxCIiDRgmDKuCBBi4XN6ACA=="], - - "@oclif/plugin-plugins": ["@oclif/plugin-plugins@5.4.67", "", { "dependencies": { "@oclif/core": "4.11.3", "ansis": "3.17.0", "debug": "4.4.3", "npm": "11.14.1", "npm-package-arg": "11.0.3", "npm-run-path": "5.3.0", "object-treeify": "4.0.1", "semver": "7.8.0", "validate-npm-package-name": "5.0.1", "which": "4.0.0", "yarn": "1.22.22" } }, "sha512-hNSNSo3kGxWsU7aRICN82bmpgPkQmjr+SAMrFnlH3v9UchoIG9bBoj5DSSCsoDAShIU118h8xRBOhRyEzq4+Qg=="], - - "@oclif/plugin-warn-if-update-available": ["@oclif/plugin-warn-if-update-available@3.1.65", "", { "dependencies": { "@oclif/core": "4.11.3", "ansis": "3.17.0", "debug": "4.4.3", "http-call": "5.3.0", "lodash": "4.18.1", "registry-auth-token": "5.1.1" } }, "sha512-HcSJc8SeCVUBHwc063xDL0LcpdjcamAISlisSX14VDDYQayMantvtVNOo9PmciwYpXRXfAykeH1z066YkA9JvQ=="], - - "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], - - "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], - - "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="], - - "@pnpm/npm-conf": ["@pnpm/npm-conf@3.0.2", "", { "dependencies": { "@pnpm/config.env-replace": "1.1.0", "@pnpm/network.ca-file": "1.0.2", "config-chain": "1.1.13" } }, "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA=="], - - "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], - - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="], - - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="], - - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="], - - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], - - "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], - - "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@3.1.0", "", { "dependencies": { "@typescript-eslint/utils": "8.59.3", "eslint-visitor-keys": "4.2.1", "espree": "10.4.0", "estraverse": "5.3.0", "picomatch": "4.0.4" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g=="], - - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@beeper/desktop-api": ["@beeper/desktop-api@github:beeper/desktop-api-js#1d94580", {}, "beeper-desktop-api-js-1d94580", "sha512-HVItzImUS2nsk45TXPQEAIhqWU0kK+Og72Wg0mvJbjzy9BqO9f+cQWIcf73B4TCoxb/MEIYB6p4K0nrYBHS4bQ=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - - "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - - "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@20.19.41", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ=="], - "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], - - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "3.2.3" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "20.19.41" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "7.0.5", "natural-compare": "1.4.0", "ts-api-utils": "2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "8.59.3", "eslint": "9.39.4", "typescript": "5.9.3" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "4.4.3" }, "peerDependencies": { "eslint": "9.39.4", "typescript": "5.9.3" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "debug": "4.4.3" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "4.4.3", "ts-api-utils": "2.5.0" }, "peerDependencies": { "eslint": "9.39.4", "typescript": "5.9.3" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "4.4.3", "minimatch": "10.2.5", "semver": "7.8.0", "tinyglobby": "0.2.16", "ts-api-utils": "2.5.0" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "9.39.4", "typescript": "5.9.3" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "5.0.1" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], - - "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], - - "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], - - "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="], - - "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="], - - "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="], - - "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="], - - "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="], - - "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="], - - "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="], - - "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="], - - "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="], - - "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="], - - "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="], - - "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="], - - "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="], - - "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "0.2.12" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="], - - "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="], - - "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="], - - "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], - - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "8.16.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - - "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - - "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - - "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "ansis": ["ansis@4.3.0", "", {}, "sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg=="], - - "are-docs-informative": ["are-docs-informative@0.0.2", "", {}, "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "is-array-buffer": "3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], - - "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-abstract": "1.24.2", "es-object-atoms": "1.1.1", "get-intrinsic": "1.3.0", "is-string": "1.1.1", "math-intrinsics": "1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], - - "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], - - "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-abstract": "1.24.2", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "es-shim-unscopables": "1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], - - "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "1.0.9", "define-properties": "1.2.1", "es-abstract": "1.24.2", "es-shim-unscopables": "1.1.0" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], - - "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "1.0.9", "define-properties": "1.2.1", "es-abstract": "1.24.2", "es-shim-unscopables": "1.1.0" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], - - "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "1.0.2", "call-bind": "1.0.9", "define-properties": "1.2.1", "es-abstract": "1.24.2", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "is-array-buffer": "3.0.5" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], - - "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "8.0.0-rc.3", "estree-walker": "3.0.3", "pathe": "2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], - - "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], - - "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], - - "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "1.1.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.30", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg=="], - - "beeper-cli": ["beeper-cli@workspace:packages/npm"], - - "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "1.0.2" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], - - "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], - - "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "2.10.30", "caniuse-lite": "1.0.30001793", "electron-to-chromium": "1.5.357", "node-releases": "2.0.44", "update-browserslist-db": "1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - - "builtin-modules": ["builtin-modules@3.3.0", "", {}, "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="], - - "builtins": ["builtins@5.1.0", "", { "dependencies": { "semver": "7.8.0" } }, "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg=="], + "beeper-cli": ["beeper-cli@workspace:packages/cli"], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], - - "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "get-intrinsic": "1.3.0", "set-function-length": "1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "get-intrinsic": "1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], - - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], - - "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], - - "clean-regexp": ["clean-regexp@1.0.0", "", { "dependencies": { "escape-string-regexp": "1.0.5" } }, "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw=="], - - "clean-stack": ["clean-stack@3.0.1", "", { "dependencies": { "escape-string-regexp": "4.0.0" } }, "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg=="], - - "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], - - "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], - - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - - "cli-truncate": ["cli-truncate@6.0.0", "", { "dependencies": { "slice-ansi": "9.0.0", "string-width": "8.2.1" } }, "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA=="], - - "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], - "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], - "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "comment-parser": ["comment-parser@1.4.1", "", {}, "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "1.3.8", "proto-list": "1.2.4" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], - - "confusing-browser-globals": ["confusing-browser-globals@1.0.11", "", {}, "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - - "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], - - "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "4.28.2" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "3.1.1", "shebang-command": "2.0.0", "which": "2.0.2" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-data-view": "1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], - - "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-data-view": "1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], - - "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-data-view": "1.0.2" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], - - "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" }, "optionalDependencies": { "supports-color": "8.1.1" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "1.0.1", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - - "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "1.1.4", "has-property-descriptors": "1.0.2", "object-keys": "1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - - "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], - - "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], - "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], - "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], - - "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "2.0.3" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], - - "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="], - - "dts-resolver": ["dts-resolver@2.1.3", "", {}, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "10.9.4" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.357", "", {}, "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g=="], - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "5.0.0", "path-exists": "4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "4.2.11", "tapable": "2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="], - - "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "4.1.3", "strip-ansi": "6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - - "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], - - "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "1.0.2", "arraybuffer.prototype.slice": "1.0.4", "available-typed-arrays": "1.0.7", "call-bind": "1.0.9", "call-bound": "1.0.4", "data-view-buffer": "1.0.2", "data-view-byte-length": "1.0.2", "data-view-byte-offset": "1.0.1", "es-define-property": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "es-set-tostringtag": "2.1.0", "es-to-primitive": "1.3.0", "function.prototype.name": "1.1.8", "get-intrinsic": "1.3.0", "get-proto": "1.0.1", "get-symbol-description": "1.1.0", "globalthis": "1.0.4", "gopd": "1.2.0", "has-property-descriptors": "1.0.2", "has-proto": "1.2.0", "has-symbols": "1.1.0", "hasown": "2.0.3", "internal-slot": "1.1.0", "is-array-buffer": "3.0.5", "is-callable": "1.2.7", "is-data-view": "1.0.2", "is-negative-zero": "2.0.3", "is-regex": "1.2.1", "is-set": "2.0.3", "is-shared-array-buffer": "1.0.4", "is-string": "1.1.1", "is-typed-array": "1.1.15", "is-weakref": "1.1.1", "math-intrinsics": "1.1.0", "object-inspect": "1.13.4", "object-keys": "1.1.1", "object.assign": "4.1.7", "own-keys": "1.0.1", "regexp.prototype.flags": "1.5.4", "safe-array-concat": "1.1.4", "safe-push-apply": "1.0.0", "safe-regex-test": "1.1.0", "set-proto": "1.0.0", "stop-iteration-iterator": "1.1.0", "string.prototype.trim": "1.2.10", "string.prototype.trimend": "1.0.9", "string.prototype.trimstart": "1.0.8", "typed-array-buffer": "1.0.3", "typed-array-byte-length": "1.0.3", "typed-array-byte-offset": "1.0.4", "typed-array-length": "1.0.7", "unbox-primitive": "1.1.0", "which-typed-array": "1.1.20" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "has-tostringtag": "1.0.2", "hasown": "2.0.3" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "2.0.3" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "1.2.7", "is-date-object": "1.1.0", "is-symbol": "1.1.1" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "2.2.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "2.3.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "@eslint-community/regexpp": "4.12.2", "@eslint/config-array": "0.21.2", "@eslint/config-helpers": "0.4.2", "@eslint/core": "0.17.0", "@eslint/eslintrc": "3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "0.4.1", "@humanfs/node": "0.16.8", "@humanwhocodes/module-importer": "1.0.1", "@humanwhocodes/retry": "0.4.3", "@types/estree": "1.0.9", "ajv": "6.15.0", "chalk": "4.1.2", "cross-spawn": "7.0.6", "debug": "4.4.3", "escape-string-regexp": "4.0.0", "eslint-scope": "8.4.0", "eslint-visitor-keys": "4.2.1", "espree": "10.4.0", "esquery": "1.7.0", "esutils": "2.0.3", "fast-deep-equal": "3.1.3", "file-entry-cache": "8.0.0", "find-up": "5.0.0", "glob-parent": "6.0.2", "ignore": "5.3.2", "imurmurhash": "0.1.4", "is-glob": "4.0.3", "json-stable-stringify-without-jsonify": "1.0.1", "lodash.merge": "4.6.2", "minimatch": "3.1.5", "natural-compare": "1.4.0", "optionator": "0.9.4" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], - "eslint-compat-utils": ["eslint-compat-utils@0.5.1", "", { "dependencies": { "semver": "7.8.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], - "eslint-config-oclif": ["eslint-config-oclif@6.0.165", "", { "dependencies": { "@eslint/compat": "1.4.1", "@eslint/eslintrc": "3.3.5", "@eslint/js": "9.39.4", "@stylistic/eslint-plugin": "3.1.0", "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "eslint-config-oclif": "5.2.2", "eslint-config-xo": "0.49.0", "eslint-config-xo-space": "0.35.0", "eslint-import-resolver-typescript": "3.10.1", "eslint-plugin-import": "2.32.0", "eslint-plugin-jsdoc": "50.8.0", "eslint-plugin-mocha": "10.5.0", "eslint-plugin-n": "17.24.0", "eslint-plugin-perfectionist": "4.15.1", "eslint-plugin-unicorn": "56.0.1", "typescript-eslint": "8.59.3" } }, "sha512-kbzxHAXEHKTY2X4UVVu4cPjjxP2YsVEsgYaXJDakpBEoAUEUSnYCKOOoxrIHl1egDM3q07kOZnBPkwYQ+nR4Og=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": "9.39.4" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], - "eslint-config-xo": ["eslint-config-xo@0.49.0", "", { "dependencies": { "@eslint/css": "0.10.0", "@eslint/json": "0.13.2", "@stylistic/eslint-plugin": "5.10.0", "confusing-browser-globals": "1.0.11", "globals": "16.5.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-hGtD689+fdJxggx1QbEjWfgGOsTasmYqtfk3Rsxru9QyKg2iOhXO2fvR9C7ck8AGw+n2wy6FsA8/MBIzznt5/Q=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], - "eslint-config-xo-space": ["eslint-config-xo-space@0.35.0", "", { "dependencies": { "eslint-config-xo": "0.44.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-+79iVcoLi3PvGcjqYDpSPzbLfqYpNcMlhsCBRsnmDoHAn4npJG6YxmHpelQKpXM7v/EeZTUKb4e1xotWlei8KA=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "8.0.0", "is-fullwidth-code-point": "3.0.0", "strip-ansi": "6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "3.2.7", "is-core-module": "2.16.2", "resolve": "2.0.0-next.7" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "4.4.3", "get-tsconfig": "4.14.0", "is-bun-module": "2.0.0", "stable-hash": "0.0.5", "tinyglobby": "0.2.16", "unrs-resolver": "1.11.1" }, "optionalDependencies": { "eslint-plugin-import": "2.32.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "3.2.7" }, "optionalDependencies": { "@typescript-eslint/parser": "8.59.3", "eslint": "9.39.4", "eslint-import-resolver-node": "0.3.10", "eslint-import-resolver-typescript": "3.10.1" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "eslint-plugin-es": ["eslint-plugin-es@4.1.0", "", { "dependencies": { "eslint-utils": "2.1.0", "regexpp": "3.2.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], - "eslint-plugin-es-x": ["eslint-plugin-es-x@7.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "@eslint-community/regexpp": "4.12.2", "eslint-compat-utils": "0.5.1" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "1.1.0", "array-includes": "3.1.9", "array.prototype.findlastindex": "1.2.6", "array.prototype.flat": "1.3.3", "array.prototype.flatmap": "1.3.3", "debug": "3.2.7", "doctrine": "2.1.0", "eslint-import-resolver-node": "0.3.10", "eslint-module-utils": "2.12.1", "hasown": "2.0.3", "is-core-module": "2.16.2", "is-glob": "4.0.3", "minimatch": "3.1.5", "object.fromentries": "2.0.8", "object.groupby": "1.0.3", "object.values": "1.2.1", "semver": "6.3.1", "string.prototype.trimend": "1.0.9", "tsconfig-paths": "3.15.0" }, "optionalDependencies": { "@typescript-eslint/parser": "8.59.3" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + "ws": ["ws@8.20.1", "", {}, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], - "eslint-plugin-jsdoc": ["eslint-plugin-jsdoc@50.8.0", "", { "dependencies": { "@es-joy/jsdoccomment": "0.50.2", "are-docs-informative": "0.0.2", "comment-parser": "1.4.1", "debug": "4.4.3", "escape-string-regexp": "4.0.0", "espree": "10.4.0", "esquery": "1.7.0", "parse-imports-exports": "0.2.4", "semver": "7.8.0", "spdx-expression-parse": "4.0.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-UyGb5755LMFWPrZTEqqvTJ3urLz1iqj+bYOHFNag+sw3NvaMWP9K2z+uIn37XfNALmQLQyrBlJ5mkiVPL7ADEg=="], + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], - "eslint-plugin-mocha": ["eslint-plugin-mocha@10.5.0", "", { "dependencies": { "eslint-utils": "3.0.0", "globals": "13.24.0", "rambda": "7.5.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-F2ALmQVPT1GoP27O1JTZGrV9Pqg8k79OeIuvw63UxMtQKREZtmkK1NFgkZQ2TW7L2JSSFKHFPTtHu5z8R9QNRw=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "eslint-plugin-n": ["eslint-plugin-n@17.24.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "enhanced-resolve": "5.21.3", "eslint-plugin-es-x": "7.8.0", "get-tsconfig": "4.14.0", "globals": "15.15.0", "globrex": "0.1.2", "ignore": "5.3.2", "semver": "7.8.0", "ts-declaration-location": "1.0.7" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw=="], - - "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@4.15.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/utils": "8.59.3", "natural-orderby": "5.0.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-MHF0cBoOG0XyBf7G0EAFCuJJu4I18wy0zAoT1OHfx2o6EOx1EFTIzr2HGeuZa1kDcusoX0xJ9V7oZmaeFd773Q=="], - - "eslint-plugin-unicorn": ["eslint-plugin-unicorn@56.0.1", "", { "dependencies": { "@babel/helper-validator-identifier": "7.28.5", "@eslint-community/eslint-utils": "4.9.1", "ci-info": "4.4.0", "clean-regexp": "1.0.0", "core-js-compat": "3.49.0", "esquery": "1.7.0", "globals": "15.15.0", "indent-string": "4.0.0", "is-builtin-module": "3.2.1", "jsesc": "3.1.0", "pluralize": "8.0.0", "read-pkg-up": "7.0.1", "regexp-tree": "0.1.27", "regjsparser": "0.10.0", "semver": "7.8.0", "strip-indent": "3.0.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-FwVV0Uwf8XPfVnKSGpMg7NtlZh0G0gBarCaFcMUOoqPxXryxdYxTRRv4kH6B9TFCVIrjRXG+emcxIk2ayZilog=="], - - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "4.3.0", "estraverse": "5.3.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], - - "eslint-utils": ["eslint-utils@3.0.0", "", { "dependencies": { "eslint-visitor-keys": "2.1.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "8.16.0", "acorn-jsx": "5.3.2", "eslint-visitor-keys": "4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "1.0.9" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "@nodelib/fs.walk": "1.2.8", "glob-parent": "5.1.2", "merge2": "1.4.1", "micromatch": "4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@3.0.0", "", { "dependencies": { "fastest-levenshtein": "1.0.16" } }, "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ=="], - - "fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="], - - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "1.1.0" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - - "fdir": ["fdir@6.5.0", "", { "optionalDependencies": { "picomatch": "4.0.4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "2.1.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], - - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "4.0.1" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - - "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "5.1.9" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], - - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "6.0.0", "path-exists": "4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "3.4.2", "keyv": "4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - - "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - - "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], - - "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "4.2.11", "jsonfile": "4.0.0", "universalify": "0.1.2" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "define-properties": "1.2.1", "functions-have-names": "1.2.3", "hasown": "2.0.3", "is-callable": "1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], - - "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], - - "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], - - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - - "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "function-bind": "1.1.2", "get-proto": "1.0.1", "gopd": "1.2.0", "has-symbols": "1.1.0", "hasown": "2.0.3", "math-intrinsics": "1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "1.0.1", "es-object-atoms": "1.1.1" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - - "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "1.2.1", "gopd": "1.2.0" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], - - "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "2.1.0", "dir-glob": "3.0.1", "fast-glob": "3.3.3", "ignore": "5.3.2", "merge2": "1.4.1", "slash": "3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], - - "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "1.0.1" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - - "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "1.0.1" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "1.1.0" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - - "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], - - "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "10.4.3" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], - - "http-call": ["http-call@5.3.0", "", { "dependencies": { "content-type": "1.0.5", "debug": "4.4.3", "is-retry-allowed": "1.2.0", "is-stream": "2.0.1", "parse-json": "4.0.0", "tunnel-agent": "0.6.0" } }, "sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w=="], - - "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "1.0.1", "resolve-from": "4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - - "import-without-cache": ["import-without-cache@0.3.3", "", {}, "sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], - - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - - "ink": ["ink@7.0.3", "", { "dependencies": { "@alcalzone/ansi-tokenize": "0.3.0", "ansi-escapes": "7.3.0", "ansi-styles": "6.2.3", "auto-bind": "5.0.1", "chalk": "5.6.2", "cli-boxes": "4.0.1", "cli-cursor": "4.0.0", "cli-truncate": "6.0.0", "code-excerpt": "4.0.0", "es-toolkit": "1.46.1", "indent-string": "5.0.0", "is-in-ci": "2.0.0", "patch-console": "2.0.0", "react-reconciler": "0.33.0", "scheduler": "0.27.0", "signal-exit": "3.0.7", "slice-ansi": "9.0.0", "stack-utils": "2.0.6", "string-width": "8.2.1", "terminal-size": "4.0.1", "type-fest": "5.6.0", "widest-line": "6.0.0", "wrap-ansi": "10.0.0", "ws": "8.20.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@types/react": "19.2.14" }, "peerDependencies": { "react": "19.2.6" } }, "sha512-5kxHkIj9+RuqCU3zyvP4qvYWNOSHP2TW/SHayHGHOmk87KwfVcZwvJGemi9ch+ci2gXUqerK/Eh2DGEDt5q45g=="], - - "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "2.9.2" }, "peerDependencies": { "ink": "7.0.3", "react": "19.2.6" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], - - "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "hasown": "2.0.3", "side-channel": "1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - - "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "get-intrinsic": "1.3.0" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], - - "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - - "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "1.0.0", "call-bound": "1.0.4", "get-proto": "1.0.1", "has-tostringtag": "1.0.2", "safe-regex-test": "1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], - - "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "1.1.0" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], - - "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], - - "is-builtin-module": ["is-builtin-module@3.2.1", "", { "dependencies": { "builtin-modules": "3.3.0" } }, "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A=="], - - "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "7.8.0" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], - - "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], - - "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], - - "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "get-intrinsic": "1.3.0", "is-typed-array": "1.1.15" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], - - "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - - "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], - - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "1.0.4" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "1.0.4", "generator-function": "2.0.1", "get-proto": "1.0.1", "has-tostringtag": "1.0.2", "safe-regex-test": "1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - - "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], - - "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], - - "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], - - "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "1.0.4", "gopd": "1.2.0", "has-tostringtag": "1.0.2", "hasown": "2.0.3" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], - - "is-retry-allowed": ["is-retry-allowed@1.2.0", "", {}, "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg=="], - - "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], - - "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "1.0.4" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], - - "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - - "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], - - "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], - - "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "1.0.4", "has-symbols": "1.1.0", "safe-regex-test": "1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], - - "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "1.1.20" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], - - "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - - "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], - - "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "1.0.4" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], - - "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "1.0.4", "get-intrinsic": "1.3.0" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], - - "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], - - "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "2.2.1" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - - "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - - "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - - "jake": ["jake@10.9.4", "", { "dependencies": { "async": "3.2.6", "filelist": "1.0.6", "picocolors": "1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - - "jsdoc-type-pratt-parser": ["jsdoc-type-pratt-parser@4.1.0", "", {}, "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="], - - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "1.2.8" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - - "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], - - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - - "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], - - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - - "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], - - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "mdn-data": ["mdn-data@2.23.0", "", {}, "sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ=="], - - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "3.0.3", "picomatch": "2.3.2" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - - "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "1.1.14" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], - - "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], - - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - - "natural-orderby": ["natural-orderby@5.0.0", "", {}, "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg=="], - - "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "1.3.3", "es-errors": "1.3.0", "object.entries": "1.1.9", "semver": "6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], - - "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "5.0.0" } }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - - "node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="], - - "normalize-package-data": ["normalize-package-data@2.5.0", "", { "dependencies": { "hosted-git-info": "2.8.9", "resolve": "1.22.12", "semver": "5.7.2", "validate-npm-package-license": "3.0.4" } }, "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA=="], - - "npm": ["npm@11.14.1", "", { "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, "sha512-aopNZ0eEl6LbxoFcrXLmTEPzNBNxfiQnVgR9RmJBqzm+5h5pFoOmRljpRJbsXxocBeSl7GLcx3MoDf2UlEOjZw=="], - - "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "7.0.2", "proc-log": "4.2.0", "semver": "7.8.0", "validate-npm-package-name": "5.0.1" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], - - "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - - "object-treeify": ["object-treeify@4.0.1", "", {}, "sha512-Y6tg5rHfsefSkfKujv2SwHulInROy/rCL5F4w0QOWxut8AnxYxf0YmNhTh95Zfyxpsudo66uqkux0ACFnyMSgQ=="], - - "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-object-atoms": "1.1.1", "has-symbols": "1.1.0", "object-keys": "1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], - - "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-object-atoms": "1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], - - "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "1.0.9", "define-properties": "1.2.1", "es-abstract": "1.24.2", "es-object-atoms": "1.1.1" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], - - "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "1.0.9", "define-properties": "1.2.1", "es-abstract": "1.24.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], - - "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-object-atoms": "1.1.1" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], - - "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], - - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "0.1.4", "fast-levenshtein": "2.0.6", "levn": "0.4.1", "prelude-ls": "1.2.1", "type-check": "0.4.0", "word-wrap": "1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - - "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], - - "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "1.3.0", "object-keys": "1.1.1", "safe-push-apply": "1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - - "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "2.1.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "3.1.0" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], - - "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - - "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "0.2.11" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], - - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - - "parse-imports-exports": ["parse-imports-exports@0.2.4", "", { "dependencies": { "parse-statements": "1.0.11" } }, "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ=="], - - "parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "1.3.4", "json-parse-better-errors": "1.0.2" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], - - "parse-statements": ["parse-statements@1.0.11", "", {}, "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA=="], - - "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - - "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], - - "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - - "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], - - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - - "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], - - "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], - - "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], - - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], - - "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], - - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - - "rambda": ["rambda@7.5.0", "", {}, "sha512-y/M9weqWAH4iopRd7EHDEQQvpFPHj1AA3oHozE9tfITHUtTR7Z9PSlIRRG2l1GuW7sefC1cXFfIcF+cgnShdBA=="], - - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - - "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "19.2.6" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - - "read-pkg": ["read-pkg@5.2.0", "", { "dependencies": { "@types/normalize-package-data": "2.4.4", "normalize-package-data": "2.5.0", "parse-json": "5.2.0", "type-fest": "0.6.0" } }, "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg=="], - - "read-pkg-up": ["read-pkg-up@7.0.1", "", { "dependencies": { "find-up": "4.1.0", "read-pkg": "5.2.0", "type-fest": "0.8.1" } }, "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg=="], - - "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "4.2.11", "js-yaml": "3.14.2", "pify": "4.0.1", "strip-bom": "3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], - - "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "1.0.9", "define-properties": "1.2.1", "es-abstract": "1.24.2", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "get-intrinsic": "1.3.0", "get-proto": "1.0.1", "which-builtin-type": "1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - - "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], - - "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "1.0.9", "define-properties": "1.2.1", "es-errors": "1.3.0", "get-proto": "1.0.1", "gopd": "1.2.0", "set-function-name": "2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - - "regexpp": ["regexpp@3.2.0", "", {}, "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg=="], - - "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], - - "regjsparser": ["regjsparser@0.10.0", "", { "dependencies": { "jsesc": "0.5.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA=="], - - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - - "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], - - "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "1.3.0", "is-core-module": "2.16.2", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], - - "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], - - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - - "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "5.1.2", "signal-exit": "3.0.7" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], - - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - - "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], - - "rolldown-plugin-dts": ["rolldown-plugin-dts@0.23.2", "", { "dependencies": { "@babel/generator": "8.0.0-rc.3", "@babel/helper-validator-identifier": "8.0.0-rc.3", "@babel/parser": "8.0.0-rc.3", "@babel/types": "8.0.0-rc.3", "ast-kit": "3.0.0-beta.1", "birpc": "4.0.0", "dts-resolver": "2.1.3", "get-tsconfig": "4.14.0", "obug": "2.1.1", "picomatch": "4.0.4" }, "optionalDependencies": { "typescript": "5.9.3" }, "peerDependencies": { "rolldown": "1.0.0-rc.17" } }, "sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ=="], - - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "1.2.3" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - - "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "get-intrinsic": "1.3.0", "has-symbols": "1.1.0", "isarray": "2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "1.3.0", "isarray": "2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], - - "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-regex": "1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], - - "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], - - "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "1.1.4", "es-errors": "1.3.0", "function-bind": "1.1.2", "get-intrinsic": "1.3.0", "gopd": "1.2.0", "has-property-descriptors": "1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - - "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "1.1.4", "es-errors": "1.3.0", "functions-have-names": "1.2.3", "has-property-descriptors": "1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], - - "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.1" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4", "side-channel-list": "1.0.1", "side-channel-map": "1.0.1", "side-channel-weakmap": "1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4", "side-channel-map": "1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - - "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "6.2.3", "is-fullwidth-code-point": "5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "7.0.6", "signal-exit": "4.1.0" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], - - "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "3.0.1", "spdx-license-ids": "3.0.23" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], - - "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], - - "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "2.5.0", "spdx-license-ids": "3.0.23" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], - - "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], - - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - - "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], - - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], - - "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "internal-slot": "1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "8.0.0", "is-fullwidth-code-point": "3.0.0", "strip-ansi": "6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "define-data-property": "1.1.4", "define-properties": "1.2.1", "es-abstract": "1.24.2", "es-object-atoms": "1.1.1", "has-property-descriptors": "1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], - - "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "1.0.9", "call-bound": "1.0.4", "define-properties": "1.2.1", "es-object-atoms": "1.1.1" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], - - "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "1.0.9", "define-properties": "1.2.1", "es-object-atoms": "1.1.1" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - - "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "1.0.1" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], - - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - - "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - - "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - - "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], - - "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], - - "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - - "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], - - "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - - "ts-declaration-location": ["ts-declaration-location@1.0.7", "", { "dependencies": { "picomatch": "4.0.4" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA=="], - - "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "0.0.29", "json5": "1.0.2", "minimist": "1.2.8", "strip-bom": "3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], - - "tsdown": ["tsdown@0.21.10", "", { "dependencies": { "ansis": "4.3.0", "cac": "7.0.0", "defu": "6.1.7", "empathic": "2.0.1", "hookable": "6.1.1", "import-without-cache": "0.3.3", "obug": "2.1.1", "picomatch": "4.0.4", "rolldown": "1.0.0-rc.17", "rolldown-plugin-dts": "0.23.2", "semver": "7.8.0", "tinyexec": "1.1.2", "tinyglobby": "0.2.16", "tree-kill": "1.2.2", "unconfig-core": "7.5.0", "unrun": "0.2.39" }, "optionalDependencies": { "typescript": "5.9.3" }, "bin": { "tsdown": "dist/run.mjs" } }, "sha512-3wk73yBhZe/wX7REqSdivNQ84TDs1mJ+IlnzrrEREP70xlJ/AEIzqaI04l/TzMKVIdkTdC3CPaADn2Lk/0SkdA=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - - "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], - - "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "is-typed-array": "1.1.15" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], - - "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "1.0.9", "for-each": "0.3.5", "gopd": "1.2.0", "has-proto": "1.2.0", "is-typed-array": "1.1.15" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], - - "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "1.0.7", "call-bind": "1.0.9", "for-each": "0.3.5", "gopd": "1.2.0", "has-proto": "1.2.0", "is-typed-array": "1.1.15", "reflect.getprototypeof": "1.0.10" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], - - "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "1.0.9", "for-each": "0.3.5", "gopd": "1.2.0", "is-typed-array": "1.1.15", "possible-typed-array-names": "1.1.0", "reflect.getprototypeof": "1.0.10" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "9.39.4", "typescript": "5.9.3" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="], - - "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "has-bigints": "1.1.0", "has-symbols": "1.1.0", "which-boxed-primitive": "1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - - "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "1.0.0", "quansync": "1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], - - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - - "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "0.3.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], - - "unrun": ["unrun@0.2.39", "", { "dependencies": { "rolldown": "1.0.0-rc.17" }, "bin": { "unrun": "dist/cli.mjs" } }, "sha512-h9FxYVpztY/wwq+bauLOh6Y3CWu2IVeRLq5lxzneBiIU9Tn86OGp9xiQrGhnYspAmg5dzdY0Cc8+Y70kuTARCg=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "3.2.0", "picocolors": "1.1.1" }, "peerDependencies": { "browserslist": "4.28.2" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - - "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "3.2.0", "spdx-expression-parse": "3.0.1" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], - - "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], - - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "0.0.3", "webidl-conversions": "3.0.1" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - - "which": ["which@4.0.0", "", { "dependencies": { "isexe": "3.1.5" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - - "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "1.1.0", "is-boolean-object": "1.2.2", "is-number-object": "1.1.1", "is-string": "1.1.1", "is-symbol": "1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], - - "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "1.0.4", "function.prototype.name": "1.1.8", "has-tostringtag": "1.0.2", "is-async-function": "2.1.1", "is-date-object": "1.1.0", "is-finalizationregistry": "1.1.1", "is-generator-function": "1.1.2", "is-regex": "1.2.1", "is-weakref": "1.1.1", "isarray": "2.0.5", "which-boxed-primitive": "1.1.1", "which-collection": "1.0.2", "which-typed-array": "1.1.20" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], - - "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "2.0.3", "is-set": "2.0.3", "is-weakmap": "2.0.2", "is-weakset": "2.0.4" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - - "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], - - "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "1.0.7", "call-bind": "1.0.9", "call-bound": "1.0.4", "for-each": "0.3.5", "get-proto": "1.0.1", "gopd": "1.2.0", "has-tostringtag": "1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], - - "widest-line": ["widest-line@3.1.0", "", { "dependencies": { "string-width": "4.2.3" } }, "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg=="], - - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - - "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], - - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "ws": ["ws@8.20.1", "", {}, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], - - "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], - - "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], - - "yarn": ["yarn@1.22.22", "", { "bin": { "yarn": "bin/yarn.js", "yarnpkg": "bin/yarn.js" } }, "sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg=="], - - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], - - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - - "@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "1.6.0" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - - "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.3", "", {}, "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw=="], - - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@eslint/css/@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], - - "@eslint/css/@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "0.15.2", "levn": "0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="], - - "@eslint/json/@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="], - - "@eslint/json/@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "0.15.2", "levn": "0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="], - - "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - - "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], - - "@manypkg/find-root/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "5.0.0", "path-exists": "4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - - "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "4.2.11", "jsonfile": "4.0.0", "universalify": "0.1.2" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - - "@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="], - - "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "4.2.11", "jsonfile": "4.0.0", "universalify": "0.1.2" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - - "@oclif/core/ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="], - - "@oclif/core/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "5.0.6" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@oclif/plugin-autocomplete/ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="], - - "@oclif/plugin-not-found/ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="], - - "@oclif/plugin-plugins/ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="], - - "@oclif/plugin-warn-if-update-available/ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="], - - "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], - - "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "5.0.6" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@tybys/wasm-util": "0.10.2" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - - "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], - - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - - "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "strip-ansi": "7.2.0" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - - "cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "eslint-config-oclif/eslint-config-oclif": ["eslint-config-oclif@5.2.2", "", { "dependencies": { "eslint-config-xo-space": "0.35.0", "eslint-plugin-mocha": "10.5.0", "eslint-plugin-n": "15.7.0", "eslint-plugin-unicorn": "48.0.1" } }, "sha512-NNTyyolSmKJicgxtoWZ/hoy2Rw56WIoWCFxgnBkXqDgi9qPKMwZs2Nx2b6SHLJvCiWWhZhWr5V46CFPo3PSPag=="], - - "eslint-config-xo/@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "4.2.1", "espree": "10.4.0", "estraverse": "5.3.0", "picomatch": "4.0.4" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="], - - "eslint-config-xo/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], - - "eslint-config-xo-space/eslint-config-xo": ["eslint-config-xo@0.44.0", "", { "dependencies": { "confusing-browser-globals": "1.0.11" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-YG4gdaor0mJJi8UBeRJqDPO42MedTWYMaUyucF5bhm2pi/HS98JIxfFQmTLuyj6hGpQlAazNfyVnn7JuDn+Sew=="], - - "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - - "eslint-import-resolver-node/resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "1.3.0", "is-core-module": "2.16.2", "node-exports-info": "1.6.0", "object-keys": "1.1.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], - - "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - - "eslint-plugin-es/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "1.3.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="], - - "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - - "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "eslint-plugin-mocha/globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], - - "eslint-plugin-n/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], - - "eslint-plugin-unicorn/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], - - "eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@2.1.0", "", {}, "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw=="], - - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.0" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - - "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - - "ink/ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "1.1.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - - "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "ink/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - - "ink/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "strip-ansi": "7.2.0" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - - "ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "8.2.1" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], - - "ink/wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "6.2.3", "string-width": "8.2.1", "strip-ansi": "7.2.0" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], - - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - - "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "normalize-package-data/hosted-git-info": ["hosted-git-info@2.8.9", "", {}, "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="], - - "normalize-package-data/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - - "optionator/fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - - "read-pkg/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "7.29.0", "error-ex": "1.3.4", "json-parse-even-better-errors": "2.3.1", "lines-and-columns": "1.2.4" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], - - "read-pkg/type-fest": ["type-fest@0.6.0", "", {}, "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg=="], - - "read-pkg-up/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "5.0.0", "path-exists": "4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - - "read-pkg-up/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="], - - "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "1.0.10", "esprima": "4.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - - "regjsparser/jsesc": ["jsesc@0.5.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA=="], - - "rolldown-plugin-dts/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.3", "", {}, "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw=="], - - "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "1.6.0" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - - "spawndamnit/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "spdx-correct/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "2.5.0", "spdx-license-ids": "3.0.23" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], - - "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - - "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - - "validate-npm-package-license/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "2.5.0", "spdx-license-ids": "3.0.23" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], - - "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "5.0.0", "path-exists": "4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - - "@eslint/css/@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="], - - "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - - "@oclif/core/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - - "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "eslint-config-oclif/eslint-config-oclif/eslint-plugin-n": ["eslint-plugin-n@15.7.0", "", { "dependencies": { "builtins": "5.1.0", "eslint-plugin-es": "4.1.0", "eslint-utils": "3.0.0", "ignore": "5.3.2", "is-core-module": "2.16.2", "minimatch": "3.1.5", "resolve": "1.22.12", "semver": "7.8.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q=="], - - "eslint-config-oclif/eslint-config-oclif/eslint-plugin-unicorn": ["eslint-plugin-unicorn@48.0.1", "", { "dependencies": { "@babel/helper-validator-identifier": "7.28.5", "@eslint-community/eslint-utils": "4.9.1", "ci-info": "3.9.0", "clean-regexp": "1.0.0", "esquery": "1.7.0", "indent-string": "4.0.0", "is-builtin-module": "3.2.1", "jsesc": "3.1.0", "lodash": "4.18.1", "pluralize": "8.0.0", "read-pkg-up": "7.0.1", "regexp-tree": "0.1.27", "regjsparser": "0.10.0", "semver": "7.8.0", "strip-indent": "3.0.0" }, "peerDependencies": { "eslint": "9.39.4" } }, "sha512-FW+4r20myG/DqFcCSzoumaddKBicIPeFnTrifon2mWIzlfyvzwyqZjqVP7m4Cqr/ZYisS2aiLghkUWaPg6vtCw=="], - - "eslint-plugin-es/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@1.3.0", "", {}, "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ=="], - - "eslint-plugin-mocha/globals/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - - "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], - - "ink/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "ink/wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "read-pkg-up/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - - "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "1.0.3" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - - "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - - "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "2.3.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - - "@oclif/core/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "eslint-config-oclif/eslint-config-oclif/eslint-plugin-unicorn/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], - - "ink/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ink/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "read-pkg-up/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "2.3.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - - "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "2.3.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - - "@manypkg/find-root/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "2.2.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - - "read-pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "2.2.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - - "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "2.2.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 948f565b..00000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import oclif from 'eslint-config-oclif' -import prettier from 'eslint-config-prettier' - -export default [ - { - ignores: [ - '**/dist/**', - '**/node_modules/**', - '.packs/**', - '.upstream/**', - 'packages/cli/README.md', - ], - }, - ...oclif, - prettier, - { - languageOptions: { - globals: { - Bun: 'readonly', - }, - }, - rules: { - 'import/no-unresolved': 'off', - 'n/no-unsupported-features/node-builtins': 'off', - 'no-await-in-loop': 'off', - 'perfectionist/sort-classes': 'off', - 'perfectionist/sort-imports': 'off', - 'perfectionist/sort-named-imports': 'off', - 'perfectionist/sort-object-types': 'off', - 'perfectionist/sort-objects': 'off', - 'perfectionist/sort-union-types': 'off', - 'unicorn/prefer-string-replace-all': 'off', - }, - }, -] diff --git a/package.json b/package.json index cbd382a8..1dbda169 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@beeper/cli", + "name": "beeper-cli-monorepo", "private": true, "type": "module", "packageManager": "bun@1.3.10", @@ -11,26 +11,10 @@ ], "scripts": { "build": "bun run --workspaces --sequential build", - "check": "bun run typecheck && bun run test && bun run build && bun run pack:packages", + "check": "bun run test && bun run pack:packages", "clean": "bun run --workspaces clean", - "changeset": "changeset", - "lint": "eslint eslint.config.mjs packages/cli-plugin-cloudflare/src packages/cli-plugin-cloudflare/test", - "pack:packages": "mkdir -p .packs && (cd packages/cli && bun pm pack --destination ../../.packs) && (cd packages/cli-plugin-cloudflare && bun pm pack --destination ../../.packs)", - "publish:packages": "bun scripts/publish-packages.ts", - "release": "bun scripts/release.ts", + "pack:packages": "mkdir -p .packs && npm pack --workspace beeper-cli --pack-destination .packs", "test": "bun run --workspaces --sequential test", - "typecheck": "bun run --filter @beeper/cli build && bun run --workspaces --sequential typecheck", - "version-packages": "changeset version" - }, - "devDependencies": { - "@changesets/changelog-github": "^0.6.0", - "@changesets/cli": "^2.31.0", - "@types/bun": "^1.3.3", - "@types/node": "^20.0.0", - "eslint": "^9.39.4", - "eslint-config-oclif": "^6.0.165", - "eslint-config-prettier": "^10.1.8", - "tsdown": "^0.21.10", - "typescript": "^5.7.2" + "typecheck": "bun run --filter beeper-cli build && bun run --workspaces --sequential typecheck" } } diff --git a/packages/cli-plugin-cloudflare/LICENSE b/packages/cli-plugin-cloudflare/LICENSE deleted file mode 100644 index 742aa938..00000000 --- a/packages/cli-plugin-cloudflare/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Beeper - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/cli-plugin-cloudflare/README.md b/packages/cli-plugin-cloudflare/README.md deleted file mode 100644 index e675ecde..00000000 --- a/packages/cli-plugin-cloudflare/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# @beeper/cli-plugin-cloudflare - -Cloudflare Tunnel commands for Beeper CLI. - -```sh -beeper plugins install @beeper/cli-plugin-cloudflare -beeper targets tunnel -``` - -`targets tunnel` exposes the selected Beeper Desktop or Server API target through -a Cloudflare quick tunnel and keeps the tunnel running until interrupted. - -```sh -beeper targets tunnel desktop -beeper targets tunnel --target server -beeper targets tunnel --base-url http://127.0.0.1:23373 -beeper targets tunnel --url-only -``` - -The command uses `cloudflared`. If it is already installed, pass its path: - -```sh -BEEPER_CLOUDFLARED_PATH=/opt/homebrew/bin/cloudflared beeper targets tunnel -``` - -Or let the plugin download the pinned `cloudflared` binary: - -```sh -beeper targets tunnel --install -``` - -Environment overrides: - -| Variable | Effect | -| --- | --- | -| `BEEPER_CLOUDFLARED_PATH` | Use this `cloudflared` binary path. | -| `BEEPER_IGNORE_CLOUDFLARED` | Skip install/version checks and try to run the configured binary path directly. | -| `BEEPER_CLOUDFLARED_DOMAIN` | Override the domain used when parsing the public URL from `cloudflared` output. Defaults to `trycloudflare.com`. | - -Tunneling makes the Desktop API reachable from the public internet. Only run it -for targets and networks you intend to expose, and stop it with `Ctrl-C` when you -are done. - -`targets tunnel` uses Cloudflare quick tunnels. Quick tunnels return temporary -public URLs. For a stable hostname on your own domain, configure a named -Cloudflare Tunnel and public hostname in Cloudflare, then route it to your Beeper -target outside this quick-tunnel command. diff --git a/packages/cli-plugin-cloudflare/package.json b/packages/cli-plugin-cloudflare/package.json deleted file mode 100644 index c032a758..00000000 --- a/packages/cli-plugin-cloudflare/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@beeper/cli-plugin-cloudflare", - "version": "0.6.0", - "description": "Cloudflare Tunnel commands for Beeper CLI", - "license": "MIT", - "type": "module", - "exports": "./dist/index.js", - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "build": "bun run --filter @beeper/cli build && bun run clean && tsc -p tsconfig.json", - "clean": "rm -rf dist", - "test": "bun run build && bun test/smoke.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit" - }, - "oclif": { - "commands": { - "strategy": "pattern", - "target": "./dist/commands", - "globPatterns": [ - "**/!(*.d).{js,ts,tsx}" - ] - }, - "flexibleTaxonomy": true, - "topicSeparator": " ", - "topics": { - "tunnel": { - "description": "Expose Beeper targets through Cloudflare Tunnel" - } - } - }, - "dependencies": { - "@oclif/core": "^4.11.2", - "@beeper/cli": "workspace:*" - }, - "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.7.2" - } -} diff --git a/packages/cli-plugin-cloudflare/src/commands/targets/tunnel.ts b/packages/cli-plugin-cloudflare/src/commands/targets/tunnel.ts deleted file mode 100644 index dbac2136..00000000 --- a/packages/cli-plugin-cloudflare/src/commands/targets/tunnel.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Args, Flags, BeeperCommand, ensureWritable, printData, printSuccess, resolveTarget, writeEvent } from '@beeper/cli/plugin-sdk' -import { cloudflaredPath, startCloudflareTunnel } from '../../lib/cloudflared.js' - -export default class TargetsTunnel extends BeeperCommand { - static override summary = 'Expose a Beeper target through Cloudflare Tunnel' - static override description = 'Starts a Cloudflare quick tunnel for the selected Beeper Desktop or Server API target. The command stays in the foreground until interrupted.' - static override args = { - name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }), - } - static override flags = { - install: Flags.boolean({ default: false, description: 'Download the pinned cloudflared binary if it is missing or outdated' }), - 'cloudflared-path': Flags.string({ description: 'Path to a cloudflared binary. Also configurable with BEEPER_CLOUDFLARED_PATH.' }), - retries: Flags.integer({ default: 5, description: 'Number of startup retries before giving up' }), - 'url-only': Flags.boolean({ default: false, description: 'Print only the public tunnel URL' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(TargetsTunnel) - if (flags.install) ensureWritable(flags) - const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) - const localURL = normalizeLocalURL(target.baseURL) - const started = await startCloudflareTunnel({ - cloudflaredPath: flags['cloudflared-path'], - debug: flags.debug, - install: flags.install, - retries: flags.retries, - timeoutMs: parseTimeout(flags.timeout) ?? 40_000, - url: localURL, - }) - - if (flags.events) writeEvent('tunnel.connected', { target: target.id, localURL, url: started.url }) - - if (flags['url-only']) { - process.stdout.write(`${started.url}\n`) - } else if (flags.json) { - await printData({ target: target.id, localURL, url: started.url, cloudflaredPath: cloudflaredPath(flags['cloudflared-path']) }, 'json') - } else { - await printSuccess({ - message: `Cloudflare Tunnel connected for ${target.id}`, - detail: `${started.url} -> ${localURL}`, - data: { target: target.id, localURL, url: started.url }, - }, 'human') - process.stderr.write('Press Ctrl-C to stop the tunnel.\n') - } - - const exit = await waitForExit(started) - if (exit.reason === 'process' && exit.code !== 0) { - throw new Error(`cloudflared exited after the tunnel connected${exit.code === null ? '' : ` with code ${exit.code}`}.\n${started.tryMessage}`) - } - } -} - -function normalizeLocalURL(value: string): string { - const url = new URL(value) - url.pathname = url.pathname === '/' ? '/' : url.pathname - url.search = '' - url.hash = '' - return url.toString().replace(/\/$/, '') -} - -function parseTimeout(value?: string): number | undefined { - if (!value) return undefined - const match = value.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/i) - if (!match) throw new Error(`Invalid --timeout value "${value}". Use values like 500ms, 30s, or 2m.`) - const amount = Number(match[1]) - const unit = (match[2] ?? 'ms').toLowerCase() - if (unit === 'ms') return amount - if (unit === 's') return amount * 1000 - if (unit === 'm') return amount * 60_000 - return amount -} - -async function waitForExit(started: Awaited>): Promise<{ code: number | null; reason: 'process' | 'signal' }> { - return new Promise(resolve => { - const finish = () => { - started.stop() - resolve({ code: 0, reason: 'signal' }) - } - - process.once('SIGINT', finish) - process.once('SIGTERM', finish) - started.done.then(({ code }) => { - process.off('SIGINT', finish) - process.off('SIGTERM', finish) - resolve({ code, reason: 'process' }) - }) - }) -} diff --git a/packages/cli-plugin-cloudflare/src/index.ts b/packages/cli-plugin-cloudflare/src/index.ts deleted file mode 100644 index 63fcd66d..00000000 --- a/packages/cli-plugin-cloudflare/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const name = '@beeper/cli-plugin-cloudflare' diff --git a/packages/cli-plugin-cloudflare/test/smoke.mjs b/packages/cli-plugin-cloudflare/test/smoke.mjs deleted file mode 100644 index de1dc4bd..00000000 --- a/packages/cli-plugin-cloudflare/test/smoke.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict' -import { Config } from '@oclif/core/config' -import { cloudflaredDomain, findKnownError, findTunnelURL, versionIsGreaterThan, whatToTry } from '../dist/lib/cloudflared.js' - -const config = await Config.load({ root: new URL('..', import.meta.url).pathname }) -const commandIDs = [...config.commands].map(command => command.id) - -assert.deepEqual(commandIDs, ['targets:tunnel']) -assert.equal(versionIsGreaterThan('2024.8.2', '2024.8.1'), true) -assert.equal(versionIsGreaterThan('2024.8.2', '2024.8.2'), false) -assert.equal(versionIsGreaterThan('2024.8.2', '2024.9.0'), false) -assert.equal(findTunnelURL('INF https://example.trycloudflare.com ready'), 'https://example.trycloudflare.com') -assert.equal(findTunnelURL('INF https://example.example.com ready', 'example.com'), 'https://example.example.com') -assert.equal(findTunnelURL('INF https://example.example.com ready'), undefined) -assert.match(findKnownError('2024-01-01 ERR Failed to serve quic connection connIndex=1'), /Could not start Cloudflare Tunnel/) -assert.match(whatToTry(), /BEEPER_CLOUDFLARED_PATH/) - -const previousDomain = process.env.BEEPER_CLOUDFLARED_DOMAIN -process.env.BEEPER_CLOUDFLARED_DOMAIN = 'beeper.test' -assert.equal(cloudflaredDomain(), 'beeper.test') -if (previousDomain === undefined) { - delete process.env.BEEPER_CLOUDFLARED_DOMAIN -} else { - process.env.BEEPER_CLOUDFLARED_DOMAIN = previousDomain -} diff --git a/packages/cli-plugin-cloudflare/tsconfig.json b/packages/cli-plugin-cloudflare/tsconfig.json deleted file mode 100644 index 7ea087bd..00000000 --- a/packages/cli-plugin-cloudflare/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "exactOptionalPropertyTypes": false, - "isolatedModules": false, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "baseUrl": ".", - "paths": { - "@beeper/cli/plugin-sdk": ["../cli/dist/plugin-sdk.d.ts"] - }, - "rootDir": "./src" - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] -} diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md deleted file mode 100644 index a8fa2acd..00000000 --- a/packages/cli/CHANGELOG.md +++ /dev/null @@ -1,125 +0,0 @@ -# Changelog - -## 0.3.0 (2026-03-24) - -Full Changelog: [v0.2.0...v0.3.0](https://github.com/beeper/desktop-api-cli/compare/v0.2.0...v0.3.0) - -### Features - -* **api:** manual updates ([0770d3a](https://github.com/beeper/desktop-api-cli/commit/0770d3ad509df79341bb1067de910a4ececdc07e)) - - -### Bug Fixes - -* avoid reading from stdin unless request body is form encoded or json ([040b555](https://github.com/beeper/desktop-api-cli/commit/040b5552613ef61130225e1f6cf81ab213d3a4a9)) -* better support passing client args in any position ([9100cf6](https://github.com/beeper/desktop-api-cli/commit/9100cf6e1ee5b4e7c7bcb262636fe3f458224cd4)) -* cli no longer hangs when stdin is attached to a pipe with empty input ([4842b1e](https://github.com/beeper/desktop-api-cli/commit/4842b1eb8edf8b1bef423780f23908e10f317631)) -* fix for test cases with newlines in YAML and better error reporting ([c997492](https://github.com/beeper/desktop-api-cli/commit/c9974925c5d60aa534b8d619e51594cab0d622f0)) -* improve linking behavior when developing on a branch not in the Go SDK ([c5964a1](https://github.com/beeper/desktop-api-cli/commit/c5964a17c6203b0de179d6c73c62665c8c33e364)) -* improved workflow for developing on branches ([e7e8488](https://github.com/beeper/desktop-api-cli/commit/e7e84887ed26fb2de032a1960ea881fb15c7b3c4)) -* no longer require an API key when building on production repos ([f38af96](https://github.com/beeper/desktop-api-cli/commit/f38af96666a55ffd4014c4f6190f5807df8b740e)) -* only set client options when the corresponding CLI flag or env var is explicitly set ([923b0eb](https://github.com/beeper/desktop-api-cli/commit/923b0ebdeeafe5aa17f416651d82ee20c643fd16)) - - -### Chores - -* **internal:** codegen related update ([2a0195e](https://github.com/beeper/desktop-api-cli/commit/2a0195ef893339e931434cc30db149f45a0479aa)) -* **internal:** tweak CI branches ([e727f07](https://github.com/beeper/desktop-api-cli/commit/e727f07b8f5f6792c0e67fd6a048cfdd91ab3ba3)) -* **internal:** update gitignore ([4b48c83](https://github.com/beeper/desktop-api-cli/commit/4b48c838b1e7afe2cf6545b0de8a4f3146e4e1dd)) -* **tests:** bump steady to v0.19.4 ([f4cc892](https://github.com/beeper/desktop-api-cli/commit/f4cc892fc179e321e0b75e381f68805fd732774e)) -* **tests:** bump steady to v0.19.5 ([097fdc8](https://github.com/beeper/desktop-api-cli/commit/097fdc85a1790b8687763f7e187645bbcba22f93)) -* **tests:** bump steady to v0.19.6 ([4af2dce](https://github.com/beeper/desktop-api-cli/commit/4af2dce0afd3741d445d75a85bf4523a9297dcbe)) - - -### Refactors - -* **tests:** switch from prism to steady ([e67e839](https://github.com/beeper/desktop-api-cli/commit/e67e839be04bd2da13cc0a941047a8552f95a32b)) - -## 0.2.0 (2026-03-06) - -Full Changelog: [v0.1.1...v0.2.0](https://github.com/beeper/desktop-api-cli/compare/v0.1.1...v0.2.0) - -### Features - -* add `--max-items` flag for paginated/streaming endpoints ([d2cd184](https://github.com/beeper/desktop-api-cli/commit/d2cd184ffe8bd8bef28411f35c23c9e0bbed958f)) -* add support for file downloads from binary response endpoints ([2e0b0a7](https://github.com/beeper/desktop-api-cli/commit/2e0b0a7080adef772b1c2b9f33d957f267d354ad)) -* improved documentation and flags for client options ([46e772c](https://github.com/beeper/desktop-api-cli/commit/46e772c72af1ea406d17ddaa7aeaf58e8b917a16)) -* support passing required body params through pipes ([4aa3f99](https://github.com/beeper/desktop-api-cli/commit/4aa3f993788d31566b0035a34134b4745ccd6643)) - - -### Bug Fixes - -* add missing client parameter flags to test cases ([83e3537](https://github.com/beeper/desktop-api-cli/commit/83e35370ae87614bba0c4deacdafcbd6614ed4ee)) -* add missing example parameters for test cases ([86b6743](https://github.com/beeper/desktop-api-cli/commit/86b6743be9efd4809bfdfbf02da29089121e680c)) -* avoid printing usage errors twice ([62fbdae](https://github.com/beeper/desktop-api-cli/commit/62fbdaedc3cf1724fc9102eae7dcb87e148aabb7)) -* fix for encoding arrays with `any` type items ([148ce2c](https://github.com/beeper/desktop-api-cli/commit/148ce2c1a8fe9b284fb29a4397c8904a0e366824)) -* more gracefully handle empty stdin input ([d758018](https://github.com/beeper/desktop-api-cli/commit/d75801861b11dfd1cb5568e2b5da3eb0176b7c21)) - - -### Chores - -* **ci:** skip uploading artifacts on stainless-internal branches ([858bf53](https://github.com/beeper/desktop-api-cli/commit/858bf533863314ae579e4bd8a3fc25ff1b0490d4)) -* **internal:** codegen related update ([2fcd536](https://github.com/beeper/desktop-api-cli/commit/2fcd53660f6195309b786d5b50aaeb22595a5404)) -* **test:** do not count install time for mock server timeout ([c89d312](https://github.com/beeper/desktop-api-cli/commit/c89d31244a0735738964709d9c15961c2f9a2a71)) -* zip READMEs as part of build artifact ([d1a1267](https://github.com/beeper/desktop-api-cli/commit/d1a12679c7c0366a8a50972010874bc9e9a6d643)) - -## 0.1.1 (2026-02-25) - -Full Changelog: [v0.1.0...v0.1.1](https://github.com/beeper/desktop-api-cli/compare/v0.1.0...v0.1.1) - -### Bug Fixes - -* pin formatting for headers to always use repeat/dot formats ([97ea814](https://github.com/beeper/desktop-api-cli/commit/97ea81439b3abccbcdaeb1fdd393e44e6a07aa6e)) - - -### Chores - -* update readme with better instructions for installing with homebrew ([b248f13](https://github.com/beeper/desktop-api-cli/commit/b248f13f6bf455a224a85fa97af2c17122c53101)) - -## 0.1.0 (2026-02-24) - -Full Changelog: [v0.0.1...v0.1.0](https://github.com/beeper/desktop-api-cli/compare/v0.0.1...v0.1.0) - -### ⚠ BREAKING CHANGES - -* add support for passing files as parameters - -### Features - -* add readme documentation for passing files as arguments ([f7b1b4a](https://github.com/beeper/desktop-api-cli/commit/f7b1b4af1c7220c9cd21afc58aba32508504073b)) -* add support for passing files as parameters ([49ca642](https://github.com/beeper/desktop-api-cli/commit/49ca642691b546494d700c2f782aa8ae88d9767e)) -* **api:** add cli ([c57f02a](https://github.com/beeper/desktop-api-cli/commit/c57f02af602f2def16c59c1ba1db4059ff2b0fd5)) -* **api:** add reactions ([b16c08a](https://github.com/beeper/desktop-api-cli/commit/b16c08ae98ca116514ce0a8977142db49196c5d9)) -* **api:** add upload asset and edit message endpoints ([da2ca66](https://github.com/beeper/desktop-api-cli/commit/da2ca66a4910e80ffd919fd8105b026497b9a0ea)) -* **api:** api update ([56afbbc](https://github.com/beeper/desktop-api-cli/commit/56afbbc6d75f019edac752e6100caefe333de434)) -* **api:** api update ([9f69525](https://github.com/beeper/desktop-api-cli/commit/9f69525f394b74266a664c50899f760525844cd8)) -* **api:** manual updates ([0c8a0ee](https://github.com/beeper/desktop-api-cli/commit/0c8a0ee510531e30ce5ed8748af4b56c19cf2433)) -* **api:** manual updates ([b3fb2a0](https://github.com/beeper/desktop-api-cli/commit/b3fb2a0cf62fff27da2f2d1ca8062bf3b3ede582)) -* **api:** manual updates ([b66f2b5](https://github.com/beeper/desktop-api-cli/commit/b66f2b5c68eb90c5644faf0c1f2fc67a94f100cb)) -* **client:** provide file completions when using file embed syntax ([bdf34ce](https://github.com/beeper/desktop-api-cli/commit/bdf34cecc8cdbd2e9d19dade4616970bfd43ae6a)) -* **cli:** improve shell completions for namespaced commands and flags ([eded84a](https://github.com/beeper/desktop-api-cli/commit/eded84a5cc05bb700f5d0c50add30ec257738aa0)) -* improved support for passing files for `any`-typed arguments ([8c8fa87](https://github.com/beeper/desktop-api-cli/commit/8c8fa8743cbfd67e8ddbf165a06c151d319e2612)) - - -### Bug Fixes - -* fix for file uploads to octet stream and form encoding endpoints ([f26b475](https://github.com/beeper/desktop-api-cli/commit/f26b475dce7f9eb0cc9fa5a20c26667e1c32fc1a)) -* fix for nullable arguments ([5f10511](https://github.com/beeper/desktop-api-cli/commit/5f105117110982a972554fb9ab720b354829bae4)) -* fix for when terminal width is not available ([eba0a3f](https://github.com/beeper/desktop-api-cli/commit/eba0a3f905f7eb7bc3cd9a8f571713e5bdff1f87)) -* fix mock tests with inner fields that have underscores ([7c4554a](https://github.com/beeper/desktop-api-cli/commit/7c4554a35871394eeed6927ee401ce7cc6fe99b8)) -* preserve filename in content-disposition for file uploads ([c230cef](https://github.com/beeper/desktop-api-cli/commit/c230cefdf540e6d49e102cbb9cb9010625be04c6)) -* prevent tests from hanging on streaming/paginated endpoints ([fcf4608](https://github.com/beeper/desktop-api-cli/commit/fcf4608ef721212651ce3839bd4885d0b86744cf)) -* restore support for void endpoints ([de2984b](https://github.com/beeper/desktop-api-cli/commit/de2984b4cec53693f0b5b684cdc498c410211a82)) -* use RawJSON for iterated values instead of re-marshalling ([06bc1c7](https://github.com/beeper/desktop-api-cli/commit/06bc1c7a0ba890d76e2c210476ad1d7586cd069a)) - - -### Chores - -* add build step to ci ([f2bddcf](https://github.com/beeper/desktop-api-cli/commit/f2bddcf00a9a3faacf1a1a8293f3f46b7befe187)) -* configure new SDK language ([6db7b30](https://github.com/beeper/desktop-api-cli/commit/6db7b300c46fd6331b4bada5759f5e31ed5a0b56)) -* configure new SDK language ([388b391](https://github.com/beeper/desktop-api-cli/commit/388b3910792deb197365fc9e5fbf266260845d9e)) -* **internal:** codegen related update ([1cfe60b](https://github.com/beeper/desktop-api-cli/commit/1cfe60b670c95b1e9840d9768b8b5936512919aa)) -* **internal:** codegen related update ([8e91787](https://github.com/beeper/desktop-api-cli/commit/8e91787eccfac7cc9455444fca72045a312f95a0)) -* **internal:** codegen related update ([46e5aef](https://github.com/beeper/desktop-api-cli/commit/46e5aefac484dd40ad069ea5500dd2c885abf611)) -* **internal:** codegen related update ([6987a4c](https://github.com/beeper/desktop-api-cli/commit/6987a4c5870caa9323a8be80124069fc5f28d45a)) -* update documentation in readme ([5633fad](https://github.com/beeper/desktop-api-cli/commit/5633fad79b0a5db1d66b83a58d1d0e8fe7cf3f1d)) diff --git a/packages/cli/README.md b/packages/cli/README.md index f1167857..f5f55ec6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,2813 +1,134 @@ -# beeper — One CLI for all your chats +# beeper -> Built for you and your agent. Batteries included. - -Talks to Beeper Desktop on this machine, to a Beeper Server you self-host, or -to either one running somewhere else. Send and receive across the chat -networks Beeper bridges, from one CLI shaped for scripts, agents, and humans -in a hurry. - -**Supported chat networks** (via Beeper's bridges): -WhatsApp · iMessage · Telegram · Discord · Signal · Instagram DMs · -Facebook Messenger · X (Twitter) DMs · LinkedIn · Slack · -Google Messages (RCS/SMS) · Google Chat · Matrix · IRC · Bluesky. -Run `beeper bridges list` for the live list on your target. - -Command manual: `beeper man` · CLI docs: `beeper docs` - -## Features - -- **Connects to your Beeper.** Local Beeper Desktop on this machine (default), a Beeper Server you install and manage via the CLI, or a remote Beeper Desktop or Beeper Server authorized over OAuth/PKCE — or a bearer token in CI. -- **Setup that does the work.** `beeper setup` finds Beeper Desktop, offers to launch it, adopts the session. `--server --install` installs and starts a headless server in one step. `--oauth` opens the browser. `--remote URL` does the rest. -- **Every chat, every network.** List, search, start, archive, pin, mute, rename, focus. Read, edit, delete, react. Send text, files, stickers, voice, typing indicators. Download media. Export to JSON or Markdown. -- **Verification first-class.** SAS/QR device verification, recovery-key unlock, `status`/`doctor` to reach an encrypted-ready target — without leaving the shell. -- **Agent-shaped automation.** `--json` everywhere, NDJSON `--events`, `watch` with WebSocket + outbound HMAC-signed webhooks, `rpc` over stdin/stdout, `man --json` tool manifests, raw `api get`/`post`/`request` for Beeper Client API endpoints we haven't wrapped yet. -- **Safe by default.** `--read-only` rejects every mutating command. Writes stay explicit. Plugins extend the CLI without forking it. +Beeper CLI for Beeper Desktop and Beeper Server. It talks to a selected target, +keeps setup interactive, and exposes a scriptable command surface for humans, +shell scripts, and agents. ## Install -### Homebrew (recommended) - -```sh -brew install beeper/tap/cli -``` - -The installed command is `beeper`. - -### npm - ```sh -npx beeper-cli --help npm install -g beeper-cli +beeper --help ``` -The package name is `beeper-cli`; the installed command is `beeper`. - -### Build from source - -This repo is a Bun workspace. From the repo root: +For source builds: ```sh bun install -bun --filter @beeper/cli run build -bun --filter @beeper/cli run dev -- --help -``` - -For local CLI development inside `packages/cli`: - -```sh -bun run dev -- --help -``` - -Regenerate this README after command, flag, or argument changes: - -```sh -bun run readme -``` - -## Quick start - -The happy path: Beeper Desktop is already on this machine. `beeper setup` finds -it, offers to launch it if it's not running, and adopts the session. - -```text -$ beeper setup -Looking for Beeper Desktop… found, not running. -Launch it now? [Y/n] y -▎ Launched Beeper Desktop - next Run `beeper setup` again once it finishes starting. - -$ beeper setup -Use this Desktop session for CLI access? [Y/n] y -▎ Connected desktop - accounts whatsapp, telegram, imessage - endpoint http://127.0.0.1:23373 - -$ beeper chats list --limit 3 - 10313 Family 3 unread - 8951 Alice · - 7204 Eng standup 12 unread - -$ beeper messages search "flight" - 8951 Alice · "your flight is at 6:40, gate B23" 2d ago - 10313 Family · "what flight are you on?" 1w ago - -$ beeper send text --to Family --message "on my way" -▎ Sent Family - message "on my way" - at 2026-05-18T14:02:11Z - -$ beeper export --out ./beeper-export -▎ Exported ./beeper-export - chats 214 messages 38,901 attachments 1,205 -``` - -Recipients accept a numeric local chat ID, a full Beeper/Matrix chat ID, an -iMessage chat ID, an exact title, or search text. Ambiguous matches prompt in a -TTY; pass `--pick N` in scripts. - -## Connecting a target - -A *target* is the Beeper endpoint `beeper` talks to — local Beeper Desktop, -local Beeper Server, or a remote Beeper Desktop or Beeper Server. Pick one of -four paths. - -### 1. Local Beeper Desktop (default, recommended) - -If Beeper Desktop is installed and signed in here, `beeper setup` discovers it -on `http://127.0.0.1:23373` and adopts the existing session. If it's installed -but not running, `setup` offers to launch it. If it's not installed at all, -`--install` does that in one step. - -```text -$ beeper setup --desktop --install -▎ Installed Beeper Desktop (stable) -▎ Launched Beeper Desktop - next Sign in to Beeper Desktop, then re-run `beeper setup`. - -$ beeper setup -▎ Connected desktop - accounts whatsapp, telegram -``` - -Variants: `beeper setup --local` to skip discovery and force the local path; -`beeper install desktop --channel nightly` for the nightly channel. - -### 2. Local Beeper Server (self-hosted, managed by the CLI) - -For a headless long-running setup on this machine, install and adopt a local -Beeper Server. The CLI manages the process — `targets start/stop/restart/logs/enable`. - -```text -$ beeper setup --server --install -▎ Installed Beeper Server (stable) -▎ Started server on http://127.0.0.1:23373 - auth Opening browser to authorize this server… -▎ Connected server - accounts (none) - next Run `beeper accounts add` to connect a network. - -$ beeper accounts add -? Which bridge? whatsapp - Scan this QR code with WhatsApp on your phone: - ▄▄▄▄▄▄▄ ▄ ▄ ▄▄▄▄▄▄▄ - █ ███ █ ▄█▄ █ ███ █ - █ ███ █ ▀█▀ █ ███ █ - ▀▀▀▀▀▀▀ ▀ ▀ ▀▀▀▀▀▀▀ -▎ Connected whatsapp · +1•••4242 -``` - -Variants: `beeper install server`, `beeper install server --server-env staging`. - -### 3. Remote Desktop or Server via OAuth (PKCE) - -For a Beeper Desktop or Server running on another machine, authorize the CLI -through a browser-based OAuth/PKCE flow. - -```text -$ beeper setup --remote https://desktop.example.com -▎ Authorizing https://desktop.example.com - flow OAuth (PKCE) — opening browser… -▎ Connected remote (desktop.example.com) - accounts whatsapp, telegram, signal -``` - -Variants: `beeper setup --oauth` (PKCE against the default Beeper auth); -`beeper targets add remote work https://desktop.example.com --default` to -register additional remotes. - -### 4. Bearer token (non-interactive / CI) - -For agents, CI, and scripts, hand the CLI a bearer token directly — no -browser, no interactive prompts. - -```sh -BEEPER_ACCESS_TOKEN=... beeper chats list --json -BEEPER_ACCESS_TOKEN=... BEEPER_DESKTOP_BASE_URL=https://desktop.example.com \ - beeper messages list --chat 10313 --json -``` - -Once connected, `beeper accounts add` walks each chat-network bridge through -its own login — QR, code, OAuth, cookie, whatever the bridge requires — so -WhatsApp, Telegram, Discord, iMessage, and the rest show up under `accounts list`. - -## Documentation - -| Topic | Page | Commands | -| --- | --- | --- | -| **Setup + install** | [setup](docs/setup.md) · [auth](docs/auth.md) | `setup` · `install desktop` · `install server` · `verify` · `status` · `doctor` · `auth status` | -| **Targets** | [targets](docs/targets.md) | `targets list` · `targets add desktop` · `targets add server` · `targets add remote` · `targets use` · `targets status` · `targets logs` | -| **Bridges + accounts** | [accounts](docs/accounts.md) | `bridges list` · `bridges show` · `accounts list` · `accounts add` · `accounts show` · `accounts use` · `accounts remove` | -| **Chats** | [chats](docs/chats.md) | `chats list` · `chats search` · `chats show` · `chats start` · `chats archive` · `chats pin` · `chats mute` · `chats priority` · `chats remind` · `chats rename` · `chats draft` · `chats focus` | -| **Messages** | [messages](docs/messages.md) · [send](docs/send.md) · [presence](docs/presence.md) | `messages list` · `messages search` · `messages export` · `send text` · `send file` · `send sticker` · `send voice` · `send react` · `presence` | -| **Contacts + media** | [contacts](docs/contacts.md) · [media](docs/media.md) · [export](docs/export.md) | `contacts list` · `contacts search` · `media download` · `export` | -| **Automation** | [watch](docs/watch.md) · [rpc](docs/rpc.md) · [api](docs/api.md) | `watch` · `watch --webhook` · `rpc` · `man` · `api get` · `api post` · `api request` | -| **Maintenance** | [config](docs/config.md) · [update](docs/update.md) | `update` · `config` · `completion` · `docs` · `version` | - -Use `beeper docs` to open the CLI docs and `beeper man` to print the local -command manual. - -## Configuration - -Default Beeper Client API target: `http://127.0.0.1:23373`. CLI configuration is -stored under your user config dir; print it with `beeper config path`. - -**Global flags:** `--base-url`, `--target`, `--json`, `--events`, -`--full`, `--timeout`, `--read-only`, `--debug`, `--yes`, `--quiet`. - -**Environment overrides:** - -| Variable | Effect | -| --- | --- | -| `BEEPER_ACCESS_TOKEN` | Bearer token for the selected target. Overrides stored OAuth login. | -| `BEEPER_DESKTOP_BASE_URL` | Beeper Client API base URL (Desktop or Server). Defaults to `http://127.0.0.1:23373`. | -| `BEEPER_READONLY` | `1`/`true`/`yes`/`on` enables read-only mode globally. | -| `BEEPER_CLI_CONFIG_DIR` | Override config directory for testing or isolated profiles. | - -## Exit codes - -| Code | Meaning | -| --- | --- | -| `0` | Success. | -| `1` | Generic runtime error. | -| `2` | Usage error (parsing, validation, missing required flag/arg, read-only refusal). | -| `3` | Auth required (no stored token; sign in or set `BEEPER_ACCESS_TOKEN`). | -| `4` | Target/account not ready (`doctor` reports this when readiness is not `ready`). | -| `5` | Selector matched nothing (unknown target, account, chat, contact). | -| `6` | Ambiguous selector (multiple matches; pass an exact ID or `--pick N`). | - -JSON output preserves the same envelope on failure: `{"success":false,"data":null,"error":"...","exitCode":N}` written to stderr. - -## Addressing - -- Chat arguments accept numeric local chat IDs, full Beeper/Matrix chat IDs, iMessage chat IDs, exact titles, or search text. -- For scripts on the same target/profile, prefer the numeric local chat ID shown by `beeper chats list`; use the full Beeper/Matrix chat ID when the selector must work across targets or profiles. -- Numeric local chat IDs come from the selected Desktop database. Treat them as local to that target/profile. -- Ambiguous chat matches return numbered choices; pass `--pick N` to select one. -- Account arguments accept account IDs, network names, bridge type/id, or account user identity. -- Account filters can expand a network name to multiple matching accounts. -- `contacts search` and `chats start` can search across all accounts when `--account` is omitted. -- `contacts list` accepts the same account selectors as other account-scoped commands. - -## Output and scripting - -Most commands support: - -- app-like text by default, optimized for scanning chats, messages, contacts, accounts, and media -- `--json` for `{"success":true,"data":...,"error":null}` output on stdout -- `--events` for NDJSON lifecycle events on stderr from long-running commands -- `--read-only` to reject commands that modify Beeper or local CLI state -- `--full` to disable truncation -- `--debug` for SDK debug logging -- `--target` or `--base-url` to point at a different target - -`man --json` prints a compact command manifest for tools and agents. -`rpc` runs newline-delimited JSON command RPC over stdin/stdout. - -## Raw API access - -Raw Beeper Client API calls live under `api`, so scripts can reach a new -endpoint before a workflow command exists: - -```sh -beeper api get /v1/info -beeper api post /v1/messages/{chatID}/send --body '{"text":"hello"}' -beeper api request DELETE /v1/chats/abc/messages/def/reactions --body '{"reactionKey":"👍"}' -``` - -## Plugins - -Beeper CLI supports optional oclif plugins. List recommended Beeper plugins: - -```sh -beeper plugins available -``` - -Install a published plugin: - -```sh -beeper plugins install @beeper/cli-plugin-cloudflare -``` - -For plugin development, import from `@beeper/cli/plugin-sdk` and expose oclif -commands from your package. Link a local plugin while working on it: - -```sh -beeper plugins link ./packages/cli-plugin-cloudflare -beeper targets tunnel --help -``` - -First-party optional plugins: - -| Package | Adds | -| --- | --- | -| `@beeper/cli-plugin-cloudflare` | `targets tunnel` for exposing a selected Beeper target through Cloudflare Tunnel. | - - -## Command Summary - -| Command | Summary | -| --- | --- | -| `setup` | Make the selected target ready for messaging | -| `install desktop` | Install Beeper Desktop locally | -| `install server` | Install Beeper Server locally | -| `targets list` | List configured Beeper targets | -| `bridges list` | List bridges that can connect chat accounts | -| `bridges show` | Show bridge details, login flows, and connected accounts | -| `targets add desktop` | Add a managed Beeper Desktop target | -| `targets add server` | Add a managed Beeper Server target | -| `targets add remote` | Add a remote Beeper Desktop or Server target | -| `targets use` | Set the default target | -| `targets show` | Show target details | -| `targets status` | Check endpoint and process reachability for a target | -| `targets start` | Start a local Server target or open Beeper Desktop | -| `targets stop` | Stop a local Beeper Server target | -| `targets restart` | Restart a local Beeper Server target | -| `targets logs` | Print logs for a local Beeper Desktop or Server install | -| `targets enable` | Enable a local Beeper Server target at login | -| `targets disable` | Disable a local Beeper Server target at login | -| `targets remove` | Remove a target | -| `targets tunnel` | Expose a local Desktop API over a public Cloudflare tunnel | -| `auth status` | Show stored auth for the selected target | -| `auth logout` | Clear stored authentication | -| `auth email start` | Start email sign-in for a target | -| `auth email response` | Finish email sign-in with a verification code | -| `verify` | Finish setup verification or verify another device | -| `verify status` | Show encryption and device-verification readiness | -| `verify approve` | Approve a pending device verification request | -| `verify recovery-key` | Unlock encrypted messages with a recovery key | -| `verify reset-recovery-key` | Create a new encrypted-messages recovery key | -| `verify cancel` | Cancel an in-progress device verification | -| `verify list` | List active verification work | -| `verify start` | Start a device verification request | -| `verify show` | Show the current active verification request | -| `verify sas` | Start emoji verification | -| `verify sas-confirm` | Confirm matching emoji verification | -| `verify qr-scan` | Submit a scanned QR-code verification payload | -| `verify qr-confirm` | Confirm that the other device scanned your QR code | -| `accounts list` | List connected accounts | -| `accounts add` | Connect a chat account by bridge | -| `accounts show` | Show account details | -| `accounts remove` | Remove an account | -| `accounts use` | Select a default account for account-scoped commands | -| `chats list` | List chats | -| `chats search` | Search chats | -| `chats show` | Show chat details | -| `chats start` | Start a chat | -| `chats archive` | Archive a chat | -| `chats unarchive` | Unarchive a chat | -| `chats pin` | Pin a chat | -| `chats unpin` | Unpin a chat | -| `chats mute` | Mute a chat | -| `chats unmute` | Unmute a chat | -| `chats mark-read` | Mark a chat as read | -| `chats mark-unread` | Mark a chat as unread | -| `chats priority` | Move a chat to the Inbox or Low Priority | -| `chats notify-anyway` | Send an iMessage Notify Anyway alert | -| `chats rename` | Rename a chat | -| `chats description` | Set a chat description | -| `chats avatar` | Set a chat avatar | -| `chats draft` | Set or clear a chat draft | -| `chats disappear` | Set disappearing-message expiry | -| `chats remind` | Set a chat reminder | -| `chats unremind` | Clear a chat reminder | -| `chats focus` | Focus Beeper Desktop on a chat | -| `messages list` | List chat messages | -| `messages search` | Search messages across chats | -| `messages show` | Show one message | -| `messages context` | Show message context | -| `messages edit` | Edit a message | -| `messages delete` | Delete a message | -| `messages export` | Export one chat to JSON | -| `send text` | Send a text message | -| `send file` | Send a file | -| `send react` | Send a reaction to a message | -| `send sticker` | Send a sticker | -| `send unreact` | Remove a reaction from a message | -| `send voice` | Send a voice note | -| `presence` | Send a typing (or paused) indicator to a chat | -| `contacts list` | List contacts | -| `contacts search` | Search contacts | -| `contacts show` | Show contact details | -| `media download` | Download message media | -| `export` | Export accounts, chats, messages, Markdown transcripts, and attachments | -| `watch` | Stream Desktop API WebSocket events | -| `rpc` | Run newline-delimited JSON command RPC over stdin/stdout | -| `man` | Print the command manual | -| `doctor` | Probe the target live and report diagnostics | -| `status` | Show selected target and setup readiness | -| `docs` | Open Beeper CLI docs | -| `version` | Print CLI version | -| `completion` | Print shell completion setup | -| `plugins` | Manage Beeper CLI plugins | -| `plugins available` | List recommended optional Beeper CLI plugins | -| `update` | Check and install Beeper updates | -| `config get` | Print CLI configuration | -| `config set` | Set a CLI configuration value | -| `config path` | Print the CLI config path | -| `config reset` | Reset CLI configuration | -| `api get` | Call a raw Desktop API GET path | -| `api post` | Call a raw Desktop API POST path with a JSON body | -| `api request` | Call a raw Desktop API path with any supported HTTP method | - -## Command Reference - -### `beeper setup` -Make the selected target ready for messaging - -```sh -beeper setup +bun run check +bun packages/cli/bin/dev.js --help ``` -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--channel=` | option | Install release channel Default: stable | -| `--desktop` | boolean | Set up a local Beeper Desktop target | -| `--email=` | option | Sign in with an email address | -| `--install` | boolean | Allow installing missing managed runtime | -| `--local` | boolean | Use the local Beeper Desktop session on this device | -| `--oauth` | boolean | Authorize the target with browser OAuth/PKCE | -| `--remote=` | option | Connect to a remote Beeper Desktop or Server URL | -| `--server` | boolean | Set up a local Beeper Server target | -| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | -| `--username=` | option | Username to use if setup creates a new account | - -Examples: +## Quick Start ```sh beeper setup -beeper setup --local -beeper setup --oauth -beeper setup --remote https://desktop.example.com -beeper setup --desktop --install -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper install desktop` -Install Beeper Desktop locally - -```sh -beeper install desktop -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--channel=` | option | Desktop release channel Default: stable | - -Examples: - -```sh -beeper install desktop -beeper install desktop --channel nightly -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper install server` -Install Beeper Server locally - -```sh -beeper install server -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--channel=` | option | Server release channel Default: stable | -| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | - -Examples: - -```sh -beeper install server -beeper install server --server-env staging -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets list` -List configured Beeper targets - -```sh -beeper targets list -``` - -Examples: - -```sh beeper targets list -beeper targets list --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper bridges list` -List bridges that can connect chat accounts - -```sh -beeper bridges list -``` - -`bridges list` is the scriptable bridge catalog. Use `accounts add` without an argument for the guided account connection flow. - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--available` | boolean | Only bridges available to add (--no-available to exclude) | -| `--provider=` | option | Limit to bridge provider | - -Examples: - -```sh -beeper bridges list -beeper bridges list --provider local --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper bridges show` -Show bridge details, login flows, and connected accounts - -```sh -beeper bridges show -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `bridge` | yes | Bridge ID, display name, network, or type | - -Examples: - -```sh -beeper bridges show local-whatsapp -beeper bridges show telegram -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets add desktop` -Add a managed Beeper Desktop target - -```sh -beeper targets add desktop [name] -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name (default: "desktop") | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--default` | boolean | Set this target as the default after creation | -| `--port=` | option | TCP port the managed Desktop will expose its API on | -| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | - -Examples: - -```sh -beeper targets add desktop work --default -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets add server` -Add a managed Beeper Server target - -```sh -beeper targets add server [name] -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name (default: "server") | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--default` | boolean | Set this target as the default after creation | -| `--port=` | option | TCP port the managed Server will expose its API on | -| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | - -Examples: - -```sh -beeper targets add server prod --server-env production --default -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets add remote` -Add a remote Beeper Desktop or Server target - -```sh -beeper targets add remote -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | yes | Local name for the target | -| `url` | yes | Base URL of the remote Desktop or Server API | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--default` | boolean | Set this target as the default after creation | - -Examples: - -```sh -beeper targets add remote work https://desktop.example.com --default -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets use` -Set the default target - -```sh -beeper targets use -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | yes | Target name | - -Examples: - -```sh -beeper targets use work -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets show` -Show target details - -```sh -beeper targets show [name] -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name. Defaults to the selected target. | - -Examples: - -```sh -beeper targets show -beeper targets show work -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets status` -Check endpoint and process reachability for a target - -```sh -beeper targets status [name] -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name. Defaults to the selected target. | - -Examples: - -```sh -beeper targets status -beeper targets status work --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets start` -Start a local Server target or open Beeper Desktop - -```sh -beeper targets start [name] +beeper doctor +beeper status +beeper chats list --limit 10 +beeper messages search "flight" +beeper send text --to "Family" --message "on my way" ``` -Arguments: +`beeper setup` preserves the interactive setup flow: it discovers local Beeper +Desktop, can install or launch Desktop, can install and start Beeper Server, +and can adopt remote targets. Use `--no-input` when running non-interactively. -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name. Defaults to the selected target. | +## Command Reference -Examples: +The live command registry is the source of truth. Use: ```sh -beeper targets start work +beeper --help +beeper schema --json +beeper exit-codes --json +beeper --help ``` -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. +Generated command docs live in [docs/commands/README.md](docs/commands/README.md) +and are rebuilt from the live registry with +`bun run --cwd packages/cli docs:commands`. -### `beeper targets stop` -Stop a local Beeper Server target +Current command groups: -```sh -beeper targets stop [name] -``` +- `setup`, `status` (`st`), `doctor`, `version`, `exit-codes`, `schema` +- `config get` (`config show`), `config keys` (`config list-keys`, `config names`), `config list` (`config ls`, `config all`), `config path` (`config where`), `config set` (`config add`, `config update`), `config unset` (`config rm`, `config del`, `config remove`) +- `use account` (`accounts use`), `use target` (`targets use`), `remove account` (`accounts remove`, `accounts rm`), `remove target` (`targets remove`, `targets rm`) +- `auth email start`, `auth email response`, `auth logout` +- `targets add`, `targets list` (`targets ls`), `targets runtime start`, `targets runtime stop`, `targets runtime restart`, `targets logs`, `targets tunnel` +- `install desktop`, `install server` +- `accounts add`, `accounts list` +- `chats list` (`chats ls`), `chats show`, `chats start`, `chats archive`, `chats pin`, `chats mute`, `chats read`, `chats rename`, `chats description`, `chats avatar`, `chats priority`, `chats draft`, `chats remind`, `chats disappear`, `chats focus`, `chats notify-anyway` +- `messages list` (`messages ls`), `messages search` (`messages find`), `messages context`, `messages edit`, `messages delete` +- `send text`, `send file`, `send sticker`, `send voice`, `send react`, `send presence` +- `contacts list` (`contacts search`, `contacts find`) +- `media download`, `export`, `watch` +- `api request`, `mcp`, `completion` +- `resolve account`, `resolve bridge`, `resolve chat`, `resolve contact`, `resolve target` -Arguments: +## Global Flags -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name. Defaults to the selected target. | +- Output: `--json`/`-j`, `--plain`/`-p`/`--tsv`, `--select`/`--fields`, `--results-only`, `--full`, `--events`, `--debug` +- Targeting/config: `--target`, `--account`/`-a`, `--home`, `--access-token` +- Safety: `--dry-run`/`-n`, `--read-only`/`BEEPER_READONLY`, `--timeout`, `--safety-profile`, `--enable-commands`, `--enable-commands-exact`, `--disable-commands`, `--wrap-untrusted` +- Interaction: `--no-input`, `--force`/`-y` -Examples: +Human output uses stable tables and diagnostic summaries. Use `--json` for raw +objects, `--select=id,name` to project JSON fields, and `--plain` for TSV-like +text. -```sh -beeper targets stop work -``` +`mcp` exposes read-only tools by default. Use `mcp --list-tools` to inspect the +enabled tool set, `mcp --allow-tool messages.*` to restrict it, and +`mcp --allow-write` only when write-risk tools should be available. -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. +## Targets -### `beeper targets restart` -Restart a local Beeper Server target +A target is the endpoint the CLI talks to. It can be local Desktop, local +Server, or a remote Desktop/Server. ```sh -beeper targets restart [name] +beeper setup --desktop +beeper setup --server --install +beeper targets add work https://desktop.example.com --default +beeper targets tunnel work --url-only ``` -Arguments: +## Safety Profiles -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name. Defaults to the selected target. | - -Examples: - -```sh -beeper targets restart work -``` +Safety profiles live in `packages/cli/safety-profiles/`: -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. +- `readonly.yaml`: blocks mutating commands. +- `agent-safe.yaml`: allows common read and messaging workflows, blocks high-risk operations. +- `full.yaml`: leaves command policy unrestricted. -### `beeper targets logs` -Print logs for a local Beeper Desktop or Server install +Use them with: ```sh -beeper targets logs [name] +beeper --safety-profile packages/cli/safety-profiles/readonly.yaml chats list ``` -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name. Defaults to the selected target. | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--all` | boolean | Print all matching log files instead of only recent files | -| `--files=` | option | Desktop log files to print, newest first Default: 5 | -| `--lines=` | option | Lines to print from each log file Default: 200 | - -Examples: +## Development ```sh -beeper targets logs work +bun --filter beeper-cli run typecheck +bun --filter beeper-cli run test +bun --filter beeper-cli run build +bun run check ``` -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets enable` -Enable a local Beeper Server target at login +In this repository checkout, the direct package form also works: ```sh -beeper targets enable [name] +bun run --cwd packages/cli typecheck +bun run --cwd packages/cli docs:commands +bun run --cwd packages/cli test +bun run --cwd packages/cli build ``` -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name. Defaults to the selected target. | - -Examples: - -```sh -beeper targets enable work -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets disable` -Disable a local Beeper Server target at login - -```sh -beeper targets disable [name] -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | no | Target name. Defaults to the selected target. | - -Examples: - -```sh -beeper targets disable work -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets remove` -Remove a target - -```sh -beeper targets remove -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `name` | yes | Target name | - -Examples: - -```sh -beeper targets remove work -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper targets tunnel` -Expose a local Desktop API over a public Cloudflare tunnel - -```sh -beeper targets tunnel -``` - -Examples: - -```sh -beeper targets tunnel -beeper targets tunnel --target work --read-only -beeper targets tunnel --as work-laptop --port 23373 -``` - -### `beeper auth status` -Show stored auth for the selected target - -```sh -beeper auth status -``` - -Examples: - -```sh -beeper auth status -beeper auth status --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper auth logout` -Clear stored authentication - -```sh -beeper auth logout -``` - -Examples: - -```sh -beeper auth logout -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper auth email start` -Start email sign-in for a target - -```sh -beeper auth email start -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--email=` | option | Email address to sign in with Required. | - -Examples: - -```sh -beeper auth email start --email you@example.com --target work --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper auth email response` -Finish email sign-in with a verification code - -```sh -beeper auth email response -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--code=` | option | Email verification code Required. | -| `--setup-request-id=` | option | Setup request ID from auth email start Required. | -| `--username=` | option | Username to use if setup creates a new account | - -Examples: - -```sh -beeper auth email response --setup-request-id --code --target work --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify` -Finish setup verification or verify another device - -```sh -beeper verify -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--user=` | option | User ID to verify against (defaults to your own account) | - -Examples: - -```sh -beeper verify -beeper verify --user @alice:beeper.com -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify status` -Show encryption and device-verification readiness - -```sh -beeper verify status -``` - -Examples: - -```sh -beeper verify status --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify approve` -Approve a pending device verification request - -```sh -beeper verify approve -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--id=` | option | Verification request ID. Defaults to the active request. | - -Examples: - -```sh -beeper verify approve --id active -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify recovery-key` -Unlock encrypted messages with a recovery key - -```sh -beeper verify recovery-key -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--key=` | option | Recovery key string Required. | - -Examples: - -```sh -beeper verify recovery-key --key ABCD-EFGH-IJKL -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify reset-recovery-key` -Create a new encrypted-messages recovery key - -```sh -beeper verify reset-recovery-key -``` - -Examples: - -```sh -beeper verify reset-recovery-key -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify cancel` -Cancel an in-progress device verification - -```sh -beeper verify cancel -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--id=` | option | Verification request ID. Defaults to the active request. | - -Examples: - -```sh -beeper verify cancel -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify list` -List active verification work - -```sh -beeper verify list -``` - -Examples: - -```sh -beeper verify list -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify start` -Start a device verification request - -```sh -beeper verify start -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--user=` | option | User ID to verify with (defaults to your own account) | - -Examples: - -```sh -beeper verify start --user @alice:beeper.com -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify show` -Show the current active verification request - -```sh -beeper verify show -``` - -Examples: - -```sh -beeper verify show --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify sas` -Start emoji verification - -```sh -beeper verify sas -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--id=` | option | Verification request ID. Defaults to the active request. | - -Examples: - -```sh -beeper verify sas -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify sas-confirm` -Confirm matching emoji verification - -```sh -beeper verify sas-confirm -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--id=` | option | Verification request ID. Defaults to the active request. | - -Examples: - -```sh -beeper verify sas-confirm -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify qr-scan` -Submit a scanned QR-code verification payload - -```sh -beeper verify qr-scan -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--id=` | option | Verification request ID. Defaults to the active request. | -| `--payload=` | option | Raw QR-code data scanned from the other device Required. | - -Examples: - -```sh -beeper verify qr-scan --payload "..." -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper verify qr-confirm` -Confirm that the other device scanned your QR code - -```sh -beeper verify qr-confirm -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--id=` | option | Verification request ID. Defaults to the active request. | - -Examples: - -```sh -beeper verify qr-confirm -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper accounts list` -List connected accounts - -```sh -beeper accounts list -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--account=...` | option | Filter by account selector | -| `--ids` | boolean | Print only account IDs | - -Examples: - -```sh -beeper accounts list -beeper accounts list --account whatsapp --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper accounts add` -Connect a chat account by bridge - -```sh -beeper accounts add [bridge] -``` - -`accounts add` without an argument opens the guided bridge chooser. Pass a bridge ID when you already know which chat network connector to use. - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `bridge` | no | Bridge ID, network, or type to connect. Omit to list available bridges. | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--cookie=...` | option | Cookie value for non-interactive login, in name=value form. Repeat for multiple cookies. | -| `--field=...` | option | Field value for non-interactive login, in id=value form. Repeat for multiple fields. | -| `--flow=` | option | Login flow ID. If omitted, Desktop chooses the default flow. | -| `--guided` | boolean | Prompt through login steps until completion | -| `--login-id=` | option | Existing login ID to re-login as | -| `--non-interactive` | boolean | Do not prompt; require --flow, --field, and --cookie values when needed. | -| `--webview` | boolean | Use Bun.WebView to collect cookie login fields when a cookie step is returned. | -| `--webview-backend=` | option | Bun.WebView backend for cookie login steps. Default: chrome | -| `--webview-timeout=` | option | Seconds to wait for Bun.WebView cookie collection. Default: 120 | - -Examples: - -```sh -beeper accounts add -beeper accounts add local-whatsapp -beeper accounts add discord --non-interactive --cookie sessiontoken=... -beeper accounts add discord --webview --webview-backend chrome -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper accounts show` -Show account details - -```sh -beeper accounts show -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `account` | yes | Account selector (ID, network, bridge, or user identity) | - -Examples: - -```sh -beeper accounts show whatsapp-main -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper accounts remove` -Remove an account - -```sh -beeper accounts remove -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `account` | yes | Account selector (ID, network, bridge, or user identity) | - -Examples: - -```sh -beeper accounts remove whatsapp-main -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper accounts use` -Select a default account for account-scoped commands - -```sh -beeper accounts use -``` - -Persists the choice in CLI config. Account-scoped commands that take --account fall back to this default when --account is omitted. Use `beeper accounts use ""` (or `beeper config set defaultAccount ""`) to clear. - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `account` | yes | Account selector (ID, network, bridge, user identity), or "" to clear. | - -Examples: - -```sh -beeper accounts use whatsapp-main -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats list` -List chats - -```sh -beeper chats list -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--account=...` | option | Limit to Account ID, network, bridge, or account user | -| `--archived` | boolean | Only archived chats (--no-archived to exclude) | -| `--ids` | boolean | Print preferred chat selectors, using numeric local chat IDs when available | -| `--limit=` | option | Maximum chats to print Default: 20 | -| `--low-priority` | boolean | Only Low Priority chats (--no-low-priority to exclude) | -| `--muted` | boolean | Only muted chats (--no-muted to exclude) | -| `--pinned` | boolean | Only pinned chats (--no-pinned to exclude) | -| `--unread` | boolean | Only chats with unread messages (--no-unread to exclude) | - -Examples: - -```sh -beeper chats list -beeper chats list --pinned --limit 50 -beeper chats list --unread --no-muted --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats search` -Search chats - -```sh -beeper chats search -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `query` | yes | Search query (title, participant, or network) | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--account=...` | option | Limit to Account ID, network, bridge, or account user | -| `--ids` | boolean | Print preferred chat selectors, using numeric local chat IDs when available | -| `--limit=` | option | Maximum chats to print Default: 20 | - -Examples: - -```sh -beeper chats search Family -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats show` -Show chat details - -```sh -beeper chats show -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--max-participants=` | option | Limit number of participants returned in chat details | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats show --chat 10313 -beeper chats show --chat '!plUOsWkvMmJmJPVAjS:beeper.com' -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats start` -Start a chat - -```sh -beeper chats start -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `user` | yes | User ID, phone number, email, or display name | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--account=` | option | Account selector. Defaults to the single available account or the matrix account. | -| `--title=` | option | Optional initial title for a new group chat | - -Examples: - -```sh -beeper chats start +15551234567 -beeper chats start @alice:beeper.com --title "Alice" -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats archive` -Archive a chat - -```sh -beeper chats archive -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats archive --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats unarchive` -Unarchive a chat - -```sh -beeper chats unarchive -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats unarchive --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats pin` -Pin a chat - -```sh -beeper chats pin -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats pin --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats unpin` -Unpin a chat - -```sh -beeper chats unpin -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats unpin --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats mute` -Mute a chat - -```sh -beeper chats mute -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats mute --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats unmute` -Unmute a chat - -```sh -beeper chats unmute -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats unmute --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats mark-read` -Mark a chat as read - -```sh -beeper chats mark-read -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--message=` | option | Mark read at (or unread starting from) this message ID | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats mark-read --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats mark-unread` -Mark a chat as unread - -```sh -beeper chats mark-unread -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--message=` | option | Mark read at (or unread starting from) this message ID | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats mark-unread --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats priority` -Move a chat to the Inbox or Low Priority - -```sh -beeper chats priority -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--level=` | option | Destination: inbox (default mailbox) or low (Low Priority) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats priority --chat 10313 --level inbox -beeper chats priority --chat '!plUOsWkvMmJmJPVAjS:beeper.com' --level low -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats notify-anyway` -Send an iMessage Notify Anyway alert - -```sh -beeper chats notify-anyway -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats notify-anyway --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats rename` -Rename a chat - -```sh -beeper chats rename -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--title=` | option | New chat title Required. | - -Examples: - -```sh -beeper chats rename --chat 10313 --title "Family" -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats description` -Set a chat description - -```sh -beeper chats description -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--clear` | boolean | Clear the existing description instead of setting one | -| `--description=` | option | New chat description | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats description --chat 10313 --description "Engineering chat" -beeper chats description --chat 10313 --clear -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats avatar` -Set a chat avatar - -```sh -beeper chats avatar -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--clear` | boolean | Clear the existing avatar instead of setting a new one | -| `--file=` | option | Image file to upload as the new avatar | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats avatar --chat 10313 --file ./team.png -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats draft` -Set or clear a chat draft - -```sh -beeper chats draft -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--clear` | boolean | Clear the existing draft instead of setting one | -| `--file=` | option | Attachment file to upload with the draft | -| `--filename=` | option | Override the displayed filename of the attachment | -| `--mime=` | option | Override MIME type detection for the attachment | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--text=` | option | Draft text. Omit and pass --clear to remove the draft. | - -Examples: - -```sh -beeper chats draft --chat 10313 --text "on my way" -beeper chats draft --chat 10313 --clear -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats disappear` -Set disappearing-message expiry - -```sh -beeper chats disappear -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--seconds=` | option | Timer in seconds, or "off" to disable Required. | - -Examples: - -```sh -beeper chats disappear --chat 10313 --seconds 86400 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats remind` -Set a chat reminder - -```sh -beeper chats remind -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--dismiss-on-message` | boolean | Dismiss the reminder automatically when a new message arrives | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--when=` | option | ISO timestamp when the reminder should trigger Required. | - -Examples: - -```sh -beeper chats remind --chat 10313 --when 2026-06-01T09:00:00Z -beeper chats remind --chat 10313 --when 2026-06-01T09:00:00Z --dismiss-on-message -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats unremind` -Clear a chat reminder - -```sh -beeper chats unremind -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats unremind --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper chats focus` -Focus Beeper Desktop on a chat - -```sh -beeper chats focus -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--attachment=` | option | Prefill the chat composer with this attachment file path | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--draft=` | option | Prefill the chat composer with this draft text | -| `--message=` | option | Scroll Desktop to this message ID after focusing | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper chats focus --chat 10313 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper messages list` -List chat messages - -```sh -beeper messages list -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--after-cursor=` | option | Paginate messages newer than this message ID | -| `--asc` | boolean | Order oldest first (default: newest first) | -| `--before-cursor=` | option | Paginate messages older than this message ID | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--ids` | boolean | Print only message IDs | -| `--limit=` | option | Maximum messages to print Default: 50 | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--sender=` | option | Filter by sender: me, others, or a specific user ID (client-side) | - -Examples: - -```sh -beeper messages list --chat 10313 --limit 50 -beeper messages list --chat 10313 --before-cursor "" --limit 100 -beeper messages list --chat 10313 --sender me --asc -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper messages search` -Search messages across chats - -```sh -beeper messages search [query] -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `query` | no | Search text (literal word match) | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--account=...` | option | Limit to an account selector. Repeat for multiple. | -| `--after=` | option | Only messages at or after this ISO timestamp | -| `--before=` | option | Only messages at or before this ISO timestamp | -| `--chat=...` | option | Limit to a chat selector. Repeat for multiple. | -| `--chat-type=` | option | Only group chats or direct messages | -| `--exclude-low-priority` | boolean | Exclude low-priority chats | -| `--ids` | boolean | Print only message IDs | -| `--include-muted` | boolean | Include muted chats | -| `--limit=` | option | Maximum results Default: 50 | -| `--media=...` | option | Filter by media type. Repeat for multiple. | -| `--sender=` | option | me, others, or a user ID | - -Examples: - -```sh -beeper messages search invoice -beeper messages search --chat 10313 --sender me --media image -beeper messages search "flight" --after 2026-01-01 --before 2026-02-01 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper messages show` -Show one message - -```sh -beeper messages show -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--id=` | option | Message ID, pendingMessageID, or Matrix event ID Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper messages show --chat 10313 --id -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper messages context` -Show message context - -```sh -beeper messages context -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--after=` | option | Number of messages to include after the target Default: 10 | -| `--before=` | option | Number of messages to include before the target Default: 10 | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--id=` | option | Target message ID to center the window on Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper messages context --chat 10313 --id --before 5 --after 5 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper messages edit` -Edit a message - -```sh -beeper messages edit -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--id=` | option | Message ID to edit (must be one of your own messages with no attachments) Required. | -| `--message=` | option | New message text Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper messages edit --chat 10313 --id --message "fixed" -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper messages delete` -Delete a message - -```sh -beeper messages delete -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--for-everyone` | boolean | Delete for everyone when the network supports it (otherwise deletes only for you) | -| `--id=` | option | Message ID to delete (final message ID; pending IDs are rejected) Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper messages delete --chat 10313 --id --for-everyone -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper messages export` -Export one chat to JSON - -```sh -beeper messages export -``` - -Lightweight per-chat JSON export. For a full export with transcripts, attachments, and multiple chats, use `beeper export`. - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--after=` | option | Only messages at or after this ISO timestamp (client-side filter) | -| `--after-cursor=` | option | Paginate messages newer than this message ID | -| `--asc` | boolean | Order oldest first (default: newest first) | -| `--before=` | option | Only messages at or before this ISO timestamp (client-side filter) | -| `--before-cursor=` | option | Paginate messages older than this message ID | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--limit=` | option | Maximum messages to export | -| `-o, --output=` | option | Output path; - writes JSON to stdout Default: - | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper messages export --chat 10313 --output chat.json -beeper messages export --chat 8951 --after 2026-01-01T00:00:00Z --output - -beeper messages export --chat '!plUOsWkvMmJmJPVAjS:beeper.com' --before-cursor "" --limit 500 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper send text` -Send a text message - -```sh -beeper send text -``` - -Returns when Desktop accepts the send request. Pass `--wait` to wait until the message leaves the pending state or fails. - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--mention=...` | option | User ID to @-mention (repeatable) | -| `--message=` | option | Message text to send Required. | -| `--no-preview` | boolean | Disable automatic link preview for URLs in the message | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--reply-to=` | option | Send as a reply to this message ID | -| `--to=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--wait` | boolean | Wait for the message to leave the pending state (or fail) before returning | -| `--wait-timeout=` | option | Maximum wait time in ms when --wait is set Default: 30000 | - -Examples: - -```sh -beeper send text --to 10313 --message "on my way" -beeper send text --to 8951 --message "hi" -beeper send text --to "Family" --message "hi" --pick 1 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper send file` -Send a file - -```sh -beeper send file -``` - -Returns when Desktop accepts the send request. Pass `--wait` to wait until the message leaves the pending state or fails. - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--caption=` | option | Optional caption to send alongside the file | -| `--file=` | option | Local file path to upload (max 500 MB) Required. | -| `--filename=` | option | Override the displayed filename | -| `--mime=` | option | Override MIME type detection | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--reply-to=` | option | Send as a reply to this message ID | -| `--to=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--wait` | boolean | Wait for the message to leave the pending state (or fail) before returning | -| `--wait-timeout=` | option | Maximum wait time in ms when --wait is set Default: 30000 | - -Examples: - -```sh -beeper send file --to 8951 --file ./photo.jpg --caption "from today" -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper send react` -Send a reaction to a message - -```sh -beeper send react -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--id=` | option | Message ID to react to Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--reaction=` | option | Reaction key (emoji, shortcode, or custom emoji key) Required. | -| `--to=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--transaction=` | option | Optional transaction ID for deduplication | - -Examples: - -```sh -beeper send react --to 10313 --id --reaction "+1" -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper send sticker` -Send a sticker - -```sh -beeper send sticker -``` - -Uploads the file and sends as a sticker message. Defaults --mime to image/webp. - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--file=` | option | Sticker file (typically 512x512 WebP) Required. | -| `--filename=` | option | Override the displayed filename | -| `--mime=` | option | MIME type for the sticker (default: image/webp) Default: image/webp | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--reply-to=` | option | Send as a reply to this message ID | -| `--to=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--wait` | boolean | Wait for the message to leave the pending state (or fail) before returning | -| `--wait-timeout=` | option | Maximum wait time in ms when --wait is set Default: 30000 | - -Examples: - -```sh -beeper send sticker --to 10313 --file ./hi.webp -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper send unreact` -Remove a reaction from a message - -```sh -beeper send unreact -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--id=` | option | Message ID whose reaction to remove Required. | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--reaction=` | option | Reaction key to remove (emoji, shortcode, or custom emoji key) Required. | -| `--to=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--transaction=` | option | Optional transaction ID for deduplication | - -Examples: - -```sh -beeper send unreact --to 10313 --id --reaction "+1" -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper send voice` -Send a voice note - -```sh -beeper send voice -``` - -Uploads the audio file and sends as a voice note. Defaults --mime to audio/ogg. - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--duration=` | option | Voice note duration in seconds (overrides upload-detected duration) | -| `--file=` | option | Voice note audio file (OGG/Opus recommended) Required. | -| `--filename=` | option | Override the displayed filename | -| `--mime=` | option | MIME type for the voice note (default: audio/ogg) Default: audio/ogg | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--reply-to=` | option | Send as a reply to this message ID | -| `--to=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--wait` | boolean | Wait for the message to leave the pending state (or fail) before returning | -| `--wait-timeout=` | option | Maximum wait time in ms when --wait is set Default: 30000 | - -Examples: - -```sh -beeper send voice --to 10313 --file ./note.ogg -beeper send voice --to 10313 --file ./note.ogg --duration 12 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper presence` -Send a typing (or paused) indicator to a chat - -```sh -beeper presence -``` - -Requires server-side support. Networks without typing notifications return an error. - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--duration=` | option | When --state is typing, send paused automatically after this many seconds | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--state=` | option | Indicator to send Default: typing | - -Examples: - -```sh -beeper presence --chat 10313 -beeper presence --chat 10313 --state paused -beeper presence --chat 10313 --duration 5 -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper contacts list` -List contacts - -```sh -beeper contacts list -``` - -List merged contacts for a specific account with cursor-based pagination. - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--account=...` | option | Limit to Account ID, network, bridge, or account user | -| `--ids` | boolean | Print only contact user IDs | -| `--limit=` | option | Maximum contacts to print Default: 50 | -| `--query=` | option | Optional blended contact lookup query | - -Examples: - -```sh -beeper contacts list --account whatsapp --query alice -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper contacts search` -Search contacts - -```sh -beeper contacts search -``` - -Search contacts on a specific account using merged account contacts, network search, and exact identifier lookup. - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `query` | yes | Contact search query | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--account=...` | option | Account ID, network, bridge, or account user. Omit to search every account. | - -Examples: - -```sh -beeper contacts search alice -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper contacts show` -Show contact details - -```sh -beeper contacts show -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `id` | yes | Contact user ID, display name, or phone/handle | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--account=...` | option | Limit to account ID, network, bridge, or account user | - -Examples: - -```sh -beeper contacts show "Alice" --account whatsapp -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper media download` -Download message media - -```sh -beeper media download -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `url` | yes | mxc:// or localmxc:// URL | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `-o, --out=` | option | Output directory; pass - to stream the file to stdout Default: . | - -Examples: - -```sh -beeper media download mxc://beeper.com/abc --out ./downloads -beeper media download mxc://beeper.com/abc -o - > photo.jpg -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper export` -Export accounts, chats, messages, Markdown transcripts, and attachments - -```sh -beeper export -``` - -Creates a resumable Beeper Desktop export using the official Desktop API SDK. The export directory contains accounts.json, chats.json, manifest.json, and one directory per chat with chat.json, messages.json, messages.markdown, messages.html, downloaded attachments, and checkpoint state for interrupted runs. - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--account=...` | option | Limit to an account selector. Repeat to include more accounts. | -| `--chat=...` | option | Limit to a chat selector. Repeat to include more chats. | -| `--force` | boolean | Re-export chats even if checkpoint state says they are complete. | -| `--limit-chats=` | option | Maximum chats to export. Intended for testing large exports. | -| `--limit-messages=` | option | Maximum messages per chat. Intended for testing large exports. | -| `--max-participants=` | option | Maximum participants to include in each chat.json. Default: 500 | -| `--no-attachments` | boolean | Skip downloading message attachments. | -| `-o, --out=` | option | Export directory. Default: beeper-export | -| `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | - -Examples: - -```sh -beeper export --out ./beeper-export -beeper export --chat 10313 --out ./chat -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper watch` -Stream Desktop API WebSocket events - -```sh -beeper watch -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `-c, --chat=...` | option | Chat ID to subscribe to. Defaults to all chats. | -| `--exclude-type=...` | option | Drop events of these types. Repeat for multiple. | -| `--include-type=...` | option | Only forward events of these types. Repeat for multiple. | -| `--webhook=` | option | Forward each event to this URL as a POST request (best-effort, fire-and-forget) | -| `--webhook-queue=` | option | Maximum pending webhook deliveries before dropping events Default: 64 | -| `--webhook-secret=` | option | HMAC-SHA256 secret. Signs payloads with X-Beeper-Signature: sha256= | - -Examples: - -```sh -beeper watch -beeper watch --chat 10313 --json -beeper watch --include-type message.upserted --include-type message.deleted -beeper watch --webhook https://example.com/hook --webhook-secret "$BEEPER_WEBHOOK_SECRET" -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper rpc` -Run newline-delimited JSON command RPC over stdin/stdout - -```sh -beeper rpc -``` - -Reads JSON lines like {"id":1,"command":"send text --to 10313 --message hello"} or {"id":1,"args":["status","--json"]}. - -Examples: - -```sh -printf '{"id":1,"command":"chats list --json"}\n' | beeper rpc -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper man` -Print the command manual - -```sh -beeper man -``` - -Examples: - -```sh -beeper man -beeper man --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper doctor` -Probe the target live and report diagnostics - -```sh -beeper doctor -``` - -Active reachability check plus readiness diagnostics. Exits non-zero when the target is not ready. For a cheap snapshot use `beeper status`. - -Examples: - -```sh -beeper doctor -beeper doctor --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper status` -Show selected target and setup readiness - -```sh -beeper status -``` - -Read-only readiness snapshot for the selected target. For active reachability checks and diagnostics, run `beeper doctor`. - -Examples: - -```sh -beeper status -beeper status --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper docs` -Open Beeper CLI docs - -```sh -beeper docs -``` - -Examples: - -```sh -beeper docs -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper version` -Print CLI version - -```sh -beeper version -``` - -Examples: - -```sh -beeper version -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper completion` -Print shell completion setup - -```sh -beeper completion [shell] -``` - -Print static shell completion setup for bash, zsh, fish, or PowerShell. Pass `--semantic` to print a small supplementary snippet that adds live suggestions for `--chat`, `--to`, `--account`, and `--target` by calling back into `beeper _complete`. Source it after the static completion setup. - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `shell` | no | Shell to set up (bash, zsh, fish, or powershell) | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `-r, --refresh-cache` | boolean | Refresh the autocomplete cache before printing setup | -| `--semantic` | boolean | Print a semantic-completion snippet (chats/accounts/targets) for bash or zsh | - -Examples: - -```sh -beeper completion -``` - -### `beeper plugins` -Manage Beeper CLI plugins - -```sh -beeper plugins -``` - -List recommended Beeper CLI plugins, or use oclif plugin commands to install, link, update, and remove plugins. - -Examples: - -```sh -beeper plugins -beeper plugins install @beeper/cli-plugin-cloudflare -``` - -### `beeper plugins available` -List recommended optional Beeper CLI plugins - -```sh -beeper plugins available -``` - -Examples: - -```sh -beeper plugins available -beeper plugins available --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper update` -Check and install Beeper updates - -```sh -beeper update -``` - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--check` | boolean | Only check for updates; do not install | -| `--cli` | boolean | Check the Beeper CLI package | -| `--desktop` | boolean | Check the CLI-owned Desktop install | -| `--server` | boolean | Check the CLI-owned Server install | - -Examples: - -```sh -beeper update --check -beeper update --cli -beeper update --server -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper config get` -Print CLI configuration - -```sh -beeper config get [key] -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `key` | no | Optional config key to print | - -Examples: - -```sh -beeper config get -beeper config get defaultTarget -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper config set` -Set a CLI configuration value - -```sh -beeper config set -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `key` | yes | Config key to set | -| `value` | yes | Config value (pass "" to clear) | - -Examples: - -```sh -beeper config set defaultTarget work -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper config path` -Print the CLI config path - -```sh -beeper config path -``` - -Examples: - -```sh -beeper config path -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper config reset` -Reset CLI configuration - -```sh -beeper config reset -``` - -Examples: - -```sh -beeper config reset -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper api get` -Call a raw Desktop API GET path - -```sh -beeper api get -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `path` | yes | API path, for example /v1/info | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--no-auth` | boolean | Call a public API path without a bearer token | - -Examples: - -```sh -beeper api get /v1/info -beeper api get /v1/chats --json -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper api post` -Call a raw Desktop API POST path with a JSON body - -```sh -beeper api post -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `path` | yes | API path, for example /v1/messages/{chatID}/send | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--body=` | option | JSON request body Default: {} | -| `--no-auth` | boolean | Call a public API path without a bearer token | - -Examples: - -```sh -beeper api post /v1/chats/abc/read --body '{"messageID":"x"}' -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -### `beeper api request` -Call a raw Desktop API path with any supported HTTP method - -```sh -beeper api request -``` - -Arguments: - -| Name | Required | Description | -| --- | --- | --- | -| `method` | yes | HTTP method | -| `path` | yes | API path, for example /v1/info | - -Flags: - -| Flag | Type | Description | -| --- | --- | --- | -| `--body=` | option | JSON request body | -| `--no-auth` | boolean | Call a public API path without a bearer token | - -Examples: - -```sh -beeper api request DELETE /v1/chats/abc/messages/def/reactions --body '{"reactionKey":"👍"}' -``` - -Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. - -## Publishing - -Beeper CLI releases ship signed macOS Bun binaries inside versioned archives and -a thin npm package that downloads, verifies, extracts, and runs the matching -GitHub Release archive. - -For now, publishing runs from a local macOS machine: - -```sh -bun run release 0.6.1 -``` - -The local release command: - -- builds standalone Bun binaries -- signs and notarizes macOS binaries when local signing credentials are available -- uploads versioned macOS and Linux archives to the GitHub release -- publishes `beeper-cli` to npm as a thin binary launcher package -- updates `beeper/homebrew-tap` with the pinned archive SHA - -Required local credentials: - -- GitHub CLI authenticated with release and tap access -- npm auth for publishing `beeper-cli` -- local Developer ID signing identity, or Fastlane match access via a - `MOBILE_SECRETS_FILE` path exported in your shell -- `HOMEBREW_TAP_GITHUB_TOKEN` for updating the tap - -## Inspiration - -- [wacli](https://wacli.sh/) — scriptable WhatsApp CLI whose command-line product shape we borrow from. +The package entrypoint is `packages/cli/bin/cli.js`; local development uses +`packages/cli/bin/dev.js`. ## License -MIT — see [`packages/cli/LICENSE`](packages/cli/LICENSE). +MIT. See `LICENSE`. diff --git a/packages/cli/SECURITY.md b/packages/cli/SECURITY.md deleted file mode 100644 index 60d38a66..00000000 --- a/packages/cli/SECURITY.md +++ /dev/null @@ -1,27 +0,0 @@ -# Security Policy - -## Reporting Security Issues - -This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. - -To report a security issue, please contact the Stainless team at security@stainless.com. - -## Responsible Disclosure - -We appreciate the efforts of security researchers and individuals who help us maintain the security of -SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible -disclosure practices by allowing us a reasonable amount of time to investigate and address the issue -before making any information public. - -## Reporting Non-SDK Related Security Issues - -If you encounter security issues that are not directly related to SDKs but pertain to the services -or products provided by Beeper Desktop, please follow the respective company's security reporting guidelines. - -### Beeper Desktop Terms and Policies - -Please contact security@beeper.com for any questions or concerns regarding the security of our services. - ---- - -Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/packages/cli/beeper-setup-redesign-spec.md b/packages/cli/beeper-setup-redesign-spec.md deleted file mode 100644 index e3767d5a..00000000 --- a/packages/cli/beeper-setup-redesign-spec.md +++ /dev/null @@ -1,475 +0,0 @@ -# Beeper CLI Setup Redesign Plan - -## Goal - -`beeper setup` should make Beeper CLI usable with the least possible explanation. - -For most Desktop users, the happy path should be: - -```sh -beeper setup -``` - -Then: - -```txt -Found Beeper Desktop on this device. - -Signed in as: you@beeper.com -Connected accounts: iMessage, WhatsApp, Signal - -Use this Desktop session for CLI access? [Y/n] -``` - -Pressing Enter should leave the CLI ready. - -## Critique Of The Draft - -The intern proposal has the right product instinct, but it over-expands the public surface. - -Keep: - -- Desktop-first setup. -- Local Desktop session as the recommended path. -- OAuth/PKCE as the limited-permission path. -- Remote Desktop/Server setup. -- Install Desktop/Server from setup when explicitly confirmed. -- Concrete prompts that show the detected account and connected accounts. -- Every interactive choice having a command or flag equivalent. - -Reject or defer: - -- A new public `connections` model. The CLI already has targets; use them. -- `beeper auth list/use/desktop/remote/manual` in the first pass. That recreates target management under another noun. -- Public `beeper setup desktop|server|remote|manual` subcommands for v1. They are redundant if flags and existing target/install commands exist. -- Keychain as a blocker. Store like the current CLI first, add keychain later. -- Email-code setup flags as the main Desktop experience. Normal users receive codes asynchronously, and installed Desktop may not expose the setup-login routes. Desktop login should be driven by Desktop itself unless the Server/headless API explicitly supports the flow. - -## Product Model - -Use one object: **target**. - -A target is a runnable or reachable Beeper endpoint/profile: - -- Built-in local Desktop target: `desktop` -- Managed Desktop profile: `targets create desktop ` -- Managed Server profile: `targets create server ` -- Remote Desktop or Server: `targets add remote ` -- One-off URL: `--base-url` - -Auth is target-scoped metadata, not a separate public object. - -`setup` is the guided orchestrator that creates, selects, starts, installs, and authenticates targets by calling the same primitives users can run directly. - -## Public Command Shape - -Keep the public command tree narrow: - -```sh -beeper setup -beeper setup --local -beeper setup --oauth -beeper setup --remote URL -beeper setup --server -beeper setup --desktop -beeper setup --install -beeper setup --yes - -beeper targets ... -beeper install desktop -beeper install server -beeper auth status -beeper auth logout -beeper verify ... -beeper doctor -``` - -Do not add these in the first pass: - -```sh -beeper setup desktop -beeper setup server -beeper setup remote -beeper auth list -beeper auth use -beeper auth desktop -beeper auth remote -beeper auth manual -``` - -If we later need script-focused auth commands, add them only after targets/setup prove insufficient. - -## Setup Modes - -### `beeper setup` - -Interactive, magical default. - -Detection order: - -1. Existing configured default target. -2. Detected local Beeper Desktop. -3. Running local Desktop API. -4. Desktop login/session state. -5. Local Desktop DB/cache/session token availability. -6. Connected accounts via Desktop API when reachable. -7. Managed Server install/config. -8. Remote URL from env/config. - -Behavior: - -- If a ready target already exists, show it and offer to keep, repair, switch, or add another target. -- If local Desktop is signed in and local session auth is readable, recommend direct Desktop connection. -- If local Desktop is installed but logged out, launch Desktop and ask the user to sign in there. -- If Desktop is missing, offer Desktop install only on supported GUI platforms. -- If Server is requested or Desktop is unsuitable, offer Server install/setup. -- If remote URL is supplied or selected, use PKCE against that target. - -### `beeper setup --local` - -Explicit local Desktop DB/cache/session path. - -Use when: - -- Desktop is installed locally. -- The user wants the fastest trusted-device path. -- QA wants to test the direct local Desktop flow without menu interaction. - -This should: - -1. Detect Desktop app/profile. -2. Read local session/token from Desktop state. -3. Materialize or update target `desktop`. -4. Store target auth with `source: "desktop-db"` or `source: "desktop-cache"`. -5. Fetch readiness and connected accounts. - -### `beeper setup --oauth` - -Explicit browser-authorized path for the resolved target. - -Use when: - -- The user wants limited/revocable permissions. -- The target is remote. -- Local Desktop DB/cache auth is unavailable or declined. - -This should use the existing PKCE implementation and store auth with: - -```ts -source: "desktop-oauth" | "remote-oauth" -``` - -### `beeper setup --remote URL` - -Shortcut for: - -1. Create or update a remote target for `URL`. -2. Use OAuth/PKCE. -3. Optionally make it default after confirmation. - -Equivalent primitive path: - -```sh -beeper targets add remote -beeper setup -t --oauth -beeper targets use -``` - -### `beeper setup --server` - -Guided Server setup. - -If no local server binary exists: - -- Prompt to install. -- Require `--install --yes` for non-interactive download/install. -- Use staging only when requested by target/server env flags in tests. - -Equivalent primitive path: - -```sh -beeper install server -beeper targets create server -beeper targets start -beeper setup -t --oauth -``` - -### `beeper setup --desktop --install` - -Guided Desktop install/setup. - -Equivalent primitive path: - -```sh -beeper install desktop -beeper targets create desktop -beeper targets start -beeper setup -t --local -``` - -## Target Schema - -Extend the current target model; do not create a separate connection schema. - -```ts -type AuthSource = - | "desktop-db" - | "desktop-cache" - | "desktop-oauth" - | "remote-oauth" - | "manual"; - -type StoredAuth = { - accessToken: string; - clientID?: string; - expiresAt?: string; - scope?: string; - tokenType: "Bearer"; - source?: AuthSource; -}; -``` - -Target records should contain stable endpoint/profile/runtime metadata. - -Volatile facts should go in cache: - -- Desktop app version. -- Reachability. -- Current signed-in user. -- Connected account summary. -- Readiness state. - -## Built-In Desktop Target - -Stop creating `personal`. - -Use `desktop` as the built-in local Desktop target when a selector is needed. - -User-facing UI should not lead with the ID: - -```txt -Current connection: - Beeper Desktop v4.2.842 on this device - connected directly -``` - -For named targets, use target language: - -```txt -Default target: - work-server - Remote Beeper Server - https://beeper.example.com -``` - -`beeper targets list` may show: - -```txt -Built-in: - desktop Beeper Desktop on this device connected directly - -Targets: - work-server remote server https://beeper.example.com - local-server managed server http://127.0.0.1:23374 -``` - -Removing `desktop` means forget CLI state only. Do not uninstall Desktop, delete Desktop data, or revoke anything remote unless a separate explicit command asks for it. - -## UX Copy - -Avoid scary or implementation-first language in the main path. - -Avoid: - -- full access -- unrestricted -- extract token -- scrape database -- read DB - -Use: - -- Use your existing Desktop session. -- Connect directly to Beeper Desktop. -- Authorize with browser. -- Limited permissions. -- Connected accounts. - -Advanced/debug output can be explicit: - -```txt -Auth source: desktop-db -Requests: Desktop API at http://127.0.0.1:23373 -``` - -## Readiness And Repair - -`setup`, `status`, `doctor`, and `verify` must share the same readiness evaluator. - -States: - -```txt -no-target -target-unreachable -needs-login -login-in-progress -initializing -needs-cross-signing-setup -needs-verification -verification-in-progress -needs-recovery-key -needs-secrets -needs-first-sync -ready -error -``` - -Setup should never dead-end on these states. It should show the next repair action: - -- `target-unreachable`: start target, install runtime, or fix URL. -- `needs-login`: Desktop users sign in in Desktop; Server/headless may use supported setup API if available. -- `needs-verification`: run or continue `verify`. -- `needs-recovery-key` / `needs-secrets`: run recovery-key flow. -- `needs-first-sync`: wait with events, and resume on rerun. - -Ctrl+C while waiting should not cancel remote verification or sync. Print: - -```txt -Run `beeper setup` to continue. -``` - -## Non-Interactive Contract - -No prompts when: - -- `--json` -- non-TTY -- `--yes` - -If blocked on a human action, return JSON error on stderr: - -```json -{"success":false,"data":null,"error":"Desktop sign-in required"} -``` - -Include current state and available actions in `data` when possible. - -Downloads/installs require: - -```sh ---install --yes -``` - -## Implementation Plan - -1. Revert public email/code setup flags from the main UX. - - Do not make OTP login the normal Desktop setup path. - - Keep any Server/headless setup API helper internal until verified against live Server. - -2. Replace `personal` with built-in `desktop`. - - Materialize `desktop` when detected or selected. - - Update `resolveTarget`, `targets list`, `targets remove`, setup docs, and smoke tests. - -3. Add auth source metadata. - - Extend `StoredAuth.source`. - - Populate for local Desktop, Desktop OAuth, remote OAuth, and manual token paths. - -4. Implement local Desktop direct auth. - - Inspect Desktop app/profile/session state. - - Read Matrix access token from local Desktop state/cache using structured storage access, not ad hoc text parsing. - - Cache target auth and verify with Desktop API or Matrix-backed API calls. - - Show connected accounts before confirmation when possible. - -5. Keep OAuth as setup mode, not auth namespace sprawl. - - Add `setup --oauth`. - - Reuse existing PKCE implementation. - - Support `setup -t --oauth` and `setup --remote URL`. - -6. Make setup orchestrate installs. - - Use existing `install desktop` and `install server`. - - Never download in non-interactive mode without `--install --yes`. - - Preserve `--server-env staging` for tests. - -7. Use primitives for direct testing. - - `targets create/start/status/logs` - - `install desktop/server` - - `setup --local` - - `setup --oauth` - - `setup --remote` - - `verify ...` - -8. Keep `auth` narrow. - - `auth status` - - `auth logout` - - Add revoke/list/use only if target commands cannot cover the need. - -9. Regenerate docs from metadata. - - README and man output should describe `setup` as guided orchestration. - - Advanced examples should show primitive equivalents. - -10. Update E2E staging scripts. - - Test primitives directly first. - - Test `beeper setup` as a shortcut over those primitives. - - Fail fast on required phase failures. - - Continue to isolate config, ports, and profiles from the default Desktop instance. - -## Test Plan - -### Local Unit/Smoke - -- Command tree still matches the nuclear redesign. -- No new public `auth login/list/use` unless deliberately added. -- `setup --json` returns stable envelopes. -- `setup --read-only` refuses writes. -- `setup --install --yes` is required for downloads. -- `targets list` shows built-in Desktop distinctly from named targets. -- `targets remove desktop` forgets CLI state only. - -### Local Desktop - -- Running signed-in Desktop: - - `setup --local` detects user and connected accounts. - - Auth source is `desktop-db` or `desktop-cache`. - - `status`, `accounts list`, `chats list` work through the configured target. - -- Installed but logged-out Desktop: - - `setup` launches or points to Desktop sign-in. - - Rerunning `setup` resumes after sign-in. - -- No Desktop: - - `setup` offers install only where supported. - - Non-interactive mode returns actionable JSON instead of prompting. - -### OAuth / Remote - -- `setup --oauth` uses PKCE for the resolved target. -- `setup --remote URL` creates/updates a remote target and authenticates. -- Remote targets reject local runtime commands with clear errors. - -### Server - -- `install server --server-env staging` installs the staging binary. -- `targets create server/start/status/logs/stop` work. -- `setup -t --oauth` works when Server supports PKCE. -- Server/headless email-code setup is tested only through verified supported routes, not assumed from Desktop. - -### Multi-Target Staging - -- Create three isolated targets: - - one managed Desktop profile - - two managed Server profiles -- Provide staging account emails and verification codes through environment variables only in the scripts that target verified setup APIs. -- Start all targets on non-default ports. -- Authenticate each target through the appropriate setup mode. -- Run device-to-device verification between two signed-in targets. -- Send messages between targets. -- Cleanup stops managed Server targets and records any Desktop target that must be quit manually. - -## Acceptance Criteria - -- `beeper setup` feels like a product wizard, not an API debugger. -- The default Desktop path succeeds without asking users to understand tokens, ports, profiles, or OAuth. -- Every wizard choice has a direct command or flag equivalent. -- The public model remains one target system, not targets plus connections. -- E2E scripts test direct primitives and then setup shortcuts. -- The default Desktop instance is never modified during staging tests unless explicitly requested. diff --git a/packages/cli/bin/binary-bootstrap.js b/packages/cli/bin/binary-bootstrap.js deleted file mode 100644 index 72cff777..00000000 --- a/packages/cli/bin/binary-bootstrap.js +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bun -import payloadArchive from '../dist/binary-payload.tar.gz' with { type: 'file' } -import { createHash } from 'node:crypto' -import { existsSync } from 'node:fs' -import { mkdir, readFile } from 'node:fs/promises' -import { homedir, tmpdir } from 'node:os' -import { dirname, join } from 'node:path' - -void (async () => { - const archive = await readFile(payloadArchive) - const payloadHash = createHash('sha256').update(archive).digest('hex').slice(0, 16) - const cacheRoot = process.env.BEEPER_CLI_BINARY_CACHE_DIR || join(homedir(), '.cache', 'beeper-cli', 'binary') - const payloadRoot = join(cacheRoot, payloadHash) - const entrypoint = join(payloadRoot, 'bin', 'cli.js') - - if (!existsSync(entrypoint)) { - const tempArchive = join(tmpdir(), `beeper-cli-${payloadHash}.tar.gz`) - await mkdir(dirname(tempArchive), { recursive: true }) - await mkdir(payloadRoot, { recursive: true }) - await Bun.write(tempArchive, archive) - await run('tar', ['-xzf', tempArchive, '-C', payloadRoot]) - } - - const child = Bun.spawn([process.execPath, entrypoint, ...process.argv.slice(2)], { - env: { - ...process.env, - BUN_BE_BUN: '1', - }, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }) - - const code = await child.exited - if (child.signalCode) process.kill(process.pid, child.signalCode) - process.exit(code ?? 1) -})() - -async function run(command, args) { - const child = Bun.spawn([command, ...args], { - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }) - const code = await child.exited - if (code !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${code}`) -} diff --git a/packages/cli/bin/check-release-environment b/packages/cli/bin/check-release-environment deleted file mode 100644 index 5c60e10e..00000000 --- a/packages/cli/bin/check-release-environment +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -cd "${root}" - -errors=() - -for path in package.json bun.lock scripts/release.ts packages/npm/package.json packages/npm/scripts/build.ts packages/cli/scripts/build-homebrew-archive.ts packages/cli/scripts/publish-homebrew-formula.ts packages/cli/scripts/publish-local-release.ts packages/cli/scripts/sign-macos-binaries.ts .github/workflows/publish-release.yml; do - if [[ ! -f "${path}" ]]; then - errors+=("Missing required release file: ${path}") - fi -done - -for command in bun git gh npm tar; do - if ! command -v "${command}" >/dev/null 2>&1; then - errors+=("Missing required release command: ${command}") - fi -done - -lenErrors=${#errors[@]} - -if [[ lenErrors -gt 0 ]]; then - echo -e "Found the following errors in the release environment:\n" - - for error in "${errors[@]}"; do - echo -e "- $error\n" - done - - exit 1 -fi - -echo "The environment is ready to push releases!" diff --git a/packages/cli/bin/cli.js b/packages/cli/bin/cli.js old mode 100644 new mode 100755 index d05ebe6e..7ecabef0 --- a/packages/cli/bin/cli.js +++ b/packages/cli/bin/cli.js @@ -1,11 +1,4 @@ #!/usr/bin/env bun -import { execute } from '@oclif/core' -import { renderStartupLogo } from './logo.js' +import { runCli } from '../dist/cli/main.js' -void (async () => { - if (process.argv.slice(2).length === 0 && process.env.BEEPER_NO_LOGO !== '1') { - process.stdout.write(`${renderStartupLogo()}\n\n`) - } - - await execute({ dir: import.meta.url }) -})() +await runCli() diff --git a/packages/cli/bin/dev.js b/packages/cli/bin/dev.js index a8376fa5..1b8512eb 100755 --- a/packages/cli/bin/dev.js +++ b/packages/cli/bin/dev.js @@ -1,11 +1,4 @@ #!/usr/bin/env bun -import { execute } from '@oclif/core' -import { renderStartupLogo } from './logo.js' +import { runCli } from '../src/cli/main.ts' -void (async () => { - if (process.argv.slice(2).length === 0 && process.env.BEEPER_NO_LOGO !== '1') { - process.stdout.write(`${renderStartupLogo()}\n\n`) - } - - await execute({ development: true, dir: import.meta.url }) -})() +await runCli() diff --git a/packages/cli/bin/logo.js b/packages/cli/bin/logo.js deleted file mode 100644 index 392bb50f..00000000 --- a/packages/cli/bin/logo.js +++ /dev/null @@ -1,97 +0,0 @@ -const iconSource = String.raw` - @@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@ @@@@@@@@@ - @@@@@@@ @@@@@@@ - @@@@@@ @@@@@@ - @@@@@ @@@@@ -@@@@@ @@@@@ -@@@@@ @@@@@ -@@@@@ @@@@@ -@@@@@ @@@@@ -@@@@@ @@@@ - @@@@@ @@@@@ - @@@@@@ @@@@@@ - @@@@@@@ @@@@@@@ - @@@@@@@@@@@ @@@@@@@ - @@@@@@@@@@@@@@ @@@@@@@ - @@@@@@@@@@@@@@ @@@@@@@@ - @@@@@@@@@@@@@@ @@@@@@@@@@@@@ - @@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ -` - -const wordmarkSource = String.raw` -@@@@@@@ @@@@@@@@ @@@@@@@@ @@@@@@@ @@@@@@@@ @@@@@@@ -@@ @@ @@ @@ @@ @@ @@ @@ @@ -@@@@@@@ @@@@@@ @@@@@@ @@@@@@@ @@@@@@ @@@@@@@ -@@ @@ @@ @@ @@ @@ @@ @@ -@@ @@ @@ @@ @@ @@ @@ @@ -@@@@@@@ @@@@@@@@ @@@@@@@@ @@ @@@@@@@@ @@ @@ -` - -const normalize = source => { - const lines = source.trim().split('\n') - const width = Math.max(...lines.map(line => line.length)) - return lines.map(line => line.padEnd(width, ' ')) -} - -const scale = (source, width, height) => { - const lines = normalize(source) - const sourceHeight = lines.length - const sourceWidth = lines[0]?.length ?? 0 - const result = [] - - for (let y = 0; y < height; y += 1) { - const sourceY = Math.min(sourceHeight - 1, Math.floor(((y + 0.5) / height) * sourceHeight)) - let line = '' - - for (let x = 0; x < width; x += 1) { - const sourceX = Math.min(sourceWidth - 1, Math.floor(((x + 0.5) / width) * sourceWidth)) - line += lines[sourceY]?.[sourceX] === '@' ? '@' : ' ' - } - - result.push(line) - } - - return result -} - -const combine = (icon, wordmark, gap) => { - const iconWidth = icon[0]?.length ?? 0 - const height = Math.max(icon.length, wordmark.length) - const wordTop = Math.max(0, Math.floor((height - wordmark.length) / 2)) - const result = [] - - for (let y = 0; y < height; y += 1) { - const iconLine = icon[y] ?? ' '.repeat(iconWidth) - const wordLine = wordmark[y - wordTop] ?? '' - result.push(`${iconLine}${' '.repeat(gap)}${wordLine}`.trimEnd()) - } - - return result -} - -export function renderStartupLogo(columns = process.stdout.columns ?? 80) { - const maxWidth = Math.max(36, columns - 2) - const gap = maxWidth < 60 ? 2 : 4 - const iconWidth = Math.min(20, Math.max(14, Math.floor(maxWidth * 0.25))) - const iconHeight = Math.max(8, Math.round(iconWidth * 0.55)) - const wordWidth = Math.max(20, maxWidth - iconWidth - gap) - const wordHeight = 6 - - const icon = scale(iconSource, iconWidth, iconHeight) - const wordmark = scale(wordmarkSource, wordWidth, wordHeight) - - return combine(icon, wordmark, gap).join('\n') -} diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js deleted file mode 100755 index de26bc2c..00000000 --- a/packages/cli/bin/run.js +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env node -import { createHash } from 'node:crypto' -import { createWriteStream, existsSync } from 'node:fs' -import { chmod, mkdir, readFile, rename, rm } from 'node:fs/promises' -import { get } from 'node:https' -import { homedir, tmpdir } from 'node:os' -import { basename, dirname, join } from 'node:path' -import { pipeline } from 'node:stream/promises' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))) -const pkg = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) -const version = pkg.version -const platform = normalizePlatform(process.platform) -const arch = normalizeArch(process.arch) -const releaseTag = process.env.BEEPER_CLI_RELEASE_TAG || `v${version}` -const releaseRepository = process.env.GITHUB_REPOSITORY || 'beeper/cli' -const releaseBaseURL = (process.env.BEEPER_CLI_RELEASE_BASE_URL || `https://github.com/${releaseRepository}/releases/download/${releaseTag}`).replace(/\/$/, '') -const cacheRoot = process.env.BEEPER_CLI_BINARY_CACHE_DIR || join(homedir(), '.cache', 'beeper-cli') -const cacheDir = join(cacheRoot, version, `${platform}-${arch}`) -const cachedExecutable = join(cacheDir, platform === 'windows' ? 'beeper.exe' : 'beeper') - -try { - const executable = await ensureExecutable() - const child = spawn(executable, process.argv.slice(2), { - env: process.env, - stdio: 'inherit', - }) - child.once('exit', (code, signal) => { - if (signal) process.kill(process.pid, signal) - process.exit(code ?? 1) - }) - child.once('error', error => { - console.error(`beeper-cli: failed to start downloaded binary: ${error.message}`) - process.exit(1) - }) -} catch (error) { - console.error(`beeper-cli: ${error instanceof Error ? error.message : String(error)}`) - process.exit(1) -} - -async function ensureExecutable() { - if (existsSync(cachedExecutable)) return cachedExecutable - - const artifact = await fetchArtifact() - const tmpDir = join(tmpdir(), `beeper-cli-${version}-${process.pid}-${Date.now()}`) - const tmpPath = join(tmpDir, artifact.file) - const url = `${releaseBaseURL}/${artifact.file}` - console.error(`beeper-cli: downloading ${url}`) - await rm(tmpDir, { recursive: true, force: true }) - await mkdir(tmpDir, { recursive: true }) - await download(url, tmpPath) - - const actualHash = await sha256(tmpPath) - if (actualHash !== artifact.sha256) { - await rm(tmpDir, { recursive: true, force: true }) - throw new Error(`downloaded archive checksum mismatch for ${artifact.file}`) - } - - await extract(tmpPath, tmpDir) - const extractedExecutable = join(tmpDir, 'bin', platform === 'windows' ? 'beeper.exe' : 'beeper') - if (platform !== 'windows') await chmod(extractedExecutable, 0o755) - await rm(cacheDir, { recursive: true, force: true }) - await mkdir(cacheDir, { recursive: true }) - await rename(extractedExecutable, cachedExecutable) - await rm(tmpDir, { recursive: true, force: true }) - return cachedExecutable -} - -function normalizePlatform(value) { - if (value === 'darwin') return 'darwin' - if (value === 'linux') return 'linux' - if (value === 'win32') return 'windows' - throw new Error(`unsupported platform: ${value}`) -} - -function normalizeArch(value) { - if (value === 'x64') return 'x64' - if (value === 'arm64') return 'arm64' - throw new Error(`unsupported architecture: ${value}`) -} - -async function fetchArtifact() { - const manifestURL = `${releaseBaseURL}/binaries.json` - const manifestPath = join(tmpdir(), `beeper-cli-binaries-${version}-${process.pid}-${Date.now()}.json`) - try { - await download(manifestURL, manifestPath, { quiet: true }) - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) - const artifact = manifest.artifacts?.find(artifact => artifact.platform === `${platform}-${arch}`) - if (!artifact) throw new Error(`no release archive found for ${platform}-${arch}`) - return artifact - } finally { - await rm(manifestPath, { force: true }) - } -} - -async function download(url, destination, options = {}, redirectCount = 0) { - if (redirectCount > 10) throw new Error(`too many redirects while downloading ${basename(url)}`) - await mkdir(dirname(destination), { recursive: true }) - await new Promise((resolve, reject) => { - const request = get(url, response => { - if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { - response.resume() - download(new URL(response.headers.location, url).toString(), destination, options, redirectCount + 1).then(resolve, reject) - return - } - if (response.statusCode !== 200) { - response.resume() - reject(new Error(`download failed for ${basename(url)}: HTTP ${response.statusCode}`)) - return - } - pipeline(response, createWriteStream(destination)).then(resolve, reject) - }) - request.once('error', reject) - request.setTimeout(120_000, () => request.destroy(new Error(`download timed out: ${url}`))) - }) -} - -async function sha256(path) { - return createHash('sha256').update(await readFile(path)).digest('hex') -} - -async function extract(archivePath, destination) { - if (archivePath.endsWith('.zip')) { - await run('/usr/bin/ditto', ['-x', '-k', archivePath, destination]) - return - } - if (archivePath.endsWith('.tar.gz')) { - await run('tar', ['-xzf', archivePath, '-C', destination]) - return - } - throw new Error(`unsupported release archive: ${basename(archivePath)}`) -} - -async function run(command, args) { - await new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: 'ignore' }) - child.once('error', reject) - child.once('exit', code => { - if (code === 0) resolve() - else reject(new Error(`${command} ${args.join(' ')} exited with ${code}`)) - }) - }) -} diff --git a/packages/cli/docs/accounts.md b/packages/cli/docs/accounts.md deleted file mode 100644 index 11610a42..00000000 --- a/packages/cli/docs/accounts.md +++ /dev/null @@ -1,49 +0,0 @@ -# accounts - -Read when: listing or adding chat-network accounts (WhatsApp, Discord, -iMessage, etc.), choosing a default account for `--account`-filtered -commands, or removing one. - -## Commands - -```sh -beeper accounts list [--account SELECTOR]... [--ids] -beeper accounts add [bridge] [--flow ID] [--login-id ID] [--cookie name=value]... [--field id=value]... [--webview] [--non-interactive] [--no-guided] -beeper accounts show -beeper accounts use # "" clears defaultAccount -beeper accounts remove -``` - -## Notes - -- An *account selector* matches by account ID, network name, bridge type/id, - or user identity (display name, username, email, phone). -- A *bridge* is the connector used to add or reconnect a chat account. -- `accounts add` without a bridge opens the account-connection chooser. -- `bridges list` is the scriptable catalog; `accounts add` is the guided - account connection flow. -- `accounts use NAME` persists `defaultAccount` in CLI config. Subsequent - account-scoped commands fall back to that default when `--account` is - omitted. -- `accounts use ""` clears the default. -- `accounts list --json` annotates the default account with `default: true`. -- For non-interactive sign-in, pass `--flow`, `--field`, and `--cookie` and - add `--non-interactive` to fail instead of prompting. -- For cookie-based sign-in, `--webview` can use Bun.WebView with Chrome to - collect cookie fields before falling back to prompts. Chrome remote debugging - must be enabled for a visible interactive tab; otherwise Bun may spawn a - headless browser. - -## Examples - -```sh -beeper accounts list --json -beeper bridges list -beeper accounts add local-whatsapp -beeper accounts add discord --non-interactive --cookie sessionid=… -beeper accounts add discord --webview --webview-backend chrome -beeper accounts use whatsapp-main -beeper accounts use "" -beeper accounts show whatsapp-main --json -beeper accounts remove whatsapp-main -``` diff --git a/packages/cli/docs/api.md b/packages/cli/docs/api.md deleted file mode 100644 index b9cf1de1..00000000 --- a/packages/cli/docs/api.md +++ /dev/null @@ -1,29 +0,0 @@ -# api - -Read when: calling raw Desktop API endpoints that the CLI doesn't yet wrap -with a workflow command. - -## Commands - -```sh -beeper api get [--no-auth] -beeper api post [--body JSON] [--no-auth] -beeper api request [--body JSON] [--no-auth] -``` - -## Notes - -- `` is a Desktop API path, e.g. `/v1/info` or `/v1/chats/{chatID}/read`. -- `--no-auth` calls a public path without the bearer token. -- `--body` is sent as `application/json`; default is `{}` for `post`. -- `api request` lets you hit `GET | POST | PUT | PATCH | DELETE`; the others are convenience shortcuts. -- `--read-only` blocks `api post` / `api put` / `api patch` / `api delete` / `api request `. - -## Examples - -```sh -beeper api get /v1/info -beeper api get /v1/chats --json -beeper api post /v1/chats/abc/read --body '{"messageID":"x"}' -beeper api request PATCH /v1/chats/abc --body '{"isPinned":true}' -``` diff --git a/packages/cli/docs/auth.md b/packages/cli/docs/auth.md deleted file mode 100644 index dfd1bbff..00000000 --- a/packages/cli/docs/auth.md +++ /dev/null @@ -1,46 +0,0 @@ -# auth - -Read when: checking sign-in status, clearing stored tokens, or driving an -end-to-end device-verification flow for encrypted messages. - -`auth` commands inspect and manage CLI-side authentication state and -encryption-readiness. The selected target's stored OAuth token lives in the -target file under `~/.beeper/targets/`; `BEEPER_ACCESS_TOKEN` overrides it. - -## Commands - -```sh -beeper auth status -beeper auth logout -beeper auth verify [--user @id] # interactive happy-path -beeper auth verify start [--user @id] # individual steps -beeper auth verify status -beeper auth verify list | show -beeper auth verify approve [--id active] [--code …] -beeper auth verify sas -beeper auth verify sas-confirm -beeper auth verify qr-scan --payload -beeper auth verify qr-confirm -beeper auth verify recovery-key [--code KEY] -beeper auth verify reset-recovery-key -beeper auth verify cancel -``` - -## Notes - -- `auth status` reports the token source (env vs. target file) and metadata; it does not call the network. -- `auth logout` revokes the token at the Desktop OAuth endpoint and clears the local copy. -- `auth verify` (no subcommand) walks the most common SAS/emoji verification flow interactively. -- For agents, drive the explicit subcommands (`start` → `sas` → `sas-confirm`) and use `--json` to inspect state. -- `verify status` returns the encryption-readiness state (`ready`, `needs-verification`, `verification-in-progress`). -- `recovery-key` and `reset-recovery-key` apply to the encrypted-messages key, not to Beeper account login. - -## Examples - -```sh -beeper auth status --json -beeper auth verify -beeper auth verify recovery-key --code ABCD-EFGH-IJKL-MNOP -beeper auth verify reset-recovery-key -beeper auth logout -``` diff --git a/packages/cli/docs/chats.md b/packages/cli/docs/chats.md deleted file mode 100644 index 11f9f361..00000000 --- a/packages/cli/docs/chats.md +++ /dev/null @@ -1,56 +0,0 @@ -# chats - -Read when: listing, searching, inspecting, or changing chat state — archive, -pin, mute, mark-read, priority (Inbox vs Low Priority), rename, draft, focus, -disappear timer, reminders. - -## Commands - -```sh -beeper chats list [--account SEL]... [--archived] [--pinned] [--muted] [--unread] [--low-priority] [--limit N] [--ids] -beeper chats search [--account SEL]... [--limit N] [--ids] -beeper chats show --chat SEL [--max-participants N] [--pick N] -beeper chats start [--account SEL] [--title TEXT] -beeper chats archive | unarchive --chat SEL [--pick N] -beeper chats pin | unpin --chat SEL [--pick N] -beeper chats mute | unmute --chat SEL [--pick N] -beeper chats mark-read | mark-unread --chat SEL [--message MSG_ID] [--pick N] -beeper chats priority --chat SEL --level inbox|low [--pick N] -beeper chats notify-anyway --chat SEL [--pick N] -beeper chats rename --chat SEL --title NEW [--pick N] -beeper chats description --chat SEL [--description TEXT | --clear] [--pick N] -beeper chats avatar --chat SEL [--file PATH | --clear] [--pick N] -beeper chats draft --chat SEL [--text TEXT [--file PATH [--filename N] [--mime TYPE]] | --clear] [--pick N] -beeper chats disappear --chat SEL --seconds N [--pick N] -beeper chats remind --chat SEL --when ISO [--dismiss-on-message] [--pick N] -beeper chats unremind --chat SEL [--pick N] -beeper chats focus --chat SEL [--message MSG_ID] [--draft TEXT] [--attachment PATH] [--pick N] -``` - -## Notes - -- All `--chat` flags accept a Beeper chat ID, a local chat ID, the exact title, or search text. -- Ambiguous matches return numbered choices; pass `--pick N` to select one. -- `chats list` filters compose: e.g. `--unread --no-muted --pinned` returns only pinned, unread, non-muted chats. -- `chats mute` is currently boolean — the Desktop API does not yet expose a mute duration. -- `chats focus` opens Beeper Desktop on the selected chat (and optionally scrolls to a message or prefills the composer). -- `chats disappear --seconds 0` turns disappearing messages off. -- Labels are not yet supported by the Desktop API; there is no `chats label` command in this CLI. - -## Examples - -```sh -beeper chats list --pinned --limit 50 -beeper chats list --unread --no-muted --json -beeper chats search Family -beeper chats start +15551234567 -beeper chats archive --chat "Family" -beeper chats mute --chat "Marketing" -beeper chats priority --chat "Family" --level inbox -beeper chats rename --chat "Family" --title "Family ❤" -beeper chats draft --chat "Family" --text "on my way" -beeper chats draft --chat "Family" --clear -beeper chats disappear --chat "Friends" --seconds 86400 -beeper chats remind --chat "Family" --when 2026-06-01T09:00:00Z -beeper chats focus --chat "Family" -``` diff --git a/packages/cli/docs/commands/README.md b/packages/cli/docs/commands/README.md new file mode 100644 index 00000000..e2348e0d --- /dev/null +++ b/packages/cli/docs/commands/README.md @@ -0,0 +1,75 @@ +# Command Index + +Generated from the live command registry. Do not edit command pages by hand. + +| Command | Description | Aliases | +| --- | --- | --- | +| [`accounts add`](accounts-add.md) | Connect a chat account by bridge | | +| [`accounts list`](accounts-list.md) | List connected accounts | | +| [`api request`](api-request.md) | Call a raw Desktop API path with any supported HTTP method | | +| [`auth email response`](auth-email-response.md) | Finish email sign-in for a target | | +| [`auth email start`](auth-email-start.md) | Start email sign-in for a target | | +| [`auth logout`](auth-logout.md) | Clear stored authentication | | +| [`chats archive`](chats-archive.md) | Archive or unarchive a chat | | +| [`chats avatar`](chats-avatar.md) | Set or clear a chat avatar | | +| [`chats description`](chats-description.md) | Set or clear a chat description | | +| [`chats disappear`](chats-disappear.md) | Set a disappearing-message timer | | +| [`chats draft`](chats-draft.md) | Set or clear a chat draft | | +| [`chats focus`](chats-focus.md) | Focus a chat in Beeper | | +| [`chats list`](chats-list.md) | List chats | `chats ls` | +| [`chats mute`](chats-mute.md) | Mute or unmute a chat | | +| [`chats notify-anyway`](chats-notify-anyway.md) | Notify a chat anyway | | +| [`chats pin`](chats-pin.md) | Pin or unpin a chat | | +| [`chats priority`](chats-priority.md) | Set chat priority | | +| [`chats read`](chats-read.md) | Mark a chat read or unread | | +| [`chats remind`](chats-remind.md) | Set or clear a chat reminder | | +| [`chats rename`](chats-rename.md) | Rename a chat | | +| [`chats show`](chats-show.md) | Show chat details | | +| [`chats start`](chats-start.md) | Start a chat | | +| [`completion`](completion.md) | Generate shell completion scripts | | +| [`config get`](config-get.md) | Get a config value | `config show` | +| [`config keys`](config-keys.md) | List available config keys | `config list-keys`, `config names` | +| [`config list`](config-list.md) | List all config values | `config ls`, `config all` | +| [`config path`](config-path.md) | Print config file path | `config where` | +| [`config set`](config-set.md) | Set a config value | `config add`, `config update` | +| [`config unset`](config-unset.md) | Unset a config value | `config rm`, `config del`, `config remove` | +| [`contacts list`](contacts-list.md) | List contacts | `contacts search`, `contacts find` | +| [`doctor`](doctor.md) | Run diagnostics for config, target reachability, auth, and readiness | | +| [`exit-codes`](exit-codes.md) | Print stable exit codes for automation | `agent exit-codes`, `exitcodes` | +| [`export`](export.md) | Export accounts, chats, messages, transcripts, and attachments | | +| [`install desktop`](install-desktop.md) | Install Beeper Desktop locally | | +| [`install server`](install-server.md) | Install Beeper Server locally | | +| [`mcp`](mcp.md) | Run a typed MCP stdio server | | +| [`media download`](media-download.md) | Download message media | | +| [`messages context`](messages-context.md) | Show a message with surrounding context | | +| [`messages delete`](messages-delete.md) | Delete a message | | +| [`messages edit`](messages-edit.md) | Edit a message | | +| [`messages list`](messages-list.md) | List chat messages | `messages ls` | +| [`messages search`](messages-search.md) | Search messages across chats | `messages find` | +| [`remove account`](remove-account.md) | Remove an account | `accounts remove`, `accounts rm` | +| [`remove target`](remove-target.md) | Remove a target | `targets remove`, `targets rm` | +| [`resolve account`](resolve-account.md) | Resolve an account selector | | +| [`resolve bridge`](resolve-bridge.md) | Resolve a bridge selector | | +| [`resolve chat`](resolve-chat.md) | Resolve a chat selector | | +| [`resolve contact`](resolve-contact.md) | Resolve a contact selector | | +| [`resolve target`](resolve-target.md) | Resolve a target selector | | +| [`schema`](schema.md) | Print machine-readable command and flag schema | `help-json`, `helpjson` | +| [`send file`](send-file.md) | Send a file message | | +| [`send presence`](send-presence.md) | Send a typing indicator | | +| [`send react`](send-react.md) | Send or remove a reaction | | +| [`send sticker`](send-sticker.md) | Send a sticker | | +| [`send text`](send-text.md) | Send a text message | | +| [`send voice`](send-voice.md) | Send a voice note | | +| [`setup`](setup.md) | Make the selected target ready for messaging | | +| [`status`](status.md) | Show selected target and setup readiness | `st` | +| [`targets add`](targets-add.md) | Add a remote Beeper Desktop or Server target | | +| [`targets list`](targets-list.md) | List configured Beeper targets | `targets ls` | +| [`targets logs`](targets-logs.md) | Print logs for a local Beeper Desktop or Server install | | +| [`targets runtime restart`](targets-runtime-restart.md) | Restart a local server runtime | | +| [`targets runtime start`](targets-runtime-start.md) | Start a local target runtime | | +| [`targets runtime stop`](targets-runtime-stop.md) | Stop a local server runtime | | +| [`targets tunnel`](targets-tunnel.md) | Expose a target through Cloudflare Tunnel | | +| [`use account`](use-account.md) | Select the default account | `accounts use` | +| [`use target`](use-target.md) | Select the default target | `targets use` | +| [`version`](version.md) | Print CLI version | | +| [`watch`](watch.md) | Stream Desktop API WebSocket events | | diff --git a/packages/cli/docs/commands/accounts-add.md b/packages/cli/docs/commands/accounts-add.md new file mode 100644 index 00000000..de18d266 --- /dev/null +++ b/packages/cli/docs/commands/accounts-add.md @@ -0,0 +1,52 @@ +# beeper accounts add +Connect a chat account by bridge +## Usage +```sh +beeper accounts add [bridge] [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `[bridge]` | | + +## Flags + +| Name | Description | +| --- | --- | +| `--cookie ` | Cookie value in name=value form Repeatable. | +| `--field ` | Field value in id=value form Repeatable. | +| `--flow ` | Login flow ID | +| `--guided` | Prompt through login steps Default: `true`. | +| `--login-id ` | Existing login ID to re-login as | +| `--webview` | Use Bun.WebView for cookie login steps Default: `false`. | +| `--webview-backend ` | Bun.WebView backend Default: `chrome`. Values: `auto`, `chrome`, `webkit`. | +| `--webview-timeout ` | Seconds to wait for WebView cookie collection Default: `120`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/accounts-list.md b/packages/cli/docs/commands/accounts-list.md new file mode 100644 index 00000000..1f907b23 --- /dev/null +++ b/packages/cli/docs/commands/accounts-list.md @@ -0,0 +1,40 @@ +# beeper accounts list +List connected accounts +## Usage +```sh +beeper accounts list [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `-a, --account , --acct` | Limit to account selector Repeatable. | +| `--ids` | Print only account IDs Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/api-request.md b/packages/cli/docs/commands/api-request.md new file mode 100644 index 00000000..5f4fc134 --- /dev/null +++ b/packages/cli/docs/commands/api-request.md @@ -0,0 +1,47 @@ +# beeper api request +Call a raw Desktop API path with any supported HTTP method +## Usage +```sh +beeper api request [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | | +| `` | | + +## Flags + +| Name | Description | +| --- | --- | +| `--body ` | JSON request body | +| `--no-auth` | Call a public API path without a bearer token Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/auth-email-response.md b/packages/cli/docs/commands/auth-email-response.md new file mode 100644 index 00000000..176b7e3c --- /dev/null +++ b/packages/cli/docs/commands/auth-email-response.md @@ -0,0 +1,41 @@ +# beeper auth email response +Finish email sign-in for a target +## Usage +```sh +beeper auth email response [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--code ` | Email verification code Required. | +| `--setup-request-id ` | Setup request ID from auth email start Required. | +| `--username ` | Username to use if setup creates a new account | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/auth-email-start.md b/packages/cli/docs/commands/auth-email-start.md new file mode 100644 index 00000000..73ef81de --- /dev/null +++ b/packages/cli/docs/commands/auth-email-start.md @@ -0,0 +1,39 @@ +# beeper auth email start +Start email sign-in for a target +## Usage +```sh +beeper auth email start [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--email ` | Email address Required. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/auth-logout.md b/packages/cli/docs/commands/auth-logout.md new file mode 100644 index 00000000..6169a753 --- /dev/null +++ b/packages/cli/docs/commands/auth-logout.md @@ -0,0 +1,33 @@ +# beeper auth logout +Clear stored authentication +## Usage +```sh +beeper auth logout [flags] +``` +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-archive.md b/packages/cli/docs/commands/chats-archive.md new file mode 100644 index 00000000..41ee3495 --- /dev/null +++ b/packages/cli/docs/commands/chats-archive.md @@ -0,0 +1,41 @@ +# beeper chats archive +Archive or unarchive a chat +## Usage +```sh +beeper chats archive [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--clear` | Unarchive the chat Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-avatar.md b/packages/cli/docs/commands/chats-avatar.md new file mode 100644 index 00000000..916573e7 --- /dev/null +++ b/packages/cli/docs/commands/chats-avatar.md @@ -0,0 +1,42 @@ +# beeper chats avatar +Set or clear a chat avatar +## Usage +```sh +beeper chats avatar [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--clear` | Clear the avatar Default: `false`. | +| `--file ` | Avatar image file path | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-description.md b/packages/cli/docs/commands/chats-description.md new file mode 100644 index 00000000..29ac70bb --- /dev/null +++ b/packages/cli/docs/commands/chats-description.md @@ -0,0 +1,42 @@ +# beeper chats description +Set or clear a chat description +## Usage +```sh +beeper chats description [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--clear` | Clear or unset the chosen state Default: `false`. | +| `--description ` | Chat description | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-disappear.md b/packages/cli/docs/commands/chats-disappear.md new file mode 100644 index 00000000..bb5c96ee --- /dev/null +++ b/packages/cli/docs/commands/chats-disappear.md @@ -0,0 +1,41 @@ +# beeper chats disappear +Set a disappearing-message timer +## Usage +```sh +beeper chats disappear [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--seconds ` | Disappearing-message timer in seconds, or off | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-draft.md b/packages/cli/docs/commands/chats-draft.md new file mode 100644 index 00000000..62281a62 --- /dev/null +++ b/packages/cli/docs/commands/chats-draft.md @@ -0,0 +1,45 @@ +# beeper chats draft +Set or clear a chat draft +## Usage +```sh +beeper chats draft [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--clear` | Clear the draft Default: `false`. | +| `--file ` | Draft attachment file path | +| `--filename ` | Draft attachment filename | +| `--mime ` | Draft attachment MIME type | +| `--text ` | Draft text | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-focus.md b/packages/cli/docs/commands/chats-focus.md new file mode 100644 index 00000000..bea8b5c5 --- /dev/null +++ b/packages/cli/docs/commands/chats-focus.md @@ -0,0 +1,43 @@ +# beeper chats focus +Focus a chat in Beeper +## Usage +```sh +beeper chats focus [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--file ` | Draft attachment file path | +| `--message ` | Message ID to focus | +| `--text ` | Draft text | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-list.md b/packages/cli/docs/commands/chats-list.md new file mode 100644 index 00000000..868c2608 --- /dev/null +++ b/packages/cli/docs/commands/chats-list.md @@ -0,0 +1,51 @@ +# beeper chats list +List chats +## Usage +```sh +beeper chats list [flags] +``` +## Aliases + +- `beeper chats ls` + +## Flags + +| Name | Description | +| --- | --- | +| `-a, --account , --acct` | Limit to account selector Repeatable. | +| `--archived` | Only archived chats; use --no-archived to exclude | +| `--ids` | Print preferred chat selectors Default: `false`. | +| `--limit ` | Maximum chats to print Default: `20`. | +| `--low-priority` | Only low-priority chats; use --no-low-priority to exclude | +| `--muted` | Only muted chats; use --no-muted to exclude | +| `--pinned` | Only pinned chats; use --no-pinned to exclude | +| `--query ` | Optional chat lookup query | +| `--unread` | Only unread chats; use --no-unread to exclude | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-mute.md b/packages/cli/docs/commands/chats-mute.md new file mode 100644 index 00000000..3ce40500 --- /dev/null +++ b/packages/cli/docs/commands/chats-mute.md @@ -0,0 +1,41 @@ +# beeper chats mute +Mute or unmute a chat +## Usage +```sh +beeper chats mute [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--clear` | Unmute the chat Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-notify-anyway.md b/packages/cli/docs/commands/chats-notify-anyway.md new file mode 100644 index 00000000..bea46090 --- /dev/null +++ b/packages/cli/docs/commands/chats-notify-anyway.md @@ -0,0 +1,40 @@ +# beeper chats notify-anyway +Notify a chat anyway +## Usage +```sh +beeper chats notify-anyway [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-pin.md b/packages/cli/docs/commands/chats-pin.md new file mode 100644 index 00000000..029f3a1b --- /dev/null +++ b/packages/cli/docs/commands/chats-pin.md @@ -0,0 +1,41 @@ +# beeper chats pin +Pin or unpin a chat +## Usage +```sh +beeper chats pin [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--clear` | Unpin the chat Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-priority.md b/packages/cli/docs/commands/chats-priority.md new file mode 100644 index 00000000..59b80009 --- /dev/null +++ b/packages/cli/docs/commands/chats-priority.md @@ -0,0 +1,41 @@ +# beeper chats priority +Set chat priority +## Usage +```sh +beeper chats priority [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--level ` | Chat priority level Values: `inbox`, `low`. Required. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-read.md b/packages/cli/docs/commands/chats-read.md new file mode 100644 index 00000000..d5ecdbb7 --- /dev/null +++ b/packages/cli/docs/commands/chats-read.md @@ -0,0 +1,42 @@ +# beeper chats read +Mark a chat read or unread +## Usage +```sh +beeper chats read [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--message ` | Read marker message ID | +| `--unread` | Mark the chat unread Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-remind.md b/packages/cli/docs/commands/chats-remind.md new file mode 100644 index 00000000..f1dec093 --- /dev/null +++ b/packages/cli/docs/commands/chats-remind.md @@ -0,0 +1,43 @@ +# beeper chats remind +Set or clear a chat reminder +## Usage +```sh +beeper chats remind [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--clear` | Clear the reminder Default: `false`. | +| `--dismiss-on-message` | Dismiss reminder when a new message arrives Default: `false`. | +| `--when ` | ISO reminder timestamp | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-rename.md b/packages/cli/docs/commands/chats-rename.md new file mode 100644 index 00000000..15fb2241 --- /dev/null +++ b/packages/cli/docs/commands/chats-rename.md @@ -0,0 +1,41 @@ +# beeper chats rename +Rename a chat +## Usage +```sh +beeper chats rename [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--title ` | Chat title Required. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-show.md b/packages/cli/docs/commands/chats-show.md new file mode 100644 index 00000000..a61145e4 --- /dev/null +++ b/packages/cli/docs/commands/chats-show.md @@ -0,0 +1,41 @@ +# beeper chats show +Show chat details +## Usage +```sh +beeper chats show [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--max-participants ` | Limit participants returned in chat details | +| `--pick ` | Pick the Nth result when selector is ambiguous | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/chats-start.md b/packages/cli/docs/commands/chats-start.md new file mode 100644 index 00000000..b560ec4f --- /dev/null +++ b/packages/cli/docs/commands/chats-start.md @@ -0,0 +1,46 @@ +# beeper chats start +Start a chat +## Usage +```sh +beeper chats start [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | | + +## Flags + +| Name | Description | +| --- | --- | +| `--account ` | Account selector | +| `--title ` | Optional initial title for a new group chat | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/completion.md b/packages/cli/docs/commands/completion.md new file mode 100644 index 00000000..d3da285d --- /dev/null +++ b/packages/cli/docs/commands/completion.md @@ -0,0 +1,39 @@ +# beeper completion +Generate shell completion scripts +## Usage +```sh +beeper completion [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | bash, zsh, fish, or powershell | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/config-get.md b/packages/cli/docs/commands/config-get.md new file mode 100644 index 00000000..7951b824 --- /dev/null +++ b/packages/cli/docs/commands/config-get.md @@ -0,0 +1,43 @@ +# beeper config get +Get a config value +## Usage +```sh +beeper config get [flags] +``` +## Aliases + +- `beeper config show` + +## Arguments + +| Name | Description | +| --- | --- | +| `` | Config key to get | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/config-keys.md b/packages/cli/docs/commands/config-keys.md new file mode 100644 index 00000000..f382c658 --- /dev/null +++ b/packages/cli/docs/commands/config-keys.md @@ -0,0 +1,38 @@ +# beeper config keys +List available config keys +## Usage +```sh +beeper config keys [flags] +``` +## Aliases + +- `beeper config list-keys` +- `beeper config names` + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/config-list.md b/packages/cli/docs/commands/config-list.md new file mode 100644 index 00000000..c543b644 --- /dev/null +++ b/packages/cli/docs/commands/config-list.md @@ -0,0 +1,38 @@ +# beeper config list +List all config values +## Usage +```sh +beeper config list [flags] +``` +## Aliases + +- `beeper config ls` +- `beeper config all` + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/config-path.md b/packages/cli/docs/commands/config-path.md new file mode 100644 index 00000000..b9bb7a50 --- /dev/null +++ b/packages/cli/docs/commands/config-path.md @@ -0,0 +1,37 @@ +# beeper config path +Print config file path +## Usage +```sh +beeper config path [flags] +``` +## Aliases + +- `beeper config where` + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/config-set.md b/packages/cli/docs/commands/config-set.md new file mode 100644 index 00000000..de3cf450 --- /dev/null +++ b/packages/cli/docs/commands/config-set.md @@ -0,0 +1,45 @@ +# beeper config set +Set a config value +## Usage +```sh +beeper config set [flags] +``` +## Aliases + +- `beeper config add` +- `beeper config update` + +## Arguments + +| Name | Description | +| --- | --- | +| `` | Config key to set | +| `` | Value to set | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/config-unset.md b/packages/cli/docs/commands/config-unset.md new file mode 100644 index 00000000..49cdf897 --- /dev/null +++ b/packages/cli/docs/commands/config-unset.md @@ -0,0 +1,45 @@ +# beeper config unset +Unset a config value +## Usage +```sh +beeper config unset [flags] +``` +## Aliases + +- `beeper config rm` +- `beeper config del` +- `beeper config remove` + +## Arguments + +| Name | Description | +| --- | --- | +| `` | Config key to unset | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/contacts-list.md b/packages/cli/docs/commands/contacts-list.md new file mode 100644 index 00000000..905ad815 --- /dev/null +++ b/packages/cli/docs/commands/contacts-list.md @@ -0,0 +1,47 @@ +# beeper contacts list +List contacts +## Usage +```sh +beeper contacts list [flags] +``` +## Aliases + +- `beeper contacts search` +- `beeper contacts find` + +## Flags + +| Name | Description | +| --- | --- | +| `-a, --account , --acct` | Limit to account selector Repeatable. | +| `--ids` | Print only contact user IDs Default: `false`. | +| `--limit ` | Maximum contacts to print Default: `50`. | +| `--query ` | Optional contact lookup query | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/doctor.md b/packages/cli/docs/commands/doctor.md new file mode 100644 index 00000000..5aa7f598 --- /dev/null +++ b/packages/cli/docs/commands/doctor.md @@ -0,0 +1,33 @@ +# beeper doctor +Run diagnostics for config, target reachability, auth, and readiness +## Usage +```sh +beeper doctor [flags] +``` +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/exit-codes.md b/packages/cli/docs/commands/exit-codes.md new file mode 100644 index 00000000..7c449491 --- /dev/null +++ b/packages/cli/docs/commands/exit-codes.md @@ -0,0 +1,38 @@ +# beeper exit-codes +Print stable exit codes for automation +## Usage +```sh +beeper exit-codes [flags] +``` +## Aliases + +- `beeper agent exit-codes` +- `beeper exitcodes` + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/export.md b/packages/cli/docs/commands/export.md new file mode 100644 index 00000000..d5dbf167 --- /dev/null +++ b/packages/cli/docs/commands/export.md @@ -0,0 +1,47 @@ +# beeper export +Export accounts, chats, messages, transcripts, and attachments +## Usage +```sh +beeper export [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `-a, --account , --acct` | Limit to account selector Repeatable. | +| `--chat ` | Limit to chat selector Repeatable. | +| `--force` | Re-export completed chats Default: `false`. | +| `--limit-chats ` | Maximum chats to export | +| `--limit-messages ` | Maximum messages per chat | +| `--max-participants ` | Maximum participants in chat.json Default: `500`. | +| `--no-attachments` | Skip downloading attachments Default: `false`. | +| `--out ` | Export directory Default: `beeper-export`. | +| `--pick ` | Pick the Nth result when selector is ambiguous | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/install-desktop.md b/packages/cli/docs/commands/install-desktop.md new file mode 100644 index 00000000..a0e01233 --- /dev/null +++ b/packages/cli/docs/commands/install-desktop.md @@ -0,0 +1,40 @@ +# beeper install desktop +Install Beeper Desktop locally +## Usage +```sh +beeper install desktop [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--channel ` | Install release channel Default: `stable`. Values: `stable`, `nightly`. | +| `--server-env ` | Server environment Default: `prod`. Values: `local`, `dev`, `staging`, `prod`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/install-server.md b/packages/cli/docs/commands/install-server.md new file mode 100644 index 00000000..54d82236 --- /dev/null +++ b/packages/cli/docs/commands/install-server.md @@ -0,0 +1,40 @@ +# beeper install server +Install Beeper Server locally +## Usage +```sh +beeper install server [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--channel ` | Install release channel Default: `stable`. Values: `stable`, `nightly`. | +| `--server-env ` | Server environment Default: `prod`. Values: `local`, `dev`, `staging`, `prod`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/mcp.md b/packages/cli/docs/commands/mcp.md new file mode 100644 index 00000000..89e2c42e --- /dev/null +++ b/packages/cli/docs/commands/mcp.md @@ -0,0 +1,43 @@ +# beeper mcp +Run a typed MCP stdio server +## Usage +```sh +beeper mcp [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--allow-tool , --tool` | Tool or command allowlist Repeatable. | +| `--allow-write` | Allow write-risk MCP tools Default: `false`. | +| `--list-tools` | Print enabled MCP tools as JSON and exit Default: `false`. | +| `--max-output-bytes ` | Maximum stdout/stderr bytes captured per tool call Default: `102400`. | +| `--timeout-seconds ` | Per-tool subprocess timeout Default: `60`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/media-download.md b/packages/cli/docs/commands/media-download.md new file mode 100644 index 00000000..435bd23e --- /dev/null +++ b/packages/cli/docs/commands/media-download.md @@ -0,0 +1,45 @@ +# beeper media download +Download message media +## Usage +```sh +beeper media download [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | | + +## Flags + +| Name | Description | +| --- | --- | +| `--out ` | Output directory; - streams to stdout Default: `.`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/messages-context.md b/packages/cli/docs/commands/messages-context.md new file mode 100644 index 00000000..4dac8af6 --- /dev/null +++ b/packages/cli/docs/commands/messages-context.md @@ -0,0 +1,43 @@ +# beeper messages context +Show a message with surrounding context +## Usage +```sh +beeper messages context [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--id ` | Message ID Required. | +| `--after ` | Messages after target Default: `10`. | +| `--before ` | Messages before target Default: `10`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/messages-delete.md b/packages/cli/docs/commands/messages-delete.md new file mode 100644 index 00000000..68279f76 --- /dev/null +++ b/packages/cli/docs/commands/messages-delete.md @@ -0,0 +1,42 @@ +# beeper messages delete +Delete a message +## Usage +```sh +beeper messages delete [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--id ` | Message ID Required. | +| `--for-everyone` | Delete for everyone when supported Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/messages-edit.md b/packages/cli/docs/commands/messages-edit.md new file mode 100644 index 00000000..15867ae3 --- /dev/null +++ b/packages/cli/docs/commands/messages-edit.md @@ -0,0 +1,42 @@ +# beeper messages edit +Edit a message +## Usage +```sh +beeper messages edit [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--id ` | Message ID Required. | +| `--message ` | New message text Required. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/messages-list.md b/packages/cli/docs/commands/messages-list.md new file mode 100644 index 00000000..86442b5a --- /dev/null +++ b/packages/cli/docs/commands/messages-list.md @@ -0,0 +1,50 @@ +# beeper messages list +List chat messages +## Usage +```sh +beeper messages list [flags] +``` +## Aliases + +- `beeper messages ls` + +## Flags + +| Name | Description | +| --- | --- | +| `--after-cursor ` | Paginate messages newer than this message ID | +| `--asc` | Order oldest first Default: `false`. | +| `--before-cursor ` | Paginate messages older than this message ID | +| `--chat ` | Chat selector Required. | +| `--ids` | Print only message IDs Default: `false`. | +| `--limit ` | Maximum messages to print Default: `50`. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--sender ` | me, others, or a specific user ID | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/messages-search.md b/packages/cli/docs/commands/messages-search.md new file mode 100644 index 00000000..6f30b055 --- /dev/null +++ b/packages/cli/docs/commands/messages-search.md @@ -0,0 +1,69 @@ +# beeper messages search +Search messages across chats +## Usage +```sh +beeper messages search [query] [flags] +``` +## Aliases + +- `beeper messages find` + +## Arguments + +| Name | Description | +| --- | --- | +| `[query]` | | + +## Flags + +| Name | Description | +| --- | --- | +| `-a, --account , --acct` | Limit to account selector Repeatable. | +| `--chat ` | Limit to a chat selector Repeatable. | +| `--chat-type ` | Only group chats or direct messages Values: `group`, `single`. | +| `--after ` | Only messages at or after this ISO timestamp | +| `--before ` | Only messages at or before this ISO timestamp | +| `--exclude-low-priority` | Exclude low-priority chats | +| `--ids` | Print only message IDs Default: `false`. | +| `--include-muted` | Include muted chats Default: `true`. | +| `--limit , --max` | Maximum results Default: `50`. | +| `--media ` | Filter by media type Values: `any`, `video`, `image`, `link`, `file`. Repeatable. | +| `--sender ` | me, others, or a user ID | +| `--fail-empty, --non-empty, --require-results` | Exit with code 3 if no results Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | + +## Examples + +```sh +beeper messages search "quarterly report" +``` +```sh +beeper messages search --chat "Work" --sender me --limit 20 +``` diff --git a/packages/cli/docs/commands/remove-account.md b/packages/cli/docs/commands/remove-account.md new file mode 100644 index 00000000..99ec38cf --- /dev/null +++ b/packages/cli/docs/commands/remove-account.md @@ -0,0 +1,44 @@ +# beeper remove account +Remove an account +## Usage +```sh +beeper remove account [flags] +``` +## Aliases + +- `beeper accounts remove` +- `beeper accounts rm` + +## Arguments + +| Name | Description | +| --- | --- | +| `` | Account selector | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/remove-target.md b/packages/cli/docs/commands/remove-target.md new file mode 100644 index 00000000..d40cf119 --- /dev/null +++ b/packages/cli/docs/commands/remove-target.md @@ -0,0 +1,44 @@ +# beeper remove target +Remove a target +## Usage +```sh +beeper remove target [flags] +``` +## Aliases + +- `beeper targets remove` +- `beeper targets rm` + +## Arguments + +| Name | Description | +| --- | --- | +| `` | Target name | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/resolve-account.md b/packages/cli/docs/commands/resolve-account.md new file mode 100644 index 00000000..0c399ef0 --- /dev/null +++ b/packages/cli/docs/commands/resolve-account.md @@ -0,0 +1,45 @@ +# beeper resolve account +Resolve an account selector +## Usage +```sh +beeper resolve account [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | | + +## Flags + +| Name | Description | +| --- | --- | +| `--pick ` | Select the Nth candidate | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/resolve-bridge.md b/packages/cli/docs/commands/resolve-bridge.md new file mode 100644 index 00000000..d26680c5 --- /dev/null +++ b/packages/cli/docs/commands/resolve-bridge.md @@ -0,0 +1,45 @@ +# beeper resolve bridge +Resolve a bridge selector +## Usage +```sh +beeper resolve bridge [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | | + +## Flags + +| Name | Description | +| --- | --- | +| `--pick ` | Select the Nth candidate | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/resolve-chat.md b/packages/cli/docs/commands/resolve-chat.md new file mode 100644 index 00000000..b84ada8e --- /dev/null +++ b/packages/cli/docs/commands/resolve-chat.md @@ -0,0 +1,47 @@ +# beeper resolve chat +Resolve a chat selector +## Usage +```sh +beeper resolve chat [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | | + +## Flags + +| Name | Description | +| --- | --- | +| `-a, --account , --acct` | Limit to account selector Repeatable. | +| `--limit , --max` | Maximum candidates Default: `10`. | +| `--pick ` | Select the Nth candidate | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/resolve-contact.md b/packages/cli/docs/commands/resolve-contact.md new file mode 100644 index 00000000..6d43261a --- /dev/null +++ b/packages/cli/docs/commands/resolve-contact.md @@ -0,0 +1,47 @@ +# beeper resolve contact +Resolve a contact selector +## Usage +```sh +beeper resolve contact [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | | + +## Flags + +| Name | Description | +| --- | --- | +| `-a, --account , --acct` | Limit to account selector Repeatable. | +| `--limit , --max` | Maximum candidates Default: `10`. | +| `--pick ` | Select the Nth candidate | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/resolve-target.md b/packages/cli/docs/commands/resolve-target.md new file mode 100644 index 00000000..9797bb5f --- /dev/null +++ b/packages/cli/docs/commands/resolve-target.md @@ -0,0 +1,45 @@ +# beeper resolve target +Resolve a target selector +## Usage +```sh +beeper resolve target [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | | + +## Flags + +| Name | Description | +| --- | --- | +| `--pick ` | Select the Nth candidate | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/schema.md b/packages/cli/docs/commands/schema.md new file mode 100644 index 00000000..74e25255 --- /dev/null +++ b/packages/cli/docs/commands/schema.md @@ -0,0 +1,44 @@ +# beeper schema +Print machine-readable command and flag schema +## Usage +```sh +beeper schema ... [flags] +``` +## Aliases + +- `beeper help-json` +- `beeper helpjson` + +## Arguments + +| Name | Description | +| --- | --- | +| `[command ...]` | | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/send-file.md b/packages/cli/docs/commands/send-file.md new file mode 100644 index 00000000..d3c10452 --- /dev/null +++ b/packages/cli/docs/commands/send-file.md @@ -0,0 +1,47 @@ +# beeper send file +Send a file message +## Usage +```sh +beeper send file [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--to ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--reply-to ` | Send as a reply to this message ID | +| `--wait` | Wait until the message leaves pending state Default: `false`. | +| `--wait-timeout ` | Maximum wait time in ms when --wait is set Default: `30000`. | +| `--file ` | Local file path to upload Required. | +| `--caption ` | Optional caption for file messages | +| `--filename ` | Override displayed filename | +| `--mime ` | Override MIME type | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/send-presence.md b/packages/cli/docs/commands/send-presence.md new file mode 100644 index 00000000..195f511f --- /dev/null +++ b/packages/cli/docs/commands/send-presence.md @@ -0,0 +1,42 @@ +# beeper send presence +Send a typing indicator +## Usage +```sh +beeper send presence [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--to ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--duration ` | Seconds to keep typing before sending paused | +| `--state ` | Presence indicator to send Default: `typing`. Values: `typing`, `paused`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/send-react.md b/packages/cli/docs/commands/send-react.md new file mode 100644 index 00000000..2c1a4c5e --- /dev/null +++ b/packages/cli/docs/commands/send-react.md @@ -0,0 +1,44 @@ +# beeper send react +Send or remove a reaction +## Usage +```sh +beeper send react [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--to ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--id ` | Message ID to react to Required. | +| `--reaction ` | Reaction key Required. | +| `--remove` | Remove the reaction Default: `false`. | +| `--transaction ` | Optional transaction ID for deduplication | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/send-sticker.md b/packages/cli/docs/commands/send-sticker.md new file mode 100644 index 00000000..b1abc9b5 --- /dev/null +++ b/packages/cli/docs/commands/send-sticker.md @@ -0,0 +1,46 @@ +# beeper send sticker +Send a sticker +## Usage +```sh +beeper send sticker [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--to ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--reply-to ` | Send as a reply to this message ID | +| `--wait` | Wait until the message leaves pending state Default: `false`. | +| `--wait-timeout ` | Maximum wait time in ms when --wait is set Default: `30000`. | +| `--file ` | Local sticker file path to upload Required. | +| `--filename ` | Override displayed filename | +| `--mime ` | Override MIME type | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/send-text.md b/packages/cli/docs/commands/send-text.md new file mode 100644 index 00000000..fdd4b957 --- /dev/null +++ b/packages/cli/docs/commands/send-text.md @@ -0,0 +1,48 @@ +# beeper send text +Send a text message +## Usage +```sh +beeper send text [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--to ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--reply-to ` | Send as a reply to this message ID | +| `--wait` | Wait until the message leaves pending state Default: `false`. | +| `--wait-timeout ` | Maximum wait time in ms when --wait is set Default: `30000`. | +| `--message ` | Message text to send | +| `--message-escapes` | Interpret backslash escapes in --message Default: `false`. | +| `--message-file ` | Read message text from a file path; '-' reads stdin | +| `--mention ` | User ID to mention Repeatable. | +| `--no-preview` | Disable automatic link preview Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/send-voice.md b/packages/cli/docs/commands/send-voice.md new file mode 100644 index 00000000..e59da362 --- /dev/null +++ b/packages/cli/docs/commands/send-voice.md @@ -0,0 +1,47 @@ +# beeper send voice +Send a voice note +## Usage +```sh +beeper send voice [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--to ` | Chat selector Required. | +| `--pick ` | Pick the Nth result when selector is ambiguous | +| `--reply-to ` | Send as a reply to this message ID | +| `--wait` | Wait until the message leaves pending state Default: `false`. | +| `--wait-timeout ` | Maximum wait time in ms when --wait is set Default: `30000`. | +| `--file ` | Local voice note file path to upload Required. | +| `--duration ` | Duration in seconds | +| `--filename ` | Override displayed filename | +| `--mime ` | Override MIME type | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/setup.md b/packages/cli/docs/commands/setup.md new file mode 100644 index 00000000..69dd1008 --- /dev/null +++ b/packages/cli/docs/commands/setup.md @@ -0,0 +1,63 @@ +# beeper setup +Make the selected target ready for messaging +## Usage +```sh +beeper setup [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--local` | Use the local Beeper Desktop session on this device Default: `false`. | +| `--oauth` | Authorize the target with browser OAuth/PKCE Default: `false`. | +| `--remote ` | Connect to a remote Beeper Desktop or Server URL | +| `--server` | Set up a local Beeper Server target Default: `false`. | +| `--desktop` | Set up a local Beeper Desktop target Default: `false`. | +| `--install` | Allow installing a missing local runtime Default: `false`. | +| `--channel ` | Install release channel Default: `stable`. Values: `stable`, `nightly`. | +| `--server-env ` | Server environment Default: `prod`. Values: `local`, `dev`, `staging`, `prod`. | +| `--email ` | Sign in with an email address | +| `--username ` | Username to use if setup creates a new account | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | + +## Examples + +```sh +beeper setup +``` +```sh +beeper setup --local +``` +```sh +beeper setup --remote https://desktop.example.com +``` +```sh +beeper setup --desktop --install +``` diff --git a/packages/cli/docs/commands/status.md b/packages/cli/docs/commands/status.md new file mode 100644 index 00000000..95cedf05 --- /dev/null +++ b/packages/cli/docs/commands/status.md @@ -0,0 +1,43 @@ +# beeper status +Show selected target and setup readiness +## Usage +```sh +beeper status [target] [flags] +``` +## Aliases + +- `beeper st` + +## Arguments + +| Name | Description | +| --- | --- | +| `[target]` | Target name. Defaults to the selected target. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/targets-add.md b/packages/cli/docs/commands/targets-add.md new file mode 100644 index 00000000..49d3c0e3 --- /dev/null +++ b/packages/cli/docs/commands/targets-add.md @@ -0,0 +1,46 @@ +# beeper targets add +Add a remote Beeper Desktop or Server target +## Usage +```sh +beeper targets add [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `` | | +| `` | | + +## Flags + +| Name | Description | +| --- | --- | +| `--default` | Set this target as the default after creation Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/targets-list.md b/packages/cli/docs/commands/targets-list.md new file mode 100644 index 00000000..9a8e4056 --- /dev/null +++ b/packages/cli/docs/commands/targets-list.md @@ -0,0 +1,37 @@ +# beeper targets list +List configured Beeper targets +## Usage +```sh +beeper targets list [flags] +``` +## Aliases + +- `beeper targets ls` + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/targets-logs.md b/packages/cli/docs/commands/targets-logs.md new file mode 100644 index 00000000..dd8c9e87 --- /dev/null +++ b/packages/cli/docs/commands/targets-logs.md @@ -0,0 +1,47 @@ +# beeper targets logs +Print logs for a local Beeper Desktop or Server install +## Usage +```sh +beeper targets logs [name] [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `[name]` | | + +## Flags + +| Name | Description | +| --- | --- | +| `--lines ` | Lines to print from each log file Default: `200`. | +| `--files ` | Desktop log files to print, newest first Default: `5`. | +| `--all` | Print all matching log files instead of only recent files Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/targets-runtime-restart.md b/packages/cli/docs/commands/targets-runtime-restart.md new file mode 100644 index 00000000..e15eeb52 --- /dev/null +++ b/packages/cli/docs/commands/targets-runtime-restart.md @@ -0,0 +1,39 @@ +# beeper targets runtime restart +Restart a local server runtime +## Usage +```sh +beeper targets runtime restart [name] [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `[name]` | | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/targets-runtime-start.md b/packages/cli/docs/commands/targets-runtime-start.md new file mode 100644 index 00000000..53390b7b --- /dev/null +++ b/packages/cli/docs/commands/targets-runtime-start.md @@ -0,0 +1,39 @@ +# beeper targets runtime start +Start a local target runtime +## Usage +```sh +beeper targets runtime start [name] [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `[name]` | | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/targets-runtime-stop.md b/packages/cli/docs/commands/targets-runtime-stop.md new file mode 100644 index 00000000..81f51723 --- /dev/null +++ b/packages/cli/docs/commands/targets-runtime-stop.md @@ -0,0 +1,39 @@ +# beeper targets runtime stop +Stop a local server runtime +## Usage +```sh +beeper targets runtime stop [name] [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `[name]` | | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/targets-tunnel.md b/packages/cli/docs/commands/targets-tunnel.md new file mode 100644 index 00000000..03de60cb --- /dev/null +++ b/packages/cli/docs/commands/targets-tunnel.md @@ -0,0 +1,49 @@ +# beeper targets tunnel +Expose a target through Cloudflare Tunnel +## Usage +```sh +beeper targets tunnel [name] [flags] +``` +## Arguments + +| Name | Description | +| --- | --- | +| `[name]` | Target name. Defaults to the selected target. | + +## Flags + +| Name | Description | +| --- | --- | +| `--cloudflared-path ` | Path to cloudflared. Also configurable with BEEPER_CLOUDFLARED_PATH. | +| `--install` | Download the pinned cloudflared binary if missing or outdated Default: `false`. | +| `--retries ` | Startup retries before giving up Default: `5`. | +| `--timeout ` | Startup timeout, for example 40s or 60000ms | +| `--url-only` | Print only the public tunnel URL Default: `false`. | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/use-account.md b/packages/cli/docs/commands/use-account.md new file mode 100644 index 00000000..bdc409db --- /dev/null +++ b/packages/cli/docs/commands/use-account.md @@ -0,0 +1,43 @@ +# beeper use account +Select the default account +## Usage +```sh +beeper use account [flags] +``` +## Aliases + +- `beeper accounts use` + +## Arguments + +| Name | Description | +| --- | --- | +| `` | Account selector | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/use-target.md b/packages/cli/docs/commands/use-target.md new file mode 100644 index 00000000..ccc7900d --- /dev/null +++ b/packages/cli/docs/commands/use-target.md @@ -0,0 +1,43 @@ +# beeper use target +Select the default target +## Usage +```sh +beeper use target [flags] +``` +## Aliases + +- `beeper targets use` + +## Arguments + +| Name | Description | +| --- | --- | +| `` | Target name | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/version.md b/packages/cli/docs/commands/version.md new file mode 100644 index 00000000..1066e2b5 --- /dev/null +++ b/packages/cli/docs/commands/version.md @@ -0,0 +1,33 @@ +# beeper version +Print CLI version +## Usage +```sh +beeper version [flags] +``` +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/commands/watch.md b/packages/cli/docs/commands/watch.md new file mode 100644 index 00000000..fe4d55da --- /dev/null +++ b/packages/cli/docs/commands/watch.md @@ -0,0 +1,44 @@ +# beeper watch +Stream Desktop API WebSocket events +## Usage +```sh +beeper watch [flags] +``` +## Flags + +| Name | Description | +| --- | --- | +| `--chat ` | Chat ID to subscribe to; defaults to all chats Repeatable. | +| `--exclude-type ` | Drop events of these types Values: `chat.upserted`, `chat.deleted`, `message.upserted`, `message.deleted`, `message.stream`. Repeatable. | +| `--include-type ` | Only forward events of these types Values: `chat.upserted`, `chat.deleted`, `message.upserted`, `message.deleted`, `message.stream`. Repeatable. | +| `--webhook ` | Forward each event to this URL as POST | +| `--webhook-queue ` | Maximum pending webhook deliveries Default: `64`. | +| `--webhook-secret ` | HMAC-SHA256 secret for X-Beeper-Signature | + +## Global Flags + +| Name | Description | +| --- | --- | +| `--access-token ` | Use provided access token directly Env: `BEEPER_ACCESS_TOKEN`. | +| `-a, --account , --acct` | Account selector for account-aware commands Repeatable. | +| `--color ` | Color output: auto\|always\|never Default: `auto`. Values: `auto`, `always`, `never`. | +| `--debug` | Default: `false`. | +| `--disable-commands ` | Comma-separated command prefixes to block | +| `-n, --dry-run, --dryrun, --noop, --preview` | Do not make changes; print intended actions Default: `false`. | +| `--enable-commands ` | Comma-separated enabled command prefixes | +| `--enable-commands-exact ` | Comma-separated exact enabled commands | +| `--events` | Default: `false`. | +| `-y, --force, --assume-yes, --yes` | Skip confirmations for destructive commands Default: `false`. | +| `--full` | Disable truncation in human table output Default: `false`. | +| `--home ` | Override Beeper CLI config/data root Env: `BEEPER_CLI_CONFIG_DIR`. | +| `-j, --json, --machine` | Output JSON to stdout Default: `false`. | +| `--no-input, --non-interactive, --noninteractive` | Never prompt; fail instead Default: `false`. | +| `-p, --plain, --tsv` | Output stable TSV-like text Default: `false`. | +| `--read-only` | Reject commands that intentionally write Default: `false`. Env: `BEEPER_READONLY`. | +| `--results-only` | In JSON mode, emit only the primary result Default: `false`. | +| `--safety-profile ` | Safety profile name or YAML path | +| `--select , --fields, --project` | Select comma-separated JSON fields | +| `--target ` | Target name or URL | +| `--timeout ` | Command timeout, for example 30s or 2m | +| `-v, --version` | Print version and exit Default: `false`. | +| `--wrap-untrusted` | Wrap fetched text fields in untrusted-content markers Default: `false`. | diff --git a/packages/cli/docs/config.md b/packages/cli/docs/config.md deleted file mode 100644 index 333135a7..00000000 --- a/packages/cli/docs/config.md +++ /dev/null @@ -1,32 +0,0 @@ -# config - -Read when: inspecting, changing, or resetting the CLI's local configuration -file (`~/.beeper/config.json`, or wherever `BEEPER_CLI_CONFIG_DIR` points). - -## Commands - -```sh -beeper config path -beeper config get [defaultTarget | defaultAccount | baseURL | auth] -beeper config set -beeper config reset -``` - -## Notes - -- `config path` prints the JSON config path (suitable for `cat` or `cd $(dirname …)`). -- `config get` without a key prints the full config; passing a key prints just that field. -- `auth.accessToken` is always redacted in `config get` output. -- `config set ""` clears the field. Only `defaultTarget` and `defaultAccount` are settable here; other fields are written by commands like `targets use` and `auth verify`. -- `config reset` deletes the config file. - -## Examples - -```sh -beeper config path -beeper config get --json -beeper config get defaultTarget -beeper config set defaultTarget work -beeper config set defaultAccount "" -beeper config reset -``` diff --git a/packages/cli/docs/contacts.md b/packages/cli/docs/contacts.md deleted file mode 100644 index 06f6e0f1..00000000 --- a/packages/cli/docs/contacts.md +++ /dev/null @@ -1,27 +0,0 @@ -# contacts - -Read when: looking up contacts across one or more accounts. - -## Commands - -```sh -beeper contacts list [--account SEL]... [--query TEXT] [--limit N] [--ids] -beeper contacts search [--account SEL]... -beeper contacts show [--account SEL]... -``` - -## Notes - -- `contacts list` reads merged account contacts; without `--account` it iterates all accounts. -- `contacts search` runs the network search where available and returns merged results across accounts; omitting `--account` searches every account. -- `contacts show` accepts a user ID, display name, or phone/handle and finds it on the first matching account. - -## Examples - -```sh -beeper contacts list --account whatsapp --query alice -beeper contacts list --json -beeper contacts search "alice" -beeper contacts show "Alice" --account whatsapp -beeper contacts show +15551234567 -``` diff --git a/packages/cli/docs/export.md b/packages/cli/docs/export.md deleted file mode 100644 index f3830af0..00000000 --- a/packages/cli/docs/export.md +++ /dev/null @@ -1,39 +0,0 @@ -# export - -Read when: making a heavy, multi-chat, attachment-including export of Beeper -data to disk. For a lightweight per-chat JSON dump, see [messages -export](messages.md). - -## Command - -```sh -beeper export - [-o, --out DIR] - [--account SEL]... - [--chat SEL]... - [--limit-chats N] - [--limit-messages N] - [--max-participants N] - [--no-attachments] - [--force] - [--quiet] - [--pick N] -``` - -## Notes - -- Default `--out` directory is `beeper-export`. -- Layout: `accounts.json`, `chats.json`, `manifest.json`, plus one directory per chat with `chat.json`, `messages.json`, `messages.markdown`, `messages.html`, attachments, and per-chat checkpoint state. -- Exports are resumable. Re-running picks up where the last run left off unless `--force` is set. -- `--max-participants` (default 500) bounds the participant list stored in each `chat.json`. -- `--no-attachments` skips downloading media; metadata is still recorded. -- `--limit-chats` / `--limit-messages` are intended for sanity-checking large exports. - -## Examples - -```sh -beeper export --out ./beeper-export -beeper export --chat "Family" --out ./family -beeper export --account whatsapp --no-attachments --quiet -beeper export --force --out ./beeper-export -``` diff --git a/packages/cli/docs/media.md b/packages/cli/docs/media.md deleted file mode 100644 index 9fc9dd55..00000000 --- a/packages/cli/docs/media.md +++ /dev/null @@ -1,22 +0,0 @@ -# media - -Read when: downloading a media file attached to a message. - -## Commands - -```sh -beeper media download [-o, --out DIR | -] -``` - -## Notes - -- `` accepts `mxc://` and `localmxc://` URLs (typically taken from a message payload). -- `--out` defaults to `.` (current directory); the file is named from the URL path. -- `--out -` streams the binary to stdout for piping. - -## Examples - -```sh -beeper media download mxc://beeper.com/abc --out ./downloads -beeper media download mxc://beeper.com/abc -o - > photo.jpg -``` diff --git a/packages/cli/docs/messages.md b/packages/cli/docs/messages.md deleted file mode 100644 index 70ccb009..00000000 --- a/packages/cli/docs/messages.md +++ /dev/null @@ -1,48 +0,0 @@ -# messages - -Read when: listing, searching, showing, contextualizing, editing, deleting, -reacting to, or exporting messages from chats. - -## Commands - -```sh -beeper messages list --chat SEL [--before-cursor MSG_ID | --after-cursor MSG_ID] [--sender me|others|] [--asc] [--limit N] [--ids] [--pick N] -beeper messages search [query] [--account SEL]... [--chat SEL]... [--chat-type group|single] [--sender me|others|] [--media TYPE]... [--after ISO] [--before ISO] [--include-muted | --no-include-muted] [--exclude-low-priority | --no-exclude-low-priority] [--limit N] [--ids] -beeper messages show --chat SEL --id MSG_ID [--pick N] -beeper messages context --chat SEL --id MSG_ID [--before N] [--after N] [--pick N] -beeper messages edit --chat SEL --id MSG_ID --message TEXT [--pick N] -beeper messages delete --chat SEL --id MSG_ID [--for-everyone] [--pick N] -beeper messages react --chat SEL --id MSG_ID --reaction KEY [--pick N] # hidden; prefer `send react` -beeper messages unreact --chat SEL --id MSG_ID --reaction KEY [--pick N] # hidden; prefer `send unreact` -beeper messages export --chat SEL [--before-cursor MSG_ID | --after-cursor MSG_ID] [--after ISO] [--before ISO] [--limit N] [--output PATH | -o -] [--asc] [--pick N] -``` - -## Notes - -- `--before-cursor` / `--after-cursor` paginate by message ID (the SDK's cursor model). -- `--before` / `--after` in `messages search` and `messages export` filter by ISO timestamp. -- `messages search` rejects an empty query *and* no filter flags with exit code 2 (`usageError`). -- `messages list --sender` filters client-side: `me` (your own messages), `others`, or an exact user ID. -- `messages list --asc` reverses the default newest-first order. -- `messages export` writes one chat to JSON. Use top-level `export` for a full - export with transcripts, attachments, and multiple chats. -- `messages export --output -` writes JSON to stdout for piping. -- `messages delete --for-everyone` requires the network supports it; otherwise it falls back to delete-for-you. -- `messages edit` only succeeds on your own text messages with no attachments. -- `messages react`/`unreact` are hidden in `--help` in favor of `send react`/`send unreact`. - -## Examples - -```sh -beeper messages list --chat 10313 --limit 50 -beeper messages list --chat 10313 --sender me --asc -beeper messages list --chat 8951 --before-cursor "$LAST_ID" --limit 200 -beeper messages search "invoice" -beeper messages search --chat 10313 --sender me --media image --after 2026-01-01 -beeper messages show --chat 10313 --id ABC123 -beeper messages context --chat 10313 --id ABC123 --before 5 --after 5 -beeper messages edit --chat 10313 --id ABC123 --message "fixed typo" -beeper messages delete --chat 10313 --id ABC123 --for-everyone -beeper messages export --chat 10313 --output family.json -beeper messages export --chat 8951 --after 2026-01-01T00:00:00Z -o - -``` diff --git a/packages/cli/docs/presence.md b/packages/cli/docs/presence.md deleted file mode 100644 index 85e16d77..00000000 --- a/packages/cli/docs/presence.md +++ /dev/null @@ -1,25 +0,0 @@ -# presence - -Read when: sending typing/paused indicators into a chat from a script or -agent. - -## Commands - -```sh -beeper presence --chat SEL [--state typing|paused] [--duration SECONDS] [--pick N] -``` - -## Notes - -- Requires server-side support; networks without typing notifications return an error. -- `--state` defaults to `typing`. -- `--duration N` (only valid with `--state typing`) sends `typing`, sleeps N seconds, then sends `paused`. -- The selected chat must be addressable via the usual selector rules (ID, local ID, title, or search text). - -## Examples - -```sh -beeper presence --chat "Family" -beeper presence --chat "Family" --state paused -beeper presence --chat "Family" --duration 5 -``` diff --git a/packages/cli/docs/rpc.md b/packages/cli/docs/rpc.md deleted file mode 100644 index d58ccb64..00000000 --- a/packages/cli/docs/rpc.md +++ /dev/null @@ -1,50 +0,0 @@ -# rpc - -Read when: scripting many CLI commands from a long-lived process (an agent, a -web server) without spawning a new node process per command. - -## Command - -```sh -beeper rpc -``` - -Reads newline-delimited JSON requests on stdin and writes one response line -per request on stdout. - -## Request shape - -```json -{"id":1,"command":"chats list --json"} -{"id":2,"args":["send","text","--to","Family","--message","ack","--json"]} -``` - -Each request must include one of: - -- `command` — a single string parsed with shell-like quoting -- `args` — an explicit `argv` array -- `argv` — alias for `args` - -`id` is echoed back in the response (string, number, or null). - -## Response shape - -```json -{"id":1,"ok":true,"code":0,"signal":null,"stdout":"…","stderr":""} -{"id":2,"ok":false,"error":"…"} -``` - -`ok` mirrors `code === 0`. `stdout`/`stderr` capture the child command's output. - -## Notes - -- Nesting `rpc` or `shell` is rejected to avoid recursion. -- `--json` on inner commands produces the standard envelope inside `stdout`. -- Exit codes use the same table as direct CLI invocation; see [exit codes](../README.md#exit-codes). - -## Examples - -```sh -printf '{"id":1,"command":"auth status --json"}\n' | beeper rpc -printf '{"id":1,"args":["chats","list","--json"]}\n{"id":2,"args":["status","--json"]}\n' | beeper rpc -``` diff --git a/packages/cli/docs/send.md b/packages/cli/docs/send.md deleted file mode 100644 index d8d01f04..00000000 --- a/packages/cli/docs/send.md +++ /dev/null @@ -1,44 +0,0 @@ -# send - -Read when: sending text, files, reactions, stickers, or voice notes from -scripts or interactive use. - -## Commands - -```sh -beeper send text --to SEL --message TEXT [--reply-to MSG_ID] [--mention USER]... [--no-preview] [--wait] [--wait-timeout MS] [--pick N] -beeper send file --to SEL --file PATH [--caption TEXT] [--filename NAME] [--mime TYPE] [--reply-to MSG_ID] [--wait] [--wait-timeout MS] [--pick N] -beeper send sticker --to SEL --file PATH [--filename NAME] [--mime TYPE] [--reply-to MSG_ID] [--wait] [--wait-timeout MS] [--pick N] -beeper send voice --to SEL --file PATH [--duration SECONDS] [--filename NAME] [--mime TYPE] [--reply-to MSG_ID] [--wait] [--wait-timeout MS] [--pick N] -beeper send react --to SEL --id MSG_ID --reaction KEY [--transaction TX_ID] [--pick N] -beeper send unreact --to SEL --id MSG_ID --reaction KEY [--pick N] -``` - -## Notes - -- `--to` accepts a chat ID, local chat ID, exact title, or search text. -- Prefer numeric local chat IDs from `beeper chats list` when scripting against - the same target/profile. Use full Beeper/Matrix chat IDs for selectors that - need to work across targets or profiles. -- Send commands return when Desktop accepts the send request. Use `--wait` when - you need to know whether the message left the pending state or failed. -- `--wait` blocks until the message leaves the pending state (or fails). Default poll cap: `--wait-timeout 30000` ms. -- `--reply-to` quotes an existing message ID. -- `send text --mention ` adds a Matrix mention; repeat for multiple users. -- `send text --no-preview` disables automatic link previews. -- `send sticker` defaults `--mime` to `image/webp`; stickers should be 512×512. -- `send voice` defaults `--mime` to `audio/ogg`; pass `--duration` to override the detected length. -- `send file` accepts any file up to 500 MB. MIME type is detected from the upload if `--mime` is omitted. - -## Examples - -```sh -beeper send text --to 10313 --message "on my way" -beeper send text --to 8951 --message "ack" --reply-to ABC123 -beeper send text --to "@alice:beeper.com" --message "hi @alice" --mention @alice:beeper.com --no-preview -beeper send file --to 10313 --file ./photo.jpg --caption "from today" -beeper send sticker --to 10313 --file ./hi.webp -beeper send voice --to 8951 --file ./note.ogg --duration 12 -beeper send react --to 10313 --id ABC123 --reaction "+1" -beeper send unreact --to 10313 --id ABC123 --reaction "+1" -``` diff --git a/packages/cli/docs/setup.md b/packages/cli/docs/setup.md deleted file mode 100644 index 9bf22140..00000000 --- a/packages/cli/docs/setup.md +++ /dev/null @@ -1,38 +0,0 @@ -# setup - -Read when: making a Beeper target ready for the first time, switching to a -different target, or installing a managed runtime. - -`beeper setup` orchestrates the path from "I have nothing" to "the selected -target is ready". By default it detects a running local Beeper Desktop, offers -to reuse that session, and falls back to a guided choice between Desktop / -Server / remote targets. - -## Commands - -```sh -beeper setup [--local | --oauth | --remote URL | --desktop | --server] [--install] [--channel stable|nightly] -beeper install desktop [--channel stable|nightly] -beeper install server [--channel stable|nightly] [--server-env production|staging] -``` - -## Notes - -- `setup --local` reuses the local Beeper Desktop session (fastest trusted-device path). -- `setup --oauth` runs browser-based OAuth/PKCE against the resolved target. -- `setup --remote URL` configures a remote Beeper Desktop or Server target. -- `setup --desktop --install` or `setup --server --install` installs the runtime if missing, then sets up. -- `install desktop|server` installs without changing the selected target. -- The selected target is persisted in `~/.beeper/config.json` (override with `BEEPER_CLI_CONFIG_DIR`). -- For non-interactive use, pass a token in the environment: `BEEPER_ACCESS_TOKEN=… beeper …`. - -## Examples - -```sh -beeper setup -beeper setup --local -beeper setup --oauth -beeper setup --remote https://desktop.example.com -beeper setup --desktop --install --channel nightly -beeper install server --server-env staging -``` diff --git a/packages/cli/docs/targets.md b/packages/cli/docs/targets.md deleted file mode 100644 index d77ee831..00000000 --- a/packages/cli/docs/targets.md +++ /dev/null @@ -1,50 +0,0 @@ -# targets - -Read when: managing local Desktop, managed Server, or remote Beeper API -targets — adding, switching, starting/stopping a managed runtime, or removing -a target. - -A *target* is a runnable or reachable Beeper endpoint profile: local Server, -local Desktop, Desktop API, or a profile that combines Desktop/Server runtime -state. The CLI tracks an optional default; commands use it unless -`--target ` overrides. - -## Commands - -```sh -beeper targets list -beeper targets add desktop [name] [--port N] [--server-env production|staging] [--default] -beeper targets add server [name] [--port N] [--server-env production|staging] [--default] -beeper targets add remote [--default] -beeper targets use -beeper targets show [name] -beeper targets status [name] -beeper targets start | stop | restart [name] -beeper targets logs [name] -beeper targets enable | disable [name] # start at login -beeper targets remove -``` - -## Notes - -- `list` prints all configured targets; the one used by default has `default: true`. -- `show` defaults to the currently-selected target if no name is given. -- `status` checks endpoint and process reachability. For setup/auth/encryption - diagnostics use `beeper doctor`. -- `start`/`stop`/`restart` only apply to managed targets (`type: desktop|server`); they error for `remote`. -- `enable`/`disable` registers/unregisters the launchd or systemd unit that - starts the managed target at login. -- Removing the active default clears the `defaultTarget` config field. -- `BEEPER_TARGET=` overrides the default for a single shell. - -## Examples - -```sh -beeper targets list --json -beeper targets add desktop work --default -beeper targets add server prod --server-env production --default -beeper targets add remote office https://desktop.office.example.com --default -beeper targets use work -beeper targets logs work | less -beeper targets restart work -``` diff --git a/packages/cli/docs/update.md b/packages/cli/docs/update.md deleted file mode 100644 index 8403eff0..00000000 --- a/packages/cli/docs/update.md +++ /dev/null @@ -1,27 +0,0 @@ -# update - -Read when: checking for new versions of the CLI, the CLI-managed Desktop -install, or the CLI-managed Server install — and choosing whether to install. - -## Command - -```sh -beeper update [--check] [--cli] [--desktop] [--server] -``` - -## Notes - -- With no kind flag, checks all three (CLI, Desktop, Server) that apply. -- `--check` prints what's available without installing. -- The CLI itself is never auto-upgraded; `--cli` prints the right command for your install method (Homebrew, npm-global, or in-repo git build). -- `--desktop` reports on the CLI-owned Desktop install; updating Desktop itself happens inside the Desktop app. -- `--server` updates the CLI-managed Server install in place, then restarts any running managed Server targets. - -## Examples - -```sh -beeper update --check -beeper update --cli -beeper update --desktop --json -beeper update --server -``` diff --git a/packages/cli/docs/watch.md b/packages/cli/docs/watch.md deleted file mode 100644 index a8df6c74..00000000 --- a/packages/cli/docs/watch.md +++ /dev/null @@ -1,35 +0,0 @@ -# watch - -Read when: subscribing to live Desktop API events (new/updated/deleted chats -and messages), optionally forwarding them to a webhook. - -## Commands - -```sh -beeper watch - [-c, --chat CHAT_ID]... - [--include-type EVENT_TYPE]... - [--exclude-type EVENT_TYPE]... - [--webhook URL [--webhook-secret SECRET] [--webhook-queue N]] - [--json] -``` - -## Notes - -- Subscribes to the Desktop API WebSocket at the path returned by `/v1/info` (defaults to `/v1/ws`). -- Without `--chat`, subscribes to all chats. -- Event types come from the Desktop API: `chat.upserted`, `chat.deleted`, `message.upserted`, `message.deleted`. -- `--include-type` and `--exclude-type` are mutually exclusive. -- `--webhook URL` forwards every event as a POST body (best-effort, fire-and-forget). -- `--webhook-secret SECRET` signs the body with HMAC-SHA256 and sets `X-Beeper-Signature: sha256=`. -- `--webhook-queue` (default 64) caps pending deliveries; excess events are dropped with a stderr warning. -- `--quiet` suppresses the human-mode status line; `--json` prints raw events line-delimited. - -## Examples - -```sh -beeper watch -beeper watch --chat '!abc:beeper.com' --json -beeper watch --include-type message.upserted --include-type message.deleted -beeper watch --webhook https://example.com/hook --webhook-secret "$BEEPER_WEBHOOK_SECRET" -``` diff --git a/packages/cli/links.txt b/packages/cli/links.txt deleted file mode 100644 index b9d08d50..00000000 --- a/packages/cli/links.txt +++ /dev/null @@ -1,89 +0,0 @@ -- Beeper Desktop - - Beeper Server desktop variant - - Channels: - - ```text - prod = stable - nightly = nightly - beta = beta - ``` - - Bundle IDs - - ```text - Beeper Desktop prod: com.automattic.beeper.desktop - Beeper Desktop nightly: com.automattic.beeper.desktop.nightly - - Beeper Server prod: com.automattic.beeper.server - Beeper Server nightly: com.automattic.beeper.server.nightly - ``` - - JSON feed API - - ### Beeper Desktop - - ```text - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop&platform=darwin&channel=stable&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop&platform=darwin&channel=stable&arch=arm64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop&platform=win32&channel=stable&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop&platform=win32&channel=stable&arch=arm64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop&platform=linux&channel=stable&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop&platform=linux&channel=stable&arch=arm64 - - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop.nightly&platform=darwin&channel=nightly&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop.nightly&platform=darwin&channel=nightly&arch=arm64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop.nightly&platform=win32&channel=nightly&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop.nightly&platform=win32&channel=nightly&arch=arm64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop.nightly&platform=linux&channel=nightly&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.desktop.nightly&platform=linux&channel=nightly&arch=arm64 - ``` - - ### Beeper Server - - ```text - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=darwin&channel=stable&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=darwin&channel=stable&arch=arm64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=win32&channel=stable&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=win32&channel=stable&arch=arm64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=linux&channel=stable&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=linux&channel=stable&arch=arm64 - - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=darwin&channel=nightly&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=darwin&channel=nightly&arch=arm64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=win32&channel=nightly&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=win32&channel=nightly&arch=arm64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=linux&channel=nightly&arch=x64 - https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=linux&channel=nightly&arch=arm64 - ``` - - YAML feed API - - Use same bundle IDs/channels/arches with these endpoints: - - ```text - macOS: https://api.beeper.com/desktop/update-feed/latest-mac.yml?bundleID=&channel=&arch= - Windows: https://api.beeper.com/desktop/update-feed/latest.yml?bundleID=&channel=&arch= - Linux x64: https://api.beeper.com/desktop/update-feed/latest-linux.yml?bundleID=&channel=&arch=x64 - Linux arm64: https://api.beeper.com/desktop/update-feed/latest-linux-arm64.yml?bundleID=&channel=&arch=arm64 - ``` - - Download redirect API - - Pattern: - - ```text - https://api.beeper.com/desktop/download//// - ``` - - Examples: - - ```text - https://api.beeper.com/desktop/download/macos/arm64/stable/com.automattic.beeper.desktop - https://api.beeper.com/desktop/download/macos/arm64/nightly/com.automattic.beeper.desktop.nightly - - https://api.beeper.com/desktop/download/macos/arm64/stable/com.automattic.beeper.server - https://api.beeper.com/desktop/download/macos/arm64/nightly/com.automattic.beeper.server.nightly - ``` - - Public API endpoints above are the supported feed and download link patterns. Do not publish underlying CDN feed URLs. diff --git a/packages/cli/package.json b/packages/cli/package.json index f79ec98f..9ca7aa12 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,141 +1,42 @@ { - "name": "@beeper/cli", + "name": "beeper-cli", "version": "0.6.2", "description": "Beeper CLI", "license": "MIT", "type": "module", "bin": { - "beeper": "bin/run.js" + "beeper": "bin/cli.js" }, "exports": { - "./plugin-sdk": { - "types": "./dist/plugin-sdk.d.ts", - "import": "./dist/plugin-sdk.js" - }, "./package.json": "./package.json" }, "files": [ - "bin", + "bin/cli.js", "dist", + "docs", + "safety-profiles", "README.md", "LICENSE" ], "scripts": { - "build": "bun run clean && bun scripts/prepare-desktop-api.ts && bun scripts/generate-command-map.ts && tsc -p tsconfig.json", - "binary": "bun run build && bun scripts/build-binaries.ts", - "release:local": "bun run build && bun scripts/build-binaries.ts && BEEPER_CLI_REQUIRE_MACOS_SIGNING=1 bun scripts/sign-macos-binaries.ts && bun scripts/build-homebrew-archive.ts && (cd ../npm && bun run build) && bun scripts/publish-local-release.ts", - "check:api-copy": "bun run build && bun scripts/check-api-copy.ts", - "check:readme": "bun run build && bun scripts/generate-readme.ts --check", + "build": "bun run clean && bun scripts/prepare-desktop-api.ts && tsc -p tsconfig.json", "clean": "rm -rf dist", + "docs:commands": "bun scripts/generate-command-docs.ts", "dev": "bun ./bin/dev.js", - "dev:shim": "node ./bin/run.js", "e2e:staging": "bun run build && bun test/e2e-staging.ts", - "pack:homebrew": "bun run binary && bun scripts/sign-macos-binaries.ts && bun scripts/build-homebrew-archive.ts", - "readme": "bun run build && bun scripts/generate-readme.ts", - "test": "bun run build && bun scripts/generate-readme.ts --check && bun scripts/check-api-copy.ts && bun scripts/check-manifest.ts && bun ./test/cli-smoke.ts && bun test", + "test": "bun run build && bun ./test/cli-smoke.ts && bun test", "typecheck": "tsc -p tsconfig.json --noEmit" }, - "oclif": { - "additionalHelpFlags": [ - "-h" - ], - "commands": { - "strategy": "explicit", - "target": "./dist/commands.generated.js", - "identifier": "commands" - }, - "bin": "beeper", - "dirname": "beeper", - "flexibleTaxonomy": true, - "helpOptions": { - "maxWidth": 100, - "hideAliasesFromRoot": true, - "hideCommandSummaryInDescription": true - }, - "scope": "beeper", - "pluginPrefix": "plugin", - "jitPlugins": { - "@beeper/cli-plugin-cloudflare": "^0.6.0" - }, - "plugins": [ - "@oclif/plugin-autocomplete", - "@oclif/plugin-help", - "@oclif/plugin-not-found", - "@oclif/plugin-plugins", - "@oclif/plugin-warn-if-update-available" - ], - "warn-if-update-available": { - "timeoutInDays": 1, - "message": "<%= chalk.dim('beeper-cli') %> <%= chalk.cyan(config.version) %> → <%= chalk.green(latest) %>. Run <%= chalk.bold('beeper update') %> to upgrade.", - "registry": "https://registry.npmjs.org", - "frequency": 6, - "frequencyUnit": "hours" - }, - "topicSeparator": " ", - "topics": { - "api": { - "description": "Call raw Desktop API endpoints" - }, - "accounts": { - "description": "Manage connected chat-network accounts" - }, - "auth": { - "description": "Inspect and clear stored authentication" - }, - "bridges": { - "description": "List bridges that can connect or reconnect chat accounts" - }, - "chats": { - "description": "List, search, manage, and modify chats" - }, - "config": { - "description": "Manage local CLI configuration" - }, - "contacts": { - "description": "List, search, and inspect contacts" - }, - "media": { - "description": "Download message media" - }, - "messages": { - "description": "List, search, show, edit, delete, and export messages" - }, - "send": { - "description": "Send text, files, and reactions" - }, - "setup": { - "description": "Make a Beeper target ready" - }, - "targets": { - "description": "Manage local Desktop, managed Server, and remote Beeper targets" - }, - "verify": { - "description": "Finish setup verification or verify another device" - }, - "install": { - "description": "Install Beeper Desktop or Beeper Server" - } - } - }, "dependencies": { "@beeper/desktop-api": "github:beeper/desktop-api-js#next", - "@oclif/core": "^4.11.2", - "@oclif/plugin-autocomplete": "^3.2.49", - "@oclif/plugin-help": "^6.2.48", - "@oclif/plugin-not-found": "^3.2.85", - "@oclif/plugin-plugins": "^5.4.67", - "@oclif/plugin-warn-if-update-available": "^3.1.49", - "figures": "^6.1.0", - "ink": "^7.0.3", - "ink-spinner": "^5.0.0", "qrcode": "1.5.4", - "react": "^19.2.6", - "ws": "^8.20.1" + "ws": "^8.20.1", + "yaml": "^2.9.0" }, "devDependencies": { "@types/bun": "^1.3.3", "@types/node": "^20.0.0", - "@types/react": "^19.2.14", + "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "typescript": "^5.7.2" } diff --git a/packages/cli/release-please-config.json b/packages/cli/release-please-config.json deleted file mode 100644 index 0eec94e0..00000000 --- a/packages/cli/release-please-config.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "packages": { - ".": {} - }, - "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", - "include-v-in-tag": true, - "include-component-in-tag": false, - "versioning": "prerelease", - "prerelease": true, - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": false, - "pull-request-header": "Automated Release PR", - "pull-request-title-pattern": "release: ${version}", - "changelog-sections": [ - { - "type": "feat", - "section": "Features" - }, - { - "type": "fix", - "section": "Bug Fixes" - }, - { - "type": "perf", - "section": "Performance Improvements" - }, - { - "type": "revert", - "section": "Reverts" - }, - { - "type": "chore", - "section": "Chores" - }, - { - "type": "docs", - "section": "Documentation" - }, - { - "type": "style", - "section": "Styles" - }, - { - "type": "refactor", - "section": "Refactors" - }, - { - "type": "test", - "section": "Tests", - "hidden": true - }, - { - "type": "build", - "section": "Build System" - }, - { - "type": "ci", - "section": "Continuous Integration", - "hidden": true - } - ], - "release-type": "simple", - "extra-files": [ - "package.json", - "README.md" - ] -} diff --git a/packages/cli/safety-profiles/agent-safe.yaml b/packages/cli/safety-profiles/agent-safe.yaml new file mode 100644 index 00000000..dd397b05 --- /dev/null +++ b/packages/cli/safety-profiles/agent-safe.yaml @@ -0,0 +1,23 @@ +name: agent-safe +description: Agent workflow profile. Allows reads, setup inspection, target runtime control, default selection, and email auth. + +allow: + - version + - status + - schema + - mcp + - setup + - use + - targets.list + - targets.logs + - targets.runtime + - auth.email + - accounts.list + - contacts.list + - chats.list + - chats.show + - messages.list + - messages.search + - messages.context + - resolve + - watch diff --git a/packages/cli/safety-profiles/full.yaml b/packages/cli/safety-profiles/full.yaml new file mode 100644 index 00000000..35e6db0e --- /dev/null +++ b/packages/cli/safety-profiles/full.yaml @@ -0,0 +1,5 @@ +name: full +description: Full Beeper CLI surface. + +allow: + - all diff --git a/packages/cli/safety-profiles/readonly.yaml b/packages/cli/safety-profiles/readonly.yaml new file mode 100644 index 00000000..742bd531 --- /dev/null +++ b/packages/cli/safety-profiles/readonly.yaml @@ -0,0 +1,19 @@ +name: readonly +description: Read-only commands only. Mutating commands, setup writes, sends, deletes, installs, and auth writes are blocked. + +allow: + - version + - status + - schema + - mcp + - targets.list + - targets.logs + - accounts.list + - contacts.list + - chats.list + - chats.show + - messages.list + - messages.search + - messages.context + - resolve + - watch diff --git a/packages/cli/scripts/bootstrap b/packages/cli/scripts/bootstrap deleted file mode 100755 index c96c19ee..00000000 --- a/packages/cli/scripts/bootstrap +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then - brew bundle check >/dev/null 2>&1 || { - echo -n "==> Install Homebrew dependencies? (y/N): " - read -r response - case "$response" in - [yY][eE][sS]|[yY]) - brew bundle - ;; - *) - ;; - esac - echo - } -fi -echo "==> Checking Bun dependencies" -if [ ! -d node_modules ]; then - echo "node_modules is missing. Run bun install after approving dependency installation." - exit 1 -fi diff --git a/packages/cli/scripts/build b/packages/cli/scripts/build deleted file mode 100755 index 5615b55c..00000000 --- a/packages/cli/scripts/build +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -echo "==> Building Beeper CLI" -bun run build diff --git a/packages/cli/scripts/build-binaries.ts b/packages/cli/scripts/build-binaries.ts deleted file mode 100644 index d0f6c3a8..00000000 --- a/packages/cli/scripts/build-binaries.ts +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bun -import { createHash } from 'node:crypto' -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const root = fileURLToPath(new URL('..', import.meta.url)) -const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) -const outDir = join(root, 'dist', 'bin') -const entrypoint = join(root, 'bin', 'binary-bootstrap.js') -const payloadArchive = join(root, 'dist', 'binary-payload.tar.gz') -const targets = (process.env.BEEPER_BINARY_TARGETS || [ - 'bun-darwin-arm64', - 'bun-darwin-x64', - 'bun-linux-arm64', - 'bun-linux-x64', -].join(',')).split(',').map(target => target.trim()).filter(Boolean) - -await mkdir(outDir, { recursive: true }) -await buildPayload() - -const artifacts = [] -for (const target of targets) { - const platform = target.replace(/^bun-/, '') - const outfile = join(outDir, platform.startsWith('windows-') ? `beeper-${platform}.exe` : `beeper-${platform}`) - const result = await Bun.build({ - entrypoints: [entrypoint], - compile: { - outfile, - target, - }, - minify: true, - sourcemap: 'linked', - bytecode: true, - }) - - if (!result.success) { - for (const log of result.logs) console.error(log) - throw new Error(`Failed to build ${target}`) - } - - const sha256 = await hashFile(outfile) - artifacts.push({ file: basename(outfile), path: outfile, platform, sha256, target }) - console.log(`${outfile}`) - console.log(`sha256 ${sha256}`) -} - -await writeFile( - join(outDir, 'binaries.json'), - `${JSON.stringify({ command: 'beeper', package: pkg.name, version: pkg.version, artifacts }, null, 2)}\n`, -) - -async function hashFile(path) { - const hash = createHash('sha256') - hash.update(await readFile(path)) - return hash.digest('hex') -} - -async function buildPayload() { - const workDir = await mkdtemp(join(tmpdir(), 'beeper-cli-payload-')) - try { - await cp(join(root, 'package.json'), join(workDir, 'package.json')) - await cp(join(root, 'bin'), join(workDir, 'bin'), { recursive: true }) - await mkdir(join(workDir, 'scripts'), { recursive: true }) - await cp(join(root, 'dist'), join(workDir, 'dist'), { - recursive: true, - filter: source => !source.includes('/dist/bin/') && source !== payloadArchive, - }) - await cp(join(root, 'scripts', 'prepare-desktop-api.ts'), join(workDir, 'scripts', 'prepare-desktop-api.ts')) - await run('bun', ['install', '--production'], { cwd: workDir }) - await run('bun', ['scripts/prepare-desktop-api.ts'], { cwd: workDir }) - await rm(payloadArchive, { force: true }) - await run('tar', ['-czf', payloadArchive, '-C', workDir, '.'], { cwd: root }) - } finally { - await rm(workDir, { recursive: true, force: true }) - } -} - -async function run(command, args, options = {}) { - const child = Bun.spawn([command, ...args], { - cwd: options.cwd || root, - env: process.env, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }) - const code = await child.exited - if (code !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${code}`) -} diff --git a/packages/cli/scripts/build-homebrew-archive.ts b/packages/cli/scripts/build-homebrew-archive.ts deleted file mode 100644 index 39669de1..00000000 --- a/packages/cli/scripts/build-homebrew-archive.ts +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bun -import { createHash } from 'node:crypto' -import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const root = fileURLToPath(new URL('..', import.meta.url)) -const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) -const version = process.env.PACKAGE_VERSION || pkg.version -const binaryDir = join(root, 'dist', 'bin') -const outDir = join(root, 'dist', 'release') -const manifestPath = join(binaryDir, 'binaries.json') -const metadataPath = join(outDir, 'homebrew.json') - -await rm(outDir, { recursive: true, force: true }) -await mkdir(outDir, { recursive: true }) - -const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) -const archives = [] -for (const artifact of manifest.artifacts) { - const platform = artifact.platform - const binaryPath = artifact.path || join(binaryDir, artifact.binaryFile || `beeper-${platform}`) - const workDir = await mkdtemp(join(tmpdir(), `beeper-cli-${platform}-`)) - const archiveName = releaseArchiveName(version, platform) - const archivePath = join(outDir, archiveName) - - await mkdir(join(workDir, 'bin'), { recursive: true }) - const installedBinary = join(workDir, 'bin', 'beeper') - await cp(binaryPath, installedBinary) - await chmod(installedBinary, 0o755) - await rm(archivePath, { force: true }) - const binarySha256 = await hashFile(binaryPath) - if (platform.startsWith('darwin-')) { - await run('/usr/bin/zip', ['-X', '-r', archivePath, 'bin'], { cwd: workDir }) - } else { - await run('tar', ['-czf', archivePath, '-C', workDir, '.'], { cwd: root }) - } - const sha256 = await hashFile(archivePath) - archives.push({ archive: basename(archivePath), path: archivePath, platform, sha256 }) - artifact.binaryFile = artifact.binaryFile || artifact.file - artifact.binarySha256 = binarySha256 - artifact.file = basename(archivePath) - artifact.sha256 = sha256 - artifact.archive = basename(archivePath) - console.log(`${archivePath}`) - console.log(`sha256 ${sha256}`) - await rm(workDir, { recursive: true, force: true }) -} - -await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) -await writeFile( - metadataPath, - `${JSON.stringify( - { - archives, - command: 'beeper', - displayName: 'Beeper CLI', - package: 'beeper-cli', - version, - }, - null, - 2, - )}\n`, -) - -function releaseArchiveName(version, platform) { - const [os, arch] = platform.split('-') - const displayOS = os === 'darwin' ? 'macos' : os - const extension = os === 'darwin' ? 'zip' : 'tar.gz' - return `beeper-cli-${version}-${displayOS}-${arch}.${extension}` -} - -async function hashFile(path) { - const hash = createHash('sha256') - hash.update(await readFile(path)) - return hash.digest('hex') -} - -async function run(command, args, options = {}) { - const child = Bun.spawn([command, ...args], { - cwd: options.cwd || root, - env: process.env, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }) - const code = await child.exited - if (code !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${code}`) -} diff --git a/packages/cli/scripts/check-api-copy.ts b/packages/cli/scripts/check-api-copy.ts deleted file mode 100644 index dca59f5a..00000000 --- a/packages/cli/scripts/check-api-copy.ts +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bun -import {readFile} from 'node:fs/promises'; -import {existsSync} from 'node:fs'; -import {fileURLToPath} from 'node:url'; -import {join, resolve} from 'node:path'; - -const root = resolve(fileURLToPath(new URL('..', import.meta.url))); -const {apiCopy} = await import('../dist/lib/copy.js'); - -const checks = [ - ['accounts.list', 'resources/accounts/accounts.d.ts', 'list'], - ['assets.download', 'resources/assets.d.ts', 'download'], - ['assets.upload', 'resources/assets.d.ts', 'upload'], - ['chats.archive', 'resources/chats/chats.d.ts', 'archive'], - ['chats.create', 'resources/chats/chats.d.ts', 'create'], - ['chats.list', 'resources/chats/chats.d.ts', 'list'], - ['chats.markRead', 'resources/chats/chats.d.ts', 'markRead'], - ['chats.markUnread', 'resources/chats/chats.d.ts', 'markUnread'], - ['chats.notifyAnyway', 'resources/chats/chats.d.ts', 'notifyAnyway'], - ['chats.retrieve', 'resources/chats/chats.d.ts', 'retrieve'], - ['chats.search', 'resources/chats/chats.d.ts', 'search'], - ['chats.start', 'resources/chats/chats.d.ts', 'start'], - ['contacts.search', 'resources/accounts/contacts.d.ts', 'search'], - ['messages.delete', 'resources/messages.d.ts', 'delete'], - ['messages.list', 'resources/messages.d.ts', 'list'], - ['messages.retrieve', 'resources/messages.d.ts', 'retrieve'], - ['messages.search', 'resources/messages.d.ts', 'search'], - ['messages.send', 'resources/messages.d.ts', 'send'], - ['messages.update', 'resources/messages.d.ts', 'update'], - ['reactions.add', 'resources/chats/messages/reactions.d.ts', 'add'], - ['reactions.delete', 'resources/chats/messages/reactions.d.ts', 'delete'], - ['reminders.create', 'resources/chats/reminders.d.ts', 'create'], - ['reminders.delete', 'resources/chats/reminders.d.ts', 'delete'], -]; - -const failures = []; - -for (const [copyPath, sdkPath, method] of checks) { - const expected = getPath(apiCopy, copyPath); - const actual = await sdkMethodDescription(sdkPath, method); - if (expected !== actual) { - failures.push(`${copyPath}\n expected: ${expected}\n actual: ${actual}`); - } -} - -if (failures.length > 0) { - console.error(`API copy drifted from @beeper/desktop-api:\n\n${failures.join('\n\n')}`); - process.exit(1); -} - -console.log(`api-copy: ${checks.length} SDK descriptions verified`); - -function getPath(object, path) { - return path.split('.').reduce((value, key) => value?.[key], object); -} - -async function sdkMethodDescription(relativePath, method) { - const packageRoot = join(root, 'node_modules', '@beeper', 'desktop-api'); - const sourcePath = join(packageRoot, relativePath); - const source = await readFile(existsSync(sourcePath) ? sourcePath : join(packageRoot, 'dist', relativePath), 'utf8'); - const methodMatch = source.match(new RegExp(String.raw`^\s*${method}\(`, 'm')); - const methodIndex = methodMatch?.index ?? -1; - if (methodIndex === -1) throw new Error(`Could not find SDK method ${relativePath}#${method}`); - - const comments = [...source.slice(0, methodIndex).matchAll(/\/\*\*([\s\S]*?)\*\//g)]; - const match = comments.at(-1); - if (!match) throw new Error(`Could not find SDK docs for ${relativePath}#${method}`); - - const lines = match[1] - .split('\n') - .map(line => line.replace(/^\s*\*\s?/, '').trimEnd()) - - const exampleIndex = lines.findIndex(line => line.startsWith('@example')); - return lines - .slice(0, exampleIndex === -1 ? undefined : exampleIndex) - .filter(Boolean) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); -} diff --git a/packages/cli/scripts/check-manifest.ts b/packages/cli/scripts/check-manifest.ts deleted file mode 100644 index 28a7108a..00000000 --- a/packages/cli/scripts/check-manifest.ts +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bun -/** - * Validate the command manifest and the public plugin-sdk export. - * - * - every manifest entry has at least one `examples[]` entry - * - the manifest contains no duplicates - * - the manifest matches src/commands/** filenames (defense-in-depth with cli-smoke.ts) - * - the ./plugin-sdk subpath resolves at runtime and re-exports BeeperCommand - */ -import { readdirSync } from 'node:fs' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { Config } from '@oclif/core/config' -import { commandManifest } from '../dist/lib/manifest.js' - -const root = fileURLToPath(new URL('..', import.meta.url)) -const failures = [] -const config = await Config.load({ root }) -const commandsByID = new Map(config.commands.map(command => [displayID(command.id), command])) - -const seen = new Set() -for (const entry of commandManifest) { - if (seen.has(entry.command)) failures.push(`Duplicate manifest entry: ${entry.command}`) - seen.add(entry.command) - if (!entry.examples?.length) failures.push(`Missing examples[] for: ${entry.command}`) - if (!entry.description) failures.push(`Missing description for: ${entry.command}`) - const command = commandsByID.get(entry.command) - const summary = command?.summary || command?.description - if (summary && entry.description !== summary) { - failures.push(`Manifest description for "${entry.command}" must match oclif summary: "${summary}"`) - } -} - -// The manifest may list commands shipped by first-party plugins (e.g. `targets tunnel` -// from @beeper/cli-plugin-cloudflare). Only enforce that every file in src/commands has -// a manifest entry — the reverse direction is allowed to include plugin-provided commands. -const internalCommands = new Set(['autocomplete']) -const commandFiles = listCommandFiles(join(root, 'src/commands')).filter(file => !internalCommands.has(fileToCommand(file))) -const fileCommands = new Set(commandFiles.map(fileToCommand)) -for (const file of fileCommands) { - if (!seen.has(file)) failures.push(`Command file has no manifest entry: ${file}`) -} - -try { - const sdk = await import('../dist/plugin-sdk.js') - if (typeof sdk.BeeperCommand !== 'function') failures.push('plugin-sdk: BeeperCommand is not exported as a class') - for (const name of ['ensureWritable', 'writeEvent', 'printData', 'printList', 'printSuccess', 'createBeeperClient', 'resolveTarget', 'readConfig', 'CLIError', 'ExitCodes', 'notFound', 'ambiguous', 'authRequired', 'notReady']) { - if (!(name in sdk)) failures.push(`plugin-sdk: missing export "${name}"`) - } -} catch (error) { - failures.push(`plugin-sdk: import failed — ${error.message}`) -} - -if (failures.length > 0) { - console.error(`check-manifest: ${failures.length} issue(s)`) - for (const issue of failures) console.error(` - ${issue}`) - process.exit(1) -} - -console.log(`check-manifest: ${commandManifest.length} commands ok, plugin-sdk surface ok`) - -function listCommandFiles(dir) { - const output = [] - for (const entry of readdirSync(dir, { withFileTypes: true })) { - // Files / directories starting with _ are private/internal (e.g. _complete used by autocomplete). - if (entry.name.startsWith('_') || entry.name === 'autocomplete.ts') continue - const path = join(dir, entry.name) - if (entry.isDirectory()) output.push(...listCommandFiles(path)) - else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) output.push(path) - } - return output -} - -function fileToCommand(file) { - const relative = file.slice(join(root, 'src/commands').length + 1) - const parts = relative.replace(/\.(ts|tsx)$/, '').split('/') - return parts.map(part => part === 'index' ? undefined : part).filter(Boolean).join(' ') -} - -function displayID(id) { - return id.replaceAll(':', ' ') -} diff --git a/packages/cli/scripts/format b/packages/cli/scripts/format deleted file mode 100755 index db2a3fa2..00000000 --- a/packages/cli/scripts/format +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Running gofmt -s -w" -gofmt -s -w . diff --git a/packages/cli/scripts/generate-command-docs.ts b/packages/cli/scripts/generate-command-docs.ts new file mode 100644 index 00000000..ef803a16 --- /dev/null +++ b/packages/cli/scripts/generate-command-docs.ts @@ -0,0 +1,96 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { commands } from '../src/cli/commands.js' +import { globalFlagSpecs } from '../src/cli/parse.js' +import type { ArgSpec, CommandSpec, FlagSpec } from '../src/cli/types.js' + +const root = new URL('..', import.meta.url).pathname +const docsDir = join(root, 'docs', 'commands') + +await rm(docsDir, { recursive: true, force: true }) +await mkdir(docsDir, { recursive: true }) +await writeFile(join(docsDir, 'README.md'), commandIndex()) + +for (const command of commands.filter(command => !command.hidden)) { + await writeFile(join(docsDir, `${command.path.join('-')}.md`), commandDoc(command)) +} + +function commandIndex(): string { + const rows = commands + .filter(command => !command.hidden) + .sort(compareCommands) + .map(command => `| [\`${command.path.join(' ')}\`](${command.path.join('-')}.md) | ${escapeTable(command.description)} | ${aliases(command)} |`) + return [ + '# Command Index', + '', + 'Generated from the live command registry. Do not edit command pages by hand.', + '', + '| Command | Description | Aliases |', + '| --- | --- | --- |', + ...rows, + '', + ].join('\n') +} + +function commandDoc(command: CommandSpec): string { + return [ + `# beeper ${command.path.join(' ')}`, + '', + command.description, + '', + '## Usage', + '', + '```sh', + `beeper ${command.path.join(' ')}${usageArgs(command.args ?? [])} [flags]`, + '```', + '', + command.aliases?.length ? ['## Aliases', '', ...command.aliases.map(alias => `- \`beeper ${alias.join(' ')}\``), ''].join('\n') : undefined, + section('Arguments', (command.args ?? []).map(argRow)), + section('Flags', (command.flags ?? []).map(flagRow)), + section('Global Flags', globalFlagSpecs.map(flagRow)), + command.examples?.length ? ['## Examples', '', ...command.examples.map(example => `\`\`\`sh\n${example}\n\`\`\``), ''].join('\n') : undefined, + ].filter(Boolean).join('\n') +} + +function section(title: string, rows: string[]): string | undefined { + if (!rows.length) return undefined + return [`## ${title}`, '', '| Name | Description |', '| --- | --- |', ...rows, ''].join('\n') +} + +function argRow(arg: ArgSpec): string { + const name = `${arg.required ? '<' : '['}${arg.name}${arg.variadic ? ' ...' : ''}${arg.required ? '>' : ']'}` + return `| \`${name}\` | ${escapeTable(arg.description ?? '')} |` +} + +function flagRow(flag: FlagSpec): string { + const tokens = [ + flag.short ? `-${flag.short}` : undefined, + `--${flag.name}${flag.type === 'boolean' ? '' : ` <${flag.placeholder ?? 'value'}>`}`, + ...(flag.aliases ?? []).map(alias => `--${alias}`), + ].filter(Boolean).join(', ') + const suffixes = [ + flag.default !== undefined ? `Default: \`${String(flag.default)}\`.` : undefined, + flag.enum?.length ? `Values: ${flag.enum.map(value => `\`${value}\``).join(', ')}.` : undefined, + flag.env?.length ? `Env: ${flag.env.map(value => `\`${value}\``).join(', ')}.` : undefined, + flag.multiple ? 'Repeatable.' : undefined, + flag.required ? 'Required.' : undefined, + ].filter(Boolean).join(' ') + return `| \`${tokens}\` | ${escapeTable([flag.description, suffixes].filter(Boolean).join(' '))} |` +} + +function usageArgs(args: ArgSpec[]): string { + if (!args.length) return '' + return ` ${args.map(arg => arg.variadic ? `<${arg.name}> ...` : arg.required ? `<${arg.name}>` : `[${arg.name}]`).join(' ')}` +} + +function aliases(command: CommandSpec): string { + return command.aliases?.length ? command.aliases.map(alias => `\`${alias.join(' ')}\``).join(', ') : '' +} + +function compareCommands(a: CommandSpec, b: CommandSpec): number { + return a.path.join(' ').localeCompare(b.path.join(' ')) +} + +function escapeTable(value: string): string { + return value.replaceAll('|', '\\|').replaceAll('\n', '
') +} diff --git a/packages/cli/scripts/generate-command-map.ts b/packages/cli/scripts/generate-command-map.ts deleted file mode 100644 index 3e68239d..00000000 --- a/packages/cli/scripts/generate-command-map.ts +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bun -import { readdir, writeFile } from 'node:fs/promises' -import { join, relative, sep } from 'node:path' -import { fileURLToPath } from 'node:url' - -const root = fileURLToPath(new URL('..', import.meta.url)) -const commandsDir = join(root, 'src', 'commands') -const outPath = join(root, 'src', 'commands.generated.ts') - -const listAliases: Record = { - 'accounts:list': ['accounts'], - 'bridges:list': ['bridges'], - 'chats:list': ['chats', 'accounts:chats'], - 'contacts:list': ['contacts'], - 'targets:list': ['targets'], -} - -const files = await listCommandFiles(commandsDir) -const canonicalEntries = files - .map(file => ({ - command: fileToCommand(file), - importPath: `./commands/${relative(commandsDir, file).split(sep).join('/').replace(/\.(ts|tsx)$/, '.js')}`, - })) - .sort((a, b) => a.command.localeCompare(b.command)) -const entries = canonicalEntries - .flatMap(entry => [entry, ...(listAliases[entry.command] ?? []).map(command => ({ command, importPath: entry.importPath }))]) - .sort((a, b) => a.command.localeCompare(b.command)) - -const importPaths = canonicalEntries.map(entry => entry.importPath) -const commandImports = new Map(importPaths.map((importPath, index) => [importPath, `Command${index}`])) -const imports = importPaths.map((importPath, index) => `import Command${index} from '${importPath}'`).join('\n') -const mapEntries = entries.map(entry => ` '${entry.command}': ${commandImports.get(entry.importPath)},`).join('\n') - -await writeFile( - outPath, - `${imports} - -export const commands = { -${mapEntries} -} -`, -) - -async function listCommandFiles(dir) { - const output = [] - for (const entry of await readdir(dir, { withFileTypes: true })) { - if (entry.name.startsWith('_')) continue - const path = join(dir, entry.name) - if (entry.isDirectory()) { - output.push(...await listCommandFiles(path)) - } else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { - output.push(path) - } - } - return output -} - -function fileToCommand(file) { - const commandPath = relative(commandsDir, file).split(sep).join('/') - const parts = commandPath.replace(/\.(ts|tsx)$/, '').split('/') - return parts.map(part => part === 'index' ? undefined : part).filter(Boolean).join(':') -} diff --git a/packages/cli/scripts/generate-readme.ts b/packages/cli/scripts/generate-readme.ts deleted file mode 100644 index f9529adc..00000000 --- a/packages/cli/scripts/generate-readme.ts +++ /dev/null @@ -1,502 +0,0 @@ -#!/usr/bin/env bun -import {readFile, writeFile} from 'node:fs/promises'; -import {Config} from '@oclif/core/config'; -import {commandManifest} from '../dist/lib/manifest.js'; - -const config = await Config.load({root: process.cwd()}); -const check = process.argv.includes('--check'); -// Include hidden commands so manifest-listed commands still render in the README -// and pass the manifest match. -const commandsByID = new Map([...config.commands].map(command => [displayID(command.id), command])); -// Manifest entries for plugin-shipped commands (e.g. `targets tunnel` from -// @beeper/cli-plugin-cloudflare) won't be in the built oclif config unless that plugin is -// installed. Render them from the manifest entry directly instead of erroring. -const commands = commandManifest.map(item => { - const command = commandsByID.get(item.command); - if (command) return command; - return { - id: item.command.replaceAll(' ', ':'), - summary: item.description, - description: item.description, - args: {}, - flags: {}, - pluginShipped: true, - }; -}); - -const globalFlags = new Set(['base-url', 'debug', 'events', 'full', 'json', 'quiet', 'read-only', 'target', 'timeout', 'yes']); -const commandList = commands.map(command => { - const id = displayID(command.id); - return `| \`${id}\` | ${escapeTable(text(command.summary || command.description || ''))} |`; -}); - -const examplesByID = new Map(commandManifest.map(item => [item.command, item.examples ?? []])); -const commandSections = commands.map(command => commandSection(command)).join('\n\n'); - -const intro = `# beeper — One CLI for all your chats - -> Built for you and your agent. Batteries included. - -Talks to Beeper Desktop on this machine, to a Beeper Server you self-host, or -to either one running somewhere else. Send and receive across the chat -networks Beeper bridges, from one CLI shaped for scripts, agents, and humans -in a hurry. - -**Supported chat networks** (via Beeper's bridges): -WhatsApp · iMessage · Telegram · Discord · Signal · Instagram DMs · -Facebook Messenger · X (Twitter) DMs · LinkedIn · Slack · -Google Messages (RCS/SMS) · Google Chat · Matrix · IRC · Bluesky. -Run \`beeper bridges list\` for the live list on your target. - -Command manual: \`beeper man\` · CLI docs: \`beeper docs\` - -## Features - -- **Connects to your Beeper.** Local Beeper Desktop on this machine (default), a Beeper Server you install and manage via the CLI, or a remote Beeper Desktop or Beeper Server authorized over OAuth/PKCE — or a bearer token in CI. -- **Setup that does the work.** \`beeper setup\` finds Beeper Desktop, offers to launch it, adopts the session. \`--server --install\` installs and starts a headless server in one step. \`--oauth\` opens the browser. \`--remote URL\` does the rest. -- **Every chat, every network.** List, search, start, archive, pin, mute, rename, focus. Read, edit, delete, react. Send text, files, stickers, voice, typing indicators. Download media. Export to JSON or Markdown. -- **Verification first-class.** SAS/QR device verification, recovery-key unlock, \`status\`/\`doctor\` to reach an encrypted-ready target — without leaving the shell. -- **Agent-shaped automation.** \`--json\` everywhere, NDJSON \`--events\`, \`watch\` with WebSocket + outbound HMAC-signed webhooks, \`rpc\` over stdin/stdout, \`man --json\` tool manifests, raw \`api get\`/\`post\`/\`request\` for Beeper Client API endpoints we haven't wrapped yet. -- **Safe by default.** \`--read-only\` rejects every mutating command. Writes stay explicit. Plugins extend the CLI without forking it. - -## Install - -### Homebrew (recommended) - -\`\`\`sh -brew install beeper/tap/cli -\`\`\` - -The installed command is \`beeper\`. - -### npm - -\`\`\`sh -npx beeper-cli --help -npm install -g beeper-cli -\`\`\` - -The package name is \`beeper-cli\`; the installed command is \`beeper\`. - -### Build from source - -This repo is a Bun workspace. From the repo root: - -\`\`\`sh -bun install -bun --filter @beeper/cli run build -bun --filter @beeper/cli run dev -- --help -\`\`\` - -For local CLI development inside \`packages/cli\`: - -\`\`\`sh -bun run dev -- --help -\`\`\` - -Regenerate this README after command, flag, or argument changes: - -\`\`\`sh -bun run readme -\`\`\` - -## Quick start - -The happy path: Beeper Desktop is already on this machine. \`beeper setup\` finds -it, offers to launch it if it's not running, and adopts the session. - -\`\`\`text -$ beeper setup -Looking for Beeper Desktop… found, not running. -Launch it now? [Y/n] y -▎ Launched Beeper Desktop - next Run \`beeper setup\` again once it finishes starting. - -$ beeper setup -Use this Desktop session for CLI access? [Y/n] y -▎ Connected desktop - accounts whatsapp, telegram, imessage - endpoint http://127.0.0.1:23373 - -$ beeper chats list --limit 3 - 10313 Family 3 unread - 8951 Alice · - 7204 Eng standup 12 unread - -$ beeper messages search "flight" - 8951 Alice · "your flight is at 6:40, gate B23" 2d ago - 10313 Family · "what flight are you on?" 1w ago - -$ beeper send text --to Family --message "on my way" -▎ Sent Family - message "on my way" - at 2026-05-18T14:02:11Z - -$ beeper export --out ./beeper-export -▎ Exported ./beeper-export - chats 214 messages 38,901 attachments 1,205 -\`\`\` - -Recipients accept a numeric local chat ID, a full Beeper/Matrix chat ID, an -iMessage chat ID, an exact title, or search text. Ambiguous matches prompt in a -TTY; pass \`--pick N\` in scripts. - -## Connecting a target - -A *target* is the Beeper endpoint \`beeper\` talks to — local Beeper Desktop, -local Beeper Server, or a remote Beeper Desktop or Beeper Server. Pick one of -four paths. - -### 1. Local Beeper Desktop (default, recommended) - -If Beeper Desktop is installed and signed in here, \`beeper setup\` discovers it -on \`http://127.0.0.1:23373\` and adopts the existing session. If it's installed -but not running, \`setup\` offers to launch it. If it's not installed at all, -\`--install\` does that in one step. - -\`\`\`text -$ beeper setup --desktop --install -▎ Installed Beeper Desktop (stable) -▎ Launched Beeper Desktop - next Sign in to Beeper Desktop, then re-run \`beeper setup\`. - -$ beeper setup -▎ Connected desktop - accounts whatsapp, telegram -\`\`\` - -Variants: \`beeper setup --local\` to skip discovery and force the local path; -\`beeper install desktop --channel nightly\` for the nightly channel. - -### 2. Local Beeper Server (self-hosted, managed by the CLI) - -For a headless long-running setup on this machine, install and adopt a local -Beeper Server. The CLI manages the process — \`targets start/stop/restart/logs/enable\`. - -\`\`\`text -$ beeper setup --server --install -▎ Installed Beeper Server (stable) -▎ Started server on http://127.0.0.1:23373 - auth Opening browser to authorize this server… -▎ Connected server - accounts (none) - next Run \`beeper accounts add\` to connect a network. - -$ beeper accounts add -? Which bridge? whatsapp - Scan this QR code with WhatsApp on your phone: - ▄▄▄▄▄▄▄ ▄ ▄ ▄▄▄▄▄▄▄ - █ ███ █ ▄█▄ █ ███ █ - █ ███ █ ▀█▀ █ ███ █ - ▀▀▀▀▀▀▀ ▀ ▀ ▀▀▀▀▀▀▀ -▎ Connected whatsapp · +1•••4242 -\`\`\` - -Variants: \`beeper install server\`, \`beeper install server --server-env staging\`. - -### 3. Remote Desktop or Server via OAuth (PKCE) - -For a Beeper Desktop or Server running on another machine, authorize the CLI -through a browser-based OAuth/PKCE flow. - -\`\`\`text -$ beeper setup --remote https://desktop.example.com -▎ Authorizing https://desktop.example.com - flow OAuth (PKCE) — opening browser… -▎ Connected remote (desktop.example.com) - accounts whatsapp, telegram, signal -\`\`\` - -Variants: \`beeper setup --oauth\` (PKCE against the default Beeper auth); -\`beeper targets add remote work https://desktop.example.com --default\` to -register additional remotes. - -### 4. Bearer token (non-interactive / CI) - -For agents, CI, and scripts, hand the CLI a bearer token directly — no -browser, no interactive prompts. - -\`\`\`sh -BEEPER_ACCESS_TOKEN=... beeper chats list --json -BEEPER_ACCESS_TOKEN=... BEEPER_DESKTOP_BASE_URL=https://desktop.example.com \\ - beeper messages list --chat 10313 --json -\`\`\` - -Once connected, \`beeper accounts add\` walks each chat-network bridge through -its own login — QR, code, OAuth, cookie, whatever the bridge requires — so -WhatsApp, Telegram, Discord, iMessage, and the rest show up under \`accounts list\`. - -## Documentation - -| Topic | Page | Commands | -| --- | --- | --- | -| **Setup + install** | [setup](docs/setup.md) · [auth](docs/auth.md) | \`setup\` · \`install desktop\` · \`install server\` · \`verify\` · \`status\` · \`doctor\` · \`auth status\` | -| **Targets** | [targets](docs/targets.md) | \`targets list\` · \`targets add desktop\` · \`targets add server\` · \`targets add remote\` · \`targets use\` · \`targets status\` · \`targets logs\` | -| **Bridges + accounts** | [accounts](docs/accounts.md) | \`bridges list\` · \`bridges show\` · \`accounts list\` · \`accounts add\` · \`accounts show\` · \`accounts use\` · \`accounts remove\` | -| **Chats** | [chats](docs/chats.md) | \`chats list\` · \`chats search\` · \`chats show\` · \`chats start\` · \`chats archive\` · \`chats pin\` · \`chats mute\` · \`chats priority\` · \`chats remind\` · \`chats rename\` · \`chats draft\` · \`chats focus\` | -| **Messages** | [messages](docs/messages.md) · [send](docs/send.md) · [presence](docs/presence.md) | \`messages list\` · \`messages search\` · \`messages export\` · \`send text\` · \`send file\` · \`send sticker\` · \`send voice\` · \`send react\` · \`presence\` | -| **Contacts + media** | [contacts](docs/contacts.md) · [media](docs/media.md) · [export](docs/export.md) | \`contacts list\` · \`contacts search\` · \`media download\` · \`export\` | -| **Automation** | [watch](docs/watch.md) · [rpc](docs/rpc.md) · [api](docs/api.md) | \`watch\` · \`watch --webhook\` · \`rpc\` · \`man\` · \`api get\` · \`api post\` · \`api request\` | -| **Maintenance** | [config](docs/config.md) · [update](docs/update.md) | \`update\` · \`config\` · \`completion\` · \`docs\` · \`version\` | - -Use \`beeper docs\` to open the CLI docs and \`beeper man\` to print the local -command manual. - -## Configuration - -Default Beeper Client API target: \`http://127.0.0.1:23373\`. CLI configuration is -stored under your user config dir; print it with \`beeper config path\`. - -**Global flags:** \`--base-url\`, \`--target\`, \`--json\`, \`--events\`, -\`--full\`, \`--timeout\`, \`--read-only\`, \`--debug\`, \`--yes\`, \`--quiet\`. - -**Environment overrides:** - -| Variable | Effect | -| --- | --- | -| \`BEEPER_ACCESS_TOKEN\` | Bearer token for the selected target. Overrides stored OAuth login. | -| \`BEEPER_DESKTOP_BASE_URL\` | Beeper Client API base URL (Desktop or Server). Defaults to \`http://127.0.0.1:23373\`. | -| \`BEEPER_READONLY\` | \`1\`/\`true\`/\`yes\`/\`on\` enables read-only mode globally. | -| \`BEEPER_CLI_CONFIG_DIR\` | Override config directory for testing or isolated profiles. | - -## Exit codes - -| Code | Meaning | -| --- | --- | -| \`0\` | Success. | -| \`1\` | Generic runtime error. | -| \`2\` | Usage error (parsing, validation, missing required flag/arg, read-only refusal). | -| \`3\` | Auth required (no stored token; sign in or set \`BEEPER_ACCESS_TOKEN\`). | -| \`4\` | Target/account not ready (\`doctor\` reports this when readiness is not \`ready\`). | -| \`5\` | Selector matched nothing (unknown target, account, chat, contact). | -| \`6\` | Ambiguous selector (multiple matches; pass an exact ID or \`--pick N\`). | - -JSON output preserves the same envelope on failure: \`{"success":false,"data":null,"error":"...","exitCode":N}\` written to stderr. - -## Addressing - -- Chat arguments accept numeric local chat IDs, full Beeper/Matrix chat IDs, iMessage chat IDs, exact titles, or search text. -- For scripts on the same target/profile, prefer the numeric local chat ID shown by \`beeper chats list\`; use the full Beeper/Matrix chat ID when the selector must work across targets or profiles. -- Numeric local chat IDs come from the selected Desktop database. Treat them as local to that target/profile. -- Ambiguous chat matches return numbered choices; pass \`--pick N\` to select one. -- Account arguments accept account IDs, network names, bridge type/id, or account user identity. -- Account filters can expand a network name to multiple matching accounts. -- \`contacts search\` and \`chats start\` can search across all accounts when \`--account\` is omitted. -- \`contacts list\` accepts the same account selectors as other account-scoped commands. - -## Output and scripting - -Most commands support: - -- app-like text by default, optimized for scanning chats, messages, contacts, accounts, and media -- \`--json\` for \`{"success":true,"data":...,"error":null}\` output on stdout -- \`--events\` for NDJSON lifecycle events on stderr from long-running commands -- \`--read-only\` to reject commands that modify Beeper or local CLI state -- \`--full\` to disable truncation -- \`--debug\` for SDK debug logging -- \`--target\` or \`--base-url\` to point at a different target - -\`man --json\` prints a compact command manifest for tools and agents. -\`rpc\` runs newline-delimited JSON command RPC over stdin/stdout. - -## Raw API access - -Raw Beeper Client API calls live under \`api\`, so scripts can reach a new -endpoint before a workflow command exists: - -\`\`\`sh -beeper api get /v1/info -beeper api post /v1/messages/{chatID}/send --body '{"text":"hello"}' -beeper api request DELETE /v1/chats/abc/messages/def/reactions --body '{"reactionKey":"👍"}' -\`\`\` - -## Plugins - -Beeper CLI supports optional oclif plugins. List recommended Beeper plugins: - -\`\`\`sh -beeper plugins available -\`\`\` - -Install a published plugin: - -\`\`\`sh -beeper plugins install @beeper/cli-plugin-cloudflare -\`\`\` - -For plugin development, import from \`@beeper/cli/plugin-sdk\` and expose oclif -commands from your package. Link a local plugin while working on it: - -\`\`\`sh -beeper plugins link ./packages/cli-plugin-cloudflare -beeper targets tunnel --help -\`\`\` - -First-party optional plugins: - -| Package | Adds | -| --- | --- | -| \`@beeper/cli-plugin-cloudflare\` | \`targets tunnel\` for exposing a selected Beeper target through Cloudflare Tunnel. | - -`; - -const inspiration = `## Inspiration - -- [wacli](https://wacli.sh/) — scriptable WhatsApp CLI whose command-line product shape we borrow from. -`; - -const license = `## License - -MIT — see [\`packages/cli/LICENSE\`](packages/cli/LICENSE). -`; - -const fullReference = `## Command Summary - -| Command | Summary | -| --- | --- | -${commandList.join('\n')} - -## Command Reference - -${commandSections} -`; - -const publishing = `## Publishing - -Beeper CLI releases ship signed macOS Bun binaries inside versioned archives and -a thin npm package that downloads, verifies, extracts, and runs the matching -GitHub Release archive. - -For now, publishing runs from a local macOS machine: - -\`\`\`sh -bun run release 0.6.1 -\`\`\` - -The local release command: - -- builds standalone Bun binaries -- signs and notarizes macOS binaries when local signing credentials are available -- uploads versioned macOS and Linux archives to the GitHub release -- publishes \`beeper-cli\` to npm as a thin binary launcher package -- updates \`beeper/homebrew-tap\` with the pinned archive SHA - -Required local credentials: - -- GitHub CLI authenticated with release and tap access -- npm auth for publishing \`beeper-cli\` -- local Developer ID signing identity, or Fastlane match access via a - \`MOBILE_SECRETS_FILE\` path exported in your shell -- \`HOMEBREW_TAP_GITHUB_TOKEN\` for updating the tap -`; - -const referencePointer = `## Full command reference - -The complete \`beeper\` command summary and per-command reference (every flag, -arg, and example) lives in [\`packages/cli/README.md\`](packages/cli/README.md). -For terminal-side reference, \`beeper man\` prints the same manual locally and -\`beeper man --json\` emits a tool manifest for agents. -`; - -const rootReadme = [intro, referencePointer, inspiration, license].join('\n'); -const packageReadme = [intro, fullReference, publishing, inspiration, license].join('\n'); - -const outputs = [ - { path: 'README.md', body: packageReadme }, - { path: '../../README.md', body: rootReadme }, -]; - -if (check) { - for (const { path, body } of outputs) { - const current = await readFile(path, 'utf8'); - if (current !== body) { - console.error(`${path} is out of date. Run bun run readme.`); - process.exit(1); - } - } -} else { - for (const { path, body } of outputs) { - await writeFile(path, body); - } -} - -function commandSection(command) { - const id = displayID(command.id); - const usage = usageFor(command); - const parts = [ - `### \`beeper ${id}\``, - text(command.summary || command.description || ''), - '', - '```sh', - usage, - '```', - ]; - - if (command.description && command.description !== command.summary) { - parts.push('', text(command.description)); - } - - const args = Object.values(command.args || {}); - if (args.length > 0) { - parts.push('', 'Arguments:', '', '| Name | Required | Description |', '| --- | --- | --- |'); - for (const arg of args) { - parts.push(`| \`${arg.name}\` | ${arg.required ? 'yes' : 'no'} | ${escapeTable(arg.description || '')} |`); - } - } - - const flags = Object.values(command.flags || {}).filter(flag => !globalFlags.has(flag.name)); - if (flags.length > 0) { - parts.push('', 'Flags:', '', '| Flag | Type | Description |', '| --- | --- | --- |'); - for (const flag of flags.sort((a, b) => a.name.localeCompare(b.name))) { - parts.push(`| \`${escapeTable(flagLabel(flag))}\` | ${flag.type || 'boolean'} | ${escapeTable(flagDescription(flag))} |`); - } - } - - const examples = examplesByID.get(id) ?? []; - if (examples.length > 0) { - parts.push('', 'Examples:', '', '```sh', ...examples, '```'); - } - - const inherited = Object.values(command.flags || {}).filter(flag => globalFlags.has(flag.name)); - if (inherited.length > 0) { - parts.push('', `Global flags: ${inherited.map(flag => `\`--${flag.name}\``).join(', ')}.`); - } - - return parts.filter((part, index, array) => part !== '' || array[index - 1] !== '').join('\n'); -} - -function displayID(id) { - return id.replaceAll(':', ' '); -} - -function usageFor(command) { - const args = Object.values(command.args || {}).map(arg => arg.required ? `<${arg.name}>` : `[${arg.name}]`); - return ['beeper', displayID(command.id), ...args].join(' '); -} - -function flagLabel(flag) { - const prefix = flag.char ? `-${flag.char}, --${flag.name}` : `--${flag.name}`; - if (flag.type === 'boolean') return prefix; - const value = flag.options?.length ? `<${flag.options.join('|')}>` : ''; - return `${prefix}=${value}${flag.multiple ? '...' : ''}`; -} - -function flagDescription(flag) { - const details = []; - if (flag.description) details.push(text(flag.description)); - if (flag.default !== undefined) details.push(`Default: ${String(flag.default)}`); - if (flag.required) details.push('Required.'); - return details.join(' '); -} - -function escapeTable(value) { - return text(value).replaceAll('|', '\\|').replace(/\s+/g, ' ').trim(); -} - -function text(value) { - return String(value) - .replaceAll('<%= config.bin %>', config.bin) - .replaceAll('<%= command.id %>', '') - .replace(/\s+/g, ' ') - .trim(); -} diff --git a/packages/cli/scripts/link b/packages/cli/scripts/link deleted file mode 100755 index aed65927..00000000 --- a/packages/cli/scripts/link +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -echo "==> Linking Beeper CLI" -bun link diff --git a/packages/cli/scripts/lint b/packages/cli/scripts/lint deleted file mode 100755 index bbe666e4..00000000 --- a/packages/cli/scripts/lint +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -echo "==> Typechecking Beeper CLI" -bun run typecheck diff --git a/packages/cli/scripts/mock b/packages/cli/scripts/mock deleted file mode 100755 index e5b0ace5..00000000 --- a/packages/cli/scripts/mock +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -if [[ -n "$1" && "$1" != '--'* ]]; then - URL="$1" - shift -else - URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" -fi - -# Check if the URL is empty -if [ -z "$URL" ]; then - echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" - exit 1 -fi - -echo "==> Starting mock server with URL ${URL}" - -# Run steady mock on the given spec -if [ "$1" == "--daemon" ]; then - # Pre-install the package so the download doesn't eat into the startup timeout - bunx -p @stdy/cli@0.19.6 -- steady --version - - bunx -p @stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & - - # Wait for server to come online via health endpoint (max 30s) - echo -n "Waiting for server" - attempts=0 - while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do - if ! kill -0 $! 2>/dev/null; then - echo - cat .stdy.log - exit 1 - fi - attempts=$((attempts + 1)) - if [ "$attempts" -ge 300 ]; then - echo - echo "Timed out waiting for Steady server to start" - cat .stdy.log - exit 1 - fi - echo -n "." - sleep 0.1 - done - - echo -else - bunx -p @stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" -fi diff --git a/packages/cli/scripts/publish-homebrew-formula.ts b/packages/cli/scripts/publish-homebrew-formula.ts deleted file mode 100644 index a3583bfc..00000000 --- a/packages/cli/scripts/publish-homebrew-formula.ts +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env bun -import {existsSync} from 'node:fs'; -import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; -import {tmpdir} from 'node:os'; -import {join} from 'node:path'; - -const root = new URL('..', import.meta.url).pathname; -const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); -const metadata = JSON.parse(await readFile(new URL('../dist/release/homebrew.json', import.meta.url), 'utf8')); - -const token = process.env.HOMEBREW_TAP_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN; -const tapRepository = process.env.HOMEBREW_TAP_REPOSITORY || 'beeper/homebrew-tap'; -const sourceRepository = process.env.GITHUB_REPOSITORY || 'beeper/cli'; -const version = process.env.PACKAGE_VERSION || metadata.version || packageJson.version; -const formulaName = process.env.HOMEBREW_FORMULA_NAME || 'cli'; -const commandName = process.env.HOMEBREW_COMMAND_NAME || metadata.command || 'beeper'; -const formulaClass = formulaName - .split(/[-_]/) - .map(part => `${part[0].toUpperCase()}${part.slice(1)}`) - .join(''); -const tag = process.env.GITHUB_REF_NAME || `v${version}`; - -if (!token) { - throw new Error('HOMEBREW_TAP_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN is required to publish the Homebrew formula.'); -} - -const cloneRoot = await mkdtemp(join(tmpdir(), 'beeper-cli-homebrew-')); -const tapPath = join(cloneRoot, 'tap'); -const remote = `https://x-access-token:${token}@github.com/${tapRepository}.git`; - -try { - await run('git', ['clone', '--depth', '1', remote, tapPath], {cwd: cloneRoot, scrub: token}); - await run('git', ['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'beeper-release-bot'], {cwd: tapPath}); - await run('git', ['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'help@beeper.com'], {cwd: tapPath}); - - const formulaDir = join(tapPath, 'Formula'); - const formulaPath = join(formulaDir, `${formulaName}.rb`); - if (!existsSync(formulaDir)) { - await mkdir(formulaDir, {recursive: true}); - } - - await writeFile( - formulaPath, - formula({formulaClass, formulaName, sourceRepository, tag, version, metadata, commandName}), - ); - await run('git', ['add', formulaPath], {cwd: tapPath}); - - const changed = await output('git', ['diff', '--cached', '--quiet'], {cwd: tapPath, allowFailure: true}); - if (changed.code === 0) { - console.log('Homebrew formula is already current.'); - } else { - await run('git', ['commit', '-m', `${formulaName} ${version}`], {cwd: tapPath}); - await run('git', ['push', 'origin', 'HEAD'], {cwd: tapPath, scrub: token}); - } -} finally { - await rm(cloneRoot, {recursive: true, force: true}); -} - -function formula({formulaClass, formulaName, sourceRepository, tag, version, metadata, commandName}) { - const archives = metadata.archives ?? [metadata] - const arm64 = archives.find(archive => archive.platform === 'darwin-arm64') ?? archives[0] - const x64 = archives.find(archive => archive.platform === 'darwin-x64') ?? arm64 - - return `class ${formulaClass} < Formula - desc "Beeper CLI" - homepage "https://developers.beeper.com/desktop-api/" - license "MIT" - version "${version}" - - on_arm do - url "https://github.com/${sourceRepository}/releases/download/${tag}/${arm64.archive}" - sha256 "${arm64.sha256}" - end - - on_intel do - url "https://github.com/${sourceRepository}/releases/download/${tag}/${x64.archive}" - sha256 "${x64.sha256}" - end - - def install - bin.install "bin/${commandName}" - end - - test do - assert_match version.to_s, shell_output("#{bin}/${commandName} --version") - end -end -`; -} - -async function run(command, args, options = {}) { - const result = await output(command, args, options); - if (result.code !== 0) { - throw new Error(`${command} ${args.join(' ')} exited with ${result.code}`); - } -} - -async function output(command, args, options = {}) { - const child = Bun.spawn([command, ...args], { - cwd: options.cwd || root, - env: process.env, - stdin: 'ignore', - stdout: 'pipe', - stderr: 'pipe', - }); - - const [stdout, stderr, code] = await Promise.all([ - collect(child.stdout, process.stdout, options.scrub), - collect(child.stderr, process.stderr, options.scrub), - child.exited, - ]); - - return {code, stdout, stderr}; -} - -async function collect(stream, sink, scrub) { - let output = ''; - const decoder = new TextDecoder(); - for await (const chunk of stream) { - const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, {stream: true}); - output += text; - sink.write(scrub ? text.replaceAll(scrub, '[token]') : text); - } - output += decoder.decode(); - return output; -} diff --git a/packages/cli/scripts/publish-local-release.ts b/packages/cli/scripts/publish-local-release.ts deleted file mode 100644 index 469d4aa5..00000000 --- a/packages/cli/scripts/publish-local-release.ts +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bun -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const root = fileURLToPath(new URL('..', import.meta.url)) -const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) -const binaries = JSON.parse(await readFile(join(root, 'dist', 'bin', 'binaries.json'), 'utf8')) -const version = process.env.PACKAGE_VERSION || pkg.version -const tag = process.env.GITHUB_REF_NAME || process.env.TAG || `v${version}` -const repo = process.env.GITHUB_REPOSITORY || 'beeper/cli' - -await run('gh', ['auth', 'status']) -await run('npm', ['whoami']) -if (!Array.isArray(binaries.artifacts) || binaries.artifacts.length === 0) { - throw new Error('Refusing to publish npm package without binary artifacts.') -} -for (const platform of ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64']) { - if (!binaries.artifacts.some(artifact => artifact.platform === platform)) { - throw new Error(`Refusing to publish without ${platform} binary artifact.`) - } -} -await ensureRelease(tag, repo) -await run('gh', [ - 'release', - 'upload', - tag, - 'dist/bin/binaries.json', - ...await releaseArchives(), - '--repo', - repo, - '--clobber', -]) -await run('bun', ['scripts/publish-homebrew-formula.ts']) -await run('npm', ['publish', '--access', 'public'], { cwd: fileURLToPath(new URL('../../npm/', import.meta.url)) }) - -async function ensureRelease(tag, repo) { - const view = await output('gh', ['release', 'view', tag, '--repo', repo], { allowFailure: true }) - if (view.code === 0) return - await run('gh', ['release', 'create', tag, '--repo', repo, '--title', tag, '--generate-notes']) -} - -async function releaseArchives() { - const metadata = JSON.parse(await readFile(join(root, 'dist', 'release', 'homebrew.json'), 'utf8')) - return [ - ...metadata.archives.map(archive => join('dist', 'release', archive.archive)), - 'dist/release/homebrew.json', - ] -} - -async function run(command, args, options = {}) { - const result = await output(command, args, options) - if (result.code !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${result.code}`) -} - -async function output(command, args, options = {}) { - const child = Bun.spawn([command, ...args], { - cwd: options.cwd || root, - env: process.env, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }) - return { code: await child.exited } -} diff --git a/packages/cli/scripts/read-signing-secrets.rb b/packages/cli/scripts/read-signing-secrets.rb deleted file mode 100644 index 5721ee08..00000000 --- a/packages/cli/scripts/read-signing-secrets.rb +++ /dev/null @@ -1,30 +0,0 @@ -require "shellwords" -require "yaml" - -secrets = YAML.load_file(ENV.fetch("SECRETS_FILE")) -team = ENV.fetch("TEAM_ID") - -required = { - "APP_STORE_CONNECT_API_KEY_KEY_ID" => "APP_STORE_CONNECT_API_#{team}_KEY_ID", - "APP_STORE_CONNECT_API_KEY_ISSUER_ID" => "APP_STORE_CONNECT_API_#{team}_ISSUER_ID", - "APP_STORE_CONNECT_API_KEY_KEY" => "APP_STORE_CONNECT_API_#{team}_KEY_CONTENT", - "MATCH_PASSWORD" => "FASTLANE_MATCH_PASSWORD", - "MATCH_S3_ACCESS_KEY" => "FASTLANE_MATCH_S3_ACCESS_KEY", - "MATCH_S3_SECRET_ACCESS_KEY" => "FASTLANE_MATCH_S3_SECRET_ACCESS_KEY", -} - -missing = required.values.reject { |key| secrets[key] && !secrets[key].to_s.empty? } -unless missing.empty? - warn "missing required signing secret keys: #{missing.join(", ")}" - exit 1 -end - -File.write(ENV.fetch("P8_FILE"), secrets.fetch("APP_STORE_CONNECT_API_#{team}_KEY_CONTENT").to_s.gsub("\\n", "\n")) -File.chmod(0o600, ENV.fetch("P8_FILE")) - -File.open(ENV.fetch("ENV_FILE"), "w", 0o600) do |file| - required.each do |env_name, secret_name| - next if env_name == "APP_STORE_CONNECT_API_KEY_KEY" - file.puts("export #{env_name}=#{Shellwords.escape(secrets.fetch(secret_name).to_s)}") - end -end diff --git a/packages/cli/scripts/run b/packages/cli/scripts/run deleted file mode 100755 index fc3e838b..00000000 --- a/packages/cli/scripts/run +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -bun run dev -- "$@" diff --git a/packages/cli/scripts/sign-macos-binaries.ts b/packages/cli/scripts/sign-macos-binaries.ts deleted file mode 100644 index 10cbdf44..00000000 --- a/packages/cli/scripts/sign-macos-binaries.ts +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env bun -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const root = fileURLToPath(new URL('..', import.meta.url)) -const outDir = join(root, 'dist', 'bin') -const manifestPath = join(outDir, 'binaries.json') -const teamID = process.env.TEAM_ID || process.env.APPLE_TEAM_ID || 'PZYM8XX95Q' -const secretsFile = process.env.MOBILE_SECRETS_FILE -const requireSigning = process.env.BEEPER_CLI_REQUIRE_MACOS_SIGNING === '1' - -if (process.platform !== 'darwin') { - if (requireSigning) throw new Error('macOS binary signing requires a macOS runner.') - console.log('Skipping macOS binary signing on non-macOS runner.') - process.exit(0) -} - -const workDir = await mkdtemp(join(tmpdir(), 'beeper-cli-signing-')) - -try { - const credentials = await prepareCredentials(workDir) - const identity = process.env.MACOS_CODESIGN_IDENTITY || process.env.IDENTITY || await findIdentity(teamID) - - if (!identity) { - if (credentials.match) { - await importDeveloperID(workDir) - } else { - if (requireSigning) throw new Error(`No Developer ID Application identity for team ${teamID}`) - console.log('Skipping macOS binary signing because no Developer ID identity is available.') - process.exit(0) - } - } - - const resolvedIdentity = identity || await findIdentity(teamID) - if (!resolvedIdentity) { - throw new Error( - `Fastlane match completed, but macOS still has no usable Developer ID Application identity for team ${teamID}. ` + - 'Run `security find-identity -v -p codesigning`; it must list a Developer ID Application certificate with an attached private key. ' + - 'If it does not, fix the local keychain or match signing storage before rerunning release.', - ) - } - - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) - const darwinArtifacts = manifest.artifacts.filter(artifact => artifact.platform.startsWith('darwin-')) - - for (const artifact of darwinArtifacts) { - await run('/usr/bin/codesign', [ - '--force', - '--options', - 'runtime', - '--timestamp', - '--sign', - resolvedIdentity, - ...(process.env.MACOS_CODESIGN_KEYCHAIN ? ['--keychain', process.env.MACOS_CODESIGN_KEYCHAIN] : []), - artifact.path, - ]) - - await run('/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', artifact.path]) - - if (credentials.notary) { - await notarize(artifact.path, credentials) - } else { - if (requireSigning) throw new Error('App Store Connect credentials are required for notarization.') - console.log('Skipping notarization because App Store Connect credentials are not available.') - } - - artifact.sha256 = await hashFile(artifact.path) - console.log(`${artifact.path}`) - console.log(`sha256 ${artifact.sha256}`) - } - - await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) -} finally { - await rm(workDir, { recursive: true, force: true }) -} - -async function notarize(path, credentials) { - const zipPath = join(outDir, `${basename(path)}.zip`) - await run('/usr/bin/ditto', ['-c', '-k', '--keepParent', path, zipPath]) - if (credentials.p8) { - await run('/usr/bin/xcrun', [ - 'notarytool', - 'submit', - zipPath, - '--key', - credentials.p8, - '--key-id', - credentials.keyID, - '--issuer', - credentials.issuerID, - '--wait', - ]) - } else { - await run('/usr/bin/xcrun', [ - 'notarytool', - 'submit', - zipPath, - '--apple-id', - credentials.appleID, - '--password', - credentials.applePassword, - '--team-id', - credentials.teamID, - '--wait', - ]) - } - await run('/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', path]) -} - -async function prepareCredentials(workDir) { - const envCredentials = { - appleID: process.env.APPLE_ID, - applePassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, - teamID, - } - - if (envCredentials.appleID && envCredentials.applePassword) { - return { ...envCredentials, notary: true, match: false } - } - - if (!secretsFile || !existsSync(secretsFile)) return { notary: false, match: false } - - const envFile = join(workDir, 'secrets.env') - const p8 = join(workDir, `AuthKey_${teamID}.p8`) - await run('ruby', [new URL('./read-signing-secrets.rb', import.meta.url).pathname], { - env: { - ...process.env, - SECRETS_FILE: secretsFile, - TEAM_ID: teamID, - ENV_FILE: envFile, - P8_FILE: p8, - }, - }) - const parsed = parseEnv(await readFile(envFile, 'utf8')) - Object.assign(process.env, parsed) - return { - keyID: parsed.APP_STORE_CONNECT_API_KEY_KEY_ID, - issuerID: parsed.APP_STORE_CONNECT_API_KEY_ISSUER_ID, - p8, - notary: true, - match: Boolean(parsed.MATCH_PASSWORD && parsed.MATCH_S3_ACCESS_KEY && parsed.MATCH_S3_SECRET_ACCESS_KEY), - } -} - -async function importDeveloperID(workDir) { - const fastlaneDir = join(workDir, 'fastlane') - await mkdir(fastlaneDir, { recursive: true }) - await writeFile( - join(fastlaneDir, 'Fastfile'), - `default_platform(:mac) - -lane :import_developer_id do - setup_ci - sync_code_signing( - type: "developer_id", - platform: "macos", - team_id: ENV.fetch("BEEPER_CLI_TEAM_ID"), - app_identifier: [], - storage_mode: "s3", - s3_bucket: "a8c-fastlane-match", - s3_region: "us-east-2", - s3_access_key: ENV.fetch("MATCH_S3_ACCESS_KEY"), - s3_secret_access_key: ENV.fetch("MATCH_S3_SECRET_ACCESS_KEY"), - readonly: true - ) -end -`, - ) - const fastlane = await commandExists('fastlane') - if (fastlane) { - await run('fastlane', ['import_developer_id'], { - cwd: workDir, - env: { - ...process.env, - FASTLANE_DISABLE_COLORS: '1', - FASTLANE_HIDE_CHANGELOG: '1', - FASTLANE_SKIP_UPDATE_CHECK: '1', - BEEPER_CLI_TEAM_ID: teamID, - }, - scrub: [ - process.env.MATCH_S3_ACCESS_KEY, - process.env.MATCH_S3_SECRET_ACCESS_KEY, - process.env.MATCH_PASSWORD, - ], - }) - return - } - throw new Error('No Developer ID identity found and fastlane is unavailable.') -} - -async function findIdentity(team) { - const args = ['/usr/bin/security', 'find-identity', '-v', '-p', 'codesigning'] - const child = Bun.spawn(args, { - stdout: 'pipe', - stderr: 'pipe', - }) - const [stdout, code] = await Promise.all([new Response(child.stdout).text(), child.exited]) - if (code !== 0) return undefined - for (const line of stdout.split('\n')) { - if (!line.includes('Developer ID Application:') || !line.includes(`(${team})`)) continue - const match = line.match(/"([^"]+)"/) - if (match) return match[1] - } - return undefined -} - -async function commandExists(command) { - const child = Bun.spawn(['/usr/bin/which', command], { stdout: 'ignore', stderr: 'ignore' }) - return (await child.exited) === 0 -} - -function parseEnv(source) { - return Object.fromEntries( - source - .split('\n') - .map(line => line.match(/^export ([A-Z0-9_]+)=(.*)$/)) - .filter(Boolean) - .map(match => [match[1], shellUnescape(match[2])]), - ) -} - -function shellUnescape(value) { - if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replaceAll("'\\''", "'") - return value.replaceAll(/\\(.)/g, '$1') -} - -async function hashFile(path) { - const hasher = new Bun.CryptoHasher('sha256') - hasher.update(await Bun.file(path).arrayBuffer()) - return hasher.digest('hex') -} - -async function run(command, args, options = {}) { - const code = await runAllowFailure(command, args, options) - if (code !== 0) throw new Error(`${command} ${scrub(args.join(' '), options.scrub ?? [])} exited with ${code}`) -} - -async function runAllowFailure(command, args, options = {}) { - if (command.startsWith('/') && !existsSync(command)) throw new Error(`Missing command: ${command}`) - const child = Bun.spawn([command, ...args], { - cwd: options.cwd || root, - env: options.env || process.env, - stdin: 'ignore', - stdout: options.quiet || options.scrub ? 'pipe' : 'inherit', - stderr: options.quiet || options.scrub ? 'pipe' : 'inherit', - }) - const [, , code] = await Promise.all([ - options.quiet ? drain(child.stdout) : options.scrub ? collect(child.stdout, process.stdout, options.scrub) : Promise.resolve(), - options.quiet ? drain(child.stderr) : options.scrub ? collect(child.stderr, process.stderr, options.scrub) : Promise.resolve(), - child.exited, - ]) - return code -} - -async function drain(stream) { - for await (const _chunk of stream) { - } -} - -async function collect(stream, sink, scrubValues) { - const decoder = new TextDecoder() - for await (const chunk of stream) { - const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }) - sink.write(scrub(text, scrubValues)) - } - const rest = decoder.decode() - if (rest) sink.write(scrub(rest, scrubValues)) -} - -function scrub(text, values) { - return values.filter(Boolean).reduce((next, value) => next.replaceAll(value, '[redacted]'), text) -} diff --git a/packages/cli/scripts/test b/packages/cli/scripts/test deleted file mode 100755 index cd0871ac..00000000 --- a/packages/cli/scripts/test +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -NC='\033[0m' # No Color - -function steady_is_running() { - curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1 -} - -kill_server_on_port() { - pids=$(lsof -t -i tcp:"$1" || echo "") - if [ "$pids" != "" ]; then - kill "$pids" - echo "Stopped $pids." - fi -} - -function is_overriding_api_base_url() { - [ -n "${TEST_API_BASE_URL:-}" ] -} - -if ! is_overriding_api_base_url && ! steady_is_running ; then - # When we exit this script, make sure to kill the background mock server process - trap 'kill_server_on_port 4010' EXIT - - # Start the dev server - ./scripts/mock --daemon -fi - -if is_overriding_api_base_url ; then - echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" - echo -elif ! steady_is_running ; then - echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server" - echo -e "running against your OpenAPI spec." - echo - echo -e "To run the server, pass in the path or url of your OpenAPI" - echo -e "spec to the steady command:" - echo - echo -e " \$ ${YELLOW}bunx -p @stdy/cli@0.19.6 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" - echo - - exit 1 -else - echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}" - echo -fi - -echo "==> Running tests" -bun run test -- "$@" diff --git a/packages/cli/scripts/unlink b/packages/cli/scripts/unlink deleted file mode 100755 index f53e006a..00000000 --- a/packages/cli/scripts/unlink +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Unlinking Beeper CLI" -bun unlink -g beeper-cli || true diff --git a/packages/cli/src/cli/commands.ts b/packages/cli/src/cli/commands.ts new file mode 100644 index 00000000..6cd1cdf3 --- /dev/null +++ b/packages/cli/src/cli/commands.ts @@ -0,0 +1,2404 @@ +import { createHmac } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { stdout as output } from 'node:process' +import { setTimeout as sleep } from 'node:timers/promises' +import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { BeeperDesktop } from '@beeper/desktop-api' +import { apiItems, apiRecord } from '../lib/api-values.js' +import { appRequest } from '../lib/app-api.js' +import { evaluateReadiness } from '../lib/app-state.js' +import { printAccountLoginStep, runGuidedAccountLogin } from '../lib/account-login.js' +import { startCloudflareTunnel, type StartedTunnel } from '../lib/cloudflare-tunnel.js' +import { authFromToken, authorizeTarget } from '../lib/desktop-auth.js' +import { AbortError, ExitCodes } from '../lib/errors.js' +import { exportBeeperData } from '../lib/export.js' +import { installDesktop, installServer } from '../lib/installations.js' +import { collectPage } from '../lib/paging.js' +import { listAccountIDs, normalizeSelector, resolveAccountID, resolveAccountIDs, resolveChatID, userQueryFromInput } from '../lib/resolve.js' +import { normalizeServerEnv } from '../lib/server-env.js' +import { finishEmailSetup, startEmailSetup } from '../lib/setup-login.js' +import { targetLiveStatus } from '../lib/target-status.js' +import { promptChoice } from '../lib/prompts.js' +import { + builtInDesktopTargetID, + configPath, + listTargets, + publicTarget, + readConfig, + readTarget, + removeTarget, + resolveTarget, + updateConfig, + writeTarget, + type Config, + type Target, +} from '../lib/targets.js' +import WebSocket from 'ws' +import { + desktopLogDir, + launchDesktopApp, + profileErrorLogPath, + profileLogPath, + startProfile, + stopProfile, +} from '../lib/profiles.js' +import type { CommandContext, CommandSpec, FlagSpec, GlobalFlags } from './types.js' +import { globalFlagSpecs, numberFlag, requiredStringFlag, stringFlag, stringListFlag } from './parse.js' +import { commandVisible } from './policy.js' +import { buildSchema } from './schema.js' +import { serveMcp } from './mcp.js' +import { usage, writeEvent, writeResult } from './output.js' +import { runSetup } from './setup.js' + +type WebhookConfig = { inflight: number; max: number; queue: Array<{ body: string; signature?: string }>; secret?: string; url: string } +type EventFilter = { include?: Set; exclude?: Set } +type AttachmentType = 'sticker' | 'voice-note' +type SendKind = 'file' | 'sticker' | 'text' | 'voice' +type SendPayload = { + attachmentType?: AttachmentType + duration?: number + file?: string + fileName?: string + mentions?: string[] + mimeType?: string + noPreview?: boolean + replyTo?: string + text: string + wait?: boolean + waitTimeoutMs?: number +} + +const accountFilterFlag: FlagSpec = { name: 'account', short: 'a', aliases: ['acct'], type: 'string', multiple: true, description: 'Limit to account selector' } +const candidateLimitFlag: FlagSpec = { name: 'limit', aliases: ['max'], type: 'integer', default: 10, description: 'Maximum candidates' } +const chatFlag: FlagSpec = { name: 'chat', type: 'string', required: true, description: 'Chat selector' } +const pickCandidateFlag: FlagSpec = { name: 'pick', type: 'integer', description: 'Select the Nth candidate' } +const pickChatFlag: FlagSpec = { name: 'pick', type: 'integer', description: 'Pick the Nth result when selector is ambiguous' } +const configKeys = ['defaultTarget', 'defaultAccount'] as const +type ConfigKey = typeof configKeys[number] + +const chatFlags: FlagSpec[] = [ + chatFlag, + pickChatFlag, +] + +const installFlags: FlagSpec[] = [ + { name: 'channel', type: 'string', enum: ['stable', 'nightly'], default: 'stable', description: 'Install release channel' }, + { name: 'server-env', type: 'string', enum: ['local', 'dev', 'staging', 'prod'], default: 'prod', description: 'Server environment' }, +] + +const sendChatFlags: FlagSpec[] = [ + { name: 'to', type: 'string', required: true, description: 'Chat selector' }, + pickChatFlag, +] + +const sendDeliveryFlags: FlagSpec[] = [ + ...sendChatFlags, + { name: 'reply-to', type: 'string', description: 'Send as a reply to this message ID' }, + { name: 'wait', type: 'boolean', default: false, description: 'Wait until the message leaves pending state' }, + { name: 'wait-timeout', type: 'integer', default: 30_000, description: 'Maximum wait time in ms when --wait is set' }, +] + +export const commands: CommandSpec[] = [ + { + description: 'Print CLI version', + mcp: true, + output: 'diagnostic', + path: ['version'], + risk: 'read', + run: version, + }, + { + args: [{ name: 'target', description: 'Target name. Defaults to the selected target.' }], + aliases: [['st']], + description: 'Show selected target and setup readiness', + mcp: true, + output: 'status', + path: ['status'], + risk: 'read', + run: status, + }, + { + description: 'Run diagnostics for config, target reachability, auth, and readiness', + mcp: true, + output: 'diagnostic', + path: ['doctor'], + risk: 'read', + run: doctor, + }, + { + aliases: [['agent', 'exit-codes'], ['exitcodes']], + description: 'Print stable exit codes for automation', + mcp: true, + output: 'diagnostic', + path: ['exit-codes'], + risk: 'read', + run: exitCodes, + }, + { + args: [{ name: 'command', variadic: true }], + aliases: [['help-json'], ['helpjson']], + description: 'Print machine-readable command and flag schema', + mcp: true, + path: ['schema'], + risk: 'read', + run: schema, + }, + { + description: 'Run a typed MCP stdio server', + flags: [ + { name: 'allow-tool', aliases: ['tool'], type: 'string', multiple: true, description: 'Tool or command allowlist' }, + { name: 'allow-write', type: 'boolean', default: false, description: 'Allow write-risk MCP tools' }, + { name: 'list-tools', type: 'boolean', default: false, description: 'Print enabled MCP tools as JSON and exit' }, + { name: 'max-output-bytes', type: 'integer', default: 102400, description: 'Maximum stdout/stderr bytes captured per tool call' }, + { name: 'timeout-seconds', type: 'integer', default: 60, description: 'Per-tool subprocess timeout' }, + ], + path: ['mcp'], + risk: 'read', + run: mcp, + }, + { + args: [{ name: 'shell', required: true, description: 'bash, zsh, fish, or powershell' }], + description: 'Generate shell completion scripts', + hidden: false, + path: ['completion'], + risk: 'read', + run: completion, + }, + { + aliases: [['config', 'show']], + args: [{ name: 'key', required: true, description: 'Config key to get' }], + description: 'Get a config value', + mcp: true, + output: 'diagnostic', + path: ['config', 'get'], + risk: 'read', + run: configGet, + }, + { + aliases: [['config', 'list-keys'], ['config', 'names']], + description: 'List available config keys', + mcp: true, + path: ['config', 'keys'], + risk: 'read', + run: configKeysCommand, + }, + { + aliases: [['config', 'ls'], ['config', 'all']], + description: 'List all config values', + mcp: true, + output: 'diagnostic', + path: ['config', 'list'], + risk: 'read', + run: configList, + }, + { + aliases: [['config', 'where']], + description: 'Print config file path', + mcp: true, + output: 'diagnostic', + path: ['config', 'path'], + risk: 'read', + run: configPathCommand, + }, + { + aliases: [['config', 'add'], ['config', 'update']], + args: [ + { name: 'key', required: true, description: 'Config key to set' }, + { name: 'value', required: true, description: 'Value to set' }, + ], + description: 'Set a config value', + output: 'diagnostic', + path: ['config', 'set'], + risk: 'write', + run: configSet, + }, + { + aliases: [['config', 'rm'], ['config', 'del'], ['config', 'remove']], + args: [{ name: 'key', required: true, description: 'Config key to unset' }], + description: 'Unset a config value', + output: 'diagnostic', + path: ['config', 'unset'], + risk: 'write', + run: configUnset, + }, + { + args: [{ name: 'words', variadic: true, description: 'Current command line words' }], + description: 'Internal completion helper', + flags: [{ name: 'cword', type: 'integer', default: -1, description: 'Current word index' }], + hidden: true, + path: ['__complete'], + risk: 'read', + run: completeCommand, + }, + { + description: 'Make the selected target ready for messaging', + examples: ['beeper setup', 'beeper setup --local', 'beeper setup --remote https://desktop.example.com', 'beeper setup --desktop --install'], + flags: [ + { name: 'local', type: 'boolean', default: false, description: 'Use the local Beeper Desktop session on this device' }, + { name: 'oauth', type: 'boolean', default: false, description: 'Authorize the target with browser OAuth/PKCE' }, + { name: 'remote', type: 'string', description: 'Connect to a remote Beeper Desktop or Server URL' }, + { name: 'server', type: 'boolean', default: false, description: 'Set up a local Beeper Server target' }, + { name: 'desktop', type: 'boolean', default: false, description: 'Set up a local Beeper Desktop target' }, + { name: 'install', type: 'boolean', default: false, description: 'Allow installing a missing local runtime' }, + ...installFlags, + { name: 'email', type: 'string', description: 'Sign in with an email address' }, + { name: 'username', type: 'string', description: 'Username to use if setup creates a new account' }, + ], + path: ['setup'], + risk: 'write', + run: runSetup, + }, + { + aliases: [['targets', 'ls']], + description: 'List configured Beeper targets', + mcp: true, + output: 'targets', + path: ['targets', 'list'], + risk: 'read', + run: targetsList, + }, + { + args: [{ name: 'name', required: true }, { name: 'url', required: true }], + description: 'Add a remote Beeper Desktop or Server target', + flags: [{ name: 'default', type: 'boolean', default: false, description: 'Set this target as the default after creation' }], + path: ['targets', 'add'], + risk: 'write', + run: targetsAdd, + }, + { + args: [{ name: 'name', description: 'Target name. Defaults to the selected target.' }], + description: 'Expose a target through Cloudflare Tunnel', + flags: [ + { name: 'cloudflared-path', type: 'string', description: 'Path to cloudflared. Also configurable with BEEPER_CLOUDFLARED_PATH.' }, + { name: 'install', type: 'boolean', default: false, description: 'Download the pinned cloudflared binary if missing or outdated' }, + { name: 'retries', type: 'integer', default: 5, description: 'Startup retries before giving up' }, + { name: 'timeout', type: 'string', description: 'Startup timeout, for example 40s or 60000ms' }, + { name: 'url-only', type: 'boolean', default: false, description: 'Print only the public tunnel URL' }, + ], + path: ['targets', 'tunnel'], + risk: 'write', + run: targetsTunnel, + }, + { + description: 'Install Beeper Desktop locally', + flags: installFlags, + path: ['install', 'desktop'], + risk: 'write', + run: installCommand, + }, + { + description: 'Install Beeper Server locally', + flags: installFlags, + path: ['install', 'server'], + risk: 'write', + run: installCommand, + }, + { + description: 'Clear stored authentication', + path: ['auth', 'logout'], + risk: 'write', + run: authLogout, + }, + { + description: 'Start email sign-in for a target', + flags: [{ name: 'email', type: 'string', required: true, description: 'Email address' }], + path: ['auth', 'email', 'start'], + risk: 'write', + run: authEmailStart, + }, + { + description: 'Finish email sign-in for a target', + flags: [ + { name: 'code', type: 'string', required: true, description: 'Email verification code' }, + { name: 'setup-request-id', type: 'string', required: true, description: 'Setup request ID from auth email start' }, + { name: 'username', type: 'string', description: 'Username to use if setup creates a new account' }, + ], + path: ['auth', 'email', 'response'], + risk: 'write', + run: authEmailResponse, + }, + { + description: 'List connected accounts', + flags: [ + accountFilterFlag, + { name: 'ids', type: 'boolean', default: false, description: 'Print only account IDs' }, + ], + mcp: true, + output: 'accounts', + path: ['accounts', 'list'], + risk: 'read', + run: accountsList, + }, + { + args: [{ name: 'selector', required: true, description: 'Target name' }], + aliases: [['targets', 'use']], + description: 'Select the default target', + path: ['use', 'target'], + risk: 'write', + run: useTarget, + }, + { + args: [{ name: 'selector', required: true, description: 'Account selector' }], + aliases: [['accounts', 'use']], + description: 'Select the default account', + path: ['use', 'account'], + risk: 'write', + run: useAccount, + }, + { + args: [{ name: 'bridge' }], + description: 'Connect a chat account by bridge', + flags: [ + { name: 'cookie', type: 'string', multiple: true, description: 'Cookie value in name=value form' }, + { name: 'field', type: 'string', multiple: true, description: 'Field value in id=value form' }, + { name: 'flow', type: 'string', description: 'Login flow ID' }, + { name: 'guided', type: 'boolean', default: true, description: 'Prompt through login steps' }, + { name: 'login-id', type: 'string', description: 'Existing login ID to re-login as' }, + { name: 'webview', type: 'boolean', default: false, description: 'Use Bun.WebView for cookie login steps' }, + { name: 'webview-backend', type: 'string', enum: ['auto', 'chrome', 'webkit'], default: 'chrome', description: 'Bun.WebView backend' }, + { name: 'webview-timeout', type: 'integer', default: 120, description: 'Seconds to wait for WebView cookie collection' }, + ], + path: ['accounts', 'add'], + risk: 'write', + run: accountsAdd, + }, + { + args: [{ name: 'selector', required: true, description: 'Target name' }], + aliases: [['targets', 'remove'], ['targets', 'rm']], + description: 'Remove a target', + path: ['remove', 'target'], + risk: 'destructive', + run: removeTargetCommand, + }, + { + args: [{ name: 'selector', required: true, description: 'Account selector' }], + aliases: [['accounts', 'remove'], ['accounts', 'rm']], + description: 'Remove an account', + path: ['remove', 'account'], + risk: 'destructive', + run: removeAccount, + }, + { + aliases: [['contacts', 'search'], ['contacts', 'find']], + description: 'List contacts', + flags: [ + accountFilterFlag, + { name: 'ids', type: 'boolean', default: false, description: 'Print only contact user IDs' }, + { name: 'limit', type: 'integer', default: 50, description: 'Maximum contacts to print' }, + { name: 'query', type: 'string', description: 'Optional contact lookup query' }, + ], + mcp: true, + output: 'contacts', + path: ['contacts', 'list'], + risk: 'read', + run: contactsList, + }, + { + aliases: [['chats', 'ls']], + description: 'List chats', + flags: [ + accountFilterFlag, + { name: 'archived', type: 'boolean', description: 'Only archived chats; use --no-archived to exclude' }, + { name: 'ids', type: 'boolean', default: false, description: 'Print preferred chat selectors' }, + { name: 'limit', type: 'integer', default: 20, description: 'Maximum chats to print' }, + { name: 'low-priority', type: 'boolean', description: 'Only low-priority chats; use --no-low-priority to exclude' }, + { name: 'muted', type: 'boolean', description: 'Only muted chats; use --no-muted to exclude' }, + { name: 'pinned', type: 'boolean', description: 'Only pinned chats; use --no-pinned to exclude' }, + { name: 'query', type: 'string', description: 'Optional chat lookup query' }, + { name: 'unread', type: 'boolean', description: 'Only unread chats; use --no-unread to exclude' }, + ], + mcp: true, + output: 'chats', + path: ['chats', 'list'], + risk: 'read', + run: chatsList, + }, + { + description: 'Show chat details', + flags: [ + chatFlag, + { name: 'max-participants', type: 'integer', description: 'Limit participants returned in chat details' }, + pickChatFlag, + ], + mcp: true, + path: ['chats', 'show'], + risk: 'read', + run: chatsShow, + }, + { + args: [{ name: 'user', required: true }], + description: 'Start a chat', + flags: [ + { name: 'account', type: 'string', description: 'Account selector' }, + { name: 'title', type: 'string', description: 'Optional initial title for a new group chat' }, + ], + path: ['chats', 'start'], + risk: 'write', + run: chatsStart, + }, + { + description: 'Archive or unarchive a chat', + flags: [...chatFlags, { name: 'clear', type: 'boolean', default: false, description: 'Unarchive the chat' }], + path: ['chats', 'archive'], + risk: 'write', + run: chatsSetFlag, + }, + { + description: 'Pin or unpin a chat', + flags: [...chatFlags, { name: 'clear', type: 'boolean', default: false, description: 'Unpin the chat' }], + path: ['chats', 'pin'], + risk: 'write', + run: chatsSetFlag, + }, + { + description: 'Mute or unmute a chat', + flags: [...chatFlags, { name: 'clear', type: 'boolean', default: false, description: 'Unmute the chat' }], + path: ['chats', 'mute'], + risk: 'write', + run: chatsSetFlag, + }, + { + description: 'Rename a chat', + flags: [...chatFlags, { name: 'title', type: 'string', required: true, description: 'Chat title' }], + path: ['chats', 'rename'], + risk: 'write', + run: chatsRename, + }, + { + description: 'Set or clear a chat description', + flags: [ + ...chatFlags, + { name: 'clear', type: 'boolean', default: false, description: 'Clear or unset the chosen state' }, + { name: 'description', type: 'string', description: 'Chat description' }, + ], + path: ['chats', 'description'], + risk: 'write', + run: chatsDescription, + }, + { + description: 'Set or clear a chat avatar', + flags: [ + ...chatFlags, + { name: 'clear', type: 'boolean', default: false, description: 'Clear the avatar' }, + { name: 'file', type: 'string', description: 'Avatar image file path' }, + ], + path: ['chats', 'avatar'], + risk: 'write', + run: chatsAvatar, + }, + { + description: 'Set chat priority', + flags: [...chatFlags, { name: 'level', type: 'string', required: true, enum: ['inbox', 'low'], description: 'Chat priority level' }], + path: ['chats', 'priority'], + risk: 'write', + run: chatsPriority, + }, + { + description: 'Mark a chat read or unread', + flags: [ + ...chatFlags, + { name: 'message', type: 'string', description: 'Read marker message ID' }, + { name: 'unread', type: 'boolean', default: false, description: 'Mark the chat unread' }, + ], + path: ['chats', 'read'], + risk: 'write', + run: chatsRead, + }, + { + description: 'Set or clear a chat draft', + flags: [ + ...chatFlags, + { name: 'clear', type: 'boolean', default: false, description: 'Clear the draft' }, + { name: 'file', type: 'string', description: 'Draft attachment file path' }, + { name: 'filename', type: 'string', description: 'Draft attachment filename' }, + { name: 'mime', type: 'string', description: 'Draft attachment MIME type' }, + { name: 'text', type: 'string', description: 'Draft text' }, + ], + path: ['chats', 'draft'], + risk: 'write', + run: chatsDraft, + }, + { + description: 'Set or clear a chat reminder', + flags: [ + ...chatFlags, + { name: 'clear', type: 'boolean', default: false, description: 'Clear the reminder' }, + { name: 'dismiss-on-message', type: 'boolean', default: false, description: 'Dismiss reminder when a new message arrives' }, + { name: 'when', type: 'string', description: 'ISO reminder timestamp' }, + ], + path: ['chats', 'remind'], + risk: 'write', + run: chatsRemind, + }, + { + description: 'Set a disappearing-message timer', + flags: [ + ...chatFlags, + { name: 'seconds', type: 'string', description: 'Disappearing-message timer in seconds, or off' }, + ], + path: ['chats', 'disappear'], + risk: 'write', + run: chatsDisappear, + }, + { + description: 'Focus a chat in Beeper', + flags: [ + ...chatFlags, + { name: 'file', type: 'string', description: 'Draft attachment file path' }, + { name: 'message', type: 'string', description: 'Message ID to focus' }, + { name: 'text', type: 'string', description: 'Draft text' }, + ], + path: ['chats', 'focus'], + risk: 'write', + run: chatsFocus, + }, + { + description: 'Notify a chat anyway', + flags: chatFlags, + path: ['chats', 'notify-anyway'], + risk: 'write', + run: chatsNotifyAnyway, + }, + { + args: [{ name: 'selector', required: true }], + description: 'Resolve an account selector', + flags: [pickCandidateFlag], + mcp: true, + path: ['resolve', 'account'], + risk: 'read', + run: resolveAccount, + }, + { + args: [{ name: 'selector', required: true }], + description: 'Resolve a bridge selector', + flags: [pickCandidateFlag], + mcp: true, + path: ['resolve', 'bridge'], + risk: 'read', + run: resolveBridge, + }, + { + args: [{ name: 'selector', required: true }], + description: 'Resolve a chat selector', + flags: [ + accountFilterFlag, + candidateLimitFlag, + pickCandidateFlag, + ], + mcp: true, + path: ['resolve', 'chat'], + risk: 'read', + run: resolveChat, + }, + { + args: [{ name: 'selector', required: true }], + description: 'Resolve a contact selector', + flags: [ + accountFilterFlag, + candidateLimitFlag, + pickCandidateFlag, + ], + mcp: true, + path: ['resolve', 'contact'], + risk: 'read', + run: resolveContact, + }, + { + args: [{ name: 'selector', required: true }], + description: 'Resolve a target selector', + flags: [pickCandidateFlag], + mcp: true, + path: ['resolve', 'target'], + risk: 'read', + run: resolveTargetCommand, + }, + { + description: 'List chat messages', + aliases: [['messages', 'ls']], + flags: [ + { name: 'after-cursor', type: 'string', description: 'Paginate messages newer than this message ID' }, + { name: 'asc', type: 'boolean', default: false, description: 'Order oldest first' }, + { name: 'before-cursor', type: 'string', description: 'Paginate messages older than this message ID' }, + chatFlag, + { name: 'ids', type: 'boolean', default: false, description: 'Print only message IDs' }, + { name: 'limit', type: 'integer', default: 50, description: 'Maximum messages to print' }, + pickChatFlag, + { name: 'sender', type: 'string', description: 'me, others, or a specific user ID' }, + ], + mcp: true, + output: 'messages', + path: ['messages', 'list'], + risk: 'read', + run: messagesList, + }, + { + description: 'Show a message with surrounding context', + flags: [ + chatFlag, + pickChatFlag, + { name: 'id', type: 'string', required: true, description: 'Message ID' }, + { name: 'after', type: 'integer', default: 10, description: 'Messages after target' }, + { name: 'before', type: 'integer', default: 10, description: 'Messages before target' }, + ], + mcp: true, + path: ['messages', 'context'], + risk: 'read', + run: messagesContext, + }, + { + description: 'Edit a message', + flags: [ + chatFlag, + pickChatFlag, + { name: 'id', type: 'string', required: true, description: 'Message ID' }, + { name: 'message', type: 'string', required: true, description: 'New message text' }, + ], + path: ['messages', 'edit'], + risk: 'write', + run: messagesEdit, + }, + { + description: 'Delete a message', + flags: [ + chatFlag, + pickChatFlag, + { name: 'id', type: 'string', required: true, description: 'Message ID' }, + { name: 'for-everyone', type: 'boolean', default: false, description: 'Delete for everyone when supported' }, + ], + path: ['messages', 'delete'], + risk: 'destructive', + run: messagesDelete, + }, + { + description: 'Stream Desktop API WebSocket events', + flags: [ + { name: 'chat', type: 'string', multiple: true, description: 'Chat ID to subscribe to; defaults to all chats' }, + { name: 'exclude-type', type: 'string', multiple: true, enum: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted', 'message.stream'], description: 'Drop events of these types' }, + { name: 'include-type', type: 'string', multiple: true, enum: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted', 'message.stream'], description: 'Only forward events of these types' }, + { name: 'webhook', type: 'string', description: 'Forward each event to this URL as POST' }, + { name: 'webhook-queue', type: 'integer', default: 64, description: 'Maximum pending webhook deliveries' }, + { name: 'webhook-secret', type: 'string', description: 'HMAC-SHA256 secret for X-Beeper-Signature' }, + ], + path: ['watch'], + risk: 'read', + run: watch, + }, + { + args: [{ name: 'url', required: true }], + description: 'Download message media', + flags: [{ name: 'out', type: 'string', default: '.', description: 'Output directory; - streams to stdout' }], + path: ['media', 'download'], + risk: 'write', + run: mediaDownload, + }, + { + description: 'Export accounts, chats, messages, transcripts, and attachments', + flags: [ + accountFilterFlag, + { name: 'chat', type: 'string', multiple: true, description: 'Limit to chat selector' }, + { name: 'force', type: 'boolean', default: false, description: 'Re-export completed chats' }, + { name: 'limit-chats', type: 'integer', description: 'Maximum chats to export' }, + { name: 'limit-messages', type: 'integer', description: 'Maximum messages per chat' }, + { name: 'max-participants', type: 'integer', default: 500, description: 'Maximum participants in chat.json' }, + { name: 'no-attachments', type: 'boolean', default: false, description: 'Skip downloading attachments' }, + { name: 'out', type: 'string', default: 'beeper-export', description: 'Export directory' }, + pickChatFlag, + ], + path: ['export'], + risk: 'write', + run: exportCommand, + }, + { + args: [{ name: 'query' }], + aliases: [['messages', 'find']], + description: 'Search messages across chats', + examples: [ + 'beeper messages search "quarterly report"', + 'beeper messages search --chat "Work" --sender me --limit 20', + ], + flags: [ + accountFilterFlag, + { name: 'chat', type: 'string', multiple: true, description: 'Limit to a chat selector' }, + { name: 'chat-type', type: 'string', enum: ['group', 'single'], description: 'Only group chats or direct messages' }, + { name: 'after', type: 'string', description: 'Only messages at or after this ISO timestamp' }, + { name: 'before', type: 'string', description: 'Only messages at or before this ISO timestamp' }, + { name: 'exclude-low-priority', type: 'boolean', description: 'Exclude low-priority chats' }, + { name: 'ids', type: 'boolean', default: false, description: 'Print only message IDs' }, + { name: 'include-muted', type: 'boolean', default: true, description: 'Include muted chats' }, + { name: 'limit', aliases: ['max'], type: 'integer', default: 50, description: 'Maximum results' }, + { name: 'media', type: 'string', multiple: true, enum: ['any', 'video', 'image', 'link', 'file'], description: 'Filter by media type' }, + { name: 'sender', type: 'string', description: 'me, others, or a user ID' }, + { name: 'fail-empty', aliases: ['non-empty', 'require-results'], type: 'boolean', default: false, description: 'Exit with code 3 if no results' }, + ], + mcp: true, + output: 'messages', + path: ['messages', 'search'], + risk: 'read', + run: messagesSearch, + }, + { + args: [{ name: 'method', required: true }, { name: 'path', required: true }], + description: 'Call a raw Desktop API path with any supported HTTP method', + flags: [ + { name: 'body', type: 'string', description: 'JSON request body' }, + { name: 'no-auth', type: 'boolean', default: false, description: 'Call a public API path without a bearer token' }, + ], + mcp: true, + path: ['api', 'request'], + risk: 'write', + run: apiCommand, + }, + { + description: 'Send a text message', + flags: [ + ...sendDeliveryFlags, + { name: 'message', type: 'string', description: 'Message text to send' }, + { name: 'message-escapes', type: 'boolean', default: false, description: 'Interpret backslash escapes in --message' }, + { name: 'message-file', type: 'string', description: "Read message text from a file path; '-' reads stdin" }, + { name: 'mention', type: 'string', multiple: true, description: 'User ID to mention' }, + { name: 'no-preview', type: 'boolean', default: false, description: 'Disable automatic link preview' }, + ], + path: ['send', 'text'], + risk: 'write', + run: sendTextLike, + }, + { + description: 'Send a file message', + flags: [ + ...sendDeliveryFlags, + { name: 'file', type: 'string', required: true, description: 'Local file path to upload' }, + { name: 'caption', type: 'string', description: 'Optional caption for file messages' }, + { name: 'filename', type: 'string', description: 'Override displayed filename' }, + { name: 'mime', type: 'string', description: 'Override MIME type' }, + ], + path: ['send', 'file'], + risk: 'write', + run: sendTextLike, + }, + { + description: 'Send a sticker', + flags: [ + ...sendDeliveryFlags, + { name: 'file', type: 'string', required: true, description: 'Local sticker file path to upload' }, + { name: 'filename', type: 'string', description: 'Override displayed filename' }, + { name: 'mime', type: 'string', description: 'Override MIME type' }, + ], + path: ['send', 'sticker'], + risk: 'write', + run: sendTextLike, + }, + { + description: 'Send a voice note', + flags: [ + ...sendDeliveryFlags, + { name: 'file', type: 'string', required: true, description: 'Local voice note file path to upload' }, + { name: 'duration', type: 'integer', description: 'Duration in seconds' }, + { name: 'filename', type: 'string', description: 'Override displayed filename' }, + { name: 'mime', type: 'string', description: 'Override MIME type' }, + ], + path: ['send', 'voice'], + risk: 'write', + run: sendTextLike, + }, + { + description: 'Send or remove a reaction', + flags: [ + ...sendChatFlags, + { name: 'id', type: 'string', required: true, description: 'Message ID to react to' }, + { name: 'reaction', type: 'string', required: true, description: 'Reaction key' }, + { name: 'remove', type: 'boolean', default: false, description: 'Remove the reaction' }, + { name: 'transaction', type: 'string', description: 'Optional transaction ID for deduplication' }, + ], + path: ['send', 'react'], + risk: 'write', + run: sendReact, + }, + { + description: 'Send a typing indicator', + flags: [ + ...sendChatFlags, + { name: 'duration', type: 'integer', description: 'Seconds to keep typing before sending paused' }, + { name: 'state', type: 'string', enum: ['typing', 'paused'], default: 'typing', description: 'Presence indicator to send' }, + ], + path: ['send', 'presence'], + risk: 'write', + run: sendPresence, + }, + { + args: [{ name: 'name' }], + description: 'Start a local target runtime', + path: ['targets', 'runtime', 'start'], + risk: 'write', + run: targetsRuntime, + }, + { + args: [{ name: 'name' }], + description: 'Stop a local server runtime', + path: ['targets', 'runtime', 'stop'], + risk: 'write', + run: targetsRuntime, + }, + { + args: [{ name: 'name' }], + description: 'Restart a local server runtime', + path: ['targets', 'runtime', 'restart'], + risk: 'write', + run: targetsRuntime, + }, + { + args: [{ name: 'name' }], + description: 'Print logs for a local Beeper Desktop or Server install', + flags: [ + { name: 'lines', type: 'integer', default: 200, description: 'Lines to print from each log file' }, + { name: 'files', type: 'integer', default: 5, description: 'Desktop log files to print, newest first' }, + { name: 'all', type: 'boolean', default: false, description: 'Print all matching log files instead of only recent files' }, + ], + path: ['targets', 'logs'], + risk: 'read', + run: targetsLogs, + }, +] + +export function commandHelp(command: CommandSpec, globalFlags?: GlobalFlags): string { + if (globalFlags && !commandVisible(command, globalFlags)) return help(globalFlags) + const path = command.path.join(' ') + const usageAliases = (command.aliases ?? []).map(alias => alias.join(' ')).join(', ') + const lines = [`Usage: beeper ${path}${command.args?.length ? ` ${command.args.map(arg => arg.variadic ? `<${arg.name}> ...` : arg.required ? `<${arg.name}>` : `[${arg.name}]`).join(' ')}` : ''} [flags]`, '', command.description] + if (usageAliases) lines.push('', `Aliases: ${usageAliases}`) + const args = command.args ?? [] + const flags = command.flags ?? [] + if (args.length) { + lines.push('', 'Arguments:') + for (const arg of args) lines.push(` ${arg.name}${arg.required ? '' : '?'}${arg.variadic ? '...' : ''}\t${arg.description ?? ''}`) + } + if (flags.length) { + lines.push('', 'Flags:') + for (const flag of flags) lines.push(` ${formatFlag(flag)}\t${flag.description ?? ''}`) + } + if (command.examples?.length) { + lines.push('', 'Examples:', ...command.examples.map(example => ` ${example}`)) + } + lines.push('', 'Global flags:') + for (const flag of globalFlagSpecs) lines.push(` ${formatFlag(flag)}\t${flag.description ?? ''}`) + return `${lines.join('\n')}\n` +} + +export function help(globalFlags?: GlobalFlags): string { + const visible = commands.filter(command => globalFlags ? commandVisible(command, globalFlags) : !command.hidden) + const width = Math.max(...visible.map(command => command.path.join(' ').length)) + 2 + const lines = [ + 'Usage: beeper [flags]', + '', + 'Beeper CLI for Beeper Desktop and Beeper Server. Built for terminals, scripts, CI, and agents.', + '', + 'Config:', + '', + ` file: ${configPath()}`, + '', + 'Commands:', + ] + for (const command of [...visible].sort((a, b) => a.path.join(' ').localeCompare(b.path.join(' ')))) { + const aliases = command.aliases?.length ? ` (${command.aliases.map(alias => alias.join(' ')).join(', ')})` : '' + lines.push(` ${command.path.join(' ').padEnd(width)}${command.description}${aliases}`) + } + lines.push('', 'Global flags:') + for (const flag of globalFlagSpecs) lines.push(` ${formatFlag(flag)}\t${flag.description ?? ''}`) + lines.push('', 'Run "beeper --help" for more information on a command.') + return `${lines.join('\n')}\n` +} + +function formatFlag(flag: FlagSpec): string { + const long = `--${flag.name}${flag.type === 'boolean' ? '' : `=${flag.placeholder ?? 'STRING'}`}` + const prefix = flag.short ? `-${flag.short}, ${long}` : ` ${long}` + const aliases = flag.aliases?.length ? ` (${flag.aliases.map(alias => `--${alias}`).join(', ')})` : '' + const env = flag.env?.length ? ` (${flag.env.map(name => `$${name}`).join(',')})` : '' + return `${prefix}${aliases}${env}` +} + +async function version(): Promise> { + const pkg = await packageInfo() + return { name: pkg.name, version: pkg.version } +} + +async function status(ctx: CommandContext): Promise> { + const target = await resolveTarget({ target: ctx.args[0] ?? ctx.globalFlags.target }) + return { + auth: { + authenticated: Boolean(process.env.BEEPER_ACCESS_TOKEN || target.auth?.accessToken), + clientID: target.auth?.clientID, + expiresAt: target.auth?.expiresAt, + scope: target.auth?.scope, + source: process.env.BEEPER_ACCESS_TOKEN ? 'env' : target.auth?.source ?? (target.auth?.accessToken ? 'target' : 'none'), + }, + live: await targetLiveStatus(target), + readiness: await evaluateReadiness({ baseURL: target.baseURL, target: target.id }), + target: publicTarget(target), + } +} + +async function doctor(ctx: CommandContext): Promise> { + const config = await readConfig() + const targets = await listTargets() + const target = await resolveTarget({ target: ctx.globalFlags.target }).catch(() => undefined) + const live = target ? await targetLiveStatus(target) : undefined + const readiness = target && live && (live as Record).reachable + ? await evaluateReadiness({ baseURL: target.baseURL, target: target.id }).catch(error => ({ state: 'unknown', message: error instanceof Error ? error.message : String(error) })) + : undefined + return { + config_file: process.env.BEEPER_CLI_CONFIG_DIR ? `${process.env.BEEPER_CLI_CONFIG_DIR}/config.json` : undefined, + default_target: config.defaultTarget ?? builtInDesktopTargetID, + default_account: config.defaultAccount, + targets: targets.length || 1, + selected_target: target?.id, + target_type: target?.type, + reachable: isRecord(live) ? live.reachable : false, + authenticated: Boolean(process.env.BEEPER_ACCESS_TOKEN || target?.auth?.accessToken), + readiness: isRecord(readiness) ? readiness.state : undefined, + next: isRecord(readiness) ? readiness.message : undefined, + } +} + +async function exitCodes(): Promise> { + return { + exit_codes: { + ok: 0, + error: ExitCodes.Generic, + usage: ExitCodes.Usage, + empty_results: ExitCodes.EmptyResults, + auth_required: ExitCodes.AuthRequired, + not_ready: ExitCodes.NotReady, + not_found: ExitCodes.NotFound, + ambiguous: ExitCodes.Ambiguous, + cancelled: 130, + command_not_found: ExitCodes.CommandNotFound, + }, + } +} + +async function schema(ctx: CommandContext): Promise> { + const pkg = await packageInfo() + return buildSchema(commands, String(pkg.version ?? '0'), ctx.args, ctx.globalFlags) +} + +async function mcp(ctx: CommandContext): Promise { + const pkg = await packageInfo() + await serveMcp(commands, ctx.globalFlags, { + allowTools: stringListFlag(ctx.flags, 'allow-tool'), + allowWrite: Boolean(ctx.flags['allow-write']), + listTools: Boolean(ctx.flags['list-tools']), + maxOutputBytes: numberFlag(ctx.flags, 'max-output-bytes', 102400), + timeoutSeconds: numberFlag(ctx.flags, 'timeout-seconds', 60), + }, String(pkg.version ?? '0')) +} + +async function completion(ctx: CommandContext): Promise { + const shell = ctx.args[0] + if (!shell) throw usage('completion requires shell') + process.stdout.write(completionScript(shell)) +} + +async function configGet(ctx: CommandContext): Promise> { + const key = parseConfigKey(ctx.args[0]) + const config = await readConfig() + return { key, value: config[key] ?? null } +} + +async function configKeysCommand(): Promise { + return [...configKeys] +} + +async function configList(): Promise> { + const config = await readConfig() + return { + path: configPath(), + defaultTarget: config.defaultTarget ?? null, + defaultAccount: config.defaultAccount ?? null, + } +} + +async function configPathCommand(): Promise> { + return { path: configPath() } +} + +async function configSet(ctx: CommandContext): Promise> { + const key = parseConfigKey(ctx.args[0]) + const value = ctx.args[1] + if (!value) throw usage('config set requires value') + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'config.set', request: { key, value } } + const config = await updateConfig(current => ({ ...current, [key]: value })) + return { key, saved: true, value: config[key] ?? null } +} + +async function configUnset(ctx: CommandContext): Promise> { + const key = parseConfigKey(ctx.args[0]) + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'config.unset', request: { key } } + const config = await updateConfig(current => unsetConfigKey(current, key)) + return { key, removed: true, value: config[key] ?? null } +} + +function parseConfigKey(value: string | undefined): ConfigKey { + if (!value) throw usage(`config key is required. Available keys: ${configKeys.join(', ')}`) + const normalized = value.replaceAll('-', '').replaceAll('_', '').toLowerCase() + const key = configKeys.find(item => item.toLowerCase() === normalized) + if (!key) throw usage(`unknown config key "${value}". Available keys: ${configKeys.join(', ')}`) + return key +} + +function unsetConfigKey(config: Config, key: ConfigKey): Config { + const next = { ...config } + delete next[key] + return next +} + +async function completeCommand(ctx: CommandContext): Promise { + const cword = numberFlag(ctx.flags, 'cword', -1) + const words = ctx.args.length ? ctx.args : ['beeper'] + for (const item of completeWords(words, cword)) process.stdout.write(`${item}\n`) +} + +async function targetsList(): Promise { + const config = await readConfig() + const targets = await listTargets() + const rows = targets.length ? targets : [await resolveTarget({ target: builtInDesktopTargetID })] + return Promise.all(rows.map(async target => ({ + baseURL: target.baseURL, + default: config.defaultTarget ? config.defaultTarget === target.id : target.id === builtInDesktopTargetID, + id: target.id, + localProfile: Boolean(target.dataDir), + name: target.name ?? target.id, + type: target.type, + ...await targetLiveStatus(target), + }))) +} + +async function targetsAdd(ctx: CommandContext): Promise> { + const [name, url] = ctx.args + if (!name || !url) throw usage('targets add requires name and url') + if (name === builtInDesktopTargetID) throw usage('Target name "desktop" is reserved for the built-in Beeper Desktop target') + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'targets.add', request: { default: ctx.flags.default, name, url } } + if (await readTarget(name)) throw usage(`Target "${name}" already exists`) + const target: Target = { baseURL: url, id: name, name, type: 'remote' } + await writeTarget(target) + if (ctx.flags.default) await updateConfig(config => ({ ...config, defaultTarget: target.id })) + return { target: publicTarget(target) } +} + +async function targetsTunnel(ctx: CommandContext): Promise> { + const target = await resolveTarget({ target: ctx.args[0] ?? ctx.globalFlags.target }) + const url = new URL(target.baseURL) + url.search = '' + url.hash = '' + const localURL = url.toString().replace(/\/$/, '') + const request = { + cloudflaredPath: stringFlag(ctx.flags, 'cloudflared-path') ?? process.env.BEEPER_CLOUDFLARED_PATH, + install: Boolean(ctx.flags.install), + localURL, + retries: numberFlag(ctx.flags, 'retries', 5), + target: target.id, + timeoutMs: parseDurationMs(stringFlag(ctx.flags, 'timeout')) ?? 40_000, + } + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'targets.tunnel', request } + + const started = await startCloudflareTunnel({ + cloudflaredPath: stringFlag(ctx.flags, 'cloudflared-path'), + debug: ctx.globalFlags.debug, + install: Boolean(ctx.flags.install), + retries: numberFlag(ctx.flags, 'retries', 5), + timeoutMs: parseDurationMs(stringFlag(ctx.flags, 'timeout')) ?? 40_000, + url: localURL, + }) + const result = { cloudflaredPath: started.cloudflaredPath, localURL, target: target.id, url: started.url } + if (ctx.globalFlags.events) writeEvent('tunnel.connected', result) + if (ctx.flags['url-only']) process.stdout.write(`${started.url}\n`) + else if (ctx.globalFlags.json || ctx.globalFlags.plain) writeResult(result, ctx.globalFlags) + else { + process.stdout.write(`Cloudflare Tunnel connected for ${target.id}\n${started.url} -> ${localURL}\n`) + process.stderr.write('Press Ctrl-C to stop the tunnel.\n') + } + + const exit = await waitForTunnelExit(started) + if (exit.reason === 'process' && exit.code !== 0) { + throw new Error(`cloudflared exited after the tunnel connected${exit.code === null ? '' : ` with code ${exit.code}`}.\n${started.tryMessage}`) + } + return undefined +} + +async function targetsRuntime(ctx: CommandContext): Promise> { + const action = ctx.commandPath[2] + if (action !== 'start' && action !== 'stop' && action !== 'restart') throw usage(`Unsupported runtime command: ${ctx.commandPath.join(' ')}`) + const target = await resolveTarget({ target: ctx.args[0] ?? ctx.globalFlags.target }) + if (ctx.globalFlags.dryRun) return { dry_run: true, op: `targets.runtime.${action}`, request: { target: publicTarget(target) } } + if (action === 'start' && target.type === 'desktop') return { result: await launchDesktopApp(target.dataDir ? target : undefined), target: publicTarget(target) } + if (!target.dataDir || target.type !== 'server') throw usage(`Target "${target.id}" is not a local Beeper Server install.`) + if (action === 'start') return { result: await startProfile(target), target: publicTarget(target) } + if (action === 'stop') { + await stopProfile(target) + return { stopped: true, target: publicTarget(target) } + } + await stopProfile(target).catch(() => undefined) + return { restarted: true, result: await startProfile(target), target: publicTarget(target) } +} + +async function targetsLogs(ctx: CommandContext): Promise { + const target = await resolveTarget({ target: ctx.args[0] ?? ctx.globalFlags.target }) + if (target.type === 'remote') throw usage(`Target "${target.id}" is remote and has no local logs.`) + const lines = numberFlag(ctx.flags, 'lines', 200) + if (target.type === 'server') { + if (!target.dataDir) throw usage(`Target "${target.id}" is not a local Beeper Server install.`) + await printLogFile(profileLogPath(target.id), lines) + await printLogFile(profileErrorLogPath(target.id), lines) + return + } + const files = await listLogFiles(desktopLogDir(target.dataDir ? target : undefined)) + const selected = ctx.flags.all ? files : files.slice(0, numberFlag(ctx.flags, 'files', 5)) + for (const file of selected) await printLogFile(file, lines) +} + +async function listLogFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + const files = await Promise.all(entries.map(async entry => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return listLogFiles(path) + if (entry.isFile() && entry.name.endsWith('.log')) return [path] + return [] + })) + const paths = files.flat() + const stats = await Promise.all(paths.map(async path => ({ path, mtimeMs: (await stat(path)).mtimeMs }))) + return stats.sort((a, b) => b.mtimeMs - a.mtimeMs).map(item => item.path) +} + +async function printLogFile(path: string, lines: number): Promise { + const content = await readFile(path, 'utf8').catch(() => '') + if (!content) return + process.stdout.write(`\n==> ${path} <==\n`) + if (lines <= 0) process.stdout.write(content.endsWith('\n') ? content : `${content}\n`) + else { + const parts = content.split('\n') + const tail = parts.slice(Math.max(0, parts.length - lines - 1)).join('\n') + process.stdout.write(tail.endsWith('\n') ? tail : `${tail}\n`) + } +} + +async function installCommand(ctx: CommandContext): Promise> { + const type = ctx.commandPath[1] + if (type !== 'desktop' && type !== 'server') throw usage(`Unsupported install command: ${ctx.commandPath.join(' ')}`) + const channel = stringFlag(ctx.flags, 'channel') === 'nightly' ? 'nightly' : 'stable' + const serverEnv = normalizeServerEnv(stringFlag(ctx.flags, 'server-env') ?? 'prod') + if (ctx.globalFlags.dryRun) return { dry_run: true, op: `install.${type}`, request: { channel, serverEnv } } + if (type === 'desktop') await installDesktop({ channel, serverEnv }) + else await installServer({ channel, serverEnv }) + return { installed: type, channel, serverEnv } +} + +async function accountsList(ctx: CommandContext): Promise { + const client = await apiClient(ctx) + const selected = await resolveAccountIDs(client, stringListFlag(ctx.flags, 'account'), { allowMultiplePerInput: true, applyDefault: false }) + const config = await readConfig() + const rows = apiItems(await client.accounts.list()) + const items = rows + .filter(row => !selected?.length || selected.includes(String(row.accountID ?? row.id))) + .map(row => ({ ...row, default: (row.accountID ?? row.id) === config.defaultAccount || undefined })) + return ctx.flags.ids ? ids(items, 'accountID') : items +} + +async function useTarget(ctx: CommandContext): Promise> { + const target = await resolveTarget({ target: ctx.args[0]! }) + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'use.target', request: { defaultTarget: target.id } } + await updateConfig(config => ({ ...config, defaultTarget: target.id })) + return { defaultTarget: target.id } +} + +async function useAccount(ctx: CommandContext): Promise> { + const input = ctx.args[0]! + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'use.account', request: { defaultAccount: input } } + const client = await apiClient(ctx) + const accountID = await resolveAccountID(client, input) + await updateConfig(config => ({ ...config, defaultAccount: accountID })) + return { defaultAccount: accountID } +} + +async function accountsAdd(ctx: CommandContext): Promise { + const client = await apiClient(ctx) + let bridge = ctx.args[0] + const guided = ctx.flags.guided !== false + const nonInteractive = ctx.globalFlags.noInput + const bridges = apiItems(await client.bridges.list()) + + if (!bridge) { + if (ctx.globalFlags.json || ctx.globalFlags.plain) return bridges + if (guided && !nonInteractive && process.stdin.isTTY) { + bridge = await chooseBridge(bridges) + } else { + printAvailableBridges(bridges) + return undefined + } + } + + const accountType = resolveBridgeChoice(bridges, bridge) + if (String(accountType.status ?? 'available') !== 'available') { + const name = String(accountType.displayName ?? accountType.name ?? accountType.id) + const detail = accountType.statusText ? `: ${String(accountType.statusText)}` : '' + throw usage(`${name} is not available${detail}`) + } + + let flowID = stringFlag(ctx.flags, 'flow') + if (!flowID) { + const flows = apiItems(await client.bridges.loginFlows.list(String(accountType.id))) + if (flows.length > 1) { + if (guided && !nonInteractive && !ctx.globalFlags.json) flowID = await chooseLoginFlow(flows) + else throw usage(`Multiple sign-in methods are available for ${String(accountType.displayName ?? accountType.id)}. Pass --flow.`) + } else { + flowID = flows[0]?.id ? String(flows[0].id) : undefined + } + if (!flowID) throw usage(`No login flows returned for ${String(accountType.displayName ?? accountType.id)}.`) + } + + const cookies = parseKeyValueFlags(stringListFlag(ctx.flags, 'cookie'), '--cookie') + const fields = parseKeyValueFlags(stringListFlag(ctx.flags, 'field'), '--field') + const request = { + bridgeID: String(accountType.id), + bridgeName: accountType.displayName ?? accountType.name, + cookieKeys: Object.keys(cookies), + fieldKeys: Object.keys(fields), + flowID, + guided, + loginID: stringFlag(ctx.flags, 'login-id'), + nonInteractive, + webview: Boolean(ctx.flags.webview), + webviewBackend: stringFlag(ctx.flags, 'webview-backend') ?? 'chrome', + } + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'accounts.add', request } + + const step = await client.bridges.loginSessions.create(String(accountType.id), { + flowID, + loginID: stringFlag(ctx.flags, 'login-id'), + }) + const result = guided + ? await runGuidedAccountLogin(client, String(accountType.id), step, { + cookies, + fields, + nonInteractive, + webview: Boolean(ctx.flags.webview), + webviewBackend: request.webviewBackend as 'auto' | 'chrome' | 'webkit', + webviewTimeoutMs: numberFlag(ctx.flags, 'webview-timeout', 120) * 1000, + }) + : step + if (ctx.globalFlags.json || ctx.globalFlags.plain) return result + await printAccountLoginStep(result) + return undefined +} + +async function removeTargetCommand(ctx: CommandContext): Promise> { + const input = ctx.args[0]! + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'remove.target', request: { id: input } } + await removeTarget(input) + return { id: input, removed: true } +} + +async function removeAccount(ctx: CommandContext): Promise> { + const input = ctx.args[0]! + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'remove.account', request: { account: input } } + const client = await apiClient(ctx) + const accountID = await resolveAccountID(client, input) + if (client.accounts.delete) await client.accounts.delete(accountID) + else if (client.accounts.remove) await client.accounts.remove(accountID) + else throw usage('This Desktop API does not expose account removal.') + return { accountID, removed: true } +} + +async function contactsList(ctx: CommandContext): Promise { + const client = await apiClient(ctx) + const accountIDs = await resolveAccountIDs(client, stringListFlag(ctx.flags, 'account'), { allowMultiplePerInput: true }) ?? await listAccountIDs(client) + const limit = numberFlag(ctx.flags, 'limit', 50) + const query = stringFlag(ctx.flags, 'query') + const items: Array> = [] + for (const accountID of accountIDs) { + const remaining = limit - items.length + if (remaining <= 0) break + const contacts = await collectPage(client.accounts.contacts.list(accountID, { query }), remaining) + items.push(...contacts.map(item => ({ ...apiRecord(item), accountID }))) + } + return ctx.flags.ids ? ids(items, 'userID') : items +} + +async function chatsList(ctx: CommandContext): Promise { + const client = await apiClient(ctx) + const accountIDs = await resolveAccountIDs(client, stringListFlag(ctx.flags, 'account'), { allowMultiplePerInput: true }) + const query = stringFlag(ctx.flags, 'query') + if (query) { + const items = (await collectPage(client.chats.search({ accountIDs, query }), numberFlag(ctx.flags, 'limit', 20))) + .map(apiRecord) + .filter(row => matchesChatFilters(row, ctx)) + return ctx.flags.ids ? ids(items, 'localChatID') : items + } + const items: Record[] = [] + for await (const item of client.chats.list({ accountIDs })) { + const row = apiRecord(item) + if (matchesChatFilters(row, ctx)) items.push(row) + if (items.length >= numberFlag(ctx.flags, 'limit', 20)) break + } + return ctx.flags.ids ? ids(items, 'localChatID') : items +} + +async function chatsShow(ctx: CommandContext): Promise { + const client = await apiClient(ctx) + const chatID = await resolveChatID(client, stringFlag(ctx.flags, 'chat')!, chatResolutionOptions(ctx)) + return client.chats.retrieve(chatID, { maxParticipantCount: numberFlag(ctx.flags, 'max-participants', 0) || undefined }) +} + +async function chatsStart(ctx: CommandContext): Promise { + const user = ctx.args[0] + if (!user) throw usage('chats start requires user') + const account = stringFlag(ctx.flags, 'account') + const title = stringFlag(ctx.flags, 'title') + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'chats.start', request: { account, title, user: userQueryFromInput(user) } } + const client = await apiClient(ctx) + const accountID = account ? await resolveAccountID(client, account) : await defaultAccountID(client) + const payload = { accountID, title, user: userQueryFromInput(user) } + return client.chats.start(payload) +} + +async function chatsUpdate(ctx: CommandContext, op: string, update: Record): Promise { + if (ctx.globalFlags.dryRun) return { dry_run: true, op: `chats.${op}`, request: { chat: stringFlag(ctx.flags, 'chat'), pick: ctx.flags.pick, ...update } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + return client.chats.update(chatID, update) +} + +async function chatsSetFlag(ctx: CommandContext): Promise { + const action = ctx.commandPath[1] ?? '' + const field = ({ archive: 'isArchived', mute: 'isMuted', pin: 'isPinned' } as const)[action] + if (!field) throw usage(`Unsupported chat command: ${ctx.commandPath.join(' ')}`) + return chatsUpdate(ctx, action, { [field]: !ctx.flags.clear }) +} + +async function chatsRename(ctx: CommandContext): Promise { + return chatsUpdate(ctx, 'rename', { title: stringFlag(ctx.flags, 'title') }) +} + +async function chatsDescription(ctx: CommandContext): Promise { + const clear = Boolean(ctx.flags.clear) + const description = stringFlag(ctx.flags, 'description') + if (!clear && !description) throw usage('Provide --description or --clear') + return chatsUpdate(ctx, 'description', { description: clear ? null : description }) +} + +async function chatsAvatar(ctx: CommandContext): Promise { + const clear = Boolean(ctx.flags.clear) + const file = stringFlag(ctx.flags, 'file') + if (!clear && !file) throw usage('Provide --file or --clear') + return chatsUpdate(ctx, 'avatar', { imgURL: clear ? null : file }) +} + +async function chatsPriority(ctx: CommandContext): Promise { + const level = stringFlag(ctx.flags, 'level')! + const update = level === 'inbox' ? { isArchived: false, isLowPriority: false } : { isLowPriority: true } + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'chats.priority', request: { chat: stringFlag(ctx.flags, 'chat'), level, pick: ctx.flags.pick, update } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + return client.chats.update(chatID, update) +} + +async function chatsRead(ctx: CommandContext): Promise { + const messageID = stringFlag(ctx.flags, 'message') + const read = !ctx.flags.unread + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'chats.read', request: { chat: stringFlag(ctx.flags, 'chat'), messageID, pick: ctx.flags.pick, read } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + return read ? client.chats.markRead(chatID, { messageID }) : client.chats.markUnread(chatID, { messageID }) +} + +async function chatsDraft(ctx: CommandContext): Promise { + const clear = Boolean(ctx.flags.clear) + if (!clear && ctx.flags.text === undefined) throw usage('Provide --text TEXT, optionally with --file PATH, or --clear.') + if (clear && (ctx.flags.text !== undefined || ctx.flags.file)) throw usage('--clear cannot be combined with --text or --file.') + if (clear) { + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'chats.draft', request: { chat: stringFlag(ctx.flags, 'chat'), draft: null, pick: ctx.flags.pick } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + return client.chats.update(chatID, { draft: null }) + } + const draft = { file: stringFlag(ctx.flags, 'file'), fileName: stringFlag(ctx.flags, 'filename'), mimeType: stringFlag(ctx.flags, 'mime'), text: stringFlag(ctx.flags, 'text') } + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'chats.draft', request: { chat: stringFlag(ctx.flags, 'chat'), draft, pick: ctx.flags.pick } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + const upload = draft.file ? await client.assets.upload({ file: createReadStream(draft.file), fileName: draft.fileName, mimeType: draft.mimeType }) : undefined + return client.chats.update(chatID, { draft: { text: draft.text, attachments: upload?.uploadID ? { [upload.uploadID]: upload } : undefined } }) +} + +async function chatsRemind(ctx: CommandContext): Promise { + if (ctx.flags.clear) { + if (ctx.flags.when || ctx.flags['dismiss-on-message']) throw usage('--clear cannot be combined with --when or --dismiss-on-message') + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'chats.remind', request: { chat: stringFlag(ctx.flags, 'chat'), pick: ctx.flags.pick, reminder: null } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + await client.chats.reminders.delete(chatID) + return { chatID, reminderCleared: true } + } + const when = requiredStringFlag(ctx.flags, 'when') + const reminder = { dismissOnIncomingMessage: Boolean(ctx.flags['dismiss-on-message']) || undefined, remindAt: when } + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'chats.remind', request: { chat: stringFlag(ctx.flags, 'chat'), pick: ctx.flags.pick, reminder } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + await client.chats.reminders.create(chatID, { reminder }) + return { chatID, remindAt: when, reminderSet: true } +} + +async function chatsDisappear(ctx: CommandContext): Promise { + const raw = requiredStringFlag(ctx.flags, 'seconds').toLowerCase() + const messageExpirySeconds = raw === 'off' ? null : /^\d+$/.test(raw) ? Number(raw) : NaN + if (messageExpirySeconds !== null && (!Number.isSafeInteger(messageExpirySeconds) || messageExpirySeconds < 0)) throw usage('--seconds must be a positive integer or "off"') + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'chats.disappear', request: { chat: stringFlag(ctx.flags, 'chat'), messageExpirySeconds, pick: ctx.flags.pick } } + return chatsUpdate(ctx, 'disappear', { messageExpirySeconds }) +} + +async function chatsFocus(ctx: CommandContext): Promise { + if (ctx.globalFlags.dryRun) { + return { + dry_run: true, + op: 'chats.focus', + request: { + chat: stringFlag(ctx.flags, 'chat'), + draftAttachmentPath: stringFlag(ctx.flags, 'file'), + draftText: stringFlag(ctx.flags, 'text'), + messageID: stringFlag(ctx.flags, 'message'), + pick: ctx.flags.pick, + }, + } + } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + const request = { + chatID, + draftAttachmentPath: stringFlag(ctx.flags, 'file'), + draftText: stringFlag(ctx.flags, 'text'), + messageID: stringFlag(ctx.flags, 'message'), + } + return client.focus(request) +} + +async function chatsNotifyAnyway(ctx: CommandContext): Promise { + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'chats.notify-anyway', request: { chat: stringFlag(ctx.flags, 'chat'), pick: ctx.flags.pick } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + return client.chats.notifyAnyway(chatID) +} + +async function resolveAccount(ctx: CommandContext): Promise { + const selector = ctx.args[0]! + const client = await apiClient(ctx) + const rows = apiItems(await client.accounts.list()) + const ids = await resolveAccountIDs(client, [selector], { allowMultiplePerInput: true, applyDefault: false }) + const candidates = rows.filter(row => ids?.includes(String(row.accountID ?? row.id))) + return resolution(ctx, 'account', selector, candidates.map(account => ({ + accountID: account.accountID, + bridge: account.bridge, + id: account.accountID ?? account.id, + network: account.network, + raw: account, + user: account.user, + }))) +} + +async function resolveChat(ctx: CommandContext): Promise { + const selector = ctx.args[0]! + const client = await apiClient(ctx) + const accountIDs = await resolveAccountIDs(client, stringListFlag(ctx.flags, 'account'), { allowMultiplePerInput: true }) + const candidates = await collectPage(client.chats.search({ accountIDs, query: selector, scope: 'titles' }), numberFlag(ctx.flags, 'limit', 10)) + const normalized = normalizeSelector(selector) + const exact = candidates.map(apiRecord).filter(chat => + normalizeSelector(chat.id) === normalized || + normalizeSelector(chat.localChatID) === normalized || + normalizeSelector(chat.title) === normalized + ) + const matches = exact.length ? exact : candidates.map(apiRecord) + return resolution(ctx, 'chat', selector, matches.map(chat => ({ + accountID: chat.accountID, + id: chat.id, + localChatID: chat.localChatID, + network: chat.network, + raw: chat, + title: chat.title, + }))) +} + +async function resolveContact(ctx: CommandContext): Promise { + const selector = ctx.args[0]! + const client = await apiClient(ctx) + const accountIDs = await resolveAccountIDs(client, stringListFlag(ctx.flags, 'account'), { allowMultiplePerInput: true }) ?? await listAccountIDs(client) + const candidates: Record[] = [] + for (const accountID of accountIDs) { + try { + const result = await client.accounts.contacts.search(accountID, { query: selector }) + candidates.push(...apiItems(result).slice(0, numberFlag(ctx.flags, 'limit', 10)).map(item => ({ ...item, accountID }))) + } catch (error) { + if (!ignorableLookupError(error)) throw error + } + } + return resolution(ctx, 'contact', selector, candidates.map(contact => ({ + accountID: contact.accountID, + displayName: contact.displayName ?? contact.fullName ?? contact.name, + email: contact.email, + id: contact.id, + phoneNumber: contact.phoneNumber, + username: contact.username, + }))) +} + +async function resolveTargetCommand(ctx: CommandContext): Promise { + const selector = ctx.args[0]! + const normalized = normalizeSelector(selector) + const targets = await listTargets() + const rows = targets.some(target => target.id === builtInDesktopTargetID) + ? targets + : [await resolveTarget({ target: builtInDesktopTargetID }), ...targets] + const candidates = rows.filter(target => + normalizeSelector(target.id) === normalized || + normalizeSelector(target.name) === normalized || + normalizeSelector(target.type) === normalized || + normalizeSelector(target.baseURL).includes(normalized) + ) + return resolution(ctx, 'target', selector, candidates.map(target => ({ + baseURL: target.baseURL, + id: target.id, + localProfile: Boolean(target.dataDir), + name: target.name, + raw: publicTarget(target), + type: target.type, + }))) +} + +async function resolveBridge(ctx: CommandContext): Promise { + const selector = ctx.args[0]! + const client = await apiClient(ctx) + const rows = apiItems(await client.bridges.list()) + const normalized = normalizeSelector(selector) + const candidates = rows.filter(bridge => + normalizeSelector(bridge.id) === normalized || + normalizeSelector(bridge.type) === normalized || + normalizeSelector(bridge.provider) === normalized || + normalizeSelector(bridge.name) === normalized || + normalizeSelector(bridge.displayName) === normalized || + normalizeSelector(bridge.id).includes(normalized) || + normalizeSelector(bridge.displayName).includes(normalized) + ) + return resolution(ctx, 'bridge', selector, candidates.map(bridge => ({ + displayName: bridge.displayName ?? bridge.name, + id: bridge.id, + provider: bridge.provider, + raw: bridge, + status: bridge.status, + type: bridge.type, + }))) +} + +async function messagesList(ctx: CommandContext): Promise { + const chat = stringFlag(ctx.flags, 'chat')! + const before = stringFlag(ctx.flags, 'before-cursor') + const after = stringFlag(ctx.flags, 'after-cursor') + if (before && after) throw usage('Use only one of --before-cursor or --after-cursor') + const client = await apiClient(ctx) + const chatID = await resolveChatID(client, chat, chatResolutionOptions(ctx)) + let items = await collectMessages(client.messages.list(chatID, { + cursor: before ?? after, + direction: before ? 'before' : after ? 'after' : undefined, + }), numberFlag(ctx.flags, 'limit', 50), stringFlag(ctx.flags, 'sender')) + if (ctx.flags.asc) items = [...items].reverse() + return ctx.flags.ids ? ids(items.map(apiRecord), 'messageID') : items +} + +async function messagesContext(ctx: CommandContext): Promise { + const id = stringFlag(ctx.flags, 'id')! + if (ctx.globalFlags.dryRun) { + return { dry_run: true, op: 'messages.context', request: { after: numberFlag(ctx.flags, 'after', 10), before: numberFlag(ctx.flags, 'before', 10), chat: stringFlag(ctx.flags, 'chat'), messageID: id, pick: ctx.flags.pick } } + } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + const message = client.messages.retrieve ? await client.messages.retrieve(id, { chatID }) : undefined + const before = await collectPage(client.messages.list(chatID, { cursor: id, direction: 'before' }), numberFlag(ctx.flags, 'before', 10)) + const after = await collectPage(client.messages.list(chatID, { cursor: id, direction: 'after' }), numberFlag(ctx.flags, 'after', 10)) + return { after, before, chatID, message, messageID: id } +} + +async function messagesEdit(ctx: CommandContext): Promise { + const id = stringFlag(ctx.flags, 'id')! + const text = stringFlag(ctx.flags, 'message')! + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'messages.edit', request: { chat: stringFlag(ctx.flags, 'chat'), messageID: id, pick: ctx.flags.pick, text } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + return client.messages.update(id, { chatID, text }) +} + +async function messagesDelete(ctx: CommandContext): Promise { + const id = stringFlag(ctx.flags, 'id')! + const forEveryone = Boolean(ctx.flags['for-everyone']) + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'messages.delete', request: { chat: stringFlag(ctx.flags, 'chat'), forEveryone, messageID: id, pick: ctx.flags.pick } } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'chat') + await client.messages.delete(id, { chatID, forEveryone: forEveryone || undefined }) + return { chatID, deleted: true, forEveryone, messageID: id } +} + +async function watch(ctx: CommandContext): Promise { + if (ctx.flags['webhook-secret'] && !ctx.flags.webhook) throw usage('--webhook-secret requires --webhook URL') + const include = stringListFlag(ctx.flags, 'include-type') + const exclude = stringListFlag(ctx.flags, 'exclude-type') + if (include.length && exclude.length) throw usage('Use either --include-type or --exclude-type, not both.') + const filter: EventFilter = { + include: include.length ? new Set(include) : undefined, + exclude: exclude.length ? new Set(exclude) : undefined, + } + const target = await resolveTarget({ target: ctx.globalFlags.target }) + const token = await targetToken(target, true) + const baseURL = target.baseURL + const info = await fetch(new URL('/v1/info', baseURL)) + if (!info.ok) throw usage(`Failed to fetch /v1/info: HTTP ${info.status}`) + const metadata = await info.json() as { endpoints?: { ws_events?: string } } + const url = new URL(metadata.endpoints?.ws_events || '/v1/ws', baseURL) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + + const subscribed = stringListFlag(ctx.flags, 'chat') + const chatIDs = subscribed.length ? subscribed : ['*'] + const webhookURL = stringFlag(ctx.flags, 'webhook') + const webhook = webhookURL + ? { inflight: 0, max: numberFlag(ctx.flags, 'webhook-queue', 64), queue: [], secret: stringFlag(ctx.flags, 'webhook-secret'), url: webhookURL } satisfies WebhookConfig + : undefined + const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } }) + + ws.addEventListener('open', () => { + if (ctx.globalFlags.events) writeEvent('watch.open', { subscribed: chatIDs }) + ws.send(JSON.stringify({ chatIDs, type: 'subscriptions.set' })) + }) + ws.addEventListener('message', event => { + const body = typeof event.data === 'string' ? event.data : event.data.toString() + if (!passesFilter(body, filter)) return + if (ctx.globalFlags.events) writeEvent('watch.message') + writeWatchEvent(body, ctx.globalFlags.json || ctx.globalFlags.plain) + if (webhook) forwardWebhook(webhook, body, ctx.globalFlags.events) + }) + ws.addEventListener('error', () => { + if (ctx.globalFlags.events) writeEvent('watch.error', { message: 'WebSocket connection failed' }) + }) + ws.addEventListener('close', event => { + if (ctx.globalFlags.events) writeEvent('watch.close', { code: event.code, reason: event.reason }) + }) + + await new Promise(resolve => { + process.once('SIGINT', () => { + ws.close(1000) + resolve() + }) + ws.addEventListener('close', () => resolve()) + }) +} + +async function mediaDownload(ctx: CommandContext): Promise { + const url = ctx.args[0] + if (!url) throw usage('media download requires url') + const out = stringFlag(ctx.flags, 'out') ?? '.' + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'media.download', request: { out, url } } + + const client = await apiClient(ctx) + const response = await client.assets.serve({ url }) + if (!response.ok) throw usage(`Failed to download media: HTTP ${response.status}`) + const buffer = Buffer.from(await response.arrayBuffer()) + if (out === '-') { + output.write(buffer) + return undefined + } + + await mkdir(out, { recursive: true }) + const path = join(out, basename(new URL(url).pathname) || 'media') + await writeFile(path, buffer) + return { bytes: buffer.length, path } +} + +async function exportCommand(ctx: CommandContext): Promise { + const accountSelectors = stringListFlag(ctx.flags, 'account') + const chatSelectors = stringListFlag(ctx.flags, 'chat') + const request = { + accounts: accountSelectors, + chats: chatSelectors, + downloadAttachments: !ctx.flags['no-attachments'], + force: Boolean(ctx.flags.force), + limitChats: ctx.flags['limit-chats'], + limitMessages: ctx.flags['limit-messages'], + maxParticipants: numberFlag(ctx.flags, 'max-participants', 500), + outDir: stringFlag(ctx.flags, 'out') ?? 'beeper-export', + pick: ctx.flags.pick, + } + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'export', request } + + const client = await apiClient(ctx) + const accountIDs = await resolveAccountIDs(client, accountSelectors, { allowMultiplePerInput: true }) + const chatIDs = chatSelectors.length + ? await Promise.all(chatSelectors.map(chat => resolveChatID(client, chat, chatResolutionOptions(ctx, accountIDs)))) + : undefined + const manifest = await exportBeeperData(client, { + accountIDs, + chatIDs, + downloadAttachments: request.downloadAttachments, + force: request.force, + limitChats: typeof request.limitChats === 'number' ? request.limitChats : undefined, + limitMessages: typeof request.limitMessages === 'number' ? request.limitMessages : undefined, + maxParticipants: request.maxParticipants, + onProgress: message => { + if (ctx.globalFlags.events) writeEvent('export.progress', { message }) + if (!ctx.globalFlags.json && !ctx.globalFlags.plain) process.stderr.write(`${message}\n`) + }, + outDir: request.outDir, + }) + return { ...manifest, outDir: request.outDir } +} + +async function messagesSearch(ctx: CommandContext): Promise { + const accountSelectors = stringListFlag(ctx.flags, 'account') + const chatSelectors = stringListFlag(ctx.flags, 'chat') + const mediaTypes = stringListFlag(ctx.flags, 'media') as Array<'any' | 'video' | 'image' | 'link' | 'file'> + const hasFilter = Boolean( + accountSelectors.length || chatSelectors.length || ctx.flags['chat-type'] + || ctx.flags.after || ctx.flags.before || mediaTypes.length || ctx.flags.sender, + ) + if (!ctx.args[0] && !hasFilter) { + throw usage('Provide a search query or at least one filter flag (--chat, --sender, --media, etc.).') + } + const client = await apiClient(ctx) + const accountIDs = await resolveAccountIDs(client, accountSelectors, { allowMultiplePerInput: true }) + const chatIDs = chatSelectors.length + ? await Promise.all(chatSelectors.map(chat => resolveChatID(client, chat, chatResolutionOptions(ctx, accountIDs)))) + : undefined + const items = await collectPage(client.messages.search({ + accountIDs, + chatIDs, + chatType: stringFlag(ctx.flags, 'chat-type') as 'group' | 'single' | undefined, + dateAfter: stringFlag(ctx.flags, 'after'), + dateBefore: stringFlag(ctx.flags, 'before'), + excludeLowPriority: ctx.flags['exclude-low-priority'], + includeMuted: ctx.flags['include-muted'], + mediaTypes: mediaTypes.length ? mediaTypes : undefined, + query: ctx.args[0], + sender: stringFlag(ctx.flags, 'sender') as 'me' | 'others' | (string & {}) | undefined, + }), numberFlag(ctx.flags, 'limit', 50)) + if (!items.length && ctx.flags['fail-empty']) { + throw new AbortError('No messages matched the query or filters.', ExitCodes.EmptyResults, undefined, 'empty_results') + } + return ctx.flags.ids ? ids(items.map(apiRecord), 'messageID') : items +} + +async function apiCommand(ctx: CommandContext): Promise { + const method = String(ctx.args[0] ?? '').toUpperCase() + const path = ctx.args[1] + if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) throw usage('api request method must be one of: GET, POST, PUT, PATCH, DELETE') + if (!path) throw usage('api request requires path') + const body = method === 'GET' ? undefined : jsonBody(ctx) + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'api.request', request: { body, method, noAuth: ctx.flags['no-auth'], path, target: ctx.globalFlags.target } } + return appRequest(method, path, { body, target: ctx.globalFlags.target, token: ctx.flags['no-auth'] ? false : undefined }) +} + +async function sendTextLike(ctx: CommandContext): Promise { + const kind = ctx.commandPath[1] + if (kind !== 'file' && kind !== 'sticker' && kind !== 'text' && kind !== 'voice') throw usage(`Unsupported send command: ${ctx.commandPath.join(' ')}`) + const to = stringFlag(ctx.flags, 'to')! + const payload = await sendPayload(ctx, kind) + if (ctx.globalFlags.dryRun) return { dry_run: true, op: `send.${kind}`, request: { chat: to, ...payload } } + + const client = await apiClient(ctx) + const chatID = await resolveChatID(client, to, chatResolutionOptions(ctx)) + return sendMessage(client, { ...payload, chatID }) +} + +async function sendMessage(client: any, options: SendPayload & { + chatID: string +}): Promise> { + const uploaded = options.file + ? await client.assets.upload({ + file: createReadStream(options.file), + fileName: options.fileName, + mimeType: options.mimeType, + }) + : undefined + + if (options.file && !uploaded?.uploadID) throw new Error('Upload did not return an uploadID') + + const pending = await client.messages.send(options.chatID, { + attachment: uploaded?.uploadID + ? { + uploadID: uploaded.uploadID, + type: options.attachmentType, + duration: options.duration ?? uploaded.duration, + fileName: uploaded.fileName, + mimeType: options.mimeType ?? uploaded.mimeType, + size: uploaded.width && uploaded.height ? { height: uploaded.height, width: uploaded.width } : undefined, + } + : undefined, + replyToMessageID: options.replyTo, + text: options.text, + mentions: options.mentions?.length ? options.mentions : undefined, + disableLinkPreview: options.noPreview || undefined, + }) + + if (!options.wait) { + return { + ...pending, + accepted: true, + state: 'accepted', + chatID: options.chatID, + hint: 'Desktop accepted the send request. Pass --wait to wait for the final message or failure.', + } + } + return { + accepted: true, + state: 'resolved', + chatID: options.chatID, + pendingMessageID: pending.pendingMessageID, + message: await waitForMessage(client, options.chatID, pending.pendingMessageID, options.waitTimeoutMs), + } +} + +async function waitForMessage(client: any, chatID: string, pendingMessageID: string, timeoutMs = 30_000): Promise { + const started = Date.now() + let lastError: unknown + while (Date.now() - started < timeoutMs) { + try { + return await client.messages.retrieve(pendingMessageID, { chatID }) + } catch (error) { + lastError = error + await sleep(750) + } + } + throw new Error(`Timed out waiting for ${pendingMessageID}${lastError instanceof Error ? `: ${lastError.message}` : ''}`) +} + +async function sendReact(ctx: CommandContext): Promise { + const id = stringFlag(ctx.flags, 'id')! + const reaction = stringFlag(ctx.flags, 'reaction')! + const transactionID = stringFlag(ctx.flags, 'transaction') + const remove = Boolean(ctx.flags.remove) + if (remove && transactionID) throw usage('--transaction cannot be combined with --remove') + if (ctx.globalFlags.dryRun) { + return { dry_run: true, op: 'send.react', request: { chat: stringFlag(ctx.flags, 'to'), messageID: id, pick: ctx.flags.pick, reactionKey: reaction, remove, transactionID } } + } + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'to') + if (remove) return client.chats.messages.reactions.delete(reaction, { chatID, messageID: id }) + return client.chats.messages.reactions.add(id, { chatID, reactionKey: reaction, transactionID }) +} + +async function authLogout(ctx: CommandContext): Promise> { + const target = await resolveTarget({ target: ctx.globalFlags.target }) + const token = target.auth?.accessToken + if (ctx.globalFlags.dryRun) { + return { dry_run: true, op: 'auth.logout', request: { baseURL: target.baseURL, hadToken: Boolean(token), revokeToken: Boolean(token), target: target.id } } + } + if (process.env.BEEPER_ACCESS_TOKEN && !target.auth?.accessToken) { + throw usage('auth logout cannot clear BEEPER_ACCESS_TOKEN from the environment; unset it in the calling process.') + } + let revoked = false + if (token) { + const response = await fetch(new URL('/oauth/revoke', target.baseURL), { + body: new URLSearchParams({ token, token_type_hint: 'access_token' }), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST', + signal: AbortSignal.timeout(5000), + }).catch(() => undefined) + revoked = Boolean(response?.ok) + await writeTarget({ ...target, auth: undefined }) + } + return { hadToken: Boolean(token), loggedOut: true, revoked } +} + +async function authEmailStart(ctx: CommandContext): Promise { + const target = await resolveTarget({ target: ctx.globalFlags.target }) + const email = stringFlag(ctx.flags, 'email')! + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'auth.email.start', request: { email, target: target.id } } + return startEmailSetup(target, email) +} + +async function authEmailResponse(ctx: CommandContext): Promise { + const target = await resolveTarget({ target: ctx.globalFlags.target }) + const code = stringFlag(ctx.flags, 'code')! + const setupRequestID = stringFlag(ctx.flags, 'setup-request-id')! + if (ctx.globalFlags.dryRun) { + return { dry_run: true, op: 'auth.email.response', request: { baseURL: target.baseURL, force: ctx.globalFlags.force, setupRequestID, target: target.id, username: stringFlag(ctx.flags, 'username') } } + } + return finishEmailSetup(target, { + code, + json: ctx.globalFlags.json, + setupRequestID, + username: stringFlag(ctx.flags, 'username'), + force: ctx.globalFlags.force, + }) +} + +async function apiClient(ctx: CommandContext): Promise { + const target = await resolveTarget({ target: ctx.globalFlags.target }) + return new BeeperDesktop({ + accessToken: await targetToken(target, target.id === 'desktop'), + baseURL: target.baseURL, + logLevel: ctx.globalFlags.debug ? 'debug' : 'warn', + }) +} + +async function targetToken(target: Target, scan?: boolean): Promise { + const token = process.env.BEEPER_ACCESS_TOKEN || target.auth?.accessToken + if (token) return token + const auth = authFromToken( + await authorizeTarget({ baseURL: target.baseURL, scan }), + target.type === 'remote' ? 'remote-oauth' : 'desktop-oauth', + ) + await writeTarget({ ...target, auth }) + return auth.accessToken +} + +function jsonBody(ctx: CommandContext): Record { + const raw = stringFlag(ctx.flags, 'body') ?? '{}' + try { + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('body must be a JSON object') + return parsed as Record + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw usage(`--body is not valid JSON: ${detail}`) + } +} + +async function sendPayload(ctx: CommandContext, kind: SendKind): Promise { + if (kind === 'text') { + const message = await messageText(ctx) + return { + mentions: stringListFlag(ctx.flags, 'mention'), + noPreview: Boolean(ctx.flags['no-preview']), + replyTo: stringFlag(ctx.flags, 'reply-to'), + text: message, + wait: Boolean(ctx.flags.wait), + waitTimeoutMs: numberFlag(ctx.flags, 'wait-timeout', 30_000), + } + } + const file = stringFlag(ctx.flags, 'file')! + const attachmentType: AttachmentType | undefined = kind === 'sticker' ? 'sticker' : kind === 'voice' ? 'voice-note' : undefined + return { + attachmentType, + duration: kind === 'voice' ? numberFlag(ctx.flags, 'duration', 0) || undefined : undefined, + file, + fileName: stringFlag(ctx.flags, 'filename'), + mimeType: stringFlag(ctx.flags, 'mime') ?? (kind === 'sticker' ? 'image/webp' : kind === 'voice' ? 'audio/ogg' : undefined), + replyTo: stringFlag(ctx.flags, 'reply-to'), + text: kind === 'file' ? stringFlag(ctx.flags, 'caption') ?? '' : '', + wait: Boolean(ctx.flags.wait), + waitTimeoutMs: numberFlag(ctx.flags, 'wait-timeout', 30_000), + } +} + +async function messageText(ctx: CommandContext): Promise { + const literal = stringFlag(ctx.flags, 'message') + const file = stringFlag(ctx.flags, 'message-file') + if (literal && file) throw usage('--message and --message-file cannot be combined') + if (file) return file === '-' ? await readStdin() : readFile(file, 'utf8') + if (literal !== undefined) return ctx.flags['message-escapes'] ? decodeEscapes(literal) : literal + throw usage('send text requires --message or --message-file') +} + +async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + return Buffer.concat(chunks).toString('utf8') +} + +function decodeEscapes(value: string): string { + return value.replaceAll(/\\([nrt\\"])/g, (_, escaped: string) => { + if (escaped === 'n') return '\n' + if (escaped === 'r') return '\r' + if (escaped === 't') return '\t' + return escaped + }) +} + +async function sendPresence(ctx: CommandContext): Promise { + const state = (stringFlag(ctx.flags, 'state') ?? 'typing') as 'typing' | 'paused' + const duration = ctx.flags.duration === undefined ? undefined : numberFlag(ctx.flags, 'duration', 0) + if (duration !== undefined && duration <= 0) throw usage('--duration must be a positive integer') + if (duration !== undefined && state !== 'typing') throw usage('--duration only applies when --state is typing') + const to = stringFlag(ctx.flags, 'to')! + if (ctx.globalFlags.dryRun) return { dry_run: true, op: 'send.presence', request: { chat: to, durationSeconds: duration, pick: ctx.flags.pick, state } } + + const client = await apiClient(ctx) + const chatID = await chatIDFromFlag(client, ctx, 'to') + const post = (nextState: 'typing' | 'paused') => + client.post(`/v1/chats/${encodeURIComponent(chatID)}/typing`, { body: { state: nextState } }) + await post(state) + if (duration !== undefined) { + await sleep(duration * 1000) + await post('paused') + return { chatID, durationSeconds: duration, sent: true, state: 'paused' } + } + return { chatID, sent: true, state } +} + +async function chatIDFromFlag(client: any, ctx: CommandContext, name: 'chat' | 'to'): Promise { + return resolveChatID(client, stringFlag(ctx.flags, name)!, chatResolutionOptions(ctx)) +} + +function chatResolutionOptions(ctx: CommandContext, accountIDs?: string[]): { accountIDs?: string[]; noInput?: boolean; pick?: number } { + return { accountIDs, noInput: ctx.globalFlags.noInput, pick: numberFlag(ctx.flags, 'pick', 0) || undefined } +} + +async function defaultAccountID(client: any): Promise { + const accountIDs = await listAccountIDs(client) + if (accountIDs.includes('matrix')) return 'matrix' + if (accountIDs.length === 1 && accountIDs[0]) return accountIDs[0] + throw usage('Use --account to choose which account should start the chat.') +} + +function parseDurationMs(value?: string): number | undefined { + if (!value) return undefined + const match = value.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/i) + if (!match) throw usage(`Invalid duration "${value}". Use values like 500ms, 30s, or 2m.`) + const amount = Number(match[1]) + const unit = (match[2] ?? 'ms').toLowerCase() + if (unit === 'ms') return amount + if (unit === 's') return amount * 1000 + if (unit === 'm') return amount * 60_000 + return amount +} + +async function waitForTunnelExit(started: StartedTunnel): Promise<{ code: number | null; reason: 'process' | 'signal' }> { + return new Promise(resolve => { + const finish = () => { + started.stop() + resolve({ code: 0, reason: 'signal' }) + } + process.once('SIGINT', finish) + process.once('SIGTERM', finish) + started.done.then(({ code }) => { + process.off('SIGINT', finish) + process.off('SIGTERM', finish) + resolve({ code, reason: 'process' }) + }) + }) +} + +function writeWatchEvent(body: string, raw: boolean): void { + if (raw) { + process.stdout.write(`${body}\n`) + return + } + try { + const parsed = JSON.parse(body) as Record + process.stdout.write([ + String(parsed.type ?? 'event'), + parsed.chatID ? `chat=${parsed.chatID}` : undefined, + parsed.messageID ? `message=${parsed.messageID}` : undefined, + String(parsed.timestamp ?? new Date().toISOString()), + ].filter(Boolean).join('\t') + '\n') + } catch { + process.stdout.write(`raw\t${new Date().toISOString()}\n`) + } +} + +function passesFilter(body: string, filter?: EventFilter): boolean { + if (!filter || (!filter.include && !filter.exclude)) return true + let type: string | undefined + try { + const parsed = JSON.parse(body) as { type?: unknown } + if (typeof parsed.type === 'string') type = parsed.type + } catch { + return true + } + if (!type) return true + if (filter.include && !filter.include.has(type)) return false + if (filter.exclude && filter.exclude.has(type)) return false + return true +} + +function forwardWebhook(webhook: WebhookConfig, body: string, events: boolean): void { + if (webhook.inflight + webhook.queue.length >= webhook.max) { + if (events) writeEvent('watch.webhook_drop', { reason: 'queue_full', size: webhook.queue.length }) + process.stderr.write(`warning: webhook queue full (${webhook.max}); dropped event\n`) + return + } + const signature = webhook.secret ? `sha256=${createHmac('sha256', webhook.secret).update(body).digest('hex')}` : undefined + webhook.queue.push({ body, signature }) + void drainWebhook(webhook, events) +} + +async function drainWebhook(webhook: WebhookConfig, events: boolean): Promise { + while (webhook.queue.length > 0) { + const item = webhook.queue.shift()! + webhook.inflight += 1 + try { + const headers: Record = { 'content-type': 'application/json' } + if (item.signature) headers['x-beeper-signature'] = item.signature + const response = await fetch(webhook.url, { body: item.body, headers, method: 'POST', signal: AbortSignal.timeout(10_000) }) + if (!response.ok) { + if (events) writeEvent('watch.webhook_error', { status: response.status }) + process.stderr.write(`warning: webhook POST ${webhook.url} returned ${response.status}\n`) + } + } catch (error) { + if (events) writeEvent('watch.webhook_error', { message: (error as Error).message }) + process.stderr.write(`warning: webhook POST failed: ${(error as Error).message}\n`) + } finally { + webhook.inflight -= 1 + } + } +} + +async function chooseBridge(items: Record[]): Promise { + const available = items.filter(item => String(item.status ?? 'available') === 'available') + if (!available.length) throw usage('No available bridges to connect.') + output.write('Choose a bridge to connect an account:\n') + available.forEach((item, index) => { + const id = String(item.id) + const name = String(item.displayName ?? item.name ?? id) + const multiple = item.supportsMultipleAccounts ? 'multiple allowed' : 'single account' + output.write(` ${index + 1}. ${name} (${id}) - ${multiple}\n`) + }) + return promptChoice('Select a bridge: ', available.map(item => String(item.id)), { output }) +} + +function printAvailableBridges(items: Record[]): void { + const sections: Array<[string, Record[]]> = [ + ['On-Device Accounts', items.filter(item => item.provider === 'local')], + ['Beeper Cloud Accounts', items.filter(item => item.provider === 'cloud')], + ['Self-Hosted Accounts', items.filter(item => item.provider === 'self-hosted')], + ] + output.write('Choose a bridge to connect an account:\n\n') + for (const [title, bridges] of sections) { + if (!bridges.length) continue + output.write(`${title}\n`) + for (const bridge of bridges) { + const id = String(bridge.id) + const name = String(bridge.displayName ?? bridge.name ?? id) + const state = String(bridge.status ?? 'available') + const status = bridge.statusText ?? (state === 'available' ? undefined : state === 'connected' ? `${name} Connected` : state.replaceAll('_', ' ')) + const multiple = bridge.supportsMultipleAccounts ? 'multiple allowed' : 'single account' + output.write(` ${name} (${id}) - ${multiple}${status ? ` - ${String(status)}` : ''}\n`) + if (String(bridge.status ?? 'available') === 'available') output.write(` beeper accounts add ${id}\n`) + } + output.write('\n') + } +} + +function resolveBridgeChoice(items: Record[], input: string): Record { + const keys = (item: Record) => [item.id, item.displayName, item.name, item.network, item.provider, item.type] + const normalized = normalizeSelector(input) + const exact = items.filter(item => keys(item).some(value => normalizeSelector(value) === normalized)) + if (exact.length === 1) return exact[0]! + if (exact.length > 1) throw ambiguousBridge(input, exact) + const partial = items.filter(item => keys(item).some(value => normalizeSelector(value).includes(normalized))) + if (partial.length === 1) return partial[0]! + if (partial.length > 1) throw ambiguousBridge(input, partial) + throw usage(`Unknown bridge "${input}". Run "beeper resolve bridge ${input}" to inspect matches.`) +} + +function ambiguousBridge(input: string, matches: Record[]): Error { + return usage(`Bridge "${input}" is ambiguous. Use one of: ${matches.map(item => `${String(item.displayName ?? item.name ?? item.id)} (${String(item.id)})`).join(', ')}`) +} + +function parseKeyValueFlags(values: string[], flagName: string): Record { + const parsed: Record = {} + for (const value of values) { + const equalsIndex = value.indexOf('=') + if (equalsIndex <= 0) throw usage(`${flagName} must use name=value form.`) + parsed[value.slice(0, equalsIndex)] = value.slice(equalsIndex + 1) + } + return parsed +} + +async function chooseLoginFlow(flows: Record[]): Promise { + output.write('Choose how you want to sign in:\n') + flows.forEach((flow, index) => { + const description = flow.description ? ` - ${String(flow.description)}` : '' + output.write(` ${index + 1}. ${String(flow.name ?? flow.id)}${description}\n`) + }) + return promptChoice('Select a sign-in method: ', flows.map(flow => String(flow.id)), { output }) +} + +function ids(items: Record[], preferred: string): string[] { + return items + .map(item => item[preferred] ?? item.localChatID ?? item.rowID ?? item.id ?? item.chatID ?? item.messageID ?? item.accountID ?? item.userID) + .filter((value): value is string | number => typeof value === 'string' || typeof value === 'number') + .map(String) +} + +function resolution(ctx: CommandContext, kind: string, selector: string, candidates: Record[]): Record { + if (!candidates.length) { + throw new AbortError(`No ${kind} matches "${selector}"`, ExitCodes.NotFound, undefined, 'not_found') + } + const pick = numberFlag(ctx.flags, 'pick', 0) + const selected = pick ? candidates[pick - 1] : candidates.length === 1 ? candidates[0] : undefined + if (pick && !selected) { + throw new AbortError(`--pick ${pick} is outside the ${candidates.length} matching ${kind}s`, ExitCodes.NotFound, undefined, 'not_found') + } + return { + candidates: candidates.map((candidate, index) => ({ pick: index + 1, ...candidate })), + kind, + selected: selected ? { pick: candidates.indexOf(selected) + 1, ...selected } : null, + selector, + } +} + +function ignorableLookupError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const shaped = error as Error & { status?: number; statusCode?: number } + const status = shaped.status ?? shaped.statusCode + return status === 400 || status === 404 || /\b(400|404)\b|not supported|not found/i.test(error.message) +} + +function matchesChatFilters(row: Record, ctx: CommandContext): boolean { + if (ctx.flags.archived !== undefined && Boolean(row.isArchived) !== ctx.flags.archived) return false + if (ctx.flags.pinned !== undefined && Boolean(row.isPinned) !== ctx.flags.pinned) return false + if (ctx.flags.muted !== undefined && Boolean(row.isMuted) !== ctx.flags.muted) return false + if (ctx.flags['low-priority'] !== undefined && Boolean(row.isLowPriority) !== ctx.flags['low-priority']) return false + if (ctx.flags.unread !== undefined) { + const unread = Number(row.unreadCount ?? 0) > 0 || Boolean(row.isMarkedUnread) + if (unread !== ctx.flags.unread) return false + } + return true +} + +async function collectMessages(iterable: AsyncIterable, limit: number, sender?: string): Promise { + if (!sender) return collectPage(iterable, limit) + const items: unknown[] = [] + for await (const item of iterable) { + if (matchesSender(item, sender)) items.push(item) + if (items.length >= limit) break + } + return items +} + +function matchesSender(item: unknown, sender: string): boolean { + if (!item || typeof item !== 'object') return false + const row = item as { isSender?: boolean; senderID?: string } + if (sender === 'me') return row.isSender === true + if (sender === 'others') return row.isSender !== true + return row.senderID === sender +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function completionScript(shell: string): string { + const command = 'beeper' + if (shell === 'bash') { + return [ + '_beeper_complete() {', + ' local completions', + ' completions=$(beeper __complete --cword "$COMP_CWORD" -- "${COMP_WORDS[@]}")', + ' COMPREPLY=( $completions )', + '}', + `complete -F _beeper_complete ${command}`, + '', + ].join('\n') + } + if (shell === 'zsh') { + return [ + '#compdef beeper', + '_beeper() {', + ' local -a completions', + ' completions=("${(@f)$(beeper __complete --cword "$((CURRENT - 1))" -- "${words[@]}")}")', + ' _describe "values" completions', + '}', + '_beeper "$@"', + '', + ].join('\n') + } + if (shell === 'fish') { + return `complete -c ${command} -f -a '(beeper __complete --cword (commandline -t | wc -w) -- (commandline -opc))'\n` + } + if (shell === 'powershell' || shell === 'pwsh') { + return [ + `Register-ArgumentCompleter -Native -CommandName ${command} -ScriptBlock {`, + ' param($wordToComplete, $commandAst, $cursorPosition)', + ' $words = $commandAst.ToString().Split(" ", [System.StringSplitOptions]::RemoveEmptyEntries)', + ' $cword = [Math]::Max(0, $words.Length - 1)', + ' beeper __complete --cword $cword -- $words | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_) }', + '}', + '', + ].join('\n') + } + throw usage('completion shell must be one of: bash, zsh, fish, powershell') +} + +function completeWords(words: string[], cword: number): string[] { + const index = normalizeCword(cword, words.length) + const start = isProgramName(words[0]) ? 1 : 0 + if (index < start) return [] + const current = index < words.length ? words[index] ?? '' : '' + const consumed = words.slice(start, Math.min(index, words.length)) + if (consumed.includes('--')) return [] + const node = completionNode(consumed) + if (!node || previousFlagNeedsValue(node.flags, words, index)) return [] + const flags = node.flags + const children = node.children + const suggestions = current.startsWith('-') + ? matching([...flags], current) + : matching([...children, ...flags], current) + return [...new Set(suggestions)].sort() +} + +function completionNode(consumed: string[]): { children: string[]; command?: CommandSpec; flags: string[] } | undefined { + let candidates = commands.filter(command => !command.hidden) + let depth = 0 + for (const word of consumed) { + if (word.startsWith('-')) continue + const next = candidates.filter(command => commandPathVariants(command).some(path => path[depth] === word)) + if (!next.length) break + candidates = next + depth += 1 + } + const exact = candidates.find(command => commandPathVariants(command).some(path => path.length === depth)) + const children = new Set() + for (const command of candidates) { + for (const path of commandPathVariants(command)) { + const part = path[depth] + if (part) children.add(part) + } + } + return { + children: [...children], + command: exact, + flags: flagTokens([...(exact?.flags ?? []), ...globalFlagSpecs]), + } +} + +function commandPathVariants(command: CommandSpec): string[][] { + return [command.path, ...(command.aliases ?? [])] +} + +function flagTokens(flags: FlagSpec[]): string[] { + return flags.flatMap(flag => [ + `--${flag.name}`, + flag.short ? `-${flag.short}` : undefined, + ...(flag.aliases ?? []).map(alias => `--${alias}`), + flag.type === 'boolean' ? `--no-${flag.name}` : undefined, + ]).filter((value): value is string => Boolean(value)) +} + +function previousFlagNeedsValue(flags: string[], words: string[], cword: number): boolean { + const previous = words[cword - 1] + if (!previous?.startsWith('-') || previous.includes('=')) return false + const spec = [...globalFlagSpecs, ...commands.flatMap(command => command.flags ?? [])] + .find(flag => [`--${flag.name}`, flag.short ? `-${flag.short}` : undefined, ...(flag.aliases ?? []).map(alias => `--${alias}`)].includes(previous)) + return Boolean(spec && spec.type !== 'boolean' && flags.includes(previous)) +} + +function matching(values: string[], prefix: string): string[] { + return values.filter(value => value.startsWith(prefix)) +} + +function normalizeCword(cword: number, count: number): number { + if (cword < 0) return Math.max(0, count - 1) + return Math.min(cword, count) +} + +function isProgramName(word?: string): boolean { + return !word || word === 'beeper' || word.endsWith('/beeper') || word.endsWith('/dev.js') || word.endsWith('/cli.js') +} + +async function packageInfo(): Promise> { + const root = dirname(dirname(dirname(fileURLToPath(import.meta.url)))) + return JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as Record +} diff --git a/packages/cli/src/cli/main.ts b/packages/cli/src/cli/main.ts new file mode 100644 index 00000000..c2edc1a2 --- /dev/null +++ b/packages/cli/src/cli/main.ts @@ -0,0 +1,84 @@ +import { AbortError, ExitCodes } from '../lib/errors.js' +import { commands, commandHelp, help } from './commands.js' +import { enforcePolicy } from './policy.js' +import { parseCommand } from './parse.js' +import { usage, writeError, writeResult } from './output.js' + +export async function runCli(argv = process.argv.slice(2)): Promise { + let parsed: ReturnType | undefined + try { + parsed = parseCommand(argv, commands) + if (parsed.globalFlags.json && parsed.globalFlags.plain) throw usage('cannot combine --json and --plain') + applyGlobalEnvironment(parsed.globalFlags) + if (parsed.helpOnly) { + process.stdout.write(help(parsed.globalFlags)) + return + } + if (!parsed.command) throw new Error('missing command') + if (parsed.flags.help) { + process.stdout.write(commandHelp(parsed.command, parsed.globalFlags)) + return + } + enforcePolicy(parsed.command, parsed.globalFlags) + const flags = commandFlags(parsed.command, parsed.flags, parsed.globalFlags) + const { command, globalFlags, positionals } = parsed + const result = await runWithTimeout(() => command.run({ + args: positionals, + commandPath: command.path, + flags, + globalFlags, + }), globalFlags.timeout) + writeResult(result, globalFlags, command) + } catch (error) { + const flags = parsed?.globalFlags ?? { events: argv.includes('--events'), json: argv.includes('--json') } + process.exitCode = writeError(error, flags) || ExitCodes.Generic + } +} + +async function runWithTimeout(run: () => Promise, timeout?: string): Promise { + const ms = parseDuration(timeout) + const promise = run() + if (!ms) return promise + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new AbortError(`command timed out after ${timeout}`, ExitCodes.Generic, undefined, 'timeout')), ms) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +function parseDuration(value: string | undefined): number | undefined { + if (!value) return undefined + const match = /^(\d+)(ms|s|m|h)?$/.exec(value.trim()) + if (!match) throw usage('--timeout must be a duration like 500ms, 30s, 2m, or 1h') + const amount = Number(match[1]) + if (!Number.isSafeInteger(amount) || amount <= 0) throw usage('--timeout must be greater than 0') + const unit = match[2] ?? 'ms' + const factor = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1_000 : 1 + return amount * factor +} + +function applyGlobalEnvironment(flags: { accessToken?: string; home?: string }): void { + if (flags.home) process.env.BEEPER_CLI_CONFIG_DIR = flags.home + if (flags.accessToken) process.env.BEEPER_ACCESS_TOKEN = flags.accessToken +} + +function commandFlags( + command: { flags?: Array<{ multiple?: boolean; name: string }> }, + flags: Record, + globalFlags: { account?: string[] }, +): Record { + const accountSpec = command.flags?.find(flag => flag.name === 'account') + if (!accountSpec || !globalFlags.account?.length) return flags + const current = flags.account + if (accountSpec.multiple) { + const local = Array.isArray(current) ? current.map(String) : typeof current === 'string' ? [current] : [] + return { ...flags, account: [...globalFlags.account, ...local] } + } + return current === undefined ? { ...flags, account: globalFlags.account[0] } : flags +} diff --git a/packages/cli/src/cli/mcp.ts b/packages/cli/src/cli/mcp.ts new file mode 100644 index 00000000..23736fbb --- /dev/null +++ b/packages/cli/src/cli/mcp.ts @@ -0,0 +1,178 @@ +import type { CommandSpec, FlagSpec, GlobalFlags } from './types.js' +import { enforcePolicy } from './policy.js' +import { wrapUntrusted } from './output.js' +import { parseFlagValue, validateCommandInput } from './parse.js' + +type JsonRpcRequest = { + id?: number | string + method?: string + params?: Record +} + +type McpOptions = { + allowTools: string[] + allowWrite: boolean + listTools: boolean + maxOutputBytes: number + timeoutSeconds: number +} + +export async function serveMcp(commands: CommandSpec[], flags: GlobalFlags, options: McpOptions, version: string): Promise { + const tools = mcpCommands(commands, options) + if (options.listTools) { + process.stdout.write(`${JSON.stringify(mcpTools(tools), null, 2)}\n`) + return + } + const buffer: string[] = [] + process.stdin.setEncoding('utf8') + for await (const chunk of process.stdin) { + buffer.push(String(chunk)) + let joined = buffer.join('') + let index = joined.indexOf('\n') + while (index !== -1) { + const line = joined.slice(0, index).trim() + joined = joined.slice(index + 1) + if (line) await handleLine(tools, flags, options, version, line) + index = joined.indexOf('\n') + } + buffer.length = 0 + if (joined) buffer.push(joined) + } + const finalLine = buffer.join('').trim() + if (finalLine) await handleLine(tools, flags, options, version, finalLine) +} + +function mcpTools(commands: CommandSpec[]): Record[] { + return commands + .filter(command => command.mcp) + .map(command => ({ + description: command.description, + inputSchema: { + additionalProperties: false, + properties: Object.fromEntries([ + ...(command.args ?? []).map(arg => [arg.name, { description: arg.description, type: 'string' }]), + ...(command.flags ?? []).map(flag => [flag.name, inputSchemaForFlag(flag)]), + ]), + required: [ + ...(command.args ?? []).filter(arg => arg.required).map(arg => arg.name), + ...(command.flags ?? []).filter(flag => flag.required).map(flag => flag.name), + ], + type: 'object', + }, + name: command.path.join('_'), + })) +} + +async function handleLine(commands: CommandSpec[], flags: GlobalFlags, options: McpOptions, version: string, line: string): Promise { + let request: JsonRpcRequest = {} + try { + request = JSON.parse(line) as JsonRpcRequest + if (request.method === 'initialize') { + respond(request.id, { capabilities: { tools: {} }, protocolVersion: '2024-11-05', serverInfo: { name: 'beeper', version } }) + return + } + if (request.method === 'tools/list') { + respond(request.id, { tools: mcpTools(commands) }) + return + } + if (request.method === 'tools/call') { + const name = String(request.params?.name ?? '') + const tool = commands.find(command => command.mcp && command.path.join('_') === name) + if (!tool) throw new Error(`unknown MCP tool: ${name}`) + if (tool.risk !== 'read' && !options.allowWrite) throw new Error(`MCP tool "${name}" requires mcp --allow-write`) + const args = request.params?.arguments && typeof request.params.arguments === 'object' + ? request.params.arguments as Record + : {} + const globalFlags = { ...flags, json: true, wrapUntrusted: true } + const positionals = positionalsFor(tool, args) + const toolFlags = flagsFor(tool, args) + validateCommandInput(tool, toolFlags, positionals) + enforcePolicy(tool, globalFlags) + const result = await withTimeout(options.timeoutSeconds, () => tool.run({ args: positionals, commandPath: tool.path, flags: toolFlags, globalFlags })) + const structured = { + exit_code: 0, + risk: tool.risk, + service: tool.path[0], + stdout: wrapUntrusted(result), + stderr: '', + tool: tool.path.join('_'), + } + respond(request.id, { content: [{ text: truncate(JSON.stringify(structured), options.maxOutputBytes), type: 'text' }], structuredContent: structured }) + return + } + if (request.id !== undefined) respond(request.id, {}) + } catch (error) { + respondError(request.id, error instanceof Error ? error.message : String(error)) + } +} + +function mcpCommands(commands: CommandSpec[], options: McpOptions): CommandSpec[] { + return commands + .filter(command => command.mcp) + .filter(command => options.allowWrite || command.risk === 'read') + .filter(command => !options.allowTools.length || options.allowTools.some(pattern => toolMatches(command, pattern))) +} + +function toolMatches(command: CommandSpec, pattern: string): boolean { + const normalized = pattern.trim().toLowerCase().replaceAll(/\s+/g, '.').replaceAll('_', '.') + if (!normalized || normalized === '*' || normalized === 'all') return true + const dotted = command.path.join('.') + const underscored = command.path.join('_') + return normalized === command.risk || normalized === command.path[0] || normalized === dotted || normalized === underscored || (normalized.endsWith('.*') && dotted.startsWith(normalized.slice(0, -2) + '.')) +} + +async function withTimeout(seconds: number, run: () => Promise): Promise { + if (seconds <= 0) throw new Error('--timeout-seconds must be greater than zero') + let timeout: NodeJS.Timeout | undefined + try { + return await Promise.race([ + run(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`MCP tool timed out after ${seconds}s`)), seconds * 1000) + }), + ]) + } finally { + if (timeout) clearTimeout(timeout) + } +} + +function truncate(value: string, maxBytes: number): string { + if (maxBytes <= 0) return value + return Buffer.byteLength(value) <= maxBytes ? value : `${value.slice(0, maxBytes)}...` +} + +function inputSchemaForFlag(flag: FlagSpec): Record { + const schema = { + description: flag.description, + enum: flag.enum, + type: flag.type === 'integer' ? 'integer' : flag.type, + } + return flag.multiple ? { description: flag.description, items: schema, type: 'array' } : schema +} + +function positionalsFor(command: CommandSpec, input: Record): string[] { + return (command.args ?? []) + .map(arg => input[arg.name]) + .filter(value => value !== undefined) + .map(String) +} + +function flagsFor(command: CommandSpec, input: Record): Record { + const flags: Record = {} + for (const flag of command.flags ?? []) { + const value = input[flag.name] ?? flag.default + if (value === undefined) continue + flags[flag.name] = flag.multiple + ? (Array.isArray(value) ? value : [value]).map(item => parseFlagValue(flag, item)) + : parseFlagValue(flag, value) + } + return flags +} + +function respond(id: JsonRpcRequest['id'], result: unknown): void { + process.stdout.write(`${JSON.stringify({ id, jsonrpc: '2.0', result })}\n`) +} + +function respondError(id: JsonRpcRequest['id'], message: string): void { + process.stdout.write(`${JSON.stringify({ error: { code: -32000, message }, id, jsonrpc: '2.0' })}\n`) +} diff --git a/packages/cli/src/cli/output.ts b/packages/cli/src/cli/output.ts new file mode 100644 index 00000000..7b6db756 --- /dev/null +++ b/packages/cli/src/cli/output.ts @@ -0,0 +1,303 @@ +import { AbortError, CLIError, ExitCodes } from '../lib/errors.js' +import type { CommandSpec, GlobalFlags } from './types.js' + +type ErrorShape = { + code: string + exitCode: number + hint?: string + kind: 'abort' | 'bug' + message: string +} + +export function writeResult(value: unknown, flags: GlobalFlags, command?: CommandSpec): void { + if (value === undefined) return + if (flags.json) { + const selected = flags.select ? selectFields(value, flags.select) : value + const result = flags.resultsOnly ? primaryResult(selected) : selected + const data = flags.wrapUntrusted ? wrapUntrusted(result) : result + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`) + } else writeText(value, flags.plain, command, flags.full) +} + +export function writeEvent(event: string, data: Record = {}): void { + process.stderr.write(`${JSON.stringify({ event, data, ts: Date.now() })}\n`) +} + +function formatError(error: unknown): ErrorShape { + const err = normalizeError(error) + const isBug = !(err instanceof CLIError) + const exitCode = err instanceof CLIError ? err.exitCode : ExitCodes.Generic + return { + code: err instanceof CLIError && err.code ? err.code : errorCode(exitCode, isBug), + exitCode, + hint: err instanceof CLIError ? err.tryMessage : undefined, + kind: isBug ? 'bug' : 'abort', + message: err.message, + } +} + +export function writeError(error: unknown, flags: Pick): number { + const formatted = formatError(error) + if (flags.events) { + writeEvent('error', formatted) + return formatted.exitCode + } + if (flags.json) { + process.stderr.write(`${JSON.stringify({ error: formatted })}\n`) + return formatted.exitCode + } + process.stderr.write(`${sanitizeHuman(formatted.message)}\n`) + if (formatted.hint) process.stderr.write(`hint: ${sanitizeHuman(formatted.hint)}\n`) + return formatted.exitCode +} + +export function usage(message: string): AbortError { + return new AbortError(message, ExitCodes.Usage, undefined, 'usage_error') +} + +function sanitizeHuman(value: string): string { + let out = '' + let inEscape = false + for (const char of value) { + const code = char.charCodeAt(0) + if (inEscape) { + if (code >= 0x40 && code <= 0x7e) inEscape = false + continue + } + if (char === '\x1b') { + inEscape = true + if (!out.endsWith(' ')) out += ' ' + continue + } + if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) { + if (!out.endsWith(' ')) out += ' ' + continue + } + out += char + } + return out.trim() +} + +function normalizeError(error: unknown): Error { + if (error instanceof Error) return error + return new Error(String(error)) +} + +function errorCode(code: number, isBug: boolean): string { + if (isBug) return 'internal_error' + if (code === ExitCodes.EmptyResults) return 'empty_results' + if (code === ExitCodes.AuthRequired) return 'auth_required' + if (code === ExitCodes.CommandNotFound) return 'command_not_found' + if (code === ExitCodes.NotFound) return 'not_found' + if (code === ExitCodes.NotReady) return 'not_ready' + if (code === ExitCodes.Usage) return 'usage_error' + return 'runtime_error' +} + +function writeText(value: unknown, plain = false, command?: CommandSpec, full = false): void { + if (value === undefined) return + if (command) { + const handled = writeCommandText(value, plain, command, full) + if (handled) return + } + if (Array.isArray(value)) { + if (value.every(isRecord)) { + writeTable(value, Object.keys(value[0] ?? {}).slice(0, 8), plain, full) + return + } + for (const item of value) writeText(item, plain, undefined, full) + return + } + if (!value || typeof value !== 'object') { + process.stdout.write(`${String(value ?? '')}\n`) + return + } + for (const [key, item] of Object.entries(value as Record)) { + if (item === undefined) continue + const cell = humanCell(item) + process.stdout.write(plain ? `${key}\t${cell.replaceAll('\n', '\\n').replaceAll('\t', '\\t')}\n` : `${key}: ${cell}\n`) + } +} + +function writeCommandText(value: unknown, plain: boolean, command: CommandSpec, full: boolean): boolean { + const kind = command.output + if (kind === 'targets' && Array.isArray(value)) { + writeTable(value.filter(isRecord), ['id', 'default', 'type', 'reachable', 'name', 'baseURL', 'version', 'error'], plain, full, { + baseURL: 'URL', + id: 'ID', + }) + return true + } + if ((kind === 'accounts' || kind === 'chats' || kind === 'contacts' || kind === 'messages') && Array.isArray(value)) { + const rows = value.filter(isRecord) + const keys = preferredColumns(kind, rows) + writeTable(rows, keys, plain, full) + return true + } + if (kind === 'status' && isRecord(value)) { + writeStatus(value, plain) + return true + } + if (kind === 'diagnostic' && isRecord(value)) { + writeDiagnostic(value, plain) + return true + } + return false +} + +function preferredColumns(kind: NonNullable, rows: Record[]): string[] { + if (kind === 'accounts') return firstPresent(rows, ['accountID', 'id', 'default', 'displayName', 'network', 'status']) + if (kind === 'chats') return firstPresent(rows, ['localChatID', 'chatID', 'title', 'accountID', 'type', 'unreadCount', 'isMuted', 'isArchived', 'isPinned']) + if (kind === 'contacts') return firstPresent(rows, ['userID', 'id', 'displayName', 'name', 'accountID', 'phoneNumber', 'email']) + if (kind === 'messages') return firstPresent(rows, ['timestamp', 'date', 'chatID', 'senderID', 'messageID', 'text', 'body']) + return Object.keys(rows[0] ?? {}).slice(0, 8) +} + +function firstPresent(rows: Record[], preferred: string[]): string[] { + const available = new Set(rows.flatMap(row => Object.keys(row))) + const selected = preferred.filter(key => available.has(key)) + return selected.length ? selected : Object.keys(rows[0] ?? {}).slice(0, 8) +} + +function writeStatus(value: Record, plain: boolean): void { + const target = isRecord(value.target) ? value.target : {} + const auth = isRecord(value.auth) ? value.auth : {} + const live = isRecord(value.live) ? value.live : {} + const readiness = isRecord(value.readiness) ? value.readiness : {} + writeDiagnostic({ + target: target.id, + name: target.name, + type: target.type, + url: target.baseURL, + reachable: live.reachable, + version: live.version, + authenticated: auth.authenticated, + auth_source: auth.source, + readiness: readiness.state, + next: readiness.message, + }, plain) +} + +function writeDiagnostic(value: Record, plain: boolean): void { + const rows = Object.entries(value) + .filter(([, item]) => item !== undefined) + .map(([key, item]) => ({ key: key.replaceAll('_', ' ').toUpperCase(), value: humanCell(item) })) + if (plain) { + for (const row of rows) process.stdout.write(`${row.key}\t${row.value.replaceAll('\n', '\\n').replaceAll('\t', '\\t')}\n`) + return + } + const width = Math.max(4, ...rows.map(row => row.key.length)) + 2 + for (const row of rows) process.stdout.write(`${row.key.padEnd(width)}${row.value}\n`) +} + +function writeTable(rows: Record[], columns: string[], plain: boolean, full: boolean, labels: Record = {}): void { + const cleanRows = rows.map(row => Object.fromEntries(columns.map(key => [key, tableCell(row[key], plain || full)]))) + const headings = columns.map(key => labels[key] ?? key.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()) + if (plain) { + process.stdout.write(`${columns.join('\t')}\n`) + for (const row of cleanRows) process.stdout.write(`${columns.map(key => escapePlain(String(row[key] ?? ''))).join('\t')}\n`) + return + } + const widths = columns.map((key, index) => Math.max(headings[index]!.length, ...cleanRows.map(row => String(row[key] ?? '').length))) + process.stdout.write(`${headings.map((heading, index) => heading.padEnd(widths[index]!)).join(' ')}\n`) + for (const row of cleanRows) { + process.stdout.write(`${columns.map((key, index) => String(row[key] ?? '').padEnd(widths[index]!)).join(' ')}\n`) + } +} + +function tableCell(value: unknown, full: boolean): string { + const cell = humanCell(value).replaceAll(/\s+/g, ' ').trim() + return full || cell.length <= 80 ? cell : `${cell.slice(0, 77)}...` +} + +function escapePlain(value: string): string { + return value.replaceAll('\n', '\\n').replaceAll('\t', '\\t') +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function humanCell(value: unknown): string { + if (Array.isArray(value)) return value.map(humanCell).join(', ') + if (value && typeof value === 'object') return JSON.stringify(value) + return String(value ?? '') +} + +function primaryResult(value: unknown): unknown { + if (!isRecord(value)) return value + for (const key of ['data', 'items', 'messages', 'chats', 'accounts', 'contacts', 'target', 'result']) { + if (value[key] !== undefined) return value[key] + } + return value +} + +function selectFields(value: unknown, fields: string): unknown { + const paths = fields.split(',').map(field => field.trim()).filter(Boolean) + if (!paths.length) return value + if (Array.isArray(value)) return value.map(item => selectFields(item, fields)) + if (!isRecord(value)) return value + const out: Record = {} + for (const path of paths) { + const selected = getPath(value, path) + if (selected !== undefined) setPath(out, path, selected) + } + return out +} + +function getPath(value: unknown, path: string): unknown { + return path.split('.').reduce((current, part) => isRecord(current) ? current[part] : undefined, value) +} + +function setPath(out: Record, path: string, value: unknown): void { + const parts = path.split('.') + let current = out + for (const part of parts.slice(0, -1)) { + current = current[part] && typeof current[part] === 'object' && !Array.isArray(current[part]) + ? current[part] as Record + : current[part] = {} + } + current[parts.at(-1)!] = value +} + +export function wrapUntrusted(value: unknown): unknown { + return wrapValue(value, []) +} + +function wrapValue(value: unknown, path: string[]): unknown { + if (Array.isArray(value)) return value.map(item => wrapValue(item, path)) + if (value && typeof value === 'object') { + const out: Record = {} + let wrapped = false + for (const [key, item] of Object.entries(value as Record)) { + const next = wrapValueForKey(item, [...path, key], key) + if (next !== item) wrapped = true + out[key] = next + } + if (path.length === 0 && wrapped) out.externalContent = { source: 'beeper_api', untrusted: true, wrapped: true } + return out + } + return value +} + +function wrapValueForKey(value: unknown, path: string[], key: string): unknown { + if (typeof value === 'string' && shouldWrapString(path, key, value)) { + return wrapText(value) + } + return wrapValue(value, path) +} + +function shouldWrapString(path: string[], key: string, value: string): boolean { + if (!value) return false + const normalized = key.replaceAll(/[-_]/g, '').toLowerCase() + if (['id', 'chatid', 'messageid', 'roomid', 'url', 'uri', 'createdat', 'updatedat', 'status'].includes(normalized)) return false + if (['about', 'body', 'caption', 'description', 'displayname', 'message', 'name', 'subject', 'text', 'title', 'topic', 'value'].includes(normalized)) return true + return path.some(part => ['messages', 'rows', 'values'].includes(part.replaceAll(/[-_]/g, '').toLowerCase())) +} + +function wrapText(value: string): string { + const sanitized = value + .replaceAll(/<<<\s*(?:END[\s_]+)?EXTERNAL[\s_]+UNTRUSTED[\s_]+CONTENT[^>]*>>>/gi, '[[UNTRUSTED_MARKER_SANITIZED]]') + .replaceAll(/<\|[^>]+?\|>/g, '[REMOVED_SPECIAL_TOKEN]') + return `<<>>\n${sanitized}\n<<>>` +} diff --git a/packages/cli/src/cli/parse.ts b/packages/cli/src/cli/parse.ts new file mode 100644 index 00000000..65f44891 --- /dev/null +++ b/packages/cli/src/cli/parse.ts @@ -0,0 +1,269 @@ +import type { CommandSpec, FlagSpec, GlobalFlags } from './types.js' +import { usage } from './output.js' + +type ParsedCommand = { + command?: CommandSpec + flags: Record + globalFlags: GlobalFlags + helpOnly?: boolean + positionals: string[] +} + +export const globalFlagSpecs: FlagSpec[] = [ + { name: 'access-token', type: 'string', env: ['BEEPER_ACCESS_TOKEN'], description: 'Use provided access token directly' }, + { name: 'account', aliases: ['acct'], short: 'a', type: 'string', multiple: true, description: 'Account selector for account-aware commands' }, + { name: 'color', type: 'string', enum: ['auto', 'always', 'never'], default: 'auto', description: 'Color output: auto|always|never' }, + { name: 'debug', type: 'boolean', default: false }, + { name: 'disable-commands', type: 'string', description: 'Comma-separated command prefixes to block' }, + { name: 'dry-run', aliases: ['dryrun', 'noop', 'preview'], short: 'n', type: 'boolean', default: false, description: 'Do not make changes; print intended actions' }, + { name: 'enable-commands', type: 'string', description: 'Comma-separated enabled command prefixes' }, + { name: 'enable-commands-exact', type: 'string', description: 'Comma-separated exact enabled commands' }, + { name: 'events', type: 'boolean', default: false }, + { name: 'force', aliases: ['assume-yes', 'yes'], short: 'y', type: 'boolean', default: false, description: 'Skip confirmations for destructive commands' }, + { name: 'full', type: 'boolean', default: false, description: 'Disable truncation in human table output' }, + { name: 'home', type: 'string', env: ['BEEPER_CLI_CONFIG_DIR'], description: 'Override Beeper CLI config/data root' }, + { name: 'json', aliases: ['machine'], short: 'j', type: 'boolean', default: false, description: 'Output JSON to stdout' }, + { name: 'no-input', aliases: ['non-interactive', 'noninteractive'], type: 'boolean', default: false, description: 'Never prompt; fail instead' }, + { name: 'plain', aliases: ['tsv'], short: 'p', type: 'boolean', default: false, description: 'Output stable TSV-like text' }, + { name: 'read-only', type: 'boolean', default: false, env: ['BEEPER_READONLY'], description: 'Reject commands that intentionally write' }, + { name: 'results-only', type: 'boolean', default: false, description: 'In JSON mode, emit only the primary result' }, + { name: 'safety-profile', type: 'string', description: 'Safety profile name or YAML path' }, + { name: 'select', aliases: ['fields', 'project'], type: 'string', description: 'Select comma-separated JSON fields' }, + { name: 'target', type: 'string', description: 'Target name or URL' }, + { name: 'timeout', type: 'string', description: 'Command timeout, for example 30s or 2m' }, + { name: 'version', short: 'v', type: 'boolean', default: false, description: 'Print version and exit' }, + { name: 'wrap-untrusted', type: 'boolean', default: false, description: 'Wrap fetched text fields in untrusted-content markers' }, +] + +export function parseCommand(argv: string[], commands: CommandSpec[]): ParsedCommand { + const helpRequested = argv.includes('--help') || argv.includes('-h') + const global = parseGlobalFlags(argv) + const tokens = parseArgv(argv, globalFlagSpecs, { allowUnknownFlags: true }).positionals + if (argv.includes('--version') || argv.includes('-v')) { + const command = commands.find(item => item.path.join(' ') === 'version') + if (command) return { command, flags: {}, globalFlags: global, positionals: [] } + } + if (argv[0] === '__complete') { + const command = commands.find(item => item.path.join(' ') === '__complete') + if (!command) throw usage('unknown command "__complete"') + return { + command, + flags: { cword: completeCword(argv) }, + globalFlags: global, + positionals: completeWordsFromArgv(argv), + } + } + const pathTokens = tokens.filter(token => !token.startsWith('-')) + if (pathTokens.length === 0) { + return { flags: {}, globalFlags: global, helpOnly: true, positionals: [] } + } + + const command = findCommand(commands, pathTokens) + if (!command) throw usage(`unknown command "${pathTokens.join(' ')}"`) + const pathLength = matchedPathLength(command, pathTokens) + const commandArgs = tokens.slice(pathLength) + if (helpRequested) return { command, flags: { help: true }, globalFlags: global, positionals: commandArgs } + + const { flags, positionals } = parseArgv(commandArgs, command.flags ?? []) + validateCommandInput(command, flags, positionals) + return { command, flags, globalFlags: global, positionals } +} + +function completeCword(argv: string[]): number { + const index = argv.indexOf('--cword') + if (index === -1) return -1 + const raw = argv[index + 1] + const parsed = raw && /^-?\d+$/.test(raw) ? Number(raw) : NaN + if (!Number.isSafeInteger(parsed)) throw usage('--cword must be an integer') + return parsed +} + +function completeWordsFromArgv(argv: string[]): string[] { + const separator = argv.indexOf('--') + if (separator !== -1) return argv.slice(separator + 1) + const out: string[] = [] + for (let index = 1; index < argv.length; index += 1) { + if (argv[index] === '--cword') { + index += 1 + continue + } + out.push(argv[index]!) + } + return out +} + +export function stringFlag(flags: Record, name: string): string | undefined { + const value = flags[name] + return typeof value === 'string' ? value : undefined +} + +export function requiredStringFlag(flags: Record, name: string): string { + const value = stringFlag(flags, name) + if (!value) throw usage(`--${name} is required`) + return value +} + +export function numberFlag(flags: Record, name: string, fallback: number): number { + const value = flags[name] + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +export function stringListFlag(flags: Record, name: string): string[] { + const value = flags[name] + if (Array.isArray(value)) return value.map(String).filter(Boolean) + return typeof value === 'string' && value ? [value] : [] +} + +export function parseFlagValue(flag: FlagSpec, value: unknown): boolean | number | string { + if (flag.type === 'integer') { + const text = String(value).trim() + const parsed = /^-?\d+$/.test(text) ? Number(text) : NaN + if (!Number.isSafeInteger(parsed)) throw usage(`--${flag.name} must be an integer`) + return parsed + } + const parsed = flag.type === 'boolean' + ? typeof value === 'boolean' ? value : String(value) !== 'false' + : String(value) + if (flag.enum && !flag.enum.includes(String(parsed))) throw usage(`--${flag.name} must be one of: ${flag.enum.join(', ')}`) + return parsed +} + +function parseGlobalFlags(argv: string[]): GlobalFlags { + const raw = parseArgv(argv, globalFlagSpecs, { allowUnknownFlags: true }).flags + const readOnlyFromEnv = envBool('BEEPER_READONLY') + return { + accessToken: typeof raw['access-token'] === 'string' && raw['access-token'] ? raw['access-token'] : undefined, + account: Array.isArray(raw.account) ? raw.account.map(String).filter(Boolean) : typeof raw.account === 'string' && raw.account ? [raw.account] : undefined, + debug: raw.debug === true, + color: raw.color === 'always' || raw.color === 'never' ? raw.color : 'auto', + disableCommands: typeof raw['disable-commands'] === 'string' && raw['disable-commands'] ? raw['disable-commands'] : undefined, + dryRun: raw['dry-run'] === true, + enableCommands: typeof raw['enable-commands'] === 'string' && raw['enable-commands'] ? raw['enable-commands'] : undefined, + enableCommandsExact: typeof raw['enable-commands-exact'] === 'string' && raw['enable-commands-exact'] ? raw['enable-commands-exact'] : undefined, + events: raw.events === true, + force: raw.force === true, + full: raw.full === true, + home: typeof raw.home === 'string' && raw.home ? raw.home : undefined, + json: raw.json === true, + noInput: raw['no-input'] === true, + plain: raw.plain === true, + readOnly: raw['read-only'] === true || (!hasNoFlag(argv, 'read-only') && readOnlyFromEnv), + resultsOnly: raw['results-only'] === true, + safetyProfile: typeof raw['safety-profile'] === 'string' && raw['safety-profile'] ? raw['safety-profile'] : undefined, + select: typeof raw.select === 'string' && raw.select ? raw.select : undefined, + target: typeof raw.target === 'string' && raw.target ? raw.target : undefined, + timeout: typeof raw.timeout === 'string' && raw.timeout ? raw.timeout : undefined, + wrapUntrusted: raw['wrap-untrusted'] === true, + } +} + +function envBool(name: string): boolean { + const value = process.env[name]?.trim().toLowerCase() + return value === '1' || value === 'true' || value === 'yes' || value === 'on' +} + +function hasNoFlag(argv: string[], name: string): boolean { + return argv.some(token => token === `--no-${name}` || token === `--${name}=false`) +} + +function parseArgv( + argv: string[], + specs: FlagSpec[], + options: { allowUnknownFlags?: boolean } = {}, +): { flags: Record; positionals: string[] } { + const byName = flagMap(specs) + const flags: Record = {} + const positionals: string[] = [] + for (const spec of specs) { + if (spec.default !== undefined) flags[spec.name] = spec.default + } + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + if (!token) continue + if (token === '--') { + positionals.push(...argv.slice(index + 1)) + break + } + if (!token.startsWith('-')) { + positionals.push(token) + continue + } + const parsed = flagToken(token) + const noPrefix = parsed.name.startsWith('no-') ? parsed.name.slice(3) : undefined + const spec = byName.get(parsed.name) ?? (noPrefix ? byName.get(noPrefix) : undefined) + if (!spec) { + if (!options.allowUnknownFlags) throw usage(`unknown flag --${parsed.name}`) + positionals.push(token) + continue + } + if (spec.type === 'boolean') { + const value = noPrefix && !byName.has(parsed.name) ? false : parsed.value === undefined ? true : parsed.value !== 'false' + setFlag(flags, spec, parseFlagValue(spec, value)) + continue + } + const value = parsed.value ?? argv[index + 1] + if (value === undefined || value.startsWith('-')) throw usage(`--${spec.name} requires a value`) + setFlag(flags, spec, parseFlagValue(spec, value)) + if (parsed.value === undefined) index += 1 + } + return { flags, positionals } +} + +function findCommand(commands: CommandSpec[], tokens: string[]): CommandSpec | undefined { + return commands + .filter(command => commandPaths(command).some(path => path.every((part, index) => tokens[index] === part))) + .sort((a, b) => matchedPathLength(b, tokens) - matchedPathLength(a, tokens))[0] +} + +function matchedPathLength(command: CommandSpec, tokens: string[]): number { + return commandPaths(command) + .filter(path => path.every((part, index) => tokens[index] === part)) + .sort((a, b) => b.length - a.length)[0]?.length ?? command.path.length +} + +function commandPaths(command: CommandSpec): string[][] { + return [command.path, ...(command.aliases ?? [])] +} + +function validatePositionals(command: CommandSpec, values: string[]): void { + const args = command.args ?? [] + const required = args.filter(arg => arg.required).length + const variadic = args.some(arg => arg.variadic) + if (values.length < required) throw usage(`${command.path.join(' ')} requires ${args[values.length]?.name ?? 'more arguments'}`) + if (!variadic && values.length > args.length) throw usage(`${command.path.join(' ')} got too many arguments`) +} + +export function validateCommandInput(command: CommandSpec, flags: Record, positionals: string[]): void { + validatePositionals(command, positionals) + for (const flag of command.flags ?? []) { + const value = flags[flag.name] + if (flag.required && (value === undefined || value === '')) throw usage(`--${flag.name} is required`) + } +} + +function flagMap(specs: FlagSpec[]): Map { + const out = new Map() + for (const spec of specs) { + out.set(spec.name, spec) + if (spec.short) out.set(spec.short, spec) + for (const alias of spec.aliases ?? []) out.set(alias, spec) + } + return out +} + +function flagToken(token: string): { name: string; value?: string } { + const trimmed = token.replace(/^-+/, '') + const index = trimmed.indexOf('=') + if (index === -1) return { name: trimmed } + return { name: trimmed.slice(0, index), value: trimmed.slice(index + 1) } +} + +function setFlag(out: Record, spec: FlagSpec, value: boolean | number | string): void { + if (!spec.multiple) { + out[spec.name] = value + return + } + const current = Array.isArray(out[spec.name]) ? out[spec.name] as unknown[] : [] + out[spec.name] = [...current, value] +} diff --git a/packages/cli/src/cli/policy.ts b/packages/cli/src/cli/policy.ts new file mode 100644 index 00000000..357ab3e0 --- /dev/null +++ b/packages/cli/src/cli/policy.ts @@ -0,0 +1,95 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, isAbsolute, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse as parseYAML } from 'yaml' +import type { CommandSpec, GlobalFlags } from './types.js' +import { usage } from './output.js' + +export function enforcePolicy(command: CommandSpec, flags: GlobalFlags): void { + enforceCommandFilters(command, flags) + if (flags.readOnly && command.risk !== 'read') { + throw usage(`read-only mode: command "${command.path.join(' ')}" would intentionally modify Beeper or local CLI state`) + } + const profile = flags.safetyProfile ? loadSafetyProfile(flags.safetyProfile) : undefined + if (profile && !matchesPrefix(profile.allow, command.path)) { + throw usage(`command "${command.path.join(' ')}" is blocked by safety profile "${profile.name}"`) + } +} + +export function commandVisible(command: CommandSpec, flags: GlobalFlags): boolean { + if (command.hidden) return false + if (flags.readOnly && command.risk !== 'read') return false + if (!commandAllowedByFilters(command, flags)) return false + const profile = flags.safetyProfile ? loadSafetyProfile(flags.safetyProfile) : undefined + return !profile || matchesPrefix(profile.allow, command.path) +} + +function enforceCommandFilters(command: CommandSpec, flags: GlobalFlags): void { + if (commandAllowedByFilters(command, flags)) return + const path = command.path + if (rulesFromCSV(flags.disableCommands).size && matchesPrefix(rulesFromCSV(flags.disableCommands), path)) throw usage(`command "${path.join(' ')}" is disabled (blocked by --disable-commands)`) + throw usage(`command "${path.join(' ')}" is not enabled (set --enable-commands or --enable-commands-exact to allow it)`) +} + +function commandAllowedByFilters(command: CommandSpec, flags: GlobalFlags): boolean { + const path = command.path + const allow = rulesFromCSV(flags.enableCommands) + const exactAllow = rulesFromCSV(flags.enableCommandsExact) + const deny = rulesFromCSV(flags.disableCommands) + if (deny.size && matchesPrefix(deny, path)) return false + if ((allow.size || exactAllow.size) && !matchesPrefix(allow, path) && !matchesExact(exactAllow, path)) return false + return true +} + +function loadSafetyProfile(nameOrPath: string): { allow: Set; name: string } { + const path = resolveProfilePath(nameOrPath) + if (!path) throw usage(`unknown safety profile "${nameOrPath}"`) + const root = parseYAML(readFileSync(path, 'utf8')) as Record | undefined + return { + allow: rules(root?.allow), + name: typeof root?.name === 'string' && root.name.trim() ? root.name.trim() : 'unnamed', + } +} + +function resolveProfilePath(nameOrPath: string): string | undefined { + if (isAbsolute(nameOrPath) || nameOrPath.includes('/')) return existsSync(nameOrPath) ? nameOrPath : undefined + const filename = nameOrPath.endsWith('.yaml') ? nameOrPath : `${nameOrPath}.yaml` + const here = dirname(fileURLToPath(import.meta.url)) + const path = join(dirname(dirname(here)), 'safety-profiles', filename) + return existsSync(path) ? path : undefined +} + +function rules(value: unknown): Set { + const out = new Set() + if (!Array.isArray(value)) return out + for (const item of value) { + const rule = normalizeRule(String(item)) + if (rule) out.add(rule) + } + return out +} + +function matchesPrefix(rules: Set, path: string[]): boolean { + if (rules.has('*') || rules.has('all')) return true + for (let index = 1; index <= path.length; index += 1) { + if (rules.has(path.slice(0, index).join('.'))) return true + } + return false +} + +function matchesExact(rules: Set, path: string[]): boolean { + return rules.has('*') || rules.has('all') || rules.has(path.join('.')) +} + +function rulesFromCSV(value?: string): Set { + const out = new Set() + for (const part of (value ?? '').split(',')) { + const rule = normalizeRule(part) + if (rule) out.add(rule) + } + return out +} + +function normalizeRule(value: string): string { + return value.trim().toLowerCase().replaceAll(/\s+/g, '.').replaceAll(/^\.+|\.+$/g, '') +} diff --git a/packages/cli/src/cli/schema.ts b/packages/cli/src/cli/schema.ts new file mode 100644 index 00000000..c497146d --- /dev/null +++ b/packages/cli/src/cli/schema.ts @@ -0,0 +1,118 @@ +import type { ArgSpec, CommandSpec, FlagSpec, GlobalFlags } from './types.js' +import { globalFlagSpecs } from './parse.js' +import { commandVisible } from './policy.js' + +type SchemaDoc = { + build: string + command: SchemaNode + schema_version: 1 +} + +type SchemaNode = { + aliases?: string[][] + flags?: SchemaFlag[] + help: string + hidden?: boolean + name: string + output?: string + path: string + positionals?: SchemaArg[] + requirements?: string[] + subcommands?: SchemaNode[] + type: 'application' | 'command' + usage?: string +} + +type SchemaFlag = { + aliases?: string[] + default?: boolean | number | string + envs?: string[] + enum?: string[] + help?: string + multiple?: boolean + name: string + placeholder?: string + required?: boolean + short?: string + type: string +} + +type SchemaArg = { + help?: string + name: string + required?: boolean + type: 'string' + variadic?: boolean +} + +export function buildSchema(commands: CommandSpec[], version: string, requested: string[] = [], flags?: GlobalFlags): SchemaDoc { + const visible = commands.filter(command => flags ? commandVisible(command, flags) : !command.hidden) + const filtered = requested.length + ? visible.filter(command => command.path.join('.').startsWith(requested.join('.'))) + : visible + return { + build: version, + command: nodeFor(filtered, requested, requested.length ? requested.at(-1) ?? 'beeper' : 'beeper'), + schema_version: 1, + } +} + +function nodeFor(commands: CommandSpec[], prefix: string[], name: string): SchemaNode { + const exact = commands.find(command => command.path.join('.') === prefix.join('.')) + const childNames = new Set() + for (const command of commands) { + const child = command.path[prefix.length] + if (child) childNames.add(child) + } + const children = [...childNames] + .sort() + .map(child => nodeFor(commands.filter(command => command.path[prefix.length] === child), [...prefix, child], child)) + + return { + aliases: exact?.aliases, + flags: prefix.length === 0 ? schemaFlags(globalFlagSpecs) : schemaFlags(exact?.flags ?? []), + help: exact?.description ?? 'Beeper CLI', + hidden: exact?.hidden || undefined, + name, + output: exact?.output, + path: prefix.join(' '), + positionals: exact?.args?.map(schemaArg), + requirements: exact ? requirements(exact) : undefined, + subcommands: children.length ? children : undefined, + type: prefix.length === 0 ? 'application' : 'command', + usage: exact ? `beeper ${exact.path.join(' ')}` : undefined, + } +} + +function schemaFlags(flags: FlagSpec[]): SchemaFlag[] { + return flags.map(flag => ({ + aliases: flag.aliases, + default: flag.default, + envs: flag.env, + enum: flag.enum, + help: flag.description, + multiple: flag.multiple, + name: flag.name, + placeholder: flag.placeholder, + required: flag.required, + short: flag.short, + type: flag.type, + })) +} + +function schemaArg(arg: ArgSpec): SchemaArg { + return { + help: arg.description, + name: arg.name, + required: arg.required, + type: 'string', + variadic: arg.variadic, + } +} + +function requirements(command: CommandSpec): string[] | undefined { + const out: string[] = [] + if (command.risk === 'write') out.push('write') + if (command.risk === 'destructive') out.push('destructive', 'force') + return out.length ? out : undefined +} diff --git a/packages/cli/src/cli/setup.ts b/packages/cli/src/cli/setup.ts new file mode 100644 index 00000000..68ccca2e --- /dev/null +++ b/packages/cli/src/cli/setup.ts @@ -0,0 +1,639 @@ +import { access } from 'node:fs/promises' +import { driveVerification, evaluateReadiness, type Readiness } from '../lib/app-state.js' +import { authFromToken, authorizeTarget, findLocalDesktop } from '../lib/desktop-auth.js' +import { installDesktop, installServer, readInstallations, type Installations } from '../lib/installations.js' +import { connectedAccountSummary, findLocalDesktopSession, localConnectedAccountSummary, localDesktopReadiness, type LocalDesktopSession } from '../lib/local-desktop.js' +import { renderStartupLogo } from '../lib/logo.js' +import { promptChoice, promptConfirm, promptText } from '../lib/prompts.js' +import { findDesktopAppPath, launchDesktopApp, startProfile } from '../lib/profiles.js' +import { SERVER_ENV_API_BASE_URLS, normalizeServerEnv } from '../lib/server-env.js' +import { finishEmailSetup, startEmailSetup, type SetupLoginResult } from '../lib/setup-login.js' +import { + builtInDesktopTargetID, + createDefaultDesktopTarget, + createProfileTarget, + listTargets, + publicTarget, + readConfig, + readTarget, + updateConfig, + writeTarget, + type ManagedTargetType, + type Target, +} from '../lib/targets.js' +import type { CommandContext } from './types.js' +import { usage, writeEvent } from './output.js' +import { stringFlag } from './parse.js' + +type SetupFlags = { + 'server-env': string + channel: string + debug: boolean + desktop: boolean + email?: string + events: boolean + install: boolean + json: boolean + local: boolean + oauth: boolean + remote?: string + server: boolean + target?: string + username?: string + force: boolean +} + +type PreparedLocalDesktopSetup = { + accounts: string[] + readiness: Readiness + session: LocalDesktopSession + target: Target +} + +type DesktopSetupDetection = + | { kind: 'session-found'; local: PreparedLocalDesktopSetup; serverInstalled: boolean } + | { kind: 'installed-not-running'; serverInstalled: boolean } + | { kind: 'running-signed-out'; readiness?: Readiness; serverInstalled: boolean } + | { kind: 'session-unreadable'; reason: string; readiness?: Readiness; serverInstalled: boolean } + | { kind: 'not-installed'; serverInstalled: boolean } + +type SetupAction = { command: string; id: string } + +export async function runSetup(ctx: CommandContext): Promise { + const flags = setupFlags(ctx) + const targetModeCount = [Boolean(flags.remote), flags.server, flags.desktop].filter(Boolean).length + if (targetModeCount > 1) throw usage('Specify at most one of --remote, --server, or --desktop') + const authModeCount = [flags.local, flags.oauth, Boolean(flags.email)].filter(Boolean).length + if (authModeCount > 1) throw usage('Specify at most one of --local, --oauth, or --email') + if ((flags.local || flags.oauth) && (flags.remote || flags.server || flags.desktop)) { + throw usage('Use --local or --oauth with an existing target, not with --remote, --server, or --desktop.') + } + if (ctx.globalFlags.dryRun) { + return { + dry_run: true, + op: 'setup', + request: { + authMode: flags.local ? 'local' : flags.oauth ? 'oauth' : flags.email ? 'email' : 'auto', + channel: flags.channel, + email: flags.email, + install: flags.install, + remote: flags.remote, + serverEnv: flags['server-env'], + target: flags.target, + targetMode: flags.remote ? 'remote' : flags.server ? 'server' : flags.desktop ? 'desktop' : 'selected', + username: flags.username, + force: flags.force, + }, + } + } + if (flags.events) writeEvent('setup_step', { step: 'start', target: flags.target }) + + if (flags.remote) return setupRemote(flags) + if (flags.server) return setupManaged('server', flags) + if (flags.desktop) return setupManaged('desktop', flags) + + const target = await setupTarget(flags) + if (flags.local) { + const prepared = await prepareLocalDesktopSetup(target, flags) + return printSetupResult(await commitLocalDesktopSetup(prepared), flags) + } + if (flags.oauth) return printSetupResult(await setupOAuthTarget(target, flags), flags) + if (flags.email) return printSetupResult(await setupEmailTarget(target, flags), flags) + return setupDefault(target, flags) +} + +function setupFlags(ctx: CommandContext): SetupFlags { + return { + 'server-env': stringFlag(ctx.flags, 'server-env') || 'prod', + channel: stringFlag(ctx.flags, 'channel') || 'stable', + debug: ctx.globalFlags.debug, + desktop: ctx.flags.desktop === true, + email: stringFlag(ctx.flags, 'email'), + events: ctx.globalFlags.events, + install: ctx.flags.install === true, + json: ctx.globalFlags.json, + local: ctx.flags.local === true, + oauth: ctx.flags.oauth === true, + remote: stringFlag(ctx.flags, 'remote'), + server: ctx.flags.server === true, + target: ctx.globalFlags.target, + username: stringFlag(ctx.flags, 'username'), + force: ctx.globalFlags.force, + } +} + +async function setupDefault(target: Target, flags: SetupFlags): Promise { + const setupCmd = setupCommand(target) + if (interactive(flags)) { + process.stdout.write(`${renderStartupLogo()}\n\n`) + process.stdout.write('Setup\n\n') + if (target.id !== builtInDesktopTargetID || flags.target) process.stdout.write(`Continuing setup for ${target.name ?? target.id}.\n\n`) + } + if (target.type === 'desktop') { + const detected = await detectDesktopSetup(target, flags) + if (detected.kind === 'session-found') { + const local = detected.local + if (flags.force) return printSetupResult(await commitLocalDesktopSetup(local), flags) + if (!interactive(flags)) return setupSessionFoundOutput(local, setupCmd, detected.serverInstalled) + printLocalDesktopPreview(local) + if (await promptConfirm('Use this Desktop session for CLI access?', true)) { + return printSetupResult(await commitLocalDesktopSetup(local), flags) + } + return printInteractiveSetupStatus( + local.readiness.state === 'ready' ? 'Beeper Desktop is ready' : `Setup paused: ${local.readiness.state}`, + setupDetailForReadiness(local.readiness), + ) + } + if (!interactive(flags)) return setupStateOutput(detected, target) + if (detected.kind === 'installed-not-running') { + printStatus('Found Beeper Desktop on this device.', 'installed, not running') + if (flags.force || await promptConfirm('Launch Beeper Desktop now?', true)) return launchAndPoll(target, setupCmd, flags) + } else if (detected.kind === 'running-signed-out') { + printStatus('Found Beeper Desktop on this device.', 'running, signed out') + if (flags.force || await promptConfirm('Open Beeper Desktop so you can sign in?', true)) return launchAndPoll(target, setupCmd, flags) + } else if (detected.kind === 'session-unreadable') { + printStatus('Found Beeper Desktop on this device.', 'signed in, but CLI could not read the local session') + process.stdout.write('You can still connect through Beeper Desktop.\n') + if (flags.debug) process.stdout.write(`\n${detected.reason}\n`) + process.stdout.write('\n') + if (flags.force || await promptConfirm('Connect through Beeper Desktop instead?', true)) { + return printSetupResult(await setupOAuthTarget(target, flags), flags) + } + } else if (detected.kind === 'not-installed') { + return setupFromChoice(flags) + } + } + + const readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id }) + if (readiness.state === 'target-unreachable' && target.type !== 'desktop') { + if (!interactive(flags)) { + return currentTargetBrokenOutput(target, readiness, await isServerInstalled()) + } + if (await handleBrokenCurrentTarget(target, readiness, flags)) return undefined + } + if (readiness.state === 'target-unreachable' && target.type === 'desktop' && interactive(flags)) { + if (flags.force || await promptConfirm('Beeper Desktop is not reachable. Launch it now?', true)) { + return launchAndPoll(target, setupCmd, flags) + } + } + if (!interactive(flags)) return { readiness, target: publicTarget(target) } + return printInteractiveSetupStatus( + readiness.state === 'ready' ? 'Target ready' : `Setup paused: ${readiness.state}`, + setupDetailForReadiness(readiness), + ) +} + +async function setupRemote(flags: SetupFlags): Promise { + const name = flags.target ?? await uniqueRemoteName(flags.remote!) + if (interactive(flags)) { + process.stdout.write('Connecting to Desktop API on another device.\n\n') + process.stdout.write(`Name: ${name}\n`) + process.stdout.write(`URL: ${flags.remote!}\n\n`) + } + const target: Target = { + baseURL: flags.remote!, + id: name, + name, + type: 'remote', + } + const result = flags.email ? await setupEmailTarget(target, flags) : await setupOAuthTarget(target, flags) + if (!flags.target) await updateConfig(config => ({ ...config, defaultTarget: config.defaultTarget ?? target.id })) + return printSetupResult(result, flags) +} + +async function setupManaged(type: ManagedTargetType, flags: SetupFlags): Promise { + if (flags.install) { + if (!interactive(flags) && !flags.force) throw usage('Install requires --install --force in non-interactive mode.') + await installWithCopy(type, flags) + } + const id = flags.target ?? type + const target = await readTarget(id) ?? await createProfileTarget(type, id, { serverEnv: flags['server-env'], port: undefined }) + if (!flags.target) await updateConfig(config => ({ ...config, defaultTarget: config.defaultTarget ?? target.id })) + await startProfile(target).catch(error => { + if (type === 'desktop') return undefined + throw error + }) + if (flags.email) return printSetupResult(await setupEmailTarget(target, flags), flags) + return { readiness: await evaluateReadiness({ baseURL: target.baseURL, target: target.id }), target: publicTarget(target) } +} + +async function printSetupResult(result: SetupLoginResult, flags: SetupFlags): Promise { + result = await maybeDriveOnboarding(result, flags) + if (!interactive(flags)) return result + process.stdout.write(result.readiness.state === 'ready' + ? `Connected to ${result.target.name ?? result.target.id}\n` + : `Connected; setup paused: ${result.readiness.state}\n`) + const readinessDetail = setupDetailForReadiness(result.readiness) + const detail = result.accounts.length && readinessDetail + ? `Connected accounts: ${result.accounts.join(', ')}\n${readinessDetail}` + : result.accounts.length + ? `Connected accounts: ${result.accounts.join(', ')}` + : readinessDetail + if (detail) process.stdout.write(`${detail}\n`) + if (result.readiness.state === 'ready') { + process.stdout.write('\nNext:\n') + process.stdout.write(' beeper chats list\n') + process.stdout.write(' beeper send text --to --message "hello"\n') + } + return undefined +} + +async function setupFromChoice(flags: SetupFlags): Promise { + const serverInstalled = await isServerInstalled() + process.stdout.write('No usable Beeper Desktop session was found on this device.\n\n') + process.stdout.write('How do you want to connect Beeper CLI?\n\n') + process.stdout.write(' 1. Install Beeper Desktop\n') + process.stdout.write(` 2. ${serverInstalled ? 'Use installed local Beeper Server' : 'Install local Beeper Server'}\n`) + process.stdout.write(' 3. Connect with Desktop API on another device\n\n') + const defaultChoice = serverInstalled ? '2' : '1' + const choice = await promptChoice(`Choose [${defaultChoice}]: `, ['1', '2', '3'], { defaultValue: defaultChoice }) + if (choice === '1') { + if (!await promptConfirm('Install Beeper Desktop stable from beeper.com?', true)) return undefined + await installWithCopy('desktop', { ...flags, channel: 'stable' }) + const target = await setupTarget({ ...flags, desktop: true }) + return launchAndPoll(target, setupCommand(target), flags) + } + if (choice === '2') { + if (!serverInstalled) { + if (!await promptConfirm('Install local Beeper Server stable from beeper.com?', true)) return undefined + await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'prod' }) + } + return setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' }) + } + const url = await promptText('Desktop API URL: ') + if (!url) throw usage('Remote URL is required.') + return setupRemote({ ...flags, remote: url }) +} + +async function handleBrokenCurrentTarget(target: Target, readiness: Readiness, flags: SetupFlags): Promise { + const serverInstalled = await isServerInstalled() + process.stdout.write(`Beeper CLI is set up for ${target.name ?? target.id}, but it is not reachable.\n\n`) + if (readiness.message) process.stdout.write(`${readiness.message}\n\n`) + process.stdout.write('What do you want to do?\n\n') + process.stdout.write(` 1. Retry ${target.name ?? target.id}\n`) + process.stdout.write(' 2. Use Beeper Desktop on this device\n') + process.stdout.write(` 3. ${serverInstalled ? 'Use installed local Beeper Server' : 'Install local Beeper Server'}\n`) + process.stdout.write(' 4. Connect with Desktop API on another device\n\n') + const choice = await promptChoice('Choose [1]: ', ['1', '2', '3', '4'], { defaultValue: '1' }) + if (choice === '1') return false + if (choice === '2') { + const desktop = await createDefaultDesktopTarget() + await setupDefault(desktop, { ...flags, target: desktop.id }) + return true + } + if (choice === '3') { + if (!serverInstalled) { + if (!await promptConfirm('Install local Beeper Server stable from beeper.com?', true)) return true + await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'prod' }) + } + await setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' }) + return true + } + const url = await promptText('Desktop API URL: ') + if (!url) throw usage('Remote URL is required.') + await setupRemote({ ...flags, remote: url }) + return true +} + +async function setupTarget(flags: SetupFlags): Promise { + if (flags.target) { + const target = await readTarget(flags.target) + if (!target) throw usage(`Unknown Beeper target "${flags.target}". Run \`beeper targets list\`.`) + return target + } + const config = await readConfig() + if (config.defaultTarget) { + const target = await readTarget(config.defaultTarget) + if (target) return target + } + const desktop = await readTarget(builtInDesktopTargetID) + if (desktop) return desktop + const detected = await findLocalDesktop({ scan: true, timeoutMs: 300 }).catch(() => undefined) + return createDefaultDesktopTarget(detected?.baseURL) +} + +async function prepareLocalDesktopSetup(target: Target, flags: SetupFlags): Promise { + if (flags.events) writeEvent('setup_step', { step: 'local-desktop', target: target.id }) + const desktop = await findLocalDesktop({ baseURL: target.baseURL, scan: target.id === builtInDesktopTargetID, timeoutMs: 500 }).catch(() => undefined) + const resolvedTarget: Target = { + ...target, + baseURL: desktop?.baseURL ?? target.baseURL, + name: target.name ?? 'Beeper Desktop', + type: 'desktop', + } + const session = await findLocalDesktopSession(resolvedTarget) + const readiness = localDesktopReadiness(session) + const accounts = await localConnectedAccountSummary(session.dataDir).catch(() => []) + return { accounts, readiness, session, target: resolvedTarget } +} + +async function detectDesktopSetup(target: Target, flags: SetupFlags): Promise { + printProgress(flags, 'Checking Beeper Desktop') + const installations = await readInstallations().catch((): Installations => ({})) + const serverInstalled = await isServerInstalled(installations) + const appInstalled = Boolean(await findDesktopAppPath(installations)) + printProgress(flags, 'Reading local Desktop session') + const local = await prepareLocalDesktopSetup(target, flags).catch(error => ({ error })) + if (!('error' in local)) return { kind: 'session-found', local, serverInstalled } + + printProgress(flags, 'Checking Desktop readiness') + const desktop = await findLocalDesktop({ baseURL: target.baseURL, scan: target.id === builtInDesktopTargetID, timeoutMs: 500 }).catch(() => undefined) + if (desktop) { + const readiness = await evaluateReadiness({ baseURL: desktop.baseURL, target: target.id, token: false }) + if (readiness.state === 'needs-login') return { kind: 'running-signed-out', readiness, serverInstalled } + return { + kind: 'session-unreadable', + reason: local.error instanceof Error ? local.error.message : String(local.error), + readiness, + serverInstalled, + } + } + return appInstalled ? { kind: 'installed-not-running', serverInstalled } : { kind: 'not-installed', serverInstalled } +} + +async function isServerInstalled(installations?: Installations): Promise { + if (process.env.BEEPER_SERVER_BIN) return true + const installation = installations ?? await readInstallations().catch((): Installations => ({})) + return Boolean(installation.server?.path && await access(installation.server.path).then(() => true, () => false)) +} + +async function commitLocalDesktopSetup(prepared: PreparedLocalDesktopSetup): Promise { + await writeTarget({ ...prepared.target, auth: prepared.session.auth }) + await updateConfig(config => ({ ...config, defaultTarget: config.defaultTarget ?? prepared.target.id })) + return { + accounts: prepared.accounts, + readiness: prepared.readiness, + target: publicTarget({ ...prepared.target, auth: prepared.session.auth }), + } +} + +async function setupOAuthTarget(target: Target, flags: SetupFlags): Promise { + if (flags.events) writeEvent('setup_step', { step: 'oauth', target: target.id }) + if (!interactive(flags) && !flags.force) throw usage('OAuth setup requires an interactive terminal or --force to open the browser.') + const auth = authFromToken(await authorizeTarget({ + baseURL: target.baseURL, + scan: target.type === 'desktop' && target.id === builtInDesktopTargetID, + }), target.type === 'remote' ? 'remote-oauth' : 'desktop-oauth') + await writeTarget({ ...target, auth }) + const [readiness, accounts] = await Promise.all([ + evaluateReadiness({ baseURL: target.baseURL, target: target.id, token: auth.accessToken }), + connectedAccountSummary(target, auth).catch(() => []), + ]) + return { accounts, readiness, target: publicTarget({ ...target, auth }) } +} + +async function setupEmailTarget(target: Target, flags: SetupFlags): Promise { + if (flags.events) writeEvent('setup_step', { step: 'email', target: target.id }) + const email = flags.email + if (!email) throw usage('Email setup requires --email.') + if (!interactive(flags)) throw usage('Email setup prompts for the verification code. For automation, use `beeper auth email start` and `beeper auth email response`.') + const start = await startEmailSetup(target, email) + return finishEmailSetup(target, { + code: await promptText('Email code: '), + force: flags.force, + json: flags.json, + setupRequestID: start.setupRequestID, + username: flags.username, + }) +} + +function printLocalDesktopPreview(prepared: PreparedLocalDesktopSetup): void { + process.stdout.write('Found Beeper Desktop on this device.\n\n') + process.stdout.write(`Status: ${prepared.readiness.state === 'ready' ? 'signed in and ready' : prepared.readiness.state}\n`) + if (prepared.session.userID) process.stdout.write(`Signed in as: ${prepared.session.userID}\n`) + if (prepared.accounts.length) process.stdout.write(`Connected accounts: ${prepared.accounts.join(', ')}\n`) + process.stdout.write('\n') +} + +function setupSessionFoundOutput(local: PreparedLocalDesktopSetup, setupCmd: string, serverInstalled: boolean): Record { + const availableActions = [ + { id: 'use-desktop-session', command: `${setupCmd} --local` }, + { id: 'desktop-oauth', command: `${setupCmd} --oauth` }, + { id: 'connect-remote', command: 'beeper setup --remote ' }, + ] + if (serverInstalled) availableActions.push(installedServerAction(true)) + return { + availableActions, + localDesktop: { + authSource: local.session.auth.source, + baseURL: local.target.baseURL, + connectedAccounts: local.accounts, + dataDir: local.session.dataDir, + signedInAs: local.session.userID, + }, + message: local.readiness.state === 'ready' + ? 'Beeper Desktop is signed in and ready.' + : 'Beeper Desktop is signed in, but setup is not finished.', + readiness: local.readiness, + recommendedAction: { id: 'use-desktop-session', command: `${setupCmd} --local` }, + state: local.readiness.state === 'ready' ? 'desktop-ready' : 'desktop-session-found', + target: publicTarget(local.target), + } +} + +function printStatus(title: string, status: string): void { + process.stdout.write(`${title}\n\n`) + process.stdout.write(`Status: ${status}\n\n`) +} + +function printProgress(flags: SetupFlags, message: string): void { + if (!interactive(flags)) return + process.stdout.write(`${message}...\n`) +} + +function printInteractiveSetupStatus(message: string, detail?: string): undefined { + process.stdout.write(`${message}\n`) + if (detail) process.stdout.write(`${detail}\n`) + return undefined +} + +async function launchAndPoll(target: Target, setupCmd: string, flags: SetupFlags): Promise { + if (flags.events) writeEvent('setup_step', { step: 'launch', target: target.id }) + if (interactive(flags)) process.stdout.write('Opening Beeper Desktop...\n') + await launchDesktopApp(target) + const readiness = await pollReadiness(target, 10_000) + const detail = readiness.state === 'target-unreachable' + ? `Run \`${setupCmd}\` again after Beeper Desktop finishes starting.` + : setupDetailForReadiness(readiness) + if (!interactive(flags)) return { readiness, target: publicTarget(target) } + process.stdout.write('Launched Beeper Desktop\n') + if (detail) process.stdout.write(`${detail}\n`) + if (readiness.state === 'target-unreachable') { + process.stdout.write('\nNext:\n') + process.stdout.write(` ${setupCmd}\n`) + process.stdout.write(' beeper status\n') + } + return undefined +} + +async function pollReadiness(target: Target, timeoutMs: number): Promise { + const started = Date.now() + let readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id, token: false }) + while (readiness.state === 'target-unreachable' && Date.now() - started < timeoutMs) { + await new Promise(resolve => setTimeout(resolve, 500)) + readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id, token: false }) + } + return readiness +} + +async function maybeDriveOnboarding(result: SetupLoginResult, flags: SetupFlags): Promise { + if (!interactive(flags)) return result + if (result.readiness.state !== 'needs-verification' && result.readiness.state !== 'verification-in-progress') return result + process.stdout.write('Continuing verification...\n\n') + await driveVerification({ baseURL: result.target.baseURL, target: result.target.id, force: flags.force }) + return { + ...result, + readiness: await evaluateReadiness({ baseURL: result.target.baseURL, target: result.target.id }), + target: result.target, + } +} + +async function installWithCopy(type: ManagedTargetType, flags: SetupFlags): Promise { + const label = type === 'desktop' ? 'Beeper Desktop' : 'local Beeper Server' + const channel = flags.channel === 'nightly' ? 'nightly' : 'stable' + const serverEnv = normalizeServerEnv(flags['server-env']) + const source = type === 'server' ? new URL(SERVER_ENV_API_BASE_URLS[serverEnv]).host : 'beeper.com' + if (interactive(flags)) process.stdout.write(`Installing ${label} ${channel} from ${source}...\n`) + if (type === 'desktop') await installDesktop({ channel, serverEnv }) + else await installServer({ channel, serverEnv }) + if (interactive(flags)) process.stdout.write(`Installed ${label} ${channel}.\n\n`) +} + +function interactive(flags: SetupFlags): boolean { + return !flags.json && process.stdin.isTTY +} + +function setupStateOutput(detected: Exclude, target: Target): Record { + if (detected.kind === 'installed-not-running') { + const serverAction = installedServerAction(detected.serverInstalled) + return setupActionEnvelope({ + availableActions: [ + { id: 'launch-desktop', command: 'beeper setup --desktop --force' }, + { id: 'connect-remote', command: 'beeper setup --remote ' }, + serverAction, + ], + message: 'Beeper Desktop is installed but not running.', + recommendedAction: { id: 'launch-desktop', command: 'beeper setup --desktop --force' }, + state: 'desktop-installed-not-running', + target, + }) + } + if (detected.kind === 'running-signed-out') { + const availableActions = [ + { id: 'open-desktop', command: 'beeper setup --desktop --force' }, + { id: 'connect-remote', command: 'beeper setup --remote ' }, + ] + if (detected.serverInstalled) availableActions.push(installedServerAction(true)) + return setupActionEnvelope({ + availableActions, + message: 'Beeper Desktop is running but not signed in.', + readiness: detected.readiness, + recommendedAction: { id: 'open-desktop', command: 'beeper setup --desktop --force' }, + state: 'desktop-running-signed-out', + target, + }) + } + if (detected.kind === 'session-unreadable') { + const availableActions = [ + { id: 'desktop-oauth', command: 'beeper setup --oauth --force' }, + { id: 'connect-remote', command: 'beeper setup --remote ' }, + ] + if (detected.serverInstalled) availableActions.push(installedServerAction(true)) + return setupActionEnvelope({ + availableActions, + detail: detected.reason, + message: 'Beeper Desktop is running, but CLI could not read the local session.', + readiness: detected.readiness, + recommendedAction: { id: 'desktop-oauth', command: 'beeper setup --oauth --force' }, + state: 'desktop-running-session-unreadable', + target, + }) + } + const serverAction = installedServerAction(detected.serverInstalled) + return setupActionEnvelope({ + availableActions: [ + { id: 'install-desktop', command: 'beeper setup --desktop --install --force' }, + serverAction, + { id: 'connect-remote', command: 'beeper setup --remote ' }, + ], + message: 'No Beeper Desktop installation was found on this device.', + recommendedAction: detected.serverInstalled ? serverAction : { id: 'install-desktop', command: 'beeper setup --desktop --install --force' }, + state: 'desktop-not-installed', + target, + }) +} + +function installedServerAction(installed: boolean): SetupAction { + return installed + ? { id: 'use-installed-server', command: 'beeper setup --server --force' } + : { id: 'install-server', command: 'beeper setup --server --install --force' } +} + +function currentTargetBrokenOutput(target: Target, readiness: Readiness, serverInstalled: boolean): Record { + return { + availableActions: [ + { id: 'retry-current', command: `beeper setup --target ${target.id}` }, + { id: 'use-desktop', command: 'beeper setup --desktop' }, + installedServerAction(serverInstalled), + { id: 'connect-remote', command: 'beeper setup --remote ' }, + ], + message: `Beeper CLI is set up for ${target.name ?? target.id}, but it is not reachable.`, + readiness, + recommendedAction: { id: 'retry-current', command: `beeper setup --target ${target.id}` }, + state: 'current-target-unreachable', + target: publicTarget(target), + } +} + +function setupActionEnvelope(options: { + availableActions: SetupAction[] + detail?: string + message: string + readiness?: Readiness + recommendedAction: SetupAction + state: string + target: Target +}): Record { + return { + availableActions: options.availableActions, + detail: options.detail, + message: options.message, + readiness: options.readiness, + recommendedAction: options.recommendedAction, + state: options.state, + target: publicTarget(options.target), + } +} + +function setupDetailForReadiness(readiness: Readiness): string | undefined { + if (readiness.state === 'needs-login') return 'Sign in to Beeper Desktop, then run `beeper setup` again.' + if (readiness.state === 'needs-verification' || readiness.state === 'verification-in-progress') return 'Continue verification to finish setup.' + if (readiness.state === 'needs-recovery-key' || readiness.state === 'needs-secrets') return 'Finish recovery in Beeper, then run `beeper setup` again.' + if (readiness.state === 'needs-cross-signing-setup') return 'Finish cross-signing setup in Beeper, then run `beeper setup` again.' + if (readiness.state === 'needs-first-sync' || readiness.state === 'initializing') return 'Beeper is still syncing. You can rerun `beeper setup` at any time.' + return readiness.message +} + +async function uniqueRemoteName(url: string): Promise { + const base = remoteName(url) + const targets = await listTargets() + const ids = new Set(targets.map(target => target.id)) + if (!ids.has(base)) return base + for (let index = 2; index < 100; index += 1) { + const id = `${base}-${index}` + if (!ids.has(id)) return id + } + return `remote-${Date.now()}` +} + +function setupCommand(target: Target): string { + return target.id === builtInDesktopTargetID ? 'beeper setup' : `beeper setup --target ${target.id}` +} + +function remoteName(url: string): string { + try { + return new URL(url).hostname.replace(/[^a-zA-Z0-9._-]/g, '-') || 'remote' + } catch { + return 'remote' + } +} diff --git a/packages/cli/src/cli/types.ts b/packages/cli/src/cli/types.ts new file mode 100644 index 00000000..9502485b --- /dev/null +++ b/packages/cli/src/cli/types.ts @@ -0,0 +1,68 @@ +export type FlagSpec = { + name: string + aliases?: string[] + short?: string + default?: boolean | number | string + description?: string + env?: string[] + enum?: string[] + multiple?: boolean + placeholder?: string + required?: boolean + type: 'boolean' | 'string' | 'integer' +} + +export type ArgSpec = { + name: string + description?: string + required?: boolean + variadic?: boolean +} + +export type CommandRisk = 'read' | 'write' | 'destructive' + +export type CommandContext = { + args: string[] + commandPath: string[] + flags: Record + globalFlags: GlobalFlags +} + +export type CommandSpec = { + args?: ArgSpec[] + aliases?: string[][] + description: string + examples?: string[] + flags?: FlagSpec[] + hidden?: boolean + mcp?: boolean + output?: 'accounts' | 'chats' | 'contacts' | 'diagnostic' | 'generic' | 'messages' | 'status' | 'targets' + path: string[] + risk: CommandRisk + run(ctx: CommandContext): Promise +} + +export type GlobalFlags = { + accessToken?: string + account?: string[] + color: 'auto' | 'always' | 'never' + debug: boolean + disableCommands?: string + dryRun: boolean + enableCommands?: string + enableCommandsExact?: string + events: boolean + force: boolean + full: boolean + home?: string + json: boolean + noInput: boolean + plain: boolean + readOnly: boolean + resultsOnly: boolean + safetyProfile?: string + select?: string + target?: string + timeout?: string + wrapUntrusted: boolean +} diff --git a/packages/cli/src/commands.generated.ts b/packages/cli/src/commands.generated.ts deleted file mode 100644 index ed34e12a..00000000 --- a/packages/cli/src/commands.generated.ts +++ /dev/null @@ -1,211 +0,0 @@ -import Command0 from './commands/accounts/add.js' -import Command1 from './commands/accounts/list.js' -import Command2 from './commands/accounts/remove.js' -import Command3 from './commands/accounts/show.js' -import Command4 from './commands/accounts/use.js' -import Command5 from './commands/api/get.js' -import Command6 from './commands/api/post.js' -import Command7 from './commands/api/request.js' -import Command8 from './commands/auth/email/response.js' -import Command9 from './commands/auth/email/start.js' -import Command10 from './commands/auth/logout.js' -import Command11 from './commands/auth/status.js' -import Command12 from './commands/autocomplete.js' -import Command13 from './commands/bridges/list.js' -import Command14 from './commands/bridges/show.js' -import Command15 from './commands/chats/archive.js' -import Command16 from './commands/chats/avatar.js' -import Command17 from './commands/chats/description.js' -import Command18 from './commands/chats/disappear.js' -import Command19 from './commands/chats/draft.js' -import Command20 from './commands/chats/focus.js' -import Command21 from './commands/chats/list.js' -import Command22 from './commands/chats/mark-read.js' -import Command23 from './commands/chats/mark-unread.js' -import Command24 from './commands/chats/mute.js' -import Command25 from './commands/chats/notify-anyway.js' -import Command26 from './commands/chats/pin.js' -import Command27 from './commands/chats/priority.js' -import Command28 from './commands/chats/remind.js' -import Command29 from './commands/chats/rename.js' -import Command30 from './commands/chats/search.js' -import Command31 from './commands/chats/show.js' -import Command32 from './commands/chats/start.js' -import Command33 from './commands/chats/unarchive.js' -import Command34 from './commands/chats/unmute.js' -import Command35 from './commands/chats/unpin.js' -import Command36 from './commands/chats/unremind.js' -import Command37 from './commands/completion.js' -import Command38 from './commands/config/get.js' -import Command39 from './commands/config/path.js' -import Command40 from './commands/config/reset.js' -import Command41 from './commands/config/set.js' -import Command42 from './commands/contacts/list.js' -import Command43 from './commands/contacts/search.js' -import Command44 from './commands/contacts/show.js' -import Command45 from './commands/docs.js' -import Command46 from './commands/doctor.js' -import Command47 from './commands/export.js' -import Command48 from './commands/install/desktop.js' -import Command49 from './commands/install/server.js' -import Command50 from './commands/man.js' -import Command51 from './commands/media/download.js' -import Command52 from './commands/messages/context.js' -import Command53 from './commands/messages/delete.js' -import Command54 from './commands/messages/edit.js' -import Command55 from './commands/messages/export.js' -import Command56 from './commands/messages/list.js' -import Command57 from './commands/messages/search.js' -import Command58 from './commands/messages/show.js' -import Command59 from './commands/plugins.js' -import Command60 from './commands/plugins/available.js' -import Command61 from './commands/presence.js' -import Command62 from './commands/rpc.js' -import Command63 from './commands/send/file.js' -import Command64 from './commands/send/react.js' -import Command65 from './commands/send/sticker.js' -import Command66 from './commands/send/text.js' -import Command67 from './commands/send/unreact.js' -import Command68 from './commands/send/voice.js' -import Command69 from './commands/setup.js' -import Command70 from './commands/status.js' -import Command71 from './commands/targets/add/desktop.js' -import Command72 from './commands/targets/add/remote.js' -import Command73 from './commands/targets/add/server.js' -import Command74 from './commands/targets/disable.js' -import Command75 from './commands/targets/enable.js' -import Command76 from './commands/targets/list.js' -import Command77 from './commands/targets/logs.js' -import Command78 from './commands/targets/remove.js' -import Command79 from './commands/targets/restart.js' -import Command80 from './commands/targets/show.js' -import Command81 from './commands/targets/start.js' -import Command82 from './commands/targets/status.js' -import Command83 from './commands/targets/stop.js' -import Command84 from './commands/targets/use.js' -import Command85 from './commands/update.js' -import Command86 from './commands/verify.js' -import Command87 from './commands/verify/approve.js' -import Command88 from './commands/verify/cancel.js' -import Command89 from './commands/verify/list.js' -import Command90 from './commands/verify/qr-confirm.js' -import Command91 from './commands/verify/qr-scan.js' -import Command92 from './commands/verify/recovery-key.js' -import Command93 from './commands/verify/reset-recovery-key.js' -import Command94 from './commands/verify/sas.js' -import Command95 from './commands/verify/sas-confirm.js' -import Command96 from './commands/verify/show.js' -import Command97 from './commands/verify/start.js' -import Command98 from './commands/verify/status.js' -import Command99 from './commands/version.js' -import Command100 from './commands/watch.js' - -export const commands = { - 'accounts': Command1, - 'accounts:add': Command0, - 'accounts:chats': Command21, - 'accounts:list': Command1, - 'accounts:remove': Command2, - 'accounts:show': Command3, - 'accounts:use': Command4, - 'api:get': Command5, - 'api:post': Command6, - 'api:request': Command7, - 'auth:email:response': Command8, - 'auth:email:start': Command9, - 'auth:logout': Command10, - 'auth:status': Command11, - 'autocomplete': Command12, - 'bridges': Command13, - 'bridges:list': Command13, - 'bridges:show': Command14, - 'chats': Command21, - 'chats:archive': Command15, - 'chats:avatar': Command16, - 'chats:description': Command17, - 'chats:disappear': Command18, - 'chats:draft': Command19, - 'chats:focus': Command20, - 'chats:list': Command21, - 'chats:mark-read': Command22, - 'chats:mark-unread': Command23, - 'chats:mute': Command24, - 'chats:notify-anyway': Command25, - 'chats:pin': Command26, - 'chats:priority': Command27, - 'chats:remind': Command28, - 'chats:rename': Command29, - 'chats:search': Command30, - 'chats:show': Command31, - 'chats:start': Command32, - 'chats:unarchive': Command33, - 'chats:unmute': Command34, - 'chats:unpin': Command35, - 'chats:unremind': Command36, - 'completion': Command37, - 'config:get': Command38, - 'config:path': Command39, - 'config:reset': Command40, - 'config:set': Command41, - 'contacts': Command42, - 'contacts:list': Command42, - 'contacts:search': Command43, - 'contacts:show': Command44, - 'docs': Command45, - 'doctor': Command46, - 'export': Command47, - 'install:desktop': Command48, - 'install:server': Command49, - 'man': Command50, - 'media:download': Command51, - 'messages:context': Command52, - 'messages:delete': Command53, - 'messages:edit': Command54, - 'messages:export': Command55, - 'messages:list': Command56, - 'messages:search': Command57, - 'messages:show': Command58, - 'plugins': Command59, - 'plugins:available': Command60, - 'presence': Command61, - 'rpc': Command62, - 'send:file': Command63, - 'send:react': Command64, - 'send:sticker': Command65, - 'send:text': Command66, - 'send:unreact': Command67, - 'send:voice': Command68, - 'setup': Command69, - 'status': Command70, - 'targets': Command76, - 'targets:add:desktop': Command71, - 'targets:add:remote': Command72, - 'targets:add:server': Command73, - 'targets:disable': Command74, - 'targets:enable': Command75, - 'targets:list': Command76, - 'targets:logs': Command77, - 'targets:remove': Command78, - 'targets:restart': Command79, - 'targets:show': Command80, - 'targets:start': Command81, - 'targets:status': Command82, - 'targets:stop': Command83, - 'targets:use': Command84, - 'update': Command85, - 'verify': Command86, - 'verify:approve': Command87, - 'verify:cancel': Command88, - 'verify:list': Command89, - 'verify:qr-confirm': Command90, - 'verify:qr-scan': Command91, - 'verify:recovery-key': Command92, - 'verify:reset-recovery-key': Command93, - 'verify:sas': Command94, - 'verify:sas-confirm': Command95, - 'verify:show': Command96, - 'verify:start': Command97, - 'verify:status': Command98, - 'version': Command99, - 'watch': Command100, -} diff --git a/packages/cli/src/commands/_complete.ts b/packages/cli/src/commands/_complete.ts deleted file mode 100644 index a422828a..00000000 --- a/packages/cli/src/commands/_complete.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Args, Command, Flags } from '@oclif/core' -import { resolveTarget, listTargets } from '../lib/targets.js' -import { BeeperDesktop } from '@beeper/desktop-api' - -type Kind = 'chat' | 'account' | 'contact' | 'target' - -export default class Complete extends Command { - static override hidden = true - static override summary = 'Internal: emit completion suggestions for chats/contacts/accounts/targets' - static override description = 'Used by the semantic shell completion wrapper. Prints one suggestion per line as `id\\tdescription`. Stays silent on any error so the shell completion never produces noise.' - static override args = { - kind: Args.string({ required: true, description: 'chat|account|contact|target', options: ['chat', 'account', 'contact', 'target'] }), - } - static override flags = { - query: Flags.string({ description: 'Filter suggestions by a substring (already-typed prefix)' }), - target: Flags.string({ description: 'Target name override' }), - limit: Flags.integer({ default: 25, description: 'Max suggestions to print' }), - 'timeout-ms': Flags.integer({ default: 1500, description: 'Live-fetch timeout' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(Complete) - const kind = args.kind as Kind - try { - const lines = await Promise.race([ - emit(kind, flags), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), flags['timeout-ms'])), - ]) - const filtered = flags.query ? lines.filter(line => fuzzy(line, flags.query!)) : lines - for (const line of filtered.slice(0, flags.limit)) process.stdout.write(`${line}\n`) - } catch { - // intentionally silent: completion noise is worse than no suggestions - } - } -} - -async function emit(kind: Kind, flags: { target?: string }): Promise { - if (kind === 'target') { - const targets = await listTargets() - return targets.map(t => `${t.id}\t${t.type} ${t.baseURL}`) - } - const target = await resolveTarget({ target: flags.target }) - const token = target.auth?.accessToken - if (!token) return [] - const client = new BeeperDesktop({ baseURL: target.baseURL, accessToken: token, logLevel: 'warn' }) - - if (kind === 'account') { - const list = await client.accounts.list() - const rows = Array.isArray(list) ? list : ((list as { items?: unknown[] }).items ?? []) - return rows - .map((row): string | undefined => { - if (!row || typeof row !== 'object') return undefined - const r = row as Record - const label = [r.network, r.user && typeof r.user === 'object' ? (r.user as Record).displayName : undefined].filter(Boolean).join(' ') - return r.accountID ? `${String(r.accountID)}\t${label}` : undefined - }) - .filter((line): line is string => !!line) - } - - if (kind === 'chat') { - const out: string[] = [] - for await (const chat of client.chats.list({ limit: 50 } as never)) { - const c = chat as unknown as Record - const id = c.localChatID || c.id - const title = (c.title as string | undefined) ?? '' - const network = (c.network as string | undefined) ?? '' - if (id) out.push(`${String(id)}\t${title}${network ? ` (${network})` : ''}`) - if (out.length >= 50) break - } - return out - } - - if (kind === 'contact') { - const accountsList = await client.accounts.list() - const accountIDs = (Array.isArray(accountsList) ? accountsList : ((accountsList as { items?: unknown[] }).items ?? [])) - .map((row: unknown) => (row && typeof row === 'object' ? (row as Record).accountID : undefined)) - .filter((id): id is string => typeof id === 'string') - const out: string[] = [] - for (const accountID of accountIDs.slice(0, 3)) { - const page = await client.accounts.contacts.list(accountID, { limit: 25 } as never) - const rows = Array.isArray(page) ? page : ((page as { items?: unknown[] }).items ?? []) - for (const contact of rows) { - const c = contact as Record - const id = c.id || c.username - const name = (c.fullName as string | undefined) || (c.displayName as string | undefined) || '' - if (id) out.push(`${String(id)}\t${name}`) - if (out.length >= 50) break - } - if (out.length >= 50) break - } - return out - } - - return [] -} - -function fuzzy(line: string, query: string): boolean { - const q = query.trim().toLowerCase() - if (!q) return true - return line.toLowerCase().includes(q) -} diff --git a/packages/cli/src/commands/accounts/add.ts b/packages/cli/src/commands/accounts/add.ts deleted file mode 100644 index 1db3c81b..00000000 --- a/packages/cli/src/commands/accounts/add.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { createInterface } from 'node:readline/promises' -import { stdin as input, stdout as output } from 'node:process' -import { Args, Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import type { Bridge, LoginFlow } from '@beeper/desktop-api/resources/bridges.js' -import { createClient } from '../../lib/client.js' -import { printAccountLoginStep, runGuidedAccountLogin } from '../../lib/account-login.js' -import { printData } from '../../lib/output.js' - -type AccountType = Bridge - -export default class AccountsAdd extends BeeperCommand { - static override summary = 'Connect a chat account by bridge' - static override description = '`accounts add` without an argument opens the guided bridge chooser. Pass a bridge ID when you already know which chat network connector to use.' - static override args = { - bridge: Args.string({ description: 'Bridge ID, network, or type to connect. Omit to list available bridges.' }), - } - static override flags = { - cookie: Flags.string({ description: 'Cookie value for non-interactive login, in name=value form. Repeat for multiple cookies.', multiple: true }), - field: Flags.string({ description: 'Field value for non-interactive login, in id=value form. Repeat for multiple fields.', multiple: true }), - flow: Flags.string({ description: 'Login flow ID. If omitted, Desktop chooses the default flow.' }), - guided: Flags.boolean({ default: true, allowNo: true, description: 'Prompt through login steps until completion' }), - 'login-id': Flags.string({ description: 'Existing login ID to re-login as' }), - 'non-interactive': Flags.boolean({ default: false, description: 'Do not prompt; require --flow, --field, and --cookie values when needed.' }), - webview: Flags.boolean({ default: false, description: 'Use Bun.WebView to collect cookie login fields when a cookie step is returned.' }), - 'webview-backend': Flags.string({ default: 'chrome', description: 'Bun.WebView backend for cookie login steps.', options: ['auto', 'chrome', 'webkit'] }), - 'webview-timeout': Flags.integer({ default: 120, description: 'Seconds to wait for Bun.WebView cookie collection.' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(AccountsAdd) - ensureWritable(flags) - const client = await createClient(flags) - - if (!args.bridge) { - const bridges = await client.bridges.list() - if (flags.json) { - await printData(bridges, 'json') - return - } - if (flags.guided && !flags['non-interactive'] && process.stdin.isTTY) { - args.bridge = await chooseAccountType(bridges.items) - } else { - printAvailableAccounts(bridges.items) - return - } - } - - const bridges = await client.bridges.list() - const accountType = resolveAccountType(bridges.items, args.bridge) - if (accountType.status !== 'available') { - const suffix = accountType.statusText ? `: ${accountType.statusText}` : '' - throw new Error(`${accountType.displayName} is not available${suffix}`) - } - - let flowID = flags.flow - if (!flowID) { - const flows = await client.bridges.loginFlows.list(accountType.id) - const loginFlows = flows.items - if (loginFlows.length > 1) { - if (flags.guided && !flags.json && !flags['non-interactive']) flowID = await chooseLoginFlow(loginFlows) - else throw new Error(`Multiple sign-in methods are available for ${accountType.displayName}. Pass --flow.`) - } else { - flowID = loginFlows[0]?.id - } - if (!flowID) throw new Error(`No login flows returned for ${accountType.displayName}.`) - if (!flags.json && loginFlows.length > 1) this.log(`Using flow ${flowID}`) - } - - const step = await client.bridges.loginSessions.create(accountType.id, { - flowID, - loginID: flags['login-id'], - }) - const result = flags.guided ? await runGuidedAccountLogin(client, accountType.id, step, { - cookies: parseKeyValueFlags(flags.cookie, '--cookie'), - fields: parseKeyValueFlags(flags.field, '--field'), - nonInteractive: flags['non-interactive'], - webview: flags.webview, - webviewBackend: flags['webview-backend'] as 'auto' | 'chrome' | 'webkit', - webviewTimeoutMs: flags['webview-timeout'] * 1000, - }) : step - if (flags.json) await printData(result, 'json') - else await printAccountLoginStep(result) - } -} - -async function chooseAccountType(items: AccountType[]): Promise { - const available = items.filter(item => item.status === 'available') - if (!available.length) throw new Error('No available bridges to connect.') - - process.stdout.write('Choose a bridge to connect an account:\n') - available.forEach((account, index) => { - const multiple = account.supportsMultipleAccounts ? 'multiple allowed' : 'single account' - process.stdout.write(` ${index + 1}. ${account.displayName} (${account.id}) - ${multiple}\n`) - }) - - const rl = createInterface({ input, output }) - try { - for (;;) { - const answer = (await rl.question('Select a bridge: ')).trim() - const selected = /^\d+$/.test(answer) ? Number.parseInt(answer, 10) : Number.NaN - if (Number.isInteger(selected) && selected >= 1 && selected <= available.length) return available[selected - 1]!.id - const byID = available.find(account => account.id === answer) - if (byID) return byID.id - process.stdout.write('Choose one of the listed bridges.\n') - } - } finally { - rl.close() - } -} - -function printAvailableAccounts(items: AccountType[]): void { - const sections: Array<[string, AccountType[]]> = [ - ['On-Device Accounts', items.filter(item => item.provider === 'local')], - ['Beeper Cloud Accounts', items.filter(item => item.provider === 'cloud')], - ['Self-Hosted Accounts', items.filter(item => item.provider === 'self-hosted')], - ] - - process.stdout.write('Choose a bridge to connect an account:\n\n') - for (const [title, accounts] of sections) { - if (!accounts.length) continue - process.stdout.write(`${title}\n`) - for (const account of accounts) { - const status = account.statusText ?? statusLabel(account) - const command = account.status === 'available' ? `beeper accounts add ${account.id}` : undefined - const multiple = account.supportsMultipleAccounts ? 'multiple allowed' : 'single account' - process.stdout.write(` ${account.displayName} (${account.id}) - ${multiple}${status ? ` - ${status}` : ''}\n`) - if (command) process.stdout.write(` ${command}\n`) - } - process.stdout.write('\n') - } - process.stdout.write('Run `beeper bridges list` for the scriptable catalog or `beeper bridges show ` for login flows.\n') -} - -function resolveAccountType(items: AccountType[], input: string): AccountType { - const normalizedInput = normalize(input) - const exact = items.filter(item => [ - item.id, - item.displayName, - item.network, - item.type, - ].some(value => normalize(value) === normalizedInput)) - - if (exact.length === 1) return exact[0]! - if (exact.length > 1) throw ambiguousAccountType(input, exact) - - const partial = items.filter(item => [ - item.id, - item.displayName, - item.network, - item.type, - ].some(value => normalize(value).includes(normalizedInput))) - - if (partial.length === 1) return partial[0]! - if (partial.length > 1) throw ambiguousAccountType(input, partial) - throw new Error(`Unknown bridge "${input}". Run \`beeper bridges list\` to list available bridges.`) -} - -function ambiguousAccountType(input: string, matches: AccountType[]): Error { - const options = matches.map(item => `${item.displayName} (${item.id})`).join(', ') - return new Error(`Account type ${input} is ambiguous. Use one of: ${options}`) -} - -function statusLabel(account: AccountType): string | undefined { - if (account.status === 'available') return undefined - if (account.status === 'connected') return `${account.displayName} Connected` - return account.status.replaceAll('_', ' ') -} - -function normalize(value: string | undefined): string { - return (value ?? '').toLowerCase().replaceAll(/[^a-z0-9]+/g, '') -} - -function parseKeyValueFlags(values: string[] | undefined, flagName: string): Record { - const parsed: Record = {} - for (const value of values ?? []) { - const equalsIndex = value.indexOf('=') - if (equalsIndex <= 0) throw new Error(`${flagName} must use name=value form.`) - parsed[value.slice(0, equalsIndex)] = value.slice(equalsIndex + 1) - } - - return parsed -} - -async function chooseLoginFlow(flows: LoginFlow[]): Promise { - process.stdout.write('Choose how you want to sign in:\n') - flows.forEach((flow, index) => { - const description = flow.description ? ` - ${flow.description}` : '' - process.stdout.write(` ${index + 1}. ${flow.name}${description}\n`) - }) - - const rl = createInterface({ input, output }) - try { - for (;;) { - const answer = (await rl.question('Select a sign-in method: ')).trim() - const selected = Number.parseInt(answer, 10) - if (Number.isInteger(selected) && selected >= 1 && selected <= flows.length) return flows[selected - 1]!.id - const byID = flows.find(flow => flow.id === answer) - if (byID) return byID.id - process.stdout.write('Choose one of the listed sign-in methods.\n') - } - } finally { - rl.close() - } -} diff --git a/packages/cli/src/commands/accounts/list.ts b/packages/cli/src/commands/accounts/list.ts deleted file mode 100644 index 45f70197..00000000 --- a/packages/cli/src/commands/accounts/list.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printList } from '../../lib/output.js' -import { resolveAccountIDs } from '../../lib/resolve.js' -import { readConfig } from '../../lib/targets.js' - -export default class AccountsList extends BeeperCommand { - static override summary = 'List connected accounts' - static override flags = { - account: Flags.string({ multiple: true, description: 'Filter by account selector' }), - ids: Flags.boolean({ default: false, description: 'Print only account IDs' }), - } - async run(): Promise { - const { flags } = await this.parse(AccountsList) - const client = await createClient(flags) - // Account filter is an explicit override here; do not auto-apply defaultAccount. - const selected = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true, applyDefault: false }) - const config = await readConfig() - const response = await client.accounts.list() - const rows = Array.isArray(response) ? response : ((response as any).items ?? []) - const filtered = selected?.length ? rows.filter((row: any) => selected.includes(row.accountID ?? row.id)) : rows - const items = filtered.map((row: any) => ({ - ...row, - default: (row.accountID ?? row.id) === config.defaultAccount || undefined, - })) - if (flags.ids) for (const item of items) process.stdout.write(`${String((item as any).accountID ?? (item as any).id)}\n`) - else await printList(items, flags.json ? 'json' : 'human', { title: 'No accounts connected', subtitle: 'Run beeper accounts add to add one.' }) - } -} diff --git a/packages/cli/src/commands/accounts/remove.ts b/packages/cli/src/commands/accounts/remove.ts deleted file mode 100644 index 6bf7180f..00000000 --- a/packages/cli/src/commands/accounts/remove.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printSuccess } from '../../lib/output.js' -import { resolveAccountID } from '../../lib/resolve.js' - -export default class AccountsRemove extends BeeperCommand { - static override summary = 'Remove an account' - static override args = { - account: Args.string({ required: true, description: 'Account selector (ID, network, bridge, or user identity)' }), - } - async run(): Promise { - const { args, flags } = await this.parse(AccountsRemove) - ensureWritable(flags) - const client = await createClient(flags) - const accountID = await resolveAccountID(client, args.account) - const accounts = client.accounts as any - if (accounts.delete) await accounts.delete(accountID) - else if (accounts.remove) await accounts.remove(accountID) - else throw new Error('This Desktop API does not expose account removal.') - await printSuccess({ message: `Removed account: ${accountID}`, data: { accountID } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/accounts/show.ts b/packages/cli/src/commands/accounts/show.ts deleted file mode 100644 index dc54a30d..00000000 --- a/packages/cli/src/commands/accounts/show.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveAccountID } from '../../lib/resolve.js' - -export default class AccountsShow extends BeeperCommand { - static override summary = 'Show account details' - static override args = { - account: Args.string({ required: true, description: 'Account selector (ID, network, bridge, or user identity)' }), - } - async run(): Promise { - const { args, flags } = await this.parse(AccountsShow) - const client = await createClient(flags) - const accountID = await resolveAccountID(client, args.account) - const account = client.accounts.retrieve ? await client.accounts.retrieve(accountID) : (await client.accounts.list()).find((item: any) => (item.accountID ?? item.id) === accountID) - await printData(account, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/accounts/use.ts b/packages/cli/src/commands/accounts/use.ts deleted file mode 100644 index cbb88f4f..00000000 --- a/packages/cli/src/commands/accounts/use.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printSuccess } from '../../lib/output.js' -import { resolveAccountID } from '../../lib/resolve.js' -import { updateConfig } from '../../lib/targets.js' - -export default class AccountsUse extends BeeperCommand { - static override summary = 'Select a default account for account-scoped commands' - static override description = 'Persists the choice in CLI config. Account-scoped commands that take --account fall back to this default when --account is omitted. Use `beeper accounts use ""` (or `beeper config set defaultAccount ""`) to clear.' - static override args = { - account: Args.string({ required: true, description: 'Account selector (ID, network, bridge, user identity), or "" to clear.' }), - } - async run(): Promise { - const { args, flags } = await this.parse(AccountsUse) - ensureWritable(flags) - if (args.account === '') { - await updateConfig(config => ({ ...config, defaultAccount: undefined })) - await printSuccess({ message: 'Cleared default account' }, flags.json ? 'json' : 'human') - return - } - const client = await createClient(flags) - const accountID = await resolveAccountID(client, args.account) - await updateConfig(config => ({ ...config, defaultAccount: accountID })) - await printSuccess({ message: `Default account: ${accountID}`, data: { accountID } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/api/get.ts b/packages/cli/src/commands/api/get.ts deleted file mode 100644 index a36515d4..00000000 --- a/packages/cli/src/commands/api/get.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { appRequest } from '../../lib/app-api.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' - -export default class ApiGet extends BeeperCommand { - static override summary = 'Call a raw Desktop API GET path' - static override args = { - path: Args.string({ description: 'API path, for example /v1/info', required: true }), - } - static override flags = { - json: Flags.boolean({ default: true, allowNo: true, description: 'Print JSON' }), - 'no-auth': Flags.boolean({ default: false, description: 'Call a public API path without a bearer token' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(ApiGet) - if (flags['no-auth']) { - await printData(await appRequest('GET', args.path, { baseURL: flags['base-url'], target: flags.target, token: false }), flags.json ? 'json' : 'human') - return - } - const client = await createClient(flags) - await printData(await client.get(args.path), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/api/post.ts b/packages/cli/src/commands/api/post.ts deleted file mode 100644 index 95c573ce..00000000 --- a/packages/cli/src/commands/api/post.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { appRequest } from '../../lib/app-api.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' - -export default class ApiPost extends BeeperCommand { - static override summary = 'Call a raw Desktop API POST path with a JSON body' - static override args = { - path: Args.string({ description: 'API path, for example /v1/messages/{chatID}/send', required: true }), - } - static override flags = { - body: Flags.string({ default: '{}', description: 'JSON request body' }), - json: Flags.boolean({ default: true, allowNo: true, description: 'Print JSON' }), - 'no-auth': Flags.boolean({ default: false, description: 'Call a public API path without a bearer token' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(ApiPost) - ensureWritable(flags) - let body: Record - try { - body = JSON.parse(flags.body) as Record - } catch { - throw new Error(`--body is not valid JSON: ${flags.body}`) - } - if (flags['no-auth']) { - await printData(await appRequest('POST', args.path, { baseURL: flags['base-url'], body, target: flags.target, token: false }), flags.json ? 'json' : 'human') - return - } - const client = await createClient(flags) - await printData(await client.post(args.path, { body }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/api/request.ts b/packages/cli/src/commands/api/request.ts deleted file mode 100644 index db3c297b..00000000 --- a/packages/cli/src/commands/api/request.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { appRequest, type AppRequestMethod } from '../../lib/app-api.js' -import { printData } from '../../lib/output.js' - -export default class ApiRequest extends BeeperCommand { - static override summary = 'Call a raw Desktop API path with any supported HTTP method' - static override args = { - method: Args.string({ description: 'HTTP method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], required: true }), - path: Args.string({ description: 'API path, for example /v1/info', required: true }), - } - static override flags = { - body: Flags.string({ description: 'JSON request body' }), - json: Flags.boolean({ default: true, allowNo: true, description: 'Print JSON' }), - 'no-auth': Flags.boolean({ default: false, description: 'Call a public API path without a bearer token' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(ApiRequest) - const method = args.method as AppRequestMethod - if (method !== 'GET') ensureWritable(flags) - const body = flags.body ? JSON.parse(flags.body) as Record : undefined - if (flags['no-auth']) { - await printData(await appRequest(method, args.path, { baseURL: flags['base-url'], body, target: flags.target, token: false }), flags.json ? 'json' : 'human') - return - } - await printData(await appRequest(method, args.path, { baseURL: flags['base-url'], body, target: flags.target }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/auth/email/response.ts b/packages/cli/src/commands/auth/email/response.ts deleted file mode 100644 index 1a517ecb..00000000 --- a/packages/cli/src/commands/auth/email/response.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../../lib/command.js' -import { resolveTarget } from '../../../lib/targets.js' -import { finishEmailSetup } from '../../../lib/setup-login.js' -import { printData } from '../../../lib/output.js' - -export default class AuthEmailResponse extends BeeperCommand { - static override summary = 'Finish email sign-in with a verification code' - static override flags = { - code: Flags.string({ required: true, description: 'Email verification code' }), - 'setup-request-id': Flags.string({ required: true, description: 'Setup request ID from auth email start' }), - username: Flags.string({ description: 'Username to use if setup creates a new account' }), - yes: Flags.boolean({ default: false, description: 'Accept required registration prompts non-interactively' }), - } - - async run(): Promise { - const { flags } = await this.parse(AuthEmailResponse) - ensureWritable(flags) - const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) - const data = await finishEmailSetup(target, { - code: flags.code, - json: flags.json, - setupRequestID: flags['setup-request-id'], - username: flags.username, - yes: flags.yes, - }) - await printData(data, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/auth/email/start.ts b/packages/cli/src/commands/auth/email/start.ts deleted file mode 100644 index 0baca3db..00000000 --- a/packages/cli/src/commands/auth/email/start.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../../lib/command.js' -import { resolveTarget } from '../../../lib/targets.js' -import { startEmailSetup } from '../../../lib/setup-login.js' -import { printData } from '../../../lib/output.js' - -export default class AuthEmailStart extends BeeperCommand { - static override summary = 'Start email sign-in for a target' - static override flags = { - email: Flags.string({ required: true, description: 'Email address to sign in with' }), - } - - async run(): Promise { - const { flags } = await this.parse(AuthEmailStart) - const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) - const data = await startEmailSetup(target, flags.email) - await printData(data, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts deleted file mode 100644 index a100569f..00000000 --- a/packages/cli/src/commands/auth/logout.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { clearTargetAuth, resolveTarget } from '../../lib/targets.js' -import { printSuccess } from '../../lib/output.js' - -export default class AuthLogout extends BeeperCommand { - static override summary = 'Clear stored authentication' - - async run(): Promise { - const { flags } = await this.parse(AuthLogout) - ensureWritable(flags) - const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) - if (process.env.BEEPER_ACCESS_TOKEN && !target.auth?.accessToken) { - throw new Error('auth logout cannot clear BEEPER_ACCESS_TOKEN from the environment; unset it in the calling process.') - } - const token = target.auth?.accessToken - let revoked = false - if (token) { - const response = await fetch(new URL('/oauth/revoke', target.baseURL), { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ token, token_type_hint: 'access_token' }), - signal: AbortSignal.timeout(5000), - }).catch(() => undefined) - revoked = Boolean(response?.ok) - await clearTargetAuth(target) - } - await printSuccess({ message: 'Logged out', detail: token ? 'local token cleared' : 'no token was stored', data: { revoked, hadToken: Boolean(token) } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts deleted file mode 100644 index 0d0f94e1..00000000 --- a/packages/cli/src/commands/auth/status.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BeeperCommand } from '../../lib/command.js' -import { resolveTarget } from '../../lib/targets.js' -import { printData } from '../../lib/output.js' - -export default class AuthStatus extends BeeperCommand { - static override summary = 'Show stored auth for the selected target' - - async run(): Promise { - const { flags } = await this.parse(AuthStatus) - const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) - const authenticated = Boolean(process.env.BEEPER_ACCESS_TOKEN || target.auth?.accessToken) - const data = { - authenticated, - target: target.id, - baseURL: target.baseURL, - source: process.env.BEEPER_ACCESS_TOKEN ? 'env' : target.auth?.source ?? (target.auth?.accessToken ? 'target' : 'none'), - clientID: target.auth?.clientID, - expiresAt: target.auth?.expiresAt, - scope: target.auth?.scope, - } - await printData(data, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/autocomplete.ts b/packages/cli/src/commands/autocomplete.ts deleted file mode 100644 index 85e69a8b..00000000 --- a/packages/cli/src/commands/autocomplete.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Command } from '@oclif/core' - -export default class Autocomplete extends Command { - static override hidden = true - - async run(): Promise { - const autocompletePlugin = this.config.plugins.get('@oclif/plugin-autocomplete') as any - const command = await autocompletePlugin?.findCommand?.('autocomplete', { must: true }) - if (!command?.run) throw new Error('Autocomplete plugin is not available. Run `beeper completion` for setup help.') - await command.run(this.argv, this.config) - } -} diff --git a/packages/cli/src/commands/bridges/list.ts b/packages/cli/src/commands/bridges/list.ts deleted file mode 100644 index 1d85a477..00000000 --- a/packages/cli/src/commands/bridges/list.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printList } from '../../lib/output.js' - -export default class BridgesList extends BeeperCommand { - static override summary = 'List bridges that can connect chat accounts' - static override description = '`bridges list` is the scriptable bridge catalog. Use `accounts add` without an argument for the guided account connection flow.' - static override flags = { - provider: Flags.string({ options: ['local', 'cloud', 'self-hosted'], description: 'Limit to bridge provider' }), - available: Flags.boolean({ allowNo: true, description: 'Only bridges available to add (--no-available to exclude)' }), - } - - async run(): Promise { - const { flags } = await this.parse(BridgesList) - const client = await createClient(flags) - const response = await client.bridges.list() - const items = ((response as unknown as { items?: Array> }).items ?? []) - .filter(item => !flags.provider || item.provider === flags.provider) - .filter(item => flags.available === undefined || (item.status === 'available') === flags.available) - - await printList(items, flags.json ? 'json' : 'human', { - title: 'No bridges matched', - subtitle: 'Try removing provider or availability filters.', - suggestions: [{ command: 'beeper accounts add', hint: 'choose a bridge to connect an account' }], - }) - } -} diff --git a/packages/cli/src/commands/bridges/show.ts b/packages/cli/src/commands/bridges/show.ts deleted file mode 100644 index 76840af4..00000000 --- a/packages/cli/src/commands/bridges/show.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' - -export default class BridgesShow extends BeeperCommand { - static override summary = 'Show bridge details, login flows, and connected accounts' - static override args = { - bridge: Args.string({ required: true, description: 'Bridge ID, display name, network, or type' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(BridgesShow) - const client = await createClient(flags) - const response = await client.bridges.list() - const listBridge = resolveBridge(((response as unknown as { items?: Array> }).items ?? []), args.bridge) - const bridgeID = String(listBridge.id) - const [bridge, loginFlows, capabilities] = await Promise.all([ - client.bridges.retrieve(bridgeID).catch(() => listBridge), - client.bridges.loginFlows.list(bridgeID).catch(() => undefined), - client.bridges.retrieveCapabilities(bridgeID).catch(() => undefined), - ]) - await printData({ - ...bridge, - loginFlows: loginFlows ? (loginFlows as { items?: unknown[] }).items ?? loginFlows : undefined, - capabilities, - }, flags.json ? 'json' : 'human') - } -} - -function resolveBridge(items: Array>, input: string): Record { - const normalizedInput = normalize(input) - const exact = items.filter(item => [ - item.id, - item.displayName, - item.network, - item.type, - ].some(value => normalize(value) === normalizedInput)) - if (exact.length === 1) return exact[0]! - if (exact.length > 1) throw ambiguousBridge(input, exact) - - const partial = items.filter(item => [ - item.id, - item.displayName, - item.network, - item.type, - ].some(value => normalize(value).includes(normalizedInput))) - if (partial.length === 1) return partial[0]! - if (partial.length > 1) throw ambiguousBridge(input, partial) - - throw new Error(`Unknown bridge "${input}". Run \`beeper bridges list\`.`) -} - -function ambiguousBridge(input: string, matches: Array>): Error { - const options = matches.map(item => `${String(item.displayName ?? item.id)} (${String(item.id)})`).join(', ') - return new Error(`Bridge "${input}" is ambiguous. Use one of: ${options}`) -} - -function normalize(value: unknown): string { - return String(value ?? '').toLowerCase().replaceAll(/[^a-z0-9]+/g, '') -} diff --git a/packages/cli/src/commands/chats/archive.ts b/packages/cli/src/commands/chats/archive.ts deleted file mode 100644 index 17b27e61..00000000 --- a/packages/cli/src/commands/chats/archive.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsArchive extends BeeperCommand { - static override summary = 'Archive a chat' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsArchive) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.update(chatID, { isArchived: true }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/avatar.ts b/packages/cli/src/commands/chats/avatar.ts deleted file mode 100644 index cee1f6b1..00000000 --- a/packages/cli/src/commands/chats/avatar.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsAvatar extends BeeperCommand { - static override summary = 'Set a chat avatar' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), file: Flags.string({ description: 'Image file to upload as the new avatar' }), clear: Flags.boolean({ default: false, description: 'Clear the existing avatar instead of setting a new one' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsAvatar) - ensureWritable(flags) - if (!flags.clear && !flags.file) throw new Error('Provide --file or --clear') - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.update(chatID, { imgURL: flags.clear ? null : flags.file }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/description.ts b/packages/cli/src/commands/chats/description.ts deleted file mode 100644 index 429c7ac6..00000000 --- a/packages/cli/src/commands/chats/description.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsDescription extends BeeperCommand { - static override summary = 'Set a chat description' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), description: Flags.string({ description: 'New chat description' }), clear: Flags.boolean({ default: false, description: 'Clear the existing description instead of setting one' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsDescription) - ensureWritable(flags) - if (!flags.clear && !flags.description) throw new Error('Provide --description or --clear') - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.update(chatID, { description: flags.clear ? null : flags.description }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/disappear.ts b/packages/cli/src/commands/chats/disappear.ts deleted file mode 100644 index 16eace97..00000000 --- a/packages/cli/src/commands/chats/disappear.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsDisappear extends BeeperCommand { - static override summary = 'Set disappearing-message expiry' - static override examples = [ - 'beeper chats disappear --chat "Mom" --seconds 86400', - 'beeper chats disappear --chat "Work Group" --seconds off', - ] - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - seconds: Flags.string({ required: true, description: 'Timer in seconds, or "off" to disable' }), - } - async run(): Promise { - const { flags } = await this.parse(ChatsDisappear) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - const expiry = flags.seconds.toLowerCase() === 'off' ? null : Number(flags.seconds) - if (expiry !== null && (!Number.isInteger(expiry) || expiry < 0)) throw new Error('--seconds must be a positive integer or "off"') - await printData(await client.chats.update(chatID, { messageExpirySeconds: expiry }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/draft.ts b/packages/cli/src/commands/chats/draft.ts deleted file mode 100644 index 15c8df79..00000000 --- a/packages/cli/src/commands/chats/draft.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsDraft extends BeeperCommand { - static override summary = 'Set or clear a chat draft' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - text: Flags.string({ description: 'Draft text. Omit and pass --clear to remove the draft.' }), - file: Flags.string({ description: 'Attachment file to upload with the draft' }), - filename: Flags.string({ description: 'Override the displayed filename of the attachment' }), - mime: Flags.string({ description: 'Override MIME type detection for the attachment' }), - clear: Flags.boolean({ default: false, description: 'Clear the existing draft instead of setting one' }), - } - async run(): Promise { - const { flags } = await this.parse(ChatsDraft) - ensureWritable(flags) - if (!flags.clear && flags.text === undefined) throw new Error('Provide --text TEXT (and optionally --file PATH) or --clear.') - if (flags.clear && (flags.text !== undefined || flags.file)) throw new Error('--clear cannot be combined with --text or --file.') - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - if (flags.clear) { - await printData(await client.chats.update(chatID, { draft: null }), flags.json ? 'json' : 'human') - return - } - const upload = flags.file ? await client.assets.upload({ file: createReadStream(flags.file), fileName: flags.filename, mimeType: flags.mime }) : undefined - await printData(await client.chats.update(chatID, { draft: { text: flags.text!, attachments: upload?.uploadID ? { [upload.uploadID]: upload as any } : undefined } }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/focus.ts b/packages/cli/src/commands/chats/focus.ts deleted file mode 100644 index ef30b469..00000000 --- a/packages/cli/src/commands/chats/focus.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsFocus extends BeeperCommand { - static override summary = 'Focus Beeper Desktop on a chat' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - message: Flags.string({ description: 'Scroll Desktop to this message ID after focusing' }), - draft: Flags.string({ description: 'Prefill the chat composer with this draft text' }), - attachment: Flags.string({ description: 'Prefill the chat composer with this attachment file path' }), - } - async run(): Promise { - const { flags } = await this.parse(ChatsFocus) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.focus({ chatID, messageID: flags.message, draftText: flags.draft, draftAttachmentPath: flags.attachment }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/list.ts b/packages/cli/src/commands/chats/list.ts deleted file mode 100644 index 42d0235b..00000000 --- a/packages/cli/src/commands/chats/list.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { cliCopy } from '../../lib/copy.js' -import { printIDs, printList } from '../../lib/output.js' -import { resolveAccountIDs } from '../../lib/resolve.js' - -export default class ChatsList extends BeeperCommand { - static override summary = 'List chats' - static override flags = { - account: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.accountSelector}` }), - ids: Flags.boolean({ default: false, description: 'Print preferred chat selectors, using numeric local chat IDs when available' }), - limit: Flags.integer({ default: 20, description: 'Maximum chats to print' }), - archived: Flags.boolean({ allowNo: true, description: 'Only archived chats (--no-archived to exclude)' }), - pinned: Flags.boolean({ allowNo: true, description: 'Only pinned chats (--no-pinned to exclude)' }), - muted: Flags.boolean({ allowNo: true, description: 'Only muted chats (--no-muted to exclude)' }), - unread: Flags.boolean({ allowNo: true, description: 'Only chats with unread messages (--no-unread to exclude)' }), - 'low-priority': Flags.boolean({ allowNo: true, description: 'Only Low Priority chats (--no-low-priority to exclude)' }), - } - async run(): Promise { - const { flags } = await this.parse(ChatsList) - const client = await createClient(flags) - const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) - - const hasStateFilter = ( - flags.archived !== undefined || flags.pinned !== undefined - || flags.muted !== undefined || flags.unread !== undefined - || flags['low-priority'] !== undefined - ) - - const matchesFilters = (row: Record): boolean => { - if (!hasStateFilter) return true - if (flags.archived !== undefined && Boolean(row.isArchived) !== flags.archived) return false - if (flags.pinned !== undefined && Boolean(row.isPinned) !== flags.pinned) return false - if (flags.muted !== undefined && Boolean(row.isMuted) !== flags.muted) return false - if (flags['low-priority'] !== undefined && Boolean(row.isLowPriority) !== flags['low-priority']) return false - if (flags.unread !== undefined) { - const unread = Number(row.unreadCount ?? 0) > 0 || Boolean(row.isMarkedUnread) - if (unread !== flags.unread) return false - } - return true - } - - const items: Array> = [] - for await (const item of client.chats.list({ accountIDs })) { - const row = item as unknown as Record - if (matchesFilters(row)) items.push(row) - if (items.length >= flags.limit) break - } - - if (flags.ids) printIDs(items) - else await printList(items, flags.json ? 'json' : 'human', { title: 'No chats matched', subtitle: hasStateFilter ? 'Try relaxing the filter flags.' : 'Connect an account or sync existing chats.' }) - } -} diff --git a/packages/cli/src/commands/chats/mark-read.ts b/packages/cli/src/commands/chats/mark-read.ts deleted file mode 100644 index 4f942867..00000000 --- a/packages/cli/src/commands/chats/mark-read.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsMarkRead extends BeeperCommand { - static override summary = 'Mark a chat as read' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), message: Flags.string({ description: 'Mark read at (or unread starting from) this message ID' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsMarkRead) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.markRead(chatID, { messageID: flags.message }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/mark-unread.ts b/packages/cli/src/commands/chats/mark-unread.ts deleted file mode 100644 index 26bd3df7..00000000 --- a/packages/cli/src/commands/chats/mark-unread.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsMarkUnread extends BeeperCommand { - static override summary = 'Mark a chat as unread' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), message: Flags.string({ description: 'Mark read at (or unread starting from) this message ID' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsMarkUnread) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.markUnread(chatID, { messageID: flags.message }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/mute.ts b/packages/cli/src/commands/chats/mute.ts deleted file mode 100644 index 082fbcbb..00000000 --- a/packages/cli/src/commands/chats/mute.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsMute extends BeeperCommand { - static override summary = 'Mute a chat' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - } - async run(): Promise { - const { flags } = await this.parse(ChatsMute) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.update(chatID, { isMuted: true }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/notify-anyway.ts b/packages/cli/src/commands/chats/notify-anyway.ts deleted file mode 100644 index 27f20517..00000000 --- a/packages/cli/src/commands/chats/notify-anyway.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsNotifyAnyway extends BeeperCommand { - static override summary = 'Send an iMessage Notify Anyway alert' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsNotifyAnyway) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.notifyAnyway(chatID), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/pin.ts b/packages/cli/src/commands/chats/pin.ts deleted file mode 100644 index a5e0b024..00000000 --- a/packages/cli/src/commands/chats/pin.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsPin extends BeeperCommand { - static override summary = 'Pin a chat' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsPin) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.update(chatID, { isPinned: true }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/priority.ts b/packages/cli/src/commands/chats/priority.ts deleted file mode 100644 index 68d75ec5..00000000 --- a/packages/cli/src/commands/chats/priority.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsPriority extends BeeperCommand { - static override summary = 'Move a chat to the Inbox or Low Priority' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - level: Flags.string({ required: true, options: ['inbox', 'low'], description: 'Destination: inbox (default mailbox) or low (Low Priority)' }), - } - async run(): Promise { - const { flags } = await this.parse(ChatsPriority) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - const update = flags.level === 'inbox' - ? { isArchived: false, isLowPriority: false } - : { isLowPriority: true } - await printData(await client.chats.update(chatID, update), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/remind.ts b/packages/cli/src/commands/chats/remind.ts deleted file mode 100644 index 7edd7d6e..00000000 --- a/packages/cli/src/commands/chats/remind.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsRemind extends BeeperCommand { - static override summary = 'Set a chat reminder' - static override examples = [ - 'beeper chats remind --chat "Mom" --when 2024-12-25T09:00:00Z', - 'beeper chats remind --chat "Work" --when 2024-12-25T09:00:00Z --dismiss-on-message', - ] - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), when: Flags.string({ required: true, description: 'ISO timestamp when the reminder should trigger' }), 'dismiss-on-message': Flags.boolean({ default: false, description: 'Dismiss the reminder automatically when a new message arrives' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsRemind) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await client.chats.reminders.create(chatID, { reminder: { remindAt: flags.when, dismissOnIncomingMessage: flags['dismiss-on-message'] || undefined } }) - await printSuccess({ message: 'Reminder set', detail: flags.when, data: { chatID, remindAt: flags.when } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/rename.ts b/packages/cli/src/commands/chats/rename.ts deleted file mode 100644 index 07139fe1..00000000 --- a/packages/cli/src/commands/chats/rename.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsRename extends BeeperCommand { - static override summary = 'Rename a chat' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - title: Flags.string({ required: true, description: 'New chat title' }), - } - async run(): Promise { - const { flags } = await this.parse(ChatsRename) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.update(chatID, { title: flags.title }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/search.ts b/packages/cli/src/commands/chats/search.ts deleted file mode 100644 index afc2aad5..00000000 --- a/packages/cli/src/commands/chats/search.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { collectPage, printIDs, printList } from '../../lib/output.js' -import { resolveAccountIDs } from '../../lib/resolve.js' - -export default class ChatsSearch extends BeeperCommand { - static override summary = 'Search chats' - static override args = { query: Args.string({ required: true, description: 'Search query (title, participant, or network)' }) } - static override flags = { - account: Flags.string({ multiple: true, description: 'Limit to Account ID, network, bridge, or account user' }), - ids: Flags.boolean({ default: false, description: 'Print preferred chat selectors, using numeric local chat IDs when available' }), - limit: Flags.integer({ default: 20, description: 'Maximum chats to print' }), - } - async run(): Promise { - const { args, flags } = await this.parse(ChatsSearch) - const client = await createClient(flags) - const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) - const items = await collectPage(client.chats.search({ query: args.query, accountIDs }), flags.limit) - if (flags.ids) printIDs(items) - else await printList(items, flags.json ? 'json' : 'human', { title: 'No chats matched', subtitle: `Nothing found for "${args.query}".` }) - } -} diff --git a/packages/cli/src/commands/chats/show.ts b/packages/cli/src/commands/chats/show.ts deleted file mode 100644 index 12b1613c..00000000 --- a/packages/cli/src/commands/chats/show.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsShow extends BeeperCommand { - static override summary = 'Show chat details' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), 'max-participants': Flags.integer({ description: 'Limit number of participants returned in chat details' }) } - async run(): Promise { - const { flags } = await this.parse(ChatsShow) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.retrieve(chatID, { maxParticipantCount: flags['max-participants'] }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/start.ts b/packages/cli/src/commands/chats/start.ts deleted file mode 100644 index 59f5c6f7..00000000 --- a/packages/cli/src/commands/chats/start.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import type { ChatStartParams } from '@beeper/desktop-api/resources/chats' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { listAccountIDs, resolveAccountID, userQueryFromInput } from '../../lib/resolve.js' - -export default class ChatsStart extends BeeperCommand { - static override summary = 'Start a chat' - static override args = { user: Args.string({ required: true, description: 'User ID, phone number, email, or display name' }) } - static override flags = { - account: Flags.string({ description: 'Account selector. Defaults to the single available account or the matrix account.' }), - title: Flags.string({ description: 'Optional initial title for a new group chat' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(ChatsStart) - ensureWritable(flags) - const client = await createClient(flags) - const accountID = flags.account ? await resolveAccountID(client, flags.account) : await defaultAccountID(client) - const user = userQueryFromInput(args.user) - const payload: ChatStartParams & { title?: string } = { accountID, user, title: flags.title } - await printData(await client.chats.start(payload), flags.json ? 'json' : 'human') - } -} - -async function defaultAccountID(client: any): Promise { - const accountIDs = await listAccountIDs(client) - if (accountIDs.includes('matrix')) return 'matrix' - if (accountIDs.length === 1 && accountIDs[0]) return accountIDs[0] - throw new Error('Use --account to choose which account should start the chat.') -} diff --git a/packages/cli/src/commands/chats/unarchive.ts b/packages/cli/src/commands/chats/unarchive.ts deleted file mode 100644 index 0da9088a..00000000 --- a/packages/cli/src/commands/chats/unarchive.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsUnarchive extends BeeperCommand { - static override summary = 'Unarchive a chat' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsUnarchive) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.update(chatID, { isArchived: false }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/unmute.ts b/packages/cli/src/commands/chats/unmute.ts deleted file mode 100644 index 022ae568..00000000 --- a/packages/cli/src/commands/chats/unmute.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsUnmute extends BeeperCommand { - static override summary = 'Unmute a chat' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsUnmute) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.update(chatID, { isMuted: false }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/unpin.ts b/packages/cli/src/commands/chats/unpin.ts deleted file mode 100644 index cb4ada38..00000000 --- a/packages/cli/src/commands/chats/unpin.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsUnpin extends BeeperCommand { - static override summary = 'Unpin a chat' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsUnpin) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.chats.update(chatID, { isPinned: false }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/chats/unremind.ts b/packages/cli/src/commands/chats/unremind.ts deleted file mode 100644 index 437ceca5..00000000 --- a/packages/cli/src/commands/chats/unremind.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Flags } from '@oclif/core' -import { createReadStream } from 'node:fs' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class ChatsUnremind extends BeeperCommand { - static override summary = 'Clear a chat reminder' - static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), } - async run(): Promise { - const { flags } = await this.parse(ChatsUnremind) - ensureWritable(flags) - - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await client.chats.reminders.delete(chatID) - await printSuccess({ message: 'Reminder cleared', data: { chatID } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/completion.ts b/packages/cli/src/commands/completion.ts deleted file mode 100644 index d1aa82bd..00000000 --- a/packages/cli/src/commands/completion.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Args, Command, Flags } from '@oclif/core' - -const ZSH_SNIPPET = `# beeper semantic completion (zsh) - source after \`beeper completion\` -# Augments static command completion with live suggestions for --chat / --to / --account / --target. -_beeper_complete_kind() { - local kind="$1" - local -a lines - local IFS=$'\\n' - lines=( $(beeper _complete "$kind" --query "$PREFIX" --limit 25 2>/dev/null) ) - local -a values descs - for line in "$lines[@]"; do - values+=("$\{line%%\\t*\}") - descs+=("$\{line\}") - done - _describe -t "$kind" "$kind" descs values -} -_beeper_chat() { _beeper_complete_kind chat } -_beeper_account() { _beeper_complete_kind account } -_beeper_target() { _beeper_complete_kind target } -_beeper_contact() { _beeper_complete_kind contact } - -zstyle ':completion:*:*:beeper:*:option-chat-1' extra-verbose yes -compdef '_arguments \\ - "--chat=[Chat ID or title]:chat:_beeper_chat" \\ - "--to=[Chat or contact]:chat:_beeper_chat" \\ - "--account=[Account]:account:_beeper_account" \\ - "--target=[Target name]:target:_beeper_target" \\ - "-t+[Target name]:target:_beeper_target"' beeper -` - -const BASH_SNIPPET = `# beeper semantic completion (bash) - source after \`beeper completion\` -# Augments static command completion with live suggestions for --chat / --to / --account / --target. -_beeper_semantic_kind() { - local kind="$1" cur="$2" - local IFS=$'\\n' - COMPREPLY+=( $(beeper _complete "$kind" --query "$cur" --limit 25 2>/dev/null | cut -f1) ) -} -_beeper_semantic_dispatch() { - local prev="$3" cur="$2" - case "$prev" in - --chat|--to) _beeper_semantic_kind chat "$cur" ;; - --account) _beeper_semantic_kind account "$cur" ;; - --target|-t) _beeper_semantic_kind target "$cur" ;; - --contact) _beeper_semantic_kind contact "$cur" ;; - esac -} -# Chain after the static beeper completion: call it, then add semantic suggestions. -complete -o nospace -o default -F _beeper_semantic_dispatch beeper -` - -export default class Completion extends Command { - static override summary = 'Print shell completion setup' - static override description = `Print static shell completion setup for bash, zsh, fish, or PowerShell. - -Pass \`--semantic\` to print a small supplementary snippet that adds live suggestions for \`--chat\`, \`--to\`, \`--account\`, and \`--target\` by calling back into \`beeper _complete\`. Source it after the static completion setup.` - static override args = { - shell: Args.string({ description: 'Shell to set up (bash, zsh, fish, or powershell)', required: false }), - } - static override flags = { - 'refresh-cache': Flags.boolean({ char: 'r', default: false, description: 'Refresh the autocomplete cache before printing setup' }), - semantic: Flags.boolean({ default: false, description: 'Print a semantic-completion snippet (chats/accounts/targets) for bash or zsh' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(Completion) - if (flags.semantic) { - const shell = (args.shell ?? process.env.SHELL ?? '').toLowerCase() - if (shell.includes('zsh')) { - process.stdout.write(`${ZSH_SNIPPET}\n`) - return - } - if (shell.includes('bash')) { - process.stdout.write(`${BASH_SNIPPET}\n`) - return - } - process.stderr.write('Semantic completion is currently supported for bash and zsh. Pass `bash` or `zsh` explicitly.\n') - this.exit(2) - } - const argv: string[] = [] - if (args.shell) argv.push(args.shell) - if (flags['refresh-cache']) argv.push('--refresh-cache') - await this.config.runCommand('autocomplete', argv) - } -} diff --git a/packages/cli/src/commands/config/get.ts b/packages/cli/src/commands/config/get.ts deleted file mode 100644 index 121c9b5b..00000000 --- a/packages/cli/src/commands/config/get.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { readConfig } from '../../lib/targets.js' -import { printConfig, printData } from '../../lib/output.js' - -export default class ConfigGet extends BeeperCommand { - static override summary = 'Print CLI configuration' - static override args = { - key: Args.string({ description: 'Optional config key to print', options: ['baseURL', 'auth', 'defaultTarget', 'defaultAccount'], required: false }), - } - - async run(): Promise { - const { args, flags } = await this.parse(ConfigGet) - const config = await readConfig() - const safeConfig = { - ...config, - auth: config.auth ? { ...config.auth, accessToken: '[redacted]' } : config.auth, - } - const format = flags.json ? 'json' : 'human' - if (args.key) { - await printData(safeConfig[args.key as keyof typeof safeConfig], format) - return - } - await printConfig(safeConfig as unknown as Record, format) - } -} diff --git a/packages/cli/src/commands/config/path.ts b/packages/cli/src/commands/config/path.ts deleted file mode 100644 index 944b9270..00000000 --- a/packages/cli/src/commands/config/path.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { BeeperCommand } from '../../lib/command.js' -import { configPath } from '../../lib/targets.js' - -export default class ConfigPath extends BeeperCommand { - static override summary = 'Print the CLI config path' - - async run(): Promise { - const { flags } = await this.parse(ConfigPath) - const path = configPath() - if (flags.json) { - process.stdout.write(`${JSON.stringify({ path }, null, 2)}\n`) - return - } - // Plain path so it's pipeable (xargs / cat / cd). - process.stdout.write(`${path}\n`) - } -} diff --git a/packages/cli/src/commands/config/reset.ts b/packages/cli/src/commands/config/reset.ts deleted file mode 100644 index e5ee7a1a..00000000 --- a/packages/cli/src/commands/config/reset.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { resetConfig } from '../../lib/targets.js' -import { printSuccess } from '../../lib/output.js' - -export default class ConfigReset extends BeeperCommand { - static override summary = 'Reset CLI configuration' - - async run(): Promise { - const { flags } = await this.parse(ConfigReset) - ensureWritable(flags) - await resetConfig() - await printSuccess({ message: 'Config reset' }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/config/set.ts b/packages/cli/src/commands/config/set.ts deleted file mode 100644 index 2bdc2a05..00000000 --- a/packages/cli/src/commands/config/set.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { updateConfig } from '../../lib/targets.js' -import { printSuccess } from '../../lib/output.js' - -export default class ConfigSet extends BeeperCommand { - static override summary = 'Set a CLI configuration value' - static override args = { - key: Args.string({ description: 'Config key to set', options: ['defaultTarget', 'defaultAccount'], required: true }), - value: Args.string({ description: 'Config value (pass "" to clear)', required: true }), - } - - async run(): Promise { - const { args, flags } = await this.parse(ConfigSet) - ensureWritable(flags) - const nextValue = args.value === '' ? undefined : args.value - await updateConfig(config => ({ ...config, [args.key]: nextValue })) - await printSuccess({ - message: nextValue === undefined ? `Cleared ${args.key}` : `Set ${args.key}`, - detail: nextValue, - data: { [args.key]: nextValue }, - }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/contacts/list.ts b/packages/cli/src/commands/contacts/list.ts deleted file mode 100644 index 9fac2b25..00000000 --- a/packages/cli/src/commands/contacts/list.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { apiCopy, cliCopy } from '../../lib/copy.js' -import { collectPage, printIDs, printList } from '../../lib/output.js' -import { listAccountIDs, resolveAccountIDs } from '../../lib/resolve.js' -import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' - -export default class ContactsList extends BeeperCommand { - static override summary = 'List contacts' - static override description = apiCopy.contacts.list - static override args = {} - - static override flags = { - ids: Flags.boolean({ default: false, description: 'Print only contact user IDs' }), - limit: Flags.integer({ default: 50, description: 'Maximum contacts to print' }), - account: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.accountSelector}` }), - query: Flags.string({ description: 'Optional blended contact lookup query' }), - } - - async run(): Promise { - const { flags } = await this.parse(ContactsList) - const client = await createClient(flags) - const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) ?? await listAccountIDs(client) - const useSpinner = !flags.json && !flags.ids - const load = async (): Promise>> => { - const collected: Array> = [] - for (const accountID of accountIDs) { - const remaining = flags.limit - collected.length - if (remaining <= 0) break - const contacts = await collectPage(client.accounts.contacts.list(accountID, { query: flags.query }), remaining) - collected.push(...contacts.map(item => ({ ...(item as unknown as Record), accountID }))) - if (collected.length >= flags.limit) break - } - return collected - } - const items = useSpinner - ? await withSpinner(`Loading contacts${flags.query ? ` matching "${flags.query}"` : ''}…`, load, { - done: value => `${value.length} contact${value.length === 1 ? '' : 's'}`, - }) - : await load() - if (flags.ids) { - printIDs(items.map(item => ({ id: item.userID ?? item.id }))) - return - } - await printList(items, flags.json ? 'json' : 'human', { - title: 'No contacts found', - subtitle: flags.query ? `Nothing matched "${flags.query}".` : 'This account has no contacts to list.', - suggestions: [ - { command: 'beeper contacts search ', hint: 'narrow with a search' }, - { command: 'beeper accounts', hint: 'check the account is online' }, - ], - }) - } -} diff --git a/packages/cli/src/commands/contacts/search.ts b/packages/cli/src/commands/contacts/search.ts deleted file mode 100644 index 7a809f6d..00000000 --- a/packages/cli/src/commands/contacts/search.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { apiCopy, cliCopy } from '../../lib/copy.js' -import { printList } from '../../lib/output.js' -import { listAccountIDs, resolveAccountIDs } from '../../lib/resolve.js' -import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' - -export default class ContactsSearch extends BeeperCommand { - static override summary = 'Search contacts' - static override description = apiCopy.contacts.search - static override args = { - query: Args.string({ description: 'Contact search query', required: true }), - } - static override flags = { - account: Flags.string({ multiple: true, description: `${cliCopy.args.accountSelector}. Omit to search every account.` }), - } - - async run(): Promise { - const { args, flags } = await this.parse(ContactsSearch) - const client = await createClient(flags) - const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) ?? await listAccountIDs(client) - const load = async (): Promise>> => { - const collected: Array> = [] - for (const accountID of accountIDs) { - try { - const result = await client.accounts.contacts.search(accountID, { query: args.query }) - collected.push(...result.items.map((item: unknown) => ({ ...(item as Record), accountID }))) - } catch { - // Some networks reject exact lookups for some identifiers; keep trying the rest. - } - } - return collected - } - const useSpinner = !flags.json - const results = useSpinner - ? await withSpinner(`Searching contacts for "${args.query}"…`, load, { - done: value => `${value.length} match${value.length === 1 ? '' : 'es'} across ${accountIDs.length} account${accountIDs.length === 1 ? '' : 's'}`, - }) - : await load() - await printList(results, flags.json ? 'json' : 'human', { - title: 'No contacts matched', - subtitle: `Nothing across your ${accountIDs.length} account${accountIDs.length === 1 ? '' : 's'} matched "${args.query}".`, - suggestions: [ - { command: 'beeper accounts', hint: 'verify which accounts are connected' }, - { command: `beeper contact ${args.query}`, hint: 'exact lookup on one account' }, - ], - }) - } -} diff --git a/packages/cli/src/commands/contacts/show.ts b/packages/cli/src/commands/contacts/show.ts deleted file mode 100644 index 8d6cf7c4..00000000 --- a/packages/cli/src/commands/contacts/show.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { notFound } from '../../lib/errors.js' -import { collectPage, printData } from '../../lib/output.js' -import { resolveAccountIDs } from '../../lib/resolve.js' -export default class ContactsShow extends BeeperCommand { - static override summary = 'Show contact details' - static override args = { - id: Args.string({ required: true, description: 'Contact user ID, display name, or phone/handle' }), - } - static override flags = { - account: Flags.string({ multiple: true, description: 'Limit to account ID, network, bridge, or account user' }), - } - async run(): Promise { - const { args, flags } = await this.parse(ContactsShow) - const client = await createClient(flags) - const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) - for (const accountID of accountIDs ?? []) { - const matches = await collectPage(client.accounts.contacts.list(accountID, { query: args.id }), 10) - const match = matches.find((item: any) => [item.userID, item.id, item.name, item.displayName].includes(args.id)) - if (match) return printData({ accountID, contact: match }, flags.json ? 'json' : 'human') - } - throw notFound(`Contact not found: ${args.id}`) - } -} diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts deleted file mode 100644 index 8565291e..00000000 --- a/packages/cli/src/commands/docs.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BeeperCommand } from '../lib/command.js' -import { printData } from '../lib/output.js' -export default class Docs extends BeeperCommand { - static override summary = 'Open Beeper CLI docs' - async run(): Promise { - const { flags } = await this.parse(Docs) - await printData({ url: 'https://developers.beeper.com/desktop-api-reference' }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts deleted file mode 100644 index e0b428d1..00000000 --- a/packages/cli/src/commands/doctor.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { BeeperCommand } from '../lib/command.js' -import { evaluateReadiness } from '../lib/app-state.js' -import { ExitCodes } from '../lib/errors.js' -import { resolveTarget } from '../lib/targets.js' -import { targetLiveStatus } from '../lib/target-status.js' -import { printData } from '../lib/output.js' -export default class Doctor extends BeeperCommand { - static override summary = 'Probe the target live and report diagnostics' - static override description = 'Active reachability check plus readiness diagnostics. Exits non-zero when the target is not ready. For a cheap snapshot use `beeper status`.' - async run(): Promise { - const { flags } = await this.parse(Doctor) - const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) - const checks = { target: await targetLiveStatus(target), readiness: await evaluateReadiness({ baseURL: target.baseURL, target: target.id }) } - await printData({ ok: checks.readiness.state === 'ready', checks }, flags.json ? 'json' : 'human') - if (checks.readiness.state !== 'ready') this.exit(ExitCodes.NotReady) - } -} diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts deleted file mode 100644 index 525bc66a..00000000 --- a/packages/cli/src/commands/export.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../lib/command.js' -import { createClient } from '../lib/client.js' -import { exportBeeperData } from '../lib/export/index.js' -import { resolveAccountIDs, resolveChatID } from '../lib/resolve.js' - -export default class Export extends BeeperCommand { - static override summary = 'Export accounts, chats, messages, Markdown transcripts, and attachments' - static override description = [ - 'Creates a resumable Beeper Desktop export using the official Desktop API SDK.', - 'The export directory contains accounts.json, chats.json, manifest.json, and one directory per chat with chat.json, messages.json, messages.markdown, messages.html, downloaded attachments, and checkpoint state for interrupted runs.', - ].join('\n') - - static override flags = { - account: Flags.string({ multiple: true, description: 'Limit to an account selector. Repeat to include more accounts.' }), - chat: Flags.string({ multiple: true, description: 'Limit to a chat selector. Repeat to include more chats.' }), - force: Flags.boolean({ default: false, description: 'Re-export chats even if checkpoint state says they are complete.' }), - 'limit-chats': Flags.integer({ description: 'Maximum chats to export. Intended for testing large exports.' }), - 'limit-messages': Flags.integer({ description: 'Maximum messages per chat. Intended for testing large exports.' }), - 'max-participants': Flags.integer({ default: 500, description: 'Maximum participants to include in each chat.json.' }), - 'no-attachments': Flags.boolean({ default: false, description: 'Skip downloading message attachments.' }), - out: Flags.directory({ char: 'o', default: 'beeper-export', description: 'Export directory.' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - quiet: Flags.boolean({ default: false, description: 'Suppress progress output.' }), - } - - async run(): Promise { - const { flags } = await this.parse(Export) - const client = await createClient(flags) - const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) - const chatIDs = flags.chat?.length - ? await Promise.all(flags.chat.map(chat => resolveChatID(client, chat, { accountIDs, pick: flags.pick }))) - : undefined - - const manifest = await exportBeeperData(client, { - accountIDs, - chatIDs, - downloadAttachments: !flags['no-attachments'], - events: flags.events, - force: flags.force, - limitChats: flags['limit-chats'], - limitMessages: flags['limit-messages'], - maxParticipants: flags['max-participants'], - outDir: flags.out, - quiet: flags.quiet, - }) - - this.log(`Exported ${manifest.chatCount} chats, ${manifest.messageCount} messages, ${manifest.attachmentCount} attachments to ${flags.out}`) - } -} diff --git a/packages/cli/src/commands/install/desktop.ts b/packages/cli/src/commands/install/desktop.ts deleted file mode 100644 index f617e172..00000000 --- a/packages/cli/src/commands/install/desktop.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { installDesktop, type InstallChannel } from '../../lib/installations.js' -import { printSuccess } from '../../lib/output.js' - -export default class SetupInstallDesktop extends BeeperCommand { - static override summary = 'Install Beeper Desktop locally' - static override flags = { - channel: Flags.string({ options: ['stable', 'nightly'], default: 'stable', description: 'Desktop release channel' }), - } - - async run(): Promise { - const { flags } = await this.parse(SetupInstallDesktop) - ensureWritable(flags) - const installation = await installDesktop({ channel: flags.channel as InstallChannel }) - await printSuccess({ - message: `Installed Beeper Desktop ${installation.version ?? ''}`.trim(), - detail: installation.path, - data: installation, - }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/install/server.ts b/packages/cli/src/commands/install/server.ts deleted file mode 100644 index c612a131..00000000 --- a/packages/cli/src/commands/install/server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { installServer, type InstallChannel } from '../../lib/installations.js' -import { pathSetupHint } from '../../lib/env.js' -import { printSuccess } from '../../lib/output.js' - -export default class SetupInstallServer extends BeeperCommand { - static override summary = 'Install Beeper Server locally' - static override flags = { - channel: Flags.string({ options: ['stable', 'nightly'], default: 'stable', description: 'Server release channel' }), - 'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }), - } - - async run(): Promise { - const { flags } = await this.parse(SetupInstallServer) - ensureWritable(flags) - const installation = await installServer({ channel: flags.channel as InstallChannel, serverEnv: flags['server-env'] }) - await printSuccess({ - message: `Installed Beeper Server ${installation.version ?? ''}`.trim(), - detail: pathSetupHint() ?? installation.path, - data: installation, - }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/man.ts b/packages/cli/src/commands/man.ts deleted file mode 100644 index 275fa1ee..00000000 --- a/packages/cli/src/commands/man.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { BeeperCommand } from '../lib/command.js' -import { commandManifest } from '../lib/manifest.js' -import { printCommands } from '../lib/output.js' -export default class Man extends BeeperCommand { - static override summary = 'Print the command manual' - async run(): Promise { - const { flags } = await this.parse(Man) - const commandsByID = new Map(this.config.commands.map(command => [command.id.replaceAll(':', ' '), command])) - const commands = commandManifest.map(item => { - const command = commandsByID.get(item.command) - return { - ...item, - description: command?.summary || command?.description || item.description, - ...metadataForCommand(item.command), - } - }) - await printCommands(commands, flags.json ? 'json' : 'human', { title: 'Beeper CLI' }) - } -} - -function metadataForCommand(command: string): { - mutates: boolean - requiresAuth: boolean - selectors: string[] - output: 'data' | 'list' | 'stream' | 'success' | 'send-result' | 'manual' - related: string[] -} { - const parts = command.split(' ') - const root = parts[0] ?? '' - const mutatingRoots = new Set(['setup', 'install', 'send', 'update']) - const mutatingVerbs = new Set([ - 'add', 'archive', 'unarchive', 'pin', 'unpin', 'mute', 'unmute', 'mark-read', 'mark-unread', - 'priority', 'notify-anyway', 'rename', 'description', 'avatar', 'draft', 'disappear', 'remind', - 'unremind', 'focus', 'edit', 'delete', 'remove', 'use', 'set', 'reset', 'logout', 'start', 'stop', - 'restart', 'enable', 'disable', 'approve', 'recovery-key', 'reset-recovery-key', 'cancel', 'sas', - 'sas-confirm', 'qr-scan', 'qr-confirm', - ]) - const mutates = mutatingRoots.has(root) || parts.some(part => mutatingVerbs.has(part ?? '')) - const localOnly = root === 'config' || root === 'completion' || root === 'docs' || root === 'version' || root === 'man' - const requiresAuth = !localOnly && command !== 'targets list' && !command.startsWith('targets add') && !command.startsWith('install ') - const selectors = [ - command.includes('chats ') || command.includes('messages ') || command.startsWith('send ') || command === 'presence' ? 'chat' : undefined, - command.includes('accounts ') || command.includes('contacts ') || command === 'chats start' ? 'account' : undefined, - command.includes('targets ') || command === 'status' || command === 'doctor' || command.startsWith('auth ') || command.startsWith('verify') ? 'target' : undefined, - command.startsWith('bridges ') || command === 'accounts add' ? 'bridge' : undefined, - command.includes('messages ') || command.startsWith('send react') || command.startsWith('send unreact') ? 'message' : undefined, - ].filter((value): value is string => Boolean(value)) - const output = command.startsWith('send ') ? 'send-result' - : command === 'watch' || command === 'rpc' ? 'stream' - : command === 'man' ? 'manual' - : command.endsWith('list') || command.includes('search') || command === 'bridges list' ? 'list' - : mutates ? 'success' - : 'data' - const related = relatedForCommand(command) - return { mutates, requiresAuth, selectors, output, related } -} - -function relatedForCommand(command: string): string[] { - if (command.startsWith('send ')) return ['messages list', 'watch'] - if (command.startsWith('messages ')) return ['chats list', 'send text'] - if (command.startsWith('chats ')) return ['messages list', 'send text'] - if (command.startsWith('bridges ')) return ['accounts add', 'accounts list'] - if (command.startsWith('accounts ')) return ['bridges list', 'chats list'] - if (command.startsWith('targets ')) return ['status', 'doctor'] - if (command === 'status') return ['doctor', 'setup'] - if (command === 'doctor') return ['status', 'setup'] - if (command.startsWith('verify')) return ['setup', 'status'] - return [] -} diff --git a/packages/cli/src/commands/media/download.ts b/packages/cli/src/commands/media/download.ts deleted file mode 100644 index 9636d4d7..00000000 --- a/packages/cli/src/commands/media/download.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { mkdir, writeFile } from 'node:fs/promises' -import { basename, join } from 'node:path' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printSuccess } from '../../lib/output.js' -export default class MediaDownload extends BeeperCommand { - static override summary = 'Download message media' - static override args = { url: Args.string({ required: true, description: 'mxc:// or localmxc:// URL' }) } - static override flags = { - out: Flags.string({ char: 'o', default: '.', description: 'Output directory; pass - to stream the file to stdout' }), - } - async run(): Promise { - const { args, flags } = await this.parse(MediaDownload) - const client = await createClient(flags) - const response = await client.assets.serve({ url: args.url }) - const buffer = Buffer.from(await response.arrayBuffer()) - if (flags.out === '-') { - process.stdout.write(buffer) - return - } - ensureWritable(flags) - await mkdir(flags.out, { recursive: true }) - const path = join(flags.out, basename(new URL(args.url).pathname) || 'media') - await writeFile(path, buffer) - await printSuccess({ message: 'Downloaded media', detail: path, data: { path, bytes: buffer.length } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/messages/context.ts b/packages/cli/src/commands/messages/context.ts deleted file mode 100644 index a70a23fa..00000000 --- a/packages/cli/src/commands/messages/context.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { collectPage, printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class MessagesContext extends BeeperCommand { - static override summary = 'Show message context' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - id: Flags.string({ required: true, description: 'Target message ID to center the window on' }), - before: Flags.integer({ default: 10, description: 'Number of messages to include before the target' }), - after: Flags.integer({ default: 10, description: 'Number of messages to include after the target' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - } - async run(): Promise { - const { flags } = await this.parse(MessagesContext) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - const before = await collectPage(client.messages.list(chatID, { cursor: flags.id, direction: 'before' }), flags.before) - const after = await collectPage(client.messages.list(chatID, { cursor: flags.id, direction: 'after' }), flags.after) - await printData({ chatID, messageID: flags.id, before, after }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/messages/delete.ts b/packages/cli/src/commands/messages/delete.ts deleted file mode 100644 index 4488cdac..00000000 --- a/packages/cli/src/commands/messages/delete.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class MessagesDelete extends BeeperCommand { - static override summary = 'Delete a message' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - id: Flags.string({ required: true, description: 'Message ID to delete (final message ID; pending IDs are rejected)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - 'for-everyone': Flags.boolean({ default: false, description: 'Delete for everyone when the network supports it (otherwise deletes only for you)' }), - } - async run(): Promise { - const { flags } = await this.parse(MessagesDelete) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await client.messages.delete(flags.id, { chatID, forEveryone: flags['for-everyone'] || undefined }) - await printSuccess({ message: flags['for-everyone'] ? 'Deleted for everyone' : 'Deleted', data: { chatID, messageID: flags.id, forEveryone: flags['for-everyone'] } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/messages/edit.ts b/packages/cli/src/commands/messages/edit.ts deleted file mode 100644 index bc5a2cc4..00000000 --- a/packages/cli/src/commands/messages/edit.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData, printSuccess } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class MessagesEdit extends BeeperCommand { - static override summary = 'Edit a message' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - id: Flags.string({ required: true, description: 'Message ID to edit (must be one of your own messages with no attachments)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - message: Flags.string({ required: true, description: 'New message text' }), - } - async run(): Promise { - const { flags } = await this.parse(MessagesEdit) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - await printData(await client.messages.update(flags.id, { chatID, text: flags.message }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/messages/export.ts b/packages/cli/src/commands/messages/export.ts deleted file mode 100644 index 74080e8f..00000000 --- a/packages/cli/src/commands/messages/export.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { writeFile } from 'node:fs/promises' -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class MessagesExport extends BeeperCommand { - static override summary = 'Export one chat to JSON' - static override description = 'Lightweight per-chat JSON export. For a full export with transcripts, attachments, and multiple chats, use `beeper export`.' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - 'before-cursor': Flags.string({ description: 'Paginate messages older than this message ID' }), - 'after-cursor': Flags.string({ description: 'Paginate messages newer than this message ID' }), - after: Flags.string({ description: 'Only messages at or after this ISO timestamp (client-side filter)' }), - before: Flags.string({ description: 'Only messages at or before this ISO timestamp (client-side filter)' }), - limit: Flags.integer({ description: 'Maximum messages to export' }), - output: Flags.string({ char: 'o', default: '-', description: 'Output path; - writes JSON to stdout' }), - asc: Flags.boolean({ default: false, description: 'Order oldest first (default: newest first)' }), - } - - async run(): Promise { - const { flags } = await this.parse(MessagesExport) - if (flags['before-cursor'] && flags['after-cursor']) throw new Error('Use only one of --before-cursor or --after-cursor') - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - const cursor = flags['before-cursor'] ?? flags['after-cursor'] - const direction = flags['before-cursor'] ? 'before' : flags['after-cursor'] ? 'after' : undefined - const afterTs = flags.after ? Date.parse(flags.after) : undefined - const beforeTs = flags.before ? Date.parse(flags.before) : undefined - if (afterTs !== undefined && Number.isNaN(afterTs)) throw new Error(`--after is not a valid ISO timestamp: ${flags.after}`) - if (beforeTs !== undefined && Number.isNaN(beforeTs)) throw new Error(`--before is not a valid ISO timestamp: ${flags.before}`) - const items: unknown[] = [] - for await (const item of client.messages.list(chatID, { cursor, direction })) { - const ts = Date.parse((item as { timestamp?: string }).timestamp ?? '') - if (!Number.isNaN(ts)) { - if (afterTs !== undefined && ts < afterTs) continue - if (beforeTs !== undefined && ts > beforeTs) continue - } - items.push(item) - if (flags.limit !== undefined && items.length >= flags.limit) break - } - const messages = flags.asc ? [...items].reverse() : items - const envelope = { - exportedAt: new Date().toISOString(), - chatID, - after: flags.after, - before: flags.before, - count: messages.length, - messages, - } - const text = `${JSON.stringify(envelope, null, 2)}\n` - if (flags.output === '-') process.stdout.write(text) - else await writeFile(flags.output, text) - } -} diff --git a/packages/cli/src/commands/messages/list.ts b/packages/cli/src/commands/messages/list.ts deleted file mode 100644 index d3798d62..00000000 --- a/packages/cli/src/commands/messages/list.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { collectPage, printIDs, printList } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class MessagesList extends BeeperCommand { - static override summary = 'List chat messages' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - 'before-cursor': Flags.string({ description: 'Paginate messages older than this message ID' }), - 'after-cursor': Flags.string({ description: 'Paginate messages newer than this message ID' }), - sender: Flags.string({ description: 'Filter by sender: me, others, or a specific user ID (client-side)' }), - asc: Flags.boolean({ default: false, description: 'Order oldest first (default: newest first)' }), - ids: Flags.boolean({ default: false, description: 'Print only message IDs' }), - limit: Flags.integer({ default: 50, description: 'Maximum messages to print' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - } - async run(): Promise { - const { flags } = await this.parse(MessagesList) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - const before = flags['before-cursor'] - const after = flags['after-cursor'] - if (before && after) throw new Error('Use only one of --before-cursor or --after-cursor') - let items = await collectFiltered(client.messages.list(chatID, { cursor: before ?? after, direction: before ? 'before' : after ? 'after' : undefined }), flags.limit, flags.sender) - if (flags.asc) items = [...items].reverse() - if (flags.ids) printIDs(items) - else await printList(items, flags.json ? 'json' : 'human', { title: 'No messages yet', subtitle: 'This chat is empty.' }) - } -} - -async function collectFiltered(iterable: AsyncIterable, limit: number, sender: string | undefined): Promise { - if (!sender) return collectPage(iterable, limit) - const items: unknown[] = [] - for await (const item of iterable) { - if (matchesSender(item, sender)) items.push(item) - if (items.length >= limit) break - } - return items -} - -export function matchesSender(item: unknown, sender: string): boolean { - if (!item || typeof item !== 'object') return false - const row = item as { isSender?: boolean; senderID?: string } - if (sender === 'me') return row.isSender === true - if (sender === 'others') return row.isSender !== true - return row.senderID === sender -} diff --git a/packages/cli/src/commands/messages/search.ts b/packages/cli/src/commands/messages/search.ts deleted file mode 100644 index d88cd8f6..00000000 --- a/packages/cli/src/commands/messages/search.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { usageError } from '../../lib/errors.js' -import { collectPage, printIDs, printList } from '../../lib/output.js' -import { resolveAccountIDs, resolveChatID } from '../../lib/resolve.js' -import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' - -export default class MessagesSearch extends BeeperCommand { - static override summary = 'Search messages across chats' - static override examples = [ - 'beeper messages search "quarterly report"', - 'beeper messages search --chat "Work" --sender me --limit 20', - 'beeper messages search --media image --after 2024-01-01T00:00:00Z', - 'beeper messages search --chat-type group --sender others "meeting"', - ] - static override args = { - query: Args.string({ description: 'Search text (literal word match)', required: false }), - } - static override flags = { - account: Flags.string({ multiple: true, description: 'Limit to an account selector. Repeat for multiple.' }), - chat: Flags.string({ multiple: true, description: 'Limit to a chat selector. Repeat for multiple.' }), - 'chat-type': Flags.string({ options: ['group', 'single'], description: 'Only group chats or direct messages' }), - after: Flags.string({ description: 'Only messages at or after this ISO timestamp' }), - before: Flags.string({ description: 'Only messages at or before this ISO timestamp' }), - 'exclude-low-priority': Flags.boolean({ allowNo: true, description: 'Exclude low-priority chats' }), - ids: Flags.boolean({ default: false, description: 'Print only message IDs' }), - 'include-muted': Flags.boolean({ allowNo: true, default: true, description: 'Include muted chats' }), - limit: Flags.integer({ default: 50, description: 'Maximum results' }), - media: Flags.string({ multiple: true, options: ['any', 'video', 'image', 'link', 'file'], description: 'Filter by media type. Repeat for multiple.' }), - sender: Flags.string({ description: 'me, others, or a user ID' }), - } - - async run(): Promise { - const { args, flags } = await this.parse(MessagesSearch) - const hasFilter = Boolean( - flags.account?.length || flags.chat?.length || flags['chat-type'] - || flags.after || flags.before || flags.media?.length - || flags.sender, - ) - if (!args.query && !hasFilter) { - throw usageError('Provide a search query or at least one filter flag (--chat, --sender, --media, etc.).') - } - const client = await createClient(flags) - const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) - const chatIDs = flags.chat?.length - ? await Promise.all(flags.chat.map(chat => resolveChatID(client, chat, { accountIDs }))) - : undefined - const params = { - accountIDs, - chatIDs, - chatType: flags['chat-type'] as 'group' | 'single' | undefined, - dateAfter: flags.after, - dateBefore: flags.before, - excludeLowPriority: flags['exclude-low-priority'], - includeMuted: flags['include-muted'], - mediaTypes: flags.media as Array<'any' | 'video' | 'image' | 'link' | 'file'> | undefined, - query: args.query, - sender: flags.sender as 'me' | 'others' | (string & {}) | undefined, - } - const useSpinner = !flags.json && !flags.ids - const label = args.query ? `Searching messages for "${args.query}"…` : 'Searching messages…' - const items = useSpinner - ? await withSpinner(label, () => collectPage(client.messages.search(params), flags.limit), { - done: value => `${value.length} match${value.length === 1 ? '' : 'es'}`, - }) - : await collectPage(client.messages.search(params), flags.limit) - if (flags.ids) { - printIDs(items) - return - } - await printList(items, flags.json ? 'json' : 'human', { - title: 'No messages matched', - subtitle: args.query ? `Nothing found for "${args.query}".` : 'Try a different filter combination.', - suggestions: [ - { command: 'beeper messages list --chat ', hint: 'list messages from a chat' }, - { command: 'beeper chats search ""', hint: 'search chats instead' }, - ], - }) - } -} diff --git a/packages/cli/src/commands/messages/show.ts b/packages/cli/src/commands/messages/show.ts deleted file mode 100644 index d46a3f01..00000000 --- a/packages/cli/src/commands/messages/show.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class MessagesShow extends BeeperCommand { - static override summary = 'Show one message' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - id: Flags.string({ required: true, description: 'Message ID, pendingMessageID, or Matrix event ID' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - } - async run(): Promise { - const { flags } = await this.parse(MessagesShow) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - if (client.messages.retrieve) await printData(await client.messages.retrieve(flags.id, { chatID }), flags.json ? 'json' : 'human') - else throw new Error('This Desktop API does not expose message lookup.') - } -} diff --git a/packages/cli/src/commands/plugins.ts b/packages/cli/src/commands/plugins.ts deleted file mode 100644 index 2f0a8c64..00000000 --- a/packages/cli/src/commands/plugins.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Command } from '@oclif/core' - -export default class Plugins extends Command { - static override summary = 'Manage Beeper CLI plugins' - static override description = 'List recommended Beeper CLI plugins, or use oclif plugin commands to install, link, update, and remove plugins.' - - async run(): Promise { - this.log('Recommended plugins:') - this.log(' beeper plugins available') - this.log('') - this.log('Plugin management:') - this.log(' beeper plugins install ') - this.log(' beeper plugins link ') - this.log(' beeper plugins uninstall ') - } -} diff --git a/packages/cli/src/commands/plugins/available.ts b/packages/cli/src/commands/plugins/available.ts deleted file mode 100644 index 4147b30b..00000000 --- a/packages/cli/src/commands/plugins/available.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BeeperCommand } from '../../lib/command.js' -import { printData } from '../../lib/output.js' -import { recommendedPlugins } from '../../lib/recommended-plugins.js' - -export default class PluginsAvailable extends BeeperCommand { - static override summary = 'List recommended optional Beeper CLI plugins' - - async run(): Promise { - const { flags } = await this.parse(PluginsAvailable) - const installed = new Set(this.config.plugins.keys()) - const corePlugins = new Set((this.config.pjson.oclif.plugins ?? []) as string[]) - const plugins = recommendedPlugins.map(plugin => ({ - ...plugin, - installed: installed.has(plugin.name), - status: installed.has(plugin.name) ? 'installed' : 'not installed', - core: corePlugins.has(plugin.name), - })) - - await printData(plugins, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/presence.ts b/packages/cli/src/commands/presence.ts deleted file mode 100644 index ffcc3df5..00000000 --- a/packages/cli/src/commands/presence.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { setTimeout as delay } from 'node:timers/promises' -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../lib/command.js' -import { createClient } from '../lib/client.js' -import { printSuccess } from '../lib/output.js' -import { resolveChatID } from '../lib/resolve.js' - -export default class Presence extends BeeperCommand { - static override summary = 'Send a typing (or paused) indicator to a chat' - static override description = 'Requires server-side support. Networks without typing notifications return an error.' - static override flags = { - chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - state: Flags.string({ default: 'typing', options: ['typing', 'paused'], description: 'Indicator to send' }), - duration: Flags.integer({ description: 'When --state is typing, send paused automatically after this many seconds' }), - } - - async run(): Promise { - const { flags } = await this.parse(Presence) - ensureWritable(flags) - if (flags.duration !== undefined && flags.duration <= 0) throw new Error('--duration must be a positive integer (seconds)') - if (flags.duration !== undefined && flags.state !== 'typing') throw new Error('--duration only applies when --state is typing') - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - const post = (state: 'typing' | 'paused') => - client.post(`/v1/chats/${encodeURIComponent(chatID)}/typing`, { body: { state } }) - - await post(flags.state as 'typing' | 'paused') - if (flags.duration !== undefined) { - await delay(flags.duration * 1000) - await post('paused') - await printSuccess({ message: `Sent typing then paused after ${flags.duration}s`, data: { chatID, state: 'paused', durationSeconds: flags.duration } }, flags.json ? 'json' : 'human') - return - } - await printSuccess({ message: `Sent ${flags.state} indicator`, data: { chatID, state: flags.state } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/rpc.ts b/packages/cli/src/commands/rpc.ts deleted file mode 100644 index 08fd3389..00000000 --- a/packages/cli/src/commands/rpc.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { BeeperCommand } from '../lib/command.js' -import { createInterface } from 'node:readline/promises' -import { stdin as input } from 'node:process' -import { splitCommandLine } from '../lib/argv.js' -import { runCli } from '../lib/runner.js' - -type RPCRequest = { - args?: string[] - argv?: string[] - command?: string - id?: string | number | null -} - -export default class RPC extends BeeperCommand { - static override summary = 'Run newline-delimited JSON command RPC over stdin/stdout' - static override description = 'Reads JSON lines like {"id":1,"command":"send text --to 10313 --message hello"} or {"id":1,"args":["status","--json"]}.' - - async run(): Promise { - const rl = createInterface({ input }) - - for await (const line of rl) { - if (!line.trim()) continue - let requestID: string | number | null = null - - try { - const request = JSON.parse(line) as RPCRequest - requestID = request.id ?? null - const args = normalizeArgs(request) - if (args[0] === 'rpc' || args[0] === 'shell') throw new Error(`Unsupported nested command: ${args[0]}`) - const result = await runCli(args) - process.stdout.write(`${JSON.stringify({ - id: requestID, - ok: result.code === 0, - code: result.code, - signal: result.signal, - stdout: result.stdout, - stderr: result.stderr, - })}\n`) - } catch (error) { - process.stdout.write(`${JSON.stringify({ - id: requestID, - ok: false, - error: error instanceof Error ? error.message : String(error), - })}\n`) - } - } - } -} - -function normalizeArgs(request: RPCRequest): string[] { - const args = request.args ?? request.argv ?? (request.command ? splitCommandLine(request.command) : undefined) - if (!args || args.length === 0) throw new Error('Expected args, argv, or command') - return args -} diff --git a/packages/cli/src/commands/send/file.ts b/packages/cli/src/commands/send/file.ts deleted file mode 100644 index 67ed8ae6..00000000 --- a/packages/cli/src/commands/send/file.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' -import { sendMessage } from '../../lib/send-message.js' - -export default class SendFile extends BeeperCommand { - static override summary = 'Send a file' - static override description = 'Returns when Desktop accepts the send request. Pass `--wait` to wait until the message leaves the pending state or fails.' - static override examples = [ - 'beeper send file --to 10313 --file ./photo.jpg --caption "Look at this"', - 'beeper send file --to alice@whatsapp --file ./report.pdf', - 'beeper send file --to 8951 --file ./clip.mp4 --reply-to ', - ] - static override flags = { - to: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - file: Flags.string({ required: true, description: 'Local file path to upload (max 500 MB)' }), - caption: Flags.string({ description: 'Optional caption to send alongside the file' }), - filename: Flags.string({ description: 'Override the displayed filename' }), - mime: Flags.string({ description: 'Override MIME type detection' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - 'reply-to': Flags.string({ description: 'Send as a reply to this message ID' }), - wait: Flags.boolean({ default: false, description: 'Wait for the message to leave the pending state (or fail) before returning' }), - 'wait-timeout': Flags.integer({ default: 30_000, description: 'Maximum wait time in ms when --wait is set' }), - } - async run(): Promise { - const { flags } = await this.parse(SendFile) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.to, { pick: flags.pick }) - await printData(await sendMessage(client, { chatID, file: flags.file, fileName: flags.filename, mimeType: flags.mime, replyTo: flags['reply-to'], text: flags.caption || '', wait: flags.wait, waitTimeoutMs: flags['wait-timeout'] }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/send/react.ts b/packages/cli/src/commands/send/react.ts deleted file mode 100644 index af3fde14..00000000 --- a/packages/cli/src/commands/send/react.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class SendReact extends BeeperCommand { - static override summary = 'Send a reaction to a message' - static override flags = { - to: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - id: Flags.string({ required: true, description: 'Message ID to react to' }), - reaction: Flags.string({ required: true, description: 'Reaction key (emoji, shortcode, or custom emoji key)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - transaction: Flags.string({ description: 'Optional transaction ID for deduplication' }), - } - async run(): Promise { - const { flags } = await this.parse(SendReact) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.to, { pick: flags.pick }) - await printData( - await client.chats.messages.reactions.add(flags.id, { chatID, reactionKey: flags.reaction, transactionID: flags.transaction }), - flags.json ? 'json' : 'human', - ) - } -} diff --git a/packages/cli/src/commands/send/sticker.ts b/packages/cli/src/commands/send/sticker.ts deleted file mode 100644 index 3fb53bb2..00000000 --- a/packages/cli/src/commands/send/sticker.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' -import { sendMessage } from '../../lib/send-message.js' - -export default class SendSticker extends BeeperCommand { - static override summary = 'Send a sticker' - static override description = 'Uploads the file and sends as a sticker message. Defaults --mime to image/webp.' - static override flags = { - to: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - file: Flags.string({ required: true, description: 'Sticker file (typically 512x512 WebP)' }), - filename: Flags.string({ description: 'Override the displayed filename' }), - mime: Flags.string({ default: 'image/webp', description: 'MIME type for the sticker (default: image/webp)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - 'reply-to': Flags.string({ description: 'Send as a reply to this message ID' }), - wait: Flags.boolean({ default: false, description: 'Wait for the message to leave the pending state (or fail) before returning' }), - 'wait-timeout': Flags.integer({ default: 30_000, description: 'Maximum wait time in ms when --wait is set' }), - } - async run(): Promise { - const { flags } = await this.parse(SendSticker) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.to, { pick: flags.pick }) - await printData( - await sendMessage(client, { - chatID, - file: flags.file, - fileName: flags.filename, - mimeType: flags.mime, - replyTo: flags['reply-to'], - text: '', - attachmentType: 'sticker', - wait: flags.wait, - waitTimeoutMs: flags['wait-timeout'], - }), - flags.json ? 'json' : 'human', - ) - } -} diff --git a/packages/cli/src/commands/send/text.ts b/packages/cli/src/commands/send/text.ts deleted file mode 100644 index 42fdacfe..00000000 --- a/packages/cli/src/commands/send/text.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' -import { sendMessage } from '../../lib/send-message.js' - -export default class SendText extends BeeperCommand { - static override summary = 'Send a text message' - static override description = 'Returns when Desktop accepts the send request. Pass `--wait` to wait until the message leaves the pending state or fails.' - static override examples = [ - 'beeper send text --to 10313 --message "On my way"', - 'beeper send text --to 8951 --message "See @alice" --mention alice@whatsapp', - 'beeper send text --to alice@whatsapp --message "Got it" --reply-to ', - ] - static override flags = { - to: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - message: Flags.string({ required: true, description: 'Message text to send' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - 'reply-to': Flags.string({ description: 'Send as a reply to this message ID' }), - mention: Flags.string({ multiple: true, description: 'User ID to @-mention (repeatable)' }), - 'no-preview': Flags.boolean({ default: false, description: 'Disable automatic link preview for URLs in the message' }), - wait: Flags.boolean({ default: false, description: 'Wait for the message to leave the pending state (or fail) before returning' }), - 'wait-timeout': Flags.integer({ default: 30_000, description: 'Maximum wait time in ms when --wait is set' }), - } - async run(): Promise { - const { flags } = await this.parse(SendText) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.to, { pick: flags.pick }) - await printData(await sendMessage(client, { chatID, text: flags.message, replyTo: flags['reply-to'], mentions: flags.mention, noPreview: flags['no-preview'], wait: flags.wait, waitTimeoutMs: flags['wait-timeout'] }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/send/unreact.ts b/packages/cli/src/commands/send/unreact.ts deleted file mode 100644 index b49bf7c9..00000000 --- a/packages/cli/src/commands/send/unreact.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' - -export default class SendUnreact extends BeeperCommand { - static override summary = 'Remove a reaction from a message' - static override flags = { - to: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - id: Flags.string({ required: true, description: 'Message ID whose reaction to remove' }), - reaction: Flags.string({ required: true, description: 'Reaction key to remove (emoji, shortcode, or custom emoji key)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - transaction: Flags.string({ description: 'Optional transaction ID for deduplication' }), - } - - async run(): Promise { - const { flags } = await this.parse(SendUnreact) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.to, { pick: flags.pick }) - await printData( - await client.chats.messages.reactions.delete(flags.reaction, { chatID, messageID: flags.id }), - flags.json ? 'json' : 'human', - ) - } -} diff --git a/packages/cli/src/commands/send/voice.ts b/packages/cli/src/commands/send/voice.ts deleted file mode 100644 index 5c831b72..00000000 --- a/packages/cli/src/commands/send/voice.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' -import { sendMessage } from '../../lib/send-message.js' - -export default class SendVoice extends BeeperCommand { - static override summary = 'Send a voice note' - static override description = 'Uploads the audio file and sends as a voice note. Defaults --mime to audio/ogg.' - static override flags = { - to: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - file: Flags.string({ required: true, description: 'Voice note audio file (OGG/Opus recommended)' }), - duration: Flags.integer({ description: 'Voice note duration in seconds (overrides upload-detected duration)' }), - filename: Flags.string({ description: 'Override the displayed filename' }), - mime: Flags.string({ default: 'audio/ogg', description: 'MIME type for the voice note (default: audio/ogg)' }), - pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - 'reply-to': Flags.string({ description: 'Send as a reply to this message ID' }), - wait: Flags.boolean({ default: false, description: 'Wait for the message to leave the pending state (or fail) before returning' }), - 'wait-timeout': Flags.integer({ default: 30_000, description: 'Maximum wait time in ms when --wait is set' }), - } - async run(): Promise { - const { flags } = await this.parse(SendVoice) - ensureWritable(flags) - const client = await createClient(flags) - const chatID = await resolveChatID(client, flags.to, { pick: flags.pick }) - await printData( - await sendMessage(client, { - chatID, - file: flags.file, - fileName: flags.filename, - mimeType: flags.mime, - replyTo: flags['reply-to'], - text: '', - attachmentType: 'voice-note', - duration: flags.duration, - wait: flags.wait, - waitTimeoutMs: flags['wait-timeout'], - }), - flags.json ? 'json' : 'human', - ) - } -} diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts deleted file mode 100644 index 5b45031b..00000000 --- a/packages/cli/src/commands/setup.ts +++ /dev/null @@ -1,737 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable, writeEvent } from '../lib/command.js' -import { driveVerification, evaluateReadiness, type Readiness } from '../lib/app-state.js' -import { ensureDesktopToken, findLocalDesktop } from '../lib/desktop-auth.js' -import { promptText, promptYesNoDefaultYes } from '../lib/app-api.js' -import { installDesktop, installServer, readInstallations } from '../lib/installations.js' -import { connectedAccountSummary, findLocalDesktopSession, localConnectedAccountSummary, localDesktopReadiness, type LocalDesktopSession } from '../lib/local-desktop.js' -import { loginWithPKCE } from '../lib/oauth.js' -import { findDesktopAppPath, launchDesktopApp, startProfile } from '../lib/profiles.js' -import { interactiveEmailSetup } from '../lib/setup-login.js' -import { renderStartupLogo } from '../lib/logo.js' -import { - builtInDesktopTargetID, - createProfileTarget, - customTargetID, - readConfig, - readTarget, - listTargets, - saveTargetAuth, - updateConfig, - writeTarget, - type AuthSource, - type Target, -} from '../lib/targets.js' -import { printData, printSuccess } from '../lib/output.js' - -export default class Setup extends BeeperCommand { - static override summary = 'Make the selected target ready for messaging' - static override flags = { - local: Flags.boolean({ default: false, description: 'Use the local Beeper Desktop session on this device' }), - oauth: Flags.boolean({ default: false, description: 'Authorize the target with browser OAuth/PKCE' }), - remote: Flags.string({ description: 'Connect to a remote Beeper Desktop or Server URL' }), - server: Flags.boolean({ default: false, description: 'Set up a local Beeper Server target' }), - desktop: Flags.boolean({ default: false, description: 'Set up a local Beeper Desktop target' }), - install: Flags.boolean({ default: false, description: 'Allow installing missing managed runtime' }), - channel: Flags.string({ options: ['stable', 'nightly'], default: 'stable', description: 'Install release channel' }), - 'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }), - email: Flags.string({ description: 'Sign in with an email address' }), - username: Flags.string({ description: 'Username to use if setup creates a new account' }), - } - - static override examples = [ - 'beeper setup --local', - 'beeper setup --oauth', - 'beeper setup --remote https://my-beeper.example.com', - 'beeper setup --server --install', - 'beeper setup --desktop --install', - ] - - async run(): Promise { - const { flags } = await this.parse(Setup) - ensureWritable(flags) - const targetModeCount = [Boolean(flags.remote), flags.server, flags.desktop].filter(Boolean).length - if (targetModeCount > 1) throw new Error('Specify at most one of --remote, --server, or --desktop') - const authModeCount = [flags.local, flags.oauth, Boolean(flags.email)].filter(Boolean).length - if (authModeCount > 1) throw new Error('Specify at most one of --local, --oauth, or --email') - if ((flags.local || flags.oauth) && (flags.remote || flags.server || flags.desktop)) { - throw new Error('Use --local or --oauth with an existing target, not with --remote, --server, or --desktop.') - } - if (flags.events) writeEvent('setup_step', { step: 'start', target: flags.target }) - - if (flags.remote) { - await this.setupRemote(flags) - return - } - if (flags.server) { - await this.setupManaged('server', flags) - return - } - if (flags.desktop) { - await this.setupManaged('desktop', flags) - return - } - - const target = await setupTarget(flags) - if (flags.local) { - await this.setupLocal(target, flags) - return - } - if (flags.oauth) { - await this.setupOAuth(target, flags) - return - } - if (flags.email) { - await this.setupEmail(target, flags) - return - } - - await this.setupDefault(target, flags) - } - - private async setupDefault(target: Target, flags: SetupFlags): Promise { - const setupCmd = setupCommand(target) - printSetupHeader(flags) - printResumeBanner(target, flags) - if (target.type === 'desktop') { - const detected = await detectDesktopSetup(target, flags) - if (detected.kind === 'session-found') { - const local = detected.local - if (flags.yes) { - await this.printSetupResult(await commitLocalDesktopSetup(local), flags) - return - } - if (flags.json || !process.stdin.isTTY) { - await printData(setupSessionFoundOutput(local, setupCmd), flags.json ? 'json' : 'human') - return - } - printLocalDesktopPreview(local) - if (await promptYesNoDefaultYes('Use this Desktop session for CLI access?')) { - await this.printSetupResult(await commitLocalDesktopSetup(local), flags) - return - } - await printSuccess({ - message: local.readiness.state === 'ready' ? 'Beeper Desktop is ready' : `Setup paused: ${local.readiness.state}`, - detail: setupDetailForReadiness(local.readiness, local.target), - data: { target: publicTarget(local.target), readiness: local.readiness }, - }, 'human') - return - } else if (flags.json || !process.stdin.isTTY) { - await printData(setupStateOutput(detected, target), flags.json ? 'json' : 'human') - return - } else if (detected.kind === 'installed-not-running' && !flags.json && process.stdin.isTTY) { - printStatus('Found Beeper Desktop on this device.', 'installed, not running') - const shouldLaunch = flags.yes || await promptYesNoDefaultYes('Launch Beeper Desktop now?') - if (shouldLaunch) { - await launchAndPoll(target, setupCmd, flags) - return - } - } else if (detected.kind === 'running-signed-out' && !flags.json && process.stdin.isTTY) { - printStatus('Found Beeper Desktop on this device.', 'running, signed out') - const shouldOpen = flags.yes || await promptYesNoDefaultYes('Open Beeper Desktop so you can sign in?') - if (shouldOpen) { - await launchAndPoll(target, setupCmd, flags) - return - } - } else if (detected.kind === 'session-unreadable' && !flags.json && process.stdin.isTTY) { - printStatus('Found Beeper Desktop on this device.', 'signed in, but CLI could not read the local session') - process.stdout.write('You can still connect through Beeper Desktop.\n') - if (flags.debug) process.stdout.write(`\n${detected.reason}\n`) - process.stdout.write('\n') - const useOAuth = flags.yes || await promptYesNoDefaultYes('Connect through Beeper Desktop instead?') - if (useOAuth) { - await this.setupOAuth(target, flags) - return - } - } else if (detected.kind === 'not-installed' && !flags.json && process.stdin.isTTY) { - await this.setupFromChoice(flags) - return - } - } - - const readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id }) - if (readiness.state === 'target-unreachable' && target.type !== 'desktop') { - if (flags.json || !process.stdin.isTTY) { - await printData(currentTargetBrokenOutput(target, readiness), flags.json ? 'json' : 'human') - return - } - if (await this.handleBrokenCurrentTarget(target, readiness, flags)) return - } - if (readiness.state === 'target-unreachable' && target.type === 'desktop' && !flags.json && process.stdin.isTTY) { - const shouldLaunch = flags.yes || await promptYesNoDefaultYes('Beeper Desktop is not reachable. Launch it now?') - if (shouldLaunch) { - await launchAndPoll(target, setupCmd, flags) - return - } - } - - if (flags.json || !process.stdin.isTTY) { - await printData({ target: publicTarget(target), readiness }, flags.json ? 'json' : 'human') - return - } - - await printSuccess({ - message: readiness.state === 'ready' ? 'Target ready' : `Setup paused: ${readiness.state}`, - detail: setupDetailForReadiness(readiness, target), - data: { target: publicTarget(target), readiness }, - }, 'human') - } - - private async setupLocal(target: Target, flags: SetupFlags): Promise { - const result = await setupLocalDesktop(target, flags) - await this.printSetupResult(result, flags) - } - - private async setupOAuth(target: Target, flags: SetupFlags): Promise { - const result = await setupOAuthTarget(target, flags) - await this.printSetupResult(result, flags) - } - - private async setupEmail(target: Target, flags: SetupFlags): Promise { - const result = await setupEmailTarget(target, flags) - await this.printSetupResult(result, flags) - } - - private async setupRemote(flags: SetupFlags): Promise { - const name = flags.target ?? await uniqueRemoteName(flags.remote!) - if (!flags.json && process.stdin.isTTY) { - process.stdout.write('Connecting to Desktop API on another device.\n\n') - process.stdout.write(`Name: ${name}\n`) - process.stdout.write(`URL: ${flags.remote!}\n\n`) - } - const target: Target = { - id: name, - name, - type: 'remote', - baseURL: flags.remote!, - managed: false, - } - const result = flags.email ? await setupEmailTarget(target, flags) : await setupOAuthTarget(target, flags, 'remote-oauth') - await writeTarget(target) - if (!flags.target) await updateConfig(config => ({ ...config, defaultTarget: config.defaultTarget ?? target.id })) - await this.printSetupResult(result, flags) - } - - private async setupManaged(type: 'desktop' | 'server', flags: SetupFlags): Promise { - if (flags.install) { - if ((flags.json || !process.stdin.isTTY) && !flags.yes) throw new Error('Install requires --install --yes in non-interactive mode.') - if (type === 'desktop') await installWithCopy('desktop', flags) - else await installWithCopy('server', flags) - } - const id = flags.target ?? type - const target = await readTarget(id) ?? await createProfileTarget(type, id, { serverEnv: flags['server-env'], port: undefined }) - if (!flags.target) await updateConfig(config => ({ ...config, defaultTarget: config.defaultTarget ?? target.id })) - await startProfile(target).catch(error => { - if (type === 'desktop') return undefined - throw error - }) - if (flags.email) { - await this.setupEmail(target, flags) - return - } - const readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id }) - await printData({ target: publicTarget(target), readiness }, flags.json ? 'json' : 'human') - } - - private async printSetupResult(result: SetupResult, flags: SetupFlags): Promise { - result = await maybeDriveOnboarding(result, flags) - if (flags.json || !process.stdin.isTTY) { - await printData(result, flags.json ? 'json' : 'human') - return - } - await printSuccess({ - message: result.readiness.state === 'ready' - ? `Connected to ${result.target.name ?? result.target.id}` - : `Connected; setup paused: ${result.readiness.state}`, - detail: setupResultDetail(result), - data: result, - }, 'human') - if (result.readiness.state === 'ready') printNextSteps() - } - - private async setupFromChoice(flags: SetupFlags): Promise { - process.stdout.write('No usable Beeper Desktop session was found on this device.\n\n') - process.stdout.write('How do you want to connect Beeper CLI?\n\n') - process.stdout.write(' 1. Install Beeper Desktop\n') - process.stdout.write(' 2. Install local Beeper Server\n') - process.stdout.write(' 3. Connect with Desktop API on another device\n\n') - const choice = await promptChoice('Choose [1]: ', ['1', '2', '3'], '1') - if (choice === '1') { - if (!await promptYesNoDefaultYes('Install Beeper Desktop stable from beeper.com?')) return - await installWithCopy('desktop', { ...flags, channel: 'stable' }) - const target = await setupTarget({ ...flags, desktop: true }) - await launchAndPoll(target, setupCommand(target), flags) - return - } - if (choice === '2') { - if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return - await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'production' }) - await this.setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' }) - return - } - const url = await promptText('Desktop API URL: ') - if (!url) throw new Error('Remote URL is required.') - await this.setupRemote({ ...flags, remote: url }) - } - - private async handleBrokenCurrentTarget(target: Target, readiness: Readiness, flags: SetupFlags): Promise { - process.stdout.write(`Beeper CLI is set up for ${target.name ?? target.id}, but it is not reachable.\n\n`) - if (readiness.message) process.stdout.write(`${readiness.message}\n\n`) - process.stdout.write('What do you want to do?\n\n') - process.stdout.write(` 1. Retry ${target.name ?? target.id}\n`) - process.stdout.write(' 2. Use Beeper Desktop on this device\n') - process.stdout.write(' 3. Install local Beeper Server\n') - process.stdout.write(' 4. Connect with Desktop API on another device\n\n') - const choice = await promptChoice('Choose [1]: ', ['1', '2', '3', '4'], '1') - if (choice === '1') return false - if (choice === '2') { - const desktop = await defaultDesktopTarget() - await this.setupDefault(desktop, { ...flags, target: desktop.id }) - return true - } - if (choice === '3') { - if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return true - await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'production' }) - await this.setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' }) - return true - } - const url = await promptText('Desktop API URL: ') - if (!url) throw new Error('Remote URL is required.') - await this.setupRemote({ ...flags, remote: url }) - return true - } -} - -type SetupFlags = { - 'base-url'?: string - channel?: string - debug?: boolean - desktop?: boolean - events?: boolean - install?: boolean - json?: boolean - local?: boolean - oauth?: boolean - email?: string - remote?: string - server?: boolean - 'server-env'?: string - target?: string - username?: string - yes?: boolean -} - -type SetupResult = { - accounts: string[] - authSource?: AuthSource - readiness: Awaited> - target: ReturnType -} - -type PreparedLocalDesktopSetup = { - accounts: string[] - readiness: Readiness - session: LocalDesktopSession - target: Target -} - -type DesktopSetupDetection = - | { kind: 'session-found'; local: PreparedLocalDesktopSetup } - | { kind: 'installed-not-running' } - | { kind: 'running-signed-out'; readiness?: Readiness } - | { kind: 'session-unreadable'; reason: string; readiness?: Readiness } - | { kind: 'not-installed' } - -async function setupTarget(flags: SetupFlags): Promise { - if (flags['base-url']) return { id: customTargetID, type: 'desktop', baseURL: flags['base-url'] } - if (flags.target) { - const target = await readTarget(flags.target) - if (!target) throw new Error(`Unknown Beeper target "${flags.target}". Run \`beeper targets list\`.`) - return target - } - const config = await readConfig() - if (config.defaultTarget) { - const target = await readTarget(config.defaultTarget) - if (target) return target - } - const desktop = await readTarget(builtInDesktopTargetID) - if (desktop) return desktop - return defaultDesktopTarget() -} - -async function defaultDesktopTarget(): Promise { - const detected = await findLocalDesktop({ scan: true, timeoutMs: 300 }).catch(() => undefined) - const target: Target = { - id: builtInDesktopTargetID, - type: 'desktop', - name: 'Beeper Desktop', - baseURL: detected?.baseURL ?? 'http://127.0.0.1:23373', - managed: false, - runtime: { install: 'desktop', port: 23373 }, - } - await writeTarget(target) - await updateConfig(next => ({ ...next, defaultTarget: next.defaultTarget ?? target.id })) - return target -} - -async function setupLocalDesktop(target: Target, flags: SetupFlags): Promise { - return commitLocalDesktopSetup(await prepareLocalDesktopSetup(target, flags)) -} - -async function prepareLocalDesktopSetup(target: Target, flags: SetupFlags): Promise { - if (flags.events) writeEvent('setup_step', { step: 'local-desktop', target: target.id }) - const desktop = await findLocalDesktop({ baseURL: target.baseURL, scan: target.id === builtInDesktopTargetID, timeoutMs: 500 }).catch(() => undefined) - const resolvedTarget: Target = { - ...target, - id: target.id === customTargetID ? builtInDesktopTargetID : target.id, - type: 'desktop', - name: target.name ?? 'Beeper Desktop', - baseURL: desktop?.baseURL ?? target.baseURL, - managed: target.managed ?? false, - } - const session = await findLocalDesktopSession(resolvedTarget) - const readiness = localDesktopReadiness(session) - const accounts = await localConnectedAccountSummary(session.dataDir).catch(() => []) - return { accounts, readiness, session, target: resolvedTarget } -} - -async function detectDesktopSetup(target: Target, flags: SetupFlags): Promise { - printProgress(flags, 'Checking Beeper Desktop') - const appInstalled = await isDesktopAppInstalled() - printProgress(flags, 'Reading local Desktop session') - const local = await prepareLocalDesktopSetup(target, flags).catch(error => ({ error })) - if (!('error' in local)) return { kind: 'session-found', local } - - printProgress(flags, 'Checking Desktop readiness') - const desktop = await findLocalDesktop({ baseURL: target.baseURL, scan: target.id === builtInDesktopTargetID, timeoutMs: 500 }).catch(() => undefined) - if (desktop) { - const readiness = await evaluateReadiness({ baseURL: desktop.baseURL, target: target.id, token: false }) - if (readiness.state === 'needs-login') return { kind: 'running-signed-out', readiness } - return { - kind: 'session-unreadable', - reason: local.error instanceof Error ? local.error.message : String(local.error), - readiness, - } - } - - return appInstalled ? { kind: 'installed-not-running' } : { kind: 'not-installed' } -} - -async function isDesktopAppInstalled(): Promise { - const installations = await readInstallations().catch((): Awaited> => ({})) - return Boolean(installations.desktop?.path || await findDesktopAppPath()) -} - -async function commitLocalDesktopSetup(prepared: PreparedLocalDesktopSetup): Promise { - await writeTarget(prepared.target) - await saveTargetAuth(prepared.target, prepared.session.auth) - await updateConfig(config => ({ ...config, defaultTarget: config.defaultTarget ?? prepared.target.id })) - return { - accounts: prepared.accounts, - authSource: prepared.session.auth.source, - readiness: prepared.readiness, - target: publicTarget({ ...prepared.target, auth: prepared.session.auth }), - } -} - -async function setupOAuthTarget(target: Target, flags: SetupFlags, source?: AuthSource): Promise { - if (flags.events) writeEvent('setup_step', { step: 'oauth', target: target.id }) - if ((flags.json || !process.stdin.isTTY) && !flags.yes) throw new Error('OAuth setup requires an interactive terminal or --yes to open the browser.') - const authSource = source ?? (target.type === 'remote' ? 'remote-oauth' : 'desktop-oauth') - const token = target.type === 'desktop' && target.id === builtInDesktopTargetID - ? await ensureDesktopToken({ baseURL: target.baseURL, save: false, scan: true }) - : await loginWithPKCE({ - baseURL: target.baseURL, - clientName: 'Beeper CLI', - openBrowser: true, - save: false, - scope: 'read write', - source: authSource, - }) - const auth = typeof token === 'string' - ? { accessToken: token, source: authSource, tokenType: 'Bearer' as const } - : { - accessToken: token.access_token, - clientID: token.clientID, - expiresAt: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : undefined, - scope: token.scope, - source: authSource, - tokenType: token.token_type, - } - await writeTarget(target) - await saveTargetAuth(target, auth) - const [readiness, accounts] = await Promise.all([ - evaluateReadiness({ baseURL: target.baseURL, target: target.id, token: auth.accessToken }), - connectedAccountSummary(target, auth).catch(() => []), - ]) - return { accounts, authSource, readiness, target: publicTarget({ ...target, auth }) } -} - -async function setupEmailTarget(target: Target, flags: SetupFlags): Promise { - if (flags.events) writeEvent('setup_step', { step: 'email', target: target.id }) - const email = flags.email - if (!email) throw new Error('Email setup requires --email.') - if (flags.json || !process.stdin.isTTY) throw new Error('Email setup prompts for the verification code. For automation, use `beeper auth email start` and `beeper auth email response`.') - return interactiveEmailSetup(target, { email, username: flags.username, yes: flags.yes, json: flags.json }) -} - -function publicTarget(target: Target): Omit & { auth?: { source?: AuthSource; tokenType?: 'Bearer' } } { - const { auth, ...rest } = target - return { ...rest, auth: auth ? { source: auth.source, tokenType: auth.tokenType } : undefined } -} - -function localDesktopPreview(prepared: PreparedLocalDesktopSetup): Record { - return { - authSource: prepared.session.auth.source, - baseURL: prepared.target.baseURL, - dataDir: prepared.session.dataDir, - signedInAs: prepared.session.userID, - connectedAccounts: prepared.accounts, - } -} - -function printLocalDesktopPreview(prepared: PreparedLocalDesktopSetup): void { - process.stdout.write('Found Beeper Desktop on this device.\n\n') - process.stdout.write(`Status: ${prepared.readiness.state === 'ready' ? 'signed in and ready' : prepared.readiness.state}\n`) - if (prepared.session.userID) process.stdout.write(`Signed in as: ${prepared.session.userID}\n`) - if (prepared.accounts.length) process.stdout.write(`Connected accounts: ${prepared.accounts.join(', ')}\n`) - process.stdout.write('\n') -} - -function setupSessionFoundOutput(local: PreparedLocalDesktopSetup, setupCmd: string): Record { - return { - state: local.readiness.state === 'ready' ? 'desktop-ready' : 'desktop-session-found', - message: local.readiness.state === 'ready' - ? 'Beeper Desktop is signed in and ready.' - : 'Beeper Desktop is signed in, but setup is not finished.', - target: publicTarget(local.target), - readiness: local.readiness, - localDesktop: localDesktopPreview(local), - recommendedAction: action('use-desktop-session', `${setupCmd} --local`), - availableActions: [ - action('use-desktop-session', `${setupCmd} --local`), - action('desktop-oauth', `${setupCmd} --oauth`), - action('connect-remote', 'beeper setup --remote '), - ], - } -} - -function printSetupHeader(flags: SetupFlags): void { - if (flags.json || !process.stdin.isTTY || process.env.BEEPER_QUIET === '1') return - process.stdout.write(`${renderStartupLogo()}\n\n`) - process.stdout.write('Setup\n\n') -} - -function printResumeBanner(target: Target, flags: SetupFlags): void { - if (flags.json || !process.stdin.isTTY || process.env.BEEPER_QUIET === '1') return - if (target.id !== builtInDesktopTargetID || flags.target) process.stdout.write(`Continuing setup for ${target.name ?? target.id}.\n\n`) -} - -function printStatus(title: string, status: string): void { - process.stdout.write(`${title}\n\n`) - process.stdout.write(`Status: ${status}\n\n`) -} - -function printProgress(flags: SetupFlags, message: string): void { - if (flags.json || !process.stdin.isTTY || process.env.BEEPER_QUIET === '1') return - process.stdout.write(`${message}...\n`) -} - -async function promptChoice(label: string, allowed: string[], fallback: string): Promise { - const value = await promptText(label) - const normalized = value || fallback - if (!allowed.includes(normalized)) throw new Error(`Choose one of: ${allowed.join(', ')}`) - return normalized -} - -async function launchAndPoll(target: Target, setupCmd: string, flags: SetupFlags): Promise { - if (flags.events) writeEvent('setup_step', { step: 'launch', target: target.id }) - if (!flags.json && process.stdin.isTTY) process.stdout.write('Opening Beeper Desktop...\n') - await launchDesktopApp(target) - const readiness = await pollReadiness(target, 10_000) - const detail = readiness.state === 'target-unreachable' - ? `Run \`${setupCmd}\` again after Beeper Desktop finishes starting.` - : setupDetailForReadiness(readiness, target) - await printSuccess({ - message: 'Launched Beeper Desktop', - detail, - data: { target: publicTarget(target), readiness }, - }, flags.json ? 'json' : 'human') - if (!flags.json && process.stdin.isTTY && readiness.state === 'target-unreachable') { - process.stdout.write('\nNext:\n') - process.stdout.write(` ${setupCmd}\n`) - process.stdout.write(' beeper doctor\n') - } -} - -async function pollReadiness(target: Target, timeoutMs: number): Promise { - const started = Date.now() - let readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id, token: false }) - while (readiness.state === 'target-unreachable' && Date.now() - started < timeoutMs) { - await new Promise(resolve => setTimeout(resolve, 500)) - readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id, token: false }) - } - return readiness -} - -async function maybeDriveOnboarding(result: SetupResult, flags: SetupFlags): Promise { - if (flags.json || !process.stdin.isTTY) return result - if (result.readiness.state !== 'needs-verification' && result.readiness.state !== 'verification-in-progress') return result - process.stdout.write('Continuing verification...\n\n') - await driveVerification({ baseURL: result.target.baseURL, target: result.target.id, yes: flags.yes }) - return { - ...result, - readiness: await evaluateReadiness({ baseURL: result.target.baseURL, target: result.target.id }), - target: result.target, - } -} - -async function installWithCopy(type: 'desktop' | 'server', flags: SetupFlags): Promise { - const label = type === 'desktop' ? 'Beeper Desktop' : 'local Beeper Server' - const channel = flags.channel === 'nightly' ? 'nightly' : 'stable' - const serverEnv = flags['server-env'] === 'staging' ? 'staging' : 'production' - if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installing ${label} ${channel} from beeper.com...\n`) - if (type === 'desktop') await installDesktop({ channel, serverEnv }) - else await installServer({ channel, serverEnv }) - if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installed ${label} ${channel}.\n\n`) -} - -function setupResultDetail(result: SetupResult): string | undefined { - const detail = setupDetailForReadiness(result.readiness, result.target) - if (result.accounts.length && detail) return `Connected accounts: ${result.accounts.join(', ')}\n${detail}` - if (result.accounts.length) return `Connected accounts: ${result.accounts.join(', ')}` - return detail -} - -function printNextSteps(): void { - process.stdout.write('\nNext:\n') - process.stdout.write(' beeper chats list\n') - process.stdout.write(' beeper send text --to "hello"\n') -} - -function setupStateOutput(detected: Exclude, target: Target): Record { - if (detected.kind === 'installed-not-running') { - return setupActionEnvelope({ - state: 'desktop-installed-not-running', - message: 'Beeper Desktop is installed but not running.', - target, - recommendedAction: action('launch-desktop', 'beeper setup --desktop --yes'), - availableActions: [ - action('launch-desktop', 'beeper setup --desktop --yes'), - action('connect-remote', 'beeper setup --remote '), - action('install-server', 'beeper setup --server --install --yes'), - ], - }) - } - if (detected.kind === 'running-signed-out') { - return setupActionEnvelope({ - state: 'desktop-running-signed-out', - message: 'Beeper Desktop is running but not signed in.', - target, - readiness: detected.readiness, - recommendedAction: action('open-desktop', 'beeper setup --desktop --yes'), - availableActions: [ - action('open-desktop', 'beeper setup --desktop --yes'), - action('connect-remote', 'beeper setup --remote '), - ], - }) - } - if (detected.kind === 'session-unreadable') { - return setupActionEnvelope({ - state: 'desktop-running-session-unreadable', - message: 'Beeper Desktop is running, but CLI could not read the local session.', - target, - readiness: detected.readiness, - detail: detected.reason, - recommendedAction: action('desktop-oauth', 'beeper setup --oauth --yes'), - availableActions: [ - action('desktop-oauth', 'beeper setup --oauth --yes'), - action('connect-remote', 'beeper setup --remote '), - ], - }) - } - return setupActionEnvelope({ - state: 'desktop-not-installed', - message: 'No Beeper Desktop installation was found on this device.', - target, - recommendedAction: action('install-desktop', 'beeper setup --desktop --install --yes'), - availableActions: [ - action('install-desktop', 'beeper setup --desktop --install --yes'), - action('install-server', 'beeper setup --server --install --yes'), - action('connect-remote', 'beeper setup --remote '), - ], - }) -} - -function currentTargetBrokenOutput(target: Target, readiness: Readiness): Record { - return { - state: 'current-target-unreachable', - message: `Beeper CLI is set up for ${target.name ?? target.id}, but it is not reachable.`, - target: publicTarget(target), - readiness, - recommendedAction: action('retry-current', `beeper setup -t ${target.id}`), - availableActions: [ - action('retry-current', `beeper setup -t ${target.id}`), - action('use-desktop', 'beeper setup --desktop'), - action('install-server', 'beeper setup --server --install --yes'), - action('connect-remote', 'beeper setup --remote '), - ], - } -} - -function setupActionEnvelope(options: { - state: string - message: string - target: Target - detail?: string - readiness?: Readiness - recommendedAction: ReturnType - availableActions: Array> -}): Record { - return { - state: options.state, - message: options.message, - detail: options.detail, - target: publicTarget(options.target), - readiness: options.readiness, - recommendedAction: options.recommendedAction, - availableActions: options.availableActions, - } -} - -function action(id: string, command: string): { id: string; command: string } { - return { id, command } -} - -function setupDetailForReadiness(readiness: Readiness, target: Pick): string | undefined { - if (readiness.state === 'needs-login') return 'Sign in to Beeper Desktop, then run `beeper setup` again.' - if (readiness.state === 'needs-verification' || readiness.state === 'verification-in-progress') return 'Continue verification to finish setup.' - if (readiness.state === 'needs-recovery-key' || readiness.state === 'needs-secrets') return `Run \`beeper verify recovery-key${target.id === builtInDesktopTargetID ? '' : ` -t ${target.id}`}\`.` - if (readiness.state === 'needs-cross-signing-setup') return `Run \`beeper verify reset-recovery-key${target.id === builtInDesktopTargetID ? '' : ` -t ${target.id}`}\`.` - if (readiness.state === 'needs-first-sync' || readiness.state === 'initializing') return 'Beeper is still syncing. You can rerun `beeper setup` at any time.' - return readiness.message -} - -async function uniqueRemoteName(url: string): Promise { - const base = remoteName(url) - const targets = await listTargets() - const ids = new Set(targets.map(target => target.id)) - if (!ids.has(base)) return base - for (let index = 2; index < 100; index += 1) { - const id = `${base}-${index}` - if (!ids.has(id)) return id - } - return `remote-${Date.now()}` -} - -function setupCommand(target: Target): string { - return target.id === builtInDesktopTargetID ? 'beeper setup' : `beeper setup -t ${target.id}` -} - -function remoteName(url: string): string { - try { - return new URL(url).hostname.replace(/[^a-zA-Z0-9._-]/g, '-') || 'remote' - } catch { - return 'remote' - } -} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts deleted file mode 100644 index 3f7b93ce..00000000 --- a/packages/cli/src/commands/status.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BeeperCommand } from '../lib/command.js' -import { evaluateReadiness } from '../lib/app-state.js' -import { resolveTarget } from '../lib/targets.js' -import { printData } from '../lib/output.js' -export default class Status extends BeeperCommand { - static override summary = 'Show selected target and setup readiness' - static override description = 'Read-only readiness snapshot for the selected target. For active reachability checks and diagnostics, run `beeper doctor`.' - async run(): Promise { - const { flags } = await this.parse(Status) - const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) - await printData({ target, readiness: await evaluateReadiness({ baseURL: target.baseURL, target: target.id }) }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/add/desktop.ts b/packages/cli/src/commands/targets/add/desktop.ts deleted file mode 100644 index 34af8af4..00000000 --- a/packages/cli/src/commands/targets/add/desktop.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../../lib/command.js' -import { createProfileTarget, readTarget, updateConfig } from '../../../lib/targets.js' -import { printSuccess } from '../../../lib/output.js' - -export default class TargetsAddDesktop extends BeeperCommand { - static override summary = 'Add a managed Beeper Desktop target' - static override args = { name: Args.string({ required: false, description: 'Target name (default: "desktop")' }) } - static override flags = { - port: Flags.integer({ description: 'TCP port the managed Desktop will expose its API on' }), - default: Flags.boolean({ default: false, description: 'Set this target as the default after creation' }), - 'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }), - } - async run(): Promise { - const { args, flags } = await this.parse(TargetsAddDesktop) - ensureWritable(flags) - const id = args.name ?? 'desktop' - if (await readTarget(id)) throw new Error(`Target "${id}" already exists.`) - const target = await createProfileTarget('desktop', id, { serverEnv: flags['server-env'], port: flags.port }) - if (flags.default) await updateConfig(config => ({ ...config, defaultTarget: target.id })) - await printSuccess({ message: `Added target: ${target.id}`, detail: target.baseURL, data: target }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/add/remote.ts b/packages/cli/src/commands/targets/add/remote.ts deleted file mode 100644 index 1036872f..00000000 --- a/packages/cli/src/commands/targets/add/remote.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../../lib/command.js' -import { readTarget, updateConfig, writeTarget, type Target } from '../../../lib/targets.js' -import { printSuccess } from '../../../lib/output.js' - -export default class TargetsAddRemote extends BeeperCommand { - static override summary = 'Add a remote Beeper Desktop or Server target' - static override args = { - name: Args.string({ required: true, description: 'Local name for the target' }), - url: Args.string({ required: true, description: 'Base URL of the remote Desktop or Server API' }), - } - static override flags = { - default: Flags.boolean({ default: false, description: 'Set this target as the default after creation' }), - } - async run(): Promise { - const { args, flags } = await this.parse(TargetsAddRemote) - ensureWritable(flags) - if (await readTarget(args.name)) throw new Error(`Target "${args.name}" already exists.`) - const target: Target = { id: args.name, name: args.name, type: 'remote', baseURL: args.url, managed: false } - await writeTarget(target) - if (flags.default) await updateConfig(config => ({ ...config, defaultTarget: target.id })) - await printSuccess({ message: `Added target: ${target.id}`, detail: target.baseURL, data: target }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/add/server.ts b/packages/cli/src/commands/targets/add/server.ts deleted file mode 100644 index b5b10aa2..00000000 --- a/packages/cli/src/commands/targets/add/server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../../lib/command.js' -import { createProfileTarget, readTarget, updateConfig } from '../../../lib/targets.js' -import { printSuccess } from '../../../lib/output.js' - -export default class TargetsAddServer extends BeeperCommand { - static override summary = 'Add a managed Beeper Server target' - static override args = { name: Args.string({ required: false, description: 'Target name (default: "server")' }) } - static override flags = { - port: Flags.integer({ description: 'TCP port the managed Server will expose its API on' }), - default: Flags.boolean({ default: false, description: 'Set this target as the default after creation' }), - 'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }), - } - async run(): Promise { - const { args, flags } = await this.parse(TargetsAddServer) - ensureWritable(flags) - const id = args.name ?? 'server' - if (await readTarget(id)) throw new Error(`Target "${id}" already exists.`) - const target = await createProfileTarget('server', id, { serverEnv: flags['server-env'], port: flags.port }) - if (flags.default) await updateConfig(config => ({ ...config, defaultTarget: target.id })) - await printSuccess({ message: `Added target: ${target.id}`, detail: target.baseURL, data: target }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/disable.ts b/packages/cli/src/commands/targets/disable.ts deleted file mode 100644 index e0aa652e..00000000 --- a/packages/cli/src/commands/targets/disable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { readTarget, resolveTarget } from '../../lib/targets.js' -import { assertServerProfile, disableProfile } from '../../lib/profiles.js' -import { printSuccess } from '../../lib/output.js' - -export default class TargetsDisable extends BeeperCommand { - static override summary = 'Disable a local Beeper Server target at login' - static override args = { name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }) } - async run(): Promise { - const { args, flags } = await this.parse(TargetsDisable) - ensureWritable(flags) - const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) - if (!target) throw new Error(`Unknown Beeper target "${args.name}".`) - assertServerProfile(target) - const path = await disableProfile(target) - await printSuccess({ message: `Disabled target at login: ${target.id}`, detail: path, data: { target, path } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/enable.ts b/packages/cli/src/commands/targets/enable.ts deleted file mode 100644 index a709806d..00000000 --- a/packages/cli/src/commands/targets/enable.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { readTarget, resolveTarget } from '../../lib/targets.js' -import { assertServerProfile, enableProfile } from '../../lib/profiles.js' -import { printSuccess } from '../../lib/output.js' - -export default class TargetsEnable extends BeeperCommand { - static override summary = 'Enable a local Beeper Server target at login' - static override args = { name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }) } - async run(): Promise { - const { args, flags } = await this.parse(TargetsEnable) - ensureWritable(flags) - const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) - if (!target) throw new Error(`Unknown Beeper target "${args.name}".`) - assertServerProfile(target) - const path = await enableProfile(target) - await printSuccess({ message: `Enabled target at login: ${target.id}`, detail: path, data: { target, path } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/list.ts b/packages/cli/src/commands/targets/list.ts deleted file mode 100644 index e8721e76..00000000 --- a/packages/cli/src/commands/targets/list.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { readFile } from 'node:fs/promises' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { builtInDesktopTargetID, createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js' -import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js' -import { targetLiveStatus } from '../../lib/target-status.js' -import { printData, printSuccess } from '../../lib/output.js' - -export default class TargetsList extends BeeperCommand { - static override summary = 'List configured Beeper targets' - async run(): Promise { - const { flags } = await this.parse(TargetsList) - const config = await readConfig() - const targets = await listTargets() - const rows = targets.length ? targets : [{ id: builtInDesktopTargetID, type: 'desktop' as const, name: 'Beeper Desktop', baseURL: 'http://127.0.0.1:23373', managed: false }] - await printData(await Promise.all(rows.map(async target => ({ default: config.defaultTarget ? config.defaultTarget === target.id : target.id === builtInDesktopTargetID, id: target.id, type: target.type, name: target.name ?? target.id, managed: target.managed, baseURL: target.baseURL, runtime: target.runtime, ...(await targetLiveStatus(target as Target)) }))), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/logs.ts b/packages/cli/src/commands/targets/logs.ts deleted file mode 100644 index 21c23dc2..00000000 --- a/packages/cli/src/commands/targets/logs.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { readdir, readFile, stat } from 'node:fs/promises' -import { join } from 'node:path' -import { BeeperCommand } from '../../lib/command.js' -import { customTargetID, readTarget, resolveTarget } from '../../lib/targets.js' -import { desktopLogDir, profileErrorLogPath, profileLogPath } from '../../lib/profiles.js' - -export default class TargetsLogs extends BeeperCommand { - static override summary = 'Print logs for a local Beeper Desktop or Server install' - static override args = { name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }) } - static override flags = { - lines: Flags.integer({ default: 200, description: 'Lines to print from each log file' }), - files: Flags.integer({ default: 5, description: 'Desktop log files to print, newest first' }), - all: Flags.boolean({ default: false, description: 'Print all matching log files instead of only recent files' }), - } - async run(): Promise { - const { args, flags } = await this.parse(TargetsLogs) - const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) - if (!target) throw new Error(`Unknown Beeper target "${args.name}".`) - if (target.type === 'remote' || target.id === customTargetID) throw new Error(`Target "${target.id}" is remote and has no local logs.`) - if (target.type === 'server') { - if (!target.managed) throw new Error(`Target "${target.id}" is not a local Beeper Server install.`) - await printLogFile(profileLogPath(target.id), flags.lines) - await printLogFile(profileErrorLogPath(target.id), flags.lines) - return - } - const files = await listLogFiles(desktopLogDir(target.managed ? target : undefined)) - const selected = flags.all ? files : files.slice(0, flags.files) - for (const file of files) { - if (!selected.includes(file)) continue - await printLogFile(file, flags.lines) - } - } -} - -async function listLogFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) - const files = await Promise.all(entries.map(async entry => { - const path = join(dir, entry.name) - if (entry.isDirectory()) return listLogFiles(path) - if (entry.isFile() && entry.name.endsWith('.log')) return [path] - return [] - })) - const paths = files.flat() - const stats = await Promise.all(paths.map(async path => ({ path, mtimeMs: (await stat(path)).mtimeMs }))) - return stats.sort((a, b) => b.mtimeMs - a.mtimeMs).map(item => item.path) -} - -async function printLogFile(path: string, lines: number): Promise { - const content = await readFile(path, 'utf8').catch(() => '') - if (!content) return - process.stdout.write(`\n==> ${path} <==\n`) - process.stdout.write(tailLines(content, lines)) -} - -function tailLines(content: string, lines: number): string { - if (lines <= 0) return content - const parts = content.split('\n') - const tail = parts.slice(Math.max(0, parts.length - lines - 1)).join('\n') - return tail.endsWith('\n') ? tail : `${tail}\n` -} diff --git a/packages/cli/src/commands/targets/remove.ts b/packages/cli/src/commands/targets/remove.ts deleted file mode 100644 index 099ae16f..00000000 --- a/packages/cli/src/commands/targets/remove.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { readFile } from 'node:fs/promises' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js' -import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js' -import { targetLiveStatus } from '../../lib/target-status.js' -import { printData, printSuccess } from '../../lib/output.js' - -export default class TargetsRemove extends BeeperCommand { - static override summary = 'Remove a target' - static override args = { name: Args.string({ required: true, description: 'Target name' }) } - async run(): Promise { - const { args, flags } = await this.parse(TargetsRemove) - ensureWritable(flags) - await removeTarget(args.name) - await printSuccess({ message: `Removed target: ${args.name}`, data: { id: args.name } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/restart.ts b/packages/cli/src/commands/targets/restart.ts deleted file mode 100644 index 414e004b..00000000 --- a/packages/cli/src/commands/targets/restart.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { readTarget, resolveTarget } from '../../lib/targets.js' -import { assertServerProfile, startProfile, stopProfile } from '../../lib/profiles.js' -import { printSuccess } from '../../lib/output.js' - -export default class TargetsRestart extends BeeperCommand { - static override summary = 'Restart a local Beeper Server target' - static override args = { name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }) } - async run(): Promise { - const { args, flags } = await this.parse(TargetsRestart) - ensureWritable(flags) - const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) - if (!target) throw new Error(`Unknown Beeper target "${args.name}".`) - assertServerProfile(target) - await stopProfile(target).catch(() => undefined) - const result = await startProfile(target) - await printSuccess({ message: `Restarted target: ${target.id}`, detail: target.baseURL, data: { target, result } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/show.ts b/packages/cli/src/commands/targets/show.ts deleted file mode 100644 index 2fc64d07..00000000 --- a/packages/cli/src/commands/targets/show.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { readFile } from 'node:fs/promises' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js' -import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js' -import { targetLiveStatus } from '../../lib/target-status.js' -import { printData, printSuccess } from '../../lib/output.js' - -export default class TargetsShow extends BeeperCommand { - static override summary = 'Show target details' - static override args = { name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }) } - async run(): Promise { - const { args, flags } = await this.parse(TargetsShow) - const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) - if (!target) throw new Error(`Unknown Beeper target "${args.name}".`) - await printData(target, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/start.ts b/packages/cli/src/commands/targets/start.ts deleted file mode 100644 index e51ca9b8..00000000 --- a/packages/cli/src/commands/targets/start.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { customTargetID, readTarget, resolveTarget } from '../../lib/targets.js' -import { launchDesktopApp, startProfile } from '../../lib/profiles.js' -import { printSuccess } from '../../lib/output.js' - -export default class TargetsStart extends BeeperCommand { - static override summary = 'Start a local Server target or open Beeper Desktop' - static override args = { name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }) } - async run(): Promise { - const { args, flags } = await this.parse(TargetsStart) - ensureWritable(flags) - const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) - if (!target) throw new Error(`Unknown Beeper target "${args.name}".`) - if (target.type === 'desktop' && target.id !== customTargetID) { - const result = await launchDesktopApp(target.managed ? target : undefined) - await printSuccess({ message: 'Opened Beeper Desktop', detail: target.baseURL, data: { target, result } }, flags.json ? 'json' : 'human') - return - } - if (!target.managed || target.type !== 'server') { - throw new Error(`Target "${target.id}" is not a local Beeper Server install.`) - } - const result = await startProfile(target) - await printSuccess({ message: `Started target: ${target.id}`, detail: target.baseURL, data: { target, result } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/status.ts b/packages/cli/src/commands/targets/status.ts deleted file mode 100644 index 531ce441..00000000 --- a/packages/cli/src/commands/targets/status.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { readFile } from 'node:fs/promises' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js' -import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js' -import { targetLiveStatus } from '../../lib/target-status.js' -import { printData, printSuccess } from '../../lib/output.js' - -export default class TargetsStatus extends BeeperCommand { - static override summary = 'Check endpoint and process reachability for a target' - static override args = { name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }) } - async run(): Promise { - const { args, flags } = await this.parse(TargetsStatus) - const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) - if (!target) throw new Error(`Unknown Beeper target "${args.name}".`) - const status = await targetLiveStatus(target) - await printData({ target, ...status }, flags.json ? 'json' : 'human') - if (!status.reachable) process.exitCode = 1 - } -} diff --git a/packages/cli/src/commands/targets/stop.ts b/packages/cli/src/commands/targets/stop.ts deleted file mode 100644 index badd49cc..00000000 --- a/packages/cli/src/commands/targets/stop.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Args } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { readTarget, resolveTarget } from '../../lib/targets.js' -import { assertServerProfile, stopProfile } from '../../lib/profiles.js' -import { printSuccess } from '../../lib/output.js' - -export default class TargetsStop extends BeeperCommand { - static override summary = 'Stop a local Beeper Server target' - static override args = { name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }) } - async run(): Promise { - const { args, flags } = await this.parse(TargetsStop) - ensureWritable(flags) - const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) - if (!target) throw new Error(`Unknown Beeper target "${args.name}".`) - assertServerProfile(target) - await stopProfile(target) - await printSuccess({ message: `Stopped target: ${target.id}`, data: { target } }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/targets/use.ts b/packages/cli/src/commands/targets/use.ts deleted file mode 100644 index cf46e5a3..00000000 --- a/packages/cli/src/commands/targets/use.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Args, Flags } from '@oclif/core' -import { readFile } from 'node:fs/promises' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js' -import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js' -import { targetLiveStatus } from '../../lib/target-status.js' -import { printData, printSuccess } from '../../lib/output.js' - -export default class TargetsUse extends BeeperCommand { - static override summary = 'Set the default target' - static override args = { name: Args.string({ required: true, description: 'Target name' }) } - async run(): Promise { - const { args, flags } = await this.parse(TargetsUse) - ensureWritable(flags) - const target = await readTarget(args.name) - if (!target) throw new Error(`Unknown Beeper target "${args.name}". Run \`beeper targets list\`.`) - await updateConfig(config => ({ ...config, defaultTarget: target.id })) - await printSuccess({ message: `Using target: ${target.id}`, detail: target.baseURL, data: target }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts deleted file mode 100644 index 587fda05..00000000 --- a/packages/cli/src/commands/update.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../lib/command.js' -import { - checkInstallationUpdate, - readInstallations, - updateServerInstallation, - type Installation, -} from '../lib/installations.js' -import { profileStatus, startProfile, stopProfile } from '../lib/profiles.js' -import { listTargets } from '../lib/targets.js' -import { pathSetupHint } from '../lib/env.js' -import { printData } from '../lib/output.js' -import pkg from '../../package.json' with { type: 'json' } - -export default class Update extends BeeperCommand { - static override summary = 'Check and install Beeper updates' - static override flags = { - cli: Flags.boolean({ default: false, description: 'Check the Beeper CLI package' }), - desktop: Flags.boolean({ default: false, description: 'Check the CLI-owned Desktop install' }), - server: Flags.boolean({ default: false, description: 'Check the CLI-owned Server install' }), - check: Flags.boolean({ default: false, description: 'Only check for updates; do not install' }), - } - - async run(): Promise { - const { flags } = await this.parse(Update) - if (!flags.check) ensureWritable(flags) - const selected = flags.cli || flags.desktop || flags.server - const installations = await readInstallations() - const results: Array> = [] - - if (!selected || flags.cli) { - results.push({ kind: 'cli', ...(await checkCLI()) }) - } - - if ((!selected || flags.desktop) && installations.desktop) { - results.push({ kind: 'desktop', ...(await checkDesktop(installations.desktop)) }) - } else if ((!selected || flags.desktop) && !installations.desktop) { - results.push({ kind: 'desktop', installed: false, action: 'Run: beeper install desktop' }) - } - - if ((!selected || flags.server) && installations.server) { - const check = await checkInstallationUpdate(installations.server) - if (check.available && !flags.check) { - const runningProfiles = await runningServerProfiles() - const updated = await updateServerInstallation(installations.server) - const restartedProfiles = [] - for (const profile of runningProfiles) { - await stopProfile(profile).catch(() => undefined) - await startProfile(profile) - restartedProfiles.push(profile.id) - } - results.push({ kind: 'server', updated: true, previousVersion: installations.server.version, currentVersion: updated.version, path: updated.path, restartedProfiles, hint: pathSetupHint() }) - } else { - results.push({ kind: 'server', ...check }) - } - } else if ((!selected || flags.server) && !installations.server) { - results.push({ kind: 'server', installed: false, action: 'Run: beeper install server' }) - } - - await printData(results, flags.json ? 'json' : 'human') - } -} - -async function runningServerProfiles(): Promise>> { - const profiles = (await listTargets()).filter(target => target.managed && target.type === 'server') - const running = [] - for (const profile of profiles) { - const status = await profileStatus(profile) - if (status.running) running.push(profile) - } - return running -} - -async function checkDesktop(installation: Installation): Promise> { - const check = await checkInstallationUpdate(installation) - return { - ...check, - action: 'Update Beeper Desktop in the app.', - } -} - -async function checkCLI(): Promise> { - const currentVersion = pkg.version - const installMethod = detectCLIInstallMethod() - try { - const response = await fetch('https://api.github.com/repos/beeper/cli/releases/latest', { - headers: { accept: 'application/vnd.github+json', 'user-agent': 'beeper-cli' }, - signal: AbortSignal.timeout(5000), - }) - if (!response.ok) throw new Error(`GitHub releases returned ${response.status}`) - const latest = await response.json() as { tag_name?: string } - const latestVersion = latest.tag_name?.replace(/^v/, '') - const available = !!latestVersion && latestVersion !== currentVersion - return { - currentVersion, - latestVersion, - installMethod: installMethod.kind, - available, - action: available ? upgradeAction(installMethod) : 'beeper-cli is up to date.', - } - } catch (error) { - return { - currentVersion, - installMethod: installMethod.kind, - available: false, - action: `Could not check GitHub releases for beeper-cli updates: ${(error as Error).message}`, - } - } -} - -type CLIInstallMethod = - | { kind: 'brew' } - | { kind: 'npm-global' } - | { kind: 'git'; path: string } - | { kind: 'unknown'; path: string } - -function detectCLIInstallMethod(): CLIInstallMethod { - const path = decodeURI(new URL(import.meta.url).pathname) - if (/\/(Cellar|homebrew|linuxbrew)\//.test(path)) return { kind: 'brew' } - // bun/npm/yarn global installs end up under `/lib/node_modules/...` or `/global/...` - if (/\/(lib\/node_modules|global\/(\d+\.\d+\.\d+\/)?node_modules)\//.test(path)) return { kind: 'npm-global' } - if (/\/(\.packs|packages\/cli\/dist|packages\/cli\/src)\//.test(path)) return { kind: 'git', path } - return { kind: 'unknown', path } -} - -function upgradeAction(method: CLIInstallMethod): string { - switch (method.kind) { - case 'brew': - return 'Update with: brew upgrade beeper/tap/cli' - case 'npm-global': - return 'Update with: npm install -g beeper-cli@latest' - case 'git': - return `Update with: git -C ${method.path.split('/packages/')[0]} pull && bun run --filter @beeper/cli build` - default: - return 'Update with: brew upgrade beeper/tap/cli OR npm install -g beeper-cli@latest' - } -} diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts deleted file mode 100644 index a95b8ab1..00000000 --- a/packages/cli/src/commands/verify.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../lib/command.js' -import { driveVerification } from '../lib/app-state.js' -import { printData } from '../lib/output.js' -export default class AuthVerify extends BeeperCommand { - static override summary = 'Finish setup verification or verify another device' - static override flags = { - user: Flags.string({ description: 'User ID to verify against (defaults to your own account)' }), - } - async run(): Promise { - const { flags } = await this.parse(AuthVerify) - ensureWritable(flags) - await printData(await driveVerification({ baseURL: flags['base-url'], target: flags.target, userID: flags.user, yes: flags.yes }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/approve.ts b/packages/cli/src/commands/verify/approve.ts deleted file mode 100644 index 489f8bc4..00000000 --- a/packages/cli/src/commands/verify/approve.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifyApprove extends BeeperCommand { - static override summary = 'Approve a pending device verification request' - static override flags = { - id: Flags.string({ description: 'Verification request ID. Defaults to the active request.' }), - } - async run(): Promise { - const { flags } = await this.parse(AuthVerifyApprove) - ensureWritable(flags) - const client = await createClient(flags) - await printData(await client.app.verifications.accept(flags.id ?? 'active'), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/cancel.ts b/packages/cli/src/commands/verify/cancel.ts deleted file mode 100644 index f30ddd33..00000000 --- a/packages/cli/src/commands/verify/cancel.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifyCancel extends BeeperCommand { - static override summary = 'Cancel an in-progress device verification' - static override flags = { - id: Flags.string({ description: 'Verification request ID. Defaults to the active request.' }), - } - async run(): Promise { - const { flags } = await this.parse(AuthVerifyCancel) - ensureWritable(flags) - const client = await createClient(flags) - await printData(await client.app.verifications.cancel(flags.id ?? 'active', {}), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/list.ts b/packages/cli/src/commands/verify/list.ts deleted file mode 100644 index 3e5724cc..00000000 --- a/packages/cli/src/commands/verify/list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BeeperCommand } from '../../lib/command.js' -import { getAppState } from '../../lib/app-state.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifyList extends BeeperCommand { - static override summary = 'List active verification work' - async run(): Promise { - const { flags } = await this.parse(AuthVerifyList) - const state = await getAppState({ baseURL: flags['base-url'], target: flags.target }) - await printData(state.verification ? [state.verification] : [], flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/qr-confirm.ts b/packages/cli/src/commands/verify/qr-confirm.ts deleted file mode 100644 index 0cb190e0..00000000 --- a/packages/cli/src/commands/verify/qr-confirm.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifyQrConfirm extends BeeperCommand { - static override summary = 'Confirm that the other device scanned your QR code' - static override flags = { - id: Flags.string({ description: 'Verification request ID. Defaults to the active request.' }), - } - async run(): Promise { - const { flags } = await this.parse(AuthVerifyQrConfirm) - ensureWritable(flags) - const client = await createClient(flags) - await printData(await client.app.verifications.qr.confirmScanned(flags.id ?? 'active'), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/qr-scan.ts b/packages/cli/src/commands/verify/qr-scan.ts deleted file mode 100644 index baf39624..00000000 --- a/packages/cli/src/commands/verify/qr-scan.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifyQrScan extends BeeperCommand { - static override summary = 'Submit a scanned QR-code verification payload' - static override flags = { - id: Flags.string({ description: 'Verification request ID. Defaults to the active request.' }), - payload: Flags.string({ required: true, description: 'Raw QR-code data scanned from the other device' }), - } - async run(): Promise { - const { flags } = await this.parse(AuthVerifyQrScan) - ensureWritable(flags) - const client = await createClient(flags) - await printData(await client.app.verifications.qr.scan({ data: flags.payload }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/recovery-key.ts b/packages/cli/src/commands/verify/recovery-key.ts deleted file mode 100644 index 1c13b696..00000000 --- a/packages/cli/src/commands/verify/recovery-key.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifyRecoveryKey extends BeeperCommand { - static override summary = 'Unlock encrypted messages with a recovery key' - static override flags = { - key: Flags.string({ description: 'Recovery key string', required: true }), - } - async run(): Promise { - const { flags } = await this.parse(AuthVerifyRecoveryKey) - ensureWritable(flags) - const client = await createClient(flags) - await printData(await client.app.login.verification.recoveryKey.verify({ recoveryKey: flags.key }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/reset-recovery-key.ts b/packages/cli/src/commands/verify/reset-recovery-key.ts deleted file mode 100644 index f2676d98..00000000 --- a/packages/cli/src/commands/verify/reset-recovery-key.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -import { promptYesNoDefaultYes } from '../../lib/app-api.js' - -export default class AuthVerifyResetRecoveryKey extends BeeperCommand { - static override summary = 'Create a new encrypted-messages recovery key' - - async run(): Promise { - const { flags } = await this.parse(AuthVerifyResetRecoveryKey) - ensureWritable(flags) - const client = await createClient(flags) - const reset = await client.app.login.verification.recoveryKey.reset.create({}) - - if ((flags.json || !process.stdin.isTTY) && !flags.yes) { - throw new Error('Resetting the recovery key requires --yes in non-interactive mode so the new key can be confirmed.') - } - - if (!flags.yes) { - process.stderr.write(`New recovery key:\n${reset.recoveryKey}\n`) - if (!await promptYesNoDefaultYes('I saved this recovery key. Use it for this account?')) throw new Error('Recovery key reset cancelled.') - } - - const confirmed = await client.app.login.verification.recoveryKey.reset.confirm({ recoveryKey: reset.recoveryKey }) - - await printData({ recoveryKey: reset.recoveryKey, session: confirmed.session }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/sas-confirm.ts b/packages/cli/src/commands/verify/sas-confirm.ts deleted file mode 100644 index dbd618b0..00000000 --- a/packages/cli/src/commands/verify/sas-confirm.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifySasConfirm extends BeeperCommand { - static override summary = 'Confirm matching emoji verification' - static override flags = { - id: Flags.string({ description: 'Verification request ID. Defaults to the active request.' }), - } - async run(): Promise { - const { flags } = await this.parse(AuthVerifySasConfirm) - ensureWritable(flags) - const client = await createClient(flags) - await printData(await client.app.verifications.sas.confirm(flags.id ?? 'active'), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/sas.ts b/packages/cli/src/commands/verify/sas.ts deleted file mode 100644 index d184102e..00000000 --- a/packages/cli/src/commands/verify/sas.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifySas extends BeeperCommand { - static override summary = 'Start emoji verification' - static override flags = { - id: Flags.string({ description: 'Verification request ID. Defaults to the active request.' }), - } - async run(): Promise { - const { flags } = await this.parse(AuthVerifySas) - ensureWritable(flags) - const client = await createClient(flags) - await printData(await client.app.verifications.sas.start(flags.id ?? 'active'), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/show.ts b/packages/cli/src/commands/verify/show.ts deleted file mode 100644 index 3fc639bb..00000000 --- a/packages/cli/src/commands/verify/show.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BeeperCommand } from '../../lib/command.js' -import { getAppState } from '../../lib/app-state.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifyShow extends BeeperCommand { - static override summary = 'Show the current active verification request' - async run(): Promise { - const { flags } = await this.parse(AuthVerifyShow) - await printData((await getAppState({ baseURL: flags['base-url'], target: flags.target })).verification ?? null, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/start.ts b/packages/cli/src/commands/verify/start.ts deleted file mode 100644 index 58f58871..00000000 --- a/packages/cli/src/commands/verify/start.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Flags } from '@oclif/core' -import { BeeperCommand, ensureWritable } from '../../lib/command.js' -import { createClient } from '../../lib/client.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifyStart extends BeeperCommand { - static override summary = 'Start a device verification request' - static override flags = { - user: Flags.string({ description: 'User ID to verify with (defaults to your own account)' }), - } - async run(): Promise { - const { flags } = await this.parse(AuthVerifyStart) - ensureWritable(flags) - const client = await createClient(flags) - await printData(await client.app.verifications.create({ userID: flags.user }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/verify/status.ts b/packages/cli/src/commands/verify/status.ts deleted file mode 100644 index 06c642e6..00000000 --- a/packages/cli/src/commands/verify/status.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BeeperCommand } from '../../lib/command.js' -import { evaluateReadiness } from '../../lib/app-state.js' -import { printData } from '../../lib/output.js' -export default class AuthVerifyStatus extends BeeperCommand { - static override summary = 'Show encryption and device-verification readiness' - async run(): Promise { - const { flags } = await this.parse(AuthVerifyStatus) - await printData(await evaluateReadiness({ baseURL: flags['base-url'], target: flags.target }), flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/version.ts b/packages/cli/src/commands/version.ts deleted file mode 100644 index bfb6efd5..00000000 --- a/packages/cli/src/commands/version.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { dirname, join } from 'node:path' -import { BeeperCommand } from '../lib/command.js' -import { printData } from '../lib/output.js' -export default class Version extends BeeperCommand { - static override summary = 'Print CLI version' - async run(): Promise { - const { flags } = await this.parse(Version) - const root = dirname(dirname(fileURLToPath(import.meta.url))) - const pkg = JSON.parse(await readFile(join(root, '../package.json'), 'utf8')) - await printData({ name: pkg.name, version: pkg.version }, flags.json ? 'json' : 'human') - } -} diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts deleted file mode 100644 index 4a4e7e5f..00000000 --- a/packages/cli/src/commands/watch.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { createHmac } from 'node:crypto' -import { Flags } from '@oclif/core' -import WebSocket from 'ws' -import { BeeperCommand, writeEvent } from '../lib/command.js' -import { requireToken } from '../lib/client.js' -import { getBaseURL } from '../lib/targets.js' -import { startStream } from '../lib/output.js' - -type WebhookConfig = { url: string; secret?: string; queue: Array<{ body: string; signature?: string }>; inflight: number; max: number } -export type EventFilter = { include?: Set; exclude?: Set } - -export default class Watch extends BeeperCommand { - static override summary = 'Stream Desktop API WebSocket events' - static override flags = { - chat: Flags.string({ char: 'c', multiple: true, description: 'Chat ID to subscribe to. Defaults to all chats.' }), - json: Flags.boolean({ default: false, description: 'Print raw JSON, one event per line' }), - 'include-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted'], description: 'Only forward events of these types. Repeat for multiple.' }), - 'exclude-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted'], description: 'Drop events of these types. Repeat for multiple.' }), - webhook: Flags.string({ description: 'Forward each event to this URL as a POST request (best-effort, fire-and-forget)' }), - 'webhook-secret': Flags.string({ description: 'HMAC-SHA256 secret. Signs payloads with X-Beeper-Signature: sha256=' }), - 'webhook-queue': Flags.integer({ default: 64, description: 'Maximum pending webhook deliveries before dropping events' }), - } - - async run(): Promise { - const { flags } = await this.parse(Watch) - if (flags['webhook-secret'] && !flags.webhook) throw new Error('--webhook-secret requires --webhook URL') - if (flags['include-type']?.length && flags['exclude-type']?.length) throw new Error('Use either --include-type or --exclude-type, not both.') - const filter: EventFilter = { - include: flags['include-type']?.length ? new Set(flags['include-type']) : undefined, - exclude: flags['exclude-type']?.length ? new Set(flags['exclude-type']) : undefined, - } - const token = await requireToken() - const baseURL = await getBaseURL(flags['base-url']) - const info = await fetch(new URL('/v1/info', baseURL)) - if (!info.ok) throw new Error(`Failed to fetch /v1/info: HTTP ${info.status}`) - const metadata = await info.json() as { endpoints?: { ws_events?: string } } - const endpoint = metadata.endpoints?.ws_events || '/v1/ws' - const url = new URL(endpoint, baseURL) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - - const subscribed = flags.chat?.length ? flags.chat : ['*'] - const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } }) - const webhook: WebhookConfig | undefined = flags.webhook - ? { url: flags.webhook, secret: flags['webhook-secret'], queue: [], inflight: 0, max: flags['webhook-queue'] } - : undefined - - if (flags.json) { - await this.runJSON(ws, subscribed, flags.events, webhook, filter) - return - } - await this.runHuman(ws, subscribed, baseURL, flags.events, webhook, filter) - } - - private async runJSON(ws: WebSocket, subscribed: string[], events: boolean, webhook?: WebhookConfig, filter?: EventFilter): Promise { - ws.addEventListener('open', () => { - if (events) writeEvent('watch.open', { subscribed }) - ws.send(JSON.stringify({ type: 'subscriptions.set', chatIDs: subscribed })) - }) - ws.addEventListener('message', event => { - const data = typeof event.data === 'string' ? event.data : event.data.toString() - if (!passesFilter(data, filter)) return - if (events) writeEvent('watch.message') - process.stdout.write(`${data}\n`) - if (webhook) forwardWebhook(webhook, data, events) - }) - ws.addEventListener('error', () => { - if (events) writeEvent('watch.error', { message: 'WebSocket connection failed' }) - this.error('WebSocket connection failed', { exit: 1 }) - }) - ws.addEventListener('close', event => { - if (events) writeEvent('watch.close', { code: event.code, reason: event.reason }) - if (event.code !== 1000) this.error(`WebSocket closed: ${event.code} ${event.reason}`, { exit: 1 }) - }) - await new Promise(resolve => { - process.once('SIGINT', () => { ws.close(1000); resolve() }) - ws.addEventListener('close', () => resolve()) - }) - } - - private async runHuman(ws: WebSocket, subscribed: string[], baseURL: string, events: boolean, webhook?: WebhookConfig, filter?: EventFilter): Promise { - const stream = await startStream({ baseURL, subscribed }) - let closed = false - - const finish = async (): Promise => { - if (closed) return - closed = true - try { ws.close(1000) } catch { /* ignore */ } - await stream.close() - } - - ws.addEventListener('open', () => { - if (events) writeEvent('watch.open', { subscribed }) - stream.setConnected(true) - ws.send(JSON.stringify({ type: 'subscriptions.set', chatIDs: subscribed })) - }) - ws.addEventListener('message', event => { - const data = typeof event.data === 'string' ? event.data : event.data.toString() - if (!passesFilter(data, filter)) return - if (events) writeEvent('watch.message') - if (webhook) forwardWebhook(webhook, data, events) - try { - const parsed = JSON.parse(data) as Record - stream.push({ - type: typeof parsed.type === 'string' ? parsed.type : 'event', - chatID: typeof parsed.chatID === 'string' ? parsed.chatID : undefined, - messageID: typeof parsed.messageID === 'string' ? parsed.messageID : undefined, - ts: typeof parsed.timestamp === 'string' ? parsed.timestamp : new Date().toISOString(), - }) - } catch { - stream.push({ type: 'raw', ts: new Date().toISOString() }) - } - }) - ws.addEventListener('error', () => { - if (events) writeEvent('watch.error', { message: 'WebSocket connection failed' }) - stream.setConnected(false) - stream.setStatus('connection error') - }) - ws.addEventListener('close', event => { - if (events) writeEvent('watch.close', { code: event.code, reason: event.reason }) - stream.setConnected(false) - if (event.code !== 1000) stream.setStatus(`closed ${event.code}${event.reason ? ` ${event.reason}` : ''}`) - void finish() - }) - process.once('SIGINT', () => { void finish() }) - - await stream.done - } -} - -export function passesFilter(body: string, filter?: EventFilter): boolean { - if (!filter || (!filter.include && !filter.exclude)) return true - let type: string | undefined - try { - const parsed = JSON.parse(body) as { type?: unknown } - if (typeof parsed.type === 'string') type = parsed.type - } catch { - return true - } - if (!type) return true - if (filter.include && !filter.include.has(type)) return false - if (filter.exclude && filter.exclude.has(type)) return false - return true -} - -function forwardWebhook(webhook: WebhookConfig, body: string, events: boolean): void { - if (webhook.inflight + webhook.queue.length >= webhook.max) { - if (events) writeEvent('watch.webhook_drop', { reason: 'queue_full', size: webhook.queue.length }) - process.stderr.write(`warning: webhook queue full (${webhook.max}); dropped event\n`) - return - } - const signature = webhook.secret - ? `sha256=${createHmac('sha256', webhook.secret).update(body).digest('hex')}` - : undefined - webhook.queue.push({ body, signature }) - void drainWebhook(webhook, events) -} - -async function drainWebhook(webhook: WebhookConfig, events: boolean): Promise { - while (webhook.queue.length > 0) { - const item = webhook.queue.shift()! - webhook.inflight++ - try { - const headers: Record = { 'content-type': 'application/json' } - if (item.signature) headers['x-beeper-signature'] = item.signature - const response = await fetch(webhook.url, { method: 'POST', headers, body: item.body, signal: AbortSignal.timeout(10_000) }) - if (!response.ok) { - if (events) writeEvent('watch.webhook_error', { status: response.status }) - process.stderr.write(`warning: webhook POST ${webhook.url} returned ${response.status}\n`) - } - } catch (error) { - if (events) writeEvent('watch.webhook_error', { message: (error as Error).message }) - process.stderr.write(`warning: webhook POST failed: ${(error as Error).message}\n`) - } finally { - webhook.inflight-- - } - } -} diff --git a/packages/cli/src/lib/account-login.ts b/packages/cli/src/lib/account-login.ts index 1572d2ff..e7571067 100644 --- a/packages/cli/src/lib/account-login.ts +++ b/packages/cli/src/lib/account-login.ts @@ -1,18 +1,20 @@ -import { createInterface } from 'node:readline/promises' import { execFileSync } from 'node:child_process' import { stdin as input, stderr as output } from 'node:process' +import { setTimeout as sleep } from 'node:timers/promises' import QRCode from 'qrcode' import type { LoginSession } from '@beeper/desktop-api/resources/bridges.js' import type { BeeperDesktop } from '@beeper/desktop-api' +import { promptText } from './prompts.js' -export type AccountLoginStep = LoginSession +type AccountLoginStep = LoginSession -export type AccountLoginOptions = { +type AccountLoginOptions = { cookies?: Record fields?: Record nonInteractive?: boolean webview?: boolean webviewBackend?: 'auto' | 'chrome' | 'webkit' + webViewConstructor?: WebViewConstructor webviewTimeoutMs?: number } @@ -78,11 +80,11 @@ export async function runGuidedAccountLogin(client: BeeperDesktop, bridgeID: str continue } - throw new Error(`Missing required field ${field.id}. Pass --field ${field.id}=... or run without --non-interactive.`) + throw new Error(`Missing required field ${field.id}. Pass --field ${field.id}=... or run without --no-input.`) } const fallback = field.initialValue ? ` [${field.initialValue}]` : '' - const value = await promptText(`${field.label ?? field.id}${fallback}: `) + const value = await promptText(`${field.label ?? field.id}${fallback}: `, output) fields[field.id] = value || field.initialValue || '' } session = await client.bridges.loginSessions.steps.submit(step.stepID, { bridgeID, loginSessionID: session.loginSessionID, type: 'user_input', fields }) @@ -111,7 +113,7 @@ export async function runGuidedAccountLogin(client: BeeperDesktop, bridgeID: str continue } - if (options.nonInteractive) throw new Error(`Missing required cookie ${id}. Pass --cookie ${id}=... or run without --non-interactive.`) + if (options.nonInteractive) throw new Error(`Missing required cookie ${id}. Pass --cookie ${id}=... or run without --no-input.`) fields[id] = await promptSecret(`${id}: `) } session = await client.bridges.loginSessions.steps.submit(step.stepID, { bridgeID, loginSessionID: session.loginSessionID, type: 'cookies', fields, source: usedWebView ? 'webview' : 'api' }) @@ -166,15 +168,10 @@ type WebViewConstructor = new (options?: Record) => { } const EXTRACT_JS_KEY = '__BEEP_BEEP_AUTH_RESULTS__' -let webViewConstructorOverride: WebViewConstructor | undefined - -export function setWebViewConstructorForTest(constructor: WebViewConstructor | undefined): void { - webViewConstructorOverride = constructor -} async function collectCookieFieldsWithWebView(step: CookieLoginStep, options: AccountLoginOptions): Promise> { const BunRuntime = (globalThis as { Bun?: { WebView?: WebViewConstructor } }).Bun - const WebView = webViewConstructorOverride ?? BunRuntime?.WebView + const WebView = options.webViewConstructor ?? BunRuntime?.WebView if (!WebView) throw new Error('Bun.WebView is not available in this Bun runtime.') const backend = options.webviewBackend && options.webviewBackend !== 'auto' ? options.webviewBackend : undefined @@ -341,19 +338,12 @@ async function collectSpecialFields(view: InstanceType, fiel function normalizeCookieFields(fields: CookieLoginStep['fields']): NormalizedCookieField[] { return fields.map(field => { const rich = field as CookieField - const sources = rich.sources?.length ? rich.sources : legacySourcesForField(rich) + const sources = rich.sources ?? [] const required = rich.required ?? true return { ...rich, sources, required } }) } -function legacySourcesForField(field: CookieField): CookieFieldSource[] { - const name = field.name ?? field.id - if (field.type === 'header') return [{ type: 'request_header', name }] - if (field.type === 'local_storage') return [{ type: 'local_storage', name }] - return [{ type: 'cookie', name }] -} - function matchesCookieDomain(actual: string, expected: string): boolean { const normalizedActual = actual.replace(/^\./, '').toLowerCase() const normalizedExpected = expected.replace(/^\./, '').toLowerCase() @@ -378,24 +368,11 @@ function stringRecord(value: unknown): Record { return out } -async function sleep(ms: number): Promise { - await new Promise(resolve => setTimeout(resolve, ms)) -} - -async function promptText(label: string): Promise { - const rl = createInterface({ input, output }) - try { - return (await rl.question(label)).trim() - } finally { - rl.close() - } -} - async function promptSecret(label: string): Promise { - if (!input.isTTY) return promptText(label) + if (!input.isTTY) return promptText(label, output) try { execFileSync('stty', ['-echo'], { stdio: ['inherit', 'ignore', 'ignore'] }) - return await promptText(label) + return await promptText(label, output) } finally { execFileSync('stty', ['echo'], { stdio: ['inherit', 'ignore', 'ignore'] }) output.write('\n') diff --git a/packages/cli/src/lib/api-values.ts b/packages/cli/src/lib/api-values.ts new file mode 100644 index 00000000..e414a507 --- /dev/null +++ b/packages/cli/src/lib/api-values.ts @@ -0,0 +1,11 @@ +export type APIRecord = Record + +export function apiRecord(value: unknown): APIRecord { + return value && typeof value === 'object' && !Array.isArray(value) ? value as APIRecord : {} +} + +export function apiItems(value: unknown): APIRecord[] { + if (Array.isArray(value)) return value.map(apiRecord) + const items = apiRecord(value).items + return Array.isArray(items) ? items.map(apiRecord) : [] +} diff --git a/packages/cli/src/lib/app-api.ts b/packages/cli/src/lib/app-api.ts index ffd7a9fc..864fd7fd 100644 --- a/packages/cli/src/lib/app-api.ts +++ b/packages/cli/src/lib/app-api.ts @@ -1,28 +1,19 @@ -import { createInterface } from 'node:readline/promises' -import { stdin as input, stdout as output } from 'node:process' -import { getAccessToken, resolveTarget, updateTargetCache } from './targets.js' -import type { LoginRegisterResponse, LoginResponseResponse } from '@beeper/desktop-api/resources/app/login' -import type { ResetCreateResponse } from '@beeper/desktop-api/resources/app/login/verification/recovery-key/reset' - -export type AppLoginSuccess = LoginResponseResponse.Success | LoginRegisterResponse -export type AppRegistrationRequired = LoginResponseResponse.RegistrationRequired -export type AppLoginOutput = LoginResponseResponse | LoginRegisterResponse -export type AppRecoveryCodeResetBeginResponse = ResetCreateResponse -export type AppRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' +import { resolveTarget } from './targets.js' export async function appRequest( - method: AppRequestMethod, + method: string, path: string, options: { baseURL?: string; body?: Record; token?: string | false; target?: string } = {}, ): Promise { const target = await resolveTarget({ target: options.target, baseURL: options.baseURL }) - const baseURL = target.baseURL - const token = options.token === false ? undefined : options.token ?? await getAccessToken(target) + const token = options.token === false + ? undefined + : options.token ?? process.env.BEEPER_ACCESS_TOKEN ?? target.auth?.accessToken const headers: Record = {} if (token) headers.authorization = `Bearer ${token}` if (options.body) headers['content-type'] = 'application/json' - const response = await fetch(new URL(path, baseURL), { + const response = await fetch(new URL(path, target.baseURL), { method, headers, body: options.body ? JSON.stringify(options.body) : undefined, @@ -30,33 +21,5 @@ export async function appRequest( if (!response.ok) throw new Error(`${method} ${path} failed: ${response.status} ${await response.text()}`) if (response.status === 204) return undefined as T const text = await response.text() - const data = (text ? JSON.parse(text) : {}) as T - if (method === 'GET' && path === '/v1/app/setup') { - await updateTargetCache(target, { baseURL, appState: data }).catch(() => undefined) - } - return data -} - -export async function promptText(label: string): Promise { - const rl = createInterface({ input, output }) - try { - const value = await rl.question(label) - return value.trim() - } finally { - rl.close() - } -} - -export async function promptYesNo(label: string): Promise { - const value = (await promptText(`${label} [y/N] `)).toLowerCase() - return value === 'y' || value === 'yes' -} - -export async function promptYesNoDefaultYes(label: string): Promise { - const value = (await promptText(`${label} [Y/n] `)).toLowerCase() - return value === '' || value === 'y' || value === 'yes' -} - -export function isRegistrationRequired(output: AppLoginOutput): output is AppRegistrationRequired { - return 'registrationRequired' in output && output.registrationRequired === true + return (text ? JSON.parse(text) : {}) as T } diff --git a/packages/cli/src/lib/app-state.ts b/packages/cli/src/lib/app-state.ts index e5ac6c68..b155a03b 100644 --- a/packages/cli/src/lib/app-state.ts +++ b/packages/cli/src/lib/app-state.ts @@ -1,10 +1,11 @@ import type { AppSessionResponse } from '@beeper/desktop-api/resources/app' import type { QrConfirmScannedResponse, SASConfirmResponse, SASStartResponse, VerificationAcceptResponse, VerificationCreateResponse } from '@beeper/desktop-api/resources/app/verifications' -import { appRequest, promptYesNo } from './app-api.js' +import { appRequest } from './app-api.js' +import { promptConfirm } from './prompts.js' -export type AppState = AppSessionResponse +type AppState = AppSessionResponse -export type ReadinessState = AppState['state'] +type ReadinessState = AppState['state'] | 'no-target' | 'target-unreachable' | 'login-in-progress' @@ -25,20 +26,19 @@ export type Readiness = { message?: string } -export function nextAppStep(state: AppState, targetID?: string): string | undefined { +function nextAppStep(state: AppState, targetID?: string): string | undefined { const appState = state.state as ReadinessState - const target = targetID && targetID !== 'desktop' ? ` -t ${targetID}` : '' + const target = targetID && targetID !== 'desktop' ? ` --target ${targetID}` : '' if (appState === 'ready') return undefined - if (appState === 'needs-login') return `Run: beeper setup${target}` - if (appState === 'needs-verification') return `Run: beeper verify${target}` - if (appState === 'needs-secrets' || appState === 'needs-recovery-key') return `Run: beeper verify recovery-key${target}` - if (appState === 'needs-cross-signing-setup') return `Run: beeper verify reset-recovery-key${target}` + if (appState === 'needs-login' || appState === 'needs-verification' || appState === 'verification-in-progress') return `Run: beeper setup${target}` + if (appState === 'needs-secrets' || appState === 'needs-recovery-key') return `Finish recovery in Beeper, then run: beeper setup${target}` + if (appState === 'needs-cross-signing-setup') return `Finish cross-signing setup in Beeper, then run: beeper setup${target}` return `Waiting for app state: ${appState}` } export async function evaluateReadiness(options: { baseURL?: string; target?: string; token?: string | false } = {}): Promise { try { - const app = await getAppState(options) + const app = await appRequest('GET', '/v1/app/setup', options) const state = normalizeReadinessState(app) return { state, @@ -49,18 +49,14 @@ export async function evaluateReadiness(options: { baseURL?: string; target?: st } catch (error) { return { state: 'target-unreachable', - actions: ['targets status', 'targets start', 'doctor'], + actions: ['status', 'targets runtime start', 'setup'], message: error instanceof Error ? error.message : String(error), } } } -export async function getAppState(options: { baseURL?: string; target?: string; token?: string | false } = {}): Promise { - return appRequest('GET', '/v1/app/setup', options) -} - -export async function driveVerification(options: { baseURL?: string; target?: string; userID?: string; yes?: boolean } = {}): Promise { - let state = await getAppState(options) +export async function driveVerification(options: { baseURL?: string; force?: boolean; target?: string; userID?: string } = {}): Promise { + let state = await appRequest('GET', '/v1/app/setup', options) if (state.state === 'ready') return state if (state.state === 'needs-login') throw new Error('Target is not signed in. Run `beeper setup` after signing in to Beeper Desktop.') @@ -89,9 +85,9 @@ export async function driveVerification(options: { baseURL?: string; target?: st if (actions.has('sas.confirm') && id) { const sas = state.verification?.sas - if (!options.yes) { + if (!options.force) { process.stdout.write(`Verify that this matches on the other device:\n${sas?.emojis ?? sas?.decimals ?? '(no SAS data)'}\n`) - if (!await promptYesNo('Do they match?')) throw new Error('Verification cancelled.') + if (!await promptConfirm('Do they match?')) throw new Error('Verification cancelled.') } state = (await appRequest('POST', `/v1/app/setup/verifications/${encodeURIComponent(id)}/sas/confirm`, options)).session continue @@ -128,28 +124,9 @@ function normalizeReadinessState(app: AppState): ReadinessState { } function actionsForState(state: ReadinessState): string[] { - switch (state) { - case 'no-target': - return ['targets add desktop', 'targets add remote'] - case 'target-unreachable': - return ['targets status', 'targets start', 'doctor'] - case 'needs-login': - case 'login-in-progress': - return ['setup', 'auth status'] - case 'needs-cross-signing-setup': - return ['verify reset-recovery-key'] - case 'needs-verification': - case 'verification-in-progress': - return ['verify', 'verify list', 'verify sas', 'verify qr-scan'] - case 'needs-recovery-key': - case 'needs-secrets': - return ['verify recovery-key'] - case 'needs-first-sync': - case 'initializing': - return ['setup', 'status'] - case 'ready': - return ['chats list', 'messages list', 'send text'] - case 'error': - return ['doctor', 'setup'] - } + if (state === 'ready') return ['chats list', 'messages list', 'send text'] + if (state === 'no-target') return ['setup', 'targets add'] + if (state === 'target-unreachable') return ['status', 'targets runtime start', 'setup'] + if (state === 'error') return ['status', 'setup'] + return ['setup', 'status'] } diff --git a/packages/cli/src/lib/argv.ts b/packages/cli/src/lib/argv.ts deleted file mode 100644 index 31515cf5..00000000 --- a/packages/cli/src/lib/argv.ts +++ /dev/null @@ -1,47 +0,0 @@ -export function splitCommandLine(input: string): string[] { - const tokens: string[] = [] - let current = '' - let tokenStarted = false - let quote: '"' | "'" | undefined - let escaped = false - - for (const char of input) { - if (escaped) { - current += char - tokenStarted = true - escaped = false - continue - } - - if (char === '\\' && quote !== "'") { - escaped = true - continue - } - - if ((char === '"' || char === "'") && (!quote || quote === char)) { - if (!quote) tokenStarted = true - quote = quote ? undefined : char - continue - } - - if (!quote && /\s/.test(char)) { - if (tokenStarted) { - tokens.push(current) - current = '' - tokenStarted = false - } - continue - } - - current += char - tokenStarted = true - } - - if (escaped) { - current += '\\' - tokenStarted = true - } - if (quote) throw new Error(`Unclosed ${quote} quote`) - if (tokenStarted) tokens.push(current) - return tokens -} diff --git a/packages/cli/src/lib/client.ts b/packages/cli/src/lib/client.ts deleted file mode 100644 index a7f674bc..00000000 --- a/packages/cli/src/lib/client.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BeeperDesktop } from '@beeper/desktop-api' -import { resolveTarget } from './targets.js' -import { ensureDesktopToken } from './desktop-auth.js' - -export async function createClient(flags: { baseURL?: string; 'base-url'?: string; target?: string; debug?: boolean } = {}) { - const target = await resolveTarget({ target: flags.target, baseURL: flags.baseURL || flags['base-url'] }) - const accessToken = process.env.BEEPER_ACCESS_TOKEN - || target.auth?.accessToken - || await ensureDesktopToken({ baseURL: target.baseURL, scan: target.id === 'desktop' }) - return new BeeperDesktop({ - accessToken, - baseURL: target.baseURL, - logLevel: flags.debug ? 'debug' : 'warn', - }) -} - -export async function requireToken(options: { baseURL?: string; target?: string; scan?: boolean } = {}): Promise { - const target = await resolveTarget({ target: options.target, baseURL: options.baseURL }) - const token = process.env.BEEPER_ACCESS_TOKEN || target.auth?.accessToken - if (token) return token - return ensureDesktopToken({ baseURL: target.baseURL, scan: options.scan }) -} diff --git a/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts b/packages/cli/src/lib/cloudflare-tunnel.ts similarity index 69% rename from packages/cli-plugin-cloudflare/src/lib/cloudflared.ts rename to packages/cli/src/lib/cloudflare-tunnel.ts index bada2c6a..18c538b5 100644 --- a/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts +++ b/packages/cli/src/lib/cloudflare-tunnel.ts @@ -1,3 +1,4 @@ +import { spawn, execFileSync, type ChildProcess } from 'node:child_process' import { createWriteStream } from 'node:fs' import { access, chmod, mkdir, rename, rm } from 'node:fs/promises' import { arch, platform } from 'node:os' @@ -6,35 +7,17 @@ import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import type { ReadableStream } from 'node:stream/web' import { fileURLToPath } from 'node:url' -import { execFileSync, spawn, type ChildProcess } from 'node:child_process' -export const currentCloudflaredVersion = '2024.8.2' -const repo = `https://github.com/cloudflare/cloudflared/releases/download/${currentCloudflaredVersion}/` +const currentCloudflaredVersion = '2024.8.2' +const downloadBaseURL = `https://github.com/cloudflare/cloudflared/releases/download/${currentCloudflaredVersion}/` const downloads: Record> = { - linux: { - arm64: 'cloudflared-linux-arm64', - arm: 'cloudflared-linux-arm', - x64: 'cloudflared-linux-amd64', - ia32: 'cloudflared-linux-386', - }, - darwin: { - arm64: 'cloudflared-darwin-arm64.tgz', - x64: 'cloudflared-darwin-amd64.tgz', - }, - win32: { - arm64: 'cloudflared-windows-amd64.exe', - ia32: 'cloudflared-windows-386.exe', - x64: 'cloudflared-windows-amd64.exe', - }, + darwin: { arm64: 'cloudflared-darwin-arm64.tgz', x64: 'cloudflared-darwin-amd64.tgz' }, + linux: { arm: 'cloudflared-linux-arm', arm64: 'cloudflared-linux-arm64', ia32: 'cloudflared-linux-386', x64: 'cloudflared-linux-amd64' }, + win32: { arm64: 'cloudflared-windows-amd64.exe', ia32: 'cloudflared-windows-386.exe', x64: 'cloudflared-windows-amd64.exe' }, } -export type TunnelStatus = - | { status: 'starting' } - | { status: 'connected'; url: string } - | { status: 'error'; message: string; tryMessage?: string } - -export type StartTunnelOptions = { +type StartTunnelOptions = { cloudflaredPath?: string debug?: boolean install?: boolean @@ -44,6 +27,7 @@ export type StartTunnelOptions = { } export type StartedTunnel = { + cloudflaredPath: string done: Promise<{ code: number | null; signal: NodeJS.Signals | null }> process: ChildProcess stop: () => void @@ -51,31 +35,39 @@ export type StartedTunnel = { url: string } -export function defaultCloudflaredPath(): string { - return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', platform() === 'win32' ? 'cloudflared.exe' : 'cloudflared') +function cloudflaredPath(explicit?: string): string { + return explicit ?? process.env.BEEPER_CLOUDFLARED_PATH ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', platform() === 'win32' ? 'cloudflared.exe' : 'cloudflared') } -export function cloudflaredPath(explicit?: string): string { - return explicit ?? process.env.BEEPER_CLOUDFLARED_PATH ?? defaultCloudflaredPath() -} +export async function startCloudflareTunnel(options: StartTunnelOptions): Promise { + const bin = await ensureCloudflared(options) + const retries = options.retries ?? 5 + let lastError: Error | undefined -export async function ensureCloudflared(options: { cloudflaredPath?: string; debug?: boolean; install?: boolean } = {}): Promise { - const target = cloudflaredPath(options.cloudflaredPath) - if (isTruthy(process.env.BEEPER_IGNORE_CLOUDFLARED)) { - if (options.debug) process.stderr.write('Skipping cloudflared installation because BEEPER_IGNORE_CLOUDFLARED is set.\n') - return target + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + return await runCloudflared(bin, options) + } catch (error) { + lastError = error as Error + if (attempt >= retries) break + if (options.debug) process.stderr.write(`cloudflared crashed before connecting; retrying (${attempt + 1}/${retries})\n`) + await new Promise(resolve => setTimeout(resolve, 1000)) + } } - if (await isUsableCloudflared(target)) return target - if (!options.install) { - throw new Error(`cloudflared not found at ${target}. Install it or rerun with --install.\n${whatToTry()}`) - } + throw new Error(`Could not start Cloudflare Tunnel: max retries reached.${lastError ? `\n${lastError.message}` : ''}\n${whatToTry()}`) +} +async function ensureCloudflared(options: { cloudflaredPath?: string; debug?: boolean; install?: boolean }): Promise { + const target = cloudflaredPath(options.cloudflaredPath) + if (truthy(process.env.BEEPER_IGNORE_CLOUDFLARED)) return target + if (await isUsableCloudflared(target)) return target + if (!options.install) throw new Error(`cloudflared not found at ${target}. Install it or rerun with --install.\n${whatToTry()}`) await installCloudflared(target) return target } -export async function installCloudflared(target = defaultCloudflaredPath()): Promise { +async function installCloudflared(target: string): Promise { const url = downloadURL() await mkdir(dirname(target), { recursive: true }) const temporary = url.endsWith('.tgz') ? `${target}.tgz` : `${target}.download` @@ -92,34 +84,8 @@ export async function installCloudflared(target = defaultCloudflaredPath()): Pro if (platform() !== 'win32') await chmod(target, 0o755) } -export async function startCloudflareTunnel(options: StartTunnelOptions): Promise { - const bin = await ensureCloudflared(options) - const retries = options.retries ?? 5 - let attempt = 0 - let lastError: Error | undefined - - while (attempt <= retries) { - try { - const started = await runCloudflared(bin, options) - return started - } catch (error) { - lastError = error as Error - attempt += 1 - if (attempt > retries) throw error - if (options.debug) process.stderr.write(`cloudflared crashed before connecting; retrying (${attempt}/${retries})\n`) - await new Promise(resolve => { - setTimeout(resolve, 1000) - }) - } - } - - throw new Error(`Could not start Cloudflare Tunnel: max retries reached.${lastError ? `\n${lastError.message}` : ''}\n${whatToTry()}`) -} - async function runCloudflared(bin: string, options: StartTunnelOptions): Promise { - const child = spawn(bin, ['tunnel', '--url', options.url, '--no-autoupdate'], { - stdio: ['ignore', 'pipe', 'pipe'], - }) + const child = spawn(bin, ['tunnel', '--url', options.url, '--no-autoupdate'], { stdio: ['ignore', 'pipe', 'pipe'] }) const errors: string[] = [] let connected = false let publicURL: string | undefined @@ -141,7 +107,7 @@ async function runCloudflared(bin: string, options: StartTunnelOptions): Promise const chunk = data.toString() if (options.debug) process.stderr.write(chunk) publicURL ??= findTunnelURL(chunk) - connected ||= hasConnection(chunk) + connected ||= /(INF Registered tunnel connection|INF Connection)/.test(chunk) const error = findKnownError(chunk) if (error) errors.push(error) @@ -149,6 +115,7 @@ async function runCloudflared(bin: string, options: StartTunnelOptions): Promise resolved = true cleanup() resolve({ + cloudflaredPath: bin, done, process: child, stop() { @@ -187,11 +154,9 @@ async function isUsableCloudflared(path: string): Promise { } function downloadURL(system = platform(), cpu = arch()): string { - const platformDownloads = downloads[system] - if (!platformDownloads) throw new Error(`Unsupported system platform: ${system}`) - const file = platformDownloads[cpu] - if (!file) throw new Error(`Unsupported system architecture: ${cpu}`) - return repo + file + const file = downloads[system]?.[cpu] + if (!file) throw new Error(`Unsupported system platform or architecture: ${system}/${cpu}`) + return downloadBaseURL + file } async function downloadFile(url: string, to: string): Promise { @@ -204,10 +169,6 @@ export function findTunnelURL(data: string, domain = cloudflaredDomain()): strin return data.match(new RegExp(`https:\\/\\/[^\\s]+\\.${escapeRegExp(domain)}`))?.[0] } -function hasConnection(data: string): boolean { - return /(INF Registered tunnel connection|INF Connection)/.test(data) -} - export function findKnownError(data: string): string | undefined { const knownErrors = [ /failed to request quick Tunnel/i, @@ -219,15 +180,7 @@ export function findKnownError(data: string): string | undefined { /ERR Failed to create new quic connection error/i, ] if (!knownErrors.some(error => error.test(data))) return undefined - return `Could not start Cloudflare Tunnel: ${cleanCloudflareLog(data)}` -} - -function cleanCloudflareLog(input: string): string { - return input.replace(/^[0-9TZ:-]+ (ERR )?/g, '').replace(/connIndex.*/g, '').trim() -} - -function lastTunnelError(errors: string[]): string | undefined { - return [...new Set(errors)].slice(-5).join('\n') || undefined + return `Could not start Cloudflare Tunnel: ${data.replace(/^[0-9TZ:-]+ (ERR )?/g, '').replace(/connIndex.*/g, '').trim()}` } export function cloudflaredDomain(): string { @@ -243,14 +196,6 @@ export function whatToTry(): string { ].join(' ') } -function escapeRegExp(value: string): string { - return value.replace(/[|\\{}()[\]^$+*?.]/g, match => `\\${match}`) -} - -function isTruthy(value: string | undefined): boolean { - return ['1', 'on', 'true', 'yes'].includes(String(value ?? '').toLowerCase()) -} - export function versionIsGreaterThan(versionA: string, versionB: string): boolean { const [majorA = 0, minorA = 0, patchA = 0] = versionA.split('.').map(Number) const [majorB = 0, minorB = 0, patchB = 0] = versionB.split('.').map(Number) @@ -258,3 +203,15 @@ export function versionIsGreaterThan(versionA: string, versionB: string): boolea if (minorA !== minorB) return minorA > minorB return patchA > patchB } + +function lastTunnelError(errors: string[]): string | undefined { + return [...new Set(errors)].slice(-5).join('\n') || undefined +} + +function escapeRegExp(value: string): string { + return value.replace(/[|\\{}()[\]^$+*?.]/g, match => `\\${match}`) +} + +function truthy(value: string | undefined): boolean { + return ['1', 'on', 'true', 'yes'].includes(String(value ?? '').toLowerCase()) +} diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts deleted file mode 100644 index a5cecd4b..00000000 --- a/packages/cli/src/lib/command.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Command, Flags } from '@oclif/core' -import { BugError, CLIError, ExitCodes } from './errors.js' - -export abstract class BeeperCommand extends Command { - static override baseFlags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL (overrides --target)' }), - target: Flags.string({ char: 't', description: 'Named Beeper target to use for this command' }), - debug: Flags.boolean({ default: false, description: 'Print SDK debug logging on stderr' }), - events: Flags.boolean({ default: false, description: 'Emit NDJSON lifecycle events on stderr (long-running commands)' }), - full: Flags.boolean({ default: false, description: 'Disable text-output truncation; print full IDs and bodies' }), - json: Flags.boolean({ default: false, description: 'Print machine-readable JSON envelope on stdout' }), - quiet: Flags.boolean({ char: 'q', default: false, description: 'Suppress spinners and success lines (errors still print). Honored with or without --json.' }), - 'read-only': Flags.boolean({ default: false, description: 'Reject commands that would modify Beeper or local CLI state (or set BEEPER_READONLY=1)' }), - timeout: Flags.string({ description: 'Maximum time to wait, such as 30s, 2m, or 1h' }), - yes: Flags.boolean({ char: 'y', default: false, description: 'Skip interactive confirmation prompts' }), - } - - public override async init(): Promise { - await super.init() - if (this.argv.includes('--quiet') || this.argv.includes('-q')) { - process.env.BEEPER_QUIET = '1' - } - } - - protected override async catch(error: Error & { exitCode?: number }): Promise { - const code = error instanceof CLIError ? error.exitCode : error.exitCode ?? ExitCodes.Generic - process.exitCode = process.exitCode ?? code - const message = error.message || String(error) - const tryMessage = error instanceof CLIError ? error.tryMessage : undefined - const isBug = !(error instanceof CLIError) || error instanceof BugError - - if (this.argv.includes('--events')) { - writeEvent('error', { message, exitCode: code, kind: isBug ? 'bug' : 'abort', tryMessage }) - return - } - - if (this.argv.includes('--json')) { - process.stderr.write(`${JSON.stringify({ success: false, data: null, error: message, exitCode: code, kind: isBug ? 'bug' : 'abort', tryMessage })}\n`) - return - } - - if (isBug) { - process.stderr.write(formatBugPanel(error, this.config.version)) - return - } - - if (tryMessage) process.stderr.write(`${message}\n hint: ${tryMessage}\n`) - else return super.catch(error) - } -} - -function formatBugPanel(error: Error, version: string): string { - const bar = '─'.repeat(60) - const stack = error.stack?.split('\n').slice(0, 8).join('\n') ?? error.message - return [ - '', - `┌─ unexpected error ${bar.slice(20)}`, - `│ ${error.message}`, - '│', - ...stack.split('\n').map(line => `│ ${line}`), - '│', - `│ beeper-cli ${version} — please report at`, - '│ https://github.com/beeper/desktop-api-cli/issues', - `└${'─'.repeat(60)}`, - '', - ].join('\n') -} - -export function ensureWritable(flags: { 'read-only'?: boolean }): void { - const env = process.env.BEEPER_READONLY - const readOnly = flags['read-only'] || ['1', 'true', 'yes', 'on'].includes(String(env ?? '').toLowerCase()) - if (readOnly) throw new CLIError('read-only mode: command would modify Beeper or local CLI state', ExitCodes.Usage) -} - -export function writeEvent(event: string, data: Record = {}): void { - process.stderr.write(`${JSON.stringify({ event, data, ts: Date.now() })}\n`) -} - -export function isQuiet(): boolean { - return process.env.BEEPER_QUIET === '1' -} diff --git a/packages/cli/src/lib/copy.ts b/packages/cli/src/lib/copy.ts deleted file mode 100644 index 3e9366c2..00000000 --- a/packages/cli/src/lib/copy.ts +++ /dev/null @@ -1,66 +0,0 @@ -export const apiCopy = { - accounts: { - list: 'List chat accounts connected to this Beeper Client API server, including bridge, network, user identity, and connection status.', - }, - assets: { - download: 'Download a file from an mxc:// or localmxc:// URL to the device running the Beeper Client API and return the local file URL.', - upload: 'Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending a message or creating a draft attachment.', - }, - chats: { - archive: 'Archive or unarchive a chat. Set archived=true to move it to Archive, or archived=false to move it back to the inbox.', - create: 'Create a direct or group chat from participant IDs. Returns the created chat.', - list: 'List all chats sorted by last activity (most recent first). Combines all accounts into a single paginated list.', - markRead: 'Mark a chat as read, optionally through a specific message ID.', - markUnread: 'Mark a chat as unread, optionally from a specific message ID.', - notifyAnyway: 'Send a notification despite the recipient focus state when the network supports it. Currently intended for iMessage on macOS; unsupported networks return an error.', - retrieve: 'Retrieve chat details, including metadata, participants, and the latest message.', - search: 'Search chats by title, network, or participant names.', - start: 'Resolve a user/contact and open a direct chat. Reuses and returns an existing direct chat when one is found. Available in Beeper v4.2.808+.', - }, - contacts: { - list: 'List merged contacts for a specific account with cursor-based pagination.', - search: 'Search contacts on a specific account using merged account contacts, network search, and exact identifier lookup.', - }, - messages: { - delete: 'Delete a message by final message ID. Pending message IDs are not accepted because messages cannot be deleted while sending.', - list: 'List all messages in a chat with cursor-based pagination. Sorted by timestamp.', - retrieve: 'Retrieve a message by final message ID, pendingMessageID, or Matrix event ID. chatID may be a Beeper chat ID or a local chat ID.', - search: 'Search messages across chats.', - send: 'Send a text message to a specific chat. Supports replying to existing messages. Returns a pending message ID.', - update: 'Edit the text content of an existing message. Messages with attachments cannot be edited.', - }, - reactions: { - add: 'Add a reaction to an existing message.', - delete: 'Remove the reaction added by the authenticated user from an existing message.', - }, - reminders: { - create: 'Set a reminder for a chat at a specific time.', - delete: 'Clear an existing reminder from a chat.', - }, -} as const - -export const sdkParamCopy = { - attachmentFile: 'The file to upload (max 500 MB).', - chatID: 'Chat selector. Prefer the numeric local chat ID shown by chats list, or use the full Beeper/Matrix chat ID.', - fileName: 'Original filename. Defaults to the uploaded file name if omitted', - forEveryone: 'True to request deletion for everyone when the network supports it; false to delete only for the authenticated user when supported.', - messageID: 'Message ID.', - mimeType: 'MIME type. Auto-detected from magic bytes if omitted', - reactionKey: 'Reaction key to add (emoji, shortcode, or custom emoji key)', - remindAt: 'Timestamp when the reminder should trigger.', - replyToMessageID: 'Provide a message ID to send this as a reply to an existing message', - searchQuery: 'User-typed search text. Literal word matching (non-semantic).', - text: 'Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit.', -} as const - -export const cliCopy = { - args: { - accountSelector: 'Account ID, network, bridge, or account user', - chatSelector: `${sdkParamCopy.chatID} Also accepts exact chat titles or search text for interactive use.`, - }, - flags: { - baseURL: 'Beeper Desktop API base URL', - json: 'Print JSON', - pick: 'Pick the Nth chat when the input is ambiguous', - }, -} as const diff --git a/packages/cli/src/lib/desktop-auth.ts b/packages/cli/src/lib/desktop-auth.ts index f8209679..aa8a1626 100644 --- a/packages/cli/src/lib/desktop-auth.ts +++ b/packages/cli/src/lib/desktop-auth.ts @@ -1,8 +1,8 @@ -import { readConfig } from './targets.js' -import { authRequired, notReady } from './errors.js' -import { loginWithPKCE } from './oauth.js' +import { AbortError, ExitCodes } from './errors.js' +import { loginWithPKCE, type TokenResponse } from './oauth.js' +import { defaultDesktopBaseURL, defaultDesktopPort, type AuthSource, type StoredAuth } from './targets.js' -export type DesktopAppStatus = { +type DesktopAppStatus = { state?: string } @@ -11,12 +11,10 @@ type DesktopProbe = { status?: DesktopAppStatus } -const defaultPort = 23_373 -const scanPorts = Array.from({ length: 20 }, (_, index) => defaultPort + index) +const scanPorts = Array.from({ length: 20 }, (_, index) => defaultDesktopPort + index) export async function findLocalDesktop(options: { baseURL?: string; scan?: boolean; timeoutMs?: number } = {}): Promise { - const config = await readConfig() - const preferred = options.baseURL ?? config.baseURL ?? 'http://127.0.0.1:23373' + const preferred = options.baseURL ?? defaultDesktopBaseURL const candidates = candidateBaseURLs(preferred, options.scan ?? true) const timeoutMs = options.timeoutMs ?? 500 @@ -34,34 +32,43 @@ export async function findLocalDesktop(options: { baseURL?: string; scan?: boole } catch { /* fall through */ } } - throw notReady(`Could not find a running Beeper Desktop API on ${candidates.join(', ')}.`) + throw new AbortError(`Could not find a running Beeper Desktop API on ${candidates.join(', ')}.`, ExitCodes.NotReady) } -export async function ensureDesktopToken(options: { +type AuthorizedTargetToken = TokenResponse & { clientID: string } + +export async function authorizeTarget(options: { baseURL?: string clientName?: string openBrowser?: boolean - save?: boolean scan?: boolean scope?: string -} = {}): Promise { +} = {}): Promise { const desktop = await findLocalDesktop({ baseURL: options.baseURL, scan: options.scan }) if (desktop.status?.state === 'needs-login') { - throw authRequired('Beeper Desktop is not signed in. Open Beeper Desktop and sign in, then rerun this command.') + throw new AbortError('Beeper Desktop is not signed in. Open Beeper Desktop and sign in, then rerun this command.', ExitCodes.AuthRequired) } - const token = await loginWithPKCE({ + return loginWithPKCE({ baseURL: desktop.baseURL, clientName: options.clientName ?? 'Beeper CLI', openBrowser: options.openBrowser ?? true, - save: options.save ?? true, scope: options.scope ?? 'read write', - source: 'desktop-oauth', }) - return token.access_token } -export async function getDesktopAppStatus(baseURL: string): Promise { +export function authFromToken(token: AuthorizedTargetToken, source: AuthSource): StoredAuth { + return { + accessToken: token.access_token, + clientID: token.clientID, + expiresAt: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : undefined, + scope: token.scope, + source, + tokenType: token.token_type, + } +} + +async function getDesktopAppStatus(baseURL: string): Promise { const response = await fetchWithTimeout(new URL('/v1/app/setup', baseURL), {}, 2_000) if (response.status === 401 || response.status === 403 || response.status === 404) return undefined if (!response.ok) throw new Error(`GET /v1/app/setup failed: ${response.status} ${await response.text()}`) diff --git a/packages/cli/src/lib/did-you-mean.ts b/packages/cli/src/lib/did-you-mean.ts deleted file mode 100644 index b89b9c2d..00000000 --- a/packages/cli/src/lib/did-you-mean.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { createInterface, Interface } from 'node:readline' -import { CLIError, ExitCodes } from './errors.js' - -export type Suggestion = { value: T; label: string; distance: number } - -export function levenshtein(a: string, b: string): number { - if (a === b) return 0 - if (!a.length) return b.length - if (!b.length) return a.length - const matrix: number[][] = Array.from({ length: a.length + 1 }, (_, i) => [i, ...new Array(b.length).fill(0)]) - for (let j = 1; j <= b.length; j++) matrix[0]![j] = j - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1 - matrix[i]![j] = Math.min( - matrix[i - 1]![j]! + 1, - matrix[i]![j - 1]! + 1, - matrix[i - 1]![j - 1]! + cost, - ) - } - } - return matrix[a.length]![b.length]! -} - -export function rankSuggestions(query: string, items: T[], labelOf: (item: T) => string | undefined, max = 3): Suggestion[] { - const q = query.trim().toLowerCase() - const scored: Suggestion[] = [] - for (const item of items) { - const label = labelOf(item) - if (!label) continue - const l = label.toLowerCase() - const dist = Math.min( - levenshtein(q, l), - l.includes(q) ? Math.max(0, l.length - q.length) : Infinity, - ) - if (Number.isFinite(dist)) scored.push({ value: item, label, distance: dist }) - } - scored.sort((a, b) => a.distance - b.distance || a.label.length - b.label.length) - const cutoff = Math.max(3, Math.ceil(q.length * 0.6)) - return scored.filter(s => s.distance <= cutoff).slice(0, max) -} - -export async function confirmSuggestion(prompt: string, options: { timeoutMs?: number; assumeYes?: boolean } = {}): Promise { - if (options.assumeYes) return true - if (!process.stdin.isTTY || !process.stderr.isTTY) return false - const rl: Interface = createInterface({ input: process.stdin, output: process.stderr }) - return new Promise(resolve => { - let resolved = false - const finish = (value: boolean): void => { - if (resolved) return - resolved = true - rl.close() - resolve(value) - } - const timer = options.timeoutMs ? setTimeout(() => finish(true), options.timeoutMs) : undefined - rl.question(`${prompt} [Y/n] `, answer => { - if (timer) clearTimeout(timer) - const a = answer.trim().toLowerCase() - finish(a === '' || a === 'y' || a === 'yes') - }) - }) -} - -export function declineWithExit127(message: string): never { - throw new CLIError(message, ExitCodes.CommandNotFound) -} diff --git a/packages/cli/src/lib/env.ts b/packages/cli/src/lib/env.ts deleted file mode 100644 index 06d1f65b..00000000 --- a/packages/cli/src/lib/env.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { delimiter } from 'node:path' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { homedir } from 'node:os' -import { binDir } from './installations.js' - -export type ShellName = 'sh' | 'fish' | 'powershell' - -export function isBeeperBinOnPath(pathValue = process.env.PATH ?? ''): boolean { - return pathValue.split(delimiter).includes(binDir()) -} - -export function pathSetup(shell: ShellName): string { - const dir = binDir() - if (shell === 'fish') return `fish_add_path ${fishQuote(dir)}` - if (shell === 'powershell') return `$env:Path = ${powershellQuote(`${dir};`)} + $env:Path` - return `export PATH=${shQuote(dir)}:$PATH` -} - -export async function installPathSetup(shell: ShellName = detectShell()): Promise<{ path: string; line: string; changed: boolean }> { - if (shell === 'powershell') throw new Error('PowerShell PATH persistence is not supported yet. Run: beeper env --shell powershell') - const path = shellConfigPath(shell) - const line = pathSetup(shell) - const current = await readFile(path, 'utf8').catch(error => { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '' - throw error - }) - if (current.includes(binDir())) return { path, line, changed: false } - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, `${current}${current.endsWith('\n') || current.length === 0 ? '' : '\n'}${line}\n`, 'utf8') - return { path, line, changed: true } -} - -export function pathSetupHint(): string | undefined { - if (isBeeperBinOnPath()) return undefined - return `Add ${binDir()} to PATH: eval "$(beeper env)"` -} - -function shQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} - -function fishQuote(value: string): string { - return shQuote(value) -} - -function powershellQuote(value: string): string { - return `'${value.replaceAll("'", "''")}'` -} - -function detectShell(): ShellName { - return (process.env.SHELL ?? '').includes('fish') ? 'fish' : 'sh' -} - -function shellConfigPath(shell: ShellName): string { - if (shell === 'fish') return join(homedir(), '.config', 'fish', 'config.fish') - return join(homedir(), (process.env.SHELL ?? '').includes('zsh') ? '.zshrc' : '.bashrc') -} diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index ce198333..ccb94244 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -1,34 +1,35 @@ /** * Beeper CLI exit codes: - * 0 success * 1 generic runtime error * 2 usage error (parsing, missing required flag/arg, invalid combination) - * 3 auth required (no stored token; user must authenticate) - * 4 target/account not ready (target reachable but not signed-in or not verified) + * 3 empty results when --fail-empty/--non-empty is set + * 4 auth required (no stored token; user must authenticate) * 5 not found (selector matched nothing) * 6 ambiguous selector (multiple matches; use exact ID or --pick) - * 127 user declined a did-you-mean suggestion (POSIX "command not found" semantics) + * 127 user declined a selector suggestion (POSIX "command not found" semantics) */ export const ExitCodes = { - Success: 0, Generic: 1, Usage: 2, - AuthRequired: 3, + EmptyResults: 3, + AuthRequired: 4, NotReady: 4, NotFound: 5, Ambiguous: 6, CommandNotFound: 127, } as const -export type ExitCode = typeof ExitCodes[keyof typeof ExitCodes] +type ExitCode = typeof ExitCodes[keyof typeof ExitCodes] export class CLIError extends Error { readonly exitCode: ExitCode readonly tryMessage?: string - constructor(message: string, exitCode: ExitCode, tryMessage?: string) { + readonly code?: string + constructor(message: string, exitCode: ExitCode, tryMessage?: string, code?: string) { super(message) this.exitCode = exitCode this.tryMessage = tryMessage + this.code = code this.name = 'CLIError' } } @@ -38,25 +39,8 @@ export class CLIError extends Error { * Renders as a single-line red message. Do not include a stack trace. */ export class AbortError extends CLIError { - constructor(message: string, exitCode: ExitCode = ExitCodes.Generic, tryMessage?: string) { - super(message, exitCode, tryMessage) + constructor(message: string, exitCode: ExitCode = ExitCodes.Generic, tryMessage?: string, code?: string) { + super(message, exitCode, tryMessage, code) this.name = 'AbortError' } } - -/** - * Unexpected internal failure that should be reported. Renders as a boxed panel with - * the stack and a "report this" hint. Always exits with ExitCodes.Generic. - */ -export class BugError extends CLIError { - constructor(message: string, tryMessage?: string) { - super(message, ExitCodes.Generic, tryMessage) - this.name = 'BugError' - } -} - -export const usageError = (message: string) => new AbortError(message, ExitCodes.Usage) -export const authRequired = (message: string) => new AbortError(message, ExitCodes.AuthRequired) -export const notReady = (message: string) => new AbortError(message, ExitCodes.NotReady) -export const notFound = (message: string) => new AbortError(message, ExitCodes.NotFound) -export const ambiguous = (message: string) => new AbortError(message, ExitCodes.Ambiguous) diff --git a/packages/cli/src/lib/export/index.ts b/packages/cli/src/lib/export.ts similarity index 94% rename from packages/cli/src/lib/export/index.ts rename to packages/cli/src/lib/export.ts index 5422f73d..8dfbea22 100644 --- a/packages/cli/src/lib/export/index.ts +++ b/packages/cli/src/lib/export.ts @@ -5,20 +5,18 @@ import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Chat } from '@beeper/desktop-api/resources/chats/chats' import type { Attachment, Message } from '@beeper/desktop-api/resources/shared' +import { apiItems } from './api-values.js' -type AnyRecord = Record - -export type ExportOptions = { +type ExportOptions = { accountIDs?: string[] chatIDs?: string[] downloadAttachments: boolean - events?: boolean force: boolean limitChats?: number limitMessages?: number maxParticipants?: number + onProgress?: (message: string) => void outDir: string - quiet: boolean } type ExportState = { @@ -66,7 +64,7 @@ export async function exportBeeperData(client: any, options: ExportOptions): Pro const startedAt = state.createdAt progress(options, `Export directory: ${options.outDir}`) - const accounts = accountItems(await client.accounts.list()) + const accounts = apiItems(await client.accounts.list()) await writeJSONAtomic(join(options.outDir, 'accounts.json'), accounts) progress(options, `Accounts: ${accounts.length}`) @@ -240,7 +238,7 @@ async function exportChatMessages( } existing.sort((a, b) => String(a.sortKey || a.timestamp).localeCompare(String(b.sortKey || b.timestamp))) - const allAttachments = await readAttachmentsManifest(chatDir) + const allAttachments = await readJSONL(join(chatDir, 'attachments', 'attachments.jsonl')) return { attachmentCount, attachments: allAttachments, messages: existing } } @@ -333,12 +331,7 @@ async function writeResponseBody(response: Response, path: string): Promise() - for (const attachment of attachments) { - const list = byMessage.get(attachment.messageID) ?? [] - list.push(attachment) - byMessage.set(attachment.messageID, list) - } + const byMessage = attachmentsByMessage(attachments) const lines = [ `# ${escapeMarkdown(chat.title || chat.id)}`, @@ -368,12 +361,7 @@ function renderMarkdown(chat: Chat, messages: Message[], attachments: Attachment } function renderHTML(chat: Chat, messages: Message[], attachments: AttachmentExport[]): string { - const byMessage = new Map() - for (const attachment of attachments) { - const list = byMessage.get(attachment.messageID) ?? [] - list.push(attachment) - byMessage.set(attachment.messageID, list) - } + const byMessage = attachmentsByMessage(attachments) const rows = messages.map(message => { const sender = message.senderName || message.senderID || 'Unknown sender' @@ -429,8 +417,14 @@ ${indent(rows, 6)} ` } -async function readAttachmentsManifest(chatDir: string): Promise { - return readJSONL(join(chatDir, 'attachments', 'attachments.jsonl')) +function attachmentsByMessage(attachments: AttachmentExport[]): Map { + const byMessage = new Map() + for (const attachment of attachments) { + const list = byMessage.get(attachment.messageID) ?? [] + list.push(attachment) + byMessage.set(attachment.messageID, list) + } + return byMessage } async function readJSONL(path: string): Promise { @@ -482,11 +476,6 @@ async function exists(path: string): Promise { } } -function accountItems(accounts: unknown): unknown[] { - if (Array.isArray(accounts)) return accounts - return (accounts as { items?: unknown[] }).items ?? [] -} - function safeSegment(value: string): string { const normalized = value.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '') return normalized.slice(0, 120) || 'item' @@ -552,6 +541,5 @@ function indent(value: string, spaces: number): string { } function progress(options: ExportOptions, message: string): void { - if (options.events) process.stderr.write(`${JSON.stringify({ event: 'export.progress', data: { message }, ts: new Date().toISOString() })}\n`) - if (!options.quiet) process.stderr.write(`${message}\n`) + options.onProgress?.(message) } diff --git a/packages/cli/src/lib/ink/components.tsx b/packages/cli/src/lib/ink/components.tsx deleted file mode 100644 index de288d11..00000000 --- a/packages/cli/src/lib/ink/components.tsx +++ /dev/null @@ -1,948 +0,0 @@ -import React from 'react' -import { Box, Text } from 'ink' -import { bridgeColor, glyphs, senderColor, theme } from './theme.js' - -// OSC 8 hyperlink — modern terminals (iTerm, Ghostty, WezTerm, VS Code, etc.) -// render this as clickable; everything else ignores the escapes and shows the -// label text once. -const supportsHyperlinks = process.stdout.isTTY && process.env.TERM !== 'dumb' -const OSC8_START = ']8;;' -const OSC8_END = ']8;;' -const BEL = '' -const Hyperlink: React.FC<{ url: string; children?: React.ReactNode }> = ({ url, children }) => { - if (!supportsHyperlinks) return <>{children ?? url} - return {OSC8_START}{url}{BEL}{children ?? url}{OSC8_END} -} - -import { - attachmentLabel, - chatPreview, - compact, - formatBytes, - formatDuration, - formatTime, - isArchived, - isLowPriority, - isMuted, - isPinned, - messageText, - participantsSummary, - type RecordValue, - shortID, - stringValue, -} from './format.js' - -// ─── primitives ──────────────────────────────────────────────────────────────── - -export const Rail: React.FC<{ color: string }> = ({ color }) => ( - {glyphs.rail} -) - -export const Hairline: React.FC<{ width?: number }> = ({ width = 60 }) => ( - {glyphs.hairline.repeat(width)} -) - -export const Meta: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -) - -export const Dim: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -) - -export const KV: React.FC<{ label: string; value: React.ReactNode; tone?: 'normal' | 'muted' | 'dim'; width?: number }> = - ({ label, value, tone = 'normal', width = 12 }) => { - const valueColor = tone === 'dim' ? theme.subtle : tone === 'muted' ? theme.muted : theme.text - return ( - - {label.padEnd(width)} - {value} - - ) - } - -export const Pill: React.FC<{ color: string; children: React.ReactNode; muted?: boolean }> = ({ color, children, muted }) => ( - - {children} - -) - -export type Suggestion = { command: string; hint?: string } - -export const Suggestions: React.FC<{ suggestions?: Suggestion[]; label?: string }> = ({ suggestions, label = 'Try' }) => { - if (!suggestions?.length) return null - return ( - - {label} - {suggestions.map(s => ( - - {glyphs.arrow} - {s.command} - {s.hint && — {s.hint}} - - ))} - - ) -} - -// ─── empty / success / failure ──────────────────────────────────────────────── - -export const EmptyState: React.FC<{ title: string; subtitle?: string; suggestions?: Suggestion[] }> = ({ title, subtitle, suggestions }) => ( - - - - - {title} - - {subtitle && ( - - {subtitle} - - )} - - -) - -export const SuccessLine: React.FC<{ message: string; detail?: string; entity?: React.ReactNode }> = ({ message, detail, entity }) => ( - - - {glyphs.check} - - {message} - {detail && {detail}} - - {entity && ( - - {entity} - - )} - -) - -export const FailureLine: React.FC<{ message: string; detail?: string }> = ({ message, detail }) => ( - - {glyphs.cross} - - {message} - {detail && {detail}} - -) - -export const SectionHeader: React.FC<{ label: string; count?: number }> = ({ label, count }) => ( - - {label.toUpperCase()} - {count != null && {count}} - -) - -// ─── domain rows ─────────────────────────────────────────────────────────────── - -type RowProps = { item: T } - -function chatRailColor(chat: RecordValue, unread: number, mentions: number): string { - if (mentions > 0) return theme.mention - if (unread > 0) return theme.primary - if (isPinned(chat)) return theme.warn - const tint = bridgeColor(stringValue(chat.network)) - return tint ?? theme.subtle -} - -export const ChatRow: React.FC> = ({ item: chat }) => { - const unread = Number(chat.unreadCount ?? 0) - const mentions = Number(chat.unreadMentionsCount ?? 0) - const muted = isMuted(chat) - const pinned = isPinned(chat) - const archived = isArchived(chat) - const lowPriority = isLowPriority(chat) - const preview = chatPreview(chat) - const lastActivity = formatTime(chat.lastActivity) - const network = stringValue(chat.network) - const tint = bridgeColor(network) - const railColor = chatRailColor(chat, unread, mentions) - - const titleNode = ( - 0} color={muted ? theme.muted : theme.text}>{String(chat.title)} - ) - - return ( - - - - - {titleNode} - {network && {network.toLowerCase()}} - {pinned && {glyphs.pin}} - {muted && {glyphs.mute}} - {archived && {glyphs.archive}} - {lowPriority && {glyphs.lowPriority}} - - {mentions > 0 && ( - {glyphs.mention}{mentions} - )} - {unread > 0 && ( - - {' '}{unread}{mentions > 0 ? '' : ` unread`} - - )} - {lastActivity && ( - {lastActivity} - )} - - {preview && ( - - {preview.kind === 'draft' ? ( - - draft - {preview.text ? ` ${preview.text}` : ''} - - ) : ( - - {preview.sender ? {preview.sender} : null} - {preview.text} - - )} - - )} - - {chat.localChatID || chat.rowID ? ( - <> - local - {String(chat.localChatID ?? chat.rowID)} - id - - ) : null} - {String(chat.id)} - - - ) -} - -export const ChatDetail: React.FC> = ({ item: chat }) => { - const unread = Number(chat.unreadCount ?? 0) - const mentions = Number(chat.unreadMentionsCount ?? 0) - const participants = chat.participants && typeof chat.participants === 'object' ? chat.participants as RecordValue : undefined - const items = Array.isArray(participants?.items) ? participants!.items as RecordValue[] : [] - const network = stringValue(chat.network) - const tint = bridgeColor(network) - - return ( - - - - - {String(chat.title)} - {network && {network.toLowerCase()}} - {isPinned(chat) && {glyphs.pin} pinned} - {isMuted(chat) && {glyphs.mute} muted} - {isArchived(chat) && {glyphs.archive} archived} - {isLowPriority(chat) && {glyphs.lowPriority} low-priority} - - {chat.type ? : null} - {chat.lastActivity ? : null} - {unread > 0 && 0 ? ` (${mentions} @)` : ''}`} />} - {participants && ( - - )} - {items.length > 0 && ( - - PARTICIPANTS - {items.slice(0, 20).map((p, i) => ( - - {glyphs.bullet} - - {stringValue(p.fullName) ?? stringValue(p.username) ?? shortID(String(p.id))} - {p.isSelf ? you : null} - - ))} - {items.length > 20 && … {items.length - 20} more} - - )} - - id - {String(chat.id)} - - {chat.localChatID || chat.rowID ? ( - - local - {String(chat.localChatID ?? chat.rowID)} - - ) : null} - {chat.accountID ? ( - - acct - {String(chat.accountID)} - - ) : null} - - ) -} - -export const MessageRow: React.FC> = ({ item: message }) => { - const mine = Boolean(message.isSender) - const senderID = stringValue(message.senderID) - const sender = stringValue(message.senderName) ?? (senderID ? shortID(senderID) : 'unknown') - const text = messageText(message) - const timestamp = formatTime(message.timestamp) - const status = typeof message.sendStatus === 'object' && message.sendStatus ? message.sendStatus as RecordValue : undefined - const showFailure = status?.status && status.status !== 'SUCCESS' - const attachments = Array.isArray(message.attachments) ? message.attachments : [] - const reactions = Array.isArray(message.reactions) ? message.reactions : [] - const railColor = mine ? theme.mine : senderColor(senderID) - - return ( - - - - - {mine ? 'you' : sender} - {message.type != null && message.type !== 'TEXT' ? ( - {String(message.type).toLowerCase()} - ) : null} - {message.isUnread ? ( - {glyphs.unread} unread - ) : null} - {message.isDeleted ? ( - deleted - ) : null} - {message.editedTimestamp ? ( - {glyphs.edited} edited - ) : null} - {attachments.length > 0 && ( - {glyphs.attachment} {attachmentLabel(attachments)} - )} - {reactions.length > 0 && ( - {glyphs.reaction}{reactions.length} - )} - - {timestamp && {timestamp}} - - {text && ( - - {text} - - )} - {showFailure ? ( - - {glyphs.cross} {String(status?.status)} - {status?.message ? {String(status.message)} : null} - - ) : null} - - {String(message.id)} - {message.chatID ? in {String(message.chatID)} : null} - - - ) -} - -export const UserRow: React.FC> = ({ item: user }) => { - const title = stringValue(user.fullName) - ?? stringValue(user.username) - ?? stringValue(user.email) - ?? stringValue(user.phoneNumber) - ?? String(user.id) - const handles = compact([ - user.username ? `@${user.username}` : undefined, - user.email ? String(user.email) : undefined, - user.phoneNumber ? String(user.phoneNumber) : undefined, - ]) - const rail = user.isSelf ? theme.mine : senderColor(stringValue(user.id)) - - return ( - - - - - {title} - {user.isSelf ? you : null} - {user.cannotMessage ? cannot message : null} - - {handles.length > 0 ? ( - - {handles.join(' ')} - - ) : null} - - {String(user.id)} - {user.accountID ? on {String(user.accountID)} : null} - - - ) -} - -export const AccountRow: React.FC> = ({ item: account }) => { - const id = account.accountID ?? account.id - const bridge = account.bridge && typeof account.bridge === 'object' ? account.bridge as RecordValue : undefined - const bridgeLabel = bridge - ? compact([stringValue(bridge.id), stringValue(bridge.provider), stringValue(bridge.type)]).join(' ') - : stringValue(account.bridge) - const title = stringValue(account.displayName) - ?? stringValue(account.name) - ?? stringValue(account.network) - ?? stringValue(bridge?.id) - ?? String(id) - const network = stringValue(account.network) ?? stringValue(bridge?.type) - const tint = bridgeColor(network) - const state = stringValue(account.state) - const stateLow = state?.toLowerCase() ?? '' - const connected = stateLow.includes('online') || stateLow.includes('connect') - const errored = stateLow.includes('error') || stateLow.includes('fail') - const stateTone = connected ? theme.mine : errored ? theme.danger : theme.warnAlt - const handles = compact([ - account.username ? `@${account.username}` : undefined, - stringValue(account.userID), - ]) - - return ( - - - - - {title} - {network && {network.toLowerCase()}} - {state && {connected ? glyphs.dot : errored ? glyphs.cross : glyphs.ring} {stateLow}} - - {handles.length > 0 && ( - - {handles.join(' ')} - - )} - - {String(id)} - {bridgeLabel ? bridge {bridgeLabel} : null} - - - ) -} - -export const BridgeRow: React.FC> = ({ item: bridge }) => { - const id = String(bridge.id) - const provider = stringValue(bridge.provider) - const type = stringValue(bridge.type) - const network = stringValue(bridge.network) ?? type - const title = stringValue(bridge.displayName) ?? id - const status = stringValue(bridge.status) - const statusText = stringValue(bridge.statusText) - const accounts = Array.isArray(bridge.accounts) ? bridge.accounts as RecordValue[] : [] - const available = status === 'available' - const connected = status === 'connected' || accounts.length > 0 - const rail = available ? theme.mine : connected ? theme.primary : theme.warn - const flows = Array.isArray(bridge.loginFlows) ? bridge.loginFlows as RecordValue[] : [] - - return ( - - - - - {title} - {network && {network.toLowerCase()}} - {provider && {provider}} - {status && {status.replaceAll('_', ' ')}} - - {statusText ? ( - - {statusText} - - ) : null} - - id - {id} - {typeof bridge.activeAccountCount === 'number' ? accounts {String(bridge.activeAccountCount)} : null} - {typeof bridge.supportsMultipleAccounts === 'boolean' ? ( - {bridge.supportsMultipleAccounts ? 'multiple allowed' : 'single account'} - ) : null} - {flows.length > 0 ? flows {flows.length} : null} - - {flows.length > 0 ? ( - - {flows.slice(0, 5).map(flow => { - const flowID = String(flow.id ?? flow.type ?? 'flow') - const name = stringValue(flow.name) - const description = stringValue(flow.description) - return ( - - {glyphs.bullet} {flowID}{name ? ` ${name}` : ''}{description ? ` - ${description}` : ''} - - ) - })} - {flows.length > 5 ? {flows.length - 5} more flows : null} - - ) : null} - {accounts.length > 0 ? ( - - {accounts.slice(0, 3).map(account => ( - - {glyphs.bullet} {String(account.accountID ?? account.id)} - {account.status ? ` ${String(account.status).replaceAll('_', ' ')}` : ''} - - ))} - {accounts.length > 3 ? {accounts.length - 3} more : null} - - ) : null} - {available ? ( - - beeper accounts add {id} - - ) : null} - - ) -} - -export const SendResultCard: React.FC<{ result: RecordValue }> = ({ result }) => { - const message = result.message && typeof result.message === 'object' ? result.message as RecordValue : undefined - const sendStatus = message?.sendStatus && typeof message.sendStatus === 'object' ? message.sendStatus as RecordValue : undefined - const state = stringValue(result.state) - const finalID = stringValue(message?.id) - const status = stringValue(sendStatus?.status) - const failed = status?.startsWith('FAIL') - const sent = status === 'SUCCESS' - return ( - - - - - Message send - - {failed ? ( - {glyphs.cross} failed - ) : sent ? ( - {glyphs.check} sent - ) : state === 'resolved' ? ( - {glyphs.check} resolved - ) : ( - {glyphs.ring} accepted by Desktop - )} - - - {result.pendingMessageID ? : null} - {finalID ? : null} - {status ? : null} - {sendStatus?.message ? : null} - {result.hint ? ( - - {String(result.hint)} - - ) : null} - - ) -} - -export const AssetRow: React.FC> = ({ item: asset }) => { - const title = stringValue(asset.fileName) - ?? stringValue(asset.uploadID) - ?? stringValue(asset.srcURL) - ?? 'asset' - const mime = stringValue(asset.mimeType) - const meta = compact([ - typeof asset.fileSize === 'number' ? formatBytes(asset.fileSize) : undefined, - asset.width && asset.height ? `${String(asset.width)}×${String(asset.height)}` : undefined, - typeof asset.duration === 'number' ? formatDuration(asset.duration) : undefined, - ]) - - return ( - - - - - {title} - {mime && {mime}} - {meta.length > 0 && {meta.join(' ')}} - - {asset.srcURL ? ( - - {String(asset.srcURL)} - - ) : null} - {asset.uploadID ? ( - - upload {String(asset.uploadID)} - - ) : null} - {asset.error ? ( - - {glyphs.cross} {String(asset.error)} - - ) : null} - - ) -} - -// ─── system / cards ──────────────────────────────────────────────────────────── - -export const InfoCard: React.FC<{ info: RecordValue }> = ({ info }) => { - const version = stringValue(info.version) - const platform = stringValue(info.platform) - const user = info.user && typeof info.user === 'object' ? info.user as RecordValue : undefined - const userName = user ? (stringValue(user.fullName) ?? stringValue(user.username) ?? stringValue(user.id)) : undefined - const endpoints = info.endpoints && typeof info.endpoints === 'object' ? info.endpoints as RecordValue : undefined - - return ( - - - - - Beeper Desktop - {version && v{version}} - {platform && {platform}} - - {userName && } - {endpoints && Object.entries(endpoints).map(([key, value]) => - typeof value === 'string' ? ( - - {key.padEnd(12)} - - {value} - - - ) : null, - )} - - ) -} - -export const DoctorCard: React.FC<{ checks: Array<{ ok: boolean; name: string; detail?: string }>; ok: boolean }> = ({ checks, ok }) => { - const longest = Math.max(0, ...checks.map(c => c.name.length)) - return ( - - - - - Doctor - - {ok ? ( - {glyphs.check} healthy - ) : ( - {glyphs.cross} attention needed - )} - - - {checks.map(check => ( - - {check.ok ? glyphs.check : glyphs.cross} - - {check.name.padEnd(longest + 2)} - {check.detail && {check.detail}} - - ))} - - {!ok && ( - - )} - - ) -} - -export const AuthStatusCard: React.FC<{ auth: RecordValue }> = ({ auth }) => { - const ok = Boolean(auth.authenticated) - const expires = auth.expiresAt ? formatTime(auth.expiresAt) ?? String(auth.expiresAt) : undefined - return ( - - - - - Authentication - - {ok ? ( - {glyphs.check} signed in - ) : ( - {glyphs.ring} signed out - )} - - {String(auth.baseURL)} - } /> - - {auth.clientID ? : null} - {auth.scope ? : null} - {expires ? : null} - {!ok && ( - - )} - - ) -} - -export const ReadinessCard: React.FC<{ data: RecordValue }> = ({ data }) => { - const target = data.target && typeof data.target === 'object' ? data.target as RecordValue : undefined - const readiness = data.readiness && typeof data.readiness === 'object' ? data.readiness as RecordValue : data - const state = stringValue(readiness.state) ?? 'unknown' - const ready = state === 'ready' - const actions = Array.isArray(readiness.actions) ? readiness.actions.map(String) : [] - const message = stringValue(readiness.message) - return ( - - - - - {ready ? 'Ready' : 'Not ready'} - {state.replaceAll('-', ' ')} - - {target ? ( - <> - - {target.baseURL ? {String(target.baseURL)}} /> : null} - - ) : null} - {message ? : null} - {actions.length > 0 && !ready ? ( - ({ command: `beeper ${command}` }))} /> - ) : null} - - ) -} - -export const UserInfoCard: React.FC<{ user: RecordValue }> = ({ user }) => { - const name = stringValue(user.name) - ?? stringValue(user.preferred_username) - ?? stringValue(user.email) - ?? String(user.sub) - return ( - - - - - {name} - you - - {user.email ? : null} - {user.preferred_username ? : null} - {user.sub ? : null} - - ) -} - -// ─── auth flow / login wizard ────────────────────────────────────────────────── - -export const AuthCodeCard: React.FC<{ url: string; code?: string; hint?: string }> = ({ url, code, hint }) => ( - - - - - Sign in to Beeper - - {hint && ( - - {hint} - - )} - - {url} - - {code && ( - - code - {code} - - )} - -) - -export const AuthSignedIn: React.FC<{ as: string; detail?: string; saved?: boolean }> = ({ as, detail, saved }) => ( - - - {glyphs.check} - - Signed in - as - {as} - - {detail && {detail}} - {saved === false && token not saved (--no-save)} - -) - -// ─── config / commands manifest ──────────────────────────────────────────────── - -function maskToken(value: string): string { - if (value.length <= 12) return '••••' - return `${value.slice(0, 6)}…${value.slice(-4)}` -} - -function renderConfigValue(key: string, value: unknown): React.ReactNode { - if (value == null) return - if (typeof value !== 'object') { - if (/token|secret|key/i.test(key) && typeof value === 'string') { - return {maskToken(value)} - } - return {String(value)} - } - const record = value as RecordValue - const inner = Object.entries(record).filter(([, v]) => v != null) - if (!inner.length) return {'{}'} - const width = Math.max(...inner.map(([k]) => k.length)) + 2 - return ( - - {inner.map(([k, v]) => ( - - {k.padEnd(width)} - {/^(accesstoken|token|secret|key)$/i.test(k) && typeof v === 'string' - ? {maskToken(v)} - : {typeof v === 'object' ? JSON.stringify(v) : String(v)}} - - ))} - - ) -} - -export const ConfigView: React.FC<{ data: RecordValue }> = ({ data }) => { - const entries = Object.entries(data).filter(([, v]) => v != null) - if (!entries.length) { - return ', hint: 'override the API endpoint' }, - ]} /> - } - const width = Math.max(...entries.map(([k]) => k.length)) + 2 - return ( - - - - - Config - - {entries.map(([key, value]) => ( - - - {key.padEnd(width)} - {typeof value !== 'object' || value == null ? renderConfigValue(key, value) : null} - - {typeof value === 'object' && value != null && ( - - {renderConfigValue(key, value)} - - )} - - ))} - - ) -} - -type ManifestItem = { command: string; description: string; group?: string } - -export const CommandsView: React.FC<{ items: ManifestItem[]; title?: string; intro?: string[] }> = ({ items, title = 'Commands', intro }) => { - const groups = new Map() - for (const item of items) { - const g = item.group ?? 'Common' - if (!groups.has(g)) groups.set(g, []) - groups.get(g)!.push(item) - } - // Hard cap so descriptions stay aligned. Anything longer drops its description onto the next indented line. - const NAME_WIDTH = 32 - return ( - - - - - {title} - - {intro?.map((line, i) => ( - - {line} - - ))} - {[...groups.entries()].map(([group, list]) => ( - - {group.toUpperCase()} - {list.map(item => { - const fits = item.command.length <= NAME_WIDTH - return fits ? ( - - {item.command.padEnd(NAME_WIDTH + 2)} - {item.description} - - ) : ( - - {item.command} - {item.description} - - ) - })} - - ))} - - ) -} - -// ─── stream feed (used by `watch`) ───────────────────────────────────────────── - -export type StreamEvent = { type: string; chatID?: string; messageID?: string; ts?: string } - -const eventTone: Record = { - 'message.new': theme.primary, - 'message.send': theme.mine, - 'message.edit': theme.warn, - 'message.delete': theme.danger, - 'chat.update': theme.cyan, - 'chat.read': theme.subtle, - 'chat.typing': theme.magenta, - 'reaction.add': theme.magenta, - 'reaction.remove': theme.subtle, -} - -export const StreamEventLine: React.FC<{ event: StreamEvent; index: number }> = ({ event, index }) => { - const color = eventTone[event.type] ?? theme.primary - const time = event.ts ? formatTime(event.ts) : undefined - return ( - - {String(index).padStart(4)} - {glyphs.dot} - - {event.type} - {event.chatID && in {event.chatID}} - {event.messageID && msg {event.messageID}} - {time && {time}} - - ) -} - -export const StreamHeader: React.FC<{ subscribed: string[]; baseURL: string; connected: boolean }> = ({ subscribed, baseURL, connected }) => { - const label = subscribed.length === 1 && subscribed[0] === '*' ? 'all chats' : `${subscribed.length} chat${subscribed.length === 1 ? '' : 's'}` - return ( - - - - - Watching events - {label} - {!connected && connecting…} - - - {baseURL} · press ⌃C to stop - - - ) -} - -// ─── generic fallback ───────────────────────────────────────────────────────── - -export const GenericRow: React.FC> = ({ item }) => { - const title = item.title ?? item.displayName ?? item.name ?? item.id ?? item.messageID - const scalarEntries = Object.entries(item).filter(([key, value]) => { - if (value == null) return false - if (key === 'title' || key === 'displayName' || key === 'name') return false - if (typeof value === 'object') return false - return true - }) - const width = scalarEntries.length ? Math.max(...scalarEntries.map(([k]) => k.length)) + 2 : 0 - return ( - - {title != null && ( - - - - {String(title)} - - )} - {scalarEntries.map(([key, value]) => ( - - {key.padEnd(width)} - {String(value)} - - ))} - - ) -} diff --git a/packages/cli/src/lib/ink/format.ts b/packages/cli/src/lib/ink/format.ts deleted file mode 100644 index 64102c80..00000000 --- a/packages/cli/src/lib/ink/format.ts +++ /dev/null @@ -1,121 +0,0 @@ -export type RecordValue = Record - -export function stringValue(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value : undefined -} - -export function compact(values: unknown[]): string[] { - return values.filter((value): value is string => typeof value === 'string' && value.length > 0) -} - -export function formatTime(value: unknown): string | undefined { - if (typeof value !== 'string' && typeof value !== 'number') return undefined - const date = new Date(value) - if (Number.isNaN(date.valueOf())) return String(value) - const now = Date.now() - const diffMs = now - date.valueOf() - const abs = Math.abs(diffMs) - const suffix = diffMs >= 0 ? 'ago' : 'from now' - if (abs < 60_000) return 'just now' - if (abs < 3_600_000) return `${Math.round(abs / 60_000)}m ${suffix}` - if (abs < 86_400_000) return `${Math.round(abs / 3_600_000)}h ${suffix}` - if (abs < 604_800_000) return `${Math.round(abs / 86_400_000)}d ${suffix}` - return date.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric', - }) -} - -export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / 1024 / 1024).toFixed(1)} MB` -} - -export function formatDuration(ms: number): string { - const seconds = Math.round(ms / 1000) - if (seconds < 60) return `${seconds}s` - const minutes = Math.floor(seconds / 60) - const remainder = seconds % 60 - return `${minutes}m ${remainder}s` -} - -export function shortID(value: string): string { - const local = value.split(':')[0] - return local?.replace(/^@/, '') || value -} - -export function truncate(value: string, max: number): string { - return value.length <= max ? value : `${value.slice(0, max - 1)}…` -} - -export function cleanText(value: unknown): string | undefined { - const raw = stringValue(value) - if (!raw) return undefined - const text = raw - .replace(/<[^>]*>/g, ' ') - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - .replace(/[*_`~>#]/g, '') - .replace(/\s+/g, ' ') - .trim() - return text ? truncate(text, 240) : undefined -} - -export function attachmentLabel(attachments: unknown[]): string { - const labels = attachments.map(item => { - if (!item || typeof item !== 'object') return 'attachment' - const attachment = item as RecordValue - return stringValue(attachment.fileName) ?? stringValue(attachment.type) ?? 'attachment' - }) - return labels.length === 1 ? labels[0]! : `${labels.length} attachments` -} - -export function messageText(message: RecordValue): string | undefined { - if (message.isDeleted) return 'deleted message' - const text = cleanText(message.text) - if (text) return text - if (Array.isArray(message.attachments) && message.attachments.length > 0) return attachmentLabel(message.attachments) - if (Array.isArray(message.links) && message.links.length > 0) return `${message.links.length} link${message.links.length === 1 ? '' : 's'}` - return undefined -} - -export function chatPreview(chat: RecordValue): { kind: 'draft' | 'message'; sender?: string; text: string } | undefined { - if (chat.draft && typeof chat.draft === 'object') { - const draft = chat.draft as RecordValue - const text = cleanText(draft.text) ?? '' - return { kind: 'draft', text } - } - const lastMessage = (chat.latestMessage ?? chat.lastMessage) as RecordValue | undefined - if (lastMessage && typeof lastMessage === 'object') { - const sender = stringValue(lastMessage.senderName) ?? (lastMessage.isSender ? 'you' : undefined) - const text = messageText(lastMessage) ?? '' - if (!text && !sender) return undefined - return { kind: 'message', sender, text } - } - return undefined -} - -export function participantsSummary(participants: RecordValue): string | undefined { - const total = participants.total - const items = Array.isArray(participants.items) ? participants.items : [] - if (typeof total === 'number') return `${total} participant${total === 1 ? '' : 's'}` - if (items.length) return `${items.length} participant${items.length === 1 ? '' : 's'}` - return undefined -} - -export function isPinned(chat: RecordValue): boolean { - return Boolean(chat.isPinned ?? chat.pinned) -} - -export function isMuted(chat: RecordValue): boolean { - return Boolean(chat.isMuted ?? chat.muted) -} - -export function isArchived(chat: RecordValue): boolean { - return Boolean(chat.isArchived ?? chat.archived) -} - -export function isLowPriority(chat: RecordValue): boolean { - return Boolean(chat.isLowPriority ?? chat.lowPriority) -} diff --git a/packages/cli/src/lib/ink/render.tsx b/packages/cli/src/lib/ink/render.tsx deleted file mode 100644 index 19110b9b..00000000 --- a/packages/cli/src/lib/ink/render.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Box, render as inkRender, Static, Text, useApp, useInput } from 'ink' -import Spinner from 'ink-spinner' -import { - AccountRow, - AssetRow, - AuthStatusCard, - BridgeRow, - ChatDetail, - ChatRow, - CommandsView, - ConfigView, - DoctorCard, - EmptyState, - FailureLine, - GenericRow, - InfoCard, - MessageRow, - ReadinessCard, - SectionHeader, - SendResultCard, - type StreamEvent, - StreamEventLine, - StreamHeader, - type Suggestion, - SuccessLine, - UserInfoCard, - UserRow, -} from './components.js' -import type { RecordValue } from './format.js' -import { glyphs, theme } from './theme.js' - -const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { exit } = useApp() - useEffect(() => { - setTimeout(() => exit(), 0) - }, [exit]) - return <>{children} -} - -async function renderOnce(node: React.ReactNode): Promise { - const instance = inkRender({node}, { exitOnCtrlC: false, patchConsole: false }) - await instance.waitUntilExit().catch(() => undefined) -} - -type Kind = - | 'chat' | 'chatDetail' | 'message' | 'user' | 'account' | 'bridge' | 'asset' - | 'info' | 'doctor' | 'auth' | 'oauth' | 'search' - | 'commandManifest' | 'config' | 'readiness' | 'sendResult' - | 'generic' - -function detectKind(record: RecordValue): Kind { - if (typeof record.id === 'string' && typeof record.accountID === 'string' && typeof record.title === 'string' && typeof record.unreadCount === 'number') { - if (record.participants && typeof record.participants === 'object') return 'chatDetail' - return 'chat' - } - if (typeof record.id === 'string' && typeof record.chatID === 'string' && typeof record.senderID === 'string' && typeof record.timestamp === 'string') return 'message' - if (typeof record.chatID === 'string' && typeof record.pendingMessageID === 'string' && (record.accepted === true || typeof record.state === 'string')) return 'sendResult' - if (typeof record.id === 'string' && (typeof record.fullName === 'string' || typeof record.username === 'string' || typeof record.email === 'string' || typeof record.phoneNumber === 'string')) return 'user' - if (typeof record.id === 'string' && typeof record.provider === 'string' && typeof record.status === 'string' && (typeof record.displayName === 'string' || Array.isArray(record.accounts))) return 'bridge' - if (typeof (record.accountID ?? record.id) === 'string' && (typeof record.network === 'string' || typeof record.bridge === 'string' || typeof record.displayName === 'string')) return 'account' - if (typeof record.uploadID === 'string' || typeof record.srcURL === 'string') return 'asset' - if (typeof record.version === 'string' && typeof record.endpoints === 'object') return 'info' - if (typeof record.ok === 'boolean' && Array.isArray(record.checks)) return 'doctor' - if (typeof record.ok === 'boolean' && record.checks && typeof record.checks === 'object') return 'doctor' - if (record.readiness && typeof record.readiness === 'object') return 'readiness' - if (typeof record.state === 'string' && Array.isArray(record.actions)) return 'readiness' - if (typeof record.authenticated === 'boolean' && typeof record.baseURL === 'string') return 'auth' - if (typeof record.sub === 'string' && (typeof record.email === 'string' || typeof record.name === 'string' || typeof record.preferred_username === 'string')) return 'oauth' - if (Array.isArray(record.chats) && Array.isArray(record.messages)) return 'search' - return 'generic' -} - -function isManifestList(items: unknown[]): items is Array<{ command: string; description: string; group?: string }> { - return items.every(item => - item != null - && typeof item === 'object' - && typeof (item as Record).command === 'string' - && typeof (item as Record).description === 'string', - ) -} - -function rowFor(kind: Kind, item: RecordValue, key: number): React.ReactNode { - switch (kind) { - case 'chat': return - case 'chatDetail': return - case 'message': return - case 'user': return - case 'account': return - case 'bridge': return - case 'asset': return - default: return - } -} - -export async function renderList(items: RecordValue[], empty?: { title: string; subtitle?: string; suggestions?: Suggestion[] }): Promise { - if (!items.length) { - if (empty) await renderOnce() - return - } - if (isManifestList(items)) { - await renderOnce() - return - } - const kind = detectKind(items[0]!) - await renderOnce( - - {items.map((item, index) => rowFor(kind === 'chatDetail' ? 'chat' : kind, item, index))} - , - ) -} - -export async function renderValue(value: unknown): Promise { - if (Array.isArray(value)) { - await renderList(value as RecordValue[]) - return - } - if (!value || typeof value !== 'object') { - if (value === undefined) return - process.stdout.write(`${String(value)}\n`) - return - } - const record = value as RecordValue - const kind = detectKind(record) - switch (kind) { - case 'info': - await renderOnce() - return - case 'doctor': { - const rawChecks = Array.isArray(record.checks) - ? record.checks as Array<{ ok: boolean; name: string; detail?: string }> - : record.checks && typeof record.checks === 'object' - ? Object.entries(record.checks as Record).map(([name, value]) => { - const detail = typeof value === 'object' && value ? JSON.stringify(value) : String(value) - const ok = name === 'readiness' - ? (value as RecordValue)?.state === 'ready' - : typeof (value as RecordValue)?.reachable === 'boolean' - ? Boolean((value as RecordValue).reachable) - : Boolean(value) - return { ok, name, detail } - }) - : [] - const checks = rawChecks - await renderOnce() - return - } - case 'auth': - await renderOnce() - return - case 'oauth': - await renderOnce() - return - case 'search': { - const chats = Array.isArray(record.chats) ? record.chats as RecordValue[] : [] - const messages = Array.isArray(record.messages) ? record.messages as RecordValue[] : [] - if (!chats.length && !messages.length) { - await renderOnce( - "', hint: 'narrow with filters' }, - ]} - />, - ) - return - } - await renderOnce( - - {chats.length > 0 && } - {chats.map((item, index) => )} - {messages.length > 0 && } - {messages.map((item, index) => )} - , - ) - return - } - case 'readiness': - await renderOnce() - return - case 'sendResult': - await renderOnce() - return - case 'chat': - case 'chatDetail': - await renderOnce() - return - case 'message': - await renderOnce() - return - case 'user': - await renderOnce() - return - case 'account': - await renderOnce() - return - case 'bridge': - await renderOnce() - return - case 'asset': - await renderOnce() - return - default: - await renderOnce() - } -} - -export async function renderEmptyState(opts: { title: string; subtitle?: string; suggestions?: Suggestion[] }): Promise { - await renderOnce() -} - -export async function renderSuccess(opts: { message: string; detail?: string; entity?: unknown }): Promise { - let entityNode: React.ReactNode = null - if (opts.entity && typeof opts.entity === 'object') { - const record = opts.entity as RecordValue - const kind = detectKind(record) - entityNode = rowFor(kind === 'chatDetail' ? 'chat' : kind, record, 0) - } - await renderOnce() -} - -export async function renderFailure(opts: { message: string; detail?: string }): Promise { - await renderOnce() -} - -export async function renderConfig(data: RecordValue): Promise { - await renderOnce() -} - -export async function renderCommands(items: Array<{ command: string; description: string; group?: string }>, opts?: { title?: string; intro?: string[] }): Promise { - await renderOnce() -} - -// ─── streaming render (used by `watch`) ─────────────────────────────────────── - -export type StreamController = { - push(event: StreamEvent): void - setConnected(connected: boolean): void - setStatus(status: string | undefined): void - close(): Promise - done: Promise -} - -type StreamState = { - events: StreamEvent[] - connected: boolean - status: string | undefined -} - -type StreamProps = { - initialState: StreamState - baseURL: string - subscribed: string[] - bind: (api: { update: (next: StreamState) => void; exit: () => void; onInterrupt: (fn: () => void) => void }) => void -} - -const StreamView: React.FC = ({ initialState, baseURL, subscribed, bind }) => { - const [state, setState] = useState(initialState) - const { exit } = useApp() - const interruptRef = useRef<(() => void) | undefined>(undefined) - - useEffect(() => { - bind({ - update: setState, - exit: () => exit(), - onInterrupt: fn => { interruptRef.current = fn }, - }) - }, [bind, exit]) - - useInput((input, key) => { - if (key.ctrl && input === 'c') { - interruptRef.current?.() - } - }) - - return ( - - - ({ event, index: index + 1 }))}> - {({ event, index }) => } - - {!state.connected && state.status && ( - - - {state.status} - - )} - - ) -} - -export function renderStream(opts: { baseURL: string; subscribed: string[] }): StreamController { - const initial: StreamState = { events: [], connected: false, status: 'connecting' } - let current = initial - let api: { update: (next: StreamState) => void; exit: () => void; onInterrupt: (fn: () => void) => void } | undefined - const interruptHandlers: Array<() => void> = [] - - const setState = (next: StreamState): void => { - current = next - api?.update(next) - } - - const instance = inkRender( - { - api = hooks - hooks.onInterrupt(() => { - for (const fn of interruptHandlers) fn() - }) - }} - />, - { exitOnCtrlC: false, patchConsole: false }, - ) - - return { - push(event) { - setState({ ...current, events: [...current.events, event] }) - }, - setConnected(connected) { - setState({ ...current, connected, status: connected ? undefined : current.status }) - }, - setStatus(status) { - setState({ ...current, status }) - }, - async close() { - api?.exit() - await instance.waitUntilExit().catch(() => undefined) - }, - get done() { - return instance.waitUntilExit().then(() => undefined) - }, - } -} - -export type { Suggestion, StreamEvent } diff --git a/packages/cli/src/lib/ink/spinner.tsx b/packages/cli/src/lib/ink/spinner.tsx deleted file mode 100644 index 2c3beabf..00000000 --- a/packages/cli/src/lib/ink/spinner.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { Box, render, Text, useApp } from 'ink' -import Spinner from 'ink-spinner' -import { glyphs, theme } from './theme.js' - -type State = - | { kind: 'spinning'; label: string } - | { kind: 'succeed'; label: string } - | { kind: 'fail'; label: string } - -const externalListeners = new Map void>() - -type SpinnerLineProps = { - id: symbol - initial: State -} - -const SpinnerLine: React.FC = ({ id, initial }) => { - const [state, setState] = useState(initial) - const { exit } = useApp() - - useEffect(() => { - externalListeners.set(id, value => { - setState(value) - if (value.kind !== 'spinning') { - setTimeout(() => exit(), 0) - } - }) - return () => { externalListeners.delete(id) } - }, [id, exit]) - - if (state.kind === 'spinning') { - return ( - - - {state.label} - - ) - } - if (state.kind === 'succeed') { - return ( - - {glyphs.check} - {state.label} - - ) - } - return ( - - {glyphs.cross} - {state.label} - - ) -} - -export type SpinnerHandle = { - update(label: string): void - succeed(label?: string): Promise - fail(label?: string): Promise - stop(): Promise -} - -export function createInkSpinner(initialLabel: string, stream: NodeJS.WriteStream = process.stderr): SpinnerHandle { - const id = Symbol('spinner') - let currentLabel = initialLabel - let finished = false - - const instance = render( - , - { stdout: stream as unknown as NodeJS.WriteStream, exitOnCtrlC: false, patchConsole: false }, - ) - - const finish = (state: State): Promise => { - if (finished) return Promise.resolve() - finished = true - const listener = externalListeners.get(id) - if (listener) listener(state) - else instance.unmount() - return instance.waitUntilExit().then(() => undefined).catch(() => undefined) - } - - return { - update(label) { - if (finished) return - currentLabel = label - const listener = externalListeners.get(id) - listener?.({ kind: 'spinning', label }) - }, - succeed(label) { - return finish({ kind: 'succeed', label: label ?? currentLabel }) - }, - fail(label) { - return finish({ kind: 'fail', label: label ?? currentLabel }) - }, - stop() { - if (finished) return Promise.resolve() - finished = true - instance.unmount() - return instance.waitUntilExit().then(() => undefined).catch(() => undefined) - }, - } -} - -export async function withInkSpinner( - label: string, - fn: () => Promise, - options?: { done?: (value: T) => string | undefined; stream?: NodeJS.WriteStream }, -): Promise { - if (process.env.BEEPER_QUIET === '1') return fn() - const stream = options?.stream ?? process.stderr - const spinner = createInkSpinner(label, stream) - try { - const value = await fn() - const doneLabel = options?.done?.(value) - if (doneLabel) await spinner.succeed(doneLabel) - else await spinner.stop() - return value - } catch (error) { - await spinner.fail(label) - throw error - } -} diff --git a/packages/cli/src/lib/ink/theme.ts b/packages/cli/src/lib/ink/theme.ts deleted file mode 100644 index 32986f4d..00000000 --- a/packages/cli/src/lib/ink/theme.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Beeper Desktop dark-theme palette → Ink hex strings. -// Mirrors src/renderer/scss/tokens/colors{,_dark}.scss in the desktop app. -export const theme = { - // Brand / accent - primary: '#2561fb', - primaryDim: '#1b43aa', - primaryGlow: '#5a86ff', - link: '#5cadff', - - // Text - text: '#ededed', // --color-text-neutrals (dark) - muted: '#adadad', // --color-text-neutrals-weak - subtle: '#7e7e7e', // --color-text-neutrals-subtle - hairline: '#343434', // --color-border-neutrals - - // Surface (only used when we explicitly fill — Ink defaults to the user's term bg) - surface: '#000000', - surfaceAlt: '#1c1c1c', - surfaceHover: '#232323', - - // Semantic - mine: '#4cc38a', // --color-text-success (dark) - online: '#1ec843', - warn: '#f6ce46', // --color-pin - warnAlt: '#f1a10d', - danger: '#ff6369', // --color-text-error (dark) - magenta: '#912ce1', // --color-mute (dark) - cyan: '#00c2d7', - - // Highlights - mention: '#5a86ff', - draft: '#f6ce46', -} as const - -// Per-bridge "iconBackground" tints from beeper/desktop bundled-platforms/bridges/*/info.ts. -// Used to tint the rail/badge on chat & account rows so a glance reveals the network. -const bridgeTint: Record = { - imessage: '#19BA3B', - imessagecloud: '#19BA3B', - imessagego: '#19BA3B', - androidsms: '#19BA3B', - whatsapp: '#48C95F', - telegram: '#2EA4DB', - signal: '#3542FF', - discord: '#5865F2', - linkedin: '#086CE1', - twitter: '#202124', - x: '#202124', - bluesky: '#549B57', - beeper: '#0D4FFB', - beeperai: '#0D4FFB', - ai: '#0D4FFB', - instagram: '#d833ca', - facebook: '#0d4ffb', - messenger: '#0d4ffb', - googlechat: '#1ab5a2', - googlevoice: '#0eb3ef', - googlemessages: '#19ba3b', - slack: '#9745ea', - matrix: '#0eb3ef', -} - -export function bridgeColor(network: string | undefined | null): string | undefined { - if (!network) return undefined - const key = String(network).toLowerCase().replace(/[^a-z0-9]/g, '') - return bridgeTint[key] -} - -// Group-chat sender name palette (8-color rotation) from the desktop dark theme. -const groupSenderPalette = [ - '#63c174', '#f1a10d', '#f76190', '#bf7af0', - '#00c2d7', '#f65cb6', '#849dff', '#0ac5b3', -] as const - -export function senderColor(id: string | undefined | null): string { - if (!id) return theme.text - let hash = 0 - for (let i = 0; i < id.length; i++) hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0 - const palette = groupSenderPalette - return palette[Math.abs(hash) % palette.length]! -} - -// Glyphs — every visual cue we use sits in this map so a single audit covers them. -export const glyphs = { - rail: '▎', // narrow left-edge bar; replaces avatars - arrow: '›', - arrowR: '→', - arrowL: '←', - check: '✓', - cross: '✗', - dot: '●', - ring: '○', - pin: '★', - mute: '◐', - archive: '◇', - reaction: '♥', - attachment: '📎', - reply: '↳', - edited: '✎', - bullet: '·', - lowPriority: '◌', - mention: '@', - draft: '✎', - unread: '●', - spinner: '◇', - hairline: '─', -} as const diff --git a/packages/cli/src/lib/installations.ts b/packages/cli/src/lib/installations.ts index 19abe049..4e5a4203 100644 --- a/packages/cli/src/lib/installations.ts +++ b/packages/cli/src/lib/installations.ts @@ -1,5 +1,5 @@ import { createWriteStream } from 'node:fs' -import { chmod, cp, mkdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, cp, mkdir, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, extname, join } from 'node:path' import { Readable } from 'node:stream' @@ -7,16 +7,14 @@ import { pipeline } from 'node:stream/promises' import type { ReadableStream } from 'node:stream/web' import { execFile } from 'node:child_process' import { promisify } from 'node:util' -import { beeperDir } from './targets.js' +import { beeperDir, type ManagedTargetType } from './targets.js' +import { SERVER_ENV_API_BASE_URLS, normalizeServerEnv, type ServerEnv } from './server-env.js' const execFileAsync = promisify(execFile) -export type InstallKind = 'desktop' | 'server' -export type InstallChannel = 'stable' | 'nightly' -export type ServerEnv = 'production' | 'staging' - -export type Installation = { - kind: InstallKind +type InstallChannel = 'stable' | 'nightly' +type Installation = { + kind: ManagedTargetType channel: InstallChannel serverEnv: ServerEnv bundleID: string @@ -28,28 +26,36 @@ export type Installation = { updatedAt: string } -export type Installations = Partial> +export type Installations = Partial> -export type UpdateInfo = { +type UpdateInfo = { available: boolean latestVersion?: string - currentVersion?: string - action: string - feedURL?: string } -export type FeedInfo = { +type FeedInfo = { version?: string url?: string raw: unknown } -export const installationsPath = () => join(beeperDir(), 'installations.json') -export const appsDir = () => join(beeperDir(), 'apps') -export const binDir = () => join(beeperDir(), 'bin') -export const desktopInstallDir = () => join(appsDir(), 'desktop') -export const serverInstallRoot = () => join(appsDir(), 'server') -export const serverBinPath = () => join(binDir(), 'beeper-server') +type InstallRequest = { + kind: ManagedTargetType + channel: InstallChannel + serverEnv: ServerEnv + platform: 'macos' | 'windows' | 'linux' + feedPlatform: 'darwin' | 'win32' | 'linux' + arch: 'x64' | 'arm64' + bundleID: string + apiBaseURL: string +} + +const installationsPath = () => join(beeperDir(), 'installations.json') +const appsDir = () => join(beeperDir(), 'apps') +const binDir = () => join(beeperDir(), 'bin') +const desktopInstallDir = () => join(appsDir(), 'desktop') +const serverInstallRoot = () => join(appsDir(), 'server') +const serverBinPath = () => join(binDir(), 'beeper-server') export async function readInstallations(): Promise { try { @@ -60,38 +66,26 @@ export async function readInstallations(): Promise { } } -export async function writeInstallations(installations: Installations): Promise { +async function writeInstallations(installations: Installations): Promise { await mkdir(dirname(installationsPath()), { recursive: true }) await writeFile(installationsPath(), `${JSON.stringify(installations, null, 2)}\n`, { mode: 0o600 }) } -export async function saveInstallation(installation: Installation): Promise { +async function saveInstallation(installation: Installation): Promise { const current = await readInstallations() await writeInstallations({ ...current, [installation.kind]: installation }) return installation } -export function normalizeInstallRequest(options: { - kind: InstallKind +function normalizeInstallRequest(options: { + kind: ManagedTargetType channel?: InstallChannel serverEnv?: string platform?: NodeJS.Platform arch?: string -}): { - kind: InstallKind - channel: InstallChannel - serverEnv: ServerEnv - platform: 'macos' | 'windows' | 'linux' - feedPlatform: 'darwin' | 'win32' | 'linux' - arch: 'x64' | 'arm64' - bundleID: string - apiBaseURL: string -} { - // TODO: switch Server installs back to production once the production download - // endpoint returns a beeper-server artifact instead of the Desktop app bundle. - const serverEnv = options.kind === 'server' ? 'staging' : normalizeServerEnv(options.serverEnv) - let channel = options.channel ?? 'stable' - if (serverEnv === 'staging') channel = 'nightly' +}): InstallRequest { + const serverEnv = normalizeServerEnv(options.serverEnv) + const channel = options.channel ?? 'stable' const platform = normalizeDownloadPlatform(options.platform ?? process.platform) const feedPlatform = normalizeFeedPlatform(options.platform ?? process.platform) const arch = normalizeArch(options.arch ?? process.arch) @@ -104,11 +98,11 @@ export function normalizeInstallRequest(options: { feedPlatform, arch, bundleID, - apiBaseURL: options.kind === 'server' || serverEnv === 'staging' ? 'https://api.beeper-staging.com' : 'https://api.beeper.com', + apiBaseURL: SERVER_ENV_API_BASE_URLS[serverEnv], } } -export function feedURLFor(options: ReturnType): string { +function feedURLFor(options: InstallRequest): string { const url = new URL('/desktop/update-feed.json', options.apiBaseURL) url.searchParams.set('bundleID', options.bundleID) url.searchParams.set('platform', options.feedPlatform) @@ -117,12 +111,11 @@ export function feedURLFor(options: ReturnType): return url.toString() } -export function downloadURLFor(options: ReturnType): string { - const channelSegment = options.serverEnv === 'staging' && options.kind === 'server' ? 'stable' : options.channel - return `${options.apiBaseURL}/desktop/download/${options.platform}/${options.arch}/${channelSegment}/${options.bundleID}` +function downloadURLFor(options: InstallRequest): string { + return `${options.apiBaseURL}/desktop/download/${options.platform}/${options.arch}/${options.channel}/${options.bundleID}` } -export async function fetchFeed(feedURL: string): Promise { +async function fetchFeed(feedURL: string): Promise { const response = await fetch(feedURL, { signal: AbortSignal.timeout(30_000) }) if (!response.ok) throw new Error(`Update feed returned ${response.status} ${response.statusText}`) const raw = await response.json() as unknown @@ -136,22 +129,21 @@ export async function fetchFeed(feedURL: string): Promise { export async function checkInstallationUpdate(installation: Installation): Promise { const feed = await fetchFeed(installation.feedURL) const latestVersion = feed.version - const available = !!latestVersion && latestVersion !== installation.version return { - available, + available: !!latestVersion && latestVersion !== installation.version, latestVersion, - currentVersion: installation.version, - action: installation.kind === 'desktop' - ? 'Update Beeper Desktop in the app.' - : available ? 'Run: beeper update --server' : 'Beeper Server is up to date.', - feedURL: installation.feedURL, } } export async function installDesktop(options: { channel?: InstallChannel; serverEnv?: string } = {}): Promise { const request = normalizeInstallRequest({ kind: 'desktop', channel: options.channel, serverEnv: options.serverEnv }) - if (request.serverEnv === 'staging') throw new Error('Desktop staging installs are not supported by the CLI.') - const feedURL = feedURLFor(request) + const feedRequest = request.serverEnv === 'prod' + ? request + : { ...request, serverEnv: 'prod' as const, apiBaseURL: SERVER_ENV_API_BASE_URLS.prod } + if (request.serverEnv !== feedRequest.serverEnv) { + process.stderr.write(`Desktop ${request.serverEnv} installs use the production update feed; the app will still launch against ${request.serverEnv}.\n`) + } + const feedURL = feedURLFor(feedRequest) const feed = await fetchFeed(feedURL) const downloadURL = feed.url if (!downloadURL) throw new Error('Desktop update feed did not include a download URL.') @@ -206,11 +198,7 @@ export async function installServer(options: { channel?: InstallChannel; serverE }) } -export async function updateServerInstallation(installation: Installation): Promise { - return installServer({ channel: installation.channel, serverEnv: installation.serverEnv }) -} - -export async function downloadArtifact(url: string, destinationDir: string): Promise { +async function downloadArtifact(url: string, destinationDir: string): Promise { await mkdir(destinationDir, { recursive: true }) const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(120_000) }) if (!response.ok || !response.body) throw new Error(`Download returned ${response.status} ${response.statusText}`) @@ -296,7 +284,6 @@ async function copyPath(source: string, destination: string): Promise { } async function findAppBundle(dir: string): Promise { - const { readdir, stat } = await import('node:fs/promises') const entries = await readdir(dir) for (const entry of entries) { const path = join(dir, entry) @@ -311,7 +298,6 @@ async function findAppBundle(dir: string): Promise { } async function findServerExecutable(dir: string): Promise { - const { readdir, stat } = await import('node:fs/promises') const entries = await readdir(dir) for (const entry of entries) { const path = join(dir, entry) @@ -326,12 +312,6 @@ async function findServerExecutable(dir: string): Promise { throw new Error('Downloaded Beeper Server artifact did not contain a beeper-server executable.') } -function normalizeServerEnv(value?: string): ServerEnv { - if (!value || value === 'production' || value === 'prod') return 'production' - if (value === 'staging') return 'staging' - throw new Error(`Unsupported server env "${value}". Expected production or staging.`) -} - function normalizeDownloadPlatform(platform: NodeJS.Platform): 'macos' | 'windows' | 'linux' { if (platform === 'darwin') return 'macos' if (platform === 'win32') return 'windows' @@ -349,7 +329,7 @@ function normalizeArch(arch: string): 'x64' | 'arm64' { throw new Error(`Unsupported architecture "${arch}".`) } -function bundleIDFor(kind: InstallKind, channel: InstallChannel): string { +function bundleIDFor(kind: ManagedTargetType, channel: InstallChannel): string { const base = kind === 'desktop' ? 'com.automattic.beeper.desktop' : 'com.automattic.beeper.server' return channel === 'nightly' ? `${base}.nightly` : base } diff --git a/packages/cli/src/lib/local-desktop.ts b/packages/cli/src/lib/local-desktop.ts index 9c3c8790..ec6bfb94 100644 --- a/packages/cli/src/lib/local-desktop.ts +++ b/packages/cli/src/lib/local-desktop.ts @@ -4,6 +4,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { BeeperDesktop } from '@beeper/desktop-api' +import { apiItems, apiRecord } from './api-values.js' import type { Readiness } from './app-state.js' import type { StoredAuth, Target } from './targets.js' @@ -44,18 +45,18 @@ export async function findLocalDesktopSession(target?: Target): Promise accountName(item)) .filter((name): name is string => Boolean(name)) .slice(0, 8) @@ -105,8 +104,7 @@ export async function connectedAccountSummary(target: Target, auth?: StoredAuth) export async function localConnectedAccountSummary(dataDir: string): Promise { const bridgeAccounts = await readKeyValue(dataDir, 'bridgeAccounts').catch(() => undefined) - const rows = Array.isArray(bridgeAccounts) ? bridgeAccounts : [] - const names = rows + const names = apiItems(bridgeAccounts) .map(item => accountName(item)) .filter((name): name is string => Boolean(name)) return [...new Set(names)].slice(0, 8) @@ -151,16 +149,15 @@ async function readKeyValue(dataDir: string, key: string): Promise { } function accountName(item: unknown): string | undefined { - if (!item || typeof item !== 'object') return undefined - const record = item as Record - const bridge = record.bridge && typeof record.bridge === 'object' ? record.bridge as Record : undefined - const network = record.network && typeof record.network === 'object' ? record.network as Record : undefined + const record = apiRecord(item) + const bridge = apiRecord(record.bridge) + const network = apiRecord(record.network) return stringValue(record.network) - ?? stringValue(network?.displayName) - ?? stringValue(network?.name) + ?? stringValue(network.displayName) + ?? stringValue(network.name) ?? stringValue(record.displayName) ?? stringValue(record.name) - ?? stringValue(bridge?.type) + ?? stringValue(bridge.type) ?? stringValue(record.accountID) ?? stringValue(record.id) } @@ -173,10 +170,6 @@ function booleanValue(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined } -function recordValue(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined -} - function sqlString(value: string): string { return value.replaceAll("'", "''") } diff --git a/packages/cli/src/lib/manifest.ts b/packages/cli/src/lib/manifest.ts deleted file mode 100644 index 5b7dfe63..00000000 --- a/packages/cli/src/lib/manifest.ts +++ /dev/null @@ -1,575 +0,0 @@ -export type ManifestCommand = { - command: string - description: string - examples?: string[] -} - -export const commandManifest: ManifestCommand[] = [ - { - command: 'setup', - description: 'Make the selected target ready for messaging', - examples: [ - 'beeper setup', - 'beeper setup --local', - 'beeper setup --oauth', - 'beeper setup --remote https://desktop.example.com', - 'beeper setup --desktop --install', - ], - }, - { - command: 'install desktop', - description: 'Install Beeper Desktop locally', - examples: ['beeper install desktop', 'beeper install desktop --channel nightly'], - }, - { - command: 'install server', - description: 'Install Beeper Server locally', - examples: ['beeper install server', 'beeper install server --server-env staging'], - }, - { - command: 'targets list', - description: 'List configured Beeper targets', - examples: ['beeper targets list', 'beeper targets list --json'], - }, - { - command: 'bridges list', - description: 'List bridges that can connect chat accounts', - examples: ['beeper bridges list', 'beeper bridges list --provider local --json'], - }, - { - command: 'bridges show', - description: 'Show bridge details, login flows, and connected accounts', - examples: ['beeper bridges show local-whatsapp', 'beeper bridges show telegram'], - }, - { - command: 'targets add desktop', - description: 'Add a managed Beeper Desktop target', - examples: ['beeper targets add desktop work --default'], - }, - { - command: 'targets add server', - description: 'Add a managed Beeper Server target', - examples: ['beeper targets add server prod --server-env production --default'], - }, - { - command: 'targets add remote', - description: 'Add a remote Beeper Desktop or Server target', - examples: ['beeper targets add remote work https://desktop.example.com --default'], - }, - { - command: 'targets use', - description: 'Set the default target', - examples: ['beeper targets use work'], - }, - { - command: 'targets show', - description: 'Show target details', - examples: ['beeper targets show', 'beeper targets show work'], - }, - { - command: 'targets status', - description: 'Check endpoint and process reachability for a target', - examples: ['beeper targets status', 'beeper targets status work --json'], - }, - { - command: 'targets start', - description: 'Start a local Server target or open Beeper Desktop', - examples: ['beeper targets start work'], - }, - { - command: 'targets stop', - description: 'Stop a local Beeper Server target', - examples: ['beeper targets stop work'], - }, - { - command: 'targets restart', - description: 'Restart a local Beeper Server target', - examples: ['beeper targets restart work'], - }, - { - command: 'targets logs', - description: 'Print logs for a local Beeper Desktop or Server install', - examples: ['beeper targets logs work'], - }, - { - command: 'targets enable', - description: 'Enable a local Beeper Server target at login', - examples: ['beeper targets enable work'], - }, - { - command: 'targets disable', - description: 'Disable a local Beeper Server target at login', - examples: ['beeper targets disable work'], - }, - { - command: 'targets remove', - description: 'Remove a target', - examples: ['beeper targets remove work'], - }, - { - command: 'targets tunnel', - description: 'Expose a local Desktop API over a public Cloudflare tunnel', - examples: [ - 'beeper targets tunnel', - 'beeper targets tunnel --target work --read-only', - 'beeper targets tunnel --as work-laptop --port 23373', - ], - }, - { - command: 'auth status', - description: 'Show stored auth for the selected target', - examples: ['beeper auth status', 'beeper auth status --json'], - }, - { - command: 'auth logout', - description: 'Clear stored authentication', - examples: ['beeper auth logout'], - }, - { - command: 'auth email start', - description: 'Start email sign-in for a target', - examples: ['beeper auth email start --email you@example.com --target work --json'], - }, - { - command: 'auth email response', - description: 'Finish email sign-in with a verification code', - examples: ['beeper auth email response --setup-request-id --code --target work --json'], - }, - { - command: 'verify', - description: 'Finish setup verification or verify another device', - examples: ['beeper verify', 'beeper verify --user @alice:beeper.com'], - }, - { - command: 'verify status', - description: 'Show encryption and device-verification readiness', - examples: ['beeper verify status --json'], - }, - { - command: 'verify approve', - description: 'Approve a pending device verification request', - examples: ['beeper verify approve --id active'], - }, - { - command: 'verify recovery-key', - description: 'Unlock encrypted messages with a recovery key', - examples: ['beeper verify recovery-key --key ABCD-EFGH-IJKL'], - }, - { - command: 'verify reset-recovery-key', - description: 'Create a new encrypted-messages recovery key', - examples: ['beeper verify reset-recovery-key'], - }, - { - command: 'verify cancel', - description: 'Cancel an in-progress device verification', - examples: ['beeper verify cancel'], - }, - { - command: 'verify list', - description: 'List active verification work', - examples: ['beeper verify list'], - }, - { - command: 'verify start', - description: 'Start a device verification request', - examples: ['beeper verify start --user @alice:beeper.com'], - }, - { - command: 'verify show', - description: 'Show the current active verification request', - examples: ['beeper verify show --json'], - }, - { - command: 'verify sas', - description: 'Start emoji verification', - examples: ['beeper verify sas'], - }, - { - command: 'verify sas-confirm', - description: 'Confirm matching emoji verification', - examples: ['beeper verify sas-confirm'], - }, - { - command: 'verify qr-scan', - description: 'Submit a scanned QR-code verification payload', - examples: ['beeper verify qr-scan --payload "..."'], - }, - { - command: 'verify qr-confirm', - description: 'Confirm that the other device scanned your QR code', - examples: ['beeper verify qr-confirm'], - }, - { - command: 'accounts list', - description: 'List connected accounts', - examples: ['beeper accounts list', 'beeper accounts list --account whatsapp --json'], - }, - { - command: 'accounts add', - description: 'Connect a chat account by bridge', - examples: [ - 'beeper accounts add', - 'beeper accounts add local-whatsapp', - 'beeper accounts add discord --non-interactive --cookie sessiontoken=...', - 'beeper accounts add discord --webview --webview-backend chrome', - ], - }, - { - command: 'accounts show', - description: 'Show account details', - examples: ['beeper accounts show whatsapp-main'], - }, - { - command: 'accounts remove', - description: 'Remove an account', - examples: ['beeper accounts remove whatsapp-main'], - }, - { - command: 'accounts use', - description: 'Select a default account for account-scoped commands', - examples: ['beeper accounts use whatsapp-main'], - }, - { - command: 'chats list', - description: 'List chats', - examples: [ - 'beeper chats list', - 'beeper chats list --pinned --limit 50', - 'beeper chats list --unread --no-muted --json', - ], - }, - { - command: 'chats search', - description: 'Search chats', - examples: ['beeper chats search Family'], - }, - { - command: 'chats show', - description: 'Show chat details', - examples: ['beeper chats show --chat 10313', 'beeper chats show --chat \'!plUOsWkvMmJmJPVAjS:beeper.com\''], - }, - { - command: 'chats start', - description: 'Start a chat', - examples: ['beeper chats start +15551234567', 'beeper chats start @alice:beeper.com --title "Alice"'], - }, - { - command: 'chats archive', - description: 'Archive a chat', - examples: ['beeper chats archive --chat 10313'], - }, - { - command: 'chats unarchive', - description: 'Unarchive a chat', - examples: ['beeper chats unarchive --chat 10313'], - }, - { - command: 'chats pin', - description: 'Pin a chat', - examples: ['beeper chats pin --chat 10313'], - }, - { - command: 'chats unpin', - description: 'Unpin a chat', - examples: ['beeper chats unpin --chat 10313'], - }, - { - command: 'chats mute', - description: 'Mute a chat', - examples: ['beeper chats mute --chat 10313'], - }, - { - command: 'chats unmute', - description: 'Unmute a chat', - examples: ['beeper chats unmute --chat 10313'], - }, - { - command: 'chats mark-read', - description: 'Mark a chat as read', - examples: ['beeper chats mark-read --chat 10313'], - }, - { - command: 'chats mark-unread', - description: 'Mark a chat as unread', - examples: ['beeper chats mark-unread --chat 10313'], - }, - { - command: 'chats priority', - description: 'Move a chat to the Inbox or Low Priority', - examples: [ - 'beeper chats priority --chat 10313 --level inbox', - 'beeper chats priority --chat \'!plUOsWkvMmJmJPVAjS:beeper.com\' --level low', - ], - }, - { - command: 'chats notify-anyway', - description: 'Send an iMessage Notify Anyway alert', - examples: ['beeper chats notify-anyway --chat 10313'], - }, - { - command: 'chats rename', - description: 'Rename a chat', - examples: ['beeper chats rename --chat 10313 --title "Family"'], - }, - { - command: 'chats description', - description: 'Set a chat description', - examples: [ - 'beeper chats description --chat 10313 --description "Engineering chat"', - 'beeper chats description --chat 10313 --clear', - ], - }, - { - command: 'chats avatar', - description: 'Set a chat avatar', - examples: ['beeper chats avatar --chat 10313 --file ./team.png'], - }, - { - command: 'chats draft', - description: 'Set or clear a chat draft', - examples: [ - 'beeper chats draft --chat 10313 --text "on my way"', - 'beeper chats draft --chat 10313 --clear', - ], - }, - { - command: 'chats disappear', - description: 'Set disappearing-message expiry', - examples: ['beeper chats disappear --chat 10313 --seconds 86400'], - }, - { - command: 'chats remind', - description: 'Set a chat reminder', - examples: [ - 'beeper chats remind --chat 10313 --when 2026-06-01T09:00:00Z', - 'beeper chats remind --chat 10313 --when 2026-06-01T09:00:00Z --dismiss-on-message', - ], - }, - { - command: 'chats unremind', - description: 'Clear a chat reminder', - examples: ['beeper chats unremind --chat 10313'], - }, - { - command: 'chats focus', - description: 'Focus Beeper Desktop on a chat', - examples: ['beeper chats focus --chat 10313'], - }, - { - command: 'messages list', - description: 'List chat messages', - examples: [ - 'beeper messages list --chat 10313 --limit 50', - 'beeper messages list --chat 10313 --before-cursor "" --limit 100', - 'beeper messages list --chat 10313 --sender me --asc', - ], - }, - { - command: 'messages search', - description: 'Search messages across chats', - examples: [ - 'beeper messages search invoice', - 'beeper messages search --chat 10313 --sender me --media image', - 'beeper messages search "flight" --after 2026-01-01 --before 2026-02-01', - ], - }, - { - command: 'messages show', - description: 'Show one message', - examples: ['beeper messages show --chat 10313 --id '], - }, - { - command: 'messages context', - description: 'Show message context', - examples: ['beeper messages context --chat 10313 --id --before 5 --after 5'], - }, - { - command: 'messages edit', - description: 'Edit a message', - examples: ['beeper messages edit --chat 10313 --id --message "fixed"'], - }, - { - command: 'messages delete', - description: 'Delete a message', - examples: ['beeper messages delete --chat 10313 --id --for-everyone'], - }, - { - command: 'messages export', - description: 'Export one chat to JSON', - examples: [ - 'beeper messages export --chat 10313 --output chat.json', - 'beeper messages export --chat 8951 --after 2026-01-01T00:00:00Z --output -', - 'beeper messages export --chat \'!plUOsWkvMmJmJPVAjS:beeper.com\' --before-cursor "" --limit 500', - ], - }, - { - command: 'send text', - description: 'Send a text message', - examples: [ - 'beeper send text --to 10313 --message "on my way"', - 'beeper send text --to 8951 --message "hi"', - 'beeper send text --to "Family" --message "hi" --pick 1', - ], - }, - { - command: 'send file', - description: 'Send a file', - examples: ['beeper send file --to 8951 --file ./photo.jpg --caption "from today"'], - }, - { - command: 'send react', - description: 'Send a reaction to a message', - examples: ['beeper send react --to 10313 --id --reaction "+1"'], - }, - { - command: 'send sticker', - description: 'Send a sticker', - examples: ['beeper send sticker --to 10313 --file ./hi.webp'], - }, - { - command: 'send unreact', - description: 'Remove a reaction from a message', - examples: ['beeper send unreact --to 10313 --id --reaction "+1"'], - }, - { - command: 'send voice', - description: 'Send a voice note', - examples: [ - 'beeper send voice --to 10313 --file ./note.ogg', - 'beeper send voice --to 10313 --file ./note.ogg --duration 12', - ], - }, - { - command: 'presence', - description: 'Send a typing (or paused) indicator to a chat', - examples: [ - 'beeper presence --chat 10313', - 'beeper presence --chat 10313 --state paused', - 'beeper presence --chat 10313 --duration 5', - ], - }, - { - command: 'contacts list', - description: 'List contacts', - examples: ['beeper contacts list --account whatsapp --query alice'], - }, - { - command: 'contacts search', - description: 'Search contacts', - examples: ['beeper contacts search alice'], - }, - { - command: 'contacts show', - description: 'Show contact details', - examples: ['beeper contacts show "Alice" --account whatsapp'], - }, - { - command: 'media download', - description: 'Download message media', - examples: [ - 'beeper media download mxc://beeper.com/abc --out ./downloads', - 'beeper media download mxc://beeper.com/abc -o - > photo.jpg', - ], - }, - { - command: 'export', - description: 'Export accounts, chats, messages, Markdown transcripts, and attachments', - examples: ['beeper export --out ./beeper-export', 'beeper export --chat 10313 --out ./chat'], - }, - { - command: 'watch', - description: 'Stream Desktop API WebSocket events', - examples: [ - 'beeper watch', - 'beeper watch --chat 10313 --json', - 'beeper watch --include-type message.upserted --include-type message.deleted', - 'beeper watch --webhook https://example.com/hook --webhook-secret "$BEEPER_WEBHOOK_SECRET"', - ], - }, - { - command: 'rpc', - description: 'Run newline-delimited JSON command RPC over stdin/stdout', - examples: ['printf \'{"id":1,"command":"chats list --json"}\\n\' | beeper rpc'], - }, - { - command: 'man', - description: 'Print the command manual', - examples: ['beeper man', 'beeper man --json'], - }, - { - command: 'doctor', - description: 'Probe the target live and report diagnostics', - examples: ['beeper doctor', 'beeper doctor --json'], - }, - { - command: 'status', - description: 'Show selected target and setup readiness', - examples: ['beeper status', 'beeper status --json'], - }, - { - command: 'docs', - description: 'Open Beeper CLI docs', - examples: ['beeper docs'], - }, - { - command: 'version', - description: 'Print CLI version', - examples: ['beeper version'], - }, - { - command: 'completion', - description: 'Print shell completion setup', - examples: ['beeper completion'], - }, - { - command: 'plugins', - description: 'Manage Beeper CLI plugins', - examples: ['beeper plugins', 'beeper plugins install @beeper/cli-plugin-cloudflare'], - }, - { - command: 'plugins available', - description: 'List recommended optional Beeper CLI plugins', - examples: ['beeper plugins available', 'beeper plugins available --json'], - }, - { - command: 'update', - description: 'Check and install Beeper updates', - examples: ['beeper update --check', 'beeper update --cli', 'beeper update --server'], - }, - { - command: 'config get', - description: 'Print CLI configuration', - examples: ['beeper config get', 'beeper config get defaultTarget'], - }, - { - command: 'config set', - description: 'Set a CLI configuration value', - examples: ['beeper config set defaultTarget work'], - }, - { - command: 'config path', - description: 'Print the CLI config path', - examples: ['beeper config path'], - }, - { - command: 'config reset', - description: 'Reset CLI configuration', - examples: ['beeper config reset'], - }, - { - command: 'api get', - description: 'Call a raw Desktop API GET path', - examples: ['beeper api get /v1/info', 'beeper api get /v1/chats --json'], - }, - { - command: 'api post', - description: 'Call a raw Desktop API POST path with a JSON body', - examples: ['beeper api post /v1/chats/abc/read --body \'{"messageID":"x"}\''], - }, - { - command: 'api request', - description: 'Call a raw Desktop API path with any supported HTTP method', - examples: ['beeper api request DELETE /v1/chats/abc/messages/def/reactions --body \'{"reactionKey":"👍"}\''], - }, -] diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index 22fbf983..7ddae58c 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -1,26 +1,28 @@ +import { createHash, randomBytes } from 'node:crypto' import { createServer } from 'node:http' import { AddressInfo } from 'node:net' import { spawn } from 'node:child_process' -import { createPKCEPair, createState } from './pkce.js' -import { updateConfig, type AuthSource } from './targets.js' -export type OAuthLoginOptions = { +type OAuthLoginOptions = { baseURL: string clientName: string openBrowser: boolean - save?: boolean scope: string - source?: AuthSource timeoutMs?: number } +type PKCEPair = { + codeChallenge: string + codeVerifier: string +} + type RegisterResponse = { authorization_endpoint?: string client_id: string token_endpoint?: string } -type TokenResponse = { +export type TokenResponse = { access_token: string expires_in?: number scope?: string @@ -59,27 +61,22 @@ export async function loginWithPKCE(options: OAuthLoginOptions): Promise ({ - ...config, - baseURL: options.baseURL, - auth: { - accessToken: token.access_token, - clientID: registered.client_id, - expiresAt: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : undefined, - scope: token.scope, - source: options.source, - tokenType: token.token_type, - }, - })) - } - return { ...token, clientID: registered.client_id } } finally { await callback.close() } } +function createPKCEPair(): PKCEPair { + const codeVerifier = randomBytes(64).toString('base64url') + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url') + return { codeChallenge, codeVerifier } +} + +function createState(): string { + return randomBytes(24).toString('base64url') +} + async function registerClient(baseURL: string, clientName: string, redirectURI: string, scope: string): Promise { const response = await fetch(new URL('/oauth/register', baseURL), { method: 'POST', diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts deleted file mode 100644 index f697d2d9..00000000 --- a/packages/cli/src/lib/output.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { StreamController, Suggestion } from './ink/render.js' - -export type OutputFormat = 'human' | 'json' | 'jsonl' -type RecordValue = Record - -const writeJSON = (value: unknown, format: 'json' | 'jsonl'): void => { - process.stdout.write(`${JSON.stringify(value, null, format === 'json' ? 2 : 0)}\n`) -} - -const envelope = (data: unknown) => ({ success: true, data, error: null }) - -const loadInk = () => import('./ink/render.js') - -export async function printData(value: unknown, format: OutputFormat): Promise { - if (format === 'json') { - writeJSON(envelope(value), 'json') - return - } - if (format === 'jsonl') { - if (Array.isArray(value)) { - for (const item of value) process.stdout.write(`${JSON.stringify(item)}\n`) - return - } - process.stdout.write(`${JSON.stringify(value)}\n`) - return - } - const { renderValue } = await loadInk() - await renderValue(value) -} - -export async function printList( - value: unknown[], - format: OutputFormat, - empty: { title: string; subtitle?: string; suggestions?: Suggestion[] }, -): Promise { - if (format === 'json') { - writeJSON(envelope(value), 'json') - return - } - if (format === 'jsonl') { - for (const item of value) process.stdout.write(`${JSON.stringify(item)}\n`) - return - } - const { renderList } = await loadInk() - await renderList(value as RecordValue[], empty) -} - -export async function collectPage(iterable: AsyncIterable, limit?: number): Promise { - if (limit !== undefined && limit <= 0) return [] - const items: T[] = [] - for await (const item of iterable) { - items.push(item) - if (limit !== undefined && items.length >= limit) break - } - return items -} - -export function printIDs(values: unknown[]): void { - for (const value of values) { - if (!value || typeof value !== 'object') continue - const record = value as Record - const id = record.localChatID ?? record.rowID ?? record.id ?? record.chatID ?? record.messageID - if (id) process.stdout.write(`${String(id)}\n`) - } -} - -export async function emptyState(opts: { title: string; subtitle?: string; suggestions?: Suggestion[] }): Promise { - const { renderEmptyState } = await loadInk() - await renderEmptyState(opts) -} - -export async function printSuccess( - opts: { message: string; detail?: string; entity?: unknown; data?: Record }, - format: OutputFormat, -): Promise { - if (format === 'json' || format === 'jsonl') { - writeJSON(envelope({ message: opts.message, detail: opts.detail, entity: opts.entity, ...(opts.data ?? {}) }), format) - return - } - if (process.env.BEEPER_QUIET === '1') return - const { renderSuccess } = await loadInk() - await renderSuccess(opts) -} - -export async function printFailure( - opts: { message: string; detail?: string; data?: Record }, - format: OutputFormat, -): Promise { - if (format === 'json' || format === 'jsonl') { - writeJSON({ success: false, data: opts.data ?? null, error: opts.message }, format) - return - } - const { renderFailure } = await loadInk() - await renderFailure(opts) -} - -export async function printConfig(data: Record, format: OutputFormat): Promise { - if (format === 'json' || format === 'jsonl') { - writeJSON(envelope(data), format) - return - } - const { renderConfig } = await loadInk() - await renderConfig(data) -} - -export async function printCommands( - items: Array<{ command: string; description: string; group?: string }>, - format: OutputFormat, - opts?: { title?: string; intro?: string[] }, -): Promise { - if (format === 'json' || format === 'jsonl') { - writeJSON(envelope(items), format) - return - } - const { renderCommands } = await loadInk() - await renderCommands(items, opts) -} - -export async function startStream(opts: { baseURL: string; subscribed: string[] }): Promise { - const { renderStream } = await loadInk() - return renderStream(opts) -} - -export type { Suggestion } from './ink/render.js' diff --git a/packages/cli/src/lib/paging.ts b/packages/cli/src/lib/paging.ts new file mode 100644 index 00000000..40f48111 --- /dev/null +++ b/packages/cli/src/lib/paging.ts @@ -0,0 +1,9 @@ +export async function collectPage(iterable: AsyncIterable, limit?: number): Promise { + if (limit !== undefined && limit <= 0) return [] + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + if (limit !== undefined && items.length >= limit) break + } + return items +} diff --git a/packages/cli/src/lib/pkce.ts b/packages/cli/src/lib/pkce.ts deleted file mode 100644 index 34cac242..00000000 --- a/packages/cli/src/lib/pkce.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createHash, randomBytes } from 'node:crypto' - -export type PKCEPair = { - codeChallenge: string - codeVerifier: string -} - -export function createPKCEPair(): PKCEPair { - const codeVerifier = randomBytes(64).toString('base64url') - const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url') - return { codeChallenge, codeVerifier } -} - -export function createState(): string { - return randomBytes(24).toString('base64url') -} diff --git a/packages/cli/src/lib/profiles.ts b/packages/cli/src/lib/profiles.ts index 6599bcad..38dd0c0c 100644 --- a/packages/cli/src/lib/profiles.ts +++ b/packages/cli/src/lib/profiles.ts @@ -4,14 +4,14 @@ import { closeSync, openSync } from 'node:fs' import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' import { promisify } from 'node:util' import { beeperDir, type Target } from './targets.js' -import { readInstallations } from './installations.js' -import { usageError } from './errors.js' +import { readInstallations, type Installations } from './installations.js' const execFileAsync = promisify(execFile) -export type ProfileRun = { +type ProfileRun = { id: string pid: number startedAt: string @@ -19,23 +19,17 @@ export type ProfileRun = { errorLog: string } -export const profileRunDir = () => join(beeperDir(), 'run', 'profiles') -export const profileLogDir = () => join(beeperDir(), 'logs', 'profiles') -export const profileRunPath = (id: string) => join(profileRunDir(), `${id}.json`) +const profileRunDir = () => join(beeperDir(), 'run', 'profiles') +const profileLogDir = () => join(beeperDir(), 'logs', 'profiles') +const profileRunPath = (id: string) => join(profileRunDir(), `${id}.json`) export const profileLogPath = (id: string) => join(profileLogDir(), `${id}.log`) export const profileErrorLogPath = (id: string) => join(profileLogDir(), `${id}.err.log`) -export function assertProfile(target: Target): void { - if (!target.managed || !target.dataDir) throw new Error(`Target "${target.id}" is not a local profile.`) +function assertProfile(target: Target): void { + if (!target.dataDir) throw new Error(`Target "${target.id}" is not a local profile.`) } -export function assertServerProfile(target: Target): void { - if (!target.managed || !target.dataDir || target.type !== 'server') { - throw usageError(`Target "${target.id}" is not a local Beeper Server install.`) - } -} - -export function defaultDesktopDataDir(profile?: string): string { +function defaultDesktopDataDir(profile?: string): string { const appName = `BeeperTexts${profile ? `-${profile}` : ''}` if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', appName) if (process.platform === 'win32') return process.env.APPDATA ? join(process.env.APPDATA, appName) : join(homedir(), appName) @@ -48,13 +42,12 @@ export function desktopLogDir(target?: Target): string { export async function startProfile(target: Target): Promise { assertProfile(target) - if (target.type === 'desktop') return startDesktopProfile(target) + if (target.type === 'desktop') return launchDesktopApp(target) return startServerProfile(target) } export async function launchDesktopApp(target?: Target): Promise<{ id: string; startedAt: string }> { - const installations = await readInstallations().catch(() => ({ desktop: undefined })) - const appPath = installations.desktop?.path ?? await findDesktopAppPath() + const appPath = await findDesktopAppPath() const args = appPath ? ['-n', appPath, '--args'] : ['-n', '-a', 'Beeper', '--args'] args.push('--no-enforce-app-location') if (target?.port) args.push(`--pas-port=${target.port}`) @@ -71,8 +64,8 @@ export async function launchDesktopApp(target?: Target): Promise<{ id: string; s return { id: target?.id ?? 'desktop', startedAt: new Date().toISOString() } } -export async function findDesktopAppPath(): Promise { - const installations = await readInstallations().catch(() => ({ desktop: undefined })) +export async function findDesktopAppPath(installations?: Installations): Promise { + installations ??= await readInstallations().catch(() => ({})) if (installations.desktop?.path && await isBeeperDesktopApp(installations.desktop.path)) return installations.desktop.path if (process.platform === 'darwin') { @@ -91,13 +84,13 @@ export async function findDesktopAppPath(): Promise { join(localAppData, 'Programs', 'Beeper Nightly', 'Beeper Nightly.exe'), ] for (const path of candidates) { - if (await pathExists(path)) return path + if (await access(path).then(() => true, () => false)) return path } } if (process.platform === 'linux') { for (const path of ['/usr/bin/beeper', '/usr/local/bin/beeper']) { - if (await pathExists(path)) return path + if (await access(path).then(() => true, () => false)) return path } } @@ -105,7 +98,7 @@ export async function findDesktopAppPath(): Promise { } async function isBeeperDesktopApp(path: string): Promise { - if (!await pathExists(path)) return false + if (!await access(path).then(() => true, () => false)) return false if (process.platform !== 'darwin') return true const bundleID = await readBundleID(path) return bundleID === 'com.automattic.beeper.desktop' || bundleID === 'com.automattic.beeper.desktop.nightly' @@ -145,53 +138,7 @@ export async function stopProfile(target: Target): Promise { await rm(profileRunPath(target.id), { force: true }) } -export async function profileStatus(target: Target): Promise> { - assertProfile(target) - const run = await readRun(target.id) - const reachable = await fetch(new URL('/v1/info', target.baseURL), { signal: AbortSignal.timeout(1000) }) - .then(response => response.ok) - .catch(() => false) - return { - id: target.id, - type: target.type, - url: target.baseURL, - running: reachable || !!run && isRunning(run.pid), - pid: run?.pid, - startedAt: run?.startedAt, - log: run?.log, - errorLog: run?.errorLog, - } -} - -export async function enableProfile(target: Target): Promise { - assertProfile(target) - if (target.type !== 'server') throw new Error('Manage Desktop start at launch in Beeper Desktop.') - if (process.platform === 'darwin') return enableLaunchAgent(target) - if (process.platform === 'linux') return enableSystemdUnit(target) - throw new Error('Beeper Server is not available on Windows.') -} - -export async function disableProfile(target: Target): Promise { - assertProfile(target) - if (target.type !== 'server') throw new Error('Manage Desktop start at launch in Beeper Desktop.') - if (process.platform === 'darwin') { - const path = join(process.env.HOME ?? beeperDir(), 'Library', 'LaunchAgents', launchAgentName(target)) - await execFileAsync('launchctl', ['bootout', `gui/${process.getuid?.() ?? 501}`, path]).catch(() => undefined) - await execFileAsync('launchctl', ['disable', `gui/${process.getuid?.() ?? 501}/${launchAgentLabel(target)}`]).catch(() => undefined) - await rm(path, { force: true }) - return path - } - if (process.platform === 'linux') { - const path = join(process.env.HOME ?? beeperDir(), '.config', 'systemd', 'user', systemdUnitName(target)) - await execFileAsync('systemctl', ['--user', 'disable', '--now', systemdUnitName(target)]).catch(() => undefined) - await execFileAsync('systemctl', ['--user', 'daemon-reload']).catch(() => undefined) - await rm(path, { force: true }) - return path - } - throw new Error('Beeper Server is not available on Windows.') -} - -export async function readRun(id: string): Promise { +async function readRun(id: string): Promise { try { return JSON.parse(await readFile(profileRunPath(id), 'utf8')) as ProfileRun } catch (error) { @@ -200,10 +147,6 @@ export async function readRun(id: string): Promise { } } -async function startDesktopProfile(target: Target): Promise<{ id: string; startedAt: string }> { - return launchDesktopApp(target) -} - async function startServerProfile(target: Target): Promise { const current = await readRun(target.id) if (current) { @@ -262,98 +205,6 @@ function isRunning(pid: number): boolean { } } -async function writeLaunchAgent(target: Target): Promise { - const installations = await readInstallations() - const binary = process.env.BEEPER_SERVER_BIN || installations.server?.path - if (!binary) throw new Error('Beeper Server is not installed. Run: beeper install server') - const dir = join(process.env.HOME ?? beeperDir(), 'Library', 'LaunchAgents') - await mkdir(dir, { recursive: true }) - const path = join(dir, launchAgentName(target)) - await writeFile(path, launchAgentPlist(target, binary), 'utf8') - return path -} - -async function enableLaunchAgent(target: Target): Promise { - const path = await writeLaunchAgent(target) - await mkdir(profileLogDir(), { recursive: true }) - const service = `gui/${process.getuid?.() ?? 501}` - await execFileAsync('launchctl', ['bootout', service, path]).catch(() => undefined) - await execFileAsync('launchctl', ['bootstrap', service, path]) - await execFileAsync('launchctl', ['enable', `${service}/${launchAgentLabel(target)}`]) - await execFileAsync('launchctl', ['kickstart', '-k', `${service}/${launchAgentLabel(target)}`]).catch(() => undefined) - return path -} - -async function enableSystemdUnit(target: Target): Promise { - const path = await writeSystemdUnit(target) - await mkdir(profileLogDir(), { recursive: true }) - await execFileAsync('systemctl', ['--user', 'daemon-reload']) - await execFileAsync('systemctl', ['--user', 'enable', '--now', systemdUnitName(target)]) - return path -} - -async function writeSystemdUnit(target: Target): Promise { - const installations = await readInstallations() - const binary = process.env.BEEPER_SERVER_BIN || installations.server?.path - if (!binary) throw new Error('Beeper Server is not installed. Run: beeper install server') - const dir = join(process.env.HOME ?? beeperDir(), '.config', 'systemd', 'user') - await mkdir(dir, { recursive: true }) - const path = join(dir, systemdUnitName(target)) - await writeFile(path, systemdUnit(target, binary), 'utf8') - return path -} - -function launchAgentName(target: Target): string { - return `${launchAgentLabel(target)}.plist` -} - -function launchAgentLabel(target: Target): string { - return `com.beeper.cli.profile.${target.id}` -} - -function systemdUnitName(target: Target): string { - return `beeper-profile-${target.id}.service` -} - -function launchAgentPlist(target: Target, binary: string): string { - return ` - - -Label${escapeXML(launchAgentLabel(target))} -ProgramArguments${[binary, ...serverArgs(target)].map(arg => `${escapeXML(arg)}`).join('')} -EnvironmentVariablesBEEPER_SERVER_DATA_DIR${escapeXML(target.dataDir!)} -RunAtLoad -KeepAlive -StandardOutPath${escapeXML(profileLogPath(target.id))} -StandardErrorPath${escapeXML(profileErrorLogPath(target.id))} - -` -} - -function systemdUnit(target: Target, binary: string): string { - return `[Unit] -Description=Beeper profile ${target.id} - -[Service] -ExecStart=${[binary, ...serverArgs(target)].map(systemdQuote).join(' ')} -Restart=always -Environment=BEEPER_SERVER_DATA_DIR=${systemdQuote(target.dataDir!)} -StandardOutput=append:${profileLogPath(target.id)} -StandardError=append:${profileErrorLogPath(target.id)} - -[Install] -WantedBy=default.target -` -} - -function escapeXML(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') -} - -function systemdQuote(value: string): string { - return value.includes(' ') ? `"${value.replaceAll('"', '\\"')}"` : value -} - async function isReachable(target: Target): Promise { return fetch(new URL('/v1/info', target.baseURL), { signal: AbortSignal.timeout(1000) }) .then(response => response.ok) @@ -377,16 +228,3 @@ async function waitForExit(pid: number, timeoutMs: number): Promise { } return false } - -async function sleep(ms: number): Promise { - await new Promise(resolve => setTimeout(resolve, ms)) -} - -async function pathExists(path: string): Promise { - try { - await access(path) - return true - } catch { - return false - } -} diff --git a/packages/cli/src/lib/prompts.ts b/packages/cli/src/lib/prompts.ts new file mode 100644 index 00000000..d3c84d68 --- /dev/null +++ b/packages/cli/src/lib/prompts.ts @@ -0,0 +1,35 @@ +import { stdin as input, stdout as defaultOutput } from 'node:process' +import { createInterface } from 'node:readline/promises' +import type { Writable } from 'node:stream' + +export async function promptText(label: string, output: Writable = defaultOutput): Promise { + const rl = createInterface({ input, output }) + try { + return (await rl.question(label)).trim() + } finally { + rl.close() + } +} + +export async function promptConfirm(label: string, defaultYes = false, output?: Writable): Promise { + const value = (await promptText(`${label} ${defaultYes ? '[Y/n]' : '[y/N]'} `, output)).toLowerCase() + if (!value) return defaultYes + return value === 'y' || value === 'yes' +} + +export async function promptChoice( + label: string, + choices: string[], + options: { defaultValue?: string; output?: Writable } = {}, +): Promise { + if (!choices.length) throw new Error('promptChoice requires at least one choice') + const out = options.output ?? defaultOutput + for (;;) { + const answer = await promptText(label, out) + const value = answer || options.defaultValue + const index = value && /^\d+$/.test(value) ? Number.parseInt(value, 10) : 0 + if (index >= 1 && index <= choices.length) return choices[index - 1]! + if (value && choices.includes(value)) return value + out.write(`Choose one of: ${choices.join(', ')}\n`) + } +} diff --git a/packages/cli/src/lib/recommended-plugins.ts b/packages/cli/src/lib/recommended-plugins.ts deleted file mode 100644 index a924aa04..00000000 --- a/packages/cli/src/lib/recommended-plugins.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type RecommendedPlugin = { - commands: string[] - description: string - install: string - name: string -} - -export const recommendedPlugins: RecommendedPlugin[] = [ - { - commands: ['targets tunnel'], - description: 'Expose a selected Beeper target through Cloudflare Tunnel', - install: 'beeper plugins install @beeper/cli-plugin-cloudflare', - name: '@beeper/cli-plugin-cloudflare', - }, -] diff --git a/packages/cli/src/lib/resolve.ts b/packages/cli/src/lib/resolve.ts index d83eb3b7..2bc6dcaf 100644 --- a/packages/cli/src/lib/resolve.ts +++ b/packages/cli/src/lib/resolve.ts @@ -1,18 +1,18 @@ +import { apiItems, apiRecord, type APIRecord } from './api-values.js' import { readConfig } from './targets.js' -import { ambiguous, notFound } from './errors.js' -import { confirmSuggestion, declineWithExit127, rankSuggestions } from './did-you-mean.js' +import { AbortError, CLIError, ExitCodes } from './errors.js' +import { collectPage } from './paging.js' +import { promptConfirm } from './prompts.js' -type AnyRecord = Record - -export type AccountResolutionOptions = { +type AccountResolutionOptions = { allowMultiplePerInput?: boolean applyDefault?: boolean } -export type ChatResolutionOptions = { +type ChatResolutionOptions = { accountIDs?: string[] + noInput?: boolean pick?: number - assumeYes?: boolean } export async function resolveAccountIDs( @@ -22,20 +22,22 @@ export async function resolveAccountIDs( ): Promise { let effectiveInputs = inputs if (!effectiveInputs?.length && options.applyDefault !== false) { - const config = await readConfig().catch(() => ({} as { defaultAccount?: string })) + const config = await readConfig() if (config.defaultAccount) effectiveInputs = [config.defaultAccount] } if (!effectiveInputs?.length) return undefined - const accounts = accountItems(await client.accounts.list()) + const accounts = apiItems(await client.accounts.list()) const resolved: string[] = [] for (const input of effectiveInputs) { const matches = matchAccounts(accounts, input) - if (matches.length === 0) throw notFound(`No account matches "${input}"`) + if (matches.length === 0) { + throw new AbortError(`No account matches "${input}"`, ExitCodes.NotFound, undefined, 'not_found') + } if (matches.length > 1 && !options.allowMultiplePerInput) { - throw ambiguous(formatAmbiguous(`account "${input}"`, matches.map(formatAccount))) + throw new AbortError(formatAmbiguous(`account "${input}"`, matches.map(formatAccount)), ExitCodes.Ambiguous, 'Pass an exact ID or --pick N.', 'ambiguous_selector') } - resolved.push(...matches.map(account => String(account.accountID))) + resolved.push(...matches.map(accountIDOf).filter(Boolean)) } return Array.from(new Set(resolved)) @@ -43,13 +45,15 @@ export async function resolveAccountIDs( export async function resolveAccountID(client: any, input: string): Promise { const [accountID] = await resolveAccountIDs(client, [input]) ?? [] - if (!accountID) throw notFound(`No account matches "${input}"`) + if (!accountID) { + throw new AbortError(`No account matches "${input}"`, ExitCodes.NotFound, undefined, 'not_found') + } return accountID } export async function listAccountIDs(client: any): Promise { - const accounts = accountItems(await client.accounts.list()) - return accounts.map(account => String(account.accountID)).filter(Boolean) + const accounts = apiItems(await client.accounts.list()) + return accounts.map(accountIDOf).filter(Boolean) } export async function resolveChatID(client: any, input: string, options: ChatResolutionOptions = {}): Promise { @@ -57,43 +61,46 @@ export async function resolveChatID(client: any, input: string, options: ChatRes const exact = await retrieveChat(client, input) if (exact) return chatInputID(exact) - const candidates = await collect(client.chats.search({ + const candidates = await collectPage(client.chats.search({ accountIDs: options.accountIDs, query: input, scope: 'titles', }), 10) - const normalizedInput = normalize(input) + const normalizedInput = normalizeSelector(input) const exactMatches = candidates.filter(chat => - normalize(chat.id) === normalizedInput || - normalize(chat.localChatID) === normalizedInput || - normalize(chat.title) === normalizedInput + normalizeSelector(chat.id) === normalizedInput || + normalizeSelector(chat.localChatID) === normalizedInput || + normalizeSelector(chat.title) === normalizedInput ) const matches = exactMatches.length ? exactMatches : candidates if (matches.length === 0) { const suggestion = await suggestChat(client, input, options) if (suggestion) return suggestion - return input + throw new AbortError(`No chat matches "${input}"`, ExitCodes.NotFound, undefined, 'not_found') } if (matches.length === 1) return chatInputID(matches[0]!) if (options.pick) { const selected = matches[options.pick - 1] - if (!selected) throw notFound(`--pick ${options.pick} is outside the ${matches.length} matching chats`) + if (!selected) { + throw new AbortError(`--pick ${options.pick} is outside the ${matches.length} matching chats`, ExitCodes.NotFound, undefined, 'not_found') + } return chatInputID(selected) } - throw ambiguous(formatAmbiguous(`chat "${input}"`, matches.map(formatChat))) + throw new AbortError(formatAmbiguous(`chat "${input}"`, matches.map(formatChat)), ExitCodes.Ambiguous, 'Pass an exact ID or --pick N.', 'ambiguous_selector') } async function suggestChat(client: any, input: string, options: ChatResolutionOptions): Promise { - let pool: AnyRecord[] + if (options.noInput) return undefined + let pool: APIRecord[] try { - pool = await collect(client.chats.list({ accountIDs: options.accountIDs, limit: 100 }), 100) + pool = await collectPage(client.chats.list({ accountIDs: options.accountIDs, limit: 100 }), 100) } catch { return undefined } - const ranked = rankSuggestions(input, pool, chat => chat.title as string | undefined, 3) + const ranked = rankSuggestions(input, pool, chat => typeof chat.title === 'string' ? chat.title : undefined) const top = ranked[0] if (!top) return undefined const detail = top.value.network ? ` (${top.value.network})` : '' @@ -102,42 +109,74 @@ async function suggestChat(client: any, input: string, options: ChatResolutionOp for (const alt of ranked.slice(1)) { process.stderr.write(` also: ${alt.label}${alt.value.network ? ` (${alt.value.network})` : ''}\n`) } - const ok = await confirmSuggestion('use it?', { assumeYes: options.assumeYes, timeoutMs: 10_000 }) - if (!ok) declineWithExit127(`no chat selected for "${input}"`) + const ok = process.stdin.isTTY && process.stderr.isTTY + ? await promptConfirm('use it?', true, process.stderr) + : false + if (!ok) throw new CLIError(`no chat selected for "${input}"`, ExitCodes.CommandNotFound) return chatInputID(top.value) } -function accountItems(accounts: unknown): AnyRecord[] { - if (Array.isArray(accounts)) return accounts as AnyRecord[] - return ((accounts as { items?: AnyRecord[] }).items ?? []) +type Suggestion = { value: T; label: string; distance: number } + +function rankSuggestions(query: string, items: T[], labelOf: (item: T) => string | undefined): Suggestion[] { + const q = query.trim().toLowerCase() + const scored: Suggestion[] = [] + for (const item of items) { + const label = labelOf(item) + if (!label) continue + const l = label.toLowerCase() + const distance = Math.min(levenshtein(q, l), l.includes(q) ? Math.max(0, l.length - q.length) : Infinity) + if (Number.isFinite(distance)) scored.push({ value: item, label, distance }) + } + scored.sort((a, b) => a.distance - b.distance || a.label.length - b.label.length) + const cutoff = Math.max(3, Math.ceil(q.length * 0.6)) + return scored.filter(suggestion => suggestion.distance <= cutoff).slice(0, 3) +} + +function levenshtein(a: string, b: string): number { + if (a === b) return 0 + if (!a.length) return b.length + if (!b.length) return a.length + const matrix: number[][] = Array.from({ length: a.length + 1 }, (_, i) => [i, ...new Array(b.length).fill(0)]) + for (let j = 1; j <= b.length; j++) matrix[0]![j] = j + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1 + matrix[i]![j] = Math.min(matrix[i - 1]![j]! + 1, matrix[i]![j - 1]! + 1, matrix[i - 1]![j - 1]! + cost) + } + } + return matrix[a.length]![b.length]! } -function matchAccounts(accounts: AnyRecord[], input: string): AnyRecord[] { - const normalizedInput = normalize(input) +function matchAccounts(accounts: APIRecord[], input: string): APIRecord[] { + const normalizedInput = normalizeSelector(input) const exact = accounts.filter(account => - normalize(account.accountID) === normalizedInput || - normalize(account.network) === normalizedInput || - normalize(account.bridge?.type) === normalizedInput || - normalize(account.bridge?.id) === normalizedInput || - normalize(account.user?.id) === normalizedInput || - normalize(account.user?.username) === normalizedInput || - normalize(account.user?.displayName) === normalizedInput || - normalize(account.user?.name) === normalizedInput || - normalize(account.user?.email) === normalizedInput + accountKeys(account).some(value => normalizeSelector(value) === normalizedInput) ) if (exact.length) return exact return accounts.filter(account => - includesNormalized(account.accountID, normalizedInput) || - includesNormalized(account.network, normalizedInput) || - includesNormalized(account.bridge?.type, normalizedInput) || - includesNormalized(account.bridge?.id, normalizedInput) || - includesNormalized(account.user?.displayName, normalizedInput) || - includesNormalized(account.user?.name, normalizedInput) + accountKeys(account).some(value => normalizeSelector(value).includes(normalizedInput)) ) } -async function retrieveChat(client: any, input: string): Promise { +function accountKeys(account: APIRecord): unknown[] { + const bridge = apiRecord(account.bridge) + const user = apiRecord(account.user) + return [ + account.accountID, + account.network, + bridge.type, + bridge.id, + user.id, + user.username, + user.displayName, + user.name, + user.email, + ] +} + +async function retrieveChat(client: any, input: string): Promise { try { return await client.chats.retrieve(input, { maxParticipantCount: 0 }) } catch (error) { @@ -147,45 +186,42 @@ async function retrieveChat(client: any, input: string): Promise(iterable: AsyncIterable, limit: number): Promise { - const items: T[] = [] - for await (const item of iterable) { - items.push(item) - if (items.length >= limit) break - } - return items -} - -function normalize(value: unknown): string { +export function normalizeSelector(value: unknown): string { return String(value ?? '').trim().toLowerCase().replace(/[\s._-]+/g, '') } -function includesNormalized(value: unknown, normalizedInput: string): boolean { - return normalize(value).includes(normalizedInput) -} - function formatAmbiguous(label: string, choices: string[]): string { return `Ambiguous ${label}. Use an exact ID or --pick N:\n${choices.map((choice, index) => ` ${index + 1}. ${choice}`).join('\n')}` } -function formatAccount(account: AnyRecord): string { +function formatAccount(account: APIRecord): string { + const bridge = apiRecord(account.bridge) + const user = apiRecord(account.user) const network = account.network ? ` ${account.network}` : '' - const bridge = account.bridge?.type ? ` ${account.bridge.type}` : '' - const user = account.user?.displayName || account.user?.name || account.user?.username || account.user?.id || '' - return `${account.accountID}${network}${bridge}${user ? ` ${user}` : ''}` + const bridgeName = bridge.type ? ` ${bridge.type}` : '' + const userName = user.displayName || user.name || user.username || user.id || '' + return `${accountIDOf(account) || account.id || ''}${network}${bridgeName}${userName ? ` ${userName}` : ''}` +} + +function accountIDOf(account: APIRecord): string { + return typeof account.accountID === 'string' && account.accountID + ? account.accountID + : typeof account.id === 'string' && account.id + ? account.id + : '' } -function formatChat(chat: AnyRecord): string { +function formatChat(chat: APIRecord): string { const network = chat.network ? ` ${chat.network}` : '' const local = chat.localChatID ? ` local:${chat.localChatID}` : '' return `${chat.id}${local}${network} ${chat.title ?? ''}`.trim() } -function chatInputID(chat: AnyRecord): string { +function chatInputID(chat: APIRecord): string { return String(chat.localChatID || chat.id) } -export function userQueryFromInput(input: string): AnyRecord { +export function userQueryFromInput(input: string): APIRecord { const trimmed = input.trim() if (/^@[^:]+:.+/.test(trimmed)) return { id: trimmed, username: trimmed } if (trimmed.includes('@')) return { email: trimmed, username: trimmed } diff --git a/packages/cli/src/lib/runner.ts b/packages/cli/src/lib/runner.ts deleted file mode 100644 index 9ffc8a79..00000000 --- a/packages/cli/src/lib/runner.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { spawn } from 'node:child_process' - -export type RunResult = { - code: number | null - signal: NodeJS.Signals | null - stdout: string - stderr: string -} - -export async function runCli(args: string[], options: { inherit?: boolean } = {}): Promise { - const child = spawn(process.execPath, [process.argv[1]!, ...args], { - env: process.env, - stdio: [options.inherit ? 'inherit' : 'ignore', options.inherit ? 'inherit' : 'pipe', options.inherit ? 'inherit' : 'pipe'], - }) - - const waitForExit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { - child.once('error', reject) - child.once('exit', (code, signal) => resolve({ code, signal })) - }) - - if (options.inherit) { - const { code, signal } = await waitForExit - return { code, signal, stdout: '', stderr: '' } - } - - const [stdout, stderr, exit] = await Promise.all([ - streamToString(child.stdout), - streamToString(child.stderr), - waitForExit, - ]) - return { code: exit.code, signal: exit.signal, stdout, stderr } -} - -async function streamToString(stream: NodeJS.ReadableStream | null): Promise { - if (!stream) return '' - const chunks: Buffer[] = [] - for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) - return Buffer.concat(chunks).toString('utf8') -} diff --git a/packages/cli/src/lib/send-message.ts b/packages/cli/src/lib/send-message.ts deleted file mode 100644 index e4722f23..00000000 --- a/packages/cli/src/lib/send-message.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createReadStream } from 'node:fs' -import { waitForMessage } from './wait.js' - -export type AttachmentType = 'sticker' | 'voice-note' -export type SendMessageResult = { - accepted: boolean - state: 'accepted' | 'resolved' - chatID: string - pendingMessageID?: string - message?: unknown - hint?: string -} - -export async function sendMessage(client: any, options: { - chatID: string - file?: string - fileName?: string - mimeType?: string - replyTo?: string - text: string - mentions?: string[] - noPreview?: boolean - attachmentType?: AttachmentType - duration?: number - wait?: boolean - waitTimeoutMs?: number -}): Promise { - const uploaded = options.file - ? await client.assets.upload({ - file: createReadStream(options.file), - fileName: options.fileName, - mimeType: options.mimeType, - }) - : undefined - - if (options.file && !uploaded?.uploadID) throw new Error('Upload did not return an uploadID') - - const pending = await client.messages.send(options.chatID, { - attachment: uploaded?.uploadID - ? { - uploadID: uploaded.uploadID, - type: options.attachmentType, - duration: options.duration ?? uploaded.duration, - fileName: uploaded.fileName, - mimeType: options.mimeType ?? uploaded.mimeType, - size: uploaded.width && uploaded.height ? { height: uploaded.height, width: uploaded.width } : undefined, - } - : undefined, - replyToMessageID: options.replyTo, - text: options.text, - mentions: options.mentions?.length ? options.mentions : undefined, - disableLinkPreview: options.noPreview || undefined, - }) - - if (!options.wait) { - return { - ...pending, - accepted: true, - state: 'accepted', - chatID: options.chatID, - hint: 'Desktop accepted the send request. Pass --wait to wait for the final message or failure.', - } - } - const message = await waitForMessage(client, options.chatID, pending.pendingMessageID, { - timeoutMs: options.waitTimeoutMs, - }) - return { - accepted: true, - state: 'resolved', - chatID: options.chatID, - pendingMessageID: pending.pendingMessageID, - message, - } -} diff --git a/packages/cli/src/lib/server-env.ts b/packages/cli/src/lib/server-env.ts new file mode 100644 index 00000000..79782e4c --- /dev/null +++ b/packages/cli/src/lib/server-env.ts @@ -0,0 +1,16 @@ +const SERVER_ENVIRONMENTS = ['local', 'dev', 'staging', 'prod'] as const + +export type ServerEnv = typeof SERVER_ENVIRONMENTS[number] + +export const SERVER_ENV_API_BASE_URLS: Record = { + local: 'https://api.beeper.localtest.me', + dev: 'https://api.beeper-dev.com', + staging: 'https://api.beeper-staging.com', + prod: 'https://api.beeper.com', +} + +export function normalizeServerEnv(value?: string): ServerEnv { + if (!value || value === 'prod' || value === 'production') return 'prod' + if (value === 'local' || value === 'dev' || value === 'staging') return value + throw new Error(`Unsupported server env "${value}". Expected local, dev, staging, or prod.`) +} diff --git a/packages/cli/src/lib/setup-login.ts b/packages/cli/src/lib/setup-login.ts index 41fab57f..62a47721 100644 --- a/packages/cli/src/lib/setup-login.ts +++ b/packages/cli/src/lib/setup-login.ts @@ -1,14 +1,16 @@ import { BeeperDesktop } from '@beeper/desktop-api' -import { evaluateReadiness } from './app-state.js' -import { isRegistrationRequired, promptText, promptYesNoDefaultYes, type AppLoginSuccess } from './app-api.js' +import type { LoginRegisterResponse, LoginResponseResponse } from '@beeper/desktop-api/resources/app/login' +import { evaluateReadiness, type Readiness } from './app-state.js' import { connectedAccountSummary } from './local-desktop.js' -import { saveTargetAuth, writeTarget, type AuthSource, type Target } from './targets.js' +import { promptConfirm, promptText } from './prompts.js' +import { publicTarget, writeTarget, type PublicTarget, type Target } from './targets.js' + +type AppLoginSuccess = LoginResponseResponse.Success | LoginRegisterResponse export type SetupLoginResult = { accounts: string[] - authSource?: AuthSource - readiness: Awaited> - target: Omit & { auth?: { source?: AuthSource; tokenType?: 'Bearer' } } + readiness: Readiness + target: PublicTarget } export async function startEmailSetup(target: Target, email: string): Promise<{ setupRequestID: string }> { @@ -20,19 +22,20 @@ export async function startEmailSetup(target: Target, email: string): Promise<{ export async function finishEmailSetup(target: Target, options: { code: string - email?: string + force?: boolean json?: boolean setupRequestID: string username?: string - yes?: boolean }): Promise { const client = setupClient(target) let output = await client.app.login.response({ setupRequestID: options.setupRequestID, response: options.code }) - if (isRegistrationRequired(output)) { - if ((options.json || !process.stdin.isTTY) && !options.yes) throw new Error('Registration requires --yes to accept the Beeper terms in non-interactive setup.') - const username = options.username ?? (options.json || !process.stdin.isTTY ? undefined : await promptUsername(output.usernameSuggestions)) + if ('registrationRequired' in output && output.registrationRequired === true) { + const nonInteractive = options.json || !process.stdin.isTTY + if (nonInteractive && !options.force) throw new Error('Registration requires --force to accept the Beeper terms in non-interactive setup.') + const fallback = output.usernameSuggestions?.[0] + const username = options.username ?? (nonInteractive ? undefined : (await promptText(`Username${fallback ? ` [${fallback}]` : ''}: `)) || fallback) if (!username) throw new Error('Registration requires --username.') - if (!options.yes && !await promptYesNoDefaultYes('Accept the Beeper terms and create this account?')) throw new Error('Registration cancelled.') + if (!options.force && !await promptConfirm('Accept the Beeper terms and create this account?', true)) throw new Error('Registration cancelled.') output = await client.app.login.register({ acceptTerms: true, leadToken: output.leadToken, @@ -43,12 +46,6 @@ export async function finishEmailSetup(target: Target, options: { return persistSetupLogin(target, output as AppLoginSuccess) } -export async function interactiveEmailSetup(target: Target, options: { email: string; json?: boolean; username?: string; yes?: boolean }): Promise { - const start = await startEmailSetup(target, options.email) - const code = await promptText('Email code: ') - return finishEmailSetup(target, { ...options, code, setupRequestID: start.setupRequestID }) -} - function setupClient(target: Target): BeeperDesktop { return new BeeperDesktop({ baseURL: target.baseURL, accessToken: 'setup-login-public-client', logLevel: 'warn' }) } @@ -56,24 +53,11 @@ function setupClient(target: Target): BeeperDesktop { async function persistSetupLogin(target: Target, data: AppLoginSuccess): Promise { const token = data.matrix?.accessToken if (!token) throw new Error('Setup did not return a Matrix access token.') - const auth = { accessToken: token, source: 'manual' as AuthSource, tokenType: 'Bearer' as const } - await writeTarget(target) - await saveTargetAuth(target, auth) + const auth = { accessToken: token, source: 'email' as const, tokenType: 'Bearer' as const } + await writeTarget({ ...target, auth }) const [readiness, accounts] = await Promise.all([ evaluateReadiness({ baseURL: target.baseURL, target: target.id, token }), connectedAccountSummary(target, auth).catch(() => []), ]) - return { accounts, authSource: auth.source, readiness, target: publicTarget({ ...target, auth }) } -} - -function publicTarget(target: Target): Omit & { auth?: { source?: AuthSource; tokenType?: 'Bearer' } } { - const { auth, ...rest } = target - return { ...rest, auth: auth ? { source: auth.source, tokenType: auth.tokenType } : undefined } -} - -async function promptUsername(suggestions: string[] | undefined): Promise { - const fallback = suggestions?.[0] - const suffix = fallback ? ` [${fallback}]` : '' - const value = await promptText(`Username${suffix}: `) - return value || fallback || '' + return { accounts, readiness, target: publicTarget({ ...target, auth }) } } diff --git a/packages/cli/src/lib/target-status.ts b/packages/cli/src/lib/target-status.ts index 0b9435f2..982fbbd6 100644 --- a/packages/cli/src/lib/target-status.ts +++ b/packages/cli/src/lib/target-status.ts @@ -1,11 +1,11 @@ -import type { Target } from './targets.js' +import type { ManagedTargetType, Target } from './targets.js' import { checkInstallationUpdate, readInstallations } from './installations.js' -export type TargetLiveStatus = { +type TargetLiveStatus = { reachable: boolean version?: string bundleID?: string - actualType?: 'desktop' | 'server' + actualType?: ManagedTargetType error?: string update?: { available: boolean @@ -14,7 +14,7 @@ export type TargetLiveStatus = { } } -export async function targetLiveStatus(target: Pick): Promise { +export async function targetLiveStatus(target: Pick): Promise { try { const response = await fetch(new URL('/v1/info', target.baseURL), { signal: AbortSignal.timeout(3000) }) if (!response.ok) return { reachable: false, error: `${response.status} ${response.statusText}` } @@ -36,8 +36,8 @@ export async function targetLiveStatus(target: Pick undefined) : undefined @@ -49,7 +49,9 @@ export async function targetLiveStatus(target: Pick, -): 'desktop' | 'server' | undefined { - if (target.type === 'server' && target.managed && info.server?.hostname === '127.0.0.1' && info.server.remote_access === false) return 'server' + target: Pick, +): ManagedTargetType | undefined { + if (target.type === 'server' && target.dataDir && info.server?.hostname === '127.0.0.1' && info.server.remote_access === false) return 'server' return typeFromBundleID(info.app?.bundle_id) } -function typeFromBundleID(bundleID?: string): 'desktop' | 'server' | undefined { +function typeFromBundleID(bundleID?: string): ManagedTargetType | undefined { if (!bundleID) return undefined if (bundleID.includes('.server')) return 'server' if (bundleID.includes('.desktop')) return 'desktop' diff --git a/packages/cli/src/lib/targets.ts b/packages/cli/src/lib/targets.ts index e16730c5..814c4b8e 100644 --- a/packages/cli/src/lib/targets.ts +++ b/packages/cli/src/lib/targets.ts @@ -1,10 +1,10 @@ -import { constants as fsConstants } from 'node:fs' -import { access, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { notFound } from './errors.js' +import { AbortError, ExitCodes } from './errors.js' +import { normalizeServerEnv } from './server-env.js' -export type AuthSource = 'desktop-db' | 'desktop-cache' | 'desktop-oauth' | 'remote-oauth' | 'manual' +export type AuthSource = 'desktop-db' | 'desktop-oauth' | 'email' | 'remote-oauth' export type StoredAuth = { accessToken: string @@ -15,55 +15,39 @@ export type StoredAuth = { tokenType: 'Bearer' } +export type ManagedTargetType = 'desktop' | 'server' + export type Target = { id: string - type: 'desktop' | 'server' | 'remote' + type: ManagedTargetType | 'remote' name?: string baseURL: string auth?: StoredAuth - managed?: boolean dataDir?: string profile?: string - runtime?: { - install?: 'desktop' | 'server' - dataDir?: string - port?: number - } serverEnv?: string port?: number } -export type ManagedTargetType = 'desktop' | 'server' +export type PublicTarget = Omit & { auth?: Pick } export type Config = { defaultTarget?: string defaultAccount?: string - baseURL?: string - auth?: StoredAuth } -const defaultPort = 23_373 -const defaultBaseURL = `http://127.0.0.1:${defaultPort}` +export const defaultDesktopPort = 23_373 +export const defaultDesktopBaseURL = `http://127.0.0.1:${defaultDesktopPort}` export const builtInDesktopTargetID = 'desktop' -export const customTargetID = 'custom' +const customTargetID = 'custom' export function beeperDir(): string { return process.env.BEEPER_CLI_CONFIG_DIR ?? join(homedir(), '.beeper') } export const configPath = () => join(beeperDir(), 'config.json') -export const cachePath = () => join(beeperDir(), 'cache.json') -export const targetsDir = () => join(beeperDir(), 'targets') -export const pluginsDir = () => join(beeperDir(), 'plugins') -export const profileDataDir = (type: ManagedTargetType, id: string) => join(beeperDir(), 'profiles', type, id) - -export async function ensureBeeperDirs(): Promise { - await Promise.all([ - mkdir(targetsDir(), { recursive: true }), - mkdir(pluginsDir(), { recursive: true }), - mkdir(join(beeperDir(), 'profiles'), { recursive: true }), - ]) -} +const targetsDir = () => join(beeperDir(), 'targets') +const profileDataDir = (type: ManagedTargetType, id: string) => join(beeperDir(), 'profiles', type, id) export async function readConfig(): Promise { try { @@ -74,7 +58,7 @@ export async function readConfig(): Promise { } } -export async function writeConfig(config: Config): Promise { +async function writeConfig(config: Config): Promise { await mkdir(dirname(configPath()), { recursive: true }) await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) } @@ -85,29 +69,7 @@ export async function updateConfig(update: (config: Config) => Config | Promise< return next } -export async function resetConfig(): Promise { - await rm(configPath(), { force: true }) -} - -export async function updateTargetCache(target: Target, data: Record): Promise { - let cache: { targets?: Record } = {} - try { - cache = JSON.parse(await readFile(cachePath(), 'utf8')) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - await mkdir(dirname(cachePath()), { recursive: true }) - await writeFile(cachePath(), `${JSON.stringify({ - ...cache, - targets: { - ...cache.targets, - [target.id]: { ...data, updatedAt: new Date().toISOString() }, - }, - }, null, 2)}\n`, { mode: 0o600 }) -} - export async function listTargets(): Promise { - await ensureBeeperDirs() const files = await readdir(targetsDir()).catch(() => []) const targets = await Promise.all(files.filter(file => file.endsWith('.json')).map(async file => { try { @@ -129,107 +91,79 @@ export async function readTarget(id: string): Promise { } export async function writeTarget(target: Target): Promise { - await ensureBeeperDirs() + await mkdir(targetsDir(), { recursive: true }) await writeFile(targetPath(target.id), `${JSON.stringify(target, null, 2)}\n`, { mode: 0o600 }) } export async function removeTarget(id: string): Promise { await rm(targetPath(id), { force: true }) await updateConfig(config => { - const next = config.defaultTarget === id ? { ...config, defaultTarget: undefined } : config - if (id === builtInDesktopTargetID) return { ...next, auth: undefined, baseURL: undefined } - return next + return config.defaultTarget === id ? { ...config, defaultTarget: undefined } : config }) } -export async function saveTargetAuth(target: Target, auth: StoredAuth): Promise { - if (target.id === customTargetID) { - await updateConfig(config => ({ ...config, baseURL: target.baseURL, auth })) - return - } - await writeTarget({ ...target, auth }) -} - -export async function clearTargetAuth(target: Target): Promise { - if (target.id === customTargetID) { - await updateConfig(config => ({ ...config, auth: undefined })) - return - } - await writeTarget({ ...target, auth: undefined }) - if (target.id === builtInDesktopTargetID) await updateConfig(config => ({ ...config, auth: undefined })) -} - export async function resolveTarget(options: { target?: string; baseURL?: string } = {}): Promise { if (options.baseURL) return { id: customTargetID, type: 'desktop', baseURL: options.baseURL } - const envTarget = process.env.BEEPER_TARGET const config = await readConfig() - const targetID = options.target ?? envTarget ?? config.defaultTarget + const targetID = options.target ?? config.defaultTarget if (targetID) { const target = await readTarget(targetID) - if (!target && targetID === builtInDesktopTargetID) return builtInDesktopTarget(config) - if (!target) throw notFound(`Unknown Beeper target "${targetID}". Run \`beeper targets list\`.`) - return withConfigAuth(target, config) + if (!target && targetID === builtInDesktopTargetID) return builtInDesktopTarget() + if (!target) { + throw new AbortError(`Unknown Beeper target "${targetID}". Run \`beeper targets list\`.`, ExitCodes.NotFound, undefined, 'not_found') + } + return target } const targets = await listTargets() - if (targets.length === 1 && targets[0]) return withConfigAuth(targets[0], config) + if (targets.length === 1 && targets[0]) return targets[0] const desktopTarget = await readTarget(builtInDesktopTargetID) - if (desktopTarget) return withConfigAuth(desktopTarget, config) - return builtInDesktopTarget(config) + if (desktopTarget) return desktopTarget + return builtInDesktopTarget() +} + +export async function createDefaultDesktopTarget(baseURL = defaultDesktopBaseURL): Promise { + const target = builtInDesktopTarget(baseURL) + await writeTarget(target) + await updateConfig(config => ({ ...config, defaultTarget: config.defaultTarget ?? target.id })) + return target } -function builtInDesktopTarget(config: Config): Target { +function builtInDesktopTarget(baseURL = defaultDesktopBaseURL): Target { return { id: builtInDesktopTargetID, type: 'desktop', name: 'Beeper Desktop', - baseURL: process.env.BEEPER_DESKTOP_BASE_URL || config.baseURL || defaultBaseURL, - auth: config.auth, + baseURL, } } -function withConfigAuth(target: Target, config: Config): Target { - if (target.auth || target.type !== 'desktop' || !config.auth) return target - if (config.baseURL && config.baseURL !== target.baseURL) return target - return { ...target, auth: config.auth } -} - function normalizeLocalTarget(target: Target): Target { - if (!target.managed || target.type === 'remote') return target - const port = target.port ?? target.runtime?.port - if (!port) return target - return { ...target, baseURL: `http://127.0.0.1:${port}` } + if (!target.dataDir || target.type === 'remote') return target + return target.port ? { ...target, baseURL: `http://127.0.0.1:${target.port}` } : target } export async function createProfileTarget(type: ManagedTargetType, id: string, options: { serverEnv?: string; port?: number } = {}): Promise { - const serverEnv = options.serverEnv ?? 'production' + const serverEnv = normalizeServerEnv(options.serverEnv) const port = options.port ?? await nextPort() + const dataDir = profileDataDir(type, id) const target: Target = { id, type, name: id, baseURL: `http://127.0.0.1:${port}`, - managed: true, - dataDir: profileDataDir(type, id), + dataDir, profile: id, - runtime: { - install: type, - dataDir: profileDataDir(type, id), - port, - }, serverEnv, port, } - await mkdir(target.dataDir!, { recursive: true }) + await mkdir(dataDir, { recursive: true }) await writeTarget(target) return target } -export async function getAccessToken(target?: Target): Promise { - return process.env.BEEPER_ACCESS_TOKEN || target?.auth?.accessToken || (await resolveTarget()).auth?.accessToken -} - -export async function getBaseURL(override?: string): Promise { - return (await resolveTarget({ baseURL: override })).baseURL +export function publicTarget(target: Target): PublicTarget { + const { auth, ...rest } = target + return { ...rest, auth: auth ? { source: auth.source, tokenType: auth.tokenType } : undefined } } function targetPath(id: string): string { @@ -238,17 +172,8 @@ function targetPath(id: string): string { async function nextPort(): Promise { const used = new Set((await listTargets()).map(target => target.port).filter((port): port is number => typeof port === 'number')) - for (let port = defaultPort + 1; port < defaultPort + 200; port++) { + for (let port = defaultDesktopPort + 1; port < defaultDesktopPort + 200; port++) { if (!used.has(port)) return port } throw new Error('No available default port for a new Beeper target.') } - -export async function pathExists(path: string): Promise { - try { - await access(path, fsConstants.F_OK) - return true - } catch { - return false - } -} diff --git a/packages/cli/src/lib/update-banner.ts b/packages/cli/src/lib/update-banner.ts deleted file mode 100644 index a065ba2f..00000000 --- a/packages/cli/src/lib/update-banner.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' - -export type UpdateAvailability = { - current: string - latest?: string - available: boolean -} - -/** - * Read the cached dist-tag info that @oclif/plugin-warn-if-update-available writes. - * Synchronous-style: returns immediately from disk; never hits the network. - * Returns undefined when the cache is missing or unreadable — caller should treat - * that as "no banner to show". - */ -export async function readUpdateAvailability(options: { cacheDir: string; currentVersion: string; tag?: string }): Promise { - try { - const raw = await readFile(join(options.cacheDir, 'version'), 'utf8') - const parsed = JSON.parse(raw) as Record - const tag = options.tag ?? 'latest' - const latest = parsed[tag] - if (!latest) return { current: options.currentVersion, available: false } - const available = stripPrerelease(latest) !== stripPrerelease(options.currentVersion) && isGreater(latest, options.currentVersion) - return { current: options.currentVersion, latest, available } - } catch { - return undefined - } -} - -export function formatUpdateFooter(availability: UpdateAvailability | undefined): string | undefined { - if (!availability?.available || !availability.latest) return undefined - return `↑ beeper-cli ${availability.latest} available — beeper update` -} - -function stripPrerelease(v: string): string { - return v.split('-')[0] ?? v -} - -function isGreater(a: string, b: string): boolean { - const aa = stripPrerelease(a).split('.').map(n => Number(n) || 0) - const bb = stripPrerelease(b).split('.').map(n => Number(n) || 0) - for (let i = 0; i < Math.max(aa.length, bb.length); i++) { - const x = aa[i] ?? 0 - const y = bb[i] ?? 0 - if (x !== y) return x > y - } - return false -} diff --git a/packages/cli/src/lib/wait.ts b/packages/cli/src/lib/wait.ts deleted file mode 100644 index 12d0ad50..00000000 --- a/packages/cli/src/lib/wait.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { setTimeout as sleep } from 'node:timers/promises' - -export type WaitOptions = { - intervalMs?: number - timeoutMs?: number -} - -export async function waitForMessage(client: any, chatID: string, pendingMessageID: string, options: WaitOptions = {}) { - const started = Date.now() - const timeoutMs = options.timeoutMs ?? 30_000 - const intervalMs = options.intervalMs ?? 750 - let lastError: unknown - - while (Date.now() - started < timeoutMs) { - try { - return await client.messages.retrieve(pendingMessageID, { chatID }) - } catch (error) { - lastError = error - await sleep(intervalMs) - } - } - - throw new Error(`Timed out waiting for ${pendingMessageID}${lastError instanceof Error ? `: ${lastError.message}` : ''}`) -} diff --git a/packages/cli/src/plugin-sdk.ts b/packages/cli/src/plugin-sdk.ts deleted file mode 100644 index 3e36b6f7..00000000 --- a/packages/cli/src/plugin-sdk.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { BeeperDesktop } from '@beeper/desktop-api' - -export { Args, Command, Flags, ux } from '@oclif/core' -export { BeeperCommand, ensureWritable, writeEvent, isQuiet } from './lib/command.js' -export { - AbortError, - BugError, - CLIError, - ExitCodes, - ambiguous, - authRequired, - notFound, - notReady, - usageError, - type ExitCode, -} from './lib/errors.js' -export { - configPath, - getAccessToken, - getBaseURL, - readConfig, - resolveTarget, - resetConfig, - updateConfig, - writeConfig, - type Config, - type StoredAuth, - type Target, -} from './lib/targets.js' -export { createClient as createBeeperClient, requireToken } from './lib/client.js' -export { - collectPage, - emptyState, - printConfig, - printData, - printFailure, - printIDs, - printList, - printSuccess, - startStream, - type OutputFormat, - type Suggestion, -} from './lib/output.js' -export { - resolveAccountID, - resolveAccountIDs, - resolveChatID, - listAccountIDs, - userQueryFromInput, - type AccountResolutionOptions, - type ChatResolutionOptions, -} from './lib/resolve.js' -export { appRequest } from './lib/app-api.js' -export { - confirmSuggestion, - declineWithExit127, - levenshtein, - rankSuggestions, - type Suggestion as DidYouMeanSuggestion, -} from './lib/did-you-mean.js' -export { - formatUpdateFooter, - readUpdateAvailability, - type UpdateAvailability, -} from './lib/update-banner.js' - -export type BeeperClient = BeeperDesktop - -export type BeeperPluginContext = { - baseURL?: string - debug?: boolean - json?: boolean - readOnly?: boolean - quiet?: boolean -} diff --git a/packages/cli/src/types/qrcode.d.ts b/packages/cli/src/types/qrcode.d.ts deleted file mode 100644 index fcc8e975..00000000 --- a/packages/cli/src/types/qrcode.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare module 'qrcode' { - export type TerminalQRCodeOptions = { - errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H' - small?: boolean - type: 'terminal' - } - - const QRCode: { - toString(data: string, options: TerminalQRCodeOptions): Promise - } - - export default QRCode -} diff --git a/packages/cli/test/account-login.test.ts b/packages/cli/test/account-login.test.ts index 5f652ecc..4b6eaba8 100644 --- a/packages/cli/test/account-login.test.ts +++ b/packages/cli/test/account-login.test.ts @@ -1,11 +1,8 @@ -import { afterEach, describe, expect, it, mock } from 'bun:test' -import { runGuidedAccountLogin, setWebViewConstructorForTest } from '../src/lib/account-login.js' +import { describe, expect, it, mock } from 'bun:test' +import { runGuidedAccountLogin } from '../src/lib/account-login.js' type Session = Parameters[2] - -afterEach(() => { - setWebViewConstructorForTest(undefined) -}) +type SubmitStep = (stepID: string, body: unknown) => Promise describe('runGuidedAccountLogin', () => { it('submits display steps interactively and returns the completed session', async () => { @@ -143,8 +140,6 @@ describe('runGuidedAccountLogin', () => { } } - setWebViewConstructorForTest(FakeWebView) - const cookieStep = session({ currentStep: { type: 'cookies', @@ -165,6 +160,7 @@ describe('runGuidedAccountLogin', () => { const result = await runGuidedAccountLogin(fakeClient(submit), 'googlechat', cookieStep, { nonInteractive: true, + webViewConstructor: FakeWebView, webview: true, webviewBackend: 'chrome', webviewTimeoutMs: 100, @@ -195,7 +191,7 @@ function session(overrides: Partial = {}): Session { } as Session } -function fakeClient(submit: ReturnType) { +function fakeClient(submit: SubmitStep) { return { bridges: { loginSessions: { diff --git a/packages/cli/test/cli-smoke.ts b/packages/cli/test/cli-smoke.ts index f10fb246..75afdf2f 100644 --- a/packages/cli/test/cli-smoke.ts +++ b/packages/cli/test/cli-smoke.ts @@ -1,15 +1,15 @@ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' -import { existsSync, readdirSync, rmSync } from 'node:fs' +import { existsSync, rmSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { commandManifest } from '../dist/lib/manifest.js' -import { resolveAccountID, resolveAccountIDs, resolveChatID } from '../dist/lib/resolve.js' -import { downloadURLFor, feedURLFor, normalizeInstallRequest } from '../dist/lib/installations.js' const root = fileURLToPath(new URL('..', import.meta.url)) -const configDir = '/tmp/beeper-cli-test' -const run = (...args) => spawnSync(process.execPath, ['./bin/dev.js', ...args], { +const configDir = '/tmp/beeper-cli-smoke' +rmSync(configDir, { recursive: true, force: true }) +rmSync('/tmp/beeper-cli-smoke-home', { recursive: true, force: true }) + +const run = (...args: string[]) => spawnSync('bun', ['./bin/dev.js', ...args], { cwd: root, encoding: 'utf8', env: { @@ -18,299 +18,563 @@ const run = (...args) => spawnSync(process.execPath, ['./bin/dev.js', ...args], }, }) -const ok = (...args) => { +const ok = (...args: string[]) => { const result = run(...args) assert.equal(result.status, 0, `${args.join(' ')} failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`) return result.stdout } -const expectedCommands = [ - 'setup', - 'install desktop', - 'install server', - 'targets list', - 'bridges list', - 'bridges show', - 'targets add desktop', - 'targets add server', - 'targets add remote', - 'targets use', - 'targets show', - 'targets status', - 'targets start', - 'targets stop', - 'targets restart', - 'targets logs', - 'targets enable', - 'targets disable', - 'targets remove', - 'targets tunnel', - 'auth status', - 'auth logout', - 'auth email start', - 'auth email response', - 'verify', - 'verify status', - 'verify approve', - 'verify recovery-key', - 'verify reset-recovery-key', - 'verify cancel', - 'verify list', - 'verify start', - 'verify show', - 'verify sas', - 'verify sas-confirm', - 'verify qr-scan', - 'verify qr-confirm', - 'accounts list', - 'accounts add', - 'accounts show', - 'accounts remove', - 'accounts use', - 'chats list', - 'chats search', - 'chats show', - 'chats start', - 'chats archive', - 'chats unarchive', - 'chats pin', - 'chats unpin', - 'chats mute', - 'chats unmute', - 'chats mark-read', - 'chats mark-unread', - 'chats priority', - 'chats notify-anyway', - 'chats rename', - 'chats description', - 'chats avatar', - 'chats draft', - 'chats disappear', - 'chats remind', - 'chats unremind', - 'chats focus', - 'messages list', - 'messages search', - 'messages show', - 'messages context', - 'messages edit', - 'messages delete', - 'messages export', - 'send text', - 'send file', - 'send react', - 'send sticker', - 'send unreact', - 'send voice', - 'presence', - 'contacts list', - 'contacts search', - 'contacts show', - 'media download', - 'export', - 'watch', - 'rpc', - 'man', - 'doctor', - 'status', - 'docs', - 'version', - 'completion', - 'plugins', - 'plugins available', - 'update', - 'config get', - 'config set', - 'config path', - 'config reset', - 'api get', - 'api post', - 'api request', -] - -const internalCommands = new Set(['autocomplete']) -const commandFiles = listCommandFiles(join(root, 'src/commands')).filter(file => !internalCommands.has(fileToCommand(file))) -const commandNames = commandFiles.map(file => fileToCommand(file)).sort() -const manifestNames = commandManifest.map(item => item.command).sort() -// First-party commands shipped by a separate plugin package (not present in src/commands here). -const pluginShippedCommands = new Set(['targets tunnel']) - -assert.deepEqual(commandManifest.map(item => item.command), expectedCommands, 'command manifest must be the nuclear public surface') -assert.deepEqual(manifestNames.filter(name => !pluginShippedCommands.has(name)), commandNames, 'command manifest must match src/commands (excluding plugin-shipped commands)') -assert.equal(new Set(manifestNames).size, manifestNames.length, 'command manifest must not contain duplicates') - -const help = ok('--help') -assert.match(help, /\btargets\b/, 'help should expose targets') -assert.match(help, /\bchats\b/, 'help should expose chats') -assert.match(help, /\bmessages\b/, 'help should expose messages') -// Anchor to the column-2 command/topic listing so we don't false-positive on the word -// "commands" inside another command's summary (e.g. rpc). -assert.doesNotMatch(help, /^\s{2,}(profile|commands|llm|login|logout)\s/m, 'help must not expose deleted root/internal commands') -assert.match(help, /\bplugins\b/, 'help should expose plugin management') -assert.doesNotMatch(help, /^\s{2,}autocomplete\s/m, 'help should expose completion instead of raw autocomplete') -assert.match(help, /\bbridges\b/, 'help should expose bridges') -assert.match(help, /\bverify\b/, 'help should expose verification') -assert.doesNotMatch(help, /\bassets\b|\bapp\b/, 'help must not expose old API namespaces') - -for (const command of expectedCommands) { - // Plugin-shipped commands aren't loaded unless the plugin is installed. - if (pluginShippedCommands.has(command)) continue - ok(...command.split(' '), '--help') -} +const runEnv = (env: Record, ...args: string[]) => spawnSync('bun', ['./bin/dev.js', ...args], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: configDir, + ...env, + }, +}) -assert.match(ok('send', 'text', '--help'), /--to/, 'send text should use --to') -assert.match(ok('send', 'text', '--help'), /--message/, 'send text should use --message') -assert.match(ok('send', 'file', '--help'), /--file/, 'send file should use --file') -assert.match(ok('send', 'file', '--help'), /--caption/, 'send file should use --caption') -assert.match(ok('messages', 'list', '--help'), /--chat/, 'messages list should use --chat') -assert.doesNotMatch(ok('chats', 'mute', '--help'), /--duration/, 'chats mute must not expose duration until API supports it') -assert.match(ok('chats', 'list', '--help'), /--account=\.\.\./, 'account filters must stay local') -assert.doesNotMatch(ok('status', '--help'), /--account/, '--account must not be global') -const setupHelp = ok('setup', '--help') -assert.match(setupHelp, /--local/, 'setup should expose local Desktop direct setup') -assert.match(setupHelp, /--oauth/, 'setup should expose OAuth setup') -assert.match(setupHelp, /--remote/, 'setup should expose remote setup shortcut') -assert.match(setupHelp, /--server/, 'setup should expose Server setup shortcut') -assert.match(setupHelp, /--desktop/, 'setup should expose Desktop setup shortcut') -assert.match(setupHelp, /--email/, 'setup should expose email setup start') -assert.doesNotMatch(setupHelp, /--code|--accept-terms/, 'setup must not accept OTP or terms flags in the first command') - -const man = JSON.parse(ok('man', '--json')) -assert.equal(man.success, true) -assert.equal(man.error, null) -assert.deepEqual(man.data.map(item => item.command), expectedCommands) - -const availablePlugins = JSON.parse(ok('plugins', 'available', '--json')) -assert.equal(availablePlugins.success, true) -assert.equal(availablePlugins.data[0].name, '@beeper/cli-plugin-cloudflare') -assert.equal(availablePlugins.data[0].status, 'not installed') -assert.deepEqual(availablePlugins.data[0].commands, ['targets tunnel']) -assert.match(ok('chats', 'list', '--help'), /preferred chat selectors/, 'chats list --ids should describe preferred selectors') -assert.match(ok('chats', '--help'), /preferred chat selectors/, 'chats should alias chats list') -assert.match(ok('accounts', 'chats', '--help'), /preferred chat selectors/, 'accounts chats should alias chats list') -assert.match(ok('accounts', '--help'), /List connected accounts/, 'accounts should alias accounts list') -assert.match(ok('bridges', 'list', '--help'), /connect chat accounts/, 'bridges list should expose bridge catalog') -assert.match(ok('bridges', '--help'), /connect chat accounts/, 'bridges should alias bridges list') -assert.match(ok('verify', '--help'), /device verification/, 'verify should be a root command') -assert.throws(() => ok('auth', 'verify', '--help'), /failed/, 'auth verify must not remain public') -assert.throws(() => ok('messages', 'react', '--help'), /failed/, 'messages react must not remain public') +assert.match(ok('--help'), /Usage: beeper /) +assert.match(ok('--help'), /targets add/) +assert.match(ok('--help'), /targets runtime start\s+Start a local target runtime/) +assert.match(ok('--help'), /targets runtime stop\s+Stop a local server runtime/) +assert.match(ok('--help'), /targets runtime restart\s+Restart a local server runtime/) +assert.match(ok('--help'), /targets tunnel/) +assert.match(ok('--help'), /use account\s+Select the default account/) +assert.match(ok('--help'), /use target\s+Select the default target/) +assert.match(ok('--help'), /remove account\s+Remove an account/) +assert.match(ok('--help'), /remove target\s+Remove a target/) +assert.match(ok('--help'), /auth email start\s+Start email sign-in for a target/) +assert.match(ok('--help'), /auth email response\s+Finish email sign-in for a target/) +assert.match(ok('--help'), /install desktop\s+Install Beeper Desktop locally/) +assert.match(ok('--help'), /install server\s+Install Beeper Server locally/) +assert.match(ok('--help'), /accounts list/) +assert.match(ok('--help'), /accounts add/) +assert.match(ok('--help'), /messages list/) +assert.match(ok('--help'), /chats archive\s+Archive or unarchive a chat/) +assert.match(ok('--help'), /chats disappear\s+Set a disappearing-message timer/) +assert.match(ok('--help'), /chats priority\s+Set chat priority/) +assert.match(ok('--help'), /chats focus\s+Focus a chat in Beeper/) +assert.match(ok('--help'), /chats notify-anyway\s+Notify a chat anyway/) +assert.match(ok('--help'), /messages context/) +assert.match(ok('--help'), /messages edit\s+Edit a message/) +assert.match(ok('--help'), /messages delete\s+Delete a message/) +assert.match(ok('--help'), /api request/) +assert.match(ok('--help'), /send text\s+Send a text message/) +assert.match(ok('--help'), /send file\s+Send a file message/) +assert.match(ok('--help'), /send sticker\s+Send a sticker/) +assert.match(ok('--help'), /send voice\s+Send a voice note/) +assert.match(ok('--help'), /send react\s+Send or remove a reaction/) +assert.match(ok('--help'), /send presence\s+Send a typing indicator/) +assert.match(ok('--help'), /resolve account\s+Resolve an account selector/) +assert.match(ok('--help'), /resolve bridge\s+Resolve a bridge selector/) +assert.match(ok('--help'), /resolve chat\s+Resolve a chat selector/) +assert.match(ok('--help'), /resolve contact\s+Resolve a contact selector/) +assert.match(ok('--help'), /resolve target\s+Resolve a target selector/) +assert.match(ok('--help'), /watch/) +assert.match(ok('--help'), /media download/) +assert.match(ok('--help'), /export\s+Export accounts/) +assert.match(ok('--help'), /doctor\s+Run diagnostics/) +assert.match(ok('--help'), /exit-codes\s+Print stable exit codes/) +assert.match(ok('--help'), /Config:\n\n file: /) +assert.match(ok('--help'), /config path\s+Print config file path/) +assert.match(ok('--help'), /config set\s+Set a config value/) +assert.match(ok('--help'), /--full\s+Disable truncation in human table output/) +assert.match(ok('--help'), /--read-only \(\$BEEPER_READONLY\)/) +assert.match(ok('--version', '--json'), /"name": "beeper-cli"/) +assert.match(ok('st'), /READINESS/) +assert.match(ok('doctor'), /SELECTED TARGET/) +assert.match(ok('targets', 'ls'), /ID\s+DEFAULT\s+TYPE/) +assert.equal(existsSync(join(root, 'docs', 'commands', 'README.md')), true) +assert.equal(existsSync(join(root, 'docs', 'commands', 'send-text.md')), true) +assert.match(ok('__complete', '--cword', '2', '--', 'beeper', 'targets', 'l'), /list/) +assert.match(ok('__complete', '--cword', '3', '--', 'beeper', 'send', 'text', '--m'), /--message-file/) +assert.match(ok('completion', 'bash'), /__complete/) +assert.match(ok('setup', '--help'), /--remote/) +assert.match(ok('targets', 'tunnel', '--help'), /--url-only/) +assert.match(ok('accounts', 'add', '--help'), /--webview-backend/) +assert.match(ok('watch', '--help'), /--include-type/) +assert.match(ok('send', 'presence', '--help'), /--state/) +assert.match(ok('media', 'download', '--help'), /--out/) +assert.match(ok('export', '--help'), /--no-attachments/) -rmSync(configDir, { recursive: true, force: true }) -let result = run('targets', 'add', 'remote', 'work', 'http://127.0.0.1:23373', '--default', '--json') -assert.equal(result.status, 0, result.stderr) -let envelope = JSON.parse(result.stdout) -assert.equal(envelope.success, true) -assert.equal(envelope.data.id, 'work') -assert.equal(envelope.data.type, 'remote') +const version = JSON.parse(ok('version', '--json')) +assert.equal(version.name, 'beeper-cli') +assert.match(version.version, /^\d+\.\d+\.\d+/) + +let result = run('version', '--json', '--plain') +assert.equal(result.status, 2) +let errorPayload = JSON.parse(result.stderr) +assert.equal(errorPayload.error.code, 'usage_error') +assert.match(errorPayload.error.message, /cannot combine --json and --plain/) + +result = run('messages', 'list', '--limit', '12abc', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.equal(errorPayload.error.code, 'usage_error') +assert.match(errorPayload.error.message, /--limit must be an integer/) + +result = run('messages', 'list', '--limit=', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.equal(errorPayload.error.code, 'usage_error') +assert.match(errorPayload.error.message, /--limit must be an integer/) + +result = run('messages', 'list', '--limit', '1e2', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.equal(errorPayload.error.code, 'usage_error') +assert.match(errorPayload.error.message, /--limit must be an integer/) + +result = run('version', '--timeout', 'bogus', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.equal(errorPayload.error.code, 'usage_error') +assert.match(errorPayload.error.message, /--timeout must be a duration/) + +let payload = JSON.parse(ok('targets', 'list', '--json')) +assert.equal(payload[0].id, 'desktop') +assert.equal(existsSync(join(configDir, 'config.json')), false) +assert.equal(existsSync(join(configDir, 'targets')), false) + +payload = JSON.parse(ok('--home', '/tmp/beeper-cli-smoke-home', 'targets', 'list', '--json')) +assert.equal(payload[0].id, 'desktop') +assert.equal(existsSync('/tmp/beeper-cli-smoke-home'), false) + +payload = JSON.parse(ok('config', 'path', '--json')) +assert.equal(payload.path, join(configDir, 'config.json')) + +payload = JSON.parse(ok('config', 'keys', '--json')) +assert.deepEqual(payload, ['defaultTarget', 'defaultAccount']) + +payload = JSON.parse(ok('config', 'set', 'default-target', 'desktop', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'config.set') +assert.equal(payload.request.key, 'defaultTarget') + +result = run('--read-only', 'config', 'set', 'default-target', 'desktop', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /read-only mode/) + +result = runEnv({ BEEPER_READONLY: '1' }, 'config', 'set', 'default-target', 'desktop', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /read-only mode/) + +payload = JSON.parse(runEnv({ BEEPER_READONLY: '1' }, '--no-read-only', 'config', 'set', 'default-target', 'desktop', '--dry-run', '--json').stdout) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'config.set') + +payload = JSON.parse(ok('use', 'target', 'desktop', '--json')) +assert.equal(payload.defaultTarget, 'desktop') + +payload = JSON.parse(ok('config', 'get', 'defaultTarget', '--json')) +assert.equal(payload.key, 'defaultTarget') +assert.equal(payload.value, 'desktop') + +payload = JSON.parse(ok('config', 'list', '--json')) +assert.equal(payload.defaultTarget, 'desktop') +assert.equal(payload.defaultAccount, null) + +payload = JSON.parse(ok('config', 'set', 'default-account', 'matrix', '--json')) +assert.equal(payload.saved, true) +assert.equal(payload.key, 'defaultAccount') +assert.equal(payload.value, 'matrix') + +payload = JSON.parse(ok('config', 'show', 'default_account', '--json')) +assert.equal(payload.value, 'matrix') + +payload = JSON.parse(ok('config', 'rm', 'default-account', '--json')) +assert.equal(payload.removed, true) +assert.equal(payload.value, null) + +result = run('--safety-profile', 'readonly', 'use', 'target', 'desktop', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) + +result = run('--safety-profile', 'readonly', 'targets', 'tunnel', 'desktop', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) + +result = run('--safety-profile', 'readonly', 'use', 'account', 'matrix', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) + +result = run('--safety-profile', 'readonly', 'remove', 'target', 'desktop', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) -result = run('targets', 'list', '--json') +result = run('--safety-profile', 'readonly', 'send', 'text', '--to', 'chat', '--message', 'hello', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) + +result = run('--safety-profile', 'readonly', 'chats', 'archive', '--chat', 'chat', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) + +result = run('--safety-profile', 'readonly', 'accounts', 'add', 'matrix', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) + +result = run('--safety-profile', 'readonly', 'send', 'presence', '--to', 'chat', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) + +result = run('--safety-profile', 'readonly', 'media', 'download', 'mxc://server/file', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) + +result = run('--safety-profile', 'readonly', 'export', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /blocked by safety profile "readonly"/) + +result = run('targets', 'add', 'desktop', 'http://127.0.0.1:23374', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /reserved/) + +result = run('targets', 'add', 'work', 'http://127.0.0.1:23373', '--default', '--json') assert.equal(result.status, 0, result.stderr) -envelope = JSON.parse(result.stdout) -assert.equal(envelope.success, true) -assert(envelope.data.some(item => item.id === 'work' && item.default)) +payload = JSON.parse(result.stdout) +assert.equal(payload.target.id, 'work') +assert.equal(payload.target.type, 'remote') + +payload = JSON.parse(ok('use', 'target', 'work', '--json')) +assert.equal(payload.defaultTarget, 'work') + +payload = JSON.parse(ok('targets', 'use', 'desktop', '--json')) +assert.equal(payload.defaultTarget, 'desktop') + +payload = JSON.parse(ok('targets', 'use', 'work', '--json')) +assert.equal(payload.defaultTarget, 'work') + +payload = JSON.parse(ok('status', '--json')) +assert.equal(payload.auth.authenticated, false) +assert.equal(payload.target.id, 'work') + +payload = JSON.parse(ok('auth', 'logout', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'auth.logout') + +payload = JSON.parse(ok('auth', 'email', 'start', '--email', 'qa@example.invalid', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'auth.email.start') + +payload = JSON.parse(ok('auth', 'email', 'response', '--setup-request-id', 'setup-1', '--code', '123456', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'auth.email.response') + +payload = JSON.parse(ok('install', 'desktop', '--server-env', 'staging', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'install.desktop') +assert.equal(payload.request.serverEnv, 'staging') + +payload = JSON.parse(ok('install', 'server', '--server-env', 'staging', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'install.server') +assert.equal(payload.request.serverEnv, 'staging') + +payload = JSON.parse(ok('targets', 'runtime', 'start', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'targets.runtime.start') +assert.equal(payload.request.target.id, 'work') +assert.equal(payload.request.target.auth, undefined) + +payload = JSON.parse(ok('targets', 'runtime', 'stop', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'targets.runtime.stop') +assert.equal(payload.request.target.id, 'work') + +payload = JSON.parse(ok('targets', 'runtime', 'restart', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'targets.runtime.restart') +assert.equal(payload.request.target.id, 'work') + +result = run('targets', 'runtime', 'bogus', '--dry-run', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /unknown command "targets runtime bogus"/) + +payload = JSON.parse(ok('targets', 'tunnel', 'work', '--retries', '1', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'targets.tunnel') +assert.equal(payload.request.target, 'work') +assert.equal(payload.request.retries, 1) + +payload = JSON.parse(ok('remove', 'target', 'work', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'remove.target') +assert.equal(payload.request.id, 'work') + +payload = JSON.parse(ok('targets', 'rm', 'work', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'remove.target') +assert.equal(payload.request.id, 'work') + +payload = JSON.parse(ok('api', 'request', 'POST', '/v1/example', '--body', '{"ok":true}', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.request.body.ok, true) + +payload = JSON.parse(ok('api', 'request', 'GET', '/v1/example', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.request.method, 'GET') + +payload = JSON.parse(ok('send', 'voice', '--to', 'chat', '--file', './note.ogg', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'send.voice') +assert.equal(payload.request.chat, 'chat') + +payload = JSON.parse(ok('send', 'text', '--to', 'chat', '--message', 'hello', '--mention', 'user1', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'send.text') +assert.equal(payload.request.mentions[0], 'user1') + +payload = JSON.parse(ok('send', 'text', '--to', 'chat', '--message', 'hello\\nthere', '--message-escapes', '--dry-run', '--json')) +assert.equal(payload.request.text, 'hello\nthere') + +payload = JSON.parse(ok('-a', 'matrix', 'chats', 'start', '@u:example.org', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'chats.start') +assert.equal(payload.request.account, 'matrix') + +payload = JSON.parse(ok('send', 'react', '--to', 'chat', '--id', 'm1', '--reaction', '+1', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'send.react') +assert.equal(payload.request.reactionKey, '+1') + +payload = JSON.parse(ok('chats', 'disappear', '--chat', 'chat', '--seconds', 'off', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.request.chat, 'chat') +assert.equal(payload.request.messageExpirySeconds, null) + +payload = JSON.parse(ok('chats', 'disappear', '--chat', 'chat', '--seconds', '3600', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.request.messageExpirySeconds, 3600) + +result = run('chats', 'disappear', '--chat', 'chat', '--seconds', '1e2', '--dry-run', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.equal(errorPayload.error.code, 'usage_error') +assert.match(errorPayload.error.message, /--seconds must be a positive integer or "off"/) + +payload = JSON.parse(ok('chats', 'priority', '--chat', 'chat', '--level', 'low', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.request.chat, 'chat') -result = run('auth', 'status', '--json') +payload = JSON.parse(ok('chats', 'focus', '--chat', 'chat', '--text', 'draft', '--file', './draft.txt', '--message', 'm1', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'chats.focus') +assert.equal(payload.request.draftText, 'draft') +assert.equal(payload.request.draftAttachmentPath, './draft.txt') + +payload = JSON.parse(ok('chats', 'notify-anyway', '--chat', 'chat', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'chats.notify-anyway') + +result = run('chats', 'notify-anyway', '--dry-run', '--json') +assert.equal(result.status, 2) +errorPayload = JSON.parse(result.stderr) +assert.match(errorPayload.error.message, /--chat is required/) + +payload = JSON.parse(ok('messages', 'context', '--chat', 'chat', '--id', 'm1', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.request.chat, 'chat') +assert.equal(payload.request.messageID, 'm1') + +payload = JSON.parse(ok('messages', 'edit', '--chat', 'chat', '--id', 'm1', '--message', 'edited', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'messages.edit') +assert.equal(payload.request.chat, 'chat') +assert.equal(payload.request.text, 'edited') + +payload = JSON.parse(ok('messages', 'delete', '--chat', 'chat', '--id', 'm1', '--for-everyone', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'messages.delete') +assert.equal(payload.request.forEveryone, true) +assert.equal(payload.request.messageID, 'm1') + +payload = JSON.parse(ok('export', '--chat', 'chat', '--out', '/tmp/beeper-export', '--limit-messages', '10', '--no-attachments', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'export') +assert.equal(payload.request.outDir, '/tmp/beeper-export') + +payload = JSON.parse(ok('send', 'presence', '--to', 'chat', '--duration', '1', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'send.presence') +assert.equal(payload.request.durationSeconds, 1) + +payload = JSON.parse(ok('media', 'download', 'mxc://server/file', '--out', '/tmp', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'media.download') +assert.equal(payload.request.out, '/tmp') + +payload = JSON.parse(ok('export', '--out', '/tmp/beeper-export', '--limit-chats', '1', '--dry-run', '--json')) +assert.equal(payload.dry_run, true) +assert.equal(payload.op, 'export') +assert.equal(payload.request.outDir, '/tmp/beeper-export') +assert.equal(payload.request.limitChats, 1) + +payload = JSON.parse(ok('--safety-profile', 'readonly', 'resolve', 'target', 'desktop', '--json')) +assert.equal(payload.kind, 'target') +assert.equal(payload.selected.id, 'desktop') + +payload = JSON.parse(ok('targets', 'list', '--json')) +assert.equal(payload[0].id, 'work') + +const schema = JSON.parse(ok('schema', '--json')) +assert.equal(schema.schema_version, 1) +assert.equal(schema.command.type, 'application') +assert.ok(schema.command.flags.some((flag: { name: string; short?: string }) => flag.name === 'json' && flag.short === 'j')) +assert.ok(schema.command.flags.some((flag: { name: string; short?: string }) => flag.name === 'account' && flag.short === 'a')) +assert.ok(schema.command.flags.some((flag: { name: string }) => flag.name === 'full')) +assert.ok(schema.command.subcommands.some((command: { name: string }) => command.name === 'doctor')) +assert.ok(!schema.command.subcommands.some((command: { name: string }) => command.name === '__complete')) + +let filteredHelp = ok('--read-only', '--help') +assert.match(filteredHelp, /targets list/) +assert.doesNotMatch(filteredHelp, /send text/) + +let filteredSchema = JSON.parse(ok('--read-only', 'schema', '--json')) +assert.equal(schemaPaths(filteredSchema).includes('send text'), false) +assert.equal(schemaPaths(filteredSchema).includes('targets list'), true) + +filteredHelp = ok('--enable-commands', 'messages', '--help') +assert.match(filteredHelp, /messages search/) +assert.doesNotMatch(filteredHelp, /targets list/) + +filteredSchema = JSON.parse(ok('--disable-commands', 'messages.search', 'schema', '--json')) +assert.equal(schemaPaths(filteredSchema).includes('messages search'), false) + +result = spawnSync('bun', ['scripts/generate-command-docs.ts'], { cwd: root, encoding: 'utf8' }) assert.equal(result.status, 0, result.stderr) -envelope = JSON.parse(result.stdout) -assert.equal(envelope.success, true) -assert.equal(envelope.data.authenticated, false) -assert.equal(envelope.data.target, 'work') - -result = run('send', 'text', '--to', 'family', '--message', 'on my way', '--read-only', '--json') -assert.notEqual(result.status, 0) -envelope = JSON.parse(result.stderr) -assert.equal(envelope.success, false) -assert.match(envelope.error, /read-only mode/) - -result = run('setup', '--remote', 'http://127.0.0.1:9', '--target', 'email-remote', '--email', 'staging-user-123456@example.invalid', '--json') -assert.notEqual(result.status, 0) -envelope = JSON.parse(result.stderr) -assert.equal(envelope.success, false) -assert.match(envelope.error, /auth email start/) -assert.doesNotMatch(envelope.error, /--code|OTP/i, 'setup must direct automation to the two-step email commands without accepting OTP itself') - -result = run('targets', 'show', 'email-remote', '--json') -assert.notEqual(result.status, 0) -envelope = JSON.parse(result.stderr) -assert.equal(envelope.success, false) -assert.match(envelope.error, /Unknown Beeper target/) - -const rpcResult = spawnSync(process.execPath, ['./bin/dev.js', 'rpc'], { +result = spawnSync('git', ['diff', '--quiet', '--', 'packages/cli/docs/commands'], { cwd: join(root, '..', '..'), encoding: 'utf8' }) +assert.equal(result.status, 0, 'generated command docs are out of date') + +const mcp = spawnSync('bun', ['./bin/dev.js', 'mcp'], { cwd: root, encoding: 'utf8', env: { ...process.env, BEEPER_CLI_CONFIG_DIR: configDir, }, - input: '{"id":1,"command":"auth status --json"}\n', + input: '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\n', }) -assert.equal(rpcResult.status, 0, rpcResult.stderr) -const rpcLine = JSON.parse(rpcResult.stdout) -assert.equal(rpcLine.id, 1) -assert.equal(rpcLine.ok, true) -assert.match(rpcLine.stdout, /"success": true/) - -const stagingServerRequest = normalizeInstallRequest({ kind: 'server', serverEnv: 'staging', channel: 'stable', platform: 'darwin', arch: 'arm64' }) -assert.equal(stagingServerRequest.channel, 'nightly') -assert.equal(stagingServerRequest.bundleID, 'com.automattic.beeper.server.nightly') -assert.equal(feedURLFor(stagingServerRequest), 'https://api.beeper-staging.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=darwin&channel=nightly&arch=arm64') -assert.equal(downloadURLFor(stagingServerRequest), 'https://api.beeper-staging.com/desktop/download/macos/arm64/stable/com.automattic.beeper.server.nightly') - -const desktopNightlyRequest = normalizeInstallRequest({ kind: 'desktop', channel: 'nightly', platform: 'darwin', arch: 'arm64' }) -assert.equal(downloadURLFor(desktopNightlyRequest), 'https://api.beeper.com/desktop/download/macos/arm64/nightly/com.automattic.beeper.desktop.nightly') - -const fakeClient = { - accounts: { - list: async () => [ - { accountID: 'imessage-main', bridge: { id: 'local-imessage', type: 'imessage' }, network: 'iMessage', user: { displayName: 'Main' } }, - { accountID: 'telegram-main', bridge: { id: 'telegramgo', type: 'telegram' }, network: 'Telegram', user: { displayName: 'Main' } }, - ], +assert.equal(mcp.status, 0, mcp.stderr) +payload = JSON.parse(mcp.stdout) +assert.ok(payload.result.tools.some((tool: { name: string }) => tool.name === 'targets_list')) +assert.ok(payload.result.tools.some((tool: { name: string }) => tool.name === 'messages_search')) +assert.ok(payload.result.tools.some((tool: { name: string }) => tool.name === 'contacts_list')) +assert.ok(!payload.result.tools.some((tool: { name: string }) => tool.name === 'api_request')) +assert.ok(payload.result.tools.some((tool: { name: string }) => tool.name === 'resolve_target')) +assert.ok(payload.result.tools.some((tool: { name: string }) => tool.name === 'resolve_chat')) +assert.ok(payload.result.tools.some((tool: { name: string }) => tool.name === 'messages_context')) + +const mcpAllowWrite = spawnSync('bun', ['./bin/dev.js', 'mcp', '--allow-write', '--list-tools'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: configDir, }, - chats: { - retrieve: async id => { - if (id === '!exact:beeper.com' || id === '10313') return { id: '!family:beeper.com', localChatID: '10313', title: 'Family', network: 'iMessage' } - throw new Error('not found') - }, - search: async function* ({ query }) { - const rows = [ - { id: '!family:beeper.com', localChatID: '10313', title: 'Family', network: 'iMessage' }, - { id: '!family-work:beeper.com', localChatID: '8951', title: 'Family Work', network: 'Telegram' }, - ].filter(chat => chat.title.toLowerCase().includes(String(query).toLowerCase())) - for (const row of rows) yield row - }, +}) +assert.equal(mcpAllowWrite.status, 0, mcpAllowWrite.stderr) +payload = JSON.parse(mcpAllowWrite.stdout) +assert.ok(payload.some((tool: { name: string }) => tool.name === 'api_request')) + +const mcpInitialize = spawnSync('bun', ['./bin/dev.js', 'mcp'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: configDir, }, -} + input: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n', +}) +assert.equal(mcpInitialize.status, 0, mcpInitialize.stderr) +payload = JSON.parse(mcpInitialize.stdout) +assert.equal(payload.result.serverInfo.name, 'beeper') +assert.equal(payload.result.serverInfo.version, version.version) -assert.equal(await resolveAccountID(fakeClient, 'imessage'), 'imessage-main') -assert.deepEqual(await resolveAccountIDs(fakeClient, ['main'], { allowMultiplePerInput: true }), ['imessage-main', 'telegram-main']) -await assert.rejects(() => resolveAccountID(fakeClient, 'main'), /Ambiguous account/) -assert.equal(await resolveChatID(fakeClient, '!exact:beeper.com'), '!exact:beeper.com') -assert.equal(await resolveChatID(fakeClient, '10313'), '10313') -assert.equal(await resolveChatID(fakeClient, 'Family Work'), '8951') -assert.equal(await resolveChatID(fakeClient, 'fam', { pick: 2 }), '8951') -await assert.rejects(() => resolveChatID(fakeClient, 'fam'), /Ambiguous chat/) - -function listCommandFiles(dir) { - const output = [] - for (const entry of readdirSync(dir, { withFileTypes: true })) { - // Skip private/internal files like _complete used by autocomplete. - if (entry.name.startsWith('_') || entry.name === 'autocomplete.ts') continue - const path = join(dir, entry.name) - if (entry.isDirectory()) { - output.push(...listCommandFiles(path)) - } else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { - output.push(path) - } - } - return output -} +const mcpCall = spawnSync('bun', ['./bin/dev.js', 'mcp'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: configDir, + }, + input: '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"version","arguments":{}}}\n', +}) +assert.equal(mcpCall.status, 0, mcpCall.stderr) +payload = JSON.parse(mcpCall.stdout) +const mcpVersion = JSON.parse(payload.result.content[0].text) +assert.equal(mcpVersion.exit_code, 0) +assert.match(mcpVersion.stdout.name, /beeper-cli/) +assert.equal(mcpVersion.stdout.version, version.version) -function fileToCommand(file) { - const relative = file.slice(join(root, 'src/commands').length + 1) - const parts = relative.replace(/\.(ts|tsx)$/, '').split('/') - return parts.map(part => part === 'index' ? undefined : part).filter(Boolean).join(' ') -} +const mcpEOFCall = spawnSync('bun', ['./bin/dev.js', 'mcp'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: configDir, + }, + input: '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"version","arguments":{}}}', +}) +assert.equal(mcpEOFCall.status, 0, mcpEOFCall.stderr) +payload = JSON.parse(mcpEOFCall.stdout) +assert.equal(payload.id, 4) +assert.equal(JSON.parse(payload.result.content[0].text).stdout.version, version.version) + +const mcpDryRunCall = spawnSync('bun', ['./bin/dev.js', '--dry-run', 'mcp'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: configDir, + }, + input: '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"messages_context","arguments":{"chat":"chat","id":"m1","after":"3","before":"4"}}}\n', +}) +assert.equal(mcpDryRunCall.status, 0, mcpDryRunCall.stderr) +payload = JSON.parse(mcpDryRunCall.stdout) +const mcpContext = JSON.parse(payload.result.content[0].text) +assert.equal(mcpContext.stdout.dry_run, true) +assert.equal(mcpContext.stdout.request.after, 3) +assert.equal(mcpContext.stdout.request.before, 4) -assert(!existsSync(join(root, 'src/commands/profile')), 'profile namespace must be deleted') -assert(!existsSync(join(root, 'src/commands/target')), 'singular target namespace must be deleted') -assert(!existsSync(join(root, 'src/commands/app')), 'app/e2ee namespace must be deleted') +const mcpInvalidJSON = spawnSync('bun', ['./bin/dev.js', 'mcp'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: configDir, + }, + input: '{bad json}\n', +}) +assert.equal(mcpInvalidJSON.status, 0, mcpInvalidJSON.stderr) +payload = JSON.parse(mcpInvalidJSON.stdout) +assert.equal(payload.jsonrpc, '2.0') +assert.equal(payload.error.code, -32000) +assert.match(payload.error.message, /JSON/) + +rmSync(configDir, { recursive: true, force: true }) + +function schemaPaths(value: unknown): string[] { + if (Array.isArray(value)) return value.flatMap(schemaPaths) + if (!value || typeof value !== 'object') return [] + const row = value as Record + return [ + typeof row.path === 'string' ? row.path : undefined, + ...Object.values(row).flatMap(schemaPaths), + ].filter((item): item is string => Boolean(item)) +} diff --git a/packages/cli/test/cloudflare-tunnel.test.ts b/packages/cli/test/cloudflare-tunnel.test.ts new file mode 100644 index 00000000..7dc60be5 --- /dev/null +++ b/packages/cli/test/cloudflare-tunnel.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'bun:test' +import { cloudflaredDomain, findKnownError, findTunnelURL, versionIsGreaterThan, whatToTry } from '../src/lib/cloudflare-tunnel.js' + +describe('cloudflare tunnel helpers', () => { + it('parses cloudflared output and versions', () => { + expect(versionIsGreaterThan('2024.8.2', '2024.8.1')).toBe(true) + expect(versionIsGreaterThan('2024.8.2', '2024.8.2')).toBe(false) + expect(versionIsGreaterThan('2024.8.2', '2024.9.0')).toBe(false) + expect(findTunnelURL('INF https://example.trycloudflare.com ready')).toBe('https://example.trycloudflare.com') + expect(findTunnelURL('INF https://example.example.com ready', 'example.com')).toBe('https://example.example.com') + expect(findTunnelURL('INF https://example.example.com ready')).toBeUndefined() + expect(findKnownError('2024-01-01 ERR Failed to serve quic connection connIndex=1')).toMatch(/Could not start Cloudflare Tunnel/) + expect(whatToTry()).toMatch(/BEEPER_CLOUDFLARED_PATH/) + }) + + it('allows overriding the parsed cloudflared domain', () => { + const previous = process.env.BEEPER_CLOUDFLARED_DOMAIN + process.env.BEEPER_CLOUDFLARED_DOMAIN = 'beeper.test' + expect(cloudflaredDomain()).toBe('beeper.test') + if (previous === undefined) delete process.env.BEEPER_CLOUDFLARED_DOMAIN + else process.env.BEEPER_CLOUDFLARED_DOMAIN = previous + }) +}) diff --git a/packages/cli/test/e2e-staging.ts b/packages/cli/test/e2e-staging.ts index 5e3cd6ae..29ad0e5c 100644 --- a/packages/cli/test/e2e-staging.ts +++ b/packages/cli/test/e2e-staging.ts @@ -5,6 +5,9 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { commands } from '../src/cli/commands.js' +import { apiItems } from '../src/lib/api-values.js' +import { createProfileTarget, readTarget } from '../src/lib/targets.js' const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') await loadEnvFile(process.env.BEEPER_E2E_ENV_FILE || path.join(repoRoot, '.env.e2e')) @@ -13,9 +16,10 @@ const cliBin = process.env.BEEPER_E2E_CLI_BIN || path.join(repoRoot, 'bin/dev.js const runID = process.env.BEEPER_E2E_RUN_ID || String(Date.now()) const workDir = process.env.BEEPER_E2E_WORKDIR || path.join(tmpdir(), `beeper-cli-e2e-${runID}`) const configDir = process.env.BEEPER_E2E_CONFIG_DIR || path.join(workDir, 'cli-config') +process.env.BEEPER_CLI_CONFIG_DIR = configDir const reportPath = process.env.BEEPER_E2E_REPORT || path.join(workDir, 'report.json') const emailBase = Number(process.env.BEEPER_E2E_EMAIL_BASE || (900000 + Math.floor(Math.random() * 50000))) -const otp = process.env.BEEPER_E2E_OTP?.trim() +const otp = process.env.BEEPER_E2E_OTP?.trim() || '959729' const accountCount = Number(process.env.BEEPER_E2E_ACCOUNT_COUNT || 3) const portStart = Number(process.env.BEEPER_E2E_PORT_START || 24_573) const desktopCount = Number(process.env.BEEPER_E2E_DESKTOP_TARGETS || 1) @@ -66,7 +70,7 @@ if (previousReport?.runID === runID) { } process.on('SIGINT', async () => { - report.notes.push('Interrupted. Run the cleanup phase to stop managed server targets and remove isolated state.') + report.notes.push('Interrupted. Run the cleanup phase to stop local server targets and remove isolated state.') await writeReport() process.exit(130) }) @@ -84,7 +88,6 @@ async function main() { if (hasPhase('start')) await phaseStart() if (hasPhase('login')) await phaseLogin() if (hasPhase('readiness')) await phaseReadiness() - if (hasPhase('verify')) await phaseVerify() if (hasPhase('messaging')) await phaseMessaging() if (hasPhase('surface')) await phaseSurface() if (hasPhase('cleanup')) await phaseCleanup() @@ -106,7 +109,7 @@ async function phasePlan() { report.targets = targets const commands = [ 'bun run --filter beeper-cli build', - `BEEPER_E2E_ENV_FILE=.env.e2e BEEPER_E2E_PHASES=targets,install-server,start,login,readiness,verify,messaging,surface,cleanup BEEPER_E2E_RUN_ID=${runID} bun packages/cli/test/e2e-staging.ts`, + `BEEPER_E2E_ENV_FILE=.env.e2e BEEPER_E2E_PHASES=targets,install-server,start,login,readiness,messaging,surface,cleanup BEEPER_E2E_RUN_ID=${runID} bun packages/cli/test/e2e-staging.ts`, `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js targets list --json`, ] report.commands.push(...commands.map(command => ({ phase: 'plan', command }))) @@ -121,11 +124,12 @@ async function phaseTargets() { const targets = plannedTargets() report.targets = targets for (const target of targets) { - const args = target.kind === 'remote' - ? ['targets', 'add', 'remote', target.name, target.baseURL, '--json'] - : target.kind === 'desktop' - ? ['targets', 'add', 'desktop', target.name, '--server-env', 'staging', '--port', String(target.port), '--json'] - : ['targets', 'add', 'server', target.name, '--server-env', 'staging', '--port', String(target.port), '--json'] + if (target.kind !== 'remote') { + await ensureManagedTarget(target) + await writeReport() + continue + } + const args = ['targets', 'add', target.name, target.baseURL, '--json'] const result = runCli(args, { allowFailure: true }) if (result.status !== 0 && !`${result.stderr}${result.stdout}`.includes('already exists')) fail(result, args) recordCommand('targets', args, result) @@ -153,7 +157,7 @@ async function phaseStart() { await writeReport() continue } - const args = ['targets', 'start', target.name, '--json'] + const args = ['targets', 'runtime', 'start', target.name, '--json'] const result = runCli(args, { env: serverEnv(), allowFailure: true }) recordCommand('start', args, result) if (result.status !== 0) { @@ -201,9 +205,7 @@ async function phaseReadiness() { const env = target.accessToken ? { BEEPER_ACCESS_TOKEN: target.accessToken } : undefined for (const args of [ ['status', '--target', target.name, '--json'], - ['doctor', '--target', target.name, '--json'], ['setup', '--target', target.name, '--json'], - ['auth', 'status', '--target', target.name, '--json'], ]) { const result = runCli(args, { env, allowFailure: true }) recordCommand('readiness', args, result) @@ -211,31 +213,6 @@ async function phaseReadiness() { } } -async function phaseVerify() { - const targets = (await plannedTargetsWithAuth()).filter(target => target.accessToken) - if (targets.length < 2) { - recordBlock('verify', undefined, 'verify phase needs at least two signed-in targets for device-to-device auth.', [ - `BEEPER_E2E_RUN_ID=${runID} BEEPER_E2E_OTP="$QA_OTP" BEEPER_E2E_PHASES=login bun packages/cli/test/e2e-staging.ts`, - `BEEPER_E2E_RUN_ID=${runID} BEEPER_E2E_PHASES=verify,readiness bun packages/cli/test/e2e-staging.ts`, - ]) - return - } - await phaseVerifySameAccountDevices(targets) - for (const target of targets) { - for (const args of [ - ['verify', 'status', '--target', target.name, '--json'], - ['verify', 'list', '--target', target.name, '--json'], - ['verify', 'show', '--target', target.name, '--json'], - ['verify', 'sas', '--target', target.name, '--json'], - ['verify', 'sas-confirm', '--target', target.name, '--json'], - ]) { - const result = runCli(args, { env: { BEEPER_ACCESS_TOKEN: target.accessToken }, allowFailure: true }) - recordCommand('verify', args, result) - } - } - report.notes.push('Review verify command results. SAS/QR often needs manual matching between the two target UIs.') -} - async function phaseMessaging() { const signedInTargets = (await plannedTargetsWithAuth()).filter(target => target.accessToken) const sender = signedInTargets[0] @@ -316,7 +293,7 @@ async function phaseSurface() { } async function phaseHelpSurface() { - const commands = await generatedCommands() + const commands = await registeredCommands() for (const command of ['', ...commands]) { const args = command ? [...command.split(' '), '--help'] : ['--help'] const result = runCli(args, { allowFailure: true }) @@ -339,8 +316,8 @@ async function phaseApiSurface() { ['api', 'request', 'GET', '/v1/spec', '--target', target.name, '--no-auth', '--json'], ['api', 'request', 'GET', '/v1/app/setup', '--target', target.name, '--json'], ['api', 'request', 'GET', '/v1/app/setup/verifications', '--target', target.name, '--json'], - ['api', 'get', '/v1/accounts', '--target', target.name, '--json'], - ['api', 'get', '/v1/chats?limit=10', '--target', target.name, '--json'], + ['api', 'request', 'GET', '/v1/accounts', '--target', target.name, '--json'], + ['api', 'request', 'GET', '/v1/chats?limit=10', '--target', target.name, '--json'], ]) { const result = runCli(args, { env, allowFailure: true }) recordCommand('api-surface', args, result) @@ -373,36 +350,21 @@ async function phaseCliSurface() { const cases = [ ['version', '--json'], - ['docs', '--json'], - ['man', '--json'], - ['config', 'path', '--json'], - ['config', 'get', '--json'], - ['config', 'set', 'defaultTarget', target.name, '--json'], - ['config', 'get', 'defaultTarget', '--json'], - ['targets', 'show', target.name, '--json'], - ['targets', 'status', target.name, '--json'], - ['targets', 'use', target.name, '--json'], - ['status', '--target', target.name, '--json'], - ['doctor', '--target', target.name, '--json'], - ['auth', 'status', '--target', target.name, '--json'], - ['bridges', 'list', '--target', target.name, '--json'], - ['bridges', 'list', '--target', target.name, '--provider', 'local', '--available', '--json'], - ['bridges', 'show', 'local-dummy', '--target', target.name, '--json'], + ['schema', '--json'], + ['use', 'target', target.name, '--json'], + ['status', target.name, '--json'], ['accounts', 'list', '--target', target.name, '--json'], ['accounts', 'add', '--target', target.name, '--json'], - ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'password', '--field', 'username=cli-e2e', '--field', `password=${dummyPassword()}`, '--non-interactive', '--json'], - ['accounts', 'add', 'local-dummy', '--target', target.name, '--login-id', 'cli-e2e', '--flow', 'password', '--field', 'username=cli-e2e', '--field', `password=${dummyPassword()}`, '--non-interactive', '--json'], - ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'cookies', '--cookie', 'username=cli-e2e-cookies', '--cookie', `password=${dummyPassword()}`, '--non-interactive', '--json'], - ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'localstorage', '--cookie', 'username=cli-e2e-localstorage', '--cookie', `password=${dummyPassword()}`, '--non-interactive', '--json'], - ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'displayandwait', '--non-interactive', '--json'], + ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'password', '--field', 'username=cli-e2e', '--field', `password=${dummyPassword()}`, '--no-input', '--json'], + ['accounts', 'add', 'local-dummy', '--target', target.name, '--login-id', 'cli-e2e', '--flow', 'password', '--field', 'username=cli-e2e', '--field', `password=${dummyPassword()}`, '--no-input', '--json'], + ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'cookies', '--cookie', 'username=cli-e2e-cookies', '--cookie', `password=${dummyPassword()}`, '--no-input', '--json'], + ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'localstorage', '--cookie', 'username=cli-e2e-localstorage', '--cookie', `password=${dummyPassword()}`, '--no-input', '--json'], + ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'displayandwait', '--no-input', '--json'], ['accounts', 'list', '--target', target.name, '--account', 'local-dummy', '--json'], - ['config', 'get', 'defaultAccount', '--json'], ['chats', 'list', '--target', target.name, '--limit', '20', '--json'], - ['chats', 'search', runID, '--target', target.name, '--limit', '10', '--json'], + ['chats', 'list', '--query', runID, '--target', target.name, '--limit', '10', '--json'], ['contacts', 'list', '--target', target.name, '--limit', '20', '--json'], - ['contacts', 'search', 'staging-user', '--target', target.name, '--json'], - ['verify', 'status', '--target', target.name, '--json'], - ['verify', 'list', '--target', target.name, '--json'], + ['contacts', 'list', '--query', 'staging-user', '--target', target.name, '--json'], ] if (target.kind !== 'remote') cases.splice(9, 0, ['targets', 'logs', target.name, '--lines', '5']) @@ -417,13 +379,13 @@ async function phaseCliSurface() { if (sdkChatID) { cases.push( ['chats', 'pin', '--chat', sdkChatID, '--target', target.name, '--json'], - ['chats', 'unpin', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'pin', '--chat', sdkChatID, '--clear', '--target', target.name, '--json'], ['chats', 'archive', '--chat', sdkChatID, '--target', target.name, '--json'], - ['chats', 'unarchive', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'archive', '--chat', sdkChatID, '--clear', '--target', target.name, '--json'], ['chats', 'mute', '--chat', sdkChatID, '--target', target.name, '--json'], - ['chats', 'unmute', '--chat', sdkChatID, '--target', target.name, '--json'], - ['chats', 'mark-read', '--chat', sdkChatID, '--target', target.name, '--json'], - ['chats', 'mark-unread', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'mute', '--chat', sdkChatID, '--clear', '--target', target.name, '--json'], + ['chats', 'read', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'read', '--chat', sdkChatID, '--unread', '--target', target.name, '--json'], ['chats', 'priority', '--chat', sdkChatID, '--level', 'inbox', '--target', target.name, '--json'], ['chats', 'description', '--chat', sdkChatID, '--description', `CLI E2E ${runID}`, '--target', target.name, '--json'], ['chats', 'description', '--chat', sdkChatID, '--clear', '--target', target.name, '--json'], @@ -431,39 +393,35 @@ async function phaseCliSurface() { ['chats', 'draft', '--chat', sdkChatID, '--clear', '--target', target.name, '--json'], ['chats', 'disappear', '--chat', sdkChatID, '--seconds', 'off', '--target', target.name, '--json'], ['chats', 'remind', '--chat', sdkChatID, '--when', reminderAt, '--target', target.name, '--json'], - ['chats', 'unremind', '--chat', sdkChatID, '--target', target.name, '--json'], - ['presence', '--chat', sdkChatID, '--state', 'typing', '--duration', '1', '--target', target.name, '--json'], + ['chats', 'remind', '--chat', sdkChatID, '--clear', '--target', target.name, '--json'], + ['send', 'presence', '--to', sdkChatID, '--state', 'typing', '--duration', '1', '--target', target.name, '--json'], ['messages', 'search', runID, '--chat', sdkChatID, '--target', target.name, '--limit', '10', '--json'], - ['messages', 'export', '--chat', sdkChatID, '--target', target.name, '--limit', '10', '--output', '-', '--json'], + ['export', '--chat', sdkChatID, '--target', target.name, '--limit-messages', '10', '--no-attachments', '--out', path.join(workDir, 'exports', 'chat'), '--json'], ) } else { - for (const command of ['chats pin/unpin/archive/unarchive/mute/unmute/mark-read/mark-unread/priority/description/draft/disappear/remind/unremind', 'presence']) { + for (const command of ['chats description/draft/disappear/remind', 'send presence']) { report.coverage.skipped.push({ command, reason: 'No Desktop-indexed chat was available from Beeper Server; raw Matrix rooms do not support Desktop chat mutation APIs.' }) } report.coverage.skipped.push({ command: 'messages search --chat', reason: 'No Desktop-indexed chat was available from Beeper Server; raw Matrix rooms are not searched through Desktop message APIs.' }) - report.coverage.skipped.push({ command: 'messages export', reason: 'No Desktop-indexed chat was available from Beeper Server; raw Matrix rooms are not exported through Desktop message APIs.' }) + report.coverage.skipped.push({ command: 'export --chat', reason: 'No Desktop-indexed chat was available from Beeper Server; raw Matrix rooms are not exported through Desktop message APIs.' }) } if (sdkChatID && messageID) { cases.push( - ['messages', 'show', '--chat', sdkChatID, '--id', messageID, '--target', target.name, '--json'], ['messages', 'context', '--chat', sdkChatID, '--id', messageID, '--target', target.name, '--before', '2', '--after', '2', '--json'], ['send', 'react', '--to', sdkChatID, '--id', messageID, '--reaction', '+1', '--target', target.name, '--json'], - ['send', 'unreact', '--to', sdkChatID, '--id', messageID, '--reaction', '+1', '--target', target.name, '--json'], + ['send', 'react', '--to', sdkChatID, '--id', messageID, '--reaction', '+1', '--remove', '--target', target.name, '--json'], ) } else if (!sdkChatID) { - report.coverage.skipped.push({ command: 'messages show/context and send react/unreact', reason: 'No Desktop-indexed chat/message was available from Beeper Server; raw Matrix rooms do not support these Desktop message APIs.' }) + report.coverage.skipped.push({ command: 'messages context and reaction send', reason: 'No Desktop-indexed chat/message was available from Beeper Server; raw Matrix rooms do not support these Desktop message APIs.' }) } for (const args of cases) { const result = runCli(args, { env, allowFailure: true }) recordCommand('cli-surface', args, result) - const expectedDoctorDiagnostic = args[0] === 'doctor' && result.status !== 0 && parseEnvelope(result.stdout)?.data - recordCoverage('commands', args, result, expectedDoctorDiagnostic ? true : undefined) + recordCoverage('commands', args, result) if (args[0] === 'accounts' && args[1] === 'add' && args.length === 5) { report.notes.push('accounts add without a bridge returned the bridge-picker data; local-dummy covers the actual login flow.') - } else if (expectedDoctorDiagnostic) { - report.notes.push('doctor returned non-zero because the target is not fully healthy; JSON diagnostics were still returned.') } else if (result.status !== 0) { recordFailure('cli-surface', target, `beeper ${args.join(' ')} failed with status ${result.status}`) } @@ -479,16 +437,15 @@ async function phaseLocalDummyAccountSurface(target, env) { recordCommand('cli-surface', listArgs, list) recordCoverage('commands', listArgs, list) const accounts = parseEnvelope(list.stdout)?.data - const account = Array.isArray(accounts) ? accounts.find(item => item?.id || item?.accountID) : undefined - const accountID = account?.id ?? account?.accountID + const accountID = firstField(apiItems(accounts), ['id', 'accountID']) if (!accountID) { recordFailure('cli-surface', target, 'local-dummy login completed but accounts list did not return a reusable account ID.') return } for (const args of [ - ['accounts', 'show', accountID, '--target', target.name, '--json'], - ['accounts', 'use', accountID, '--target', target.name, '--json'], - ['config', 'get', 'defaultAccount', '--json'], + ['accounts', 'list', '--account', accountID, '--target', target.name, '--json'], + ['use', 'account', accountID, '--target', target.name, '--json'], + ['accounts', 'list', '--account', accountID, '--target', target.name, '--json'], ]) { const result = runCli(args, { env, allowFailure: true }) recordCommand('cli-surface', args, result) @@ -502,23 +459,8 @@ async function phaseControlSurface() { const target = targets.find(item => item.accessToken) ?? targets[0] if (!target) return - for (const args of [ - ['update', '--server', '--check', '--json'], - ]) { - const result = runCli(args, { env: serverEnv(), allowFailure: true }) - recordCommand('control-surface', args, result) - recordCoverage('commands', args, result) - if (result.status !== 0) recordFailure('control-surface', target, `beeper ${args.join(' ')} failed with status ${result.status}`) - if (args[0] === 'targets' && args[1] === 'restart') { - try { - await waitForInfo(target) - } catch (error) { - recordFailure('control-surface', target, error) - } - } - } if (target.kind === 'server') { - const args = ['targets', 'restart', target.name, '--json'] + const args = ['targets', 'runtime', 'restart', target.name, '--json'] const result = runCli(args, { env: serverEnv(), allowFailure: true }) recordCommand('control-surface', args, result) recordCoverage('commands', args, result) @@ -529,21 +471,20 @@ async function phaseControlSurface() { recordFailure('control-surface', target, error) } } else { - report.coverage.skipped.push({ command: 'targets restart', reason: 'Only server targets are lifecycle-managed by the CLI.' }) + report.coverage.skipped.push({ command: 'targets runtime restart', reason: 'Only local server targets are controlled by the CLI.' }) } const remoteName = `remote-${runID}` for (const args of [ - ['targets', 'add', 'remote', remoteName, 'http://127.0.0.1:9', '--json'], - ['targets', 'show', remoteName, '--json'], - ['targets', 'status', remoteName, '--json'], - ['targets', 'remove', remoteName, '--json'], + ['targets', 'add', remoteName, 'http://127.0.0.1:9', '--json'], + ['status', remoteName, '--json'], + ['remove', 'target', remoteName, '--json'], ]) { const result = runCli(args, { allowFailure: true }) recordCommand('control-surface', args, result) - const expectedUnreachable = args[0] === 'targets' && args[1] === 'status' && result.status !== 0 && parseEnvelope(result.stdout)?.data + const expectedUnreachable = args[0] === 'status' && result.status !== 0 && parseEnvelope(result.stdout)?.data recordCoverage('commands', args, result, expectedUnreachable ? true : undefined) - if (args[0] === 'targets' && args[1] === 'status' && result.status !== 0 && parseEnvelope(result.stdout)?.data) { + if (args[0] === 'status' && result.status !== 0 && parseEnvelope(result.stdout)?.data) { report.notes.push('remote target status returned non-zero because the test URL is intentionally unreachable; JSON diagnostics were still returned.') } else if (result.status !== 0) { recordFailure('control-surface', target, `beeper ${args.join(' ')} failed with status ${result.status}`) @@ -554,7 +495,7 @@ async function phaseControlSurface() { if (logoutTarget) { for (const args of [ ['auth', 'logout', '--target', logoutTarget.name, '--json'], - ['auth', 'status', '--target', logoutTarget.name, '--json'], + ['status', logoutTarget.name, '--json'], ]) { const result = runCli(args, { allowFailure: true }) recordCommand('control-surface', args, result) @@ -564,139 +505,13 @@ async function phaseControlSurface() { } } -async function phaseVerifySameAccountDevices(targets) { - const byUserID = new Map() - for (const target of targets) { - const userID = target.matrix?.userID - if (!userID) continue - const group = byUserID.get(userID) ?? [] - group.push(target) - byUserID.set(userID, group) - } - - const pair = [...byUserID.values()].find(group => group.length >= 2) - if (!pair) { - recordBlock('verify', undefined, 'Device-to-device verification needs two targets signed into the same QA account.', [ - `BEEPER_E2E_OTP="$QA_OTP" BEEPER_E2E_EMAIL_1="$QA_EMAIL_1" BEEPER_E2E_EMAIL_2="$QA_EMAIL_1" BEEPER_E2E_EMAIL_3="$QA_EMAIL_2" BEEPER_E2E_ACCOUNT_COUNT=3 BEEPER_E2E_DESKTOP_TARGETS=0 BEEPER_E2E_SERVER_TARGETS=3 BEEPER_E2E_PHASES=targets,install-server,start,login,readiness,verify,messaging,cleanup bun packages/cli/test/e2e-staging.ts`, - ]) - return - } - - await Promise.all(pair.map(target => waitForVerificationState(target))) - const [initiator, responder] = await verificationPair(pair) - const startArgs = ['verify', 'start', '--target', initiator.name, '--user', responder.matrix.userID, '--json'] - const start = runCli(startArgs, { env: { BEEPER_ACCESS_TOKEN: initiator.accessToken }, allowFailure: true }) - recordCommand('verify-devices', startArgs, start) - - const responderResults = await pollResponderVerification(responder) - const responderVerificationID = verificationIDFromResults(responderResults) - for (const baseArgs of [ - ['verify', 'approve', '--target', responder.name], - ['verify', 'sas', '--target', responder.name], - ]) { - const args = responderVerificationID ? [...baseArgs, '--id', responderVerificationID, '--json'] : [...baseArgs, '--json'] - const result = runCli(args, { env: { BEEPER_ACCESS_TOKEN: responder.accessToken }, allowFailure: true }) - recordCommand('verify-devices', args, result) - } - await sleep(1000) - - const initiatorSASArgs = responderVerificationID - ? ['verify', 'sas', '--target', initiator.name, '--id', responderVerificationID, '--json'] - : ['verify', 'sas', '--target', initiator.name, '--json'] - const initiatorSAS = runCli(initiatorSASArgs, { env: { BEEPER_ACCESS_TOKEN: initiator.accessToken }, allowFailure: true }) - recordCommand('verify-devices', initiatorSASArgs, initiatorSAS) - await sleep(1000) - - for (const args of [ - ['verify', 'show', '--target', responder.name, '--json'], - ['verify', 'show', '--target', initiator.name, '--json'], - ['verify', 'status', '--target', initiator.name, '--json'], - ['verify', 'status', '--target', responder.name, '--json'], - ]) { - const target = args.includes(initiator.name) ? initiator : responder - const result = runCli(args, { env: { BEEPER_ACCESS_TOKEN: target.accessToken }, allowFailure: true }) - recordCommand('verify-devices', args, result) - } - - for (const target of [initiator, responder]) { - const args = responderVerificationID - ? ['verify', 'sas-confirm', '--target', target.name, '--id', responderVerificationID, '--json'] - : ['verify', 'sas-confirm', '--target', target.name, '--json'] - const result = runCli(args, { env: { BEEPER_ACCESS_TOKEN: target.accessToken }, allowFailure: true }) - recordCommand('verify-devices', args, result) - } - await sleep(1000) - for (const args of [ - ['verify', 'status', '--target', initiator.name, '--json'], - ['verify', 'status', '--target', responder.name, '--json'], - ]) { - const target = args.includes(initiator.name) ? initiator : responder - const result = runCli(args, { env: { BEEPER_ACCESS_TOKEN: target.accessToken }, allowFailure: true }) - recordCommand('verify-devices', args, result) - } -} - -async function waitForVerificationState(target) { - for (let attempt = 0; attempt < 30; attempt++) { - const args = ['verify', 'status', '--target', target.name, '--json'] - const result = runCli(args, { env: { BEEPER_ACCESS_TOKEN: target.accessToken }, allowFailure: true }) - recordCommand('verify-devices', args, result) - const state = parseEnvelope(result.stdout)?.data?.state - if (result.status === 0 && (state === 'ready' || state === 'needs-verification' || state === 'needs-recovery-key' || state === 'needs-secrets')) return state - await sleep(1000) - } - throw new Error(`Timed out waiting for ${target.name} to reach a verification-ready state`) -} - -async function verificationPair(pair) { - const states = [] - for (const target of pair) { - const args = ['verify', 'status', '--target', target.name, '--json'] - const result = runCli(args, { env: { BEEPER_ACCESS_TOKEN: target.accessToken }, allowFailure: true }) - recordCommand('verify-devices', args, result) - const data = parseEnvelope(result.stdout)?.data - states.push({ target, verified: data?.app?.e2ee?.verified === true }) - } - const initiator = states.find(item => !item.verified)?.target ?? pair[0] - const responder = states.find(item => item.target !== initiator && item.verified)?.target ?? pair.find(target => target !== initiator) ?? pair[1] - return [initiator, responder] -} - -async function pollResponderVerification(responder) { - const results = [] - for (let attempt = 0; attempt < 12; attempt++) { - const listArgs = ['verify', 'list', '--target', responder.name, '--json'] - const list = runCli(listArgs, { env: { BEEPER_ACCESS_TOKEN: responder.accessToken }, allowFailure: true }) - recordCommand('verify-devices', listArgs, list) - results.push(list) - if (verificationIDFromResults([list])) { - const showArgs = ['verify', 'show', '--target', responder.name, '--json'] - const show = runCli(showArgs, { env: { BEEPER_ACCESS_TOKEN: responder.accessToken }, allowFailure: true }) - recordCommand('verify-devices', showArgs, show) - results.push(show) - return results - } - await sleep(1000) - } - return results -} - -function verificationIDFromResults(results) { - for (const result of results) { - const data = parseEnvelope(result.stdout)?.data - if (Array.isArray(data) && data[0]?.id) return data[0].id - if (data?.id) return data.id - } - return undefined -} - async function phaseCleanup() { for (const target of plannedTargets()) { if (target.kind === 'server') { - const stop = runCli(['targets', 'stop', target.name, '--json'], { allowFailure: true }) - recordCommand('cleanup', ['targets', 'stop', target.name, '--json'], stop) + const stop = runCli(['targets', 'runtime', 'stop', target.name, '--json'], { allowFailure: true }) + recordCommand('cleanup', ['targets', 'runtime', 'stop', target.name, '--json'], stop) } else if (target.kind === 'remote') { - report.notes.push(`Remote Server target ${target.name} was not lifecycle-managed by the harness.`) + report.notes.push(`Remote Server target ${target.name} was not controlled by the harness.`) } else { report.notes.push(`Desktop target ${target.name} may need manual quit if it was launched through the app.`) } @@ -734,7 +549,7 @@ async function plannedTargetsWithAuth() { } function targetPlan(kind, index, ordinal, baseURL) { - const email = process.env[`BEEPER_E2E_EMAIL_${ordinal + 1}`] || `staging-user-${emailBase + ordinal}@example.invalid` + const email = process.env[`BEEPER_E2E_EMAIL_${ordinal + 1}`] || `qatest+${emailBase + ordinal}@beeper.com` const port = Number(process.env[`BEEPER_E2E_PORT_${ordinal + 1}`] || (portStart + ordinal)) return { kind, @@ -747,6 +562,18 @@ function targetPlan(kind, index, ordinal, baseURL) { } } +async function ensureManagedTarget(target) { + const existing = await readTarget(target.name) + if (!existing) await createProfileTarget(target.kind, target.name, { port: target.port, serverEnv: 'staging' }) + report.commands.push({ + phase: 'targets', + command: `prepare local ${target.kind} target ${target.name}`, + status: 0, + stdout: existing ? 'target already exists' : `created ${target.baseURL}`, + stderr: '', + }) +} + async function readPreviousReport() { try { return JSON.parse(await readFile(reportPath, 'utf8')) @@ -812,7 +639,7 @@ function recordLoginBlock(target, args, result) { const command = `beeper ${args.join(' ')}` if (target.kind === 'desktop' && /signed-in local Beeper Desktop session|missing access_token/i.test(output)) { recordBlock('login', target, 'Sign in to the isolated Desktop target, then rerun the login/readiness phases.', [ - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js targets start ${target.name} --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js targets runtime start ${target.name} --json`, `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js setup --target ${target.name} --local --json`, `BEEPER_E2E_RUN_ID=${runID} BEEPER_E2E_OTP="$QA_OTP" BEEPER_E2E_PHASES=login,readiness bun packages/cli/test/e2e-staging.ts`, ]) @@ -820,9 +647,9 @@ function recordLoginBlock(target, args, result) { } if ((target.kind === 'server' || target.kind === 'remote') && /OAuth authorization failed|needs-login|server_error/i.test(output)) { recordBlock('login', target, 'Complete Server setup sign-in, then rerun the login/readiness phases.', [ - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js targets start ${target.name} --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js targets runtime start ${target.name} --json`, `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js auth email start --target ${target.name} --email ${target.email} --json`, - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js auth email response --target ${target.name} --setup-request-id "$SETUP_REQUEST_ID" --code "$QA_OTP" --username "$QA_USERNAME" --yes --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js auth email response --target ${target.name} --setup-request-id "$SETUP_REQUEST_ID" --code "$QA_OTP" --username "$QA_USERNAME" --force --json`, `BEEPER_E2E_RUN_ID=${runID} BEEPER_E2E_OTP="$QA_OTP" BEEPER_E2E_PHASES=login,readiness bun packages/cli/test/e2e-staging.ts`, ]) return @@ -844,7 +671,7 @@ async function loginServerViaSetupAPI(target) { return false } - const responseArgs = ['auth', 'email', 'response', '--target', target.name, '--setup-request-id', setupRequestID, '--code', otp, '--username', usernameForEmail(target.email), '--yes', '--json'] + const responseArgs = ['auth', 'email', 'response', '--target', target.name, '--setup-request-id', setupRequestID, '--code', otp, '--username', usernameForEmail(target.email), '--force', '--json'] const response = runCli(responseArgs, { allowFailure: true }) recordCommand('login', responseArgs, response) if (response.status !== 0) { @@ -904,24 +731,33 @@ async function findReusableChatID(target, env) { const result = runCli(['chats', 'list', '--target', target.name, '--limit', '20', '--json'], { env, allowFailure: true }) recordCommand('surface-setup', ['chats', 'list', '--target', target.name, '--limit', '20', '--json'], result) const items = parseEnvelope(result.stdout)?.data - const chat = Array.isArray(items) ? items.find(item => item?.id || item?.localChatID || item?.chatID) : undefined - if (!chat) { + const chatID = firstField(apiItems(items), ['localChatID', 'id', 'chatID']) + if (!chatID) { report.coverage.skipped.push({ command: 'Desktop-indexed chat mutation surface', reason: 'No reusable Desktop-indexed chat was returned by chats list.' }) return undefined } - return chat.localChatID ?? chat.id ?? chat.chatID + return chatID } async function findReusableMessageID(target, chatID, env) { const result = runCli(['messages', 'list', '--chat', chatID, '--target', target.name, '--limit', '20', '--json'], { env, allowFailure: true }) recordCommand('surface-setup', ['messages', 'list', '--chat', chatID, '--target', target.name, '--limit', '20', '--json'], result) const items = parseEnvelope(result.stdout)?.data - const message = Array.isArray(items) ? items.find(item => item?.id || item?.messageID || item?.eventID || item?.event_id) : undefined - if (!message) { + const messageID = firstField(apiItems(items), ['id', 'messageID', 'eventID', 'event_id']) + if (!messageID) { report.coverage.skipped.push({ command: 'message-specific Desktop surface', reason: 'No reusable message ID was returned by messages list.' }) return undefined } - return message.id ?? message.messageID ?? message.eventID ?? message.event_id + return messageID +} + +function firstField(items, fields) { + for (const item of items) { + for (const field of fields) { + if (item[field]) return String(item[field]) + } + } + return undefined } function recordCoverage(type, args, result, ok = result.status === 0) { @@ -933,10 +769,9 @@ function recordCoverage(type, args, result, ok = result.status === 0) { }) } -async function generatedCommands() { - const source = await readFile(path.join(repoRoot, 'src/commands.generated.ts'), 'utf8') - return [...source.matchAll(/'([^']+)': Command/g)] - .map(match => match[1].replaceAll(':', ' ')) +async function registeredCommands() { + return commands + .map(command => command.path.join(' ')) .sort() } diff --git a/packages/cli/test/e2e-staging/README.md b/packages/cli/test/e2e-staging/README.md index 0e016fd5..49a24d83 100644 --- a/packages/cli/test/e2e-staging/README.md +++ b/packages/cli/test/e2e-staging/README.md @@ -1,54 +1,43 @@ # Beeper CLI Staging E2E -This harness is for coordinated staging QA of the unreleased Beeper CLI command -surface. It is intentionally explicit: the default run prints a plan and does -not launch apps, download artifacts, or touch the default Desktop instance. +This harness is for coordinated staging QA of the Beeper CLI command surface. +The default run prints a plan only. It does not launch apps, download artifacts, +or touch the default Desktop instance. ## Safety Model - Use a fresh `BEEPER_E2E_RUN_ID` per run. -- Use `BEEPER_E2E_WORKDIR` under `/tmp` unless you need to preserve artifacts. -- The harness writes CLI state under `BEEPER_E2E_CONFIG_DIR`, defaulting to - `/cli-config`. +- Use `BEEPER_E2E_WORKDIR` under `/tmp` unless preserving artifacts. +- The harness writes CLI state under `BEEPER_E2E_CONFIG_DIR`, defaulting to `/cli-config`. - Use non-default PAS ports. The default starts at `24573`, not `23373`. - Every test target uses `--server-env staging`. -- Provide staging account emails through `BEEPER_E2E_EMAIL_*` and verification - codes through `BEEPER_E2E_OTP` only for scripts that explicitly target - verified setup-login APIs. -- Do not run `install-server` unless you intend to download the staging server - artifact. +- Put staging OTPs in `.env.e2e` or set `BEEPER_E2E_ENV_FILE`; reports redact OTPs, setup responses, access tokens, and lead tokens. +- Do not run `install-server` unless you intend to download the staging server artifact. ## Basic Plan ```sh cd path/to/cli -bun run --filter @beeper/cli build +bun run --filter beeper-cli build BEEPER_E2E_RUN_ID=qa-$(date +%Y%m%d-%H%M%S) \ bun packages/cli/test/e2e-staging.ts ``` -The plan output shows the target names, ports, emails, and follow-up command. +The plan output shows target names, ports, emails, and follow-up commands. ## Full Coordinated Surface Run Use this when a staging server binary already exists or `BEEPER_SERVER_BIN` is set. This creates isolated targets, starts them, authenticates Desktop targets -with `beeper setup --local` and Server targets with `beeper setup --oauth`, -checks readiness, attempts device verification commands, runs a small messaging -pass, creates a group when three QA users are available, runs CLI/API surface -coverage, and stops managed server targets. -Server targets sign in through the public setup API using `beeper api post ---no-auth` and the QA OTP from `BEEPER_E2E_OTP`. Put the OTP in `.env.e2e` or -point `BEEPER_E2E_ENV_FILE` at the secret env file. The report redacts OTPs, -setup responses, access tokens, and lead tokens. Desktop targets still use -`beeper setup --local` after the isolated Desktop profile has been signed in -through the app UI. +with `beeper setup --local` and Server targets through the setup API, checks +readiness, runs messaging coverage, creates a group when three QA users are +available, runs CLI/API surface coverage, and stops local server targets. ```sh BEEPER_E2E_RUN_ID=qa-$(date +%Y%m%d-%H%M%S) \ BEEPER_E2E_ENV_FILE=.env.e2e \ -BEEPER_E2E_PHASES=targets,start,login,readiness,verify,messaging,surface,cleanup \ +BEEPER_E2E_PHASES=targets,start,login,readiness,messaging,surface,cleanup \ BEEPER_E2E_ACCOUNT_COUNT=3 \ BEEPER_E2E_DESKTOP_TARGETS=1 \ BEEPER_E2E_SERVER_TARGETS=2 \ @@ -57,8 +46,8 @@ bun packages/cli/test/e2e-staging.ts ``` The report is written to `/tmp/beeper-cli-e2e-/report.json` by default. -Expected human steps are written under `blocked` with concrete follow-up -commands. Real harness failures are written under `failures`. +Expected human steps are written under `blocked`; harness failures are written +under `failures`. The `surface` phase is the consolidated Desktop/Client API coverage pass. It uses CLI commands when the CLI has a first-class command and falls back to @@ -73,7 +62,7 @@ Only run this when you want the CLI to download the staging server artifact: ```sh BEEPER_E2E_RUN_ID=qa-$(date +%Y%m%d-%H%M%S) \ BEEPER_E2E_OTP="$QA_OTP" \ -BEEPER_E2E_PHASES=targets,install-server,start,login,readiness,verify,messaging,cleanup \ +BEEPER_E2E_PHASES=targets,install-server,start,login,readiness,messaging,cleanup \ bun packages/cli/test/e2e-staging.ts ``` @@ -88,25 +77,6 @@ dependencies, and it does not modify lockfiles. ## Manual Coordination Points -Device-to-device auth may require looking at the two target UIs and matching SAS -or QR state. The harness records the CLI attempts for: - -- `verify status` -- `verify list` -- `verify start` -- `verify show` -- `verify sas` -- `verify sas confirm` - -If the verification transaction needs UI confirmation, use the report to find -the target names and ports, complete the UI action, then rerun: - -```sh -BEEPER_E2E_RUN_ID= \ -BEEPER_E2E_PHASES=verify,readiness \ -bun packages/cli/test/e2e-staging.ts -``` - If login is blocked, the report includes target-specific commands for opening the isolated target and rerunning `setup --local` or the setup API commands. Complete the browser or Desktop UI step, then rerun: @@ -114,7 +84,7 @@ Complete the browser or Desktop UI step, then rerun: ```sh BEEPER_E2E_RUN_ID= \ BEEPER_E2E_OTP="$QA_OTP" \ -BEEPER_E2E_PHASES=login,readiness,verify,messaging,cleanup \ +BEEPER_E2E_PHASES=login,readiness,messaging,cleanup \ bun packages/cli/test/e2e-staging.ts ``` @@ -127,7 +97,3 @@ BEEPER_E2E_RUN_ID= \ BEEPER_E2E_PHASES=cleanup \ bun packages/cli/test/e2e-staging.ts ``` - -Desktop targets launched through the app may need to be quit manually. The -harness uses separate `BEEPER_PROFILE`, data directories, and ports, so this -does not require touching the default Desktop profile. diff --git a/packages/cli/test/errors.test.ts b/packages/cli/test/errors.test.ts index 3f4e8c82..ff842c15 100644 --- a/packages/cli/test/errors.test.ts +++ b/packages/cli/test/errors.test.ts @@ -1,30 +1,14 @@ import { describe, expect, it } from 'bun:test' -import { CLIError, ExitCodes, ambiguous, authRequired, notFound, notReady, usageError } from '../src/lib/errors.js' +import { AbortError, CLIError, ExitCodes } from '../src/lib/errors.js' -describe('CLIError factories', () => { - it('attaches exit code 2 for usage', () => { - const err = usageError('bad flag') +describe('CLIError', () => { + it('AbortError carries an explicit exit code', () => { + const err = new AbortError('bad flag', ExitCodes.Usage) expect(err).toBeInstanceOf(CLIError) expect(err.exitCode).toBe(ExitCodes.Usage) expect(err.message).toBe('bad flag') }) - it('attaches exit code 3 for authRequired', () => { - expect(authRequired('sign in').exitCode).toBe(ExitCodes.AuthRequired) - }) - - it('attaches exit code 4 for notReady', () => { - expect(notReady('not ready').exitCode).toBe(ExitCodes.NotReady) - }) - - it('attaches exit code 5 for notFound', () => { - expect(notFound('missing').exitCode).toBe(ExitCodes.NotFound) - }) - - it('attaches exit code 6 for ambiguous', () => { - expect(ambiguous('pick one').exitCode).toBe(ExitCodes.Ambiguous) - }) - it('CLIError instances are Error subclasses', () => { const err = new CLIError('boom', ExitCodes.Generic) expect(err).toBeInstanceOf(Error) diff --git a/packages/cli/test/fixtures/fake-client.ts b/packages/cli/test/fixtures/fake-client.ts deleted file mode 100644 index 5dd83619..00000000 --- a/packages/cli/test/fixtures/fake-client.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Lightweight fake of the @beeper/desktop-api client. Shape matches what - * commands actually call. Pass per-test overrides to swap individual methods. - */ -import { mock } from 'bun:test' - -type Mock = ReturnType - -export type FakeChat = { - id: string - accountID?: string - title?: string - localChatID?: string - network?: string - isArchived?: boolean - isPinned?: boolean - isMuted?: boolean - isLowPriority?: boolean - isMarkedUnread?: boolean - unreadCount?: number - type?: 'single' | 'group' -} - -export type FakeMessage = { - id: string - chatID: string - text?: string - isSender?: boolean - senderID?: string - timestamp?: string - type?: string -} - -export type FakeClient = { - accounts: { - list: Mock - contacts: { list: Mock; search: Mock } - retrieve?: Mock - } - chats: { - list: Mock - retrieve: Mock - search: Mock - update: Mock - archive: Mock - markRead: Mock - markUnread: Mock - notifyAnyway: Mock - start: Mock - messages: { reactions: { add: Mock; delete: Mock } } - reminders: { create: Mock; delete: Mock } - } - messages: { - list: Mock - search: Mock - retrieve: Mock - send: Mock - update: Mock - delete: Mock - } - assets: { upload: Mock; serve: Mock } - bridges: { list: Mock; loginFlows: { list: Mock }; loginSessions: { create: Mock } } - app: any - post: Mock - get: Mock - put: Mock - delete: Mock - focus: Mock -} - -export function makeFakeClient(overrides: Partial = {}): FakeClient { - const empty = () => async function* () {}() as AsyncIterable - const okPage = (items: T[]) => async function* () { for (const it of items) yield it }() as AsyncIterable - - return { - accounts: { - list: mock(async () => []), - contacts: { list: mock(() => empty()), search: mock(async () => ({ items: [] })) }, - ...overrides.accounts, - }, - chats: { - list: mock(() => empty()), - retrieve: mock(async (id: string) => ({ id })), - search: mock(() => empty()), - update: mock(async (id: string, body: any) => ({ id, ...body })), - archive: mock(async () => ({})), - markRead: mock(async () => ({})), - markUnread: mock(async () => ({})), - notifyAnyway: mock(async () => ({})), - start: mock(async () => ({ chatID: '!new:beeper.com' })), - messages: { reactions: { add: mock(async () => ({})), delete: mock(async () => ({})) } }, - reminders: { create: mock(async () => ({})), delete: mock(async () => ({})) }, - ...overrides.chats, - }, - messages: { - list: mock(() => empty()), - search: mock(() => empty()), - retrieve: mock(async (id: string) => ({ id })), - send: mock(async () => ({ pendingMessageID: 'pending-1' })), - update: mock(async (id: string) => ({ id })), - delete: mock(async () => undefined), - ...overrides.messages, - }, - assets: { upload: mock(async () => ({ uploadID: 'upload-1', mimeType: 'application/octet-stream' })), serve: mock(async () => ({ arrayBuffer: async () => new ArrayBuffer(0) })), ...overrides.assets }, - bridges: { list: mock(async () => ({ items: [] })), loginFlows: { list: mock(async () => ({ items: [] })) }, loginSessions: { create: mock(async () => ({})) }, ...overrides.bridges }, - app: overrides.app ?? {}, - post: mock(async () => ({})), - get: mock(async () => ({})), - put: mock(async () => ({})), - delete: mock(async () => ({})), - focus: mock(async () => ({})), - ...overrides, - } -} - -export function chatsPage(items: FakeChat[]) { - return async function* () { for (const it of items) yield it }() -} - -export function messagesPage(items: FakeMessage[]) { - return async function* () { for (const it of items) yield it }() -} diff --git a/packages/cli/test/messages-list-filter.test.ts b/packages/cli/test/messages-list-filter.test.ts deleted file mode 100644 index 231a854f..00000000 --- a/packages/cli/test/messages-list-filter.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { matchesSender } from '../src/commands/messages/list.js' - -describe('messages list matchesSender', () => { - it('matches "me" for outgoing messages', () => { - expect(matchesSender({ isSender: true }, 'me')).toBe(true) - expect(matchesSender({ isSender: false }, 'me')).toBe(false) - expect(matchesSender({}, 'me')).toBe(false) - }) - - it('matches "others" for incoming messages', () => { - expect(matchesSender({ isSender: false }, 'others')).toBe(true) - expect(matchesSender({}, 'others')).toBe(true) - expect(matchesSender({ isSender: true }, 'others')).toBe(false) - }) - - it('matches by specific senderID', () => { - expect(matchesSender({ senderID: '@alice:beeper.com' }, '@alice:beeper.com')).toBe(true) - expect(matchesSender({ senderID: '@bob:beeper.com' }, '@alice:beeper.com')).toBe(false) - }) - - it('rejects non-objects', () => { - expect(matchesSender(null, 'me')).toBe(false) - expect(matchesSender('hello', 'me')).toBe(false) - }) -}) diff --git a/packages/cli/test/messages-search-validation.test.ts b/packages/cli/test/messages-search-validation.test.ts index ad053898..0adc3c40 100644 --- a/packages/cli/test/messages-search-validation.test.ts +++ b/packages/cli/test/messages-search-validation.test.ts @@ -16,10 +16,9 @@ describe('messages search query-or-filter requirement', () => { it('rejects empty query with no filters and exits with usage error', () => { const result = run('messages', 'search', '--json') expect(result.status).toBe(2) - const envelope = JSON.parse(result.stderr) - expect(envelope.success).toBe(false) - expect(envelope.exitCode).toBe(2) - expect(envelope.error).toMatch(/Provide a search query or at least one filter flag/) + const payload = JSON.parse(result.stderr) + expect(payload.error.exitCode).toBe(2) + expect(payload.error.message).toMatch(/Provide a search query or at least one filter flag/) }) it('accepts a bare query', () => { diff --git a/packages/cli/test/plugin-sdk.test.ts b/packages/cli/test/plugin-sdk.test.ts deleted file mode 100644 index 2d815aa9..00000000 --- a/packages/cli/test/plugin-sdk.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import * as sdk from '../src/plugin-sdk.js' - -describe('plugin-sdk public surface', () => { - it('exports BeeperCommand as a constructor', () => { - expect(typeof sdk.BeeperCommand).toBe('function') - }) - - it('exports the error class and factories', () => { - expect(typeof sdk.CLIError).toBe('function') - expect(sdk.ExitCodes.AuthRequired).toBe(3) - expect(sdk.notFound('x').exitCode).toBe(sdk.ExitCodes.NotFound) - expect(sdk.ambiguous('x').exitCode).toBe(sdk.ExitCodes.Ambiguous) - expect(sdk.notReady('x').exitCode).toBe(sdk.ExitCodes.NotReady) - expect(sdk.authRequired('x').exitCode).toBe(sdk.ExitCodes.AuthRequired) - expect(sdk.usageError('x').exitCode).toBe(sdk.ExitCodes.Usage) - }) - - it('exports printers and resolvers', () => { - for (const name of ['printData', 'printList', 'printSuccess', 'printFailure', 'collectPage', 'startStream', 'printIDs'] as const) { - expect(typeof sdk[name]).toBe('function') - } - for (const name of ['resolveAccountID', 'resolveAccountIDs', 'resolveChatID', 'listAccountIDs', 'userQueryFromInput'] as const) { - expect(typeof sdk[name]).toBe('function') - } - }) - - it('exports target + config helpers', () => { - for (const name of ['createBeeperClient', 'requireToken', 'resolveTarget', 'readConfig', 'updateConfig', 'writeConfig', 'resetConfig', 'getAccessToken', 'getBaseURL', 'configPath'] as const) { - expect(typeof sdk[name]).toBe('function') - } - }) - - it('exports the raw appRequest escape hatch', () => { - expect(typeof sdk.appRequest).toBe('function') - }) - - it('re-exports the oclif primitives plugins need', () => { - expect(typeof sdk.Args).toBe('object') - expect(typeof sdk.Flags).toBe('object') - expect(typeof sdk.Command).toBe('function') - expect(typeof sdk.ux).toBe('object') - }) -}) diff --git a/packages/cli/test/resolve.test.ts b/packages/cli/test/resolve.test.ts new file mode 100644 index 00000000..36390eec --- /dev/null +++ b/packages/cli/test/resolve.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test' +import { listAccountIDs, resolveAccountID } from '../src/lib/resolve.js' + +const clientWithAccounts = (accounts: unknown[]) => ({ + accounts: { + list: async () => accounts, + }, +}) + +describe('account resolution', () => { + it('uses accountID when present', async () => { + const client = clientWithAccounts([{ accountID: 'whatsapp-main', network: 'whatsapp' }]) + + expect(await resolveAccountID(client, 'whatsapp')).toBe('whatsapp-main') + expect(await listAccountIDs(client)).toEqual(['whatsapp-main']) + }) + + it('falls back to id when accountID is absent', async () => { + const client = clientWithAccounts([{ id: 'matrix-main', network: 'matrix' }]) + + expect(await resolveAccountID(client, 'matrix')).toBe('matrix-main') + expect(await listAccountIDs(client)).toEqual(['matrix-main']) + }) +}) diff --git a/packages/cli/test/watch-filter.test.ts b/packages/cli/test/watch-filter.test.ts deleted file mode 100644 index 5665eeef..00000000 --- a/packages/cli/test/watch-filter.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { passesFilter } from '../src/commands/watch.js' - -describe('watch passesFilter', () => { - const body = (type: string) => JSON.stringify({ type, chatID: '!x:beeper.com' }) - - it('passes through when no filter is set', () => { - expect(passesFilter(body('message.upserted'))).toBe(true) - }) - - it('respects include set', () => { - const filter = { include: new Set(['message.upserted']) } - expect(passesFilter(body('message.upserted'), filter)).toBe(true) - expect(passesFilter(body('message.deleted'), filter)).toBe(false) - expect(passesFilter(body('chat.upserted'), filter)).toBe(false) - }) - - it('respects exclude set', () => { - const filter = { exclude: new Set(['chat.upserted', 'chat.deleted']) } - expect(passesFilter(body('message.upserted'), filter)).toBe(true) - expect(passesFilter(body('chat.upserted'), filter)).toBe(false) - }) - - it('passes-through events without a type field', () => { - expect(passesFilter(JSON.stringify({ chatID: 'x' }), { include: new Set(['message.upserted']) })).toBe(true) - }) - - it('passes-through unparseable bodies', () => { - expect(passesFilter('not json', { include: new Set(['message.upserted']) })).toBe(true) - }) -}) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 64a740e5..4b472d66 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -8,6 +8,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "include": ["src/**/*.ts", "src/**/*.tsx"], + "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules"] } diff --git a/packages/npm/.gitignore b/packages/npm/.gitignore deleted file mode 100644 index 65ad7f62..00000000 --- a/packages/npm/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/bin/ -/binaries.json -/LICENSE -/README.md diff --git a/packages/npm/package.json b/packages/npm/package.json deleted file mode 100644 index cfceecf4..00000000 --- a/packages/npm/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "beeper-cli", - "version": "0.6.2", - "description": "Beeper CLI binary launcher", - "license": "MIT", - "type": "module", - "bin": { - "beeper": "bin/beeper.js" - }, - "files": [ - "bin", - "binaries.json", - "README.md", - "LICENSE" - ], - "scripts": { - "build": "bun scripts/build.ts", - "clean": "rm -rf bin binaries.json README.md LICENSE", - "test": "bun run build", - "typecheck": "bun build scripts/build.ts --target=bun --outdir=/tmp/beeper-cli-npm-check --entry-naming='[name].js'" - } -} diff --git a/packages/npm/scripts/build.ts b/packages/npm/scripts/build.ts deleted file mode 100644 index 95b406b9..00000000 --- a/packages/npm/scripts/build.ts +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bun -import { chmod, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const root = fileURLToPath(new URL('..', import.meta.url)) -const cliRoot = fileURLToPath(new URL('../../cli/', import.meta.url)) -const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) -const cliPkg = JSON.parse(await readFile(join(cliRoot, 'package.json'), 'utf8')) -const binariesPath = join(cliRoot, 'dist', 'bin', 'binaries.json') -const binaries = existsSync(binariesPath) - ? JSON.parse(await readFile(binariesPath, 'utf8')) - : { command: 'beeper', package: cliPkg.name, version: cliPkg.version, artifacts: [] } - -if (pkg.version !== cliPkg.version) { - throw new Error(`packages/npm version ${pkg.version} does not match packages/cli version ${cliPkg.version}`) -} - -await rm(join(root, 'bin'), { recursive: true, force: true }) -await rm(join(root, 'binaries.json'), { force: true }) -await rm(join(root, 'README.md'), { force: true }) -await rm(join(root, 'LICENSE'), { force: true }) -await mkdir(join(root, 'bin'), { recursive: true }) -await cp(join(cliRoot, 'README.md'), join(root, 'README.md')) -await cp(join(cliRoot, 'LICENSE'), join(root, 'LICENSE')) -await writeFile(join(root, 'binaries.json'), `${JSON.stringify(binaries, null, 2)}\n`) -await writeFile(join(root, 'bin', 'beeper.js'), launcher()) -await chmod(join(root, 'bin', 'beeper.js'), 0o755) - -function launcher() { - return `#!/usr/bin/env node -import { createHash } from 'node:crypto' -import { createWriteStream, existsSync } from 'node:fs' -import { chmod, mkdir, readFile, rename, rm } from 'node:fs/promises' -import { get } from 'node:https' -import { homedir, platform as osPlatform, arch as osArch, tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from 'node:child_process' - -const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..') -const manifest = JSON.parse(await readFile(join(packageRoot, 'binaries.json'), 'utf8')) -const platform = targetPlatform() -const artifact = manifest.artifacts.find(item => item.platform === platform) - -if (!artifact) { - console.error(\`beeper-cli does not ship a binary for \${process.platform}/\${process.arch}.\`) - process.exit(1) -} - -const cacheDir = process.env.BEEPER_CLI_BINARY_CACHE_DIR || join(homedir(), '.cache', 'beeper-cli', manifest.version) -const binPath = join(cacheDir, 'bin', manifest.command || 'beeper') - -const expectedBinarySha256 = artifact.binarySha256 || artifact.sha256 - -if (!existsSync(binPath) || await sha256(binPath).catch(() => '') !== expectedBinarySha256) { - const tempDir = join(tmpdir(), \`beeper-cli-\${manifest.version}-\${process.pid}\`) - const archivePath = join(tempDir, artifact.file) - await rm(tempDir, { recursive: true, force: true }) - await mkdir(tempDir, { recursive: true }) - await download(\`https://github.com/beeper/cli/releases/download/v\${manifest.version}/\${artifact.file}\`, archivePath) - const actual = await sha256(archivePath) - if (actual !== artifact.sha256) { - await rm(tempDir, { recursive: true, force: true }) - console.error(\`beeper-cli binary checksum mismatch for \${artifact.file}.\`) - process.exit(1) - } - await extract(archivePath, tempDir) - const extractedBin = join(tempDir, 'bin', manifest.command || 'beeper') - await chmod(extractedBin, 0o755) - await rm(cacheDir, { recursive: true, force: true }) - await mkdir(dirname(binPath), { recursive: true }) - await rename(extractedBin, binPath) - await rm(tempDir, { recursive: true, force: true }) -} - -const child = spawn(binPath, process.argv.slice(2), { stdio: 'inherit', env: process.env }) -child.on('exit', (code, signal) => { - if (signal) process.kill(process.pid, signal) - process.exit(code ?? 1) -}) - -function targetPlatform() { - const os = osPlatform() - const cpu = osArch() - const normalizedOS = os === 'darwin' || os === 'linux' ? os : os === 'win32' ? 'windows' : os - const normalizedArch = cpu === 'x64' || cpu === 'arm64' ? cpu : cpu - return \`\${normalizedOS}-\${normalizedArch}\` -} - -async function sha256(path) { - const hash = createHash('sha256') - hash.update(await readFile(path)) - return hash.digest('hex') -} - -async function download(url, destination) { - await new Promise((resolve, reject) => { - get(url, response => { - if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0) && response.headers.location) { - response.resume() - download(response.headers.location, destination).then(resolve, reject) - return - } - if (response.statusCode !== 200) { - response.resume() - reject(new Error(\`Download failed with HTTP \${response.statusCode}: \${url}\`)) - return - } - const file = createWriteStream(destination, { mode: 0o755 }) - response.pipe(file) - file.on('finish', () => file.close(resolve)) - file.on('error', reject) - }).on('error', reject) - }) -} - -async function extract(archivePath, destination) { - if (artifact.file.endsWith('.zip')) { - await run('/usr/bin/ditto', ['-x', '-k', archivePath, destination]) - return - } - if (artifact.file.endsWith('.tar.gz')) { - await run('tar', ['-xzf', archivePath, '-C', destination]) - return - } - throw new Error(\`Unsupported beeper-cli archive: \${artifact.file}\`) -} - -async function run(command, args) { - await new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: 'ignore' }) - child.on('error', reject) - child.on('exit', code => { - if (code === 0) resolve() - else reject(new Error(\`\${command} \${args.join(' ')} exited with \${code}\`)) - }) - }) -} -` -} diff --git a/run.sh b/run.sh new file mode 100755 index 00000000..198b924f --- /dev/null +++ b/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" +cd packages/cli +exec bun run dev -- "$@" diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts deleted file mode 100644 index 9881bc1e..00000000 --- a/scripts/publish-packages.ts +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env bun -import { existsSync } from "node:fs"; -import { readdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; - -const root = process.cwd(); -const args = Bun.argv.slice(2); - -const flags = new Set(args.filter((arg) => arg.startsWith("--"))); -const positional = args.filter((arg) => !arg.startsWith("--")); - -const dryRun = flags.has("--dry-run"); -const skipChecks = flags.has("--skip-checks"); -const skipExisting = flags.has("--skip-existing"); - -const usage = `Usage: bun run publish:packages [version] [--dry-run] [--skip-checks] [--skip-existing] - -Publishes: - - beeper-cli - - @beeper/cli-plugin-* - -All publishable packages are updated to the same version before publishing. -`; - -if (flags.has("--help") || flags.has("-h")) { - console.log(usage); - process.exit(0); -} - -let version = positional[0]; -if (!version) { - version = prompt("Version to publish (for beeper-cli and @beeper/cli-plugin-*):")?.trim(); -} - -if (!version || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/.test(version)) { - console.error(`Invalid semver version: ${version ?? ""}`); - console.error(usage); - process.exit(1); -} - -const readJson = async (path: string) => JSON.parse(await readFile(path, "utf8")); -const writeJson = async (path: string, value: unknown) => { - await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); -}; - -const run = async (command: string[], options: { cwd?: string; allowFailure?: boolean } = {}) => { - console.log(`$ ${command.join(" ")}`); - const proc = Bun.spawn(command, { - cwd: options.cwd ?? root, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }); - const code = await proc.exited; - if (code !== 0 && !options.allowFailure) { - throw new Error(`Command failed (${code}): ${command.join(" ")}`); - } - return code; -}; - -const packagesDir = join(root, "packages"); -const packageDirs = (await readdir(packagesDir, { withFileTypes: true })) - .filter((entry) => entry.isDirectory()) - .map((entry) => join(packagesDir, entry.name)); - -const packageJsonPaths = packageDirs - .map((dir) => join(dir, "package.json")) - .filter((path) => existsSync(path)); - -const packages = await Promise.all( - packageJsonPaths.map(async (path) => ({ path, dir: dirname(path), json: await readJson(path) })), -); - -const publishable = packages.filter( - (pkg) => pkg.json.name === "beeper-cli" || /^@beeper\/cli-plugin-/.test(pkg.json.name), -); - -if (publishable.length === 0) { - throw new Error("No publishable packages found."); -} - -const publishableNames = new Set(publishable.map((pkg) => pkg.json.name)); -const pluginNames = [...publishableNames].filter((name) => name.startsWith("@beeper/cli-plugin-")); - -for (const pkg of publishable) { - pkg.json.version = version; - - for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const) { - const deps = pkg.json[section]; - if (!deps) continue; - for (const depName of Object.keys(deps)) { - if (publishableNames.has(depName)) deps[depName] = `^${version}`; - } - } - - if (pkg.json.name === "beeper-cli") { - pkg.json.bin ??= {}; - if (pkg.json.bin.beeper === "./bin/run.js") pkg.json.bin.beeper = "bin/run.js"; - - pkg.json.oclif ??= {}; - pkg.json.oclif.jitPlugins ??= {}; - for (const pluginName of pluginNames) { - pkg.json.oclif.jitPlugins[pluginName] = `^${version}`; - } - } - - await writeJson(pkg.path, pkg.json); -} - -console.log(`Updated ${publishable.length} package.json file(s) to ${version}:`); -for (const pkg of publishable) console.log(` - ${pkg.json.name}@${version}`); - -await run(["bun", "install", "--lockfile-only"]); - -if (!skipChecks) { - await run(["bun", "run", "check"]); -} else { - console.warn("Skipping checks because --skip-checks was provided."); -} - -const ordered = [ - ...publishable.filter((pkg) => pkg.json.name === "beeper-cli"), - ...publishable.filter((pkg) => pkg.json.name !== "beeper-cli").sort((a, b) => a.json.name.localeCompare(b.json.name)), -]; - -for (const pkg of ordered) { - if (skipExisting) { - const code = await run(["npm", "view", `${pkg.json.name}@${version}`, "version"], { allowFailure: true }); - if (code === 0) { - console.log(`Skipping already-published ${pkg.json.name}@${version}`); - continue; - } - } - - const command = ["npm", "publish", "--access", "public"]; - if (dryRun) command.push("--dry-run"); - await run(command, { cwd: pkg.dir }); -} - -console.log(dryRun ? "Dry run complete." : `Published ${ordered.length} package(s) at ${version}.`); diff --git a/scripts/release.ts b/scripts/release.ts deleted file mode 100644 index e5366df1..00000000 --- a/scripts/release.ts +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bun -import { readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const root = fileURLToPath(new URL('..', import.meta.url)) -const version = process.argv[2] - -if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { - console.error('Usage: bun run release ') - console.error('Example: bun run release 0.6.1') - process.exit(2) -} - -await setPackageVersion('packages/cli/package.json', version) -await setPackageVersion('packages/npm/package.json', version) - -await run('bun', ['run', 'readme'], { - cwd: join(root, 'packages/cli'), - env: { ...process.env, PACKAGE_VERSION: version, TAG: `v${version}` }, -}) - -await run('bun', ['run', 'release:local'], { - cwd: join(root, 'packages/cli'), - env: { ...process.env, PACKAGE_VERSION: version, TAG: `v${version}` }, -}) - -async function setPackageVersion(path, nextVersion) { - const absolute = join(root, path) - const pkg = JSON.parse(await readFile(absolute, 'utf8')) - pkg.version = nextVersion - await writeFile(absolute, `${JSON.stringify(pkg, null, 2)}\n`) - console.log(`${path} -> ${nextVersion}`) -} - -async function run(command, args, options = {}) { - const child = Bun.spawn([command, ...args], { - cwd: options.cwd || root, - env: options.env || process.env, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }) - const code = await child.exited - if (code !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${code}`) -}