From 946287e53949a71ab4beff9e1d88db216a8f107c Mon Sep 17 00:00:00 2001 From: Alibek Seksenali Date: Mon, 12 Oct 2026 20:14:38 +0500 Subject: [PATCH 1/9] chore: enforce strict CLI quality and architecture gates --- .dependency-cruiser.cjs | 48 + .github/workflows/quality.yml | 52 + .gitignore | 7 + .husky/pre-commit | 1 + .nvmrc | 1 + .prettierignore | 5 + .prettierrc.json | 5 + AGENTS.md | 11 + README.md | 308 +- eslint.complexity.config.mjs | 11 + eslint.config.mjs | 14 + eslint.typed.config.mjs | 5 + knip.json | 11 + package.json | 24 +- quality.config.mjs | 26 + quality/baselines/complexity.json | 146 + quality/baselines/duplicates.json | 45 + quality/baselines/unused-full.json | 258 + quality/baselines/unused-production.json | 314 + scripts/pack-smoke.mjs | 54 + scripts/quality-negative.mjs | 58 + src/__tests__/commands.test.ts | 5 +- src/__tests__/config-precedence.test.ts | 7 +- src/__tests__/coverage.test.ts | 8 +- src/__tests__/helpers.ts | 59 +- src/__tests__/operations.test.ts | 3 +- src/__tests__/rfc-help.test.ts | 24 +- src/__tests__/sdk-only-architecture.test.ts | 11 +- src/channel-rest.ts | 35 + src/channel-status.ts | 21 + src/command-binding.ts | 62 + src/completion.ts | 10 +- src/config.ts | 35 +- src/context.ts | 36 +- src/dest.ts | 4 +- src/errors.ts | 12 +- src/execute.ts | 30 +- src/flow-program.ts | 75 + src/help-text.ts | 119 +- src/io.ts | 4 +- src/local.ts | 81 +- src/message-sticker.ts | 49 + src/output.ts | 31 +- src/paths.ts | 3 +- src/program.ts | 394 +- src/rest.ts | 68 +- src/run.ts | 6 +- src/send.ts | 106 +- tools/quality/README.md | 85 + tools/quality/eslint.mjs | 137 + tools/quality/lib/baseline.mjs | 82 + tools/quality/lib/git.mjs | 86 + tools/quality/lib/guards.mjs | 356 + tools/quality/lib/historical.mjs | 139 + tools/quality/lib/knip-reporter.mjs | 42 + tools/quality/lib/reports.mjs | 265 + tools/quality/lib/template.mjs | 56 + tools/quality/package-lock.json | 6852 +++++++++++++++++++ tools/quality/package.json | 34 + tools/quality/prettier.config.mjs | 2 + tools/quality/run.mjs | 360 + tools/quality/seal.mjs | 19 + tools/quality/template-manifest.json | 25 + tools/quality/test/cli.test.mjs | 510 ++ tools/quality/test/ratchet.test.mjs | 238 + tools/quality/tsconfig.next.json | 16 + tools/quality/tsconfig.node.json | 13 + tools/quality/tsconfig.svelte.json | 14 + tsconfig.json | 9 +- tsconfig.quality.json | 19 + 70 files changed, 11399 insertions(+), 662 deletions(-) create mode 100644 .dependency-cruiser.cjs create mode 100644 .github/workflows/quality.yml create mode 100644 .husky/pre-commit create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 AGENTS.md create mode 100644 eslint.complexity.config.mjs create mode 100644 eslint.config.mjs create mode 100644 eslint.typed.config.mjs create mode 100644 knip.json create mode 100644 quality.config.mjs create mode 100644 quality/baselines/complexity.json create mode 100644 quality/baselines/duplicates.json create mode 100644 quality/baselines/unused-full.json create mode 100644 quality/baselines/unused-production.json create mode 100644 scripts/pack-smoke.mjs create mode 100644 scripts/quality-negative.mjs create mode 100644 src/channel-rest.ts create mode 100644 src/channel-status.ts create mode 100644 src/command-binding.ts create mode 100644 src/flow-program.ts create mode 100644 src/message-sticker.ts create mode 100644 tools/quality/README.md create mode 100644 tools/quality/eslint.mjs create mode 100644 tools/quality/lib/baseline.mjs create mode 100644 tools/quality/lib/git.mjs create mode 100644 tools/quality/lib/guards.mjs create mode 100644 tools/quality/lib/historical.mjs create mode 100644 tools/quality/lib/knip-reporter.mjs create mode 100644 tools/quality/lib/reports.mjs create mode 100644 tools/quality/lib/template.mjs create mode 100644 tools/quality/package-lock.json create mode 100644 tools/quality/package.json create mode 100644 tools/quality/prettier.config.mjs create mode 100644 tools/quality/run.mjs create mode 100644 tools/quality/seal.mjs create mode 100644 tools/quality/template-manifest.json create mode 100644 tools/quality/test/cli.test.mjs create mode 100644 tools/quality/test/ratchet.test.mjs create mode 100644 tools/quality/tsconfig.next.json create mode 100644 tools/quality/tsconfig.node.json create mode 100644 tools/quality/tsconfig.svelte.json create mode 100644 tsconfig.quality.json diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 0000000..1c8ef7a --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,48 @@ +// Owners and constraints for this repository's actual runtime roots. +module.exports = { + forbidden: [ + { + name: 'no-runtime-cycles', + severity: 'error', + from: {}, + to: { + circular: true, + dependencyTypesNot: ['type-only'], + }, + }, + { + name: 'no-unresolved-dependencies', + severity: 'error', + from: {}, + to: { + couldNotResolve: true, + }, + }, + { + name: 'no-runtime-test-dependencies', + severity: 'error', + from: { + path: '^(?:src)/', + pathNot: '(?:__tests__|test|tests)/|\\.(?:test|spec)\\.', + }, + to: { + path: '(?:^|/)(?:__tests__|tests|test)/|(?:^|node_modules/)(?:jest|vitest|ts-jest)', + }, + }, + ], + options: { + doNotFollow: { + path: 'node_modules', + }, + tsConfig: { + fileName: 'tsconfig.quality.json', + }, + tsPreCompilationDeps: true, + enhancedResolveOptions: { + exportsFields: ['exports'], + conditionNames: ['types', 'require', 'node', 'default'], + extensions: ['.js', '.ts', '.json'], + }, + exclude: '(?:^|/)node_modules/', + }, +}; diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..e2c9527 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,52 @@ +name: Quality +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +concurrency: + group: quality-${{ github.ref }} + cancel-in-progress: true +jobs: + checks: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '24.19.0' + - run: npm ci + - run: npm --prefix tools/quality ci + - run: npm run test:preset + - run: npm run quality:guards + - run: npm run format:check + - run: npm run lint:static + - run: npm run lint:typed + - run: npm run typecheck + - run: npm run lint:arch + - run: npm run lint:unused:production + - run: npm run lint:unused:full + - run: npm run lint:duplicates + - run: npm run lint:duplicates:full + - run: npm run lint:complexity + - run: npm run test:quality + - run: npm test + - run: npm run build + - run: npm run test:pack + - uses: actions/upload-artifact@v4 + if: always() + with: + name: quality-reports + path: quality/reports/ + if-no-files-found: error + quality-required: + if: always() + needs: [checks] + runs-on: ubuntu-24.04 + steps: + - env: + RESULTS: ${{ toJSON(needs) }} + run: node -e 'const jobs=JSON.parse(process.env.RESULTS); if(!Object.values(jobs).every(job=>job.result==="success")) process.exit(1)' diff --git a/.gitignore b/.gitignore index 0fd6563..9d32e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,10 @@ coverage/ .env.* !.env.example npm-debug.log* + +quality/reports/ + +.husky/_/ + +# Local shared-tool dependency links are never repository content. +tools/quality/node_modules diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..81e401a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +node tools/quality/node_modules/lint-staged/bin/lint-staged.js diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..60ade1a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.19.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b292e4d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +tools/quality +quality/reports + diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..2fec1f2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1f484ca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Quality and ownership + +Profile A, Node 24.19.0. SDK/tooling maintainers own this repository. The public package entrypoints are defined in package.json; framework registration and executable consumers must remain intact. Imports must be acyclic at runtime, production cannot import tests, and dependency boundaries are enforced in `.dependency-cruiser.cjs`. `quality.config.mjs` is the source inventory and exact generated-path declaration. + +Configuration belongs in the existing config/bootstrap adapters; pure conversion/validation helpers must not gain transport or environment reads. SDK and HTTP calls belong in the existing transport/client adapters. Keep complete scenarios readable at their application entrypoints; avoid pass-through facade layers. Comments on new/changed functions describe purpose, inputs/results, invariants and relevant failure/resource ownership. Check comments against behavior rather than repeating the function name. + +Install with the frozen lockfile, then `npm --prefix tools/quality ci`. Run `npm run hooks:install` for local Husky/lint-staged feedback. Run all commands listed in `.github/workflows/quality.yml` before review: formatter, static and typed ESLint, strict production/test typecheck, dependency-cruiser, both Knip graphs, jscpd runtime gate and full report, complexity, guard negative fixtures, tests, build and packed-entrypoint smoke. No CI skip variable disables typed lint. + +Historical debt is recorded per finding in `quality/baselines`, with adoption SHA, evidence, owner, issue and expiry. Initial allowances are replayed against the default branch; new findings and increases are forbidden. Remove entries when fixed. Generated paths have producer provenance; authored tooling is always checked. + +The `quality-required` job fails on failed/skipped/cancelled prerequisites. Configure branch protection to require it; YAML alone does not change server protection. Tests use isolated/local inputs; never point them at production services. diff --git a/README.md b/README.md index 5f0c616..51678f1 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,20 @@ HTTP never goes through `fetch` / `axios`. Every API call uses [`@1msg/sdk`](htt [![node](https://img.shields.io/node/v/@1msg/cli.svg)](https://nodejs.org) [![license](https://img.shields.io/npm/l/@1msg/cli.svg)](./LICENSE) -| | | -|--|--| -| **npm** | [`@1msg/cli`](https://www.npmjs.com/package/@1msg/cli) | -| **Binary** | `1msg` (`npx @1msg/cli` is the same program) | -| **SDK** | [`@1msg/sdk`](https://www.npmjs.com/package/@1msg/sdk) | -| **Requires** | Node.js ≥ 18 | -| **Language** | English (`--help`, tables, errors) | -| **Docs** | [https://docs.1msg.io/](https://docs.1msg.io/) | +| | | +| ------------ | ------------------------------------------------------ | +| **npm** | [`@1msg/cli`](https://www.npmjs.com/package/@1msg/cli) | +| **Binary** | `1msg` (`npx @1msg/cli` is the same program) | +| **SDK** | [`@1msg/sdk`](https://www.npmjs.com/package/@1msg/sdk) | +| **Requires** | Node.js ≥ 18 | +| **Language** | English (`--help`, tables, errors) | +| **Docs** | [https://docs.1msg.io/](https://docs.1msg.io/) | API hosts: -| Environment | `ONE_MSG_BASE_URL` | -|-------------|--------------------| -| Live channels | `https://api.1msg.io` | +| Environment | `ONE_MSG_BASE_URL` | +| ------------- | ------------------------- | +| Live channels | `https://api.1msg.io` | | Test channels | `https://sandbox.1msg.io` | Bare hostnames (`api.1msg.io`, `sandbox.1msg.io`) are accepted and normalized to `https://`. Stage / local (`api-stage.1msg.io`, `http://127.0.0.1:3050`) work for `init` / `channel add`. @@ -97,10 +97,10 @@ Session messages (`1msg send`, most `1msg message *`) need an open 24-hour windo Config file mode `0600`: -| OS | Path | -|----|------| +| OS | Path | +| ------------- | ------------------------------------------------------------------ | | Linux / macOS | `~/.config/1msg/config.yaml` (`$XDG_CONFIG_HOME/1msg/config.yaml`) | -| Windows | `%AppData%\1msg\config.yaml` | +| Windows | `%AppData%\1msg\config.yaml` | ```yaml # ~/.config/1msg/config.yaml @@ -114,7 +114,7 @@ channels: acme-test: base_url: https://sandbox.1msg.io instance_id: ODI371267301 - token: "…" # optional; file mode 0600 + token: '…' # optional; file mode 0600 ``` There is **no API** to list a customer's channels. `channel list` / `use` / `add` / `remove` / `current` are always local. @@ -123,12 +123,12 @@ Tokens are never printed in tables, logs, or `--json`. ### Environment -| Variable | Description | -|----------|-------------| -| `ONE_MSG_CHANNEL` | Same as `--channel` (config **name**, not instance id) | -| `ONE_MSG_BASE_URL` | `https://api.1msg.io` or `https://sandbox.1msg.io` | -| `ONE_MSG_INSTANCE_ID` | Channel instance id | -| `ONE_MSG_TOKEN` | Channel API token | +| Variable | Description | +| --------------------- | ------------------------------------------------------ | +| `ONE_MSG_CHANNEL` | Same as `--channel` (config **name**, not instance id) | +| `ONE_MSG_BASE_URL` | `https://api.1msg.io` or `https://sandbox.1msg.io` | +| `ONE_MSG_INSTANCE_ID` | Channel instance id | +| `ONE_MSG_TOKEN` | Channel API token | Deprecated aliases match MCP: `CHAT_API_BASE_URL`, `CHAT_API_TOKEN`, `CHAT_API_INSTANCE_ID`, `CHAT_API_KEY`, `TOKEN`, `INSTANCE_ID`. Prefer `ONE_MSG_*`. @@ -177,16 +177,16 @@ export ONE_MSG_TOKEN=*** Available on every command: -| Flag | Description | -|------|-------------| -| `-c, --channel ` | Named channel from the config file | -| `--instance-id ` | Override instance id | -| `--base-url ` | Override API root | -| `--token ` | Override token (prefer `ONE_MSG_TOKEN`) | -| `--json` | Print the API response body as JSON | -| `--no-color` | Disable ANSI color | -| `-h, --help` | Help for this command | -| `-v, --version` | Version (root only) | +| Flag | Description | +| ---------------------- | --------------------------------------- | +| `-c, --channel ` | Named channel from the config file | +| `--instance-id ` | Override instance id | +| `--base-url ` | Override API root | +| `--token ` | Override token (prefer `ONE_MSG_TOKEN`) | +| `--json` | Print the API response body as JSON | +| `--no-color` | Disable ANSI color | +| `-h, --help` | Help for this command | +| `-v, --version` | Version (root only) | `--json` is the **raw API body**. There is no `--raw`. The one CLI-built object is `channel info --json`: `{ "status": , "me": }`. @@ -194,11 +194,11 @@ Available on every command: Used by send-like commands unless a command says otherwise. Exactly one of `--to` or `--chat-id`. Exception: `message payment` is `--to` only (public schema has no `chatId`). -| Flag | Description | -|------|-------------| -| `--to ` | Phone with country code, no `+` | -| `--chat-id ` | `phone@c.us` · `group@g.us` · `BSUID@lid` | -| `--quote ` | Reply to this message (`wamid.*`) | +| Flag | Description | +| ----------------- | ----------------------------------------- | +| `--to ` | Phone with country code, no `+` | +| `--chat-id ` | `phone@c.us` · `group@g.us` · `BSUID@lid` | +| `--quote ` | Reply to this message (`wamid.*`) | Complex bodies (`template add`, interactive messages, calling SDP) take flags for scalars and `--from-file` for JSON (`-` = stdin). @@ -222,18 +222,18 @@ printf '%s' "$ONE_MSG_TOKEN" | 1msg init --name prod \ Local named channels, plus remote status/settings of the selected channel. -| Subcommand | Where | What | -|------------|-------|------| -| `list` | local | Channels in the file (`*` = default). Columns: `default`, `name`, `instance_id`, `base_url`. Never `token`. | -| `use ` | local | Set default channel | -| `add` | local | Add a channel (first channel becomes default unless `--default`) | -| `remove ` | local | Remove by **config name** only | -| `current` | local | Default name and instance id | -| `info` | API | `GET /status` then `GET /me` | -| `status` | API | `GET /status` (alias: `1msg status`) | -| `settings` / `settings set` | API | `GET/POST /settings` (only flags you pass are sent) | -| `mm-lite` | API | `GET /mmLiteStatus` | -| `automation` / `automation set` | API | Conversational automation | +| Subcommand | Where | What | +| ------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------- | +| `list` | local | Channels in the file (`*` = default). Columns: `default`, `name`, `instance_id`, `base_url`. Never `token`. | +| `use ` | local | Set default channel | +| `add` | local | Add a channel (first channel becomes default unless `--default`) | +| `remove ` | local | Remove by **config name** only | +| `current` | local | Default name and instance id | +| `info` | API | `GET /status` then `GET /me` | +| `status` | API | `GET /status` (alias: `1msg status`) | +| `settings` / `settings set` | API | `GET/POST /settings` (only flags you pass are sent) | +| `mm-lite` | API | `GET /mmLiteStatus` | +| `automation` / `automation set` | API | Conversational automation | ```bash 1msg channel list @@ -283,25 +283,25 @@ to 12020721369@c.us ### `message` -| Command | API | Notes | -|---------|-----|-------| -| `send` | `POST /sendMessage` or `/sendFile` | Same as `1msg send` | -| `list` | `GET /messages` | Inbox history. Columns: `time`, `chat_id`, `from`, `type`, `id`, `body` (body truncated to 60). If the API returns `notice` and no rows, print the notice and exit 0. | -| `read` | `POST /readMessage` | `--message-id` only; no chat id. `--typing` = typing indicator (max 25s) | -| `react` | `POST /sendReaction` | `--emoji` → API `body`; `--message-id` → `quotedMsgId`. Empty emoji removes the reaction | -| `location` | `POST /sendLocation` | `--lat` / `--lng` | -| `location-request` | `POST /sendLocationRequest` | Optional `--text` | -| `contact` | `POST /sendContact` | Flags or `--from-file` | -| `buttons` | `POST /sendButton` | `--button id:title` repeatable, max 3. Interactive header only via `--from-file` | -| `menu` | `POST /sendList` | WhatsApp list. `--from-file` required (`sections[]`) | -| `carousel` | `POST /sendCarousel` | `--from-file` with `cards[]` and/or `params[]`. Carousel **outside** 24h is `template send --params`, not this command | -| `product` | `POST /sendProduct` | `--catalog-id` + `--product-id`, or `--from-file` for lists | -| `cta` | `POST /sendCtaUrl` | `--text` / `--button` / `--url` | -| `address` | `POST /sendAddressMessage` | India and Singapore only (`--country IN\|SG`) | -| `order` | `POST /sendOrderDetails` | India payments; `--from-file` for order body | -| `payment` | `POST /sendPaymentRequest` | `--to` + `--region IN\|SG\|BR` only (no `--chat-id`) | -| `sticker` | `POST /sendSticker` | Exactly one of `--link`, `--media-id`, `--file` | -| `flow` | `POST /sendFlow` | Session window. Outside 24h send a template with a FLOW button | +| Command | API | Notes | +| ------------------ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `send` | `POST /sendMessage` or `/sendFile` | Same as `1msg send` | +| `list` | `GET /messages` | Inbox history. Columns: `time`, `chat_id`, `from`, `type`, `id`, `body` (body truncated to 60). If the API returns `notice` and no rows, print the notice and exit 0. | +| `read` | `POST /readMessage` | `--message-id` only; no chat id. `--typing` = typing indicator (max 25s) | +| `react` | `POST /sendReaction` | `--emoji` → API `body`; `--message-id` → `quotedMsgId`. Empty emoji removes the reaction | +| `location` | `POST /sendLocation` | `--lat` / `--lng` | +| `location-request` | `POST /sendLocationRequest` | Optional `--text` | +| `contact` | `POST /sendContact` | Flags or `--from-file` | +| `buttons` | `POST /sendButton` | `--button id:title` repeatable, max 3. Interactive header only via `--from-file` | +| `menu` | `POST /sendList` | WhatsApp list. `--from-file` required (`sections[]`) | +| `carousel` | `POST /sendCarousel` | `--from-file` with `cards[]` and/or `params[]`. Carousel **outside** 24h is `template send --params`, not this command | +| `product` | `POST /sendProduct` | `--catalog-id` + `--product-id`, or `--from-file` for lists | +| `cta` | `POST /sendCtaUrl` | `--text` / `--button` / `--url` | +| `address` | `POST /sendAddressMessage` | India and Singapore only (`--country IN\|SG`) | +| `order` | `POST /sendOrderDetails` | India payments; `--from-file` for order body | +| `payment` | `POST /sendPaymentRequest` | `--to` + `--region IN\|SG\|BR` only (no `--chat-id`) | +| `sticker` | `POST /sendSticker` | Exactly one of `--link`, `--media-id`, `--file` | +| `flow` | `POST /sendFlow` | Session window. Outside 24h send a template with a FLOW button | ```bash 1msg message list --chat-id 12020721369@c.us --limit 20 --last @@ -486,14 +486,14 @@ Error: channel "missing" not in config See: 1msg channel list ``` -| Exit | Meaning | -|------|---------| -| `0` | OK | -| `1` | Usage | -| `2` | API 4xx | -| `3` | API 5xx | -| `4` | Network | -| `5` | Not configured (`1msg init`) | +| Exit | Meaning | +| ---- | ---------------------------- | +| `0` | OK | +| `1` | Usage | +| `2` | API 4xx | +| `3` | API 5xx | +| `4` | Network | +| `5` | Not configured (`1msg init`) | --- @@ -509,19 +509,19 @@ Print a script to stdout. The CLI never writes into `/etc` or `$fpath` by itself What is completed: -| Kind | Source | -|------|--------| -| Commands, subcommands, flags | Always, from the command tree | -| Channel names (`-c` / `--channel` / `channel use` / `remove`) | Keys of `channels:` in the config file (local, no API) | -| Template names (`template send --name`, `template remove --name`) | Cache written after a successful `1msg template list` | -| Flow ids / group ids | Not cached; type them | +| Kind | Source | +| ----------------------------------------------------------------- | ------------------------------------------------------ | +| Commands, subcommands, flags | Always, from the command tree | +| Channel names (`-c` / `--channel` / `channel use` / `remove`) | Keys of `channels:` in the config file (local, no API) | +| Template names (`template send --name`, `template remove --name`) | Cache written after a successful `1msg template list` | +| Flow ids / group ids | Not cached; type them | Template cache: -| OS | Path | -|----|------| -| Linux / macOS | `~/.cache/1msg/completion.json` | -| Windows | `%LOCALAPPDATA%\1msg\completion.json` | +| OS | Path | +| ------------- | ------------------------------------- | +| Linux / macOS | `~/.cache/1msg/completion.json` | +| Windows | `%LOCALAPPDATA%\1msg\completion.json` | Shape: `{ "updated_at": "", "channel": "", "templates": ["hello_world", …] }`. Cache is per default channel name; after `channel use`, run `template list` again or names may be stale. Completing `--name` with no cache: no values (commands/flags still complete). @@ -568,70 +568,70 @@ Once in `$PROFILE`: `. (Join-Path (Split-Path $PROFILE) '1msg.ps1')`. 61 mapped public `operationId`s + explicit skip `deleteMediaLegacy` = all 62 operations in the public OpenAPI spec. -| operationId | CLI | -|---|---| -| `sendMessage` | `message send --text` / `send --text` | -| `sendFile` | `message send --file` / `--media-id` | -| `createUploadMedia` | `media upload` | -| `listMessages` | `message list` | -| `createReadMessage` | `message read` | -| `sendTemplate` | `template send` | -| `listTemplates` | `template list` | -| `addTemplate` | `template add` | -| `removeTemplate` | `template remove` | -| `getStatus` | `status` / `channel status` | -| `getMe` | `me` | -| `updateMe` | `me update` | -| `retrieveMedia` | `media get` | -| `deleteMedia` | `media delete` | -| `sendReaction` | `message react` | -| `sendLocation` | `message location` | -| `sendLocationRequest` | `message location-request` | -| `sendContact` | `message contact` | -| `sendButton` | `message buttons` | -| `sendList` | `message menu` | -| `sendCarousel` | `message carousel` | -| `sendProduct` | `message product` | -| `sendCtaUrl` | `message cta` | -| `sendAddressMessage` | `message address` | -| `sendOrderDetails` | `message order` | -| `sendPaymentRequest` | `message payment` | -| `sendSticker` | `message sticker` | -| `sendFlow` | `message flow` | -| `getWebhook` | `webhook get` | -| `setWebhook` | `webhook set` | -| `listSettings` | `channel settings` | -| `createSettings` | `channel settings set` / `webhook clear` | -| `getMmLiteStatus` | `channel mm-lite` | -| `getCommerce` | `catalog get` | -| `createCommerce` | `catalog set` | -| `getConversationalAutomation` | `channel automation` | -| `setConversationalAutomation` | `channel automation set` | -| `blockUser` | `user block` | -| `unblockUser` | `user unblock` | -| `listBlockedUsers` | `user blocked` | -| `createGroups` | `group create` | -| `listGroups` | `group list` | -| `getGroupsGroupId` | `group get` | -| `createGroupsGroupId` | `group update` | -| `deleteGroupsGroupId` | `group delete` | -| `getGroupsGroupIdInvitelink` | `group invite-link` | -| `createGroupsGroupIdInvitelink` | `group invite-link reset` | -| `createFlows` | `flow create` | -| `listFlows` | `flow list` | -| `getFlowsFlowId` | `flow get` | -| `deleteFlowsFlowId` | `flow delete` | -| `patchFlowsFlowIdMetadata` | `flow metadata` | -| `patchFlowsFlowIdAssets` | `flow assets` | -| `getFlowsFlowIdPreview` | `flow preview` | -| `createFlowsFlowIdPublish` | `flow publish` | -| `createFlowsFlowIdDeprecate` | `flow deprecate` | -| `getCallingSettings` | `call settings` | -| `updateCallingSettings` | `call settings set` | -| `initiateCall` | `call connect` / `pre-accept` / `accept` / `reject` / `hangup` | -| `getWhatsappBusinessEncryption` | `flow encryption` | -| `setWhatsappBusinessEncryption` | `flow encryption set` | -| `deleteMediaLegacy` | not exposed | +| operationId | CLI | +| ------------------------------- | -------------------------------------------------------------- | +| `sendMessage` | `message send --text` / `send --text` | +| `sendFile` | `message send --file` / `--media-id` | +| `createUploadMedia` | `media upload` | +| `listMessages` | `message list` | +| `createReadMessage` | `message read` | +| `sendTemplate` | `template send` | +| `listTemplates` | `template list` | +| `addTemplate` | `template add` | +| `removeTemplate` | `template remove` | +| `getStatus` | `status` / `channel status` | +| `getMe` | `me` | +| `updateMe` | `me update` | +| `retrieveMedia` | `media get` | +| `deleteMedia` | `media delete` | +| `sendReaction` | `message react` | +| `sendLocation` | `message location` | +| `sendLocationRequest` | `message location-request` | +| `sendContact` | `message contact` | +| `sendButton` | `message buttons` | +| `sendList` | `message menu` | +| `sendCarousel` | `message carousel` | +| `sendProduct` | `message product` | +| `sendCtaUrl` | `message cta` | +| `sendAddressMessage` | `message address` | +| `sendOrderDetails` | `message order` | +| `sendPaymentRequest` | `message payment` | +| `sendSticker` | `message sticker` | +| `sendFlow` | `message flow` | +| `getWebhook` | `webhook get` | +| `setWebhook` | `webhook set` | +| `listSettings` | `channel settings` | +| `createSettings` | `channel settings set` / `webhook clear` | +| `getMmLiteStatus` | `channel mm-lite` | +| `getCommerce` | `catalog get` | +| `createCommerce` | `catalog set` | +| `getConversationalAutomation` | `channel automation` | +| `setConversationalAutomation` | `channel automation set` | +| `blockUser` | `user block` | +| `unblockUser` | `user unblock` | +| `listBlockedUsers` | `user blocked` | +| `createGroups` | `group create` | +| `listGroups` | `group list` | +| `getGroupsGroupId` | `group get` | +| `createGroupsGroupId` | `group update` | +| `deleteGroupsGroupId` | `group delete` | +| `getGroupsGroupIdInvitelink` | `group invite-link` | +| `createGroupsGroupIdInvitelink` | `group invite-link reset` | +| `createFlows` | `flow create` | +| `listFlows` | `flow list` | +| `getFlowsFlowId` | `flow get` | +| `deleteFlowsFlowId` | `flow delete` | +| `patchFlowsFlowIdMetadata` | `flow metadata` | +| `patchFlowsFlowIdAssets` | `flow assets` | +| `getFlowsFlowIdPreview` | `flow preview` | +| `createFlowsFlowIdPublish` | `flow publish` | +| `createFlowsFlowIdDeprecate` | `flow deprecate` | +| `getCallingSettings` | `call settings` | +| `updateCallingSettings` | `call settings set` | +| `initiateCall` | `call connect` / `pre-accept` / `accept` / `reject` / `hangup` | +| `getWhatsappBusinessEncryption` | `flow encryption` | +| `setWhatsappBusinessEncryption` | `flow encryption set` | +| `deleteMediaLegacy` | not exposed | Local-only (no `operationId`): `init`, `channel list|use|add|remove|current`, `completion`, `version`. @@ -657,11 +657,11 @@ Coverage is asserted in the `1msg-api` monorepo: 61 mapped public `operationId`s ## Development -| Script | Purpose | -|--------|---------| -| `npm run build` | Compile TypeScript → `dist/` | -| `npm test` | Jest unit tests (`--help` snapshots, precedence, SDK-only architecture) | -| `npm start` | Run `dist/index.js` | +| Script | Purpose | +| -------------------- | --------------------------------------------------------------------------- | +| `npm run build` | Compile TypeScript → `dist/` | +| `npm test` | Jest unit tests (`--help` snapshots, precedence, SDK-only architecture) | +| `npm start` | Run `dist/index.js` | | `npm pack --dry-run` | What npm would publish (`dist/`, this README, `ARCHITECTURE.md`, `LICENSE`) | CI (`.github/workflows/ci.yml`): install, build, test, smoke `--help` / `version`, `npm pack --dry-run`. diff --git a/eslint.complexity.config.mjs b/eslint.complexity.config.mjs new file mode 100644 index 0000000..57365b3 --- /dev/null +++ b/eslint.complexity.config.mjs @@ -0,0 +1,11 @@ +import { maintainabilityConfig, tseslint } from './tools/quality/eslint.mjs'; +export default [ + { linterOptions: { reportUnusedDisableDirectives: 'off' } }, + { ignores: ['**/node_modules/**', '**/dist/**', '**/tools/quality/**', '**/quality/reports/**'] }, + { + files: ['**/*.{ts,tsx,cts,mts}'], + languageOptions: { parser: tseslint.parser }, + plugins: { '@typescript-eslint': tseslint.plugin }, + }, + ...maintainabilityConfig(), +]; diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..67de2f1 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,14 @@ +import { staticConfig, globals } from './tools/quality/eslint.mjs'; + +export default [ + { ignores: ['**/node_modules/**', '**/dist/**', '**/tools/quality/**', '**/quality/reports/**'] }, + ...staticConfig({ globals: { ...globals.node, ...globals.jest } }), + { + settings: { + 'import/resolver': { + typescript: { project: './tsconfig.quality.json' }, + node: { extensions: ['.js', '.json', '.ts', '.mjs', '.cjs'] }, + }, + }, + }, +]; diff --git a/eslint.typed.config.mjs b/eslint.typed.config.mjs new file mode 100644 index 0000000..48de3a5 --- /dev/null +++ b/eslint.typed.config.mjs @@ -0,0 +1,5 @@ +import { typedConfig } from './tools/quality/eslint.mjs'; +export default [ + { ignores: ['**/node_modules/**', '**/dist/**', '**/tools/quality/**', '**/quality/reports/**'] }, + ...typedConfig({ project: './tsconfig.quality.json', tsconfigRootDir: import.meta.dirname }), +]; diff --git a/knip.json b/knip.json new file mode 100644 index 0000000..7cbb16f --- /dev/null +++ b/knip.json @@ -0,0 +1,11 @@ +{ + "entry": [ + "src/index.ts!", + "scripts/*.{js,mjs,cjs}", + "jest.config.js", + "eslint*.mjs", + "quality.config.mjs" + ], + "project": ["src/**/*.ts!", "scripts/*.{js,mjs,cjs}"], + "ignore": ["tools/quality/**"] +} diff --git a/package.json b/package.json index e8a0e5d..a08468a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,23 @@ "clean": "rm -rf dist", "start": "node dist/index.js", "test": "jest", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "typecheck": "tsc -p tsconfig.quality.json", + "lint:static": "node tools/quality/node_modules/eslint/bin/eslint.js . --max-warnings=0", + "lint:typed": "node tools/quality/node_modules/eslint/bin/eslint.js -c eslint.typed.config.mjs src --max-warnings=0", + "lint:unused:production": "node tools/quality/run.mjs unused-production", + "lint:unused:full": "node tools/quality/run.mjs unused-full", + "lint:duplicates": "node tools/quality/run.mjs duplicates", + "quality:guards": "node tools/quality/run.mjs guards", + "lint:arch": "node tools/quality/node_modules/dependency-cruiser/bin/dependency-cruiser.mjs --config .dependency-cruiser.cjs src", + "test:quality": "node scripts/quality-negative.mjs src", + "lint:complexity": "node tools/quality/run.mjs report --kind eslint --name complexity -- node tools/quality/node_modules/eslint/bin/eslint.js -c eslint.complexity.config.mjs . --format json", + "lint:duplicates:full": "node tools/quality/run.mjs duplicates-full", + "format:check": "node tools/quality/node_modules/prettier/bin/prettier.cjs --check .", + "format": "node tools/quality/node_modules/prettier/bin/prettier.cjs --write .", + "test:pack": "node scripts/pack-smoke.mjs", + "test:preset": "node --test tools/quality/test/*.test.mjs", + "hooks:install": "node tools/quality/node_modules/husky/bin.js" }, "dependencies": { "@1msg/sdk": "^1.0.4", @@ -46,6 +62,10 @@ "typescript": "^5.1.3" }, "engines": { - "node": ">=18.0.0" + "node": "24.x" + }, + "packageManager": "npm@11.17.0", + "lint-staged": { + "*.{js,ts,mjs,cjs,json,md,yml,yaml}": "node tools/quality/node_modules/prettier/bin/prettier.cjs --write" } } diff --git a/quality.config.mjs b/quality.config.mjs new file mode 100644 index 0000000..ca9529f --- /dev/null +++ b/quality.config.mjs @@ -0,0 +1,26 @@ +export default { + schemaVersion: 1, + profile: 'A', + defaultBranch: 'main', + sourceRoots: [ + 'src', + 'scripts', + 'jest.config.js', + 'eslint.config.mjs', + 'eslint.typed.config.mjs', + 'eslint.complexity.config.mjs', + '.dependency-cruiser.cjs', + 'quality.config.mjs', + ], + generated: [], + comments: { + full: ['scripts/pack-smoke.mjs', 'scripts/quality-negative.mjs'], + }, + knipConfig: 'knip.json', + duplicatePaths: ['src', 'scripts'], + duplicateIgnore: ['**/__tests__/**', '**/*.test.*', '**/*.spec.*'], + baselineDir: 'quality/baselines', + duplicateMaxPercentage: 5, + duplicateFullPaths: ['src', 'scripts'], + duplicateFullIgnore: [], +}; diff --git a/quality/baselines/complexity.json b/quality/baselines/complexity.json new file mode 100644 index 0000000..5cf6d91 --- /dev/null +++ b/quality/baselines/complexity.json @@ -0,0 +1,146 @@ +{ + "schemaVersion": 1, + "adoptionBase": "e5c0a856387326a9786ee9669441fb787de36926", + "entries": [ + { + "key": "src/errors.ts|complexity|normalizeThrown|0", + "value": 23, + "path": "src/errors.ts", + "rule": "complexity", + "scope": "normalizeThrown", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/errors.ts|sonarjs/cognitive-complexity|normalizeThrown|0", + "value": 18, + "path": "src/errors.ts", + "rule": "sonarjs/cognitive-complexity", + "scope": "normalizeThrown", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/local.ts|sonarjs/cognitive-complexity|promptChannel|0", + "value": 18, + "path": "src/local.ts", + "rule": "sonarjs/cognitive-complexity", + "scope": "promptChannel", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/local.ts": "4daabceb4d1ba74ca9e60e3674cf0458a360cf4385399d6c59a45a5e0973d613" + } + }, + { + "key": "src/program.ts|max-lines-per-function|createProgram|0", + "value": 421, + "path": "src/program.ts", + "rule": "max-lines-per-function", + "scope": "createProgram", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/program.ts": "3d32a585101f10cd8b70613697848a8dfce8df0f643118b30b5f79d782a564c7" + } + }, + { + "key": "src/program.ts|max-lines||0", + "value": 506, + "path": "src/program.ts", + "rule": "max-lines", + "scope": "", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/program.ts": "3d32a585101f10cd8b70613697848a8dfce8df0f643118b30b5f79d782a564c7" + } + }, + { + "key": "src/rest.ts|max-lines||0", + "value": 648, + "path": "src/rest.ts", + "rule": "max-lines", + "scope": "", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/rest.ts": "3ed26d4e118210cf7d6e7bafb590c9a8f7b11c9dd0376a9bfd0eeff2b78b8f52" + } + }, + { + "key": "src/send.ts|complexity|handleMessageContact|0", + "value": 21, + "path": "src/send.ts", + "rule": "complexity", + "scope": "handleMessageContact", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/send.ts": "9dc6f0a974e9d928255cbb82f929a700130fac7aff815d5915ef731622bb93a8" + } + }, + { + "key": "src/send.ts|max-lines||0", + "value": 713, + "path": "src/send.ts", + "rule": "max-lines", + "scope": "", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/send.ts": "9dc6f0a974e9d928255cbb82f929a700130fac7aff815d5915ef731622bb93a8" + } + }, + { + "key": "src/send.ts|max-params||0", + "value": 7, + "path": "src/send.ts", + "rule": "max-params", + "scope": "", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/send.ts": "9dc6f0a974e9d928255cbb82f929a700130fac7aff815d5915ef731622bb93a8" + } + }, + { + "key": "src/send.ts|sonarjs/cognitive-complexity|handleMessageContact|0", + "value": 21, + "path": "src/send.ts", + "rule": "sonarjs/cognitive-complexity", + "scope": "handleMessageContact", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical maintainability findings replayed at the adoption base; individual measurements cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/send.ts": "9dc6f0a974e9d928255cbb82f929a700130fac7aff815d5915ef731622bb93a8" + } + } + ] +} diff --git a/quality/baselines/duplicates.json b/quality/baselines/duplicates.json new file mode 100644 index 0000000..e40e461 --- /dev/null +++ b/quality/baselines/duplicates.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "adoptionBase": "e5c0a856387326a9786ee9669441fb787de36926", + "entries": [ + { + "key": "typescript|296f1f362436f27192adda6d8dcb2e81f73eab7f8c979766f196f6169b88af10|src/send.ts", + "value": 1, + "paths": ["src/send.ts"], + "tokens": 59, + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/send.ts": "9dc6f0a974e9d928255cbb82f929a700130fac7aff815d5915ef731622bb93a8" + } + }, + { + "key": "typescript|481a91f0567f2761220752e2ca37d954ab23af4c0d83d427319cff8c7bcb667c|src/rest.ts", + "value": 1, + "paths": ["src/rest.ts"], + "tokens": 56, + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/rest.ts": "3ed26d4e118210cf7d6e7bafb590c9a8f7b11c9dd0376a9bfd0eeff2b78b8f52" + } + }, + { + "key": "typescript|b0ade4b300c5e369d4264c263e8700d48683131beed1fe560e97169d00c59afb|src/rest.ts", + "value": 1, + "paths": ["src/rest.ts"], + "tokens": 65, + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/rest.ts": "3ed26d4e118210cf7d6e7bafb590c9a8f7b11c9dd0376a9bfd0eeff2b78b8f52" + } + } + ] +} diff --git a/quality/baselines/unused-full.json b/quality/baselines/unused-full.json new file mode 100644 index 0000000..f92c2cf --- /dev/null +++ b/quality/baselines/unused-full.json @@ -0,0 +1,258 @@ +{ + "schemaVersion": 1, + "adoptionBase": "e5c0a856387326a9786ee9669441fb787de36926", + "entries": [ + { + "key": "src/completion.ts|exports|loadCompletionCache", + "value": 1, + "path": "src/completion.ts", + "rule": "exports", + "symbol": "loadCompletionCache", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/completion.ts": "fed6ce2eb4baedce716a3ab1bb25d76b2b18b182258d7688a855b0375e622185" + } + }, + { + "key": "src/completion.ts|types|CompletionCache", + "value": 1, + "path": "src/completion.ts", + "rule": "types", + "symbol": "CompletionCache", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/completion.ts": "fed6ce2eb4baedce716a3ab1bb25d76b2b18b182258d7688a855b0375e622185" + } + }, + { + "key": "src/config.ts|exports|envBaseUrl", + "value": 1, + "path": "src/config.ts", + "rule": "exports", + "symbol": "envBaseUrl", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/config.ts": "195a13ec65c483ff0518cc81294ea3d215dbea84d73bba7b20c459efbda5f246" + } + }, + { + "key": "src/config.ts|exports|envChannel", + "value": 1, + "path": "src/config.ts", + "rule": "exports", + "symbol": "envChannel", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/config.ts": "195a13ec65c483ff0518cc81294ea3d215dbea84d73bba7b20c459efbda5f246" + } + }, + { + "key": "src/config.ts|types|ChannelBlock", + "value": 1, + "path": "src/config.ts", + "rule": "types", + "symbol": "ChannelBlock", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/config.ts": "195a13ec65c483ff0518cc81294ea3d215dbea84d73bba7b20c459efbda5f246" + } + }, + { + "key": "src/context.ts|types|CliFs", + "value": 1, + "path": "src/context.ts", + "rule": "types", + "symbol": "CliFs", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/context.ts": "d3d2c2c84129e7276dab7febb23b78ffe0a8c9bf2e421396213660a027e6c7d9" + } + }, + { + "key": "src/dest.ts|exports|mergeDest", + "value": 1, + "path": "src/dest.ts", + "rule": "exports", + "symbol": "mergeDest", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/dest.ts": "f149244887863f1d70364c5d501ec51b0dc15a7f6950eaf9fece13e15f214eb7" + } + }, + { + "key": "src/errors.ts|exports|EXIT_API_5XX", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "EXIT_API_5XX", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/errors.ts|exports|EXIT_NETWORK", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "EXIT_NETWORK", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/errors.ts|exports|EXIT_NOT_CONFIGURED", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "EXIT_NOT_CONFIGURED", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/errors.ts|exports|EXIT_USAGE", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "EXIT_USAGE", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/errors.ts|exports|NetworkError", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "NetworkError", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/io.ts|exports|mimeFromName", + "value": 1, + "path": "src/io.ts", + "rule": "exports", + "symbol": "mimeFromName", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/io.ts": "2f9cbed38ec998a0d7ddb33b65507873e16fd97ae571b2e7fb7094a53c764d0a" + } + }, + { + "key": "src/io.ts|exports|splitOnce", + "value": 1, + "path": "src/io.ts", + "rule": "exports", + "symbol": "splitOnce", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/io.ts": "2f9cbed38ec998a0d7ddb33b65507873e16fd97ae571b2e7fb7094a53c764d0a" + } + }, + { + "key": "src/local.ts|exports|resolveHelp", + "value": 1, + "path": "src/local.ts", + "rule": "exports", + "symbol": "resolveHelp", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/local.ts": "4daabceb4d1ba74ca9e60e3674cf0458a360cf4385399d6c59a45a5e0973d613" + } + }, + { + "key": "src/output.ts|exports|normalizeMe", + "value": 1, + "path": "src/output.ts", + "rule": "exports", + "symbol": "normalizeMe", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/output.ts": "fc443a7c02bce1551be7f19e37866baac99936c761481ecf0456cd8ee41cf876" + } + }, + { + "key": "src/output.ts|exports|sanitizeForOutput", + "value": 1, + "path": "src/output.ts", + "rule": "exports", + "symbol": "sanitizeForOutput", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/output.ts": "fc443a7c02bce1551be7f19e37866baac99936c761481ecf0456cd8ee41cf876" + } + }, + { + "key": "src/version.ts|exports|CLI_NAME", + "value": 1, + "path": "src/version.ts", + "rule": "exports", + "symbol": "CLI_NAME", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/version.ts": "53188cecf399e0983fe133085f7e2f7a99b77f5b6eb36456983ad072a8616af1" + } + } + ] +} diff --git a/quality/baselines/unused-production.json b/quality/baselines/unused-production.json new file mode 100644 index 0000000..cf1c325 --- /dev/null +++ b/quality/baselines/unused-production.json @@ -0,0 +1,314 @@ +{ + "schemaVersion": 1, + "adoptionBase": "e5c0a856387326a9786ee9669441fb787de36926", + "entries": [ + { + "key": "src/__tests__/helpers.ts|files|src/__tests__/helpers.ts", + "value": 1, + "path": "src/__tests__/helpers.ts", + "rule": "files", + "symbol": "src/__tests__/helpers.ts", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/__tests__/helpers.ts": "9df17bb352bda868530bfd5fe9d92814fc51c986e45eb3f182e619098922cf6e" + } + }, + { + "key": "src/commands.ts|exports|OPERATION_COMMANDS", + "value": 1, + "path": "src/commands.ts", + "rule": "exports", + "symbol": "OPERATION_COMMANDS", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/commands.ts": "5d20aa57e5e77a8f0b2dc9c4ad1aca75abefe7f5df6741bb032ec631b296750b" + } + }, + { + "key": "src/commands.ts|exports|SKIPPED_OPERATION_IDS", + "value": 1, + "path": "src/commands.ts", + "rule": "exports", + "symbol": "SKIPPED_OPERATION_IDS", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/commands.ts": "5d20aa57e5e77a8f0b2dc9c4ad1aca75abefe7f5df6741bb032ec631b296750b" + } + }, + { + "key": "src/completion.ts|exports|loadCompletionCache", + "value": 1, + "path": "src/completion.ts", + "rule": "exports", + "symbol": "loadCompletionCache", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/completion.ts": "fed6ce2eb4baedce716a3ab1bb25d76b2b18b182258d7688a855b0375e622185" + } + }, + { + "key": "src/completion.ts|types|CompletionCache", + "value": 1, + "path": "src/completion.ts", + "rule": "types", + "symbol": "CompletionCache", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/completion.ts": "fed6ce2eb4baedce716a3ab1bb25d76b2b18b182258d7688a855b0375e622185" + } + }, + { + "key": "src/config.ts|exports|envBaseUrl", + "value": 1, + "path": "src/config.ts", + "rule": "exports", + "symbol": "envBaseUrl", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/config.ts": "195a13ec65c483ff0518cc81294ea3d215dbea84d73bba7b20c459efbda5f246" + } + }, + { + "key": "src/config.ts|exports|envChannel", + "value": 1, + "path": "src/config.ts", + "rule": "exports", + "symbol": "envChannel", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/config.ts": "195a13ec65c483ff0518cc81294ea3d215dbea84d73bba7b20c459efbda5f246" + } + }, + { + "key": "src/config.ts|types|ChannelBlock", + "value": 1, + "path": "src/config.ts", + "rule": "types", + "symbol": "ChannelBlock", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/config.ts": "195a13ec65c483ff0518cc81294ea3d215dbea84d73bba7b20c459efbda5f246" + } + }, + { + "key": "src/context.ts|types|CliFs", + "value": 1, + "path": "src/context.ts", + "rule": "types", + "symbol": "CliFs", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/context.ts": "d3d2c2c84129e7276dab7febb23b78ffe0a8c9bf2e421396213660a027e6c7d9" + } + }, + { + "key": "src/dest.ts|exports|mergeDest", + "value": 1, + "path": "src/dest.ts", + "rule": "exports", + "symbol": "mergeDest", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/dest.ts": "f149244887863f1d70364c5d501ec51b0dc15a7f6950eaf9fece13e15f214eb7" + } + }, + { + "key": "src/errors.ts|exports|EXIT_API_5XX", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "EXIT_API_5XX", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/errors.ts|exports|EXIT_NETWORK", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "EXIT_NETWORK", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/errors.ts|exports|EXIT_NOT_CONFIGURED", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "EXIT_NOT_CONFIGURED", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/errors.ts|exports|EXIT_USAGE", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "EXIT_USAGE", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/errors.ts|exports|NetworkError", + "value": 1, + "path": "src/errors.ts", + "rule": "exports", + "symbol": "NetworkError", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/errors.ts": "0b1bfdd43cf890f83f948cc29243e04a57098481abc94c8fbacad379fa56338e" + } + }, + { + "key": "src/help-text.ts|exports|HELP_TEXT", + "value": 1, + "path": "src/help-text.ts", + "rule": "exports", + "symbol": "HELP_TEXT", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/help-text.ts": "88cfda8aa4f8fee00a089d7e2b557e80d7e7473d9038ba29bce24a314d0f3527" + } + }, + { + "key": "src/io.ts|exports|mimeFromName", + "value": 1, + "path": "src/io.ts", + "rule": "exports", + "symbol": "mimeFromName", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/io.ts": "2f9cbed38ec998a0d7ddb33b65507873e16fd97ae571b2e7fb7094a53c764d0a" + } + }, + { + "key": "src/io.ts|exports|splitOnce", + "value": 1, + "path": "src/io.ts", + "rule": "exports", + "symbol": "splitOnce", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/io.ts": "2f9cbed38ec998a0d7ddb33b65507873e16fd97ae571b2e7fb7094a53c764d0a" + } + }, + { + "key": "src/local.ts|exports|resolveHelp", + "value": 1, + "path": "src/local.ts", + "rule": "exports", + "symbol": "resolveHelp", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/local.ts": "4daabceb4d1ba74ca9e60e3674cf0458a360cf4385399d6c59a45a5e0973d613" + } + }, + { + "key": "src/output.ts|exports|normalizeMe", + "value": 1, + "path": "src/output.ts", + "rule": "exports", + "symbol": "normalizeMe", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/output.ts": "fc443a7c02bce1551be7f19e37866baac99936c761481ecf0456cd8ee41cf876" + } + }, + { + "key": "src/output.ts|exports|sanitizeForOutput", + "value": 1, + "path": "src/output.ts", + "rule": "exports", + "symbol": "sanitizeForOutput", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/output.ts": "fc443a7c02bce1551be7f19e37866baac99936c761481ecf0456cd8ee41cf876" + } + }, + { + "key": "src/version.ts|exports|CLI_NAME", + "value": 1, + "path": "src/version.ts", + "rule": "exports", + "symbol": "CLI_NAME", + "owner": "1MSG SDK/tooling maintainers", + "expires": "2026-12-11", + "reason": "Historical findings replayed at the adoption base; each item is tracked independently and cannot increase.", + "issue": "https://plane.1msg.io/1msg/browse/1MSG-156/", + "evidence": { + "src/version.ts": "53188cecf399e0983fe133085f7e2f7a99b77f5b6eb36456983ad072a8616af1" + } + } + ] +} diff --git a/scripts/pack-smoke.mjs b/scripts/pack-smoke.mjs new file mode 100644 index 0000000..dcd8c2b --- /dev/null +++ b/scripts/pack-smoke.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +const root = process.cwd(); +const temporary = mkdtempSync(join(tmpdir(), '1msg-packed-')); +/** Run the artifact consumer and propagate both command failure and unexpected hangs. */ +function run(command, args, cwd = root) { + const result = spawnSync(command, args, { cwd, encoding: 'utf8', timeout: 30000 }); + if (result.status !== 0) + throw new Error(`${command} failed: ${result.stderr || result.stdout || result.error}`); + return result.stdout; +} +try { + const [packed] = JSON.parse( + run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', temporary]), + ); + assert.ok( + packed.files.some((file) => file.path.startsWith('dist/')), + 'packed build output missing', + ); + run('tar', ['-xzf', join(temporary, packed.filename), '-C', temporary]); + const artifact = join(temporary, 'package'); + // The artifact is isolated from source; dependencies come from the frozen checkout install. + symlinkSync(resolve('node_modules'), join(artifact, 'node_modules'), 'dir'); + const manifest = JSON.parse(readFileSync(join(artifact, 'package.json'), 'utf8')); + if (manifest.n8n) { + for (const entry of [...manifest.n8n.nodes, ...manifest.n8n.credentials]) { + run( + process.execPath, + [ + '-e', + `const value=require(${JSON.stringify(join(artifact, entry))}); if(!Object.values(value).some(item=>typeof item==='function')) throw new Error('Missing registered node/credential export')`, + ], + artifact, + ); + } + } else if (manifest.bin) { + const entry = typeof manifest.bin === 'string' ? manifest.bin : Object.values(manifest.bin)[0]; + assert.match(run(process.execPath, [join(artifact, entry), '--help'], artifact), /1msg/i); + } else { + run( + process.execPath, + ['-e', `require(${JSON.stringify(join(artifact, manifest.main))})`], + artifact, + ); + } + console.log( + 'Packed artifact resolves and its public entrypoints load without source files or external services', + ); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/scripts/quality-negative.mjs b/scripts/quality-negative.mjs new file mode 100644 index 0000000..e9392eb --- /dev/null +++ b/scripts/quality-negative.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +const root = process.argv[2] ?? 'src'; +const directory = join(root, '__quality_negative_fixtures__'); +if (existsSync(directory)) throw new Error(`Refusing to overwrite ${directory}`); +mkdirSync(directory); + +/** Run the repository's actual analyzer and require the intended diagnostic, not an unrelated setup failure. */ +function rejects(command, args, expected) { + const result = spawnSync(process.execPath, [command, ...args], { + encoding: 'utf8', + env: { ...process.env, CI: 'true', ESLINT_SKIP_TYPE_AWARE: 'true' }, + }); + const output = result.stdout + result.stderr; + assert.notEqual(result.status, 0, 'negative fixture unexpectedly passed'); + assert.match(output, expected, `wrong failure: ${output}`); +} + +try { + const fixture = join(directory, 'unsafe.ts'); + writeFileSync( + fixture, + '/** Negative fixture: checks that typed lint cannot be disabled by environment flags. */\nexport function unsafe(value: any) { Promise.resolve(1); return value.field; }\n', + ); + rejects( + 'tools/quality/node_modules/eslint/bin/eslint.js', + ['-c', 'eslint.typed.config.mjs', fixture], + /no-floating-promises/, + ); + rejects( + 'tools/quality/node_modules/eslint/bin/eslint.js', + ['-c', 'eslint.typed.config.mjs', fixture], + /no-unsafe-member-access/, + ); + for (const [name, next] of [ + ['a', 'b'], + ['b', 'c'], + ['c', 'a'], + ]) { + writeFileSync( + join(directory, `${name}.ts`), + `import { ${next} } from './${next}.js';\nexport const ${name} = () => ${next}();\n`, + ); + } + rejects( + 'tools/quality/node_modules/dependency-cruiser/bin/dependency-cruiser.mjs', + ['--config', '.dependency-cruiser.cjs', directory], + /no-runtime-cycles/, + ); + console.log( + 'Negative fixtures reject floating promises, unsafe access, skip flags and a three-edge runtime cycle', + ); +} finally { + rmSync(directory, { recursive: true, force: true }); +} diff --git a/src/__tests__/commands.test.ts b/src/__tests__/commands.test.ts index 6ac6cc8..6a2789e 100644 --- a/src/__tests__/commands.test.ts +++ b/src/__tests__/commands.test.ts @@ -1,9 +1,10 @@ import { stringify } from 'yaml'; -import { runCli } from '../run'; + +import { releaseStdio } from '../context'; import { configFilePath } from '../paths'; +import { runCli } from '../run'; import { CLI_VERSION } from '../version'; import { createRecordingClient, makeCtx } from './helpers'; -import { releaseStdio } from '../context'; function withChannel(ctxFilesWrite: (ctx: ReturnType['ctx']) => void) { const bundle = createRecordingClient(); diff --git a/src/__tests__/config-precedence.test.ts b/src/__tests__/config-precedence.test.ts index 5e05186..f7c8978 100644 --- a/src/__tests__/config-precedence.test.ts +++ b/src/__tests__/config-precedence.test.ts @@ -1,9 +1,10 @@ import { stringify } from 'yaml'; -import { configFilePath } from '../paths'; + import { resolveChannel } from '../config'; -import { makeCtx } from './helpers'; -import { runCli } from '../run'; import { NotConfiguredError } from '../errors'; +import { configFilePath } from '../paths'; +import { runCli } from '../run'; +import { makeCtx } from './helpers'; describe('config precedence', () => { it('does not let leftover ONE_MSG_TOKEN override a named channel token', () => { diff --git a/src/__tests__/coverage.test.ts b/src/__tests__/coverage.test.ts index 7780587..c3a72dd 100644 --- a/src/__tests__/coverage.test.ts +++ b/src/__tests__/coverage.test.ts @@ -1,14 +1,16 @@ -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; + import { OPERATION_COMMANDS, SKIPPED_OPERATION_IDS } from '../commands'; const SPEC = path.resolve(__dirname, '../../../../public/openapi.bundled.yaml'); const IN_MONOREPO = fs.existsSync(SPEC); +/** Collect unique declared operations so the CLI coverage test detects missing command bindings. */ function operationIdsFromSpec(yaml: string): string[] { const ids = new Set(); for (const match of yaml.matchAll(/^\s+operationId:\s+(\S+)\s*$/gm)) { - ids.add(match[1]); + if (match[1] !== undefined) ids.add(match[1]); } return [...ids].sort(); } diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index 4bbb972..92dd2c3 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -1,13 +1,19 @@ -import { Writable } from 'stream'; -import os from 'os'; -import path from 'path'; -import fs from 'fs'; import type { Client, ClientConfigOptions } from '@1msg/sdk'; + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Writable } from 'node:stream'; + import { createDefaultContext, type CliContext } from '../context'; export class MemoryWriter extends Writable { chunks: string[] = []; - override _write(chunk: Buffer | string, _enc: BufferEncoding, cb: (err?: Error | null) => void): void { + override _write( + chunk: Buffer | string, + _enc: BufferEncoding, + cb: (err?: Error | null) => void, + ): void { this.chunks.push(String(chunk)); cb(); } @@ -16,7 +22,11 @@ export class MemoryWriter extends Writable { } } -export function createRecordingClient(): { client: Client; calls: Array<{ method: string; args: unknown[] }> } { +/** Create an in-memory SDK double that records request arguments and returns configured responses without network access. */ +export function createRecordingClient(): { + client: Client; + calls: Array<{ method: string; args: unknown[] }>; +} { const calls: Array<{ method: string; args: unknown[] }> = []; const fn = (label: string) => jest.fn().mockImplementation(async (...args: unknown[]) => { @@ -25,10 +35,32 @@ export function createRecordingClient(): { client: Client; calls: Array<{ method return { sent: true, id: 'wamid.mock', chatId: '12020721369@c.us' }; } if (label === 'templates.listTemplates') { - return { total: 1, templates: [{ name: 'hello_world', status: 'APPROVED', language: 'en', category: 'UTILITY', id: '1' }] }; + return { + total: 1, + templates: [ + { + name: 'hello_world', + status: 'APPROVED', + language: 'en', + category: 'UTILITY', + id: '1', + }, + ], + }; } if (label === 'messaging.listMessages') { - return { messages: [{ time: 1, chatId: '12020721369@c.us', from: 'me', type: 'text', id: 'm1', body: 'hello' }] }; + return { + messages: [ + { + time: 1, + chatId: '12020721369@c.us', + from: 'me', + type: 'text', + id: 'm1', + body: 'hello', + }, + ], + }; } if (label === 'channel.getStatus') { return { status: 'connected', accountStatus: 'authenticated', mm_lite_available: false }; @@ -85,15 +117,22 @@ export function createRecordingClient(): { client: Client; calls: Array<{ method return { client, calls }; } +/** Construct isolated CLI streams, filesystem and client factories so command tests cannot affect user configuration. */ export function makeCtx(opts?: { env?: NodeJS.ProcessEnv; homedir?: string; createClient?: (options: ClientConfigOptions) => Client; files?: Record; -}): { ctx: CliContext; stdout: MemoryWriter; stderr: MemoryWriter; files: Map } { +}): { + ctx: CliContext; + stdout: MemoryWriter; + stderr: MemoryWriter; + files: Map; +} { const stdout = new MemoryWriter(); const stderr = new MemoryWriter(); - const homedir = opts?.homedir ?? path.join(os.tmpdir(), `1msg-cli-test-${Math.random().toString(16).slice(2)}`); + const homedir = + opts?.homedir ?? path.join(os.tmpdir(), `1msg-cli-test-${Math.random().toString(16).slice(2)}`); const files = new Map(); if (opts?.files) { for (const [k, v] of Object.entries(opts.files)) files.set(k, v); diff --git a/src/__tests__/operations.test.ts b/src/__tests__/operations.test.ts index d5cf318..231f610 100644 --- a/src/__tests__/operations.test.ts +++ b/src/__tests__/operations.test.ts @@ -1,5 +1,6 @@ -import path from 'path'; +import path from 'node:path'; import { stringify } from 'yaml'; + import { OPERATION_COMMANDS } from '../commands'; import { configFilePath } from '../paths'; import { runCli } from '../run'; diff --git a/src/__tests__/rfc-help.test.ts b/src/__tests__/rfc-help.test.ts index 0f7043e..20b4333 100644 --- a/src/__tests__/rfc-help.test.ts +++ b/src/__tests__/rfc-help.test.ts @@ -1,5 +1,6 @@ -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; + import { HELP_TEXT } from '../help-text'; const REPO_ROOT = path.resolve(__dirname, '../../../..'); @@ -27,7 +28,7 @@ function unlabeledFences(slice: string): string[] { const re = /```\n([\s\S]*?)\n```/g; let m: RegExpExecArray | null; while ((m = re.exec(slice))) { - fences.push(normalizeHelp(m[1])); + if (m[1] !== undefined) fences.push(normalizeHelp(m[1])); } return fences; } @@ -38,6 +39,7 @@ function extraFenceKey(text: string): string | undefined { return m?.[1]; } +/** Extract each RFC help block and its aliases; reject headings without a help fence instead of silently dropping coverage. */ function rfcHelpPages(md: string): Map { const headingRe = /^#{2,4} .*(`1msg(?: [^`]+)? --help`)/gm; const headings: { index: number; keys: string[] }[] = []; @@ -48,14 +50,18 @@ function rfcHelpPages(md: string): Map { } const pages = new Map(); for (let i = 0; i < headings.length; i++) { - const end = i + 1 < headings.length ? headings[i + 1].index : md.length; - const fences = unlabeledFences(md.slice(headings[i].index, end)); - expect({ keys: headings[i].keys, fenceCount: fences.length > 0 }).toEqual({ - keys: headings[i].keys, + const heading = headings[i]; + if (heading === undefined) throw new Error('Missing RFC heading'); + const end = headings[i + 1]?.index ?? md.length; + const fences = unlabeledFences(md.slice(heading.index, end)); + expect({ keys: heading.keys, fenceCount: fences.length > 0 }).toEqual({ + keys: heading.keys, fenceCount: true, }); - for (const key of headings[i].keys) { - pages.set(key, fences[0]); + const firstFence = fences[0]; + if (firstFence === undefined) throw new Error('RFC heading has no help block'); + for (const key of heading.keys) { + pages.set(key, firstFence); } for (const fence of fences.slice(1)) { const key = extraFenceKey(fence); diff --git a/src/__tests__/sdk-only-architecture.test.ts b/src/__tests__/sdk-only-architecture.test.ts index 5de0d2f..52af7ca 100644 --- a/src/__tests__/sdk-only-architecture.test.ts +++ b/src/__tests__/sdk-only-architecture.test.ts @@ -1,5 +1,5 @@ -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; const CLI_SRC = path.resolve(__dirname, '..'); @@ -23,10 +23,13 @@ function walk(dir: string): string[] { return out; } +/** Protect the package boundary against introducing a second HTTP transport alongside the shared SDK. */ describe('CLI SDK-only architecture', () => { + /** Inspect the committed dependency manifest rather than relying on locally installed transitive modules. */ it('depends on @1msg/sdk and not on HTTP clients', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const pkg = require('../../package.json') as { + const pkg = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8'), + ) as { dependencies?: Record; devDependencies?: Record; }; diff --git a/src/channel-rest.ts b/src/channel-rest.ts new file mode 100644 index 0000000..0252871 --- /dev/null +++ b/src/channel-rest.ts @@ -0,0 +1,35 @@ +import type { Command } from 'commander'; + +import type { CliContext } from './context'; + +import { printChannelStatus } from './channel-status'; +import { ok, printApiResult, withClient } from './execute'; +import { globalsFrom } from './local'; +import { asRecord, meProfileRows, printKv } from './output'; + +/** Fetch channel status and profile through the configured client and render their combined view. */ +export async function handleChannelInfo(ctx: CliContext, cmd: Command): Promise { + const flags = globalsFrom(cmd); + const status = await withClient(ctx, flags, (client) => + client.channel.getStatus(client.config.token), + ); + const me = await withClient(ctx, flags, (client) => client.profile.getMe(client.config.token)); + const combined = { status, me }; + /** Present status followed by profile rows in a stable human-readable order. */ + printApiResult(ctx, flags, combined, () => { + const s = asRecord(status); + printKv(ctx.stdout, [ + ['status', s.status], + ['accountStatus', s.accountStatus], + ['mm_lite_available', s.mm_lite_available], + ...meProfileRows(me), + ]); + }); + return ok(); +} + +/** Adapt Commander flags into the shared channel-status scenario and preserve the successful exit code. */ +export async function handleStatus(ctx: CliContext, cmd: Command): Promise { + await printChannelStatus(ctx, globalsFrom(cmd)); + return ok(); +} diff --git a/src/channel-status.ts b/src/channel-status.ts new file mode 100644 index 0000000..06cf595 --- /dev/null +++ b/src/channel-status.ts @@ -0,0 +1,21 @@ +import type { GlobalFlags } from './config'; +import type { CliContext } from './context'; + +import { printApiResult, withClient } from './execute'; +import { asRecord, printKv } from './output'; + +/** Fetch channel availability and render the same JSON/human output for verification and status commands. */ +export async function printChannelStatus(ctx: CliContext, flags: GlobalFlags): Promise { + const body = await withClient(ctx, flags, (client) => + client.channel.getStatus(client.config.token), + ); + /** Render stable human-readable channel fields when JSON output was not requested. */ + printApiResult(ctx, flags, body, () => { + const rec = asRecord(body); + printKv(ctx.stdout, [ + ['status', rec.status], + ['accountStatus', rec.accountStatus], + ['mm_lite_available', rec.mm_lite_available], + ]); + }); +} diff --git a/src/command-binding.ts b/src/command-binding.ts new file mode 100644 index 0000000..8f5c04b --- /dev/null +++ b/src/command-binding.ts @@ -0,0 +1,62 @@ +import { Command, Option } from 'commander'; + +import type { CliContext } from './context'; + +import { CliExit } from './errors'; +import { writeHelp } from './local'; + +/** Preserve each Commander action's parsed argument tuple while awaiting its result. */ +export function wrap(fn: (this: Command, ...args: Args) => unknown) { + /** Await the selected action with its original Commander receiver and parsed argument tuple. */ + return /** Preserve the receiver and arguments while awaiting the selected command. */ async function ( + this: Command, + ...args: Args + ): Promise { + await fn.apply(this, args); + }; +} + +/** Bind a typed application handler to Commander's untyped action boundary, retaining parsed positional/options tuples. */ +export function bindCommandAction( + command: Command, + ctx: CliContext, + handler: (context: CliContext, command: Command, ...args: Args) => unknown, +): void { + /** Commander supplies the parsed argument tuple only when the command runs; async failure remains observed by wrap. */ + command.action(wrap((...args: Args) => handler(ctx, command, ...args))); +} + +/** Derive the display path from the Commander parent chain without including the executable name. */ +function commandPath(cmd: Command): string { + const names: string[] = []; + let current: Command | null = cmd; + while (current) { + const name = current.name(); + if (name && name !== '1msg') names.unshift(name); + current = current.parent; + } + return names.join(' '); +} + +/** Apply channel/credential/output flags and a pre-action help hook consistently to every command. */ +export function applyCommon(cmd: Command, ctx: CliContext, helpPath?: string): Command { + cmd.helpOption(false); + cmd.addHelpCommand(false); + cmd.option('-c, --channel '); + cmd.option('--instance-id '); + cmd.option('--base-url '); + cmd.option('--token '); + cmd.option('--json'); + cmd.addOption(new Option('--no-color')); + cmd.option('-h, --help'); + /** Short-circuit help before any command action can access an API or configuration. */ + cmd.hook('preAction', (thisCommand, actionCommand) => { + if (thisCommand !== actionCommand) return; + const opts = actionCommand.optsWithGlobals() as { help?: boolean }; + if (opts.help) { + writeHelp(ctx, helpPath || commandPath(actionCommand) || 'root'); + throw new CliExit(0); + } + }); + return cmd; +} diff --git a/src/completion.ts b/src/completion.ts index d6a616c..5fe302d 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -1,5 +1,7 @@ -import path from 'path'; +import path from 'node:path'; + import type { CliContext } from './context'; + import { COMPLETION_COMMANDS } from './commands'; import { channelNames, loadConfig } from './config'; import { cacheFilePath } from './paths'; @@ -22,11 +24,7 @@ export function loadCompletionCache(ctx: CliContext): CompletionCache | null { } } -export function writeCompletionCache( - ctx: CliContext, - channel: string, - templates: string[], -): void { +export function writeCompletionCache(ctx: CliContext, channel: string, templates: string[]): void { const filePath = cacheFilePath(ctx); const parent = path.dirname(filePath); ctx.fs.mkdirSync(parent, { recursive: true }); diff --git a/src/config.ts b/src/config.ts index bf13173..5483042 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,8 @@ -import path from 'path'; +import path from 'node:path'; import { parse, stringify } from 'yaml'; + import type { CliContext } from './context'; + import { NotConfiguredError, UsageError } from './errors'; import { configFilePath } from './paths'; @@ -16,13 +18,14 @@ export interface FileConfig { channels: Record; } +/** Commander produces present keys with undefined values when flags are unset. */ export interface GlobalFlags { - channel?: string; - instanceId?: string; - baseUrl?: string; - token?: string; - json?: boolean; - color?: boolean; + channel?: string | undefined; + instanceId?: string | undefined; + baseUrl?: string | undefined; + token?: string | undefined; + json?: boolean | undefined; + color?: boolean | undefined; } export interface ResolvedChannel { @@ -40,14 +43,13 @@ const PUBLIC_API_HOST_ALIASES: Record = { 'api.sandbox.1msg.io': 'sandbox.1msg.io', }; +/** Normalize the configured API endpoint while preserving its path and rejecting an empty value. */ export function normalizeBaseUrl(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { throw new UsageError('base URL must not be empty'); } - const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) - ? trimmed - : `https://${trimmed}`; + const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`; let parsed: URL; try { parsed = new URL(withScheme); @@ -95,6 +97,7 @@ export function envToken(ctx: CliContext): string | undefined { return readEnv(ctx, 'ONE_MSG_TOKEN', ['CHAT_API_TOKEN', 'CHAT_API_KEY', 'TOKEN']); } +/** Read the user config file without network access; absent/empty configuration returns null and missing optional keys stay absent. */ export function loadConfig(ctx: CliContext): FileConfig | null { const filePath = configFilePath(ctx); if (!ctx.fs.existsSync(filePath)) return null; @@ -102,11 +105,10 @@ export function loadConfig(ctx: CliContext): FileConfig | null { if (!String(raw).trim()) return null; const parsed = parse(String(raw)) as Partial | null; if (!parsed || typeof parsed !== 'object') return null; - const channels = - parsed.channels && typeof parsed.channels === 'object' ? parsed.channels : {}; + const channels = parsed.channels && typeof parsed.channels === 'object' ? parsed.channels : {}; return { version: 1, - default_channel: parsed.default_channel, + ...(parsed.default_channel === undefined ? {} : { default_channel: parsed.default_channel }), channels, }; } @@ -138,9 +140,7 @@ export function resolveChannel(ctx: CliContext, flags: GlobalFlags): ResolvedCha flags.channel || envChannel(ctx) || file?.default_channel || - (file && Object.keys(file.channels).length === 1 - ? Object.keys(file.channels)[0] - : undefined); + (file && Object.keys(file.channels).length === 1 ? Object.keys(file.channels)[0] : undefined); if (channelName) { const block = file?.channels[channelName]; @@ -190,12 +190,13 @@ function fillChannel( }; } +/** Resolve a configured name or unique instance ID; reject absent and ambiguous matches before changing configuration. */ export function lookupChannelName(config: FileConfig, nameOrId: string): string { if (config.channels[nameOrId]) return nameOrId; const matches = Object.entries(config.channels) .filter(([, block]) => block.instance_id === nameOrId) .map(([name]) => name); - if (matches.length === 1) return matches[0]; + if (matches.length === 1 && matches[0] !== undefined) return matches[0]; if (matches.length === 0) { throw new UsageError(`channel "${nameOrId}" not in config`, '1msg channel list'); } diff --git a/src/context.ts b/src/context.ts index 6d4cb56..be4d6ac 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,9 +1,8 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import readline from 'node:readline/promises'; -import { Writable } from 'stream'; import { createClient, type Client, type ClientConfigOptions } from '@1msg/sdk'; +import fs from 'node:fs'; +import os from 'node:os'; +import readline from 'node:readline/promises'; +import { Writable } from 'node:stream'; export interface CliFs { readFileSync: typeof fs.readFileSync; @@ -32,13 +31,22 @@ export interface CliContext { readStdin: () => Promise; } +/** Buffer text or binary stdin until end; reject unsupported object-mode chunks and propagate stream failures. */ function collectStream(stream: NodeJS.ReadableStream): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); - stream.on('error', reject); - }); + /** Resolve only after the stream ends and reject stream errors before producing a partial input value. */ + return new Promise( + /** Collect validated chunks until end; reject input stream errors. */ (resolve, reject) => { + const chunks: Buffer[] = []; + /** Accept only byte buffers and strings from the input stream; unsupported object-mode values are errors. */ + stream.on('data', (chunk: unknown) => { + if (Buffer.isBuffer(chunk)) chunks.push(Buffer.from(chunk)); + else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk)); + else reject(new TypeError('Expected a text or Buffer stream chunk')); + }); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + stream.on('error', reject); + }, + ); } function pauseStdin(stdin: NodeJS.ReadableStream): void { @@ -61,7 +69,11 @@ export function releaseStdio(stdin: NodeJS.ReadableStream = process.stdin): void pauseStdin(stdin); } -async function question(stdin: NodeJS.ReadableStream, stdout: Writable, query: string): Promise { +async function question( + stdin: NodeJS.ReadableStream, + stdout: Writable, + query: string, +): Promise { const rl = readline.createInterface({ input: stdin, output: stdout, diff --git a/src/dest.ts b/src/dest.ts index ff86495..4c3c5bd 100644 --- a/src/dest.ts +++ b/src/dest.ts @@ -26,7 +26,9 @@ export function requireDest( } if (!opts.to && !opts.chatId) { usage( - allowChatId ? 'missing destination: pass --to or --chat-id' : 'missing destination: pass --to', + allowChatId + ? 'missing destination: pass --to or --chat-id' + : 'missing destination: pass --to', help, ); } diff --git a/src/errors.ts b/src/errors.ts index f49379f..3c0bf37 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -16,7 +16,7 @@ export class CliExit extends Error { export class UsageError extends Error { readonly exitCode = EXIT_USAGE; - readonly see?: string; + readonly see: string | undefined; constructor(message: string, see?: string) { super(message); this.name = 'UsageError'; @@ -65,7 +65,8 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function extractErrorMessage(jsonBody: unknown, rawBody: string, status: number): string { +/** Prefer structured upstream error details, then a short raw body; avoid dumping large response payloads into CLI errors. */ +function extractErrorMessage(jsonBody: unknown, rawBody: string): string { if (isRecord(jsonBody) && typeof jsonBody.error === 'string' && jsonBody.error.trim()) { return jsonBody.error; } @@ -81,6 +82,7 @@ function extractErrorMessage(jsonBody: unknown, rawBody: string, status: number) return `request failed`; } +/** Translate SDK and transport failures into stable CLI error classes while retaining HTTP status and response details. */ export async function normalizeThrown(error: unknown): Promise { if ( error instanceof UsageError || @@ -112,7 +114,7 @@ export async function normalizeThrown(error: unknown): Promise { } catch { jsonBody = undefined; } - const message = extractErrorMessage(jsonBody, rawBody, response.status); + const message = extractErrorMessage(jsonBody, rawBody); return new ApiError(response.status, rawBody, jsonBody, message); } @@ -136,12 +138,10 @@ export async function normalizeThrown(error: unknown): Promise { return err; } +/** Return an actionable token hint only for authentication failures; other errors keep their original explanation. */ export function hintFor(error: Error): string | undefined { if (error instanceof ApiError && error.status === 401) { return 'set ONE_MSG_TOKEN or run `1msg init`'; } - if (error instanceof UsageError) { - return error.see ? undefined : undefined; - } return undefined; } diff --git a/src/execute.ts b/src/execute.ts index 07253db..c31b3a7 100644 --- a/src/execute.ts +++ b/src/execute.ts @@ -1,5 +1,7 @@ import type { Client } from '@1msg/sdk'; + import type { CliContext } from './context'; + import { type GlobalFlags, resolveChannel } from './config'; import { ApiError, @@ -74,13 +76,23 @@ export function printSent(ctx: CliContext, flags: GlobalFlags, body: unknown, to }); } -export async function emitError(ctx: CliContext, flags: GlobalFlags, error: unknown): Promise { +/** Render the normalized error in the selected output format and return its stable exit code without terminating the process. */ +export async function emitError( + ctx: CliContext, + flags: GlobalFlags, + error: unknown, +): Promise { const normalized = await normalizeThrown(error); if (normalized instanceof CliExit) return normalized.exitCode; const opts = outputOpts(flags, ctx); if (normalized instanceof UsageError) { - printError(ctx.stderr, opts, normalized, { see: normalized.see }); + printError( + ctx.stderr, + opts, + normalized, + normalized.see === undefined ? {} : { see: normalized.see }, + ); return normalized.exitCode; } if (normalized instanceof NotConfiguredError) { @@ -89,15 +101,21 @@ export async function emitError(ctx: CliContext, flags: GlobalFlags, error: unkn } if (normalized instanceof ApiError) { const human = new Error(`${normalized.message} (HTTP ${normalized.status})`); + const hint = hintFor(normalized); printError(ctx.stderr, opts, opts.json ? normalized : human, { - hint: hintFor(normalized), - rawBody: opts.json ? normalized.rawBody || JSON.stringify({ error: normalized.message }) : undefined, - jsonBody: opts.json ? normalized.jsonBody ?? { error: normalized.message } : undefined, + ...(hint === undefined ? {} : { hint }), + ...(opts.json + ? { + rawBody: normalized.rawBody || JSON.stringify({ error: normalized.message }), + jsonBody: normalized.jsonBody ?? { error: normalized.message }, + } + : {}), }); return normalized.exitCode; } printError(ctx.stderr, opts, normalized); - return 'exitCode' in normalized && typeof (normalized as { exitCode: number }).exitCode === 'number' + return 'exitCode' in normalized && + typeof (normalized as { exitCode: number }).exitCode === 'number' ? (normalized as { exitCode: number }).exitCode : 1; } diff --git a/src/flow-program.ts b/src/flow-program.ts new file mode 100644 index 0000000..6cfe91b --- /dev/null +++ b/src/flow-program.ts @@ -0,0 +1,75 @@ +import type { Command } from 'commander'; + +import type { CliContext } from './context'; + +import { applyCommon, bindCommandAction, wrap } from './command-binding'; +import { collect } from './io'; +import { missingSubcommand } from './local'; +import { + handleFlowAssets, + handleFlowCreate, + handleFlowEncryption, + handleFlowEncryptionSet, + handleFlowIdOp, + handleFlowList, + handleFlowMetadata, +} from './rest'; + +/** Register the flow lifecycle command family and its shared account scoping without executing handlers. */ +export function registerFlowCommands(program: Command, ctx: CliContext): void { + const flow = applyCommon(program.command('flow'), ctx, 'flow'); + /** A missing operation prints domain help without issuing an API call. */ + flow.action(wrap(() => missingSubcommand(ctx, 'flow'))); + /** Scope flow operations to an optional WABA account consistently. */ + const flowWaba = (c: Command) => c.option('--waba-account-id '); + const flowList = applyCommon(flow.command('list'), ctx, 'flow'); + flowWaba(flowList); + flowList.option('--fields '); + bindCommandAction(flowList, ctx, handleFlowList); + const flowCreate = applyCommon(flow.command('create'), ctx, 'flow'); + flowWaba(flowCreate); + flowCreate.option('--name '); + flowCreate.option('--category ', 'repeatable', collect, [] as string[]); + flowCreate.option('--endpoint '); + flowCreate.option('--publish'); + flowCreate.option('--json-file '); + bindCommandAction(flowCreate, ctx, handleFlowCreate); + for (const operation of ['get', 'delete', 'preview', 'publish', 'deprecate'] as const) { + const command = applyCommon(flow.command(operation), ctx, 'flow'); + flowWaba(command); + command.argument('[flow-id]'); + /** Bind each immutable operation value to its parsed flow ID and options. */ + command.action( + wrap((id: string | undefined, opts: Parameters[4]) => + handleFlowIdOp(ctx, command, id, operation, opts), + ), + ); + } + const flowMetadata = applyCommon(flow.command('metadata'), ctx, 'flow'); + flowWaba(flowMetadata); + flowMetadata.argument('[flow-id]'); + flowMetadata.option('--name '); + flowMetadata.option('--category ', 'repeatable', collect, [] as string[]); + flowMetadata.option('--endpoint '); + /** Preserve parsed metadata options and flow ID at the Commander boundary. */ + flowMetadata.action( + wrap((id: string | undefined, opts: Parameters[3]) => + handleFlowMetadata(ctx, flowMetadata, id, opts), + ), + ); + const flowAssets = applyCommon(flow.command('assets'), ctx, 'flow'); + flowWaba(flowAssets); + flowAssets.argument('[flow-id]'); + flowAssets.option('--json-file '); + /** Preserve the flow ID and asset file options for upload/download selection. */ + flowAssets.action( + wrap((id: string | undefined, opts: Parameters[3]) => + handleFlowAssets(ctx, flowAssets, id, opts), + ), + ); + const encryption = applyCommon(flow.command('encryption'), ctx, 'flow'); + bindCommandAction(encryption, ctx, handleFlowEncryption); + const encryptionSet = applyCommon(encryption.command('set'), ctx, 'flow'); + encryptionSet.option('--pem-file '); + bindCommandAction(encryptionSet, ctx, handleFlowEncryptionSet); +} diff --git a/src/help-text.ts b/src/help-text.ts index 3d589d2..c103057 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -1,47 +1,82 @@ /* Generated from docs/CLI.md — freeze RFC --help as the acceptance artifact. */ export const HELP_TEXT: Record = { - "root": "1msg — command-line client for the 1msg WhatsApp Business API\n\nUSAGE\n 1msg [flags]\n\nA channel is one WhatsApp instance (instance id + token). Commands use the\ndefault channel from the config file unless --channel is set. Environment\nvariables fill empty fields on that channel; they do not override a named\nchannel that already has a value.\n\nCOMMANDS\n init Interactive setup: config file, API host, instance, token\n send Send a text or file message (alias of message send)\n message Send, list, and mark messages\n template List, send, create, and delete message templates\n channel Local channel switcher; remote status and settings\n me WhatsApp Business profile (GET/POST /me)\n status Connection status (alias of channel status)\n media Upload, fetch, and delete WABA media\n group Create and manage WhatsApp groups\n flow WhatsApp Flows and business encryption key\n webhook Get or set the client webhook URL\n user Block, unblock, and list blocked users\n catalog Catalog and cart commerce settings\n call WhatsApp Calling API (beta, signaling only)\n completion Print shell completion script (bash | zsh | powershell)\n version Print CLI and SDK versions\n\nFLAGS\n -c, --channel Named channel from the config file\n --instance-id Override instance id\n --base-url Override API root\n --token Override token (prefer ONE_MSG_TOKEN)\n --json Print the API response body as JSON\n --no-color Disable ANSI color\n -h, --help Help for this command\n -v, --version Version\n\nEXAMPLES\n $ 1msg init\n $ 1msg channel list\n $ 1msg channel use acme-prod\n $ 1msg channel use ODI371267300\n $ 1msg channel info\n $ 1msg channel info --json\n $ 1msg send --to 12020721369 --text \"Hello\"\n $ 1msg send --to 12020721369 --file ./invoice.pdf --caption \"Invoice\"\n $ 1msg template list\n $ 1msg status --json\n $ 1msg message list --chat-id 12020721369@c.us --limit 20\n\nENVIRONMENT\n ONE_MSG_CHANNEL Same as --channel\n ONE_MSG_BASE_URL https://api.1msg.io or https://sandbox.1msg.io\n ONE_MSG_INSTANCE_ID Channel instance id\n ONE_MSG_TOKEN Channel API token\n\n Deprecated aliases (same as MCP): CHAT_API_BASE_URL, CHAT_API_TOKEN,\n CHAT_API_INSTANCE_ID, CHAT_API_KEY, TOKEN, INSTANCE_ID.\n\nConfig: ~/.config/1msg/config.yaml (Windows: %AppData%\\1msg\\config.yaml)\nDocs: https://docs.1msg.io/\n", - "init": "Create a config file and register the first channel.\n\nUSAGE\n 1msg init [flags]\n\nFLAGS\n --name Channel name in the config file (default: default)\n --base-url https://api.1msg.io or https://sandbox.1msg.io\n (also api-stage.1msg.io / http://127.0.0.1:3050)\n --instance-id Channel instance id\n --token-stdin Read token from stdin (non-interactive)\n --no-verify Skip GET /status after save\n -h, --help\n\nEXAMPLES\n $ 1msg init\n $ printf '%s' \"$ONE_MSG_TOKEN\" | 1msg init --name prod \\\n --base-url https://api.1msg.io --instance-id ODI371267300 --token-stdin\n\nInteractive prompts: name, live vs sandbox, instance id, token (hidden),\nset as default. Writes the config file with mode 0600 and runs 1msg status\nunless --no-verify.\n", - "channel": "Local named channels, and remote status/settings of the selected channel.\n\nUSAGE\n 1msg channel [flags]\n\nThere is no API to list a customer's channels. list / use / add / remove /\ncurrent are always local. Other subcommands call the public API.\n\nLOCAL\n list Channels in the config file (* = default)\n use Set the default channel (see `1msg channel use --help`)\n add Add a channel (see `1msg channel add --help`)\n remove Remove a channel from the config file\n current Print the default channel name and instance id\n\nREMOTE\n info GET /status + GET /me (human summary, or one JSON object)\n status GET /status\n settings GET /settings\n settings set POST /settings (only flags you pass are sent)\n mm-lite GET /mmLiteStatus\n automation GET /conversationalAutomation\n automation set POST /conversationalAutomation\n\nEXAMPLES\n $ 1msg channel add --name acme-test --base-url https://sandbox.1msg.io \\\n --instance-id ODI… --token-stdin\n $ 1msg channel use acme-test\n $ 1msg channel use ODI371267300\n $ 1msg channel list\n $ 1msg channel current\n $ 1msg -c acme-prod channel info\n $ 1msg channel info --json\n $ 1msg channel status --json\n $ 1msg me --json\n $ 1msg channel settings set --ack-notifications --no-guaranteed-hooks\n\nCOLUMNS (channel list)\n default name instance_id base_url\n\n`channel info` calls GET /status then GET /me. `--json` prints one object:\n\n { \"status\": , \"me\": }\n\nThis is the only CLI-built JSON (not a single server body). If either call\nfails, do not print a partial object: emit that call's error (raw body with\n`--json`) and that HTTP status; skip the second call if the first failed.\n`-c` / `--channel` take a config name, not an instance id.\n\n\nFLAGS (settings set)\n --webhook-url Repeatable, max 5; empty string clears webhooks\n --guaranteed-hooks Retry webhooks until acknowledged\n --no-guaranteed-hooks\n --ack-notifications Include ACK / status events\n --no-ack-notifications\n --raw-hooks Forward raw provider payloads\n --no-raw-hooks\n --template-analytics Enable template analytics\n --no-template-analytics\n --cta-tracking-opt-out Opt out of CTA URL click tracking\n --no-cta-tracking-opt-out\n\nFLAGS (automation set)\n --welcome Enable welcome message\n --no-welcome\n --prompt Repeatable, max 4, each ≤ 80 chars\n --command Repeatable slash commands\n\nFLAGS\n -c, --channel Selects which config channel (does not filter `list`)\n -h, --help\n", - "channel use": "Set the default channel in the config file. Local only; no API call.\n\nUSAGE\n 1msg channel use \n\nLOOKUP\n 1. Exact key under channels: in the config file (e.g. acme-prod)\n 2. Else unique instance_id among channels\n 3. Zero matches: exit 1, \"channel \\\"…\\\" not in config\"\n 4. Two or more names share that instance_id: exit 1,\n \"instance id … matches NAME and NAME; pass a config name\"\n\n`--channel` / ONE_MSG_CHANNEL / tab-completion still use config names.\n`remove` takes a config name only.\n\nEXAMPLES\n $ 1msg channel use acme-test\n $ 1msg channel use ODI371267300\n", - "channel info": "Human summary of the selected channel: GET /status then GET /me.\n\nUSAGE\n 1msg channel info [flags]\n\nFLAGS\n --json One object: { \"status\": , \"me\": }\n -c, --channel \n -h, --help\n\nEXAMPLES\n $ 1msg channel info\n $ 1msg channel info --json\n $ 1msg -c acme-prod channel info --json\n\nWithout --json: key-value from both responses (status, accountStatus,\nmm_lite_available, then profile fields from /me).\nWith --json: the two raw bodies nested under \"status\" and \"me\". Not a server\nschema — CLI-only. A single endpoint remains `1msg status --json` / `1msg me --json`.\n", - "channel add": "Add a named channel to the config file. Does not change the default unless\nthis is the first channel (then it becomes default).\n\nUSAGE\n 1msg channel add [flags]\n\nFLAGS\n --name Required. Config name (e.g. acme-prod), not instance id\n --base-url https://api.1msg.io | https://sandbox.1msg.io\n Also accepts https://api-stage.1msg.io and http://127.0.0.1:3050\n --instance-id Required unless ONE_MSG_INSTANCE_ID is set\n --token-stdin Read token from stdin\n --default Make this channel the default\n --no-verify Skip GET /status after save\n -h, --help\n\nEXAMPLES\n $ 1msg channel add --name acme-test --base-url https://sandbox.1msg.io \\\n --instance-id ODI371267301 --token-stdin\n $ 1msg channel add --name prod --base-url https://api.1msg.io \\\n --instance-id ODI371267300 --token-stdin --default\n\nNon-interactive: --name, --base-url, --instance-id, and a token (stdin or env)\nare required. Interactive prompts match `1msg init` when flags are omitted.\nWrites ~/.config/1msg/config.yaml mode 0600.\n", - "status": "USAGE\n 1msg status [flags]\n 1msg channel status [flags]\n\nPrints connection status (typically `connected` when the number can send),\naccountStatus, and mm_lite_available.\n\nEXAMPLES\n $ 1msg status\n $ 1msg status --json\n $ 1msg -c acme-test status\n\nCOLUMNS (human)\n key-value: status, accountStatus, mm_lite_available\n", - "send": "Send a text or file message (session window must be open).\nOutside 24h use: 1msg template send\n\nUSAGE\n 1msg send [flags]\n 1msg message send [flags]\n\nExactly one destination: --to or --chat-id.\nExactly one body: --text, --file, or --media-id.\n\nFLAGS\n --to \n --chat-id \n --text UTF-8 text (max 4096) → sendMessage\n --file Local file or https URL → sendFile\n --caption Caption when sending a file (max 1024)\n --filename File name with extension. Required with an\n https --file URL (API requires filename with\n body). Optional for a local path (default:\n basename). Ignored with --media-id.\n --media-id WABA media id from `1msg media upload`\n --media-type image | video | audio | document (required with --media-id)\n --voice Native voice note (audio/ogg only)\n --quote \n -h, --help\n\nEXAMPLES\n $ 1msg send --to 12020721369 --text \"Hello\"\n $ 1msg send --chat-id 12020721369@c.us --text \"Hello\" --quote wamid.HBgN…\n $ 1msg send --to 12020721369 --file ./photo.jpg --caption \"Look\"\n $ 1msg send --to 12020721369 --file https://example.com/a.pdf --filename invoice.pdf\n $ 1msg send --to 12020721369 --media-id 123456 --media-type image\n\nA local --file is read from disk, encoded as a data URI, and sent as\nsendFile.body. An https URL is passed through as sendFile.body and **requires\n--filename**. This does not call uploadMedia unless you run `1msg media upload`\nyourself. Exit 1 if --file is an https URL without --filename.\n\n`1msg send` is only sendMessage + sendFile. The other 15 public send\noperations are under `1msg message --help` and `1msg template send`.\n", - "message send": "Send a text or file message (session window must be open).\nOutside 24h use: 1msg template send\n\nUSAGE\n 1msg send [flags]\n 1msg message send [flags]\n\nExactly one destination: --to or --chat-id.\nExactly one body: --text, --file, or --media-id.\n\nFLAGS\n --to \n --chat-id \n --text UTF-8 text (max 4096) → sendMessage\n --file Local file or https URL → sendFile\n --caption Caption when sending a file (max 1024)\n --filename File name with extension. Required with an\n https --file URL (API requires filename with\n body). Optional for a local path (default:\n basename). Ignored with --media-id.\n --media-id WABA media id from `1msg media upload`\n --media-type image | video | audio | document (required with --media-id)\n --voice Native voice note (audio/ogg only)\n --quote \n -h, --help\n\nEXAMPLES\n $ 1msg send --to 12020721369 --text \"Hello\"\n $ 1msg send --chat-id 12020721369@c.us --text \"Hello\" --quote wamid.HBgN…\n $ 1msg send --to 12020721369 --file ./photo.jpg --caption \"Look\"\n $ 1msg send --to 12020721369 --file https://example.com/a.pdf --filename invoice.pdf\n $ 1msg send --to 12020721369 --media-id 123456 --media-type image\n\nA local --file is read from disk, encoded as a data URI, and sent as\nsendFile.body. An https URL is passed through as sendFile.body and **requires\n--filename**. This does not call uploadMedia unless you run `1msg media upload`\nyourself. Exit 1 if --file is an https URL without --filename.\n\n`1msg send` is only sendMessage + sendFile. The other 15 public send\noperations are under `1msg message --help` and `1msg template send`.\n", - "message": "Send, list, and mark messages.\n\nUSAGE\n 1msg message [flags]\n\nCOMMANDS\n send Text or file (see 1msg send)\n list Stored chat history (GET /messages)\n read Mark a message as read (POST /readMessage)\n react Send or remove a reaction\n location Send a location pin\n location-request Ask the user to share location\n contact Send a contact card\n buttons Interactive reply buttons (max 3)\n menu Interactive list message (WhatsApp list)\n carousel Carousel\n product Product message\n cta CTA URL button\n address Address message\n order Order details\n payment Payment request\n sticker Sticker\n flow Send a Flow message\n\nSEND FORMATS (all 17 public send* operations)\n send --text POST /sendMessage\n send --file|--media-id POST /sendFile (url, data URI, mediaId, --voice)\n template send POST /sendTemplate (Cloud API components, MM Lite)\n react POST /sendReaction\n location POST /sendLocation\n location-request POST /sendLocationRequest\n contact POST /sendContact\n buttons POST /sendButton\n menu POST /sendList\n carousel POST /sendCarousel\n product POST /sendProduct\n cta POST /sendCtaUrl\n address POST /sendAddressMessage\n order POST /sendOrderDetails\n payment POST /sendPaymentRequest\n sticker POST /sendSticker\n flow POST /sendFlow\n\n`message list` is inbox history. WhatsApp interactive lists are `message menu`.\nCarousel-as-template (no 24h window) is `1msg template send --params`, not\n`message carousel`.\n\nEXAMPLES\n $ 1msg message send --to 12020721369 --text \"Hello\"\n $ 1msg message list --chat-id 12020721369@c.us --limit 20\n $ 1msg message location --to 12020721369 --lat 55.7558 --lng 37.6173\n $ 1msg message buttons --to 12020721369 --text \"Choose:\" --button yes:Yes\n $ 1msg template send --to 12020721369 --name hello_world --lang en\n", - "message list": "USAGE\n 1msg message list [flags]\n\nFLAGS\n --chat-id Filter one chat\n --limit Max rows (API default 100)\n --last Latest first (DESC)\n --msg-id Filter by stored message id\n --min-time Lower bound\n --max-time Upper bound\n --first-number \n --last-number \n\nCOLUMNS\n time chat_id from type id body\n\nEXAMPLES\n $ 1msg message list\n $ 1msg message list --chat-id 12020721369@c.us --limit 20 --last\n $ 1msg message list --limit 5 --json\n", - "message read": "USAGE\n 1msg message read --message-id [flags]\n\nFLAGS\n --message-id Required. API field messageId / msgId\n --typing Show typing indicator (max 25s)\n\nThe read API does not take a chat id.\n\nEXAMPLES\n $ 1msg message read --message-id wamid.HBgNMTIwMjA3MjEzNjkVAgARGBI5RTBCNUY0QUE2RjZBQzhGNDkA\n $ 1msg message read --message-id wamid.… --typing\n", - "message react": "USAGE\n 1msg message react --message-id --emoji [flags]\n\nFLAGS\n --to / --chat-id Required destination\n --message-id Target message → API quotedMsgId\n --emoji Reaction → API body. Empty string removes it\n\nEXAMPLES\n $ 1msg message react --to 12020721369 --message-id wamid.… --emoji \"👍\"\n $ 1msg message react --chat-id 12020721369@c.us --message-id wamid.… --emoji \"\"\n", - "message location": "USAGE\n 1msg message location --lat --lng [flags]\n\nFLAGS\n --to / --chat-id / --quote\n --lat Required, -90..90\n --lng Required, -180..180\n --name \n --address \n\nEXAMPLES\n $ 1msg message location --to 12020721369 --lat 55.7558 --lng 37.6173 \\\n --name \"Red Square\" --address \"Moscow\"\n", - "message location-request": "USAGE\n 1msg message location-request [flags]\n\nFLAGS\n --to / --chat-id / --quote\n --text Optional prompt\n\nEXAMPLES\n $ 1msg message location-request --to 12020721369 --text \"Share your location\"\n", - "message contact": "USAGE\n 1msg message contact --name --phone [flags]\n\nFLAGS\n --to / --chat-id / --quote\n --name Required (formatted_name)\n --first-name \n --last-name \n --phone Contact's phone (not the destination)\n --email \n --org \n --title \n --url \n --from-file Full contact JSON (SendContactRequest)\n\nEXAMPLES\n $ 1msg message contact --to 12020721369 --name \"John Doe\" --phone +12020721369\n $ 1msg message contact --to 12020721369 --from-file contact.json\n", - "message buttons": "USAGE\n 1msg message buttons --text --button [flags]\n\nFLAGS\n --to / --chat-id / --quote\n --text Required, max 1024\n --footer Max 60\n --button Repeatable, max 3\n --from-file Full SendButtonRequest JSON. Optional\n interactive header (text/image/video/document)\n is only via --from-file, not a flag.\n\nEXAMPLES\n $ 1msg message buttons --to 12020721369 --text \"Choose:\" \\\n --button yes:Yes --button no:No\n $ 1msg message buttons --to 12020721369 --from-file buttons.json\n", - "message menu": "USAGE\n 1msg message menu --from-file [flags]\n\nAPI requires body, buttonText, and sections[]. sections[] is nested JSON,\nso --from-file is required. --text / --button-text / --title / --footer\noverride the file when set. Destination may be on the CLI or in the file.\n\nFLAGS\n --to / --chat-id / --quote\n --from-file Required. `-` = stdin. Public sendList body\n --text Override body (max 1024)\n --button-text Override list button label (max 20)\n --title Max 60\n --footer Max 60\n\nEXAMPLES\n $ 1msg message menu --to 12020721369 --from-file menu.json\n $ 1msg message menu --from-file menu.json --text \"Pick one\" --button-text \"Open\"\n", - "message carousel": "USAGE\n 1msg message carousel --from-file [flags]\n\nFLAGS\n --to / --chat-id / --quote\n --text Text above the carousel\n --from-file Required. JSON with cards[] and/or params[]\n\nEXAMPLES\n $ 1msg message carousel --to 12020721369 --from-file carousel.json\n $ 1msg message carousel --to 12020721369 --text \"Pick a product\" --from-file cards.json\n", - "message product": "USAGE\n 1msg message product [flags]\n\nExactly one of: --from-file, or --catalog-id plus --product-id (single product).\nProduct lists use --from-file (action.sections).\n\nFLAGS\n --to / --chat-id / --quote\n --text \n --header Product list only\n --footer \n --catalog-id \n --product-id Single product (action.product_retailer_id)\n --from-file Full body including action\n\nEXAMPLES\n $ 1msg message product --to 12020721369 --catalog-id 448008021021719 --product-id 0001\n $ 1msg message product --to 12020721369 --from-file product-list.json\n", - "message cta": "USAGE\n 1msg message cta --text --button