diff --git a/README.md b/README.md index 37535f9..c574664 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Bolt is built for human developers writing TypeScript. This CLI is built for cod - **No interactive prompts, no ASCII art.** Every successful response is a single line of JSON on stdout. Errors are JSON on stdout *and* a human-readable line on stderr. - **Exhaustive surface.** The agent has access to the full Slack Web API — not a hand-picked subset. Capability boundaries are enforced through **bot token scopes**, not through code. -- **Bot tokens only.** Uses `SLACK_BOT_TOKEN` exclusively. There is no user-OAuth flow because there is no human in the loop. +- **Two transports, one interface.** Direct mode calls Slack with `SLACK_BOT_TOKEN`; proxy mode routes the same `{method, args}` calls through a Nori Sessions broker using `NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN`, so managed sessions never hold a raw bot token. There is no user-OAuth flow because there is no human in the loop. - **Self-locating errors.** Every error response includes a `source` field with the on-disk path to the CLI, so an agent can read the source code to debug. - **Install from npm.** `npm install -g nori-slack-cli` puts `nori-slack` on your `PATH`. Cloning and building from source is also supported for contributors. @@ -32,10 +32,15 @@ npm run build npm link # makes `nori-slack` available globally ``` -Then set your bot token: +Then set credentials for one of the two transports (see [Authentication](#authentication)): ```bash +# Direct mode export SLACK_BOT_TOKEN=xoxb-... + +# Proxy mode (set automatically inside Nori Sessions) +export NORI_SLACK_PROXY_URL=https://broker.example.com/api/slack-proxy +export NORI_SLACK_CONTEXT_TOKEN=... ``` ## Usage @@ -83,12 +88,21 @@ nori-slack describe chat.postMessage ### Exit codes - `0` — success -- `1` — Slack API error or missing token +- `1` — Slack API error, proxy error, or missing credentials - `2` — bad CLI usage (missing args, invalid stdin JSON) ## Authentication -Set `SLACK_BOT_TOKEN` in the environment. The CLI does not read tokens from any other source. To control what the agent can do, scope the bot token in the Slack app's OAuth & Permissions page — the CLI itself imposes no method-level restrictions. +The CLI supports two transports, selected from the environment: + +| Mode | Environment | Behavior | +| --- | --- | --- | +| **Proxy** | `NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN` | POSTs `{method, args}` to `/method` with the context token as a bearer token. Used inside Nori Sessions, where the broker enforces a per-session access grant and the raw bot token never reaches the machine. | +| **Direct** | `SLACK_BOT_TOKEN` | Calls the Slack Web API directly via `@slack/web-api`. | + +When both are configured, **proxy mode wins**. All CLI features (`--json-input`, `--paginate`, `--dry-run`, kebab-case conversion, type coercion, error suggestions) behave identically in both modes. `--dry-run` reports which transport would be used via the `transport` field (`proxy`, `direct`, or `none`). + +In direct mode, capability boundaries come from the bot token's OAuth scopes. In proxy mode, the broker additionally restricts methods and channels to the session's access grant — requests outside the grant fail with a structured `proxy_error`. ## License diff --git a/docs.md b/docs.md index ce50b8c..7e13320 100644 --- a/docs.md +++ b/docs.md @@ -5,7 +5,7 @@ Path: @/ ### Overview - A TypeScript CLI that exposes the entire Slack Web API as a single command: `nori-slack [--param value ...]` - Designed for coding agents: all output is JSON on stdout, human-readable errors go to stderr -- Uses `@slack/web-api` WebClient for dynamic dispatch -- the CLI is not limited to a fixed set of methods +- Supports two transports, resolved in [src/transport.ts](src/transport.ts): direct calls to Slack via the `@slack/web-api` WebClient, or a Nori Sessions broker proxy. Dispatch is dynamic in both modes -- the CLI is not limited to a fixed set of methods - Supports automatic cursor pagination via `--paginate`, which fetches all pages and returns a single merged JSON response - Supports `--dry-run` to preview resolved API requests without sending them -- designed as a safety net for coding agents to validate parameter resolution - Supports `describe ` to look up parameter documentation for any Slack API method without requiring a token -- the metadata map covers all methods in `KNOWN_METHODS`, so agents always get full parameter documentation rather than a fallback @@ -13,16 +13,17 @@ Path: @/ ### How it fits into the larger codebase - Standalone repository (was originally imported from the `nori-integrations` monorepo and now lives on its own). Distributed via the public npm registry as `nori-slack-cli` - The canonical install path is `npm install -g nori-slack-cli`, which places the `nori-slack` binary on `PATH`; `npm link` from a local clone is retained for contributors -- Authentication is bot-token-only via `SLACK_BOT_TOKEN` environment variable (no user OAuth flows) +- Two credential modes, no user OAuth flows: direct mode via the `SLACK_BOT_TOKEN` environment variable, and proxy mode via `NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN` (both must be set; Nori session machines export them). Proxy mode takes precedence when both credential sets are present +- Proxy mode exists so Nori Sessions can route Slack calls through its broker's scoped access grants. It replaced a separate hand-rolled proxy client script in the sessions repo, consolidating two diverging implementations of the same command behind this one CLI - The CLI is a thin wrapper -- it does not contain business logic, scheduling, or state management; it translates CLI flags into Slack API calls and returns the raw JSON response -- The pagination merge logic in [src/paginate.ts](src/paginate.ts) is a pure function decoupled from the Slack SDK -- it operates on any `AsyncIterable` of page objects +- The pagination logic in [src/paginate.ts](src/paginate.ts) is decoupled from the Slack SDK -- the cursor loop talks only to the `Transport` interface, and the merge step operates on any `AsyncIterable` of page objects - User-facing installation and usage documentation lives in [README.md](README.md) ### Core Implementation - Entry point is [src/index.ts](src/index.ts), which uses Commander.js with `allowUnknownOption()` so arbitrary `--flag value` pairs pass through without Commander rejecting them -- The dynamic handler has three code paths: `--dry-run` short-circuits after param resolution (no token required, no API call), `--paginate` triggers `WebClient.paginate()` + `mergePages()`, and the default path uses `WebClient.apiCall()` +- The dynamic handler has three code paths: `--dry-run` short-circuits after param resolution (no credentials required, no API call, reports which transport would be used), `--paginate` runs the generic cursor loop `paginatePages()` + `mergePages()` from [src/paginate.ts](src/paginate.ts), and the default path makes a single `transport.call()`. The transport is resolved once per invocation and both API paths route through it, so behavior (including pagination) is identical in proxy and direct mode - Two input modes: CLI flags (`--channel C123 --text "hi"`) and piped JSON via `--json-input`; when both are provided, CLI flags override stdin values -- Two discovery subcommands that do not require `SLACK_BOT_TOKEN`: `list-methods` outputs known method names as JSON (supports `--namespace` filtering and `--descriptions` to include method descriptions), and `describe ` returns structured parameter documentation +- Two discovery subcommands that do not require credentials: `list-methods` outputs known method names as JSON (supports `--namespace` filtering and `--descriptions` to include method descriptions), and `describe ` returns structured parameter documentation - `describe` uses [src/method-metadata.ts](src/method-metadata.ts), a hand-curated static map covering every method in `KNOWN_METHODS` -- this is static because `@slack/web-api` erases parameter type information at compile time, so runtime introspection is not possible - For unknown methods (not in `KNOWN_METHODS`), `getMethodMetadata()` returns a fallback entry with empty params and a generated docs URL, so `describe` never errors; the `known` field in the output distinguishes curated entries from fallbacks - When an unknown method is used, [src/suggest.ts](src/suggest.ts) provides fuzzy matching via Levenshtein distance against `KNOWN_METHODS`, surfacing "Did you mean?" suggestions; suggestions are non-blocking -- unknown methods still proceed to the API @@ -50,7 +51,7 @@ dist/ (gitignored) - Flag parsing in [src/parse-args.ts](src/parse-args.ts) converts `--kebab-case` to `snake_case` because the Slack API uses snake_case parameter names - Type coercion in `coerceValue` handles booleans (`"true"`/`"false"`), numbers (but preserves leading-zero strings like `"007"`), and inline JSON arrays/objects - A standalone `--flag` with no following value (or followed by another `--flag`) is treated as boolean `true` -- Error formatting in [src/errors.ts](src/errors.ts) maps Slack error codes to actionable suggestions (e.g., `channel_not_found` suggests running `conversations.list`); unknown errors get a generic suggestion pointing to the source directory +- Error formatting in [src/errors.ts](src/errors.ts) maps Slack error codes to actionable suggestions (e.g., `channel_not_found` suggests running `conversations.list`); unknown errors get a generic suggestion pointing to the source directory. Broker errors from proxy mode are normalized into the same envelope, including extracting Slack platform codes embedded in broker messages - Every error response includes a `source` field with the filesystem path to the CLI, so agents can locate the source code for debugging - The method metadata in [src/method-metadata.ts](src/method-metadata.ts) marks `files.upload` as deprecated with a pointer to the two-step `files.getUploadURLExternal` + `files.completeUploadExternal` flow - The CLI version string is currently duplicated: once in [package.json](package.json) `version` and once as a hardcoded argument to Commander's `.version()` call in [src/index.ts](src/index.ts). Both must be bumped together on release diff --git a/package-lock.json b/package-lock.json index 1d5a6ee..d87e6b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "nori-slack-cli", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nori-slack-cli", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@slack/web-api": "^7.0.0", - "commander": "^13.0.0", - "nori-slack-cli": "^0.1.0" + "commander": "^13.0.0" }, "bin": { "nori-slack": "dist/index.js" @@ -1569,18 +1568,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/nori-slack-cli": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/nori-slack-cli/-/nori-slack-cli-0.1.0.tgz", - "integrity": "sha512-gGaPCv9igHessBfU41arpjxsOfqYl23Y5KvLYPcVMyy+gagU5bNYZRxPbtVNMh67C+bAKUBNYj2HS7lJFQmOzg==", - "dependencies": { - "@slack/web-api": "^7.0.0", - "commander": "^13.0.0" - }, - "bin": { - "nori-slack": "dist/index.js" - } - }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", diff --git a/package.json b/package.json index bddec21..e19f9e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nori-slack-cli", - "version": "0.1.1", + "version": "0.2.0", "description": "CLI for interacting with the Slack Web API, designed for coding agents", "type": "module", "bin": { @@ -18,8 +18,7 @@ }, "dependencies": { "@slack/web-api": "^7.0.0", - "commander": "^13.0.0", - "nori-slack-cli": "^0.1.0" + "commander": "^13.0.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/src/docs.md b/src/docs.md index 6ca5002..6543815 100644 --- a/src/docs.md +++ b/src/docs.md @@ -3,12 +3,13 @@ Path: @/src ### Overview -- Contains all source modules for the CLI: entry point, argument parsing, error formatting, pagination merging, fuzzy method suggestion, the known-methods catalog, and the method metadata registry +- Contains all source modules for the CLI: entry point, argument parsing, transport selection, error formatting, pagination, fuzzy method suggestion, the known-methods catalog, and the method metadata registry - Compiles from `src/` to `dist/` via TypeScript (ES2022 target, Node16 module resolution) ### How it fits into the larger codebase - [index.ts](index.ts) is the CLI entry point (shebang `#!/usr/bin/env node`), compiled to `dist/index.js` and exposed as the `nori-slack` binary via the `bin` field in [../package.json](../package.json). The compiled `dist/` directory is produced at pack time by the `prepare` script and shipped to the npm registry via the `files` allowlist -- see [../docs.md](../docs.md) for the full packaging chain -- [parse-args.ts](parse-args.ts), [errors.ts](errors.ts), and [paginate.ts](paginate.ts) are pure utility modules with no side effects -- they are independently testable and tested in [@/test](../test/) +- [transport.ts](transport.ts) is the only module that knows how to reach Slack. It selects between proxy mode (a Nori Sessions broker, configured by `NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN`) and direct mode (`SLACK_BOT_TOKEN` via `@slack/web-api`); everything downstream works against its `Transport` interface +- [parse-args.ts](parse-args.ts) and [errors.ts](errors.ts) are pure utility modules with no side effects; [paginate.ts](paginate.ts) is transport-generic (no Slack SDK dependency). All are independently testable and tested in [@/test](../test/) - [methods.ts](methods.ts) is a static data file; it is only used by the `list-methods` subcommand and has no effect on which methods the CLI can actually call ### Core Implementation @@ -16,13 +17,21 @@ Path: @/src **Entry point (`index.ts`)** - Sets up Commander with three subcommands: `list-methods`, `describe`, and the default dynamic method handler - The dynamic handler: optionally reads JSON from stdin, parses CLI flags, merges params (CLI flags win over stdin), then branches into three paths: - 1. `--dry-run`: short-circuits immediately after param resolution -- outputs a JSON preview with `ok`, `dry_run`, `method`, `params`, `token_present`, `paginate`, and optionally a `warning` for unknown methods. Does not require a token. Always exits 0. - 2. `--paginate`: validates token, then calls `client.paginate()` + `mergePages()` - 3. Default: validates token, then calls `client.apiCall()` + 1. `--dry-run`: short-circuits immediately after param resolution -- outputs a JSON preview with `ok`, `dry_run`, `method`, `params`, `transport`, `token_present`, `paginate`, and optionally a `warning` for unknown methods. Does not require credentials. Always exits 0. + 2. `--paginate`: resolves the transport via `resolveTransport()`, then runs `mergePages(paginatePages(transport, method, params))` + 3. Default: resolves the transport, then makes a single `transport.call(method, params)` +- If `resolveTransport()` returns null (no credentials in either mode), the handler emits the `no_token` error envelope and exits 1 before any API path runs - When no arguments are provided (`process.argv.length <= 2`), help text and error go to stderr and the process exits with code 2 - The `list-methods` subcommand supports two options that compose together: `--namespace ` filters the method list to those starting with the given prefix (e.g., `chat.`), and `--descriptions` changes the output shape from `string[]` to `Array<{ method, description }>` by pulling descriptions from `getMethodMetadata()`. When `--namespace` is provided, a `namespace` field is added to the response JSON. -**Pagination merging (`paginate.ts`)** +**Transport selection (`transport.ts`)** +- `detectTransportMode(env)` returns `'proxy' | 'direct' | 'none'`: proxy when both `NORI_SLACK_PROXY_URL` and `NORI_SLACK_CONTEXT_TOKEN` are non-empty, otherwise direct when `SLACK_BOT_TOKEN` is set, otherwise none. Proxy takes precedence over a bot token +- `resolveTransport(env)` returns a `Transport` (`{ mode, call(method, params) }`) or `null` when no credentials are available +- Proxy `call` POSTs `{ method, args }` as JSON to `/method` (trailing slashes are stripped from the configured URL first) with an `authorization: Bearer ` header. A 2xx response is returned as the raw Slack JSON body; a non-2xx response throws `ProxyError` (code `nori_slack_proxy_error`) carrying the HTTP status and the broker's `error` message +- Direct `call` wraps `WebClient.apiCall` from `@slack/web-api` + +**Pagination (`paginate.ts`)** +- `paginatePages(transport, method, params)` is an async generator that repeatedly calls the transport, following `response_metadata.next_cursor` and terminating when the cursor is empty or missing. It replaces the old `WebClient.paginate()` path so pagination works identically over both transports - `mergePages(pages)` takes an `AsyncIterable` of page objects and returns a single merged object - Array-valued keys are concatenated across pages; scalar/metadata keys (`ok`, `response_metadata`, `headers`, `warning`) are overwritten with the last page's value - This design means the function works generically with any Slack method's response shape -- it does not need to know which key holds the data (e.g., `channels`, `members`, `messages`) @@ -34,8 +43,10 @@ Path: @/src **Error formatting (`errors.ts`)** - `formatError(error, sourceDir)` returns a `CliError` object with fields: `ok`, `error`, `message`, `suggestion`, `source` -- Handles four specific `@slack/web-api` error codes: `slack_webapi_platform_error`, `slack_webapi_rate_limited_error`, `slack_webapi_request_error`, and the custom `no_token` -- The `SUGGESTIONS` map provides agent-friendly remediation text for common Slack platform errors like `channel_not_found`, `not_in_channel`, `invalid_auth`, `rate_limited`, etc. +- Handles five specific error codes: the `@slack/web-api` codes `slack_webapi_platform_error`, `slack_webapi_rate_limited_error`, and `slack_webapi_request_error`, plus the custom `no_token` and the proxy transport's `nori_slack_proxy_error` +- For `nori_slack_proxy_error`: broker messages of the form "An API error occurred: \" have the Slack platform code extracted and mapped through the same `SUGGESTIONS` table as direct-mode platform errors; HTTP 401 maps to `proxy_unauthorized` with a context-token rotation suggestion; any other status maps to `proxy_error` with a suggestion about the session's access grant +- Broker wire contract behind those mappings: 200 returns raw Slack JSON; error statuses (e.g., 401, 403, 404) return `{ error: message }` +- The `SUGGESTIONS` map provides agent-friendly remediation text for common Slack platform errors like `channel_not_found`, `not_in_channel`, `rate_limited`, etc. **Fuzzy method suggestion (`suggest.ts`)** - `findSimilarMethods(input, methods?, maxResults?)` returns up to 3 similar method names from `KNOWN_METHODS` for typo correction @@ -60,5 +71,7 @@ Path: @/src - When both stdin JSON and CLI flags provide the same key, the CLI flag value wins due to spread order: `{ ...stdinParams, ...cliParams }` - Non-flag arguments (tokens not starting with `--`) are silently skipped by `parseArgs` -- they do not cause errors - Rate limit errors extract `retryAfter` from the `@slack/web-api` error object and include the retry duration in the message +- The `--dry-run` output's `token_present` field only reflects `SLACK_BOT_TOKEN`; the `transport` field is the authoritative indicator of which mode would be used (proxy wins when both credential sets are present) +- The missing-credentials error keeps the error code `no_token` for backward compatibility, but its message ("No Slack credentials provided.") and suggestion cover both credential sets Created and maintained by Nori. diff --git a/src/errors.ts b/src/errors.ts index 6c649b0..aa48932 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -11,7 +11,7 @@ const SUGGESTIONS: Record = { not_in_channel: 'The bot is not in this channel. Use `nori-slack conversations.join --channel ` first.', invalid_auth: 'The bot token is invalid. Check that SLACK_BOT_TOKEN is set to a valid xoxb-* token.', missing_scope: 'The bot token lacks a required scope. Check your Slack app permissions at https://api.slack.com/apps.', - no_token: 'Set the SLACK_BOT_TOKEN environment variable. Example: export SLACK_BOT_TOKEN=xoxb-your-token', + no_token: 'Set the SLACK_BOT_TOKEN environment variable (direct mode, xoxb-* token), or NORI_SLACK_PROXY_URL + NORI_SLACK_CONTEXT_TOKEN (session proxy mode).', account_inactive: 'The token belongs to a deactivated account. Generate a new token.', token_revoked: 'The bot token has been revoked. Generate a new token from your Slack app settings.', channel_not_found_or_not_accessible: 'The channel does not exist or the bot cannot access it. Use `nori-slack conversations.list` to check.', @@ -29,12 +29,48 @@ export function formatError(error: unknown, sourceDir: string): CliError { return { ok: false, error: 'no_token', - message: 'No Slack bot token provided.', + message: 'No Slack credentials provided.', suggestion: SUGGESTIONS.no_token, source: sourceDir, }; } + if (err?.code === 'nori_slack_proxy_error') { + const status: number = err.status; + const brokerMessage: string = err.message || 'Unknown proxy error'; + + // The broker surfaces Slack platform errors as "An API error occurred: ". + const platformMatch = /An API error occurred: ([a-z0-9_]+)/.exec(brokerMessage); + if (platformMatch) { + const slackError = platformMatch[1]; + return { + ok: false, + error: slackError, + message: `Slack API error: ${slackError}`, + suggestion: SUGGESTIONS[slackError] || `Refer to Slack API docs for error "${slackError}". Run \`nori-slack list-methods\` for available methods.`, + source: sourceDir, + }; + } + + if (status === 401) { + return { + ok: false, + error: 'proxy_unauthorized', + message: `Slack proxy rejected the request (HTTP 401): ${brokerMessage}`, + suggestion: 'The session proxy rejected the context token. Check that NORI_SLACK_CONTEXT_TOKEN is current; it may have been rotated.', + source: sourceDir, + }; + } + + return { + ok: false, + error: 'proxy_error', + message: `Slack proxy error (HTTP ${status}): ${brokerMessage}`, + suggestion: "This session's Slack access grant does not permit the request. Stay within the session's conversation, or use direct mode with SLACK_BOT_TOKEN.", + source: sourceDir, + }; + } + if (err?.code === 'slack_webapi_platform_error') { const slackError = err.data?.error || 'unknown_platform_error'; return { diff --git a/src/index.ts b/src/index.ts index d7d84f8..ced0040 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ #!/usr/bin/env node import { Command } from 'commander'; -import { WebClient } from '@slack/web-api'; import { parseArgs } from './parse-args.js'; import { formatError } from './errors.js'; import { KNOWN_METHODS } from './methods.js'; -import { mergePages } from './paginate.js'; +import { mergePages, paginatePages } from './paginate.js'; +import { detectTransportMode, resolveTransport } from './transport.js'; import { getMethodMetadata, METHOD_METADATA } from './method-metadata.js'; import { findSimilarMethods } from './suggest.js'; import { fileURLToPath } from 'node:url'; @@ -19,7 +19,7 @@ const program = new Command(); program .name('nori-slack') .description('CLI for the Slack Web API. Designed for coding agents.\n\nUsage: nori-slack [--param value ...]\n\nExamples:\n nori-slack chat.postMessage --channel C123 --text "Hello"\n nori-slack conversations.list --limit 10\n nori-slack api.test --foo bar\n echo \'{"channel":"C123","text":"hi"}\' | nori-slack chat.postMessage --json-input') - .version('0.1.1'); + .version('0.2.0'); program .command('list-methods') @@ -80,8 +80,6 @@ program .allowUnknownOption(true) .allowExcessArguments(true) .action(async (method: string, opts: Record) => { - const token = process.env.SLACK_BOT_TOKEN; - let params: Record = {}; if (opts.jsonInput) { @@ -125,7 +123,8 @@ program dry_run: true, method, params, - token_present: !!token, + transport: detectTransportMode(), + token_present: !!process.env.SLACK_BOT_TOKEN, paginate: !!opts.paginate, }; if (!KNOWN_METHODS.includes(method)) { @@ -140,7 +139,8 @@ program return; } - if (!token) { + const transport = resolveTransport(); + if (!transport) { const error = formatError({ code: 'no_token' }, SOURCE_DIR); process.stdout.write(JSON.stringify(error) + '\n'); process.exit(1); @@ -153,14 +153,12 @@ program } } - const client = new WebClient(token); - try { let result; if (opts.paginate) { - result = await mergePages(client.paginate(method, params)); + result = await mergePages(paginatePages(transport, method, params)); } else { - result = await client.apiCall(method, params); + result = await transport.call(method, params); } process.stdout.write(JSON.stringify(result) + '\n'); } catch (err) { diff --git a/src/paginate.ts b/src/paginate.ts index 1584e15..f28b7dc 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -1,5 +1,21 @@ +import type { Transport } from './transport.js'; + const METADATA_KEYS = new Set(['ok', 'response_metadata', 'headers', 'warning']); +export async function* paginatePages( + transport: Transport, + method: string, + params: Record +): AsyncIterable> { + let cursor: string | undefined; + do { + const page = await transport.call(method, cursor ? { ...params, cursor } : params); + yield page; + const next = page?.response_metadata?.next_cursor; + cursor = typeof next === 'string' && next.length > 0 ? next : undefined; + } while (cursor); +} + export async function mergePages(pages: AsyncIterable>): Promise> { const merged: Record = {}; const arrays: Record = {}; diff --git a/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..a7fd192 --- /dev/null +++ b/src/transport.ts @@ -0,0 +1,71 @@ +import { WebClient } from '@slack/web-api'; + +export type TransportMode = 'proxy' | 'direct' | 'none'; + +export interface Transport { + mode: 'proxy' | 'direct'; + call(method: string, params: Record): Promise>; +} + +export const PROXY_ERROR_CODE = 'nori_slack_proxy_error'; + +export class ProxyError extends Error { + code = PROXY_ERROR_CODE; + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export function detectTransportMode(env: NodeJS.ProcessEnv = process.env): TransportMode { + if (env.NORI_SLACK_PROXY_URL && env.NORI_SLACK_CONTEXT_TOKEN) return 'proxy'; + if (env.SLACK_BOT_TOKEN) return 'direct'; + return 'none'; +} + +export function resolveTransport(env: NodeJS.ProcessEnv = process.env): Transport | null { + const mode = detectTransportMode(env); + + if (mode === 'proxy') { + const baseUrl = env.NORI_SLACK_PROXY_URL!.replace(/\/+$/, ''); + const contextToken = env.NORI_SLACK_CONTEXT_TOKEN!; + return { + mode, + async call(method, params) { + const res = await fetch(`${baseUrl}/method`, { + method: 'POST', + headers: { + authorization: `Bearer ${contextToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ method, args: params }), + }); + const text = await res.text(); + let body: any = null; + try { + body = JSON.parse(text); + } catch { + // Non-JSON body: fall through with the raw text as the error message. + } + if (!res.ok) { + throw new ProxyError(res.status, typeof body?.error === 'string' ? body.error : text); + } + return body; + }, + }; + } + + if (mode === 'direct') { + const client = new WebClient(env.SLACK_BOT_TOKEN); + return { + mode, + call(method, params) { + return client.apiCall(method, params) as Promise>; + }, + }; + } + + return null; +} diff --git a/test/cli.test.ts b/test/cli.test.ts index ec53f8e..a40ff94 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,52 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { execFile, spawn } from 'node:child_process'; -import { promisify } from 'node:util'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const exec = promisify(execFile); -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CLI_PATH = path.resolve(__dirname, '../src/index.ts'); -const PROJECT_ROOT = path.resolve(__dirname, '..'); - -async function runCli(args: string[], env: Record = {}): Promise<{ stdout: string; stderr: string; exitCode: number }> { - try { - const { stdout, stderr } = await exec( - 'npx', ['tsx', CLI_PATH, ...args], - { - cwd: PROJECT_ROOT, - env: { ...process.env, ...env }, - timeout: 10000, - } - ); - return { stdout, stderr, exitCode: 0 }; - } catch (error: any) { - return { - stdout: error.stdout || '', - stderr: error.stderr || '', - exitCode: error.code ?? 1, - }; - } -} - -async function runCliWithStdin(args: string[], stdinData: string, env: Record = {}): Promise<{ stdout: string; stderr: string; exitCode: number }> { - return new Promise((resolve) => { - const child = spawn('npx', ['tsx', CLI_PATH, ...args], { - cwd: PROJECT_ROOT, - env: { ...process.env, ...env }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); - child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); - child.on('close', (code: number | null) => { - resolve({ stdout, stderr, exitCode: code ?? 1 }); - }); - child.stdin.write(stdinData); - child.stdin.end(); - }); -} +import { runCli, runCliWithStdin } from './helpers.js'; describe('CLI integration', () => { it('exits with non-zero code and shows usage when no method is provided', async () => { diff --git a/test/docs.md b/test/docs.md index fa69347..97f11b0 100644 --- a/test/docs.md +++ b/test/docs.md @@ -3,12 +3,13 @@ Path: @/test ### Overview -- Unit tests for `parseArgs`, `formatError`, `mergePages`, and method metadata coverage, plus integration tests that invoke the CLI as a subprocess, plus an end-to-end packaging test that installs the npm tarball -- Uses Vitest as the test runner; integration tests in `cli.test.ts` use `tsx` to run TypeScript source directly, `build.test.ts` compiles via `tsc` and runs the built `dist/index.js` artifact, and `packaging.test.ts` runs `npm pack` and installs the tarball into a tmpdir +- Unit tests for `parseArgs`, `formatError`, `mergePages`, and method metadata coverage, plus integration tests that invoke the CLI as a subprocess (direct mode against the real Slack API, proxy mode against a local fake broker), plus an end-to-end packaging test that installs the npm tarball +- Uses Vitest as the test runner; integration tests in `cli.test.ts` and `proxy-mode.test.ts` use `tsx` to run TypeScript source directly via the shared helpers in [helpers.ts](helpers.ts), `build.test.ts` compiles via `tsc` and runs the built `dist/index.js` artifact, and `packaging.test.ts` runs `npm pack` and installs the tarball into a tmpdir ### How it fits into the larger codebase - Tests cover the pure utility modules in [@/src](../src/): argument parsing, error formatting, pagination merging, and method metadata -- Integration tests in [cli.test.ts](cli.test.ts) exercise the full CLI binary by spawning `npx tsx src/index.ts` as a child process, verifying end-to-end behavior including exit codes, stdout JSON structure, and stderr output +- Integration tests in [cli.test.ts](cli.test.ts) and [proxy-mode.test.ts](proxy-mode.test.ts) exercise the full CLI binary by spawning `npx tsx src/index.ts` as a child process, verifying end-to-end behavior including exit codes, stdout JSON structure, and stderr output +- [proxy-mode.test.ts](proxy-mode.test.ts) is the blackbox verification of the proxy transport in [@/src/transport.ts](../src/transport.ts) -- it pins the wire contract that the Nori Sessions broker depends on - [packaging.test.ts](packaging.test.ts) closes the loop on the npm distribution path documented in [@/docs.md](../docs.md) -- it is the guard against the `0.1.0` regression where `dist/` was missing from the published tarball - All tests run on every PR and on every push to `main` via the workflows in [@/.github/workflows](../.github/workflows/) - The test directory is excluded from TypeScript compilation via `tsconfig.json` @@ -24,7 +25,7 @@ Path: @/test - Platform errors (e.g., `channel_not_found`) produce suggestions referencing relevant API methods - Rate limit errors include retry timing - Network errors surface the underlying error message -- Missing token errors suggest setting `SLACK_BOT_TOKEN` +- Missing-credential (`no_token`) errors suggest setting `SLACK_BOT_TOKEN`; proxy-specific error mapping is covered end-to-end in [proxy-mode.test.ts](proxy-mode.test.ts) **`paginate.test.ts`** -- Unit tests for the `mergePages` function: - Uses a `toAsyncIterable` helper to create async iterables from arrays of page objects @@ -33,15 +34,25 @@ Path: @/test **`method-metadata.test.ts`** -- Coverage guard for method metadata: - Asserts that `getMethodMetadata` returns a curated (non-fallback) description for every method in `KNOWN_METHODS`, ensuring new methods added to the catalog also get metadata entries -**`cli.test.ts`** -- Integration tests that run the CLI as a subprocess: -- `runCli` helper spawns the CLI with `execFile` and captures stdout/stderr/exit code -- `runCliWithStdin` helper uses `spawn` with piped stdin for `--json-input` tests +**`helpers.ts`** -- Shared infrastructure for the subprocess integration tests: +- `runCli` spawns the CLI with `execFile` (10-second timeout) and captures stdout/stderr/exit code; `runCliWithStdin` uses `spawn` with piped stdin for `--json-input` tests +- Both build a hermetic environment: `SLACK_BOT_TOKEN`, `NORI_SLACK_PROXY_URL`, and `NORI_SLACK_CONTEXT_TOKEN` are stripped from the inherited process env before per-test overrides are applied. This exists because Nori session machines export the proxy vars, which would otherwise silently flip tests into proxy mode +- `startFakeBroker()` starts a real local `http.Server` that records every request (URL, headers, parsed JSON body) and serves queued responses (defaulting to `{ok: true}`); its URL includes a path prefix so tests can verify URL joining + +**`cli.test.ts`** -- Direct-mode integration tests that run the CLI as a subprocess: +- Uses the shared `runCli`/`runCliWithStdin` helpers from [helpers.ts](helpers.ts) - Tests use fake tokens (`xoxb-fake-token`) which produce real Slack `invalid_auth` errors, proving the full request path works without needing a valid token - Validates: no-args usage error, missing token error, `list-methods` output, structured JSON for API failures, stdin JSON input, source path in errors, suggestion text presence, `--paginate` flag acceptance, `--dry-run` behavior, `describe` command behavior, and `list-methods` filtering/description options - Describe tests cover: known method metadata output (required/optional params, docs URL), fallback for unknown methods (`known: false`), pagination support flags, deprecation notices, missing argument error, and spot-checks across newly-added namespaces (e.g., `dnd.setSnooze`, `usergroups.create`, `views.open`, `team.info`) - `list-methods` tests cover: `--namespace` filtering (verifies all returned methods share the prefix and unrelated methods are excluded), empty namespace returning an empty array, `--descriptions` changing the output shape to objects with `method` and `description` fields, and composition of both flags together - Suggestion tests cover: dry-run with misspelled methods verifying `suggestions` array and `warning` field in JSON output, case-correction suggestions, and stderr "Did you mean" warnings before API calls +**`proxy-mode.test.ts`** -- Blackbox subprocess tests of the proxy transport against the fake broker from [helpers.ts](helpers.ts) (no real Slack traffic): +- Pins the wire contract: POST `{method, args}` JSON to `/method` with an `authorization: Bearer ` header, plus trailing-slash URL handling and proxy-over-direct precedence when both credential sets are set +- Verifies param handling is unchanged through the proxy (kebab-to-snake conversion, type coercion, `--json-input` pass-through) and that `--paginate` follows broker-supplied cursors and merges pages +- Verifies error mapping: broker error envelopes, Slack platform-code extraction from "An API error occurred" messages, the 401 context-token suggestion, and the no-credentials envelope mentioning both auth options +- Verifies `--dry-run` reports the correct `transport` value for all three modes without contacting the broker + **`suggest.test.ts`** -- Unit tests for the `findSimilarMethods` function: - Verifies exact matches return no suggestions, case-insensitive matches return the correctly-cased method, single-character typos find the right method, nonsense input returns empty, result count respects the `maxResults` parameter, and results are sorted by similarity (closest first) @@ -57,8 +68,9 @@ Path: @/test - This test is the enforcement mechanism for the packaging invariant documented in [@/docs.md](../docs.md): if `prepare`, `files`, or `bin` regress, this test fails before a broken version can be published ### Things to Know -- Integration tests make real HTTP calls to Slack's API (with invalid tokens), so they require network access -- The `runCli` helper sets a 10-second timeout to prevent hangs +- Direct-mode integration tests in [cli.test.ts](cli.test.ts) make real HTTP calls to Slack's API (with invalid tokens), so they require network access; proxy-mode tests talk only to the local fake broker +- The `runCli` helper in [helpers.ts](helpers.ts) sets a 10-second timeout to prevent hangs +- The hermetic env in [helpers.ts](helpers.ts) means tests can never see host Slack credentials -- each test passes exactly the env vars it wants, and transport selection is fully determined by that input - Tests intentionally verify structure (JSON shape, field presence, field types) rather than exact string values, making them resilient to Slack API message changes - `packaging.test.ts` shells out to `npm` and writes into `os.tmpdir()`, so CI runners must have npm available and writable tmp space diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..edfe2cf --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,116 @@ +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +const exec = promisify(execFile); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const CLI_PATH = path.resolve(__dirname, '../src/index.ts'); +export const PROJECT_ROOT = path.resolve(__dirname, '..'); + +const SLACK_ENV_VARS = ['SLACK_BOT_TOKEN', 'NORI_SLACK_PROXY_URL', 'NORI_SLACK_CONTEXT_TOKEN']; + +// Tests must not inherit Slack credentials from the host environment (Nori +// sessions export the proxy vars), so the CLI only sees what each test sets. +function hermeticEnv(env: Record): Record { + const base: Record = { ...process.env }; + for (const key of SLACK_ENV_VARS) { + delete base[key]; + } + return { ...base, ...env }; +} + +export interface CliResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export async function runCli(args: string[], env: Record = {}): Promise { + try { + const { stdout, stderr } = await exec( + 'npx', ['tsx', CLI_PATH, ...args], + { + cwd: PROJECT_ROOT, + env: hermeticEnv(env), + timeout: 10000, + } + ); + return { stdout, stderr, exitCode: 0 }; + } catch (error: any) { + return { + stdout: error.stdout || '', + stderr: error.stderr || '', + exitCode: error.code ?? 1, + }; + } +} + +export async function runCliWithStdin(args: string[], stdinData: string, env: Record = {}): Promise { + return new Promise((resolve) => { + const child = spawn('npx', ['tsx', CLI_PATH, ...args], { + cwd: PROJECT_ROOT, + env: hermeticEnv(env), + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + child.on('close', (code: number | null) => { + resolve({ stdout, stderr, exitCode: code ?? 1 }); + }); + child.stdin.write(stdinData); + child.stdin.end(); + }); +} + +export interface RecordedRequest { + url: string; + headers: http.IncomingHttpHeaders; + body: any; +} + +export interface FakeBroker { + url: string; + requests: RecordedRequest[]; + queueResponse(response: { status?: number; body: unknown }): void; + close(): Promise; +} + +export async function startFakeBroker(): Promise { + const requests: RecordedRequest[] = []; + const responses: Array<{ status?: number; body: unknown }> = []; + + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + requests.push({ + url: req.url ?? '', + headers: req.headers, + body: raw ? JSON.parse(raw) : null, + }); + const next = responses.length > 0 ? responses.shift()! : { status: 200, body: { ok: true } }; + res.writeHead(next.status ?? 200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(next.body)); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + + return { + url: `http://127.0.0.1:${port}/slack-proxy`, + requests, + queueResponse(response) { + responses.push(response); + }, + close() { + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} diff --git a/test/proxy-mode.test.ts b/test/proxy-mode.test.ts new file mode 100644 index 0000000..1809fdc --- /dev/null +++ b/test/proxy-mode.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { runCli, runCliWithStdin, startFakeBroker, type FakeBroker } from './helpers.js'; + +describe('proxy mode', () => { + let broker: FakeBroker; + + beforeEach(async () => { + broker = await startFakeBroker(); + }); + + afterEach(async () => { + await broker.close(); + }); + + function proxyEnv(extra: Record = {}): Record { + return { + NORI_SLACK_PROXY_URL: broker.url, + NORI_SLACK_CONTEXT_TOKEN: 'ctx-token-123', + ...extra, + }; + } + + it('sends {method, args} to /method with a bearer token and prints the response', async () => { + broker.queueResponse({ body: { ok: true, ts: '1716000000.000100' } }); + const result = await runCli( + ['chat.postMessage', '--channel', 'C123', '--text', 'hello'], + proxyEnv() + ); + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.ok).toBe(true); + expect(output.ts).toBe('1716000000.000100'); + expect(broker.requests).toHaveLength(1); + expect(broker.requests[0].url).toBe('/slack-proxy/method'); + expect(broker.requests[0].headers.authorization).toBe('Bearer ctx-token-123'); + expect(broker.requests[0].body).toEqual({ + method: 'chat.postMessage', + args: { channel: 'C123', text: 'hello' }, + }); + }); + + it('prefers the proxy when both proxy vars and SLACK_BOT_TOKEN are set', async () => { + const result = await runCli( + ['chat.postMessage', '--channel', 'C123', '--text', 'hi'], + proxyEnv({ SLACK_BOT_TOKEN: 'xoxb-fake-token' }) + ); + expect(result.exitCode).toBe(0); + expect(broker.requests).toHaveLength(1); + }); + + it('handles a trailing slash in the proxy URL', async () => { + const result = await runCli( + ['chat.postMessage', '--channel', 'C123', '--text', 'hi'], + proxyEnv({ NORI_SLACK_PROXY_URL: broker.url + '/' }) + ); + expect(result.exitCode).toBe(0); + expect(broker.requests[0].url).toBe('/slack-proxy/method'); + }); + + it('--json-input params reach the broker intact', async () => { + const params = { + channel: 'C123', + text: '*markdown* with `code`', + blocks: [{ type: 'section', text: { type: 'mrkdwn', text: 'hi' } }], + }; + const result = await runCliWithStdin( + ['chat.postMessage', '--json-input'], + JSON.stringify(params), + proxyEnv() + ); + expect(result.exitCode).toBe(0); + expect(broker.requests[0].body).toEqual({ + method: 'chat.postMessage', + args: params, + }); + }); + + it('converts kebab-case flags and coerces values in proxy mode', async () => { + const result = await runCli( + ['conversations.replies', '--channel', 'C123', '--thread-ts', '1716000000.001200', '--limit', '10'], + proxyEnv() + ); + expect(result.exitCode).toBe(0); + expect(broker.requests[0].body.args).toEqual({ + channel: 'C123', + thread_ts: '1716000000.001200', + limit: 10, + }); + }); + + it('maps broker errors to the structured error envelope', async () => { + broker.queueResponse({ + status: 403, + body: { error: 'Slack method is not available through scoped proxy' }, + }); + const result = await runCli( + ['search.messages', '--query', 'foo'], + proxyEnv() + ); + expect(result.exitCode).toBe(1); + const output = JSON.parse(result.stdout); + expect(output.ok).toBe(false); + expect(output.message).toContain('Slack method is not available through scoped proxy'); + expect(output.suggestion.length).toBeGreaterThan(0); + expect(result.stderr).toContain('Error:'); + }); + + it('extracts slack error codes from proxied platform errors', async () => { + broker.queueResponse({ + status: 403, + body: { error: 'An API error occurred: channel_not_found' }, + }); + const result = await runCli( + ['chat.postMessage', '--channel', 'C404', '--text', 'hi'], + proxyEnv() + ); + expect(result.exitCode).toBe(1); + const output = JSON.parse(result.stdout); + expect(output.ok).toBe(false); + expect(output.error).toBe('channel_not_found'); + expect(output.suggestion).toContain('conversations.list'); + }); + + it('maps a 401 from the broker to a context-token suggestion', async () => { + broker.queueResponse({ status: 401, body: { error: 'Unauthorized' } }); + const result = await runCli( + ['chat.postMessage', '--channel', 'C123', '--text', 'hi'], + proxyEnv() + ); + expect(result.exitCode).toBe(1); + const output = JSON.parse(result.stdout); + expect(output.ok).toBe(false); + expect(output.suggestion).toContain('NORI_SLACK_CONTEXT_TOKEN'); + }); + + it('--paginate merges cursor pages through the proxy', async () => { + broker.queueResponse({ + body: { + ok: true, + channels: [{ id: 'C1' }, { id: 'C2' }], + response_metadata: { next_cursor: 'cursor-abc' }, + }, + }); + broker.queueResponse({ + body: { + ok: true, + channels: [{ id: 'C3' }], + response_metadata: { next_cursor: '' }, + }, + }); + const result = await runCli(['conversations.list', '--paginate'], proxyEnv()); + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.ok).toBe(true); + expect(output.channels.map((c: any) => c.id)).toEqual(['C1', 'C2', 'C3']); + expect(broker.requests).toHaveLength(2); + expect(broker.requests[1].body.args.cursor).toBe('cursor-abc'); + }); + + it('--dry-run reports the proxy transport without contacting the broker', async () => { + const result = await runCli( + ['chat.postMessage', '--dry-run', '--channel', 'C123'], + proxyEnv() + ); + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.dry_run).toBe(true); + expect(output.transport).toBe('proxy'); + expect(broker.requests).toHaveLength(0); + }); + + it('--dry-run reports the direct transport with only a bot token', async () => { + const result = await runCli( + ['chat.postMessage', '--dry-run', '--channel', 'C123'], + { SLACK_BOT_TOKEN: 'xoxb-test-token' } + ); + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.transport).toBe('direct'); + }); + + it('--dry-run reports no transport when no credentials are set', async () => { + const result = await runCli( + ['chat.postMessage', '--dry-run', '--channel', 'C123'], + {} + ); + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.transport).toBe('none'); + }); + + it('no-credentials error mentions both auth options', async () => { + const result = await runCli(['chat.postMessage', '--channel', 'C123'], {}); + expect(result.exitCode).toBe(1); + const output = JSON.parse(result.stdout); + expect(output.ok).toBe(false); + expect(output.error).toBe('no_token'); + expect(output.suggestion).toContain('SLACK_BOT_TOKEN'); + expect(output.suggestion).toContain('NORI_SLACK_PROXY_URL'); + }); +});