From f7d8730014dfd9a9ab08a8550d950c1f85799723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Misi=C5=82o?= Date: Thu, 16 Jul 2026 16:57:51 +0200 Subject: [PATCH] feat: cli --- .changeset/pre.json | 14 + .changeset/quiet-messages-cli.md | 7 + AGENTS.md | 11 +- README.md | 30 +- RELEASING.md | 39 +- packages/cli/LICENSE | 21 + packages/cli/README.md | 475 ++++++++++++++++ packages/cli/package.json | 60 +- packages/cli/src/cli.ts | 4 + packages/cli/src/commands/attachment.ts | 41 ++ packages/cli/src/commands/base-command.ts | 38 ++ packages/cli/src/commands/client-command.ts | 36 ++ packages/cli/src/commands/completion.ts | 95 ++++ packages/cli/src/commands/config.ts | 57 ++ packages/cli/src/commands/connection.ts | 440 +++++++++++++++ packages/cli/src/commands/conversation.ts | 77 +++ packages/cli/src/commands/default.ts | 11 + packages/cli/src/commands/message.ts | 29 + .../src/commands/provider-options-command.ts | 63 +++ packages/cli/src/commands/provider.ts | 221 ++++++++ packages/cli/src/commands/reaction.ts | 85 +++ packages/cli/src/commands/schema.ts | 32 ++ packages/cli/src/commands/send.ts | 251 +++++++++ packages/cli/src/commands/typing.ts | 49 ++ packages/cli/src/commands/webhook.ts | 94 ++++ packages/cli/src/config.ts | 244 +++++++++ packages/cli/src/context.ts | 60 ++ packages/cli/src/credentials.ts | 123 +++++ packages/cli/src/errors.ts | 122 +++++ packages/cli/src/input.ts | 251 +++++++++ packages/cli/src/output.ts | 95 ++++ packages/cli/src/program.ts | 106 ++++ packages/cli/src/provider-names.ts | 3 + packages/cli/src/providers.ts | 474 ++++++++++++++++ packages/cli/src/runtime.ts | 272 ++++++++++ packages/cli/src/webhook-server.ts | 283 ++++++++++ packages/cli/test/cli.integration.test.ts | 377 +++++++++++++ packages/cli/test/cli.test.ts | 513 ++++++++++++++++++ packages/cli/test/config.test.ts | 146 +++++ packages/cli/test/credentials.test.ts | 103 ++++ packages/cli/test/input.test.ts | 24 + packages/cli/test/providers.test.ts | 183 +++++++ packages/cli/test/run-integration.mjs | 27 + packages/cli/test/webhook-server.test.ts | 255 +++++++++ packages/cli/tsconfig.json | 5 + packages/cli/tsup.config.ts | 14 + packages/cli/vitest.config.ts | 7 + packages/providers/README.md | 3 + pnpm-lock.yaml | 462 ++++++++++++++++ scripts/check-packages.sh | 2 + 50 files changed, 6410 insertions(+), 24 deletions(-) create mode 100644 .changeset/pre.json create mode 100644 .changeset/quiet-messages-cli.md create mode 100644 packages/cli/LICENSE create mode 100644 packages/cli/README.md create mode 100644 packages/cli/src/cli.ts create mode 100644 packages/cli/src/commands/attachment.ts create mode 100644 packages/cli/src/commands/base-command.ts create mode 100644 packages/cli/src/commands/client-command.ts create mode 100644 packages/cli/src/commands/completion.ts create mode 100644 packages/cli/src/commands/config.ts create mode 100644 packages/cli/src/commands/connection.ts create mode 100644 packages/cli/src/commands/conversation.ts create mode 100644 packages/cli/src/commands/default.ts create mode 100644 packages/cli/src/commands/message.ts create mode 100644 packages/cli/src/commands/provider-options-command.ts create mode 100644 packages/cli/src/commands/provider.ts create mode 100644 packages/cli/src/commands/reaction.ts create mode 100644 packages/cli/src/commands/schema.ts create mode 100644 packages/cli/src/commands/send.ts create mode 100644 packages/cli/src/commands/typing.ts create mode 100644 packages/cli/src/commands/webhook.ts create mode 100644 packages/cli/src/config.ts create mode 100644 packages/cli/src/context.ts create mode 100644 packages/cli/src/credentials.ts create mode 100644 packages/cli/src/errors.ts create mode 100644 packages/cli/src/input.ts create mode 100644 packages/cli/src/output.ts create mode 100644 packages/cli/src/program.ts create mode 100644 packages/cli/src/provider-names.ts create mode 100644 packages/cli/src/providers.ts create mode 100644 packages/cli/src/runtime.ts create mode 100644 packages/cli/src/webhook-server.ts create mode 100644 packages/cli/test/cli.integration.test.ts create mode 100644 packages/cli/test/cli.test.ts create mode 100644 packages/cli/test/config.test.ts create mode 100644 packages/cli/test/credentials.test.ts create mode 100644 packages/cli/test/input.test.ts create mode 100644 packages/cli/test/providers.test.ts create mode 100644 packages/cli/test/run-integration.mjs create mode 100644 packages/cli/test/webhook-server.test.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/tsup.config.ts create mode 100644 packages/cli/vitest.config.ts diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..6ec88ba --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,14 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "@imessage-sdk/example-basic-blooio": "0.0.0", + "@imessage-sdk/chat-adapter": "0.1.0", + "imessage-cli": "0.0.0", + "imessage-sdk": "0.1.3", + "@imessage-sdk/blooio": "0.1.2", + "@imessage-sdk/photon": "0.1.2", + "@imessage-sdk/sendblue": "0.1.0" + }, + "changesets": [] +} diff --git a/.changeset/quiet-messages-cli.md b/.changeset/quiet-messages-cli.md new file mode 100644 index 0000000..fa10054 --- /dev/null +++ b/.changeset/quiet-messages-cli.md @@ -0,0 +1,7 @@ +--- +'imessage-cli': minor +--- + +Add the first provider-neutral iMessage CLI with bundled Blooio, Photon, and Sendblue support, +secure named connections, JSON agent input and output, normalized messaging commands, provider +extensions, and an opt-in experimental Hono-based local signed-webhook server. diff --git a/AGENTS.md b/AGENTS.md index ec094f6..f1fec6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ depend on the public `imessage-sdk` interface rather than provider internals. | `packages/providers/` | Independently published provider package | | `packages/providers/README.md` | Cross-provider feature support matrix | | `packages/chat-adapter` | Chat SDK integration (`@imessage-sdk/chat-adapter`) | -| `packages/cli` | Private placeholder for `@imessage-sdk/cli` | +| `packages/cli` | Provider-neutral CLI (`imessage-cli`) | | `examples/basic-blooio` | Opt-in live example using only published Blooio and core APIs | | `test/package-consumer` | Clean TypeScript consumer used by package smoke tests | | `.changeset` | Changesets configuration, prerelease state, and pending release notes | @@ -51,7 +51,7 @@ The repository does not use Turborepo. Workspace membership is defined only by ```text @imessage-sdk/ -> imessage-sdk @imessage-sdk/chat-adapter -> imessage-sdk -future adapters and CLI -> imessage-sdk +imessage-cli -> imessage-sdk + every official provider ``` Workspace packages import package names, never source files from another package: @@ -263,8 +263,9 @@ When adding a provider: 6. Add an opt-in live integration test when real API verification is possible. 7. Add the provider to `packages/providers/README.md` and its support matrix. 8. Add the package to `scripts/check-packages.sh` and `test/package-consumer`. -9. Document install, configuration, verified operations, and known limitations. -10. Add a Changeset for all affected public packages. +9. Add the provider to the `imessage-cli` registry; its completeness test must pass. +10. Document install, configuration, verified operations, and known limitations. +11. Add a Changeset for all affected public packages. ## Coding standards @@ -324,7 +325,7 @@ Use conventional SemVer: - `major`: breaking changes Select only public packages actually affected. Tests, internal refactors, documentation-only -changes, and private placeholder packages do not need a changeset unless they alter a published +changes, and private workspace packages do not need a changeset unless they alter a published artifact. When Changesets prerelease mode is active, eligible releases receive the configured prerelease suffix. diff --git a/README.md b/README.md index e13536c..1df1e08 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ A provider-neutral TypeScript conversation layer for iMessage infrastructure. The repository contains the provider-neutral [`imessage-sdk`](./packages/imessage-sdk) -core, independently installable providers, and the +core, independently installable providers, the [`@imessage-sdk/chat-adapter`](./packages/chat-adapter) for -[Chat SDK](https://chat-sdk.dev). The core also exposes a public contract for custom providers. +[Chat SDK](https://chat-sdk.dev), and [`imessage-cli`](./packages/cli) for local users and agents. +The core also exposes a public contract for custom providers. ## Install @@ -62,13 +63,29 @@ packages/ │ ├── photon/ @imessage-sdk/photon │ └── sendblue/ @imessage-sdk/sendblue ├── chat-adapter/ @imessage-sdk/chat-adapter -└── cli/ Private placeholder for @imessage-sdk/cli +└── cli/ imessage-cli examples/ └── basic-blooio/ Opt-in live Blooio API and webhook example ``` -The core, provider packages, and Chat SDK adapter are independently publishable. The repository -root and remaining future package placeholders are private. +The core, provider packages, Chat SDK adapter, and CLI are independently publishable. The +repository root is private. + +## CLI + +The CLI bundles every official provider and supports flags, saved OS-keychain connections, stable +JSON input/output, attachments, replies, normalized operations, and an experimental Hono-based +local signed-webhook server: + +```bash +npx imessage-cli@beta send \ + --provider blooio \ + --to +15551234567 \ + --text 'Hello' +``` + +See the [CLI README](./packages/cli/README.md) for credential storage, agent input, provider +commands, and ngrok or Cloudflare Tunnel setup. ## Chat SDK @@ -119,7 +136,8 @@ then creates package-specific Git tags and GitHub Releases, such as Before publishing, the same release command used by automation also packs each public package, runs Publint and Are the Types Wrong, installs all tarballs in -a clean strict-TypeScript consumer, and checks every public import. +a clean strict-TypeScript consumer, checks every public import, and invokes the +installed CLI. Stable releases publish under npm's `latest` dist-tag, while prereleases publish under `beta`. See [RELEASING.md](./RELEASING.md) for the complete maintainer workflow. diff --git a/RELEASING.md b/RELEASING.md index f9f96d2..54f4009 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,14 +5,14 @@ requests, npm trusted publishing, and package-specific GitHub Releases. ## What is published -| Directory | npm package | Status | -| ----------------------------- | ---------------------------- | ------------------- | -| `packages/imessage-sdk` | `imessage-sdk` | Public | -| `packages/providers/blooio` | `@imessage-sdk/blooio` | Public | -| `packages/providers/photon` | `@imessage-sdk/photon` | Public | -| `packages/providers/sendblue` | `@imessage-sdk/sendblue` | Public | -| `packages/chat-adapter` | `@imessage-sdk/chat-adapter` | Public | -| `packages/cli` | `@imessage-sdk/cli` | Private placeholder | +| Directory | npm package | Status | +| ----------------------------- | ---------------------------- | ------ | +| `packages/imessage-sdk` | `imessage-sdk` | Public | +| `packages/providers/blooio` | `@imessage-sdk/blooio` | Public | +| `packages/providers/photon` | `@imessage-sdk/photon` | Public | +| `packages/providers/sendblue` | `@imessage-sdk/sendblue` | Public | +| `packages/chat-adapter` | `@imessage-sdk/chat-adapter` | Public | +| `packages/cli` | `imessage-cli` | Public | ## One-time local setup @@ -102,7 +102,7 @@ changelogs. Configure trusted publishing separately in the settings for `imessage-sdk`, `@imessage-sdk/blooio`, `@imessage-sdk/photon`, `@imessage-sdk/sendblue`, and -`@imessage-sdk/chat-adapter`: +`@imessage-sdk/chat-adapter`, and `imessage-cli`: ```text Provider: GitHub Actions @@ -154,6 +154,25 @@ pnpm --filter @imessage-sdk/ pack --pack-destination "$PACKAGE_DIR" npm publish "$PACKAGE_DIR/.tgz" --access public --provenance=false ``` +For the first `imessage-cli` beta, enter Changesets prerelease mode before the Version Packages +pull request is generated: + +```bash +pnpm changeset pre enter beta +``` + +After that pull request versions the package, bootstrap its exact tarball locally with `--tag beta`, +then configure `imessage-cli` as another trusted publisher using the settings above: + +```bash +PACKAGE_DIR=$(mktemp -d) +pnpm --filter imessage-cli pack --pack-destination "$PACKAGE_DIR" +npm publish "$PACKAGE_DIR/imessage-cli-0.1.0-beta.0.tgz" \ + --tag beta \ + --access public \ + --provenance=false +``` + Add `--tag beta` when bootstrapping a prerelease. For the initial stable Sendblue release, use: ```bash @@ -282,6 +301,8 @@ npm view @imessage-sdk/blooio version npm dist-tag ls @imessage-sdk/blooio npm view @imessage-sdk/sendblue version npm dist-tag ls @imessage-sdk/sendblue +npm view imessage-cli@beta version +npm dist-tag ls imessage-cli ``` Then install the release in a clean external project using `@beta` during the diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..6bb425f --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 imessage-sdk contributors + +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/README.md b/packages/cli/README.md new file mode 100644 index 0000000..8d0557f --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,475 @@ +# imessage-cli + +Provider-neutral terminal access to iMessage infrastructure through +[`imessage-sdk`](https://imessage-sdk.dev). + +`imessage-cli` is designed for both local users and AI agents. It bundles every official provider, +keeps one provider connection per command, accepts flags or validated JSON input, and emits stable +machine-readable results. + +> The CLI is currently in beta. Its command and JSON schemas may evolve before the first stable +> release. + +## Installation + +Install the current beta globally: + +```bash +npm install --global imessage-cli@beta +``` + +Or run it without a global installation: + +```bash +npx imessage-cli@beta --help +``` + +The CLI requires Node.js `^20.19.0`, `^22.13.0`, or `>=24`. + +## Live CLI verification (maintainers) + +The repository includes an opt-in end-to-end suite that invokes the built CLI against real +provider accounts. It sends messages and performs supported provider mutations, but does not +start a webhook server or create a tunnel. + +Create an ignored repository-root `.env.cli-test` file with the credentials and dedicated test +recipient/assets for all three providers. It must explicitly opt in: + +```dotenv +IMESSAGE_CLI_RUN_LIVE=1 + +BLOOIO_API_KEY= +BLOOIO_FROM_NUMBER= +IMESSAGE_CLI_TEST_RECIPIENT= +IMESSAGE_CLI_TEST_IMAGE_URL= +IMESSAGE_CLI_TEST_VIDEO_URL= +IMESSAGE_CLI_TEST_FILE_URL= + +PHOTON_PROJECT_ID= +PHOTON_PROJECT_SECRET= +PHOTON_PHONE_NUMBER= + +SENDBLUE_API_KEY= +SENDBLUE_API_SECRET= +SENDBLUE_FROM_NUMBER= +``` + +Then run: + +```bash +pnpm --filter imessage-cli test:integration +``` + +The test file is ignored by Git. The runner loads it directly, redacts known secrets from its +own failure messages, and uses only per-command environment credentials—no persistent CLI +connection or keychain record is created. + +## Providers + +Every provider published from `packages/providers/*` is bundled with the CLI: + +```text +blooio +photon +sendblue +``` + +Inspect the installed providers and their normalized capabilities: + +```bash +imessage-cli provider list +imessage-cli provider show blooio +imessage-cli provider show photon --json +``` + +## Send a message + +Use provider environment variables or a saved connection and send directly: + +```bash +imessage-cli send \ + --provider blooio \ + --to +15551234567 \ + --text 'Hello' +``` + +Addresses may be written as a phone number, email address, or an explicit address: + +```text ++15551234567 +hello@example.com +phone:+15551234567 +email:hello@example.com +``` + +Send through an existing provider-native conversation: + +```bash +imessage-cli send \ + --provider photon \ + --conversation 'provider-conversation-id' \ + --text 'Hello again' +``` + +Send a reply: + +```bash +imessage-cli send \ + --provider blooio \ + --conversation 'provider-conversation-id' \ + --text 'Replying in this thread' \ + --reply-to 'provider-message-id' \ + --reply-part 0 +``` + +### Attachments + +Attachment flags are repeatable and accept a public HTTP(S) URL or local path: + +```bash +imessage-cli send \ + --provider photon \ + --to +15551234567 \ + --text 'See these files' \ + --image ./screenshot.png \ + --video https://cdn.example.com/video.mp4 \ + --file ./report.pdf +``` + +Current provider restrictions still apply: + +- Blooio requires public attachment URLs. +- Photon accepts URLs and local files. +- Sendblue accepts one URL or local file per message. +- Group messaging remains disabled for the current official provider configurations. + +For predictable memory use, local attachments are read sequentially and limited to 100 MiB in +total per command. Provider limits may be lower. + +The CLI never uploads a local Blooio attachment to third-party storage implicitly. + +### Agent input + +Use `--input -` to read a complete validated input document from stdin. `--json` controls the +output format; it does not change how stdin is parsed. + +```bash +imessage-cli send \ + --provider photon \ + --input - \ + --json <<'JSON' +{ + "to": [ + { + "kind": "phone", + "value": "+15551234567" + } + ], + "text": "Generated by an agent", + "attachments": [ + { + "kind": "image", + "source": { + "type": "path", + "path": "./screenshot.png" + } + } + ], + "idempotencyKey": "agent-run-018fd6" +} +JSON +``` + +An input file works as well: + +```bash +imessage-cli send --provider photon --input ./send.json --json +``` + +Input flags and `--input` are mutually exclusive. Discover the current agent-facing schema with: + +```bash +imessage-cli schema +imessage-cli schema send --json +``` + +Validate a send without contacting the provider: + +```bash +imessage-cli send \ + --provider blooio \ + --to +15551234567 \ + --text 'Hello' \ + --dry-run \ + --json +``` + +## Credentials and saved connections + +There are three credential modes. + +### Saved connection + +Create a durable named connection: + +```bash +imessage-cli connection add support --provider blooio +``` + +Interactive input is masked where appropriate. Credentials and selected sending identities are +stored in the operating system credential store: + +- macOS Keychain +- Windows Credential Manager +- Linux Secret Service when available + +On headless Linux, the native keyring dependency may fall back to the kernel keyring, whose entries +can be session-scoped. Use a Secret Service daemon for durable saved connections, or use environment +variables when no durable operating-system credential service is available. + +The JSON configuration contains only connection metadata and non-sensitive behavioral settings. +It is written with user-only permissions. The CLI never falls back to a plaintext credential file. + +The first saved connection for a provider becomes that provider's default. It can then be selected +with either form: + +```bash +imessage-cli send --provider blooio --to +15551234567 --text 'Hello' +imessage-cli send --connection support --to +15551234567 --text 'Hello' +``` + +Manage saved connections: + +```bash +imessage-cli connection list +imessage-cli connection show support +imessage-cli connection doctor support +imessage-cli connection doctor support --offline +imessage-cli connection remove support +``` + +`show` reports credential presence and masks identities. It never prints secret values. `doctor` +performs only non-mutating checks and never sends a message. `remove` deletes local configuration +and keychain entries; it does not change the remote provider account or revoke an API key. + +Set or replace one credential safely from a masked prompt: + +```bash +imessage-cli connection credential set support webhookSecret +``` + +For non-interactive setup, read it from stdin: + +```bash +imessage-cli connection credential set support webhookSecret --input - < secret-value-file +``` + +### Environment variables + +Environment variables provide an ephemeral automation and CI interface: + +| Provider | Variables | +| -------- | -------------------------------------------------------------------------------------------- | +| Blooio | `BLOOIO_API_KEY`, `BLOOIO_FROM_NUMBER`, `BLOOIO_WEBHOOK_SECRET` | +| Photon | `PHOTON_PROJECT_ID`, `PHOTON_PROJECT_SECRET`, `PHOTON_PHONE_NUMBER`, `PHOTON_WEBHOOK_SECRET` | +| Sendblue | `SENDBLUE_API_KEY`, `SENDBLUE_API_SECRET`, `SENDBLUE_FROM_NUMBER`, `SENDBLUE_WEBHOOK_SECRET` | + +Environment values override a saved connection for the current process and are never persisted. + +### One-time options + +Provider credential options can be supplied for one invocation: + +```bash +imessage-cli send \ + --provider blooio \ + --api-key "$BLOOIO_API_KEY" \ + --from-number +15551234567 \ + --to +15557654321 \ + --text 'Hello' +``` + +One-time options are never saved or logged by the CLI. Supplying a literal secret as a command-line +argument is nevertheless discouraged because operating-system process inspection and shell history +may expose it. Prefer a saved connection, a masked prompt, or environment injection. + +Resolution order is: + +```text +command option + -> environment variable + -> selected/default saved connection + -> provider default +``` + +Machine-readable and non-interactive commands never prompt. A missing required value becomes a +structured error. + +## Experimental local webhooks + +The CLI webhook server is experimental in the initial release. You must explicitly acknowledge +that status with `--experimental`; otherwise the command exits with a usage error before loading +provider credentials or binding a port. The server application is implemented with Hono and runs +through Hono's official Node.js adapter. + +Start a signed-webhook receiver on the loopback interface: + +```bash +imessage-cli webhook serve \ + --experimental \ + --provider blooio \ + --host 127.0.0.1 \ + --port 8787 \ + --path /webhooks \ + --json +``` + +The server: + +- passes the untouched body and headers to the provider verifier; +- emits normalized events as JSON Lines on stdout; +- writes startup information and diagnostics to stderr; +- returns `204` for an accepted webhook, including an irrelevant but valid event; +- returns `401` for an invalid signature; +- limits request body size; +- exposes `GET /healthz` by default; +- closes the selected SDK client on `SIGINT` or `SIGTERM`. + +It binds `127.0.0.1` by default and does not install or supervise a tunnel. + +### ngrok + +Run the webhook server in one terminal and ngrok in another: + +```bash +ngrok http 8787 +``` + +If ngrok assigns `https://example.ngrok.app`, register this full callback URL in the provider +dashboard: + +```text +https://example.ngrok.app/webhooks +``` + +For private message content, consider disabling ngrok request inspection: + +```bash +ngrok http 8787 --url https://example.ngrok.app --inspect=false +``` + +### Cloudflare Quick Tunnel + +```bash +cloudflared tunnel --url http://127.0.0.1:8787 +``` + +Append `/webhooks` to the generated HTTPS origin. Quick Tunnel hostnames are temporary and may need +to be updated in the provider dashboard after a restart. + +The webhook command is intended for local development and agent experiments. It does not provide a +durable queue, high availability, delivery persistence, or replay ledger. Production webhook +consumers should deploy the SDK handler behind a stable HTTPS endpoint. + +### External agent process + +Normalized webhook events can be piped into any program: + +```bash +imessage-cli webhook serve --experimental --provider photon --json | node ./my-agent.mjs +``` + +That program can invoke its preferred AI model and reply with a separate `imessage-cli send` +command. The CLI does not bundle a model SDK or tunnel provider. + +## Other normalized commands + +```text +message get +conversation open +conversation get +conversation mark-read +attachment download +reaction add +reaction remove +typing start +typing stop +``` + +Provider-specific extensions remain namespaced: + +```text +provider blooio numbers list +provider blooio message status +provider photon line show +provider sendblue message status +provider sendblue tapback add +``` + +Unsupported normalized operations return `UnsupportedCapabilityError`; the CLI does not silently +approximate provider behavior. + +## Machine output + +`--json` writes one success object to stdout: + +```json +{ + "schemaVersion": 1, + "ok": true, + "command": "send", + "context": { + "provider": "blooio", + "connectionId": "support" + }, + "data": { + "id": "provider-message-id" + } +} +``` + +Failures are written to stderr: + +```json +{ + "schemaVersion": 1, + "ok": false, + "command": "send", + "error": { + "type": "AmbiguousDeliveryError", + "code": "ambiguous_delivery", + "message": "The provider may have accepted the message.", + "retryable": true, + "deliveryAmbiguous": true, + "safeToRetry": false + } +} +``` + +Provider `raw` payloads, credentials, and authorization headers are omitted. The CLI never retries +an ambiguous send automatically. + +Exit codes: + +| Code | Meaning | +| ----: | --------------------------------------------------------------- | +| `0` | Success | +| `1` | Unexpected internal failure | +| `2` | Invalid command input, configuration, or local credential store | +| `3` | Authentication failure | +| `4` | Unsupported or permanent provider operation | +| `5` | Rate limit or provider availability failure | +| `6` | Ambiguous message delivery; do not retry blindly | +| `130` | Interrupted with `SIGINT` | +| `143` | Terminated with `SIGTERM` | + +## Shell completion + +```bash +imessage-cli completion bash +imessage-cli completion zsh +imessage-cli completion fish +imessage-cli completion powershell +``` + +Follow the installation convention for the selected shell and source the generated script. diff --git a/packages/cli/package.json b/packages/cli/package.json index 089435f..d82a728 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,11 +1,63 @@ { - "name": "@imessage-sdk/cli", + "name": "imessage-cli", "version": "0.0.0", - "private": true, - "description": "Command-line interface for imessage-sdk", + "description": "Provider-neutral command-line interface for iMessage infrastructure", + "author": "jmisilo", + "keywords": [ + "imessage", + "cli", + "messaging", + "automation", + "ai-agents" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/jmisilo/imessage-sdk.git", + "directory": "packages/cli" + }, + "homepage": "https://imessage-sdk.dev", + "bugs": { + "url": "https://github.com/jmisilo/imessage-sdk/issues" + }, "license": "MIT", "type": "module", + "sideEffects": false, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "bin": { + "imessage-cli": "./dist/cli.js" + }, + "exports": { + "./package.json": "./package.json" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:integration": "pnpm build && node test/run-integration.mjs", + "test:watch": "vitest", + "typecheck": "tsc --project tsconfig.json --noEmit" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, "dependencies": { - "imessage-sdk": "workspace:^" + "@hono/node-server": "^2.0.10", + "@imessage-sdk/blooio": "workspace:^", + "@imessage-sdk/photon": "workspace:^", + "@imessage-sdk/sendblue": "workspace:^", + "@inquirer/prompts": "^8.5.2", + "@napi-rs/keyring": "^1.3.0", + "clipanion": "3.2.1", + "hono": "^4.12.30", + "imessage-sdk": "workspace:^", + "zod": "^4.4.3" } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..2a31575 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,4 @@ +import { createDefaultContext } from './context.js'; +import { runCli } from './program.js'; + +process.exitCode = await runCli(process.argv.slice(2), createDefaultContext()); diff --git a/packages/cli/src/commands/attachment.ts b/packages/cli/src/commands/attachment.ts new file mode 100644 index 0000000..60aea3a --- /dev/null +++ b/packages/cli/src/commands/attachment.ts @@ -0,0 +1,41 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { Option } from 'clipanion'; + +import { CliUsageError } from '../errors.js'; +import { ClientCommand } from './client-command.js'; + +export class AttachmentDownloadCommand extends ClientCommand { + static override paths = [['attachment', 'download']]; + static override usage = AttachmentDownloadCommand.Usage({ + category: 'Attachments', + description: 'Download one provider-native inbound attachment.', + }); + + attachmentId = Option.String({ name: 'attachmentId' }); + outputPath = Option.String('--output', { required: true }); + + async execute(): Promise { + return await this.action('attachment.download', async () => { + if (this.outputPath === '-' && this.json) { + throw new CliUsageError('--json cannot be combined with binary --output -.'); + } + + await this.withClient('api', async ({ client, providerName, connectionId }) => { + const data = await client.attachments.download(this.attachmentId); + if (this.outputPath === '-') { + this.context.stdout.write(data); + return; + } + await mkdir(dirname(this.outputPath), { recursive: true }); + await writeFile(this.outputPath, data); + this.output().success( + 'attachment.download', + { attachmentId: this.attachmentId, path: this.outputPath, byteLength: data.byteLength }, + { provider: providerName, connectionId }, + ); + }); + }); + } +} diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts new file mode 100644 index 0000000..65741f3 --- /dev/null +++ b/packages/cli/src/commands/base-command.ts @@ -0,0 +1,38 @@ +import { Command, Option } from 'clipanion'; + +import type { CliContext } from '../context.js'; +import { ConfigStore } from '../config.js'; +import { exitCodeForError } from '../errors.js'; +import { CommandOutput } from '../output.js'; + +export abstract class BaseCommand extends Command { + json = Option.Boolean('--json', false, { + description: 'Write one stable JSON object instead of human-readable output.', + }); + + configPath = Option.String('--config', { + description: 'Use a specific imessage-cli configuration file.', + }); + + protected output(): CommandOutput { + return new CommandOutput(this.context.stdout, this.context.stderr, this.json); + } + + protected configStore(): ConfigStore { + return this.configPath === undefined + ? this.context.configStore + : new ConfigStore(this.configPath); + } + + protected async action( + command: string, + operation: () => Promise, + ): Promise { + try { + return (await operation()) ?? 0; + } catch (error) { + this.output().failure(command, error); + return exitCodeForError(error); + } + } +} diff --git a/packages/cli/src/commands/client-command.ts b/packages/cli/src/commands/client-command.ts new file mode 100644 index 0000000..7603a4b --- /dev/null +++ b/packages/cli/src/commands/client-command.ts @@ -0,0 +1,36 @@ +import { Option } from 'clipanion'; + +import type { ProviderPurpose } from '../providers.js'; +import type { ResolvedClient } from '../runtime.js'; +import { withResolvedClient } from '../runtime.js'; +import { ProviderOptionsCommand } from './provider-options-command.js'; + +export abstract class ClientCommand extends ProviderOptionsCommand { + connection = Option.String('--connection', { + description: 'Use an exact saved connection; its provider is inferred.', + }); + + protected async withClient( + purpose: ProviderPurpose, + operation: (resolved: ResolvedClient) => Promise, + ): Promise { + const config = await this.configStore().load(); + return await withResolvedClient( + this.context, + config, + { + ...(this.provider === undefined ? {} : { provider: this.provider }), + ...(this.connection === undefined ? {} : { connection: this.connection }), + overrides: this.providerOverrides(), + purpose, + allowPrompt: this.promptsAllowed(), + }, + operation, + (_error, resolved) => { + this.output().diagnostic( + `Warning: ${resolved.providerName} resources did not close cleanly; the command result is unchanged.`, + ); + }, + ); + } +} diff --git a/packages/cli/src/commands/completion.ts b/packages/cli/src/commands/completion.ts new file mode 100644 index 0000000..24ebc4b --- /dev/null +++ b/packages/cli/src/commands/completion.ts @@ -0,0 +1,95 @@ +import { Option } from 'clipanion'; + +import { CliUsageError } from '../errors.js'; +import { BaseCommand } from './base-command.js'; + +const COMMANDS = [ + 'send', + 'message get', + 'conversation open', + 'conversation get', + 'conversation mark-read', + 'attachment download', + 'reaction add', + 'reaction remove', + 'typing start', + 'typing stop', + 'webhook serve', + 'provider list', + 'provider show', + 'connection add', + 'connection list', + 'connection show', + 'connection doctor', + 'connection remove', + 'config init', + 'config validate', + 'config path', + 'schema', +] as const; + +function bashCompletion(): string { + const topLevel = [...new Set(COMMANDS.map((command) => command.split(' ')[0]))].join(' '); + return `_imessage_cli_complete() { + local current="\${COMP_WORDS[COMP_CWORD]}" + COMPREPLY=( $(compgen -W "${topLevel}" -- "$current") ) +} +complete -F _imessage_cli_complete imessage-cli`; +} + +function zshCompletion(): string { + const topLevel = [...new Set(COMMANDS.map((command) => command.split(' ')[0]))]; + return `#compdef imessage-cli +_arguments '1:command:(${topLevel.join(' ')})' '*::argument:->args'`; +} + +function fishCompletion(): string { + return [ + 'complete -c imessage-cli -f', + ...[...new Set(COMMANDS.map((command) => command.split(' ')[0]))].map( + (command) => `complete -c imessage-cli -n '__fish_use_subcommand' -a '${command}'`, + ), + ].join('\n'); +} + +function powershellCompletion(): string { + const commands = [...new Set(COMMANDS.map((command) => command.split(' ')[0]))] + .map((command) => `'${command}'`) + .join(', '); + return `Register-ArgumentCompleter -Native -CommandName imessage-cli -ScriptBlock { + param($wordToComplete) + ${commands} | Where-Object { $_ -like "$wordToComplete*" } +}`; +} + +export class CompletionCommand extends BaseCommand { + static override paths = [['completion']]; + static override usage = CompletionCommand.Usage({ + category: 'Automation', + description: 'Generate basic shell completion for top-level commands.', + }); + + shell = Option.String({ name: 'shell' }); + + async execute(): Promise { + return await this.action('completion', async () => { + if (this.json) throw new CliUsageError('--json is not supported for shell completion.'); + switch (this.shell) { + case 'bash': + this.context.stdout.write(`${bashCompletion()}\n`); + return; + case 'zsh': + this.context.stdout.write(`${zshCompletion()}\n`); + return; + case 'fish': + this.context.stdout.write(`${fishCompletion()}\n`); + return; + case 'powershell': + this.context.stdout.write(`${powershellCompletion()}\n`); + return; + default: + throw new CliUsageError('Expected bash, zsh, fish, or powershell.'); + } + }); + } +} diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts new file mode 100644 index 0000000..e8d6fde --- /dev/null +++ b/packages/cli/src/commands/config.ts @@ -0,0 +1,57 @@ +import { Option } from 'clipanion'; + +import { createEmptyConfig } from '../config.js'; +import { BaseCommand } from './base-command.js'; + +export class ConfigPathCommand extends BaseCommand { + static override paths = [['config', 'path']]; + static override usage = ConfigPathCommand.Usage({ + category: 'Configuration', + description: 'Print the active configuration path.', + }); + + async execute(): Promise { + return await this.action('config.path', async () => { + this.output().success('config.path', this.configStore().path); + }); + } +} + +export class ConfigValidateCommand extends BaseCommand { + static override paths = [['config', 'validate']]; + static override usage = ConfigValidateCommand.Usage({ + category: 'Configuration', + description: 'Parse and validate the active configuration.', + }); + + async execute(): Promise { + return await this.action('config.validate', async () => { + const store = this.configStore(); + const config = await store.load(); + this.output().success('config.validate', { + path: store.path, + valid: true, + connectionCount: Object.keys(config.connections).length, + }); + }); + } +} + +export class ConfigInitCommand extends BaseCommand { + static override paths = [['config', 'init']]; + static override usage = ConfigInitCommand.Usage({ + category: 'Configuration', + description: 'Create an empty, user-only configuration file.', + }); + + force = Option.Boolean('--force', false, { description: 'Replace an existing configuration.' }); + + async execute(): Promise { + return await this.action('config.init', async () => { + const store = this.configStore(); + if (this.force) await store.save(createEmptyConfig()); + else await store.create(createEmptyConfig()); + this.output().success('config.init', { path: store.path, created: true }); + }); + } +} diff --git a/packages/cli/src/commands/connection.ts b/packages/cli/src/commands/connection.ts new file mode 100644 index 0000000..f044881 --- /dev/null +++ b/packages/cli/src/commands/connection.ts @@ -0,0 +1,440 @@ +import { Option } from 'clipanion'; + +import type { CliConfig, ConnectionConfig } from '../config.js'; +import type { CredentialRef, CredentialStore } from '../credentials.js'; +import type { BuiltInProviderName, ProviderValue } from '../providers.js'; +import { CliUsageError, ConfigError } from '../errors.js'; +import { readTextInput } from '../input.js'; +import { providerRegistry } from '../providers.js'; +import { isBuiltInProviderName, providerValuesFromOverrides, resolveClient } from '../runtime.js'; +import { BaseCommand } from './base-command.js'; +import { ProviderOptionsCommand } from './provider-options-command.js'; + +function ref(connection: string, provider: BuiltInProviderName, name: string): CredentialRef { + return { connection, provider, name }; +} + +function maskIdentity(value: string): string { + if (value.includes('@')) { + const [local = '', domain = ''] = value.split('@', 2); + return `${local.slice(0, 1)}•••@${domain}`; + } + if (value.length <= 4) return '••••'; + return `${value.slice(0, Math.min(3, value.length - 4))}${'•'.repeat(Math.min(6, value.length - 4))}${value.slice(-4)}`; +} + +function withConnection( + config: CliConfig, + name: string, + connection: ConnectionConfig, + makeDefault: boolean, + previous?: ConnectionConfig, +): CliConfig { + const defaultConnections = { + ...config.defaultConnections, + }; + if ( + previous !== undefined && + previous.provider !== connection.provider && + defaultConnections[previous.provider] === name + ) { + delete defaultConnections[previous.provider]; + } + if (makeDefault) defaultConnections[connection.provider] = name; + return { + version: 1, + connections: { ...config.connections, [name]: connection }, + defaultConnections, + }; +} + +interface CredentialSnapshot { + readonly ref: CredentialRef; + readonly value: string | null; +} + +async function snapshotCredentials( + store: CredentialStore, + refs: readonly CredentialRef[], +): Promise { + const snapshots: CredentialSnapshot[] = []; + for (const credential of refs) { + snapshots.push({ ref: credential, value: await store.get(credential) }); + } + return snapshots; +} + +async function restoreCredentials( + store: CredentialStore, + snapshots: readonly CredentialSnapshot[], +): Promise { + for (const snapshot of snapshots) { + if (snapshot.value === null) { + await store.delete(snapshot.ref); + } else { + await store.set(snapshot.ref, snapshot.value); + } + } +} + +function withoutConnection(config: CliConfig, name: string): CliConfig { + const connections = { ...config.connections }; + delete connections[name]; + const defaultConnections = { ...config.defaultConnections }; + for (const provider of Object.keys(defaultConnections) as BuiltInProviderName[]) { + if (defaultConnections[provider] === name) delete defaultConnections[provider]; + } + return { version: 1, connections, defaultConnections }; +} + +export class ConnectionAddCommand extends ProviderOptionsCommand { + static override paths = [['connection', 'add']]; + static override usage = ConnectionAddCommand.Usage({ + category: 'Connections', + description: 'Store one provider account or sending line on this device.', + }); + + name = Option.String({ name: 'name' }); + makeDefault = Option.Boolean('--default', false, { + description: 'Make this the default connection for its provider.', + }); + force = Option.Boolean('--force', false, { description: 'Replace an existing connection.' }); + + async execute(): Promise { + return await this.action('connection.add', async () => { + if (this.provider === undefined || !isBuiltInProviderName(this.provider)) { + throw new CliUsageError( + `--provider must be one of: ${Object.keys(providerRegistry).join(', ')}.`, + ); + } + const providerName = this.provider; + const definition = providerRegistry[providerName]; + const store = this.configStore(); + const config = await store.load(); + const existing = config.connections[this.name]; + if (existing !== undefined && !this.force) { + throw new ConfigError( + `Connection ${JSON.stringify(this.name)} already exists. Use --force to replace it.`, + ); + } + + const values: Record = { + ...providerValuesFromOverrides(providerName, this.providerOverrides()), + }; + for (const field of definition.fields) { + if (values[field.key] !== undefined) continue; + const required = field.requiredFor.includes('api'); + const promptOptional = field.kind === 'identity' || field.key === 'webhookSecret'; + if (!this.promptsAllowed()) { + if (required) { + throw new ConfigError(`Missing ${field.label}. Pass its option or run interactively.`); + } + continue; + } + if (!required && !promptOptional) continue; + const value = + field.kind === 'secret' + ? await this.context.prompt.secret( + `${definition.displayName} ${field.label}${required ? '' : ' (optional)'}`, + ) + : await this.context.prompt.text( + `${definition.displayName} ${field.label}${required ? '' : ' (optional)'}`, + ); + if (value.length > 0) values[field.key] = value; + } + + const settings: Record = {}; + for (const field of definition.fields) { + const value = values[field.key]; + if (field.kind === 'setting' && value !== undefined) settings[field.key] = value; + } + const connection: ConnectionConfig = { + provider: providerName, + ...(Object.keys(settings).length === 0 ? {} : { settings }), + }; + const isFirstForProvider = !Object.values(config.connections).some( + (candidate) => candidate.provider === providerName && candidate !== existing, + ); + const next = withConnection( + config, + this.name, + connection, + this.makeDefault || isFirstForProvider, + existing, + ); + + const credentialsToWrite = definition.fields.flatMap((field) => { + if (field.kind === 'setting') return []; + const value = values[field.key]; + return typeof value === 'string' && value.length > 0 + ? [{ ref: ref(this.name, providerName, field.key), value }] + : []; + }); + const staleCredentials = + existing === undefined || existing.provider === providerName + ? [] + : providerRegistry[existing.provider].fields.flatMap((field) => + field.kind === 'setting' ? [] : [ref(this.name, existing.provider, field.key)], + ); + const snapshots = await snapshotCredentials(this.context.credentialStore, [ + ...credentialsToWrite.map((credential) => credential.ref), + ...staleCredentials, + ]); + + try { + for (const credential of credentialsToWrite) { + await this.context.credentialStore.set(credential.ref, credential.value); + } + await store.save(next); + for (const credential of staleCredentials) { + await this.context.credentialStore.delete(credential); + } + } catch (error) { + const rollback = await Promise.allSettled([ + store.save(config), + restoreCredentials(this.context.credentialStore, snapshots), + ]); + const rollbackFailures = rollback.filter((result) => result.status === 'rejected'); + if (rollbackFailures.length > 0) { + throw new ConfigError( + 'Could not replace the connection atomically; local configuration may need repair.', + { cause: error, rollbackFailureCount: rollbackFailures.length }, + ); + } + throw error; + } + + this.output().success('connection.add', { + name: this.name, + provider: providerName, + default: next.defaultConnections[providerName] === this.name, + configuredFields: definition.fields + .filter((field) => values[field.key] !== undefined) + .map((field) => field.key), + }); + }); + } +} + +export class ConnectionListCommand extends BaseCommand { + static override paths = [['connection', 'list']]; + static override usage = ConnectionListCommand.Usage({ + category: 'Connections', + description: 'List locally configured provider connections.', + }); + + async execute(): Promise { + return await this.action('connection.list', async () => { + const config = await this.configStore().load(); + const connections = Object.entries(config.connections) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, connection]) => ({ + name, + provider: connection.provider, + connectionId: name, + default: config.defaultConnections[connection.provider] === name, + })); + this.output().success('connection.list', connections); + }); + } +} + +export class ConnectionShowCommand extends BaseCommand { + static override paths = [['connection', 'show']]; + static override usage = ConnectionShowCommand.Usage({ + category: 'Connections', + description: 'Show safe connection settings and credential presence.', + }); + + name = Option.String({ name: 'name' }); + + async execute(): Promise { + return await this.action('connection.show', async () => { + const config = await this.configStore().load(); + const connection = config.connections[this.name]; + if (connection === undefined) + throw new ConfigError(`Connection ${this.name} does not exist.`); + const definition = providerRegistry[connection.provider]; + const fields: Record = {}; + for (const field of definition.fields) { + if (field.kind === 'setting') { + fields[field.key] = connection.settings?.[field.key] ?? null; + continue; + } + const value = await this.context.credentialStore.get( + ref(this.name, connection.provider, field.key), + ); + fields[field.key] = + field.kind === 'secret' + ? { configured: value !== null } + : { + configured: value !== null, + ...(value === null ? {} : { value: maskIdentity(value) }), + }; + } + this.output().success('connection.show', { + name: this.name, + provider: connection.provider, + connectionId: this.name, + default: config.defaultConnections[connection.provider] === this.name, + fields, + }); + }); + } +} + +export class ConnectionDoctorCommand extends BaseCommand { + static override paths = [['connection', 'doctor']]; + static override usage = ConnectionDoctorCommand.Usage({ + category: 'Connections', + description: 'Validate a connection without sending or mutating provider data.', + }); + + name = Option.String({ name: 'name' }); + offline = Option.Boolean('--offline', false, { + description: 'Check only local configuration and credential availability.', + }); + + async execute(): Promise { + return await this.action('connection.doctor', async () => { + const config = await this.configStore().load(); + const resolved = await resolveClient(this.context, config, { + connection: this.name, + purpose: 'doctor', + allowPrompt: false, + }); + let result; + try { + result = this.offline + ? { + status: 'ok' as const, + message: 'Local configuration and required credentials are valid.', + } + : await resolved.definition.doctor(resolved.values); + } finally { + try { + await resolved.client.close(); + } catch { + this.output().diagnostic( + `Warning: ${resolved.providerName} resources did not close cleanly; the doctor result is unchanged.`, + ); + } + } + this.output().success('connection.doctor', result, { + provider: resolved.providerName, + connectionId: resolved.connectionId, + }); + }); + } +} + +export class ConnectionRemoveCommand extends BaseCommand { + static override paths = [['connection', 'remove']]; + static override usage = ConnectionRemoveCommand.Usage({ + category: 'Connections', + description: 'Remove local configuration and keychain entries only.', + }); + + name = Option.String({ name: 'name' }); + yes = Option.Boolean('--yes', false, { description: 'Skip the confirmation prompt.' }); + + async execute(): Promise { + return await this.action('connection.remove', async () => { + const store = this.configStore(); + const config = await store.load(); + const connection = config.connections[this.name]; + if (connection === undefined) + throw new ConfigError(`Connection ${this.name} does not exist.`); + if (!this.yes) { + const stdin = this.context.stdin as NodeJS.ReadStream; + if (this.json || stdin.isTTY !== true) { + throw new CliUsageError('Use --yes when removing a connection non-interactively.'); + } + const confirmed = await this.context.prompt.confirm( + `Remove local connection ${this.name}? This does not change the provider account.`, + ); + if (!confirmed) { + this.output().success('connection.remove', { name: this.name, removed: false }); + return; + } + } + const credentialRefs = providerRegistry[connection.provider].fields.flatMap((field) => + field.kind === 'setting' ? [] : [ref(this.name, connection.provider, field.key)], + ); + const snapshots = await snapshotCredentials(this.context.credentialStore, credentialRefs); + try { + for (const credential of credentialRefs) { + await this.context.credentialStore.delete(credential); + } + await store.save(withoutConnection(config, this.name)); + } catch (error) { + const rollback = await Promise.allSettled([ + store.save(config), + restoreCredentials(this.context.credentialStore, snapshots), + ]); + const rollbackFailures = rollback.filter((result) => result.status === 'rejected'); + if (rollbackFailures.length > 0) { + throw new ConfigError( + 'Could not remove the connection atomically; local configuration may need repair.', + { cause: error, rollbackFailureCount: rollbackFailures.length }, + ); + } + throw error; + } + this.output().success('connection.remove', { name: this.name, removed: true }); + }); + } +} + +export class ConnectionCredentialSetCommand extends BaseCommand { + static override paths = [['connection', 'credential', 'set']]; + static override usage = ConnectionCredentialSetCommand.Usage({ + category: 'Connections', + description: 'Set one connection credential from a masked prompt or stdin.', + }); + + connectionName = Option.String({ name: 'connection' }); + fieldName = Option.String({ name: 'field' }); + inputPath = Option.String('--input', { + description: 'Read the value from a file, or use - for stdin.', + }); + + async execute(): Promise { + return await this.action('connection.credential.set', async () => { + const config = await this.configStore().load(); + const connection = config.connections[this.connectionName]; + if (connection === undefined) { + throw new ConfigError(`Connection ${this.connectionName} does not exist.`); + } + const field = providerRegistry[connection.provider].fields.find( + (candidate) => candidate.key === this.fieldName, + ); + if (field === undefined || field.kind === 'setting') { + throw new CliUsageError(`${this.fieldName} is not a credential or identity field.`); + } + let value: string; + if (this.inputPath !== undefined) { + value = (await readTextInput(this.inputPath, this.context.stdin)).replace(/\r?\n$/u, ''); + } else { + const stdin = this.context.stdin as NodeJS.ReadStream; + if (this.json || stdin.isTTY !== true) { + throw new CliUsageError('Use --input in non-interactive mode.'); + } + value = + field.kind === 'secret' + ? await this.context.prompt.secret(`${field.label}`) + : await this.context.prompt.text(`${field.label}`); + } + if (value.length === 0) throw new CliUsageError('Credential value must not be empty.'); + await this.context.credentialStore.set( + ref(this.connectionName, connection.provider, field.key), + value, + ); + this.output().success('connection.credential.set', { + connection: this.connectionName, + field: field.key, + configured: true, + }); + }); + } +} diff --git a/packages/cli/src/commands/conversation.ts b/packages/cli/src/commands/conversation.ts new file mode 100644 index 0000000..6036158 --- /dev/null +++ b/packages/cli/src/commands/conversation.ts @@ -0,0 +1,77 @@ +import { Option } from 'clipanion'; + +import { CliUsageError } from '../errors.js'; +import { parseAddress } from '../input.js'; +import { ClientCommand } from './client-command.js'; + +export class ConversationOpenCommand extends ClientCommand { + static override paths = [['conversation', 'open']]; + static override usage = ConversationOpenCommand.Usage({ + category: 'Conversations', + description: 'Open a direct or supported group conversation.', + }); + + participants = Option.Array('--participant', { required: true }); + + async execute(): Promise { + return await this.action('conversation.open', async () => { + await this.withClient('api', async ({ client, providerName, connectionId }) => { + const addresses = this.participants.map(parseAddress); + const first = addresses[0]; + if (first === undefined) throw new CliUsageError('Provide at least one --participant.'); + const conversation = await client.conversations.open({ + participants: [first, ...addresses.slice(1)], + }); + this.output().success('conversation.open', conversation, { + provider: providerName, + connectionId, + }); + }); + }); + } +} + +export class ConversationGetCommand extends ClientCommand { + static override paths = [['conversation', 'get']]; + static override usage = ConversationGetCommand.Usage({ + category: 'Conversations', + description: 'Get one provider-native conversation.', + }); + + conversationId = Option.String({ name: 'conversationId' }); + + async execute(): Promise { + return await this.action('conversation.get', async () => { + await this.withClient('api', async ({ client, providerName, connectionId }) => { + const conversation = await client.conversations.get(this.conversationId); + this.output().success('conversation.get', conversation, { + provider: providerName, + connectionId, + }); + }); + }); + } +} + +export class ConversationMarkReadCommand extends ClientCommand { + static override paths = [['conversation', 'mark-read']]; + static override usage = ConversationMarkReadCommand.Usage({ + category: 'Conversations', + description: 'Mark a provider-native conversation as read.', + }); + + conversationId = Option.String({ name: 'conversationId' }); + + async execute(): Promise { + return await this.action('conversation.mark-read', async () => { + await this.withClient('api', async ({ client, providerName, connectionId }) => { + await client.conversations.markRead(this.conversationId); + this.output().success( + 'conversation.mark-read', + { conversationId: this.conversationId, markedRead: true }, + { provider: providerName, connectionId }, + ); + }); + }); + } +} diff --git a/packages/cli/src/commands/default.ts b/packages/cli/src/commands/default.ts new file mode 100644 index 0000000..2dd4277 --- /dev/null +++ b/packages/cli/src/commands/default.ts @@ -0,0 +1,11 @@ +import { Command } from 'clipanion'; + +import type { CliContext } from '../context.js'; + +export class DefaultCommand extends Command { + static override paths = [Command.Default]; + + async execute(): Promise { + this.context.stdout.write(this.cli.usage(null, { detailed: false })); + } +} diff --git a/packages/cli/src/commands/message.ts b/packages/cli/src/commands/message.ts new file mode 100644 index 0000000..027115f --- /dev/null +++ b/packages/cli/src/commands/message.ts @@ -0,0 +1,29 @@ +import { Option } from 'clipanion'; + +import { ClientCommand } from './client-command.js'; + +export class MessageGetCommand extends ClientCommand { + static override paths = [['message', 'get']]; + static override usage = MessageGetCommand.Usage({ + category: 'Messages', + description: 'Get one provider-native message.', + }); + + conversationId = Option.String('--conversation', { required: true }); + messageId = Option.String('--message', { required: true }); + + async execute(): Promise { + return await this.action('message.get', async () => { + await this.withClient('api', async ({ client, providerName, connectionId }) => { + const message = await client.messages.get({ + conversationId: this.conversationId, + messageId: this.messageId, + }); + this.output().success('message.get', message, { + provider: providerName, + connectionId, + }); + }); + }); + } +} diff --git a/packages/cli/src/commands/provider-options-command.ts b/packages/cli/src/commands/provider-options-command.ts new file mode 100644 index 0000000..11d4f75 --- /dev/null +++ b/packages/cli/src/commands/provider-options-command.ts @@ -0,0 +1,63 @@ +import { Option } from 'clipanion'; + +import type { ProviderOverrides } from '../runtime.js'; +import { CliUsageError } from '../errors.js'; +import { BaseCommand } from './base-command.js'; + +export abstract class ProviderOptionsCommand extends BaseCommand { + provider = Option.String('--provider', { + description: 'Use Blooio, Photon, or Sendblue (and its configured default connection).', + }); + + noInput = Option.Boolean('--no-input', false, { + description: 'Never prompt for missing credentials or settings.', + }); + + apiKey = Option.String('--api-key', { description: 'One-time Blooio or Sendblue API key.' }); + apiSecret = Option.String('--api-secret', { description: 'One-time Sendblue API secret.' }); + projectId = Option.String('--project-id', { description: 'One-time Photon project ID.' }); + projectSecret = Option.String('--project-secret', { + description: 'One-time Photon project secret.', + }); + fromNumber = Option.String('--from-number', { + description: 'One-time Blooio sender or Sendblue from number.', + }); + phoneNumber = Option.String('--phone-number', { + description: 'One-time Photon phone-number selector.', + }); + webhookSecret = Option.String('--webhook-secret', { + description: 'One-time provider webhook verification secret.', + }); + timeout = Option.String('--timeout', { description: 'One-time Photon timeout in milliseconds.' }); + retry = Option.Boolean('--retry', { description: 'Enable or disable Photon transport retries.' }); + markReadEnabled = Option.Boolean('--mark-read-enabled', { + description: 'Enable Sendblue manual mark-read support for this invocation.', + }); + + protected providerOverrides(): ProviderOverrides { + let timeout: number | undefined; + if (this.timeout !== undefined) { + timeout = Number(this.timeout); + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new CliUsageError('--timeout must be a positive number.'); + } + } + return { + ...(this.apiKey === undefined ? {} : { apiKey: this.apiKey }), + ...(this.apiSecret === undefined ? {} : { apiSecret: this.apiSecret }), + ...(this.projectId === undefined ? {} : { projectId: this.projectId }), + ...(this.projectSecret === undefined ? {} : { projectSecret: this.projectSecret }), + ...(this.fromNumber === undefined ? {} : { fromNumber: this.fromNumber }), + ...(this.phoneNumber === undefined ? {} : { phoneNumber: this.phoneNumber }), + ...(this.webhookSecret === undefined ? {} : { webhookSecret: this.webhookSecret }), + ...(timeout === undefined ? {} : { timeout }), + ...(this.retry === undefined ? {} : { retry: this.retry }), + ...(this.markReadEnabled === undefined ? {} : { markReadEnabled: this.markReadEnabled }), + }; + } + + protected promptsAllowed(): boolean { + const stdin = this.context.stdin as NodeJS.ReadStream; + return !this.noInput && !this.json && stdin.isTTY === true; + } +} diff --git a/packages/cli/src/commands/provider.ts b/packages/cli/src/commands/provider.ts new file mode 100644 index 0000000..7ef33e8 --- /dev/null +++ b/packages/cli/src/commands/provider.ts @@ -0,0 +1,221 @@ +import { Option } from 'clipanion'; +import { z } from 'zod'; + +import type { BlooioProvider } from '@imessage-sdk/blooio'; +import type { PhotonProvider } from '@imessage-sdk/photon'; +import type { SendblueProvider } from '@imessage-sdk/sendblue'; +import type { IMessageReaction } from 'imessage-sdk'; + +import type { BuiltInProviderName } from '../providers.js'; +import { CliUsageError } from '../errors.js'; +import { BUILT_IN_PROVIDER_NAMES, providerRegistry } from '../providers.js'; +import { isBuiltInProviderName } from '../runtime.js'; +import { BaseCommand } from './base-command.js'; +import { ClientCommand } from './client-command.js'; + +const ReactionSchema = z.enum(['love', 'like', 'dislike', 'laugh', 'emphasize', 'question']); + +function fieldSummary(name: BuiltInProviderName): readonly object[] { + return providerRegistry[name].fields.map((field) => ({ + key: field.key, + label: field.label, + kind: field.kind, + ...(field.env === undefined ? {} : { environmentVariable: field.env }), + requiredFor: field.requiredFor, + description: field.description, + })); +} + +export class ProviderListCommand extends BaseCommand { + static override paths = [['provider', 'list']]; + static override usage = ProviderListCommand.Usage({ + category: 'Providers', + description: 'List every provider bundled with this CLI.', + }); + + async execute(): Promise { + return await this.action('provider.list', async () => { + this.output().success( + 'provider.list', + BUILT_IN_PROVIDER_NAMES.map((name) => ({ + name, + displayName: providerRegistry[name].displayName, + packageName: providerRegistry[name].packageName, + description: providerRegistry[name].description, + })), + ); + }); + } +} + +export class ProviderShowCommand extends BaseCommand { + static override paths = [['provider', 'show']]; + static override usage = ProviderShowCommand.Usage({ + category: 'Providers', + description: 'Show static capabilities and configuration fields for a provider.', + }); + + name = Option.String({ name: 'provider' }); + + async execute(): Promise { + return await this.action('provider.show', async () => { + if (!isBuiltInProviderName(this.name)) { + throw new CliUsageError(`Unknown provider ${this.name}.`); + } + const definition = providerRegistry[this.name]; + this.output().success('provider.show', { + name: definition.name, + displayName: definition.displayName, + packageName: definition.packageName, + description: definition.description, + capabilities: definition.capabilities, + fields: fieldSummary(this.name), + }); + }); + } +} + +abstract class NamedProviderCommand extends ClientCommand { + protected selectProvider(name: BuiltInProviderName): void { + if (this.provider !== undefined && this.provider !== name) { + throw new CliUsageError(`This command requires --provider ${name}.`); + } + this.provider = name; + } +} + +export class BlooioNumbersListCommand extends NamedProviderCommand { + static override paths = [['provider', 'blooio', 'numbers', 'list']]; + static override usage = BlooioNumbersListCommand.Usage({ + category: 'Provider extensions', + description: 'List numbers linked to the selected Blooio API key.', + }); + + async execute(): Promise { + return await this.action('provider.blooio.numbers.list', async () => { + this.selectProvider('blooio'); + await this.withClient('api', async ({ provider, connectionId }) => { + const numbers = await (provider as BlooioProvider).numbers.list(); + this.output().success('provider.blooio.numbers.list', numbers, { + provider: 'blooio', + connectionId, + }); + }); + }); + } +} + +abstract class MessageStatusCommand extends NamedProviderCommand { + conversationId = Option.String('--conversation', { required: true }); + messageId = Option.String('--message', { required: true }); +} + +export class BlooioMessageStatusCommand extends MessageStatusCommand { + static override paths = [['provider', 'blooio', 'message', 'status']]; + static override usage = BlooioMessageStatusCommand.Usage({ + category: 'Provider extensions', + description: 'Get Blooio delivery status for one message.', + }); + + async execute(): Promise { + return await this.action('provider.blooio.message.status', async () => { + this.selectProvider('blooio'); + await this.withClient('api', async ({ provider, connectionId }) => { + const result = await (provider as BlooioProvider).messages.getStatus({ + conversationId: this.conversationId, + messageId: this.messageId, + }); + this.output().success('provider.blooio.message.status', result, { + provider: 'blooio', + connectionId, + }); + }); + }); + } +} + +export class PhotonLineShowCommand extends NamedProviderCommand { + static override paths = [['provider', 'photon', 'line', 'show']]; + static override usage = PhotonLineShowCommand.Usage({ + category: 'Provider extensions', + description: 'Resolve the Photon line selected by the current project configuration.', + }); + + async execute(): Promise { + return await this.action('provider.photon.line.show', async () => { + this.selectProvider('photon'); + await this.withClient('api', async ({ provider, connectionId }) => { + const line = await (provider as PhotonProvider).connection.getLine(); + this.output().success('provider.photon.line.show', line, { + provider: 'photon', + connectionId, + }); + }); + }); + } +} + +export class SendblueMessageStatusCommand extends MessageStatusCommand { + static override paths = [['provider', 'sendblue', 'message', 'status']]; + static override usage = SendblueMessageStatusCommand.Usage({ + category: 'Provider extensions', + description: 'Get Sendblue delivery status for one message.', + }); + + async execute(): Promise { + return await this.action('provider.sendblue.message.status', async () => { + this.selectProvider('sendblue'); + await this.withClient('api', async ({ provider, connectionId }) => { + const result = await (provider as SendblueProvider).messages.getStatus({ + conversationId: this.conversationId, + messageId: this.messageId, + }); + this.output().success('provider.sendblue.message.status', result, { + provider: 'sendblue', + connectionId, + }); + }); + }); + } +} + +export class SendblueTapbackAddCommand extends NamedProviderCommand { + static override paths = [['provider', 'sendblue', 'tapback', 'add']]; + static override usage = SendblueTapbackAddCommand.Usage({ + category: 'Provider extensions', + description: 'Add a Sendblue tapback to an existing inbound iMessage.', + }); + + conversationId = Option.String('--conversation', { required: true }); + messageId = Option.String('--message', { required: true }); + reaction = Option.String('--reaction', { required: true }); + partIndex = Option.String('--part-index'); + + async execute(): Promise { + return await this.action('provider.sendblue.tapback.add', async () => { + this.selectProvider('sendblue'); + const parsed = ReactionSchema.safeParse(this.reaction); + if (!parsed.success) throw new CliUsageError(`Unknown reaction ${this.reaction}.`); + let partIndex: number | undefined; + if (this.partIndex !== undefined) { + partIndex = Number(this.partIndex); + if (!Number.isInteger(partIndex) || partIndex < 0) { + throw new CliUsageError('--part-index must be a non-negative integer.'); + } + } + await this.withClient('api', async ({ provider, connectionId }) => { + await (provider as SendblueProvider).tapbacks.add({ + conversationId: this.conversationId, + messageId: this.messageId, + reaction: parsed.data as IMessageReaction, + ...(partIndex === undefined ? {} : { partIndex }), + }); + this.output().success( + 'provider.sendblue.tapback.add', + { messageId: this.messageId, reaction: parsed.data, added: true }, + { provider: 'sendblue', connectionId }, + ); + }); + }); + } +} diff --git a/packages/cli/src/commands/reaction.ts b/packages/cli/src/commands/reaction.ts new file mode 100644 index 0000000..18fd6c8 --- /dev/null +++ b/packages/cli/src/commands/reaction.ts @@ -0,0 +1,85 @@ +import { Option } from 'clipanion'; +import { z } from 'zod'; + +import type { IMessageReaction } from 'imessage-sdk'; + +import { CliUsageError } from '../errors.js'; +import { ClientCommand } from './client-command.js'; + +const ReactionSchema = z.enum(['love', 'like', 'dislike', 'laugh', 'emphasize', 'question']); + +abstract class ReactionCommand extends ClientCommand { + conversationId = Option.String('--conversation', { required: true }); + messageId = Option.String('--message', { required: true }); + reaction = Option.String('--reaction', { required: true }); + partIndex = Option.String('--part-index'); + + protected input(): { + readonly conversationId: string; + readonly messageId: string; + readonly reaction: IMessageReaction; + readonly partIndex?: number; + } { + const reaction = ReactionSchema.safeParse(this.reaction); + if (!reaction.success) { + throw new CliUsageError(`Unknown reaction ${JSON.stringify(this.reaction)}.`); + } + let partIndex: number | undefined; + if (this.partIndex !== undefined) { + partIndex = Number(this.partIndex); + if (!Number.isInteger(partIndex) || partIndex < 0) { + throw new CliUsageError('--part-index must be a non-negative integer.'); + } + } + return { + conversationId: this.conversationId, + messageId: this.messageId, + reaction: reaction.data, + ...(partIndex === undefined ? {} : { partIndex }), + }; + } +} + +export class ReactionAddCommand extends ReactionCommand { + static override paths = [['reaction', 'add']]; + static override usage = ReactionAddCommand.Usage({ + category: 'Interactions', + description: 'Add a normalized reaction to a provider-native message.', + }); + + async execute(): Promise { + return await this.action('reaction.add', async () => { + const input = this.input(); + await this.withClient('api', async ({ client, providerName, connectionId }) => { + await client.reactions.add(input); + this.output().success( + 'reaction.add', + { ...input, added: true }, + { provider: providerName, connectionId }, + ); + }); + }); + } +} + +export class ReactionRemoveCommand extends ReactionCommand { + static override paths = [['reaction', 'remove']]; + static override usage = ReactionRemoveCommand.Usage({ + category: 'Interactions', + description: 'Remove a normalized reaction from a provider-native message.', + }); + + async execute(): Promise { + return await this.action('reaction.remove', async () => { + const input = this.input(); + await this.withClient('api', async ({ client, providerName, connectionId }) => { + await client.reactions.remove(input); + this.output().success( + 'reaction.remove', + { ...input, removed: true }, + { provider: providerName, connectionId }, + ); + }); + }); + } +} diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts new file mode 100644 index 0000000..0cd0f83 --- /dev/null +++ b/packages/cli/src/commands/schema.ts @@ -0,0 +1,32 @@ +import { Option } from 'clipanion'; + +import { CliUsageError } from '../errors.js'; +import { BaseCommand } from './base-command.js'; +import { SendCommandInputJsonSchema } from './send.js'; + +const SCHEMAS = { + send: SendCommandInputJsonSchema, +} as const; + +export class SchemaCommand extends BaseCommand { + static override paths = [['schema']]; + static override usage = SchemaCommand.Usage({ + category: 'Automation', + description: 'Print discoverable JSON Schemas for agent-facing command input.', + }); + + commandName = Option.String({ name: 'command', required: false }); + + async execute(): Promise { + return await this.action('schema', async () => { + if (this.commandName === undefined) { + this.output().success('schema', { commands: Object.keys(SCHEMAS) }); + return; + } + if (!(this.commandName in SCHEMAS)) { + throw new CliUsageError(`No input schema is available for ${this.commandName}.`); + } + this.output().success('schema', SCHEMAS[this.commandName as keyof typeof SCHEMAS]); + }); + } +} diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts new file mode 100644 index 0000000..e873528 --- /dev/null +++ b/packages/cli/src/commands/send.ts @@ -0,0 +1,251 @@ +import { Option } from 'clipanion'; +import { z } from 'zod'; + +import { isFallbackConversationId, UnsupportedCapabilityError } from 'imessage-sdk'; + +import type { SendCliInput } from '../input.js'; +import { CliUsageError } from '../errors.js'; +import { + attachmentFromArgument, + materializeSendInput, + parseAddress, + parseMetadata, + readSendInput, + readTextInput, + SendCliInputSchema, +} from '../input.js'; +import { ClientCommand } from './client-command.js'; + +const SENDBLUE_MAX_MESSAGE_LENGTH = 18_996; + +export class SendCommand extends ClientCommand { + static override paths = [['send']]; + + static override usage = SendCommand.Usage({ + category: 'Messages', + description: 'Send text and attachments through one iMessage provider connection.', + examples: [ + ['Send a text message', "$0 send --provider blooio --to +15551234567 --text 'Hello'"], + ['Read agent input from stdin', '$0 send --provider photon --input - --json'], + ], + }); + + to = Option.Array('--to', { description: 'Recipient address. Repeat for multiple recipients.' }); + conversationId = Option.String('--conversation', { + description: 'Provider-native conversation ID instead of --to.', + }); + text = Option.String('--text', { description: 'Plain-text message body.' }); + textFile = Option.String('--text-file', { + description: 'Read plain text from a file, or use - for stdin.', + }); + images = Option.Array('--image', { description: 'Image URL or local path. Repeatable.' }); + videos = Option.Array('--video', { description: 'Video URL or local path. Repeatable.' }); + files = Option.Array('--file', { description: 'File URL or local path. Repeatable.' }); + replyTo = Option.String('--reply-to', { description: 'Provider-native message ID to reply to.' }); + replyPart = Option.String('--reply-part', { + description: 'Non-negative part index for --reply-to.', + }); + idempotencyKey = Option.String('--idempotency-key', { + description: 'Provider idempotency key when supported.', + }); + metadata = Option.Array('--metadata', { + description: 'Metadata entry as key=value. Repeatable.', + }); + inputPath = Option.String('--input', { + description: 'Read the complete send input as JSON from a file, or use - for stdin.', + }); + dryRun = Option.Boolean('--dry-run', false, { + description: 'Validate and print the send intent without contacting the provider.', + }); + + async execute(): Promise { + return await this.action('send', async () => { + const cliInput = await this.readInput(); + + await this.withClient('api', async ({ client, providerName, connectionId }) => { + this.validateCapabilities(client.capabilities, cliInput, providerName, connectionId); + const sendInput = await materializeSendInput(cliInput); + + if (this.dryRun) { + this.output().success( + 'send', + { dryRun: true, input: sendInput }, + { provider: providerName, connectionId }, + ); + return; + } + + const message = await client.messages.send(sendInput); + this.output().success('send', message, { provider: providerName, connectionId }); + }); + }); + } + + private async readInput(): Promise { + if (this.inputPath !== undefined) { + if (this.hasInlineInput()) { + throw new CliUsageError('--input cannot be combined with message-content options.'); + } + return await readSendInput(this.inputPath, this.context.stdin); + } + + if (this.text !== undefined && this.textFile !== undefined) { + throw new CliUsageError('Use either --text or --text-file, not both.'); + } + if (this.textFile === '-' && this.inputPath === '-') { + throw new CliUsageError('stdin cannot be used for both --input and --text-file.'); + } + + const text = + this.textFile === undefined + ? this.text + : await readTextInput(this.textFile, this.context.stdin); + const attachments = [ + ...(this.images ?? []).map((value) => attachmentFromArgument('image', value)), + ...(this.videos ?? []).map((value) => attachmentFromArgument('video', value)), + ...(this.files ?? []).map((value) => attachmentFromArgument('file', value)), + ]; + let partIndex: number | undefined; + if (this.replyPart !== undefined) { + if (this.replyTo === undefined) throw new CliUsageError('--reply-part requires --reply-to.'); + partIndex = Number(this.replyPart); + if (!Number.isInteger(partIndex) || partIndex < 0) { + throw new CliUsageError('--reply-part must be a non-negative integer.'); + } + } + const metadata = parseMetadata(this.metadata ?? []); + const raw = { + ...((this.to?.length ?? 0) === 0 ? {} : { to: this.to }), + ...(this.conversationId === undefined ? {} : { conversationId: this.conversationId }), + ...(text === undefined ? {} : { text }), + ...(attachments.length === 0 ? {} : { attachments }), + ...(this.replyTo === undefined + ? {} + : { + replyTo: { + messageId: this.replyTo, + ...(partIndex === undefined ? {} : { partIndex }), + }, + }), + ...(this.idempotencyKey === undefined ? {} : { idempotencyKey: this.idempotencyKey }), + ...(Object.keys(metadata).length === 0 ? {} : { metadata }), + }; + const parsed = SendCliInputSchema.safeParse(raw); + if (!parsed.success) { + throw new CliUsageError('Invalid send input.', parsed.error.issues); + } + return parsed.data; + } + + private hasInlineInput(): boolean { + return ( + this.to !== undefined || + this.conversationId !== undefined || + this.text !== undefined || + this.textFile !== undefined || + this.images !== undefined || + this.videos !== undefined || + this.files !== undefined || + this.replyTo !== undefined || + this.replyPart !== undefined || + this.idempotencyKey !== undefined || + this.metadata !== undefined + ); + } + + private validateCapabilities( + capabilities: { + readonly messages: { + readonly text: boolean; + readonly attachments: boolean; + readonly replies: boolean; + }; + readonly conversations: { readonly direct: boolean; readonly groups: boolean }; + }, + input: SendCliInput, + provider: string, + connectionId: string, + ): void { + const unsupported = (capability: string): never => { + throw new UnsupportedCapabilityError(capability, { provider, connectionId }); + }; + if (input.text !== undefined && !capabilities.messages.text) unsupported('messages.text'); + if ((input.attachments?.length ?? 0) > 0 && !capabilities.messages.attachments) { + unsupported('messages.attachments'); + } + if (input.replyTo !== undefined && !capabilities.messages.replies) + unsupported('messages.replies'); + if (input.conversationId !== undefined && isFallbackConversationId(input.conversationId)) { + throw new CliUsageError( + 'SDK fallback conversation IDs are diagnostic and cannot be used for provider operations.', + ); + } + const recipients = ( + input.to === undefined ? [] : Array.isArray(input.to) ? input.to : [input.to] + ).map((recipient) => (typeof recipient === 'string' ? parseAddress(recipient) : recipient)); + if (recipients.length > 1 && !capabilities.conversations.groups) { + unsupported('conversations.groups'); + } + if (recipients.length === 1 && !capabilities.conversations.direct) { + unsupported('conversations.direct'); + } + if ( + provider === 'blooio' && + input.attachments?.some((attachment) => attachment.source.type === 'path') === true + ) { + throw new CliUsageError( + 'Blooio requires attachment URLs; local paths cannot be sent without an external upload step.', + ); + } + if (provider === 'sendblue') { + if ((input.attachments?.length ?? 0) > 1) { + throw new CliUsageError('Sendblue supports one attachment per direct message.'); + } + if (input.idempotencyKey !== undefined) { + throw new CliUsageError('Sendblue does not document idempotency keys.'); + } + if (input.text !== undefined && input.text.length > SENDBLUE_MAX_MESSAGE_LENGTH) { + throw new CliUsageError( + `Sendblue message text must not exceed ${SENDBLUE_MAX_MESSAGE_LENGTH} characters.`, + ); + } + const recipient = + input.conversationId === undefined + ? recipients.length === 1 + ? recipients[0] + : undefined + : { kind: 'phone' as const, value: input.conversationId }; + if ( + recipient === undefined || + recipient.kind !== 'phone' || + !/^\+[1-9]\d{6,14}$/u.test(recipient.value) + ) { + throw new CliUsageError('Sendblue requires exactly one E.164 phone recipient.'); + } + } + } +} + +export const SendCommandInputJsonSchema = { + ...z.toJSONSchema(SendCliInputSchema), + allOf: [ + { + oneOf: [ + { required: ['to'], not: { required: ['conversationId'] } }, + { required: ['conversationId'], not: { required: ['to'] } }, + ], + }, + { + anyOf: [ + { + required: ['text'], + properties: { text: { type: 'string', pattern: '\\S' } }, + }, + { + required: ['attachments'], + properties: { attachments: { type: 'array', minItems: 1 } }, + }, + ], + }, + ], +} as const; diff --git a/packages/cli/src/commands/typing.ts b/packages/cli/src/commands/typing.ts new file mode 100644 index 0000000..d3f1b8a --- /dev/null +++ b/packages/cli/src/commands/typing.ts @@ -0,0 +1,49 @@ +import { Option } from 'clipanion'; + +import { ClientCommand } from './client-command.js'; + +abstract class TypingCommand extends ClientCommand { + conversationId = Option.String({ name: 'conversationId' }); +} + +export class TypingStartCommand extends TypingCommand { + static override paths = [['typing', 'start']]; + static override usage = TypingStartCommand.Usage({ + category: 'Interactions', + description: 'Start the provider typing indicator.', + }); + + async execute(): Promise { + return await this.action('typing.start', async () => { + await this.withClient('api', async ({ client, providerName, connectionId }) => { + await client.typing.start(this.conversationId); + this.output().success( + 'typing.start', + { conversationId: this.conversationId, typing: true }, + { provider: providerName, connectionId }, + ); + }); + }); + } +} + +export class TypingStopCommand extends TypingCommand { + static override paths = [['typing', 'stop']]; + static override usage = TypingStopCommand.Usage({ + category: 'Interactions', + description: 'Stop the provider typing indicator.', + }); + + async execute(): Promise { + return await this.action('typing.stop', async () => { + await this.withClient('api', async ({ client, providerName, connectionId }) => { + await client.typing.stop(this.conversationId); + this.output().success( + 'typing.stop', + { conversationId: this.conversationId, typing: false }, + { provider: providerName, connectionId }, + ); + }); + }); + } +} diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts new file mode 100644 index 0000000..20d68c3 --- /dev/null +++ b/packages/cli/src/commands/webhook.ts @@ -0,0 +1,94 @@ +import { Option } from 'clipanion'; + +import { CliUsageError } from '../errors.js'; +import { startWebhookServer } from '../webhook-server.js'; +import { ClientCommand } from './client-command.js'; + +function waitForShutdown(): Promise { + return new Promise((resolve) => { + const finish = (signal: NodeJS.Signals): void => { + process.off('SIGINT', onInterrupt); + process.off('SIGTERM', onTerminate); + resolve(signal); + }; + const onInterrupt = (): void => finish('SIGINT'); + const onTerminate = (): void => finish('SIGTERM'); + process.once('SIGINT', onInterrupt); + process.once('SIGTERM', onTerminate); + }); +} + +function exitCodeForSignal(signal: NodeJS.Signals): number { + return signal === 'SIGINT' ? 130 : 143; +} + +export class WebhookServeCommand extends ClientCommand { + static override paths = [['webhook', 'serve']]; + static override usage = WebhookServeCommand.Usage({ + category: 'Webhooks', + description: 'Serve and normalize signed provider webhooks on localhost.', + details: + 'This development server does not create a public tunnel. Run ngrok or cloudflared separately and register its HTTPS origin plus the configured path with the provider.', + }); + + host = Option.String('--host', '127.0.0.1', { + description: 'Local interface to bind. Use 0.0.0.0 only intentionally.', + }); + port = Option.String('--port', '8787', { description: 'Local TCP port.' }); + webhookPath = Option.String('--path', '/webhooks', { description: 'Webhook URL path.' }); + healthPath = Option.String('--health-path', '/healthz', { + description: 'Health-check URL path.', + }); + maxBodyBytes = Option.String('--max-body-bytes', '1048576', { + description: 'Maximum accepted webhook body size.', + }); + experimental = Option.Boolean('--experimental', false, { + description: 'Acknowledge that the CLI webhook server is experimental.', + }); + + async execute(): Promise { + return await this.action('webhook.serve', async () => { + if (!this.experimental) { + throw new CliUsageError( + 'The CLI webhook server is experimental. Re-run this command with --experimental.', + ); + } + + const port = Number(this.port); + const maxBodyBytes = Number(this.maxBodyBytes); + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new CliUsageError('--port must be an integer between 0 and 65535.'); + } + if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes <= 0) { + throw new CliUsageError('--max-body-bytes must be a positive safe integer.'); + } + + return await this.withClient('webhook', async ({ client, providerName, connectionId }) => { + const server = await startWebhookServer({ + client, + host: this.host, + port, + path: this.webhookPath, + healthPath: this.healthPath, + maxBodyBytes, + onEvent: (event) => { + this.output().success('webhook.event', event, { provider: providerName, connectionId }); + }, + onError: ({ statusCode, code }) => { + this.output().diagnostic(`Webhook request rejected: ${statusCode} ${code}.`); + }, + }); + this.output().diagnostic(`Listening for ${providerName} webhooks at ${server.address.url}`); + this.output().diagnostic( + `Expose this URL with an HTTPS tunnel and register ${server.address.path}.`, + ); + + try { + return exitCodeForSignal(await waitForShutdown()); + } finally { + await server.close(); + } + }); + }); + } +} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..41a83ba --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,244 @@ +import { randomUUID } from 'node:crypto'; +import { chmod, link, mkdir, open, readFile, rename, unlink } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +import { z } from 'zod'; + +import type { BuiltInProviderName } from './provider-names.js'; +import { ConfigError } from './errors.js'; +import { BUILT_IN_PROVIDER_NAMES } from './provider-names.js'; + +export { ConfigError } from './errors.js'; + +export type ProviderName = BuiltInProviderName; + +export interface ConnectionConfig { + readonly provider: ProviderName; + readonly settings?: Readonly> | undefined; +} + +export type DefaultConnections = Readonly<{ + readonly [TProvider in ProviderName]?: string | undefined; +}>; + +export interface CliConfig { + readonly version: 1; + readonly connections: Readonly>; + readonly defaultConnections: DefaultConnections; +} + +export interface ConfigPathOptions { + readonly env?: Readonly>; + readonly homeDirectory?: string; + readonly platform?: NodeJS.Platform; +} + +const ProviderNameSchema = z.enum(BUILT_IN_PROVIDER_NAMES); +const ConnectionNameSchema = z + .string() + .min(1) + .max(64) + .regex( + /^[A-Za-z0-9][A-Za-z0-9._-]*$/, + 'Connection names may contain only letters, numbers, dots, underscores, and hyphens.', + ); + +const ConnectionConfigSchema = z + .object({ + provider: ProviderNameSchema, + settings: z.record(z.string(), z.unknown()).optional(), + }) + .strict(); + +const DefaultConnectionsSchema = z + .object({ + blooio: ConnectionNameSchema.optional(), + photon: ConnectionNameSchema.optional(), + sendblue: ConnectionNameSchema.optional(), + }) + .strict() + .default({}); + +export const CliConfigSchema: z.ZodType = z + .object({ + version: z.literal(1), + connections: z.record(ConnectionNameSchema, ConnectionConfigSchema).default({}), + defaultConnections: DefaultConnectionsSchema, + }) + .strict() + .superRefine((config, context) => { + for (const provider of BUILT_IN_PROVIDER_NAMES) { + const connectionName = config.defaultConnections[provider]; + if (connectionName === undefined) { + continue; + } + + const connection = config.connections[connectionName]; + if (connection === undefined) { + context.addIssue({ + code: 'custom', + message: `Default ${provider} connection ${JSON.stringify(connectionName)} does not exist.`, + path: ['defaultConnections', provider], + }); + continue; + } + + if (connection.provider !== provider) { + context.addIssue({ + code: 'custom', + message: `Default ${provider} connection ${JSON.stringify(connectionName)} uses provider ${JSON.stringify(connection.provider)}.`, + path: ['defaultConnections', provider], + }); + } + } + }); + +export function createEmptyConfig(): CliConfig { + return { + version: 1, + connections: {}, + defaultConnections: {}, + }; +} + +export function getDefaultConfigPath(options: ConfigPathOptions = {}): string { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const homeDirectory = options.homeDirectory ?? homedir(); + const xdgConfigHome = env['XDG_CONFIG_HOME']; + + if (xdgConfigHome !== undefined && xdgConfigHome.length > 0) { + return join(xdgConfigHome, 'imessage-cli', 'config.json'); + } + + const appData = env['APPDATA']; + if (platform === 'win32' && appData !== undefined && appData.length > 0) { + return join(appData, 'imessage-cli', 'config.json'); + } + + return join(homeDirectory, '.config', 'imessage-cli', 'config.json'); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error; +} + +async function removeTemporaryFile(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + throw error; + } + } +} + +export class ConfigStore { + readonly path: string; + + constructor(path = getDefaultConfigPath()) { + this.path = path; + } + + async load(): Promise { + let contents: string; + + try { + contents = await readFile(this.path, 'utf8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return createEmptyConfig(); + } + + throw new ConfigError(`Could not read CLI configuration from ${this.path}.`, { + cause: error, + }); + } + + let value: unknown; + try { + value = JSON.parse(contents) as unknown; + } catch (error) { + throw new ConfigError(`CLI configuration at ${this.path} is not valid JSON.`, { + cause: error, + }); + } + + const parsed = CliConfigSchema.safeParse(value); + if (!parsed.success) { + throw new ConfigError( + `CLI configuration at ${this.path} is invalid.\n${z.prettifyError(parsed.error)}`, + { cause: parsed.error }, + ); + } + + return parsed.data; + } + + async create(config: CliConfig): Promise { + await this.write(config, false); + } + + async save(config: CliConfig): Promise { + await this.write(config, true); + } + + private async write(config: CliConfig, replace: boolean): Promise { + const parsed = CliConfigSchema.safeParse(config); + if (!parsed.success) { + throw new ConfigError( + `Cannot save invalid CLI configuration.\n${z.prettifyError(parsed.error)}`, + { + cause: parsed.error, + }, + ); + } + + let contents: string; + try { + contents = `${JSON.stringify(parsed.data, null, 2)}\n`; + } catch (error) { + throw new ConfigError('Cannot serialize CLI configuration.', { cause: error }); + } + + const directory = dirname(this.path); + const temporaryPath = join( + directory, + `.${basename(this.path)}.${process.pid}.${randomUUID()}.tmp`, + ); + + let handle: Awaited> | undefined; + try { + const createdDirectory = await mkdir(directory, { recursive: true, mode: 0o700 }); + if (createdDirectory !== undefined) { + await chmod(directory, 0o700); + } + + handle = await open(temporaryPath, 'wx', 0o600); + await handle.writeFile(contents, 'utf8'); + await handle.sync(); + await handle.close(); + handle = undefined; + + await chmod(temporaryPath, 0o600); + if (replace) { + await rename(temporaryPath, this.path); + } else { + await link(temporaryPath, this.path); + await removeTemporaryFile(temporaryPath); + } + } catch (error) { + await handle?.close(); + await removeTemporaryFile(temporaryPath); + if (!replace && isNodeError(error) && error.code === 'EEXIST') { + throw new ConfigError(`Configuration already exists at ${this.path}.`, { + cause: error, + }); + } + throw new ConfigError(`Could not save CLI configuration to ${this.path}.`, { + cause: error, + }); + } + } +} diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts new file mode 100644 index 0000000..761b058 --- /dev/null +++ b/packages/cli/src/context.ts @@ -0,0 +1,60 @@ +import type { BaseContext } from 'clipanion'; + +import { confirm, input, password } from '@inquirer/prompts'; + +import type { CredentialStore } from './credentials.js'; +import { ConfigStore, getDefaultConfigPath } from './config.js'; +import { SystemCredentialStore } from './credentials.js'; + +export interface PromptService { + text(message: string, options?: { readonly default?: string }): Promise; + secret(message: string): Promise; + confirm(message: string, defaultValue?: boolean): Promise; +} + +export interface CliContext extends BaseContext { + readonly configStore: ConfigStore; + readonly credentialStore: CredentialStore; + readonly prompt: PromptService; + readonly cwd: string; +} + +function defaultPromptService( + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream, +): PromptService { + const promptContext = { input: stdin, output: stdout, clearPromptOnDone: true }; + return { + async text(message, options = {}) { + return await input( + { + message, + ...(options.default === undefined ? {} : { default: options.default }), + }, + promptContext, + ); + }, + async secret(message) { + return await password({ message, mask: true }, promptContext); + }, + async confirm(message, defaultValue = false) { + return await confirm({ message, default: defaultValue }, promptContext); + }, + }; +} + +export function createDefaultContext(): CliContext { + const stdin = process.stdin; + const stdout = process.stdout; + return { + env: process.env, + stdin, + stdout, + stderr: process.stderr, + colorDepth: process.env['NO_COLOR'] === undefined && stdout.isTTY ? stdout.getColorDepth() : 1, + configStore: new ConfigStore(getDefaultConfigPath({ env: process.env })), + credentialStore: new SystemCredentialStore(), + prompt: defaultPromptService(stdin, process.stderr), + cwd: process.cwd(), + }; +} diff --git a/packages/cli/src/credentials.ts b/packages/cli/src/credentials.ts new file mode 100644 index 0000000..b21baba --- /dev/null +++ b/packages/cli/src/credentials.ts @@ -0,0 +1,123 @@ +import type { ProviderName } from './config.js'; +import { CredentialStoreUnavailableError } from './errors.js'; + +export const DEFAULT_CREDENTIAL_SERVICE = 'imessage-cli'; + +export interface CredentialRef { + readonly connection: string; + readonly provider: ProviderName; + readonly name: string; +} + +export interface CredentialStore { + get(ref: CredentialRef): Promise; + set(ref: CredentialRef, secret: string): Promise; + delete(ref: CredentialRef): Promise; +} + +export class CredentialStoreError extends CredentialStoreUnavailableError { + override readonly name = 'CredentialStoreError'; + + constructor(message: string, details?: unknown) { + super(message, details); + } +} + +function assertAccountPart(value: string, label: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)) { + throw new CredentialStoreError( + `${label} may contain only letters, numbers, dots, underscores, and hyphens.`, + ); + } +} + +export function getCredentialAccount(ref: CredentialRef): string { + assertAccountPart(ref.connection, 'Connection name'); + assertAccountPart(ref.name, 'Credential name'); + return `v1:${ref.connection}:${ref.provider}:${ref.name}`; +} + +export class SystemCredentialStore implements CredentialStore { + readonly service: string; + + constructor(service = DEFAULT_CREDENTIAL_SERVICE) { + if (service.trim().length === 0) { + throw new CredentialStoreError('Credential service must not be empty.'); + } + + this.service = service; + } + + async get(ref: CredentialRef): Promise { + try { + const { AsyncEntry } = await import('@napi-rs/keyring'); + return (await new AsyncEntry(this.service, getCredentialAccount(ref)).getPassword()) ?? null; + } catch (error) { + throw new CredentialStoreError( + 'Could not read credentials from the system credential store.', + { + cause: error, + }, + ); + } + } + + async set(ref: CredentialRef, secret: string): Promise { + if (secret.length === 0) { + throw new CredentialStoreError('Credential value must not be empty.'); + } + + try { + const { AsyncEntry } = await import('@napi-rs/keyring'); + await new AsyncEntry(this.service, getCredentialAccount(ref)).setPassword(secret); + } catch (error) { + throw new CredentialStoreError( + 'Could not write credentials to the system credential store.', + { + cause: error, + }, + ); + } + } + + async delete(ref: CredentialRef): Promise { + try { + const { AsyncEntry } = await import('@napi-rs/keyring'); + return await new AsyncEntry(this.service, getCredentialAccount(ref)).deleteCredential(); + } catch (error) { + throw new CredentialStoreError( + 'Could not delete credentials from the system credential store.', + { cause: error }, + ); + } + } +} + +export class MemoryCredentialStore implements CredentialStore { + readonly #credentials = new Map(); + readonly #service: string; + + constructor(service = DEFAULT_CREDENTIAL_SERVICE) { + this.#service = service; + } + + async get(ref: CredentialRef): Promise { + return this.#credentials.get(this.#key(ref)) ?? null; + } + + async set(ref: CredentialRef, secret: string): Promise { + if (secret.length === 0) { + throw new CredentialStoreError('Credential value must not be empty.'); + } + + this.#credentials.set(this.#key(ref), secret); + } + + async delete(ref: CredentialRef): Promise { + return this.#credentials.delete(this.#key(ref)); + } + + #key(ref: CredentialRef): string { + return `${this.#service}:${getCredentialAccount(ref)}`; + } +} diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts new file mode 100644 index 0000000..766cc89 --- /dev/null +++ b/packages/cli/src/errors.ts @@ -0,0 +1,122 @@ +import { + AmbiguousDeliveryError, + AuthenticationError, + ConflictError, + IMessageSDKError, + NotFoundError, + ProviderUnavailableError, + RateLimitError, + UnsupportedCapabilityError, + ValidationError, + WebhookVerificationError, +} from 'imessage-sdk'; + +export class CliError extends Error { + readonly code: string; + readonly exitCode: number; + readonly details: unknown; + + constructor( + message: string, + options: { + readonly code: string; + readonly exitCode?: number; + readonly details?: unknown; + readonly cause?: unknown; + }, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = new.target.name; + this.code = options.code; + this.exitCode = options.exitCode ?? 2; + this.details = options.details; + } +} + +export class CliUsageError extends CliError { + constructor(message: string, details?: unknown) { + super(message, { code: 'invalid_cli_input', details }); + } +} + +export class ConfigError extends CliError { + constructor(message: string, details?: unknown) { + const cause = + typeof details === 'object' && details !== null && 'cause' in details + ? details.cause + : undefined; + super(message, { + code: 'invalid_config', + details, + ...(cause === undefined ? {} : { cause }), + }); + } +} + +export class CredentialStoreUnavailableError extends CliError { + constructor(message: string, details?: unknown) { + const cause = + typeof details === 'object' && details !== null && 'cause' in details + ? details.cause + : undefined; + super(message, { + code: 'credential_store_unavailable', + details, + ...(cause === undefined ? {} : { cause }), + }); + } +} + +export interface SerializedCliError { + readonly type: string; + readonly code: string; + readonly message: string; + readonly provider?: string; + readonly connectionId?: string; + readonly retryable: boolean; + readonly retryAfter?: number; + readonly statusCode?: number; + readonly traceId?: string; + readonly deliveryAmbiguous: boolean; + readonly safeToRetry: boolean; + readonly details?: unknown; +} + +export function exitCodeForError(error: unknown): number { + if (error instanceof CliError) return error.exitCode; + if (error instanceof AmbiguousDeliveryError) return 6; + if (error instanceof AuthenticationError) return 3; + if (error instanceof RateLimitError || error instanceof ProviderUnavailableError) return 5; + if ( + error instanceof UnsupportedCapabilityError || + error instanceof NotFoundError || + error instanceof ConflictError || + error instanceof WebhookVerificationError + ) { + return 4; + } + if (error instanceof ValidationError) return 2; + return 1; +} + +export function serializeCliError(error: unknown): SerializedCliError { + const sdkError = error instanceof IMessageSDKError ? error : undefined; + const cliError = error instanceof CliError ? error : undefined; + const ambiguous = error instanceof AmbiguousDeliveryError; + const message = error instanceof Error ? error.message : 'An unknown error occurred.'; + + return { + type: error instanceof Error ? error.name : 'UnknownError', + code: cliError?.code ?? sdkError?.code ?? 'internal_error', + message, + ...(sdkError?.provider === undefined ? {} : { provider: sdkError.provider }), + ...(sdkError?.connectionId === undefined ? {} : { connectionId: sdkError.connectionId }), + retryable: sdkError?.retryable ?? false, + ...(sdkError?.retryAfter === undefined ? {} : { retryAfter: sdkError.retryAfter }), + ...(sdkError?.statusCode === undefined ? {} : { statusCode: sdkError.statusCode }), + ...(sdkError?.traceId === undefined ? {} : { traceId: sdkError.traceId }), + deliveryAmbiguous: ambiguous, + safeToRetry: (sdkError?.retryable ?? false) && !ambiguous, + ...(cliError?.details === undefined ? {} : { details: cliError.details }), + }; +} diff --git a/packages/cli/src/input.ts b/packages/cli/src/input.ts new file mode 100644 index 0000000..32b9f15 --- /dev/null +++ b/packages/cli/src/input.ts @@ -0,0 +1,251 @@ +import type { Readable } from 'node:stream'; + +import { createReadStream } from 'node:fs'; +import { basename } from 'node:path'; + +import { z } from 'zod'; + +import type { + IMessageAddress, + IMessageAttachmentInput, + IMessageAttachmentKind, + SendMessageInput, +} from 'imessage-sdk'; + +import { CliUsageError } from './errors.js'; + +const MAX_TEXT_INPUT_BYTES = 1024 * 1024; +export const MAX_LOCAL_ATTACHMENT_BYTES = 100 * 1024 * 1024; + +export const AddressSchema = z.object({ + kind: z.enum(['phone', 'email']), + value: z.string().trim().min(1), +}); + +const AddressInputSchema = z.union([AddressSchema, z.string().trim().min(1)]); + +const PublicHttpUrlSchema = z.url().refine( + (value) => { + const protocol = new URL(value).protocol; + return protocol === 'http:' || protocol === 'https:'; + }, + { message: 'Attachment URLs must use HTTP or HTTPS.' }, +); + +export const CliAttachmentSchema = z.object({ + kind: z.enum(['image', 'video', 'file']), + source: z.discriminatedUnion('type', [ + z.object({ type: z.literal('url'), url: PublicHttpUrlSchema }), + z.object({ type: z.literal('path'), path: z.string().min(1) }), + ]), + filename: z.string().min(1).optional(), + contentType: z.string().min(1).optional(), +}); + +export const SendCliInputSchema = z + .object({ + to: z.union([AddressInputSchema, z.array(AddressInputSchema).min(1)]).optional(), + conversationId: z.string().trim().min(1).optional(), + text: z.string().optional(), + attachments: z.array(CliAttachmentSchema).optional(), + replyTo: z + .object({ + messageId: z.string().trim().min(1), + partIndex: z.number().int().nonnegative().optional(), + }) + .optional(), + idempotencyKey: z.string().trim().min(1).optional(), + metadata: z.record(z.string(), z.string()).optional(), + }) + .superRefine((value, context) => { + if ((value.to === undefined) === (value.conversationId === undefined)) { + context.addIssue({ + code: 'custom', + message: 'Provide exactly one destination: to or conversationId.', + path: ['to'], + }); + } + if ((value.text?.trim().length ?? 0) === 0 && (value.attachments?.length ?? 0) === 0) { + context.addIssue({ + code: 'custom', + message: 'Provide non-empty text and/or at least one attachment.', + path: ['text'], + }); + } + }); + +export type SendCliInput = z.infer; + +const CONTENT_TYPES: Readonly> = { + gif: 'image/gif', + heic: 'image/heic', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + mov: 'video/quicktime', + mp4: 'video/mp4', + pdf: 'application/pdf', + png: 'image/png', + txt: 'text/plain', + webp: 'image/webp', +}; + +export function parseAddress(value: string): IMessageAddress { + const trimmed = value.trim(); + const address = trimmed.startsWith('phone:') + ? { kind: 'phone' as const, value: trimmed.slice(6) } + : trimmed.startsWith('email:') + ? { kind: 'email' as const, value: trimmed.slice(6) } + : { kind: trimmed.includes('@') ? ('email' as const) : ('phone' as const), value: trimmed }; + if (address.value.trim().length === 0) { + throw new CliUsageError('Message addresses must not be empty.'); + } + return { ...address, value: address.value.trim() }; +} + +function contentTypeFor(path: string): string | undefined { + const extension = path.split('.').pop()?.toLowerCase(); + return extension === undefined ? undefined : CONTENT_TYPES[extension]; +} + +function isUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +export function attachmentFromArgument( + kind: IMessageAttachmentKind, + value: string, +): z.input { + return isUrl(value) + ? { kind, source: { type: 'url', url: value } } + : { kind, source: { type: 'path', path: value } }; +} + +async function materializeAttachment( + attachment: z.infer, + maxBytes: number, +): Promise { + if (attachment.source.type === 'url') { + return { + kind: attachment.kind, + source: { type: 'url', url: attachment.source.url }, + ...(attachment.filename === undefined ? {} : { filename: attachment.filename }), + ...(attachment.contentType === undefined ? {} : { contentType: attachment.contentType }), + }; + } + + let data: Uint8Array; + try { + data = await readBytes(createReadStream(attachment.source.path), maxBytes); + } catch (error) { + if (error instanceof CliUsageError) throw error; + throw new CliUsageError(`Could not read attachment ${attachment.source.path}.`, { + cause: error, + }); + } + const contentType = attachment.contentType ?? contentTypeFor(attachment.source.path); + return { + kind: attachment.kind, + source: { type: 'bytes', data }, + filename: attachment.filename ?? basename(attachment.source.path), + ...(contentType === undefined ? {} : { contentType }), + }; +} + +function normalizeAddress(value: z.infer): IMessageAddress { + return typeof value === 'string' ? parseAddress(value) : value; +} + +export async function materializeSendInput(input: SendCliInput): Promise { + const attachments: IMessageAttachmentInput[] = []; + let remainingAttachmentBytes = MAX_LOCAL_ATTACHMENT_BYTES; + for (const attachment of input.attachments ?? []) { + const materialized = await materializeAttachment(attachment, remainingAttachmentBytes); + attachments.push(materialized); + if (materialized.source.type === 'bytes') { + remainingAttachmentBytes -= materialized.source.data.byteLength; + } + } + const to = + input.to === undefined + ? undefined + : Array.isArray(input.to) + ? input.to.map(normalizeAddress) + : normalizeAddress(input.to); + + const content = { + ...(input.text === undefined ? {} : { text: input.text }), + ...(attachments.length === 0 ? {} : { attachments }), + }; + const extras = { + ...(input.replyTo === undefined ? {} : { replyTo: input.replyTo }), + ...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }), + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + }; + + return ( + input.conversationId === undefined + ? { + to: to as IMessageAddress | readonly [IMessageAddress, ...IMessageAddress[]], + ...content, + ...extras, + } + : { conversationId: input.conversationId, ...content, ...extras } + ) as SendMessageInput; +} + +async function readBytes(stream: Readable, limit: number): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); + size += buffer.byteLength; + if (size > limit) throw new CliUsageError(`Input exceeds the ${limit}-byte limit.`); + chunks.push(buffer); + } + return Buffer.concat(chunks, size); +} + +export async function readTextInput(path: string, stdin: Readable): Promise { + try { + const bytes = await readBytes( + path === '-' ? stdin : createReadStream(path), + MAX_TEXT_INPUT_BYTES, + ); + return Buffer.from(bytes).toString('utf8'); + } catch (error) { + if (error instanceof CliUsageError) throw error; + throw new CliUsageError(`Could not read input from ${path}.`, { cause: error }); + } +} + +export async function readSendInput(path: string, stdin: Readable): Promise { + const source = await readTextInput(path, stdin); + let raw: unknown; + try { + raw = JSON.parse(source); + } catch (error) { + throw new CliUsageError('Send input is not valid JSON.', { cause: error }); + } + + const result = SendCliInputSchema.safeParse(raw); + if (!result.success) { + throw new CliUsageError('Send input does not match the command schema.', result.error.issues); + } + return result.data; +} + +export function parseMetadata(values: readonly string[]): Readonly> { + const metadata: Record = {}; + for (const value of values) { + const separator = value.indexOf('='); + if (separator < 1) + throw new CliUsageError(`Invalid metadata entry: ${value}. Expected key=value.`); + metadata[value.slice(0, separator)] = value.slice(separator + 1); + } + return metadata; +} diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts new file mode 100644 index 0000000..4cc471b --- /dev/null +++ b/packages/cli/src/output.ts @@ -0,0 +1,95 @@ +import type { Writable } from 'node:stream'; + +import { serializeCliError } from './errors.js'; + +export interface OutputContext { + readonly provider?: string; + readonly connectionId?: string; +} + +function serializable(value: unknown, seen = new WeakSet()): unknown { + if (value === null || typeof value === 'string' || typeof value === 'number') return value; + if (typeof value === 'boolean') return value; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (value instanceof Date) return value.toISOString(); + if (value instanceof Uint8Array) { + return { type: 'bytes', byteLength: value.byteLength }; + } + if (value instanceof Error) return serializeCliError(value); + if (Array.isArray(value)) return value.map((entry) => serializable(entry, seen)); + if (typeof value !== 'object') return String(value); + if (seen.has(value)) return '[Circular]'; + + seen.add(value); + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (key === 'raw') continue; + const converted = serializable(entry, seen); + if (converted !== undefined) result[key] = converted; + } + seen.delete(value); + return result; +} + +export function toSerializable(value: unknown): unknown { + return serializable(value); +} + +export function writeJsonLine(stream: Writable, value: unknown): void { + stream.write(`${JSON.stringify(toSerializable(value))}\n`); +} + +export class CommandOutput { + constructor( + private readonly stdout: Writable, + private readonly stderr: Writable, + private readonly json: boolean, + ) {} + + success(command: string, data: unknown, context: OutputContext = {}): void { + if (this.json) { + writeJsonLine(this.stdout, { + schemaVersion: 1, + ok: true, + command, + ...(Object.keys(context).length === 0 ? {} : { context }), + data, + }); + return; + } + + if (typeof data === 'string') { + this.stdout.write(`${data}\n`); + return; + } + + if (data === undefined) { + this.stdout.write('Done.\n'); + return; + } + + this.stdout.write(`${JSON.stringify(toSerializable(data), null, 2)}\n`); + } + + failure(command: string, error: unknown): void { + const serialized = serializeCliError(error); + if (this.json) { + writeJsonLine(this.stderr, { + schemaVersion: 1, + ok: false, + command, + error: serialized, + }); + return; + } + + this.stderr.write(`${serialized.type}: ${serialized.message}\n`); + } + + diagnostic(message: string): void { + this.stderr.write(`${message}\n`); + } +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts new file mode 100644 index 0000000..daae313 --- /dev/null +++ b/packages/cli/src/program.ts @@ -0,0 +1,106 @@ +import { Builtins, Cli } from 'clipanion'; + +import type { CliContext } from './context.js'; +import packageJson from '../package.json' with { type: 'json' }; +import { AttachmentDownloadCommand } from './commands/attachment.js'; +import { CompletionCommand } from './commands/completion.js'; +import { ConfigInitCommand, ConfigPathCommand, ConfigValidateCommand } from './commands/config.js'; +import { + ConnectionAddCommand, + ConnectionCredentialSetCommand, + ConnectionDoctorCommand, + ConnectionListCommand, + ConnectionRemoveCommand, + ConnectionShowCommand, +} from './commands/connection.js'; +import { + ConversationGetCommand, + ConversationMarkReadCommand, + ConversationOpenCommand, +} from './commands/conversation.js'; +import { DefaultCommand } from './commands/default.js'; +import { MessageGetCommand } from './commands/message.js'; +import { + BlooioMessageStatusCommand, + BlooioNumbersListCommand, + PhotonLineShowCommand, + ProviderListCommand, + ProviderShowCommand, + SendblueMessageStatusCommand, + SendblueTapbackAddCommand, +} from './commands/provider.js'; +import { ReactionAddCommand, ReactionRemoveCommand } from './commands/reaction.js'; +import { SchemaCommand } from './commands/schema.js'; +import { SendCommand } from './commands/send.js'; +import { TypingStartCommand, TypingStopCommand } from './commands/typing.js'; +import { WebhookServeCommand } from './commands/webhook.js'; +import { CliUsageError } from './errors.js'; +import { CommandOutput } from './output.js'; + +const COMMANDS = [ + DefaultCommand, + Builtins.HelpCommand, + Builtins.VersionCommand, + Builtins.DefinitionsCommand, + SendCommand, + MessageGetCommand, + ConversationOpenCommand, + ConversationGetCommand, + ConversationMarkReadCommand, + AttachmentDownloadCommand, + ReactionAddCommand, + ReactionRemoveCommand, + TypingStartCommand, + TypingStopCommand, + WebhookServeCommand, + ProviderListCommand, + ProviderShowCommand, + BlooioNumbersListCommand, + BlooioMessageStatusCommand, + PhotonLineShowCommand, + SendblueMessageStatusCommand, + SendblueTapbackAddCommand, + ConnectionAddCommand, + ConnectionListCommand, + ConnectionShowCommand, + ConnectionDoctorCommand, + ConnectionRemoveCommand, + ConnectionCredentialSetCommand, + ConfigInitCommand, + ConfigValidateCommand, + ConfigPathCommand, + SchemaCommand, + CompletionCommand, +] as const; + +export function createCli(): Cli { + const cli = new Cli({ + binaryLabel: 'iMessage CLI', + binaryName: 'imessage-cli', + binaryVersion: packageJson.version, + enableCapture: false, + }); + for (const command of COMMANDS) cli.register(command); + return cli; +} + +export async function runCli(args: readonly string[], context: CliContext): Promise { + const cli = createCli(); + let command; + try { + command = cli.process([...args], context); + } catch (error) { + if (args.includes('--json')) { + const message = error instanceof Error ? error.message : 'Could not parse the command.'; + new CommandOutput(context.stdout, context.stderr, true).failure( + 'cli', + new CliUsageError(message), + ); + } else { + const colored = context.colorDepth > 1; + context.stderr.write(cli.error(error, { colored })); + } + return 2; + } + return await cli.run(command, context); +} diff --git a/packages/cli/src/provider-names.ts b/packages/cli/src/provider-names.ts new file mode 100644 index 0000000..40aaab9 --- /dev/null +++ b/packages/cli/src/provider-names.ts @@ -0,0 +1,3 @@ +export const BUILT_IN_PROVIDER_NAMES = ['blooio', 'photon', 'sendblue'] as const; + +export type BuiltInProviderName = (typeof BUILT_IN_PROVIDER_NAMES)[number]; diff --git a/packages/cli/src/providers.ts b/packages/cli/src/providers.ts new file mode 100644 index 0000000..665f0d2 --- /dev/null +++ b/packages/cli/src/providers.ts @@ -0,0 +1,474 @@ +import type { BlooioProvider } from '@imessage-sdk/blooio'; +import type { PhotonProvider } from '@imessage-sdk/photon'; +import type { SendblueProvider } from '@imessage-sdk/sendblue'; +import type { AnyIMessageProvider, IMessageCapabilities } from 'imessage-sdk'; +import { blooio, BLOOIO_CAPABILITIES } from '@imessage-sdk/blooio'; +import { photon, PHOTON_CAPABILITIES } from '@imessage-sdk/photon'; +import { sendblue, SENDBLUE_CAPABILITIES } from '@imessage-sdk/sendblue'; +import { IMessageSDKError, ValidationError } from 'imessage-sdk'; + +import type { BuiltInProviderName } from './provider-names.js'; +import { BUILT_IN_PROVIDER_NAMES } from './provider-names.js'; + +export type { BuiltInProviderName }; +export { BUILT_IN_PROVIDER_NAMES }; + +export type ProviderPurpose = 'api' | 'webhook' | 'doctor'; + +export type ProviderValue = string | boolean | number; + +/** Values have already been resolved from environment, keyring, or trusted settings. */ +export type ProviderValues = Readonly>; + +export type ProviderFieldKind = 'secret' | 'identity' | 'setting'; + +export interface ProviderFieldDefinition { + /** Canonical key accepted by `createProvider()`. */ + readonly key: string; + readonly label: string; + readonly kind: ProviderFieldKind; + /** Standard provider environment variable, when one exists. */ + readonly env?: string; + /** Purposes for which this field is unconditionally required. */ + readonly requiredFor: readonly ProviderPurpose[]; + readonly description: string; +} + +export type ProviderDoctorStatus = 'ok' | 'warning' | 'error'; + +export type ProviderDoctorDetail = string | number | boolean | readonly string[]; + +export interface ProviderDoctorResult { + readonly status: ProviderDoctorStatus; + readonly message: string; + readonly code?: string; + readonly details?: Readonly>; +} + +interface BuiltInProviderMap { + readonly blooio: BlooioProvider; + readonly photon: PhotonProvider; + readonly sendblue: SendblueProvider | SendblueProvider; +} + +export interface ProviderDefinition< + TName extends BuiltInProviderName = BuiltInProviderName, + TProvider extends AnyIMessageProvider = BuiltInProviderMap[TName], +> { + readonly name: TName; + readonly displayName: string; + readonly packageName: `@imessage-sdk/${TName}`; + readonly description: string; + /** Base normalized capabilities before optional account-gated settings are applied. */ + readonly capabilities: IMessageCapabilities; + readonly fields: readonly ProviderFieldDefinition[]; + readonly create: (values: ProviderValues) => TProvider; + /** Performs only non-mutating checks. It never sends a message or changes provider state. */ + readonly doctor: (values: ProviderValues) => Promise; +} + +export type ProviderRegistry = { + readonly [TName in BuiltInProviderName]: ProviderDefinition; +}; + +function optionalString( + provider: BuiltInProviderName, + values: ProviderValues, + key: string, +): string | undefined { + const value = values[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.trim().length === 0) { + throw invalidValue(provider, key, 'a non-empty string'); + } + return value; +} + +function optionalBoolean( + provider: BuiltInProviderName, + values: ProviderValues, + key: string, +): boolean | undefined { + const value = values[key]; + if (value === undefined) return undefined; + if (typeof value !== 'boolean') throw invalidValue(provider, key, 'a boolean'); + return value; +} + +function optionalPositiveNumber( + provider: BuiltInProviderName, + values: ProviderValues, + key: string, +): number | undefined { + const value = values[key]; + if (value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw invalidValue(provider, key, 'a positive finite number'); + } + return value; +} + +function invalidValue( + provider: BuiltInProviderName, + key: string, + expected: string, +): ValidationError { + return new ValidationError(`${provider}.${key} must be ${expected}.`, { + provider, + code: 'invalid_provider_configuration', + }); +} + +function createBlooio(values: ProviderValues): BlooioProvider { + const apiKey = optionalString('blooio', values, 'apiKey'); + const sender = optionalString('blooio', values, 'sender'); + const webhookSecret = optionalString('blooio', values, 'webhookSecret'); + const baseUrl = optionalString('blooio', values, 'baseUrl'); + + return blooio({ + ...(apiKey === undefined ? {} : { apiKey }), + ...(sender === undefined + ? {} + : { + sender: { + kind: sender.includes('@') ? ('email' as const) : ('phone' as const), + value: sender, + }, + }), + ...(webhookSecret === undefined ? {} : { webhookSecret }), + ...(baseUrl === undefined ? {} : { baseUrl }), + }); +} + +function createPhoton(values: ProviderValues): PhotonProvider { + const projectId = optionalString('photon', values, 'projectId'); + const projectSecret = optionalString('photon', values, 'projectSecret'); + const phone = optionalString('photon', values, 'phone'); + const webhookSecret = optionalString('photon', values, 'webhookSecret'); + const timeout = optionalPositiveNumber('photon', values, 'timeout'); + const retry = optionalBoolean('photon', values, 'retry'); + + return photon({ + ...(projectId === undefined ? {} : { projectId }), + ...(projectSecret === undefined ? {} : { projectSecret }), + ...(phone === undefined ? {} : { phone }), + ...(webhookSecret === undefined ? {} : { webhookSecret }), + ...(timeout === undefined ? {} : { timeout }), + ...(retry === undefined ? {} : { retry }), + }); +} + +function createSendblue(values: ProviderValues): SendblueProvider | SendblueProvider { + const apiKey = optionalString('sendblue', values, 'apiKey'); + const apiSecret = optionalString('sendblue', values, 'apiSecret'); + const fromNumber = optionalString('sendblue', values, 'fromNumber'); + const webhookSecret = optionalString('sendblue', values, 'webhookSecret'); + const baseUrl = optionalString('sendblue', values, 'baseUrl'); + const markReadEnabled = optionalBoolean('sendblue', values, 'markReadEnabled') ?? false; + const options = { + ...(apiKey === undefined ? {} : { apiKey }), + ...(apiSecret === undefined ? {} : { apiSecret }), + ...(fromNumber === undefined ? {} : { fromNumber }), + ...(webhookSecret === undefined ? {} : { webhookSecret }), + ...(baseUrl === undefined ? {} : { baseUrl }), + }; + + return markReadEnabled + ? sendblue({ ...options, markReadEnabled: true }) + : sendblue({ ...options, markReadEnabled: false }); +} + +function redactSecrets( + message: string, + values: ProviderValues, + secretKeys: readonly string[], +): string { + let redacted = message; + for (const key of secretKeys) { + const value = values[key]; + if (typeof value === 'string' && value.length > 0) { + redacted = redacted.split(value).join('[REDACTED]'); + } + } + return redacted; +} + +function doctorError( + error: unknown, + values: ProviderValues, + secretKeys: readonly string[], +): ProviderDoctorResult { + const message = + error instanceof Error ? error.message : 'The provider doctor check failed unexpectedly.'; + return { + status: 'error', + message: redactSecrets(message, values, secretKeys), + ...(error instanceof IMessageSDKError && error.code !== undefined ? { code: error.code } : {}), + }; +} + +async function doctorBlooio(values: ProviderValues): Promise { + let provider: BlooioProvider | undefined; + try { + provider = createBlooio(values); + const numbers = await provider.numbers.list(); + const activeNumbers = numbers.filter((number) => number.active); + const sender = optionalString('blooio', values, 'sender'); + if (sender !== undefined && !activeNumbers.some((number) => number.phoneNumber === sender)) { + return { + status: 'error', + code: 'configured_sender_not_active', + message: 'The configured Blooio sender is not an active number for this API key.', + details: { sender, activeNumbers: activeNumbers.map((number) => number.phoneNumber) }, + }; + } + if (activeNumbers.length === 0) { + return { + status: 'warning', + code: 'no_active_numbers', + message: 'Blooio credentials are valid, but the account has no active linked numbers.', + details: { linkedNumberCount: numbers.length, activeNumberCount: 0 }, + }; + } + return { + status: 'ok', + message: 'Blooio credentials and active line access were verified.', + details: { + linkedNumberCount: numbers.length, + activeNumberCount: activeNumbers.length, + activeNumbers: activeNumbers.map((number) => number.phoneNumber), + }, + }; + } catch (error) { + return doctorError(error, values, ['apiKey', 'webhookSecret']); + } finally { + await provider?.close?.(); + } +} + +async function doctorPhoton(values: ProviderValues): Promise { + let provider: PhotonProvider | undefined; + try { + provider = createPhoton(values); + const line = await provider.connection.getLine(); + return { + status: 'ok', + message: 'Photon project credentials and line access were verified.', + details: { + phone: line.phone, + instanceId: line.instanceId, + lineType: line.type, + }, + }; + } catch (error) { + return doctorError(error, values, ['projectSecret', 'webhookSecret']); + } finally { + try { + await provider?.close?.(); + } catch { + // A failed lazy connection can also reject during cleanup. The original + // diagnostic above is more useful and must remain the doctor result. + } + } +} + +async function doctorSendblue(values: ProviderValues): Promise { + try { + createSendblue(values); + const required = ['apiKey', 'apiSecret', 'fromNumber'] as const; + const missing = required.filter((key) => values[key] === undefined); + if (missing.length > 0) { + return { + status: 'error', + code: 'missing_provider_configuration', + message: `Sendblue doctor requires: ${missing.join(', ')}.`, + }; + } + const fromNumber = optionalString('sendblue', values, 'fromNumber'); + if (fromNumber === undefined || !/^\+[1-9]\d{6,14}$/u.test(fromNumber)) { + return { + status: 'error', + code: 'invalid_phone_number', + message: 'The configured Sendblue from number must be an E.164 phone number.', + }; + } + return { + status: 'warning', + code: 'remote_credentials_not_verified', + message: + 'Sendblue configuration is complete, but the SDK exposes no non-mutating identity endpoint for remote credential verification.', + details: { + fromNumber, + markReadEnabled: optionalBoolean('sendblue', values, 'markReadEnabled') ?? false, + }, + }; + } catch (error) { + return doctorError(error, values, ['apiKey', 'apiSecret', 'webhookSecret']); + } +} + +export const providerRegistry: ProviderRegistry = { + blooio: { + name: 'blooio', + displayName: 'Blooio', + packageName: '@imessage-sdk/blooio', + description: 'Blooio API v2 hosted iMessage provider.', + capabilities: BLOOIO_CAPABILITIES, + fields: [ + { + key: 'apiKey', + label: 'API key', + kind: 'secret', + env: 'BLOOIO_API_KEY', + requiredFor: ['api', 'doctor'], + description: 'Bearer credential for Blooio API operations.', + }, + { + key: 'sender', + label: 'Sender', + kind: 'identity', + env: 'BLOOIO_FROM_NUMBER', + requiredFor: [], + description: 'Linked phone number or email address used as the outbound sender.', + }, + { + key: 'webhookSecret', + label: 'Webhook secret', + kind: 'secret', + env: 'BLOOIO_WEBHOOK_SECRET', + requiredFor: ['webhook'], + description: 'HMAC secret used to verify Blooio webhook requests.', + }, + { + key: 'baseUrl', + label: 'API base URL', + kind: 'setting', + requiredFor: [], + description: 'Trusted Blooio API endpoint override.', + }, + ], + create: createBlooio, + doctor: doctorBlooio, + }, + photon: { + name: 'photon', + displayName: 'Photon Cloud', + packageName: '@imessage-sdk/photon', + description: 'Photon provider backed by a Spectrum Cloud project.', + capabilities: PHOTON_CAPABILITIES, + fields: [ + { + key: 'projectId', + label: 'Project ID', + kind: 'identity', + env: 'PHOTON_PROJECT_ID', + requiredFor: ['api', 'doctor'], + description: 'Spectrum Cloud project identifier.', + }, + { + key: 'projectSecret', + label: 'Project secret', + kind: 'secret', + env: 'PHOTON_PROJECT_SECRET', + requiredFor: ['api', 'doctor'], + description: 'Spectrum Cloud project credential.', + }, + { + key: 'phone', + label: 'Phone number', + kind: 'identity', + env: 'PHOTON_PHONE_NUMBER', + requiredFor: [], + description: 'Dedicated line selector; optional when the project resolves one line.', + }, + { + key: 'webhookSecret', + label: 'Webhook secret', + kind: 'secret', + env: 'PHOTON_WEBHOOK_SECRET', + requiredFor: ['webhook'], + description: 'HMAC secret used to verify Spectrum webhook requests.', + }, + { + key: 'timeout', + label: 'Request timeout', + kind: 'setting', + requiredFor: [], + description: 'Positive timeout forwarded to the Photon client.', + }, + { + key: 'retry', + label: 'Retry enabled', + kind: 'setting', + requiredFor: [], + description: 'Whether the Photon transport may retry eligible upstream operations.', + }, + ], + create: createPhoton, + doctor: doctorPhoton, + }, + sendblue: { + name: 'sendblue', + displayName: 'Sendblue', + packageName: '@imessage-sdk/sendblue', + description: 'Sendblue API v2 hosted iMessage provider.', + capabilities: SENDBLUE_CAPABILITIES, + fields: [ + { + key: 'apiKey', + label: 'API key', + kind: 'secret', + env: 'SENDBLUE_API_KEY', + requiredFor: ['api', 'doctor'], + description: 'Sendblue API key ID; handled as a secret credential.', + }, + { + key: 'apiSecret', + label: 'API secret', + kind: 'secret', + env: 'SENDBLUE_API_SECRET', + requiredFor: ['api', 'doctor'], + description: 'Sendblue API secret key.', + }, + { + key: 'fromNumber', + label: 'From number', + kind: 'identity', + env: 'SENDBLUE_FROM_NUMBER', + requiredFor: ['api', 'webhook', 'doctor'], + description: 'E.164 Sendblue line used for API calls and account-wide webhook filtering.', + }, + { + key: 'webhookSecret', + label: 'Webhook secret', + kind: 'secret', + env: 'SENDBLUE_WEBHOOK_SECRET', + requiredFor: ['webhook'], + description: 'Shared secret expected in the sb-signing-secret webhook header.', + }, + { + key: 'markReadEnabled', + label: 'Mark read enabled', + kind: 'setting', + requiredFor: [], + description: 'Enable only after Sendblue activates manual mark-read for the account.', + }, + { + key: 'baseUrl', + label: 'API base URL', + kind: 'setting', + requiredFor: [], + description: 'Trusted Sendblue API endpoint override.', + }, + ], + create: createSendblue, + doctor: doctorSendblue, + }, +}; + +export function createProvider( + name: TName, + values: ProviderValues = {}, +): BuiltInProviderMap[TName] { + return providerRegistry[name].create(values) as BuiltInProviderMap[TName]; +} diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts new file mode 100644 index 0000000..034b54a --- /dev/null +++ b/packages/cli/src/runtime.ts @@ -0,0 +1,272 @@ +import type { AnyIMessageProvider, IMessageClient } from 'imessage-sdk'; +import { createIMessageClient, DEFAULT_CONNECTION_ID } from 'imessage-sdk'; + +import type { CliConfig, ConnectionConfig, ProviderName } from './config.js'; +import type { CliContext } from './context.js'; +import type { CredentialRef } from './credentials.js'; +import type { + BuiltInProviderName, + ProviderDefinition, + ProviderPurpose, + ProviderValue, + ProviderValues, +} from './providers.js'; +import { CliUsageError, ConfigError } from './errors.js'; +import { BUILT_IN_PROVIDER_NAMES, createProvider, providerRegistry } from './providers.js'; + +export interface ProviderOverrides { + readonly apiKey?: string; + readonly apiSecret?: string; + readonly projectId?: string; + readonly projectSecret?: string; + readonly fromNumber?: string; + readonly phoneNumber?: string; + readonly webhookSecret?: string; + readonly timeout?: number; + readonly retry?: boolean; + readonly markReadEnabled?: boolean; +} + +export interface ClientSelection { + readonly provider?: string; + readonly connection?: string; + readonly overrides?: ProviderOverrides; + readonly purpose: ProviderPurpose; + readonly allowPrompt: boolean; +} + +export interface ResolvedClient { + readonly client: IMessageClient; + readonly provider: AnyIMessageProvider; + readonly definition: ProviderDefinition; + readonly providerName: BuiltInProviderName; + readonly connectionName?: string; + readonly connectionId: string; + readonly values: ProviderValues; +} + +export function isBuiltInProviderName(value: string): value is BuiltInProviderName { + return (BUILT_IN_PROVIDER_NAMES as readonly string[]).includes(value); +} + +function assertProviderName(value: string): BuiltInProviderName { + if (!isBuiltInProviderName(value)) { + throw new CliUsageError( + `Unknown provider ${JSON.stringify(value)}. Expected one of: ${BUILT_IN_PROVIDER_NAMES.join(', ')}.`, + ); + } + return value; +} + +function readConnection(config: CliConfig, name: string): ConnectionConfig { + const connection = config.connections[name]; + if (connection === undefined) { + throw new ConfigError(`Connection ${JSON.stringify(name)} does not exist.`); + } + return connection; +} + +function resolveSelection( + config: CliConfig, + selection: ClientSelection, +): { + readonly providerName: BuiltInProviderName; + readonly connectionName?: string; + readonly connection?: ConnectionConfig; +} { + if (selection.connection !== undefined) { + const connection = readConnection(config, selection.connection); + const providerName = assertProviderName(connection.provider); + if (selection.provider !== undefined && selection.provider !== providerName) { + throw new CliUsageError( + `Connection ${JSON.stringify(selection.connection)} uses ${providerName}, not ${selection.provider}.`, + ); + } + return { providerName, connectionName: selection.connection, connection }; + } + + if (selection.provider === undefined) { + throw new CliUsageError('Provide --provider or --connection .'); + } + + const providerName = assertProviderName(selection.provider); + const defaultConnection = config.defaultConnections[providerName]; + if (defaultConnection === undefined) return { providerName }; + return { + providerName, + connectionName: defaultConnection, + connection: readConnection(config, defaultConnection), + }; +} + +function settingValue(provider: BuiltInProviderName, key: string, value: unknown): ProviderValue { + if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') { + return value; + } + throw new ConfigError(`Stored setting ${provider}.${key} must be a string, boolean, or number.`); +} + +function environmentValue(key: string, value: string): ProviderValue { + if (key === 'timeout') { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) { + throw new ConfigError(`${key} environment value must be a positive number.`); + } + return number; + } + if (key === 'retry' || key === 'markReadEnabled') { + if (value === 'true' || value === '1') return true; + if (value === 'false' || value === '0') return false; + throw new ConfigError(`${key} environment value must be true or false.`); + } + return value; +} + +export function providerValuesFromOverrides( + provider: BuiltInProviderName, + overrides: ProviderOverrides, +): Readonly> { + const shared = { + apiKey: overrides.apiKey, + webhookSecret: overrides.webhookSecret, + }; + switch (provider) { + case 'blooio': + return { ...shared, sender: overrides.fromNumber }; + case 'photon': + return { + projectId: overrides.projectId, + projectSecret: overrides.projectSecret, + phone: overrides.phoneNumber, + webhookSecret: overrides.webhookSecret, + timeout: overrides.timeout, + retry: overrides.retry, + }; + case 'sendblue': + return { + ...shared, + apiSecret: overrides.apiSecret, + fromNumber: overrides.fromNumber, + markReadEnabled: overrides.markReadEnabled, + }; + } +} + +function credentialRef( + connection: string, + provider: BuiltInProviderName, + name: string, +): CredentialRef { + return { connection, provider, name }; +} + +async function resolveValues( + context: CliContext, + config: CliConfig, + selection: ClientSelection, + providerName: BuiltInProviderName, + connectionName: string | undefined, + connection: ConnectionConfig | undefined, +): Promise { + const definition = providerRegistry[providerName]; + const values: Record = {}; + const allowedKeys = new Set(definition.fields.map((field) => field.key)); + + for (const [key, value] of Object.entries(connection?.settings ?? {})) { + if (!allowedKeys.has(key)) { + throw new ConfigError(`Connection contains an unknown ${providerName} setting: ${key}.`); + } + values[key] = settingValue(providerName, key, value); + } + + if (connectionName !== undefined) { + for (const field of definition.fields) { + if (field.kind === 'setting') continue; + const value = await context.credentialStore.get( + credentialRef(connectionName, providerName, field.key), + ); + if (value !== null) values[field.key] = value; + } + } + + for (const field of definition.fields) { + if (field.env === undefined) continue; + const value = context.env[field.env]; + if (value !== undefined && value.length > 0) { + values[field.key] = environmentValue(field.key, value); + } + } + + for (const [key, value] of Object.entries( + providerValuesFromOverrides(providerName, selection.overrides ?? {}), + )) { + if (value !== undefined) values[key] = value; + } + + for (const field of definition.fields) { + if (!field.requiredFor.includes(selection.purpose) || values[field.key] !== undefined) continue; + if (!selection.allowPrompt) { + throw new ConfigError( + `Missing ${field.label} for ${providerName}. Configure a connection, set ${field.env ?? field.key}, or pass the corresponding option.`, + ); + } + values[field.key] = + field.kind === 'secret' + ? await context.prompt.secret(`${definition.displayName} ${field.label}`) + : await context.prompt.text(`${definition.displayName} ${field.label}`); + } + + void config; + return values; +} + +export async function resolveClient( + context: CliContext, + config: CliConfig, + selection: ClientSelection, +): Promise { + const resolved = resolveSelection(config, selection); + const values = await resolveValues( + context, + config, + selection, + resolved.providerName, + resolved.connectionName, + resolved.connection, + ); + const provider = createProvider(resolved.providerName, values) as AnyIMessageProvider; + const connectionId = resolved.connectionName ?? DEFAULT_CONNECTION_ID; + const client = createIMessageClient({ provider, connectionId }); + return { + client, + provider, + definition: providerRegistry[resolved.providerName], + providerName: resolved.providerName, + ...(resolved.connectionName === undefined ? {} : { connectionName: resolved.connectionName }), + connectionId, + values, + }; +} + +export async function withResolvedClient( + context: CliContext, + config: CliConfig, + selection: ClientSelection, + operation: (resolved: ResolvedClient) => Promise, + onCleanupError?: (error: unknown, resolved: ResolvedClient) => void, +): Promise { + const resolved = await resolveClient(context, config, selection); + try { + return await operation(resolved); + } finally { + try { + await resolved.client.close(); + } catch (error) { + onCleanupError?.(error, resolved); + } + } +} + +export function providerForConnection(config: CliConfig, connectionName: string): ProviderName { + return readConnection(config, connectionName).provider; +} diff --git a/packages/cli/src/webhook-server.ts b/packages/cli/src/webhook-server.ts new file mode 100644 index 0000000..3b7627b --- /dev/null +++ b/packages/cli/src/webhook-server.ts @@ -0,0 +1,283 @@ +import type { AddressInfo } from 'node:net'; + +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; + +import type { IMessageEvent } from 'imessage-sdk'; +import { ValidationError, WebhookVerificationError } from 'imessage-sdk'; + +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_PORT = 8787; +const DEFAULT_PATH = '/webhooks'; +const DEFAULT_HEALTH_PATH = '/healthz'; +const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; + +class MalformedWebhookRequestError extends Error {} + +class WebhookBodyTooLargeError extends Error {} + +export interface WebhookServerClient { + readonly webhooks: { + handle(request: Request): Promise; + }; + close(): Promise; +} + +export interface StartWebhookServerOptions { + readonly client: WebhookServerClient; + readonly onEvent: (event: TEvent) => void | Promise; + readonly onError?: (failure: WebhookServerFailure) => void | Promise; + readonly host?: string; + /** Use `0` to bind an available ephemeral port. */ + readonly port?: number; + readonly path?: string; + /** Defaults to `/healthz`. Set to `false` to disable the health endpoint. */ + readonly healthPath?: string | false; + readonly maxBodyBytes?: number; +} + +export interface WebhookServerFailure { + readonly statusCode: number; + readonly code: string; + readonly error: unknown; +} + +export interface WebhookServerAddress { + readonly host: string; + readonly port: number; + readonly path: string; + readonly url: string; + readonly healthUrl?: string; +} + +export interface WebhookServer { + readonly address: WebhookServerAddress; + /** Stops accepting requests and waits for in-flight requests. Does not close the SDK client. */ + close(): Promise; +} + +function validatePath(value: string, name: string): void { + if (!value.startsWith('/') || value.includes('?') || value.includes('#')) { + throw new ValidationError(`${name} must be an absolute URL path without a query or fragment.`, { + code: 'invalid_webhook_server_path', + }); + } +} + +function validateOptions(options: StartWebhookServerOptions): void { + const host = options.host ?? DEFAULT_HOST; + const port = options.port ?? DEFAULT_PORT; + const path = options.path ?? DEFAULT_PATH; + const healthPath = options.healthPath ?? DEFAULT_HEALTH_PATH; + const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + + if (host.trim().length === 0) { + throw new ValidationError('host must not be empty.', { + code: 'invalid_webhook_server_host', + }); + } + + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new ValidationError('port must be an integer between 0 and 65535.', { + code: 'invalid_webhook_server_port', + }); + } + + validatePath(path, 'path'); + + if (healthPath !== false) { + validatePath(healthPath, 'healthPath'); + + if (healthPath === path) { + throw new ValidationError('healthPath must differ from path.', { + code: 'invalid_webhook_server_health_path', + }); + } + } + + if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes <= 0) { + throw new ValidationError('maxBodyBytes must be a positive safe integer.', { + code: 'invalid_webhook_server_body_limit', + }); + } +} + +function formatHost(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; +} + +function errorResponse(statusCode: number, code: string, allow?: string): Response { + const body = `${JSON.stringify({ error: code })}\n`; + return new Response(body, { + status: statusCode, + headers: { + ...(allow === undefined ? {} : { allow }), + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }, + }); +} + +function parseContentLength(request: Request): number | undefined { + const value = request.headers.get('content-length'); + if (value === null) return undefined; + if (!/^\d+$/u.test(value)) { + throw new MalformedWebhookRequestError('Invalid Content-Length header.'); + } + const length = Number(value); + if (!Number.isSafeInteger(length)) { + throw new MalformedWebhookRequestError('Invalid Content-Length header.'); + } + return length; +} + +function providerRequest(request: Request, body: ArrayBuffer): Request { + return new Request(request.url, { + method: request.method, + headers: request.headers, + ...(body.byteLength === 0 ? {} : { body }), + }); +} + +export async function startWebhookServer( + options: StartWebhookServerOptions, +): Promise { + validateOptions(options); + + const host = options.host ?? DEFAULT_HOST; + const requestedPort = options.port ?? DEFAULT_PORT; + const path = options.path ?? DEFAULT_PATH; + const healthPath = options.healthPath ?? DEFAULT_HEALTH_PATH; + const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + const app = new Hono(); + + const reportError = async (failure: WebhookServerFailure): Promise => { + try { + await options.onError?.(failure); + } catch { + // Diagnostics must never change the provider's webhook response. + } + }; + + const limitWebhookBody = bodyLimit({ + maxSize: maxBodyBytes, + onError: async () => { + const error = new WebhookBodyTooLargeError('Webhook request body is too large.'); + await reportError({ statusCode: 413, code: 'request_body_too_large', error }); + return errorResponse(413, 'request_body_too_large'); + }, + }); + + app.use('*', async (context, next) => { + const url = new URL(context.req.url); + if (url.pathname !== path || context.req.method !== 'POST') return await next(); + + try { + const contentLength = parseContentLength(context.req.raw); + if (contentLength !== undefined && contentLength > maxBodyBytes) { + const error = new WebhookBodyTooLargeError('Webhook request body is too large.'); + await reportError({ statusCode: 413, code: 'request_body_too_large', error }); + return errorResponse(413, 'request_body_too_large'); + } + } catch (error) { + await reportError({ statusCode: 400, code: 'invalid_webhook_request', error }); + return errorResponse(400, 'invalid_webhook_request'); + } + + return await limitWebhookBody(context, next); + }); + + app.all('*', async (context) => { + const url = new URL(context.req.url); + + if (healthPath !== false && url.pathname === healthPath) { + return context.req.method === 'GET' + ? new Response(null, { status: 204 }) + : errorResponse(405, 'method_not_allowed', 'GET'); + } + + if (url.pathname !== path) return errorResponse(404, 'not_found'); + if (context.req.method !== 'POST') { + return errorResponse(405, 'method_not_allowed', 'POST'); + } + + try { + const body = await context.req.arrayBuffer(); + const events = await options.client.webhooks.handle(providerRequest(context.req.raw, body)); + + for (const event of events) { + await options.onEvent(event); + } + + return new Response(null, { status: 204 }); + } catch (error) { + let statusCode = 500; + let code = 'webhook_processing_failed'; + if (error instanceof WebhookVerificationError) { + statusCode = 401; + code = 'webhook_verification_failed'; + } else if (error instanceof WebhookBodyTooLargeError) { + statusCode = 413; + code = 'request_body_too_large'; + } else if ( + error instanceof ValidationError || + error instanceof MalformedWebhookRequestError + ) { + statusCode = 400; + code = 'invalid_webhook_request'; + } + await reportError({ statusCode, code, error }); + return errorResponse(statusCode, code); + } + }); + + app.onError(async (error) => { + await reportError({ statusCode: 500, code: 'webhook_processing_failed', error }); + return errorResponse(500, 'webhook_processing_failed'); + }); + + let server: ReturnType | undefined; + const boundAddress = await new Promise((resolve, reject) => { + server = serve( + { + fetch: app.fetch, + hostname: host, + port: requestedPort, + }, + resolve, + ); + server.once('error', reject); + }); + + if (server === undefined) throw new Error('Webhook server did not start.'); + const boundServer = server; + + const boundPort = boundAddress.port; + const origin = `http://${formatHost(host)}:${boundPort}`; + let closePromise: Promise | undefined; + + return { + address: { + host, + port: boundPort, + path, + url: `${origin}${path}`, + ...(healthPath === false ? {} : { healthUrl: `${origin}${healthPath}` }), + }, + close() { + if (closePromise !== undefined) return closePromise; + + closePromise = new Promise((resolve, reject) => { + boundServer.close((error) => { + if (error !== undefined) { + reject(error); + return; + } + resolve(); + }); + }); + return closePromise; + }, + }; +} diff --git a/packages/cli/test/cli.integration.test.ts b/packages/cli/test/cli.integration.test.ts new file mode 100644 index 0000000..791b776 --- /dev/null +++ b/packages/cli/test/cli.integration.test.ts @@ -0,0 +1,377 @@ +import { execFile as executeFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { beforeAll, describe, expect, it } from 'vitest'; + +const execFile = promisify(executeFile); +const rootDirectory = new URL('../../../', import.meta.url); +const binaryPath = new URL('../dist/cli.js', import.meta.url); +const enabled = process.env['IMESSAGE_CLI_RUN_LIVE'] === '1'; +const runId = `cli-live-${Date.now()}`; + +interface CommandResult { + readonly schemaVersion: 1; + readonly ok: true; + readonly command: string; + readonly context?: { readonly provider?: string; readonly connectionId?: string }; + readonly data: unknown; +} + +interface SentMessage { + readonly providerMessageId: string; + readonly conversationId: string; +} + +const secretValues = [ + process.env['BLOOIO_API_KEY'], + process.env['BLOOIO_WEBHOOK_SECRET'], + process.env['PHOTON_PROJECT_SECRET'], + process.env['PHOTON_WEBHOOK_SECRET'], + process.env['SENDBLUE_API_KEY'], + process.env['SENDBLUE_API_SECRET'], + process.env['SENDBLUE_WEBHOOK_SECRET'], +].filter((value): value is string => value !== undefined && value.length > 0); + +describe.skipIf(!enabled)('imessage-cli live provider API', () => { + beforeAll(() => { + expect(process.env['IMESSAGE_CLI_RUN_LIVE']).toBe('1'); + }); + + it('exercises Blooio through the built CLI', async () => { + const recipient = required('IMESSAGE_CLI_TEST_RECIPIENT'); + const imageUrl = required('IMESSAGE_CLI_TEST_IMAGE_URL'); + const videoUrl = required('IMESSAGE_CLI_TEST_VIDEO_URL'); + const fileUrl = required('IMESSAGE_CLI_TEST_FILE_URL'); + + await command(['provider', 'blooio', 'numbers', 'list', '--json', '--no-input']); + const conversation = await openConversation('blooio', recipient); + const text = await sendText('blooio', conversation, 'Blooio text'); + + await command([ + 'message', + 'get', + '--provider', + 'blooio', + '--conversation', + text.conversationId, + '--message', + text.providerMessageId, + '--json', + '--no-input', + ]); + await command([ + 'provider', + 'blooio', + 'message', + 'status', + '--conversation', + text.conversationId, + '--message', + text.providerMessageId, + '--json', + '--no-input', + ]); + await sendAttachments( + 'blooio', + conversation, + imageUrl, + videoUrl, + fileUrl, + text.providerMessageId, + ); + await testCommonInteractions('blooio', text); + }, 180_000); + + it('exercises Photon through the built CLI', async () => { + const recipient = required('IMESSAGE_CLI_TEST_RECIPIENT'); + const imageUrl = required('IMESSAGE_CLI_TEST_IMAGE_URL'); + const videoUrl = required('IMESSAGE_CLI_TEST_VIDEO_URL'); + const fileUrl = required('IMESSAGE_CLI_TEST_FILE_URL'); + + await command(['provider', 'photon', 'line', 'show', '--json', '--no-input']); + const conversation = await openConversation('photon', recipient); + const text = await sendText('photon', conversation, 'Photon text'); + + await command([ + 'message', + 'get', + '--provider', + 'photon', + '--conversation', + text.conversationId, + '--message', + text.providerMessageId, + '--json', + '--no-input', + ]); + await sendAttachments( + 'photon', + conversation, + imageUrl, + videoUrl, + fileUrl, + text.providerMessageId, + ); + await testCommonInteractions('photon', text); + }, 180_000); + + it('exercises Sendblue through the built CLI', async () => { + const recipient = required('IMESSAGE_CLI_TEST_RECIPIENT'); + const imageUrl = required('IMESSAGE_CLI_TEST_IMAGE_URL'); + const videoUrl = required('IMESSAGE_CLI_TEST_VIDEO_URL'); + const fileUrl = required('IMESSAGE_CLI_TEST_FILE_URL'); + + const conversation = await openConversation('sendblue', recipient); + const text = await sendText('sendblue', conversation, 'Sendblue text'); + + await command([ + 'message', + 'get', + '--provider', + 'sendblue', + '--conversation', + text.conversationId, + '--message', + text.providerMessageId, + '--json', + '--no-input', + ]); + await command([ + 'provider', + 'sendblue', + 'message', + 'status', + '--conversation', + text.conversationId, + '--message', + text.providerMessageId, + '--json', + '--no-input', + ]); + + // Sendblue accepts one attachment per message and does not support replies. + for (const [flag, url] of [ + ['--image', imageUrl], + ['--video', videoUrl], + ['--file', fileUrl], + ] as const) { + await command([ + 'send', + '--provider', + 'sendblue', + '--conversation', + conversation, + '--text', + `${runId} Sendblue ${flag.slice(2)} attachment`, + flag, + url, + '--json', + '--no-input', + ]); + } + await command([ + 'typing', + 'start', + '--provider', + 'sendblue', + conversation, + '--json', + '--no-input', + ]); + await command([ + 'typing', + 'stop', + '--provider', + 'sendblue', + conversation, + '--json', + '--no-input', + ]); + }, 180_000); +}); + +async function openConversation(provider: 'blooio' | 'photon' | 'sendblue', recipient: string) { + const result = await command([ + 'conversation', + 'open', + '--provider', + provider, + '--participant', + recipient, + '--json', + '--no-input', + ]); + return field(result.data, 'id'); +} + +async function sendText( + provider: 'blooio' | 'photon' | 'sendblue', + conversation: string, + label: string, +): Promise { + const result = await command([ + 'send', + '--provider', + provider, + '--conversation', + conversation, + '--text', + `${runId} ${label}`, + ...(provider === 'sendblue' ? [] : ['--idempotency-key', `${runId}-${provider}-text`]), + '--json', + '--no-input', + ]); + return { + providerMessageId: field(result.data, 'providerMessageId'), + conversationId: field(result.data, 'conversationId'), + }; +} + +async function sendAttachments( + provider: 'blooio' | 'photon', + conversation: string, + imageUrl: string, + videoUrl: string, + fileUrl: string, + replyTo: string, +): Promise { + await command([ + 'send', + '--provider', + provider, + '--conversation', + conversation, + '--text', + `${runId} ${provider} attachments and reply`, + '--image', + imageUrl, + '--video', + videoUrl, + '--file', + fileUrl, + '--reply-to', + replyTo, + '--idempotency-key', + `${runId}-${provider}-attachments`, + '--json', + '--no-input', + ]); +} + +async function testCommonInteractions( + provider: 'blooio' | 'photon', + message: SentMessage, +): Promise { + await command([ + 'conversation', + 'get', + '--provider', + provider, + message.conversationId, + '--json', + '--no-input', + ]); + await command([ + 'typing', + 'start', + '--provider', + provider, + message.conversationId, + '--json', + '--no-input', + ]); + await command([ + 'typing', + 'stop', + '--provider', + provider, + message.conversationId, + '--json', + '--no-input', + ]); + await command([ + 'reaction', + 'add', + '--provider', + provider, + '--conversation', + message.conversationId, + '--message', + message.providerMessageId, + '--reaction', + 'like', + '--json', + '--no-input', + ]); + await command([ + 'reaction', + 'remove', + '--provider', + provider, + '--conversation', + message.conversationId, + '--message', + message.providerMessageId, + '--reaction', + 'like', + '--json', + '--no-input', + ]); + await command([ + 'conversation', + 'mark-read', + '--provider', + provider, + message.conversationId, + '--json', + '--no-input', + ]); +} + +async function command(arguments_: readonly string[]): Promise { + try { + const { stdout } = await execFile(process.execPath, [binaryPath.pathname, ...arguments_], { + cwd: rootDirectory.pathname, + env: process.env, + maxBuffer: 1024 * 1024, + }); + const parsed = JSON.parse(stdout) as unknown; + if (!isSuccess(parsed)) { + throw new Error(`CLI returned an invalid JSON success response: ${JSON.stringify(parsed)}`); + } + return parsed; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(redact(message), { cause: error }); + } +} + +function isSuccess(value: unknown): value is CommandResult { + return ( + typeof value === 'object' && + value !== null && + (value as { readonly ok?: unknown }).ok === true && + (value as { readonly schemaVersion?: unknown }).schemaVersion === 1 + ); +} + +function field(value: unknown, name: string): string { + if (typeof value !== 'object' || value === null) { + throw new Error(`CLI response did not include ${name}.`); + } + const candidate = (value as Record)[name]; + if (typeof candidate !== 'string') throw new Error(`CLI response did not include ${name}.`); + return candidate; +} + +function required(name: string): string { + const value = process.env[name]; + if (value === undefined || value.length === 0) { + throw new Error(`${name} is required when IMESSAGE_CLI_RUN_LIVE=1.`); + } + return value; +} + +function redact(value: string): string { + return secretValues.reduce((result, secret) => result.split(secret).join('[REDACTED]'), value); +} diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..c5cc82e --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -0,0 +1,513 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable, Writable } from 'node:stream'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { CliContext, PromptService } from '../src/context.js'; +import type { CredentialRef, CredentialStore } from '../src/credentials.js'; +import { ConfigStore } from '../src/config.js'; +import { CredentialStoreError, MemoryCredentialStore } from '../src/credentials.js'; +import { runCli } from '../src/program.js'; + +class Capture extends Writable { + readonly chunks: Buffer[] = []; + + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + callback(); + } + + text(): string { + return Buffer.concat(this.chunks).toString('utf8'); + } +} + +const prompt: PromptService = { + async text() { + throw new Error('Unexpected prompt'); + }, + async secret() { + throw new Error('Unexpected prompt'); + }, + async confirm() { + throw new Error('Unexpected prompt'); + }, +}; + +function context( + configPath: string, + options: { readonly input?: string; readonly credentials?: CredentialStore } = {}, +): { readonly context: CliContext; readonly stdout: Capture; readonly stderr: Capture } { + const stdout = new Capture(); + const stderr = new Capture(); + return { + context: { + env: {}, + stdin: Readable.from(options.input === undefined ? [] : [options.input]), + stdout, + stderr, + colorDepth: 1, + configStore: new ConfigStore(configPath), + credentialStore: options.credentials ?? new MemoryCredentialStore('test-imessage-cli'), + prompt, + cwd: process.cwd(), + }, + stdout, + stderr, + }; +} + +class OneShotFailingCredentialStore implements CredentialStore { + failNextDelete = false; + + constructor(readonly delegate: MemoryCredentialStore) {} + + async get(ref: CredentialRef): Promise { + return await this.delegate.get(ref); + } + + async set(ref: CredentialRef, secret: string): Promise { + await this.delegate.set(ref, secret); + } + + async delete(ref: CredentialRef): Promise { + if (this.failNextDelete) { + this.failNextDelete = false; + throw new CredentialStoreError('Simulated credential deletion failure.'); + } + return await this.delegate.delete(ref); + } +} + +function jsonLine(output: Capture): Record { + return JSON.parse(output.text()) as Record; +} + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + const { rm } = await import('node:fs/promises'); + await Promise.all( + temporaryDirectories.splice(0).map(async (path) => rm(path, { recursive: true })), + ); +}); + +async function temporaryConfig(): Promise<{ readonly directory: string; readonly path: string }> { + const directory = await mkdtemp(join(tmpdir(), 'imessage-cli-test-')); + temporaryDirectories.push(directory); + return { directory, path: join(directory, 'config.json') }; +} + +describe('imessage-cli', () => { + it('requires an explicit opt-in for the experimental webhook server', async () => { + const config = await temporaryConfig(); + const test = context(config.path); + + const code = await runCli( + ['webhook', 'serve', '--provider', 'blooio', '--json', '--no-input'], + test.context, + ); + + expect(code).toBe(2); + expect(jsonLine(test.stderr)).toMatchObject({ + ok: false, + command: 'webhook.serve', + error: { + type: 'CliUsageError', + message: 'The CLI webhook server is experimental. Re-run this command with --experimental.', + }, + }); + expect(test.stdout.text()).toBe(''); + }); + + it('lists every bundled provider as stable JSON', async () => { + const config = await temporaryConfig(); + const test = context(config.path); + + const code = await runCli(['provider', 'list', '--json'], test.context); + + expect(code).toBe(0); + expect(jsonLine(test.stdout)).toMatchObject({ + schemaVersion: 1, + ok: true, + command: 'provider.list', + data: [{ name: 'blooio' }, { name: 'photon' }, { name: 'sendblue' }], + }); + expect(test.stderr.text()).toBe(''); + }); + + it('stores connection secrets and identities outside the JSON config', async () => { + const config = await temporaryConfig(); + const credentials = new MemoryCredentialStore('test-imessage-cli'); + const add = context(config.path, { credentials }); + + const addCode = await runCli( + [ + 'connection', + 'add', + 'support', + '--provider', + 'blooio', + '--api-key', + 'private-api-key', + '--from-number', + '+15555550100', + '--webhook-secret', + 'private-webhook-secret', + '--no-input', + '--json', + ], + add.context, + ); + + expect(addCode).toBe(0); + const storedConfig = await readFile(config.path, 'utf8'); + expect(storedConfig).not.toContain('private-api-key'); + expect(storedConfig).not.toContain('private-webhook-secret'); + expect(storedConfig).not.toContain('+15555550100'); + expect(storedConfig).toContain('support'); + + const show = context(config.path, { credentials }); + const showCode = await runCli(['connection', 'show', 'support', '--json'], show.context); + expect(showCode).toBe(0); + expect(jsonLine(show.stdout)).toMatchObject({ + ok: true, + data: { + name: 'support', + provider: 'blooio', + default: true, + fields: { + apiKey: { configured: true }, + sender: { configured: true }, + webhookSecret: { configured: true }, + }, + }, + }); + + const send = context(config.path, { credentials }); + const sendCode = await runCli( + [ + 'send', + '--provider', + 'blooio', + '--to', + '+15555550101', + '--text', + 'Hello', + '--dry-run', + '--json', + ], + send.context, + ); + expect(sendCode).toBe(0); + expect(jsonLine(send.stdout)).toMatchObject({ + ok: true, + context: { provider: 'blooio', connectionId: 'support' }, + data: { dryRun: true }, + }); + + const remove = context(config.path, { credentials }); + const removeCode = await runCli( + ['connection', 'remove', 'support', '--yes', '--json'], + remove.context, + ); + expect(removeCode).toBe(0); + expect((await new ConfigStore(config.path).load()).connections).toEqual({}); + }); + + it('accepts the agent stdin envelope with a local Photon attachment', async () => { + const config = await temporaryConfig(); + const attachmentPath = join(config.directory, 'screenshot.png'); + await writeFile(attachmentPath, Buffer.from([1, 2, 3, 4])); + const input = JSON.stringify({ + to: [{ kind: 'phone', value: '+15555550101' }], + text: 'Generated by an agent', + attachments: [{ kind: 'image', source: { type: 'path', path: attachmentPath } }], + idempotencyKey: 'agent-run-018fd6', + }); + const test = context(config.path, { input }); + + const code = await runCli( + [ + 'send', + '--provider', + 'photon', + '--project-id', + 'project', + '--project-secret', + 'secret', + '--input', + '-', + '--dry-run', + '--json', + ], + test.context, + ); + + expect(code).toBe(0); + expect(jsonLine(test.stdout)).toMatchObject({ + ok: true, + data: { + input: { + text: 'Generated by an agent', + attachments: [{ source: { type: 'bytes', data: { byteLength: 4 } } }], + idempotencyKey: 'agent-run-018fd6', + }, + }, + }); + }); + + it('normalizes explicit phone addresses during a Sendblue dry run', async () => { + const config = await temporaryConfig(); + const test = context(config.path); + + const code = await runCli( + [ + 'send', + '--provider', + 'sendblue', + '--api-key', + 'key', + '--api-secret', + 'secret', + '--from-number', + '+15555550100', + '--to', + 'phone:+15555550101', + '--text', + 'Hello', + '--dry-run', + '--json', + ], + test.context, + ); + + expect(code).toBe(0); + expect(jsonLine(test.stdout)).toMatchObject({ + data: { input: { to: [{ kind: 'phone', value: '+15555550101' }] } }, + }); + }); + + it('rejects local Blooio attachments before trying to read them', async () => { + const config = await temporaryConfig(); + const test = context(config.path); + + const code = await runCli( + [ + 'send', + '--provider', + 'blooio', + '--api-key', + 'key', + '--to', + '+15555550101', + '--image', + './does-not-exist.png', + '--dry-run', + '--json', + ], + test.context, + ); + + expect(code).toBe(2); + expect(jsonLine(test.stderr)).toMatchObject({ + error: { + code: 'invalid_cli_input', + message: expect.stringContaining('requires attachment URLs'), + }, + }); + }); + + it('publishes runtime destination and content requirements in the send schema', async () => { + const config = await temporaryConfig(); + const test = context(config.path); + + expect(await runCli(['schema', 'send', '--json'], test.context)).toBe(0); + expect(jsonLine(test.stdout)).toMatchObject({ + data: { allOf: [{ oneOf: expect.any(Array) }, { anyOf: expect.any(Array) }] }, + }); + }); + + it('replaces a default connection across providers without leaving an invalid default', async () => { + const config = await temporaryConfig(); + const credentials = new MemoryCredentialStore('test-imessage-cli'); + const add = context(config.path, { credentials }); + expect( + await runCli( + [ + 'connection', + 'add', + 'support', + '--provider', + 'blooio', + '--api-key', + 'old-key', + '--no-input', + ], + add.context, + ), + ).toBe(0); + + const replace = context(config.path, { credentials }); + expect( + await runCli( + [ + 'connection', + 'add', + 'support', + '--provider', + 'photon', + '--project-id', + 'project', + '--project-secret', + 'secret', + '--force', + '--no-input', + ], + replace.context, + ), + ).toBe(0); + + await expect(new ConfigStore(config.path).load()).resolves.toMatchObject({ + connections: { support: { provider: 'photon' } }, + defaultConnections: { photon: 'support' }, + }); + expect((await new ConfigStore(config.path).load()).defaultConnections.blooio).toBeUndefined(); + await expect( + credentials.get({ connection: 'support', provider: 'blooio', name: 'apiKey' }), + ).resolves.toBeNull(); + await expect( + credentials.get({ connection: 'support', provider: 'photon', name: 'projectSecret' }), + ).resolves.toBe('secret'); + }); + + it('rolls back config and credentials when cross-provider replacement fails', async () => { + const config = await temporaryConfig(); + const memory = new MemoryCredentialStore('test-imessage-cli'); + const credentials = new OneShotFailingCredentialStore(memory); + const add = context(config.path, { credentials }); + expect( + await runCli( + [ + 'connection', + 'add', + 'support', + '--provider', + 'blooio', + '--api-key', + 'old-key', + '--no-input', + ], + add.context, + ), + ).toBe(0); + + credentials.failNextDelete = true; + const replace = context(config.path, { credentials }); + expect( + await runCli( + [ + 'connection', + 'add', + 'support', + '--provider', + 'photon', + '--project-id', + 'project', + '--project-secret', + 'secret', + '--force', + '--no-input', + '--json', + ], + replace.context, + ), + ).toBe(2); + + await expect(new ConfigStore(config.path).load()).resolves.toMatchObject({ + connections: { support: { provider: 'blooio' } }, + defaultConnections: { blooio: 'support' }, + }); + await expect( + memory.get({ connection: 'support', provider: 'blooio', name: 'apiKey' }), + ).resolves.toBe('old-key'); + await expect( + memory.get({ connection: 'support', provider: 'photon', name: 'projectSecret' }), + ).resolves.toBeNull(); + }); + + it('keeps a connection removable after credential cleanup fails', async () => { + const config = await temporaryConfig(); + const memory = new MemoryCredentialStore('test-imessage-cli'); + const credentials = new OneShotFailingCredentialStore(memory); + const add = context(config.path, { credentials }); + expect( + await runCli( + [ + 'connection', + 'add', + 'support', + '--provider', + 'blooio', + '--api-key', + 'old-key', + '--no-input', + ], + add.context, + ), + ).toBe(0); + + credentials.failNextDelete = true; + const remove = context(config.path, { credentials }); + expect( + await runCli(['connection', 'remove', 'support', '--yes', '--json'], remove.context), + ).toBe(2); + + await expect(new ConfigStore(config.path).load()).resolves.toMatchObject({ + connections: { support: { provider: 'blooio' } }, + }); + await expect( + memory.get({ connection: 'support', provider: 'blooio', name: 'apiKey' }), + ).resolves.toBe('old-key'); + }); + + it('returns structured errors without prompting in JSON mode', async () => { + const config = await temporaryConfig(); + const test = context(config.path); + + const code = await runCli( + ['send', '--provider', 'photon', '--to', '+15555550101', '--text', 'Hello', '--json'], + test.context, + ); + + expect(code).toBe(2); + expect(test.stdout.text()).toBe(''); + expect(jsonLine(test.stderr)).toMatchObject({ + ok: false, + command: 'send', + error: { code: 'invalid_config', retryable: false, safeToRetry: false }, + }); + }); + + it('returns command-syntax failures as structured JSON on stderr', async () => { + const config = await temporaryConfig(); + const test = context(config.path); + + const code = await runCli(['message', 'get', '--json'], test.context); + + expect(code).toBe(2); + expect(test.stdout.text()).toBe(''); + expect(jsonLine(test.stderr)).toMatchObject({ + schemaVersion: 1, + ok: false, + command: 'cli', + error: { code: 'invalid_cli_input' }, + }); + }); +}); diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts new file mode 100644 index 0000000..d041a8b --- /dev/null +++ b/packages/cli/test/config.test.ts @@ -0,0 +1,146 @@ +import { access, chmod, readdir, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + CliConfigSchema, + ConfigError, + ConfigStore, + createEmptyConfig, + getDefaultConfigPath, +} from '../src/config.js'; + +const temporaryDirectories: string[] = []; + +async function createTemporaryDirectory(): Promise { + const { mkdtemp } = await import('node:fs/promises'); + const directory = await mkdtemp(join(tmpdir(), 'imessage-cli-config-')); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + const { rm } = await import('node:fs/promises'); + await Promise.all( + temporaryDirectories.splice(0).map(async (directory) => { + await rm(directory, { recursive: true, force: true }); + }), + ); +}); + +describe('CLI configuration', () => { + it('resolves XDG, Windows, and home-directory configuration paths', () => { + expect( + getDefaultConfigPath({ + env: { XDG_CONFIG_HOME: '/tmp/xdg' }, + homeDirectory: '/home/user', + platform: 'linux', + }), + ).toBe(join('/tmp/xdg', 'imessage-cli', 'config.json')); + + expect( + getDefaultConfigPath({ + env: { APPDATA: 'C:\\Users\\user\\AppData\\Roaming' }, + homeDirectory: 'C:\\Users\\user', + platform: 'win32', + }), + ).toBe(join('C:\\Users\\user\\AppData\\Roaming', 'imessage-cli', 'config.json')); + + expect(getDefaultConfigPath({ env: {}, homeDirectory: '/home/user', platform: 'linux' })).toBe( + join('/home/user', '.config', 'imessage-cli', 'config.json'), + ); + }); + + it('returns an empty v1 configuration when the file does not exist', async () => { + const directory = await createTemporaryDirectory(); + const store = new ConfigStore(join(directory, 'missing', 'config.json')); + + await expect(store.load()).resolves.toEqual(createEmptyConfig()); + }); + + it('atomically saves and loads named connections', async () => { + const directory = await createTemporaryDirectory(); + const path = join(directory, 'nested', 'config.json'); + const store = new ConfigStore(path); + const config = { + version: 1, + connections: { + personal: { + provider: 'blooio', + settings: { fromNumber: '+15551234567', markReadEnabled: true, timeoutMs: 2_000 }, + }, + }, + defaultConnections: { blooio: 'personal' }, + } as const; + + await store.save(config); + + await expect(store.load()).resolves.toEqual(config); + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual(config); + expect( + (await readdir(join(directory, 'nested'))).filter((name) => name.endsWith('.tmp')), + ).toEqual([]); + + if (process.platform !== 'win32') { + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect((await stat(join(directory, 'nested'))).mode & 0o777).toBe(0o700); + } + }); + + it('does not change permissions on an existing parent directory', async () => { + const directory = await createTemporaryDirectory(); + if (process.platform === 'win32') return; + await chmod(directory, 0o750); + + await new ConfigStore(join(directory, 'config.json')).save(createEmptyConfig()); + + expect((await stat(directory)).mode & 0o777).toBe(0o750); + }); + + it('creates a configuration exclusively without replacing an existing file', async () => { + const directory = await createTemporaryDirectory(); + const path = join(directory, 'config.json'); + const store = new ConfigStore(path); + const original = { + version: 1, + connections: { main: { provider: 'photon' } }, + defaultConnections: { photon: 'main' }, + } as const; + await store.create(original); + + await expect(store.create(createEmptyConfig())).rejects.toBeInstanceOf(ConfigError); + await expect(store.load()).resolves.toEqual(original); + }); + + it('rejects unknown providers and inconsistent defaults', () => { + expect( + CliConfigSchema.safeParse({ + version: 1, + connections: { main: { provider: 'unknown' } }, + defaultConnections: {}, + }).success, + ).toBe(false); + + expect( + CliConfigSchema.safeParse({ + version: 1, + connections: { main: { provider: 'photon' } }, + defaultConnections: { blooio: 'main' }, + }).success, + ).toBe(false); + }); + + it('rejects malformed JSON without replacing it', async () => { + const directory = await createTemporaryDirectory(); + const path = join(directory, 'config.json'); + const { writeFile } = await import('node:fs/promises'); + await writeFile(path, '{not json', { mode: 0o600 }); + const store = new ConfigStore(path); + + await expect(store.load()).rejects.toBeInstanceOf(ConfigError); + await expect(access(path)).resolves.toBeUndefined(); + await expect(readFile(path, 'utf8')).resolves.toBe('{not json'); + }); +}); diff --git a/packages/cli/test/credentials.test.ts b/packages/cli/test/credentials.test.ts new file mode 100644 index 0000000..950b263 --- /dev/null +++ b/packages/cli/test/credentials.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + CredentialStoreError, + getCredentialAccount, + MemoryCredentialStore, + SystemCredentialStore, +} from '../src/credentials.js'; + +const keyring = vi.hoisted(() => ({ + entries: new Map(), + operations: [] as { readonly account: string; readonly service: string }[], + error: undefined as Error | undefined, +})); + +vi.mock('@napi-rs/keyring', () => ({ + AsyncEntry: class { + readonly #key: string; + + constructor(service: string, account: string) { + keyring.operations.push({ service, account }); + this.#key = `${service}:${account}`; + } + + async getPassword(): Promise { + if (keyring.error !== undefined) throw keyring.error; + return keyring.entries.get(this.#key); + } + + async setPassword(secret: string): Promise { + if (keyring.error !== undefined) throw keyring.error; + keyring.entries.set(this.#key, secret); + } + + async deleteCredential(): Promise { + if (keyring.error !== undefined) throw keyring.error; + return keyring.entries.delete(this.#key); + } + }, +})); + +const ref = { + connection: 'personal', + provider: 'blooio', + name: 'api-key', +} as const; + +afterEach(() => { + keyring.entries.clear(); + keyring.operations.length = 0; + keyring.error = undefined; +}); + +describe('credential stores', () => { + it('keeps one in-memory credential per connection, provider, and name', async () => { + const store = new MemoryCredentialStore(); + const second = { ...ref, connection: 'work' }; + + await store.set(ref, 'personal-secret'); + await store.set(second, 'work-secret'); + + await expect(store.get(ref)).resolves.toBe('personal-secret'); + await expect(store.get(second)).resolves.toBe('work-secret'); + await expect(store.delete(ref)).resolves.toBe(true); + await expect(store.get(ref)).resolves.toBeNull(); + await expect(store.get(second)).resolves.toBe('work-secret'); + }); + + it('maps each system credential to one stable service/account pair', async () => { + const store = new SystemCredentialStore(); + + await store.set(ref, 'secret'); + await expect(store.get(ref)).resolves.toBe('secret'); + await expect(store.delete(ref)).resolves.toBe(true); + + expect(getCredentialAccount(ref)).toBe('v1:personal:blooio:api-key'); + expect(keyring.operations).toEqual([ + { service: 'imessage-cli', account: 'v1:personal:blooio:api-key' }, + { service: 'imessage-cli', account: 'v1:personal:blooio:api-key' }, + { service: 'imessage-cli', account: 'v1:personal:blooio:api-key' }, + ]); + }); + + it('wraps native keyring failures without falling back to plaintext storage', async () => { + const store = new SystemCredentialStore(); + const failure = new Error('keyring is locked'); + keyring.error = failure; + + const promise = store.set(ref, 'secret'); + await expect(promise).rejects.toBeInstanceOf(CredentialStoreError); + await expect(promise).rejects.toMatchObject({ cause: failure }); + expect(keyring.entries).toEqual(new Map()); + }); + + it('rejects empty secrets and unsafe account components', async () => { + const store = new MemoryCredentialStore(); + + await expect(store.set(ref, '')).rejects.toBeInstanceOf(CredentialStoreError); + expect(() => getCredentialAccount({ ...ref, connection: '../personal' })).toThrow( + CredentialStoreError, + ); + }); +}); diff --git a/packages/cli/test/input.test.ts b/packages/cli/test/input.test.ts new file mode 100644 index 0000000..48aeb54 --- /dev/null +++ b/packages/cli/test/input.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { CliUsageError } from '../src/errors.js'; +import { parseAddress, SendCliInputSchema } from '../src/input.js'; + +describe('CLI input validation', () => { + it('accepts only HTTP(S) attachment URLs', () => { + expect( + SendCliInputSchema.safeParse({ + to: '+15555550100', + attachments: [{ kind: 'file', source: { type: 'url', url: 'ftp://example.com/file' } }], + }).success, + ).toBe(false); + }); + + it('does not treat whitespace-only text as message content', () => { + expect(SendCliInputSchema.safeParse({ to: '+15555550100', text: ' ' }).success).toBe(false); + }); + + it('rejects empty explicit address prefixes', () => { + expect(() => parseAddress('phone:')).toThrow(CliUsageError); + expect(() => parseAddress('email: ')).toThrow(CliUsageError); + }); +}); diff --git a/packages/cli/test/providers.test.ts b/packages/cli/test/providers.test.ts new file mode 100644 index 0000000..675598e --- /dev/null +++ b/packages/cli/test/providers.test.ts @@ -0,0 +1,183 @@ +import { readdir } from 'node:fs/promises'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ProviderFieldDefinition } from '../src/providers.js'; +import { BUILT_IN_PROVIDER_NAMES, createProvider, providerRegistry } from '../src/providers.js'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('built-in provider registry', () => { + it('contains every provider package in the workspace', async () => { + const entries = await readdir(new URL('../../providers/', import.meta.url), { + withFileTypes: true, + }); + const packageDirectories = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + expect([...BUILT_IN_PROVIDER_NAMES].sort()).toEqual(packageDirectories); + expect(Object.keys(providerRegistry).sort()).toEqual(packageDirectories); + }); + + it('defines unique fields, environment variables, and required purposes', () => { + for (const name of BUILT_IN_PROVIDER_NAMES) { + const definition = providerRegistry[name]; + expect(definition.name).toBe(name); + expect(definition.packageName).toBe(`@imessage-sdk/${name}`); + expect(definition.capabilities).toBeDefined(); + const fields: readonly ProviderFieldDefinition[] = definition.fields; + expect(new Set(fields.map((field) => field.key)).size).toBe(fields.length); + + const environmentVariables = fields.flatMap((field) => + field.env === undefined ? [] : [field.env], + ); + expect(new Set(environmentVariables).size).toBe(environmentVariables.length); + + for (const field of fields) { + expect(['secret', 'identity', 'setting']).toContain(field.kind); + expect( + field.requiredFor.every((purpose) => ['api', 'webhook', 'doctor'].includes(purpose)), + ).toBe(true); + } + } + }); + + it('marks the exact webhook requirements for each provider', () => { + const requiredWebhookFields = (name: (typeof BUILT_IN_PROVIDER_NAMES)[number]): string[] => { + const fields: readonly ProviderFieldDefinition[] = providerRegistry[name].fields; + return fields + .filter((field) => field.requiredFor.includes('webhook')) + .map((field) => field.key); + }; + + expect(requiredWebhookFields('blooio')).toEqual(['webhookSecret']); + expect(requiredWebhookFields('photon')).toEqual(['webhookSecret']); + expect(requiredWebhookFields('sendblue')).toEqual(['fromNumber', 'webhookSecret']); + }); + + it('creates each bundled provider without initializing a network connection', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const blooio = createProvider('blooio', { + apiKey: 'blooio-key', + sender: '+15550000001', + }); + const photon = createProvider('photon', { + projectId: 'project-id', + projectSecret: 'project-secret', + phone: '+15550000002', + }); + const sendblue = createProvider('sendblue', { + apiKey: 'sendblue-key', + apiSecret: 'sendblue-secret', + fromNumber: '+15550000003', + markReadEnabled: true, + }); + + expect(blooio.name).toBe('blooio'); + expect(photon.name).toBe('photon'); + expect(sendblue.name).toBe('sendblue'); + expect(sendblue.capabilities.conversations.markRead).toBe(true); + expect('markRead' in sendblue.conversations).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + + await photon.close?.(); + }); + + it('rejects settings with the wrong resolved value type', () => { + expect(() => createProvider('photon', { timeout: 'slow' })).toThrowError( + expect.objectContaining({ + name: 'ValidationError', + code: 'invalid_provider_configuration', + }), + ); + expect(() => createProvider('sendblue', { markReadEnabled: 'yes' })).toThrowError( + expect.objectContaining({ + name: 'ValidationError', + code: 'invalid_provider_configuration', + }), + ); + }); + + it('verifies Blooio credentials through a non-mutating number lookup', async () => { + const fetchMock = vi.fn(async () => + Promise.resolve( + new Response( + JSON.stringify({ + numbers: [ + { + phone_number: '+15550000001', + is_active: true, + plan_kind: 'dedicated', + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await providerRegistry.blooio.doctor({ + apiKey: 'blooio-key', + sender: '+15550000001', + baseUrl: 'https://blooio.test/v2/api', + }); + + expect(result).toMatchObject({ + status: 'ok', + details: { activeNumberCount: 1, activeNumbers: ['+15550000001'] }, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://blooio.test/v2/api/me/numbers', + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'Bearer blooio-key' }), + }), + ); + }); + + it('keeps Sendblue doctor non-mutating and reports remote verification limits', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await providerRegistry.sendblue.doctor({ + apiKey: 'sendblue-key', + apiSecret: 'sendblue-secret', + fromNumber: '+15550000003', + }); + + expect(result).toMatchObject({ + status: 'warning', + code: 'remote_credentials_not_verified', + details: { fromNumber: '+15550000003', markReadEnabled: false }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns safe doctor failures without exposing secret values', async () => { + const secret = 'blooio-secret-value'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Promise.resolve( + new Response(JSON.stringify({ message: `Rejected ${secret}` }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }), + ), + ), + ); + + const result = await providerRegistry.blooio.doctor({ apiKey: secret }); + + expect(result).toMatchObject({ status: 'error' }); + expect(result.message).not.toContain(secret); + expect(result.message).toContain('[REDACTED]'); + }); +}); diff --git a/packages/cli/test/run-integration.mjs b/packages/cli/test/run-integration.mjs new file mode 100644 index 0000000..41c35d5 --- /dev/null +++ b/packages/cli/test/run-integration.mjs @@ -0,0 +1,27 @@ +/* global process, URL */ + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const environmentPath = fileURLToPath(new URL('../../../.env.cli-test', import.meta.url)); + +try { + process.loadEnvFile(environmentPath); +} catch (error) { + if (error?.code !== 'ENOENT') throw error; +} + +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const child = spawn(pnpm, ['exec', 'vitest', 'run', 'test/cli.integration.test.ts'], { + cwd: fileURLToPath(new URL('../', import.meta.url)), + env: process.env, + stdio: 'inherit', +}); + +child.once('exit', (code, signal) => { + if (signal !== null) { + process.kill(process.pid, signal); + return; + } + process.exitCode = code ?? 1; +}); diff --git a/packages/cli/test/webhook-server.test.ts b/packages/cli/test/webhook-server.test.ts new file mode 100644 index 0000000..72c7b5b --- /dev/null +++ b/packages/cli/test/webhook-server.test.ts @@ -0,0 +1,255 @@ +import { request as httpRequest } from 'node:http'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ValidationError, WebhookVerificationError } from 'imessage-sdk'; + +import type { WebhookServer, WebhookServerClient } from '../src/webhook-server.js'; +import { startWebhookServer } from '../src/webhook-server.js'; + +interface TestEvent { + readonly id: string; +} + +function createClient( + handle: (request: Request) => Promise, +): WebhookServerClient & { close: ReturnType } { + return { + webhooks: { handle }, + close: vi.fn(async () => {}), + }; +} + +async function rawRequest( + url: string, + options: { + readonly method: string; + readonly headers?: Readonly>; + readonly body?: string; + }, +): Promise<{ readonly status: number; readonly headers: Headers; readonly body: string }> { + const target = new URL(url); + + return await new Promise((resolve, reject) => { + const request = httpRequest( + { + hostname: target.hostname, + port: target.port, + path: `${target.pathname}${target.search}`, + method: options.method, + headers: options.headers, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.once('error', reject); + response.once('end', () => { + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else if (value !== undefined) { + headers.append(name, value); + } + } + resolve({ + status: response.statusCode ?? 0, + headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + }, + ); + + request.once('error', reject); + + if (options.body !== undefined) { + request.write(options.body); + } + + request.end(); + }); +} + +describe('startWebhookServer', () => { + const servers: WebhookServer[] = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (server) => await server.close())); + }); + + it('preserves the request and emits normalized events sequentially', async () => { + const receivedRequests: Request[] = []; + const client = createClient(async (request) => { + receivedRequests.push(request); + return [{ id: 'first' }, { id: 'second' }]; + }); + const emitted: string[] = []; + const server = await startWebhookServer({ + client, + port: 0, + path: '/provider-hook', + onEvent: async (event) => { + emitted.push(`start:${event.id}`); + await Promise.resolve(); + emitted.push(`end:${event.id}`); + }, + }); + servers.push(server); + + const body = '{"message":"hello"}\n'; + const response = await fetch(`${server.address.url}?delivery=one`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-provider-signature': 'signature-value', + }, + body, + }); + + expect(response.status).toBe(204); + expect(await response.text()).toBe(''); + expect(emitted).toEqual(['start:first', 'end:first', 'start:second', 'end:second']); + expect(receivedRequests).toHaveLength(1); + + const providerRequest = receivedRequests[0]; + expect(providerRequest?.method).toBe('POST'); + expect(providerRequest?.headers.get('content-type')).toBe('application/json'); + expect(providerRequest?.headers.get('x-provider-signature')).toBe('signature-value'); + expect(new URL(providerRequest?.url ?? '').search).toBe('?delivery=one'); + expect(await providerRequest?.text()).toBe(body); + + await server.close(); + expect(client.close).not.toHaveBeenCalled(); + }); + + it('accepts a verified webhook that produces no events', async () => { + const client = createClient(async () => []); + const onEvent = vi.fn(); + const server = await startWebhookServer({ client, onEvent, port: 0 }); + servers.push(server); + + const response = await fetch(server.address.url, { method: 'POST' }); + + expect(response.status).toBe(204); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: 'verification failure', + error: new WebhookVerificationError(), + status: 401, + code: 'webhook_verification_failed', + }, + { + name: 'SDK validation failure', + error: new ValidationError('Malformed provider payload.'), + status: 400, + code: 'invalid_webhook_request', + }, + { + name: 'unexpected processing failure', + error: new Error('unexpected'), + status: 500, + code: 'webhook_processing_failed', + }, + ])('maps $name to HTTP $status', async ({ error, status, code }) => { + const client = createClient(async () => { + throw error; + }); + const onError = vi.fn(); + const server = await startWebhookServer({ client, onEvent: vi.fn(), onError, port: 0 }); + servers.push(server); + + const response = await fetch(server.address.url, { method: 'POST' }); + + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ error: code }); + expect(onError).toHaveBeenCalledWith({ statusCode: status, code, error }); + }); + + it('rejects oversized request bodies without calling the client', async () => { + const handle = vi.fn(async () => []); + const client = createClient(handle); + const server = await startWebhookServer({ + client, + onEvent: vi.fn(), + port: 0, + maxBodyBytes: 4, + }); + servers.push(server); + + const response = await fetch(server.address.url, { + method: 'POST', + body: '12345', + }); + + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ error: 'request_body_too_large' }); + expect(handle).not.toHaveBeenCalled(); + }); + + it('serves health checks, rejects other methods, and returns 404 for unknown paths', async () => { + const client = createClient(async () => []); + const server = await startWebhookServer({ client, onEvent: vi.fn(), port: 0 }); + servers.push(server); + + const health = await fetch(server.address.healthUrl ?? ''); + expect(health.status).toBe(204); + + const wrongHealthMethod = await fetch(server.address.healthUrl ?? '', { method: 'POST' }); + expect(wrongHealthMethod.status).toBe(405); + expect(wrongHealthMethod.headers.get('allow')).toBe('GET'); + + const wrongWebhookMethod = await fetch(server.address.url); + expect(wrongWebhookMethod.status).toBe(405); + expect(wrongWebhookMethod.headers.get('allow')).toBe('POST'); + + const missing = await fetch(new URL('/missing', server.address.url)); + expect(missing.status).toBe(404); + }); + + it('can disable the health endpoint', async () => { + const client = createClient(async () => []); + const server = await startWebhookServer({ + client, + onEvent: vi.fn(), + port: 0, + healthPath: false, + }); + servers.push(server); + + expect(server.address.healthUrl).toBeUndefined(); + const response = await fetch(new URL('/healthz', server.address.url)); + expect(response.status).toBe(404); + }); + + it('rejects a malformed Content-Length header', async () => { + const handle = vi.fn(async () => []); + const client = createClient(handle); + const server = await startWebhookServer({ client, onEvent: vi.fn(), port: 0 }); + servers.push(server); + + const response = await rawRequest(server.address.url, { + method: 'POST', + headers: { 'content-length': 'not-a-number' }, + }); + + expect(response.status).toBe(400); + expect(response.status).toBe(400); + if (response.body.length > 0) { + expect(JSON.parse(response.body)).toEqual({ error: 'invalid_webhook_request' }); + } + expect(handle).not.toHaveBeenCalled(); + }); + + it('validates server paths before binding', async () => { + const client = createClient(async () => []); + + await expect( + startWebhookServer({ client, onEvent: vi.fn(), path: 'webhooks' }), + ).rejects.toBeInstanceOf(ValidationError); + }); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..ac0957c --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "types": ["node"] }, + "include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts", "vitest.config.ts"] +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 0000000..b8580ff --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { cli: 'src/cli.ts' }, + format: ['esm'], + target: 'es2022', + platform: 'node', + banner: { js: '#!/usr/bin/env node' }, + clean: true, + sourcemap: true, + splitting: false, + treeshake: true, + external: ['@napi-rs/keyring'], +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..fcb0b3a --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + ssr: { + noExternal: ['clipanion'], + }, +}); diff --git a/packages/providers/README.md b/packages/providers/README.md index 6451ae7..0dcb82e 100644 --- a/packages/providers/README.md +++ b/packages/providers/README.md @@ -3,6 +3,9 @@ Provider adapters are independently installable packages built on the public `imessage-sdk` contract. +All providers in this directory are also bundled with [`imessage-cli`](../cli). A workspace test +fails when a provider package is added without a corresponding CLI registry entry. + | Capability | Blooio | Photon Cloud | Sendblue | | ----------------------------- | ---------------------- | --------------------------------- | ------------------------ | | Package | `@imessage-sdk/blooio` | `@imessage-sdk/photon` | `@imessage-sdk/sendblue` | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91b61bd..af7644e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,9 +90,36 @@ importers: packages/cli: dependencies: + '@hono/node-server': + specifier: ^2.0.10 + version: 2.0.10(hono@4.12.30) + '@imessage-sdk/blooio': + specifier: workspace:^ + version: link:../providers/blooio + '@imessage-sdk/photon': + specifier: workspace:^ + version: link:../providers/photon + '@imessage-sdk/sendblue': + specifier: workspace:^ + version: link:../providers/sendblue + '@inquirer/prompts': + specifier: ^8.5.2 + version: 8.5.2(@types/node@20.19.43) + '@napi-rs/keyring': + specifier: ^1.3.0 + version: 1.3.0 + clipanion: + specifier: 3.2.1 + version: 3.2.1(typanion@3.14.0) + hono: + specifier: ^4.12.30 + version: 4.12.30 imessage-sdk: specifier: workspace:^ version: link:../imessage-sdk + zod: + specifier: ^4.4.3 + version: 4.4.3 packages/imessage-sdk: {} @@ -488,6 +515,12 @@ packages: peerDependencies: hono: ^4 + '@hono/node-server@2.0.10': + resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -526,6 +559,55 @@ packages: prettier-plugin-ember-template-tag: optional: true + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -535,6 +617,91 @@ packages: '@types/node': optional: true + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -560,6 +727,82 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -1264,6 +1507,15 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clipanion@3.2.1: + resolution: {integrity: sha512-dYFdjLb7y1ajfxQopN05mylEpK9ZX0sO1/RfMXdfmwjlIsPkbh4p7A682x++zFPLDCo1x3p82dtljHf5cW2LKA==} + peerDependencies: + typanion: '*' + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -1483,6 +1735,15 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1952,6 +2213,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -2354,6 +2619,9 @@ packages: typescript: optional: true + typanion@3.14.0: + resolution: {integrity: sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2975,6 +3243,10 @@ snapshots: dependencies: hono: 4.12.30 + '@hono/node-server@2.0.10(hono@4.12.30)': + dependencies: + hono: 4.12.30 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3002,6 +3274,51 @@ snapshots: transitivePeerDependencies: - supports-color + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/confirm@6.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/core@11.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/editor@5.2.2(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/external-editor': 3.0.3(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/expand@5.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + '@inquirer/external-editor@1.0.3(@types/node@20.19.43)': dependencies: chardet: 2.2.0 @@ -3009,6 +3326,80 @@ snapshots: optionalDependencies: '@types/node': 20.19.43 + '@inquirer/external-editor@3.0.3(@types/node@20.19.43)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/number@4.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/password@5.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/prompts@8.5.2(@types/node@20.19.43)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@20.19.43) + '@inquirer/confirm': 6.1.1(@types/node@20.19.43) + '@inquirer/editor': 5.2.2(@types/node@20.19.43) + '@inquirer/expand': 5.1.1(@types/node@20.19.43) + '@inquirer/input': 5.1.2(@types/node@20.19.43) + '@inquirer/number': 4.1.1(@types/node@20.19.43) + '@inquirer/password': 5.1.1(@types/node@20.19.43) + '@inquirer/rawlist': 5.3.1(@types/node@20.19.43) + '@inquirer/search': 4.2.1(@types/node@20.19.43) + '@inquirer/select': 5.2.1(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/rawlist@5.3.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/search@4.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/select@5.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/type@4.0.7(@types/node@20.19.43)': + optionalDependencies: + '@types/node': 20.19.43 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3045,6 +3436,57 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -3726,6 +4168,12 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + cli-width@4.1.0: {} + + clipanion@3.2.1(typanion@3.14.0): + dependencies: + typanion: 3.14.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -3967,6 +4415,16 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4556,6 +5014,8 @@ snapshots: ms@2.1.3: {} + mute-stream@3.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -4983,6 +5443,8 @@ snapshots: - tsx - yaml + typanion@3.14.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/scripts/check-packages.sh b/scripts/check-packages.sh index 3360e8f..7d2bf10 100644 --- a/scripts/check-packages.sh +++ b/scripts/check-packages.sh @@ -15,6 +15,7 @@ pnpm --filter @imessage-sdk/blooio pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/photon pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/sendblue pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/chat-adapter pack --pack-destination "${PACKAGE_DIR}" +pnpm --filter imessage-cli pack --pack-destination "${PACKAGE_DIR}" TARBALLS=("${PACKAGE_DIR}"/*.tgz) for package_path in "${TARBALLS[@]}"; do @@ -39,4 +40,5 @@ pnpm exec tsc --project "${CONSUMER_DIR}/tsconfig.json" await import("@imessage-sdk/chat-adapter"); await import("chat"); ' + ./node_modules/.bin/imessage-cli provider list --json >/dev/null )