diff --git a/.github/workflows/main-ci.yaml b/.github/workflows/main-ci.yaml new file mode 100644 index 0000000..e8a4e7b --- /dev/null +++ b/.github/workflows/main-ci.yaml @@ -0,0 +1,37 @@ +# Runs on push to main branch (post-merge) + +name: main-ci + +on: + push: + branches: + - main + +jobs: + ci: + strategy: + matrix: + os: [ubuntu-latest] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v3 + + - name: Set up root node + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: 'npm' + cache-dependency-path: 'package-lock.json' + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Run tests + run: npm test + env: + NODE_OPTIONS: --max-old-space-size=4096 diff --git a/.github/workflows/pr-ci.yaml b/.github/workflows/pr-ci.yaml new file mode 100644 index 0000000..730bf63 --- /dev/null +++ b/.github/workflows/pr-ci.yaml @@ -0,0 +1,37 @@ +# Runs on pull requests to main branch + +name: pr-ci + +on: + pull_request: + branches: + - main + +jobs: + ci: + strategy: + matrix: + os: [ubuntu-latest] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v3 + + - name: Set up root node + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: 'npm' + cache-dependency-path: 'package-lock.json' + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Run tests + run: npm test + env: + NODE_OPTIONS: --max-old-space-size=4096 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 12c9eef..c5b791a 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,18 @@ Bolt is built for human developers writing TypeScript. This CLI is built for cod - **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. - **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. -- **Distributed as source.** Build it locally; the postbuild step makes `nori-slack` available on your `PATH`. +- **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. ## Install +From npm: + +```bash +npm install -g nori-slack-cli +``` + +From source (for contributors): + ```bash git clone https://github.com/tilework-tech/nori-slack-cli.git cd nori-slack-cli diff --git a/docs.md b/docs.md index dfcb62f..ce50b8c 100644 --- a/docs.md +++ b/docs.md @@ -1,6 +1,6 @@ # Noridoc: nori-slack-cli -Path: @/nori-slack-cli +Path: @/ ### Overview - A TypeScript CLI that exposes the entire Slack Web API as a single command: `nori-slack [--param value ...]` @@ -11,24 +11,41 @@ Path: @/nori-slack-cli - 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 ### How it fits into the larger codebase -- Lives as a standalone tool under the `nori-integrations` monorepo, in the `slack` worktree -- Intended to be `npm link`ed or installed globally so agents can invoke `nori-slack` from any working directory +- 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) - 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 project spec lives in [spec/APPLICATION-SPEC.md](spec/APPLICATION-SPEC.md) - 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 +- 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()` - 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 (required params, optional params, pagination support, deprecation notices, and docs URL) +- 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 - `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 up to 3 "Did you mean?" suggestions in both `--dry-run` JSON output and stderr warnings before API calls; suggestions are non-blocking -- unknown methods still proceed to the API +- 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 - Successful API responses and error responses both go to stdout as JSON; errors additionally write a human-readable line to stderr - Exit codes: `0` for success, `1` for API/token errors, `2` for missing args or invalid stdin JSON +### Packaging and distribution + +The published npm artifact is assembled at pack time, not committed to git. The relevant `package.json` fields form a single chain that must stay in sync: + +``` +dist/ (gitignored) + └─ produced by `prepare: "npm run build"` (runs on npm pack / npm publish / npm install from tarball) + └─ made executable by `postbuild: "chmod +x dist/index.js"` + └─ included in the tarball by `files: ["dist"]` + └─ exposed as `nori-slack` via `bin: { "nori-slack": "./dist/index.js" }` + └─ verified end-to-end by test/packaging.test.ts on every `npm test` +``` + +- `test/packaging.test.ts` runs `npm pack`, installs the resulting tarball into a tmpdir, and executes the installed `nori-slack` binary to confirm `dist/` actually ships. See [test/docs.md](test/docs.md) for test-level detail +- CI is defined in [.github/workflows/pr-ci.yaml](.github/workflows/pr-ci.yaml) (on pull requests to `main`) and [.github/workflows/main-ci.yaml](.github/workflows/main-ci.yaml) (on push to `main`). Both mirror `nori-registrar` conventions: checkout, `actions/setup-node` reading Node version from [.nvmrc](.nvmrc), `npm install`, `npm run build`, `npm test` +- [.nvmrc](.nvmrc) pins Node 22 to match the `nori-registrar` baseline + ### Things to Know - 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 @@ -36,6 +53,7 @@ Path: @/nori-slack-cli - 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 - 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 `postbuild` script runs `chmod +x` on the output and `npm link` to make the binary available immediately after build +- 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 +- **Packaging invariant**: anything that changes how the distributed artifact is produced must keep the `prepare` script, the `files` allowlist, `bin`, and [test/packaging.test.ts](test/packaging.test.ts) consistent. Concretely, any future change that removes `prepare`, removes `files`, emits generated code outside `dist/`, or adds a second bin entry needs matching updates in the allowlist and the packaging test -- otherwise `npm install -g nori-slack-cli` silently ships a broken binary (this was the exact `0.1.0` regression) Created and maintained by Nori. diff --git a/package.json b/package.json index e1d5a72..15e6423 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,18 @@ { "name": "nori-slack-cli", - "version": "0.1.0", + "version": "0.1.1", "description": "CLI for interacting with the Slack Web API, designed for coding agents", "type": "module", "bin": { "nori-slack": "./dist/index.js" }, + "files": [ + "dist" + ], "scripts": { "build": "tsc", "postbuild": "chmod +x dist/index.js", + "prepare": "npm run build", "test": "vitest run", "test:watch": "vitest" }, diff --git a/src/docs.md b/src/docs.md index 79171dd..6ca5002 100644 --- a/src/docs.md +++ b/src/docs.md @@ -1,14 +1,14 @@ -# Noridoc: nori-slack-cli/src +# Noridoc: src -Path: @/nori-slack-cli/src +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 - 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 `package.json` `bin` field -- [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 [@/nori-slack-cli/test](../test/) +- [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/) - [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 diff --git a/src/index.ts b/src/index.ts index 1b8b001..d7d84f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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.0'); + .version('0.1.1'); program .command('list-methods') diff --git a/test/docs.md b/test/docs.md index 218ada5..fa69347 100644 --- a/test/docs.md +++ b/test/docs.md @@ -1,14 +1,16 @@ -# Noridoc: nori-slack-cli/test +# Noridoc: test -Path: @/nori-slack-cli/test +Path: @/test ### Overview -- Unit tests for `parseArgs`, `formatError`, `mergePages`, and method metadata coverage, plus integration tests that invoke the CLI as a subprocess -- Uses Vitest as the test runner; integration tests in `cli.test.ts` use `tsx` to run TypeScript source directly, while `build.test.ts` compiles via `tsc` and runs the built `dist/index.js` artifact +- 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 ### How it fits into the larger codebase -- Tests cover the pure utility modules in [@/nori-slack-cli/src](../src/): argument parsing, error formatting, pagination merging, and method metadata +- 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 +- [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` ### Core Implementation @@ -45,12 +47,19 @@ Path: @/nori-slack-cli/test **`build.test.ts`** -- Build verification tests that exercise the compiled output: - `beforeAll` runs `tsc` once; all tests share the resulting `dist/index.js` -- Uses `node dist/index.js` directly (unlike `cli.test.ts` which uses `npx tsx src/index.ts`), verifying the actual build artifact that `npm link` would expose +- Uses `node dist/index.js` directly (unlike `cli.test.ts` which uses `npx tsx src/index.ts`), verifying the actual build artifact that a global install would expose - Validates `--version` output, `list-methods` JSON structure, and no-args usage error exit code +**`packaging.test.ts`** -- End-to-end packaging test that validates the npm distribution path: +- `beforeAll` creates two tmpdirs, runs `npm pack` on the project root to produce a `.tgz`, then `npm init -y` + `npm install --no-save ` in the second tmpdir to simulate a downstream install +- Asserts the installed `node_modules/.bin/nori-slack` binary exists and that running it with `list-methods --namespace chat` returns exit 0 with JSON containing `chat.postMessage` +- Runs on every `npm test` invocation (not gated) so the tarball contents are continuously verified; the `beforeAll` has a 180s timeout because `npm pack` + `npm install` of the tarball is slow +- 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 - 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 Created and maintained by Nori. diff --git a/test/packaging.test.ts b/test/packaging.test.ts new file mode 100644 index 0000000..1f6f71c --- /dev/null +++ b/test/packaging.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { + mkdtempSync, + rmSync, + readdirSync, + existsSync, + copyFileSync, + cpSync, + symlinkSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(__dirname, '..'); + +describe('Package install from tarball', () => { + let sourceDir: string; + let packDir: string; + let installDir: string; + let tarballPath: string; + + beforeAll(() => { + sourceDir = mkdtempSync(path.join(tmpdir(), 'nori-slack-source-')); + packDir = mkdtempSync(path.join(tmpdir(), 'nori-slack-pack-')); + installDir = mkdtempSync(path.join(tmpdir(), 'nori-slack-install-')); + + copyFileSync(path.join(PROJECT_ROOT, 'package.json'), path.join(sourceDir, 'package.json')); + copyFileSync(path.join(PROJECT_ROOT, 'tsconfig.json'), path.join(sourceDir, 'tsconfig.json')); + copyFileSync(path.join(PROJECT_ROOT, 'README.md'), path.join(sourceDir, 'README.md')); + copyFileSync(path.join(PROJECT_ROOT, 'LICENSE'), path.join(sourceDir, 'LICENSE')); + cpSync(path.join(PROJECT_ROOT, 'src'), path.join(sourceDir, 'src'), { recursive: true }); + symlinkSync(path.join(PROJECT_ROOT, 'node_modules'), path.join(sourceDir, 'node_modules')); + + execFileSync('npm', ['pack', '--pack-destination', packDir], { + cwd: sourceDir, + stdio: 'pipe', + }); + + const tarball = readdirSync(packDir).find((f) => f.endsWith('.tgz')); + if (!tarball) throw new Error('npm pack did not produce a .tgz'); + tarballPath = path.join(packDir, tarball); + + execFileSync('npm', ['init', '-y'], { cwd: installDir, stdio: 'pipe' }); + execFileSync('npm', ['install', '--no-save', tarballPath], { + cwd: installDir, + stdio: 'pipe', + }); + }, 180000); + + afterAll(() => { + if (sourceDir) rmSync(sourceDir, { recursive: true, force: true }); + if (packDir) rmSync(packDir, { recursive: true, force: true }); + if (installDir) rmSync(installDir, { recursive: true, force: true }); + }); + + it('installs a working nori-slack binary that lists methods', { timeout: 30000 }, () => { + const binPath = path.join(installDir, 'node_modules', '.bin', 'nori-slack'); + expect(existsSync(binPath)).toBe(true); + + const output = execFileSync(binPath, ['list-methods', '--namespace', 'chat'], { + cwd: installDir, + env: { ...process.env, SLACK_BOT_TOKEN: '' }, + encoding: 'utf-8', + }); + + const parsed = JSON.parse(output); + expect(parsed.namespace).toBe('chat'); + expect(parsed.methods).toContain('chat.postMessage'); + }); +});