diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 96b9bf956..877365555 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -11,9 +11,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 strategy: + fail-fast: false matrix: node-version: [18.x, 20.x] - package: [packages/logger, packages/oauth, packages/rtm-api, packages/socket-mode, packages/types, packages/web-api, packages/webhook] + package: + - packages/cli-hooks + - packages/logger + - packages/oauth + - packages/rtm-api + - packages/socket-mode + - packages/types + - packages/web-api + - packages/webhook steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} diff --git a/packages/cli-hooks/.c8rc.json b/packages/cli-hooks/.c8rc.json new file mode 100644 index 000000000..0c61fde48 --- /dev/null +++ b/packages/cli-hooks/.c8rc.json @@ -0,0 +1,7 @@ +{ + "include": ["src/*.js"], + "exclude": ["**/*.spec.js"], + "reporter": ["lcov", "text"], + "all": false, + "cache": true +} diff --git a/packages/cli-hooks/.eslintignore b/packages/cli-hooks/.eslintignore new file mode 120000 index 000000000..5169d53c8 --- /dev/null +++ b/packages/cli-hooks/.eslintignore @@ -0,0 +1 @@ +../../lint-configs/.eslintignore \ No newline at end of file diff --git a/packages/cli-hooks/.eslintrc.cjs b/packages/cli-hooks/.eslintrc.cjs new file mode 100644 index 000000000..19f0e0509 --- /dev/null +++ b/packages/cli-hooks/.eslintrc.cjs @@ -0,0 +1,188 @@ +// SlackAPI JavaScript style +// --- +// This style helps maintainers enforce safe and consistent programming practices in this project. It is not meant to be +// comprehensive on its own or vastly different from existing styles. The goal is to inherit and aggregate as many of +// the communities' recommended styles for the technologies used as we can. When, and only when, we have a stated need +// to differentiate, we add more rules (or modify options). Therefore, the fewer rules directly defined in this file, +// the better. +// +// These styles are a subset of the shared JavaScript and TypeScript configurations to only target JavaScript packages. + +module.exports = { + root: true, + parserOptions: { + ecmaVersion: 2022 + }, + + // Ignore all build outputs and artifacts (node_modules, dotfiles, and dot directories are implicitly ignored) + ignorePatterns: [ + '/coverage', + ], + + // These environments contain lists of global variables which are allowed to be accessed + // + // The target node version (v18) supports all needed ES2022 features: https://node.green + env: { + es2022: true, + node: true, + }, + + extends: [ + // ESLint's recommended built-in rules: https://eslint.org/docs/rules/ + 'eslint:recommended', + + // Node plugin's recommended rules: https://github.com/mysticatea/eslint-plugin-node + 'plugin:node/recommended', + + // AirBnB style guide (without React) rules: https://github.com/airbnb/javascript. + 'airbnb-base', + + // JSDoc plugin's recommended rules + 'plugin:jsdoc/recommended', + ], + + rules: { + // JavaScript rules + // --- + // The top level of this configuration contains rules which apply to JavaScript. + // + // This section does not contain rules meant to override options or disable rules in the base configurations + // (ESLint, Node, AirBnb). Those rules are added in the final override. + + // Eliminate tabs to standardize on spaces for indentation. If you want to use tabs for something other than + // indentation, you may need to turn this rule off using an inline config comments. + 'no-tabs': 'error', + + // Bans use of comma as an operator because it can obscure side effects and is often an accident. + 'no-sequences': 'error', + + // Disallow the use of process.exit() + 'node/no-process-exit': 'error', + + // Allow safe references to functions before the declaration. + 'no-use-before-define': ['error', 'nofunc'], + + // Allow scripts for hooks implementations. + 'node/shebang': 'off', + + // Allow unlimited classes in a file. + 'max-classes-per-file': 'off', + + // Disallow invocations of require(). This will help make imports more consistent and ensures a smoother + // transition to the best future syntax. + 'import/no-commonjs': ['error', { + allowConditionalRequire: false, + }], + + // Don't verify that all named imports are part of the set of named exports for the referenced module. The + // TypeScript compiler will already perform this check, so it is redundant. + 'import/named': 'off', + 'node/no-missing-import': 'off', + + // Require extensions for imported modules + 'import/extensions': ['error', 'ignorePackages', { + 'js': 'always', + }], + + // Allow use of import and export syntax, despite it not being supported in the node versions. Since this + // project is transpiled, the ignore option is used. Overrides node/recommended. + 'node/no-unsupported-features/es-syntax': ['error', { ignores: ['modules'] }], + + 'operator-linebreak': ['error', 'after', { + overrides: { '=': 'none' } + }], + }, + + overrides: [ + { + files: ['**/*.js'], + rules: { + // Override rules + // --- + // This level of this configuration contains rules which override options or disable rules in the base + // configurations in JavaScript. + + // Increase the max line length to 120. The rest of this setting is copied from the AirBnB config. + 'max-len': ['error', 120, 2, { + ignoreUrls: true, + ignoreComments: false, + ignoreRegExpLiterals: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + }], + + // Restrict the use of backticks to declare a normal string. Template literals should only be used when the + // template string contains placeholders. The rest of this setting is copied from the AirBnb config. + quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], + + // The server side Slack API uses snake_case for parameters often. + // + // For mocking and override support, we need to allow snake_case. + // Allow leading underscores for parameter names, which is used to acknowledge unused variables in TypeScript. + // Also, enforce camelCase naming for variables. Ideally, the leading underscore could be restricted to only + // unused parameter names, but this rule isn't capable of knowing when a variable is unused. The camelcase and + // no-underscore-dangle rules are replaced with the naming-convention rule because this single rule can serve + // both purposes, and it works fine on non-TypeScript code. + camelcase: 'error', + 'no-underscore-dangle': 'error', + 'no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_', + argsIgnorePattern: '^_' + } + ], + + // Allow cyclical imports. Turning this rule on is mainly a way to manage the performance concern for linting + // time. Our projects are not large enough to warrant this. Overrides AirBnB styles. + 'import/no-cycle': 'off', + + // Prevent importing submodules of other modules. Using the internal structure of a module exposes + // implementation details that can potentially change in breaking ways. Overrides AirBnB styles. + 'import/no-internal-modules': ['error', { + // Use the following option to set a list of allowable globs in this project. + allow: [ + '**/middleware/*', // the src/middleware directory doesn't export a module, it's just a namespace. + '**/receivers/*', // the src/receivers directory doesn't export a module, it's just a namespace. + '**/types/**/*', + '**/types/*', // type heirarchies should be used however one wants + ], + }], + + // Remove the minProperties option for enforcing line breaks between braces. The AirBnB config sets this to 4, + // which is arbitrary and not backed by anything specific in the style guide. If we just remove it, we can + // rely on the max-len rule to determine if the line is too long and then enforce line breaks. Overrides AirBnB + // styles. + 'object-curly-newline': ['error', { multiline: true, consistent: true }], + }, + }, + { + files: ['src/**/*.spec.js'], + rules: { + // Test-specific rules + // --- + // Rules that only apply to JavaScript _test_ source files + + // With Mocha as a test framework, it is sometimes helpful to assign + // shared state to Mocha's Context object, for example in setup and + // teardown test methods. Assigning stub/mock objects to the Context + // object via `this` is a common pattern in Mocha. As such, using + // `function` over the the arrow notation binds `this` appropriately and + // should be used in tests. So: we turn off the prefer-arrow-callback + // rule. + // See https://github.com/slackapi/bolt-js/pull/1012#pullrequestreview-711232738 + // for a case of arrow-vs-function syntax coming up for the team + 'prefer-arrow-callback': 'off', + // Using ununamed functions (e.g., null logger) in tests is fine + 'func-names': 'off', + // In tests, don't force constructing a Symbol with a descriptor, as + // it's probably just for tests + 'symbol-description': 'off', + + 'node/no-unpublished-import': ['error', { + "allowModules": ["mocha"], + }], + }, + }, + ], +}; diff --git a/packages/cli-hooks/.gitignore b/packages/cli-hooks/.gitignore new file mode 100644 index 000000000..805472905 --- /dev/null +++ b/packages/cli-hooks/.gitignore @@ -0,0 +1,6 @@ +# Node and NPM stuff +/node_modules +package-lock.json + +# Coverage carryover +/coverage diff --git a/packages/cli-hooks/LICENSE b/packages/cli-hooks/LICENSE new file mode 100644 index 000000000..e0e9e304f --- /dev/null +++ b/packages/cli-hooks/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024- Slack Technologies, LLC + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/cli-hooks/README.md b/packages/cli-hooks/README.md new file mode 100644 index 000000000..a671b5e53 --- /dev/null +++ b/packages/cli-hooks/README.md @@ -0,0 +1,122 @@ +# Slack CLI Hooks + +The `@slack/cli-hooks` package contains scripts that implement the contract +between the [Slack CLI][cli] and [Bolt for JavaScript][bolt]. + +## Overview + +This library enables inter-process communication between the Slack CLI and +applications built with Bolt for JavaScript. + +When used together, the CLI delegates various tasks to the Bolt application by +invoking processes ("hooks") and then making use of the responses provided by +each hook's `stdout`. + +For a complete list of available hooks, read the [Supported Hooks][supported] +section. + +## Requirements + +This package supports Node v18 and higher. It's highly recommended to use [the +latest LTS version of Node][node]. + +An updated version of the Slack CLI is also encouraged while using this package. + +## Installation + +Add this package as a development dependency for your project with the following +command: + +```sh +$ npm install --save-dev @slack/cli-hooks +``` + +Follow [the installation guide][install] to download the Slack CLI and easily +run the scripts included in this package. + +## Usage + +A Slack CLI-compatible Slack application includes a `slack.json` file that +contains hooks specific to that project. Each hook is associated with commands +that are available in the Slack CLI. By default, `get-hooks` retrieves all of +the [supported hooks][supported] and their corresponding scripts as defined in +this package. + +The CLI will try to use the version of the `@slack/cli-hooks` specified in your +application's `package.json`. The hooks in this package are automatically added +to the `./node_modules/.bin` directory of your application when this package is +installed. + +### Supported Hooks + +The hooks that are currently supported for use within the Slack CLI include +`check-update`, `get-hooks`, `get-manifest`, and `start`: + +| Hook Name | CLI Command | File |Description | +| -------------- | ---------------- | ---- | ----------- | +| `check-update` | `slack update` | [`check-update.js`](./src/check-update.js) | Checks the project's Slack dependencies to determine whether or not any packages need to be updated. | +| `get-hooks` | All | [`get-hooks.js`](./src/get-hooks.js) | Fetches the list of available hooks for the CLI from this repository. | +| `get-manifest` | `slack manifest` | [`get-manifest.js`](./src/get-manifest.js) | Converts a `manifest.json` file into a valid manifest JSON payload. | +| `start` | `slack run` | [`start.js`](./src/start.js) | While developing locally, the CLI manages a socket connection with Slack's backend and utilizes this hook for events received via this connection. | + +### Overriding Hooks + +To customize the behavior of a hook, add the hook to your application's +`slack.json` file and provide a corresponding script to be executed. + +When commands are run, the Slack CLI will look to the project's hook definitions +and use those instead of what's defined in this library, if provided. Only +[supported hooks][supported] will be recognized and executed by the Slack CLI. + +Below is an example `slack.json` file that overrides the default `start` hook: + +```json +{ + "hooks": { + "get-hooks": "npx -q --no-install -p @slack/cli-hooks slack-cli-get-hooks", + "start": "npm run dev" + } +} +``` + +### Troubleshooting + +Sometimes the hook scripts are installed globally and might not be automatically +updated. To determine the source of these scripts, check the `node_modules/.bin` +directory of your project then run the following command: + +```sh +$ which npx slack-cli-get-hooks # macOS / Linux +``` + +```cmd +C:\> where.exe npx slack-cli-get-hooks # Windows +``` + +These hooks can be safely removed and reinstalled at your application directory +to ensure you're using the correct version for your project. + +## Getting help + +If you get stuck, we're here to help. The following are the best ways to get +assistance working through your issue: + +* [Issue Tracker][issues] for questions, feature requests, bug reports and + general discussion related to these packages. Try searching before you create + a new issue. +* [Email us][email]: `developers@slack.com` +* [Community Slack][community]: a Slack community for developers building all + kinds of Slack apps. You can find the maintainers and users of these packages + in **#lang-javascript**. + + +[bolt]: https://github.com/slackapi/bolt-js +[cli]: https://api.slack.com/automation/cli +[community]: https://community.slack.com/ +[config]: https://api.slack.com/apps +[email]: mailto:developers@slack.com +[install]: https://api.slack.com/automation/cli/install +[issues]: http://github.com/slackapi/node-slack-sdk/issues +[manifest]: https://api.slack.com/reference/manifests +[node]: https://github.com/nodejs/Release#release-schedule +[supported]: #supported-hooks diff --git a/packages/cli-hooks/jsconfig.json b/packages/cli-hooks/jsconfig.json new file mode 100644 index 000000000..2e3e52e08 --- /dev/null +++ b/packages/cli-hooks/jsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "es2022", + "moduleResolution": "node", + "esModuleInterop" : true, + "checkJs": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + "baseUrl": ".", + "paths": { + "*": ["./types/*"] + }, + }, + "include": [ + "src/**/*" + ], + "jsdoc": { + "out": "support/jsdoc", + "access": "public" + } +} + diff --git a/packages/cli-hooks/package.json b/packages/cli-hooks/package.json new file mode 100644 index 000000000..693deddee --- /dev/null +++ b/packages/cli-hooks/package.json @@ -0,0 +1,68 @@ +{ + "name": "@slack/cli-hooks", + "version": "1.0.0", + "description": "Node implementation of the contract between the Slack CLI and Bolt for JavaScript", + "author": "Slack Technologies, LLC", + "license": "MIT", + "keywords": [ + "slack", + "cli", + "hooks" + ], + "type": "module", + "main": "src/get-hooks.js", + "files": [ + "src/check-update.js", + "src/get-hooks.js", + "src/get-manifest.js", + "src/protocols.js", + "src/start.js" + ], + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/slackapi/node-slack-sdk.git" + }, + "homepage": "https://slack.dev/node-slack-sdk", + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://github.com/slackapi/node-slack-sdk/issues" + }, + "scripts": { + "prebuild": "shx rm -rf ./coverage", + "build": "shx chmod +x src/*.js", + "prelint": "tsc --noemit --module es2022 --project ./jsconfig.json", + "lint": "eslint --ext .js src", + "pretest": "npm run lint -- --fix", + "test": "c8 mocha src/*.spec.js" + }, + "bin": { + "slack-cli-get-hooks": "src/get-hooks.js", + "slack-cli-get-manifest": "src/get-manifest.js", + "slack-cli-check-update": "src/check-update.js", + "slack-cli-start": "src/start.js" + }, + "dependencies": { + "minimist": "^1.2.8", + "semver": "^7.5.4" + }, + "devDependencies": { + "@types/minimist": "^1.2.5", + "@types/mocha": "^10.0.6", + "@types/node": "^20.10.8", + "@types/semver": "^7.5.6", + "@types/sinon": "^17.0.3", + "c8": "^9.0.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-plugin-jsdoc": "^48.0.2", + "eslint-plugin-node": "^11.1.0", + "mocha": "^10.2.0", + "shx": "^0.3.4", + "sinon": "^17.0.1" + } +} diff --git a/packages/cli-hooks/src/check-update.js b/packages/cli-hooks/src/check-update.js new file mode 100755 index 000000000..ec3b3dc9c --- /dev/null +++ b/packages/cli-hooks/src/check-update.js @@ -0,0 +1,314 @@ +#!/usr/bin/env node + +import { clean, gt, major } from 'semver'; +import { fileURLToPath } from 'url'; +import childProcess from 'child_process'; +import fs from 'fs'; +import util from 'util'; + +import { getProtocol } from './protocols.js'; + +const SLACK_BOLT_SDK = '@slack/bolt'; +const SLACK_CLI_HOOKS = '@slack/cli-hooks'; + +/** + * Implementation of the check-update hook that finds available SDK updates. + * Prints an object detailing information on Slack dependencies for the CLI. + */ + +if (fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + const protocol = getProtocol(process.argv.slice(1)); + checkForSDKUpdates(process.cwd()).then(JSON.stringify).then(protocol.respond); +} + +/** + * @typedef {object} UpdateInfo + * @property {string} name - Overall identifier of the package. + * @property {ReleaseInfo[]} releases - Collection of new releases. + * @property {string | undefined} message - Details about updates. + * @property {string | undefined} url - More information to inspect. + * @property {ErrorInfo | undefined} error - Notes of any failures. + */ + +/** + * @typedef {object} ReleaseInfo + * @property {string} name - Dependency identifier on registry. + * @property {string | undefined} current - Version in current project. + * @property {string | undefined} latest - Most recent version available. + * @property {boolean} update - If latest version is newer than current. + * @property {boolean} breaking - If latest version requires a major bump. + * @property {string | undefined} message - Details about the dependency. + * @property {string | undefined} url - More information to inspect. + * @property {ErrorInfo | undefined} error - Notes of any failures. + */ + +/** + * @typedef {object} ErrorInfo + * @property {string} message - Details about the error. + */ + +/** + * File that cannot be accessed for some unexpected reason. + * @typedef {object} InaccessibleFile + * @property {string} name - Identifier of the file. + * @property {unknown} error - Cause of the failure. + */ + +/** + * Checks for available SDK updates of specified Slack dependencies. + * @param {string} cwd - The current working directory of the CLI project. + * @returns {Promise} Formatted package version information. + */ +export default async function checkForSDKUpdates(cwd) { + const { versionMap, inaccessibleFiles } = await getProjectDependencies(cwd); + const checkUpdateResponse = createCheckUpdateResponse(versionMap, inaccessibleFiles); + return checkUpdateResponse; +} + +/** + * @typedef ProjectDependencies + * @property {Record} versionMap - + * @property {InaccessibleFile[]} inaccessibleFiles - + * Array of files that could not be read or accessed. + */ + +/** + * Gathers version information about Slack packages that are project dependencies. + * @param {string} cwd - The current working directory of the CLI project. + * @returns {Promise} a map of version information and any encountered errors + */ +async function getProjectDependencies(cwd) { + /** @type {Record} */ + const versionMap = {}; + const { projectDependencies, inaccessibleFiles } = await gatherDependencyFile(cwd); + try { + if (projectDependencies.dependencies[SLACK_BOLT_SDK]) { + versionMap[SLACK_BOLT_SDK] = await collectVersionInfo(SLACK_BOLT_SDK); + } + if (projectDependencies.dependencies[SLACK_CLI_HOOKS]) { + versionMap[SLACK_CLI_HOOKS] = await collectVersionInfo(SLACK_CLI_HOOKS); + } + } catch (err) { + inaccessibleFiles.push({ name: projectDependencies.fileName, error: err }); + } + return { versionMap, inaccessibleFiles }; +} + +/** + * Details about the dependencies and versioning for the current project. + * @typedef DependencyFile + * @property {ProjectPackages} projectDependencies - Installation information of packages. + * @property {InaccessibleFile[]} inaccessibleFiles - Array of files that could not be read. + */ + +/** + * Mappings from dependencies to installed package information for the project. + * @typedef {object} ProjectPackages + * @property {string} fileName - The file with package information (package.json). + * @property {Record} dependencies - Install details for packages. + */ + +/** + * Gathers dependencies and version information from the project (package.json). + * @param {string} cwd - The current working directory of the CLI project. + * @returns {Promise} Dependencies found for the project and any encountered errors. + */ +async function gatherDependencyFile(cwd) { + const packageJSONFileName = 'package.json'; + const projectDependencies = { + fileName: packageJSONFileName, + dependencies: {}, + }; + const inaccessibleFiles = []; + try { + const packageJSONFile = await getJSON(`${cwd}/${packageJSONFileName}`); + if ('devDependencies' in packageJSONFile && + typeof packageJSONFile.devDependencies === 'object' && + packageJSONFile.devDependencies !== null && + Object.values(packageJSONFile.devDependencies).every((value) => (typeof value === 'string'))) { + Object.assign(projectDependencies.dependencies, packageJSONFile.devDependencies); + } + if ('dependencies' in packageJSONFile && + typeof packageJSONFile.dependencies === 'object' && + packageJSONFile.dependencies !== null && + Object.values(packageJSONFile.dependencies).every((value) => (typeof value === 'string'))) { + Object.assign(projectDependencies.dependencies, packageJSONFile.dependencies); + } + } catch (err) { + inaccessibleFiles.push({ name: packageJSONFileName, error: err }); + } + return { projectDependencies, inaccessibleFiles }; +} + +/** + * Finds version information for a package and prepares release information. + * @param {string} packageName - Name of the package to lookup. + * @returns {Promise} Current version and release information for the package. + */ +async function collectVersionInfo(packageName) { + /** @type {string | undefined} */ + let currentVersion; + /** @type {string | undefined} */ + let latestVersion; + /** @type {string | undefined} */ + let releaseNotesUrl; + /** @type {ErrorInfo | undefined} */ + let dependencyError; + try { + currentVersion = await getProjectPackageVersion(packageName); + latestVersion = await fetchLatestPackageVersion(packageName); + if (hasAvailableUpdates(currentVersion, latestVersion)) { + releaseNotesUrl = getReleaseNotesUrl(packageName, latestVersion); + } + } catch (err) { + if (typeof err === 'string') { + dependencyError = { message: err }; + } else if (err instanceof Error) { + dependencyError = { message: err.message }; + } + } + return { + name: packageName, + current: currentVersion, + latest: latestVersion, + update: hasAvailableUpdates(currentVersion, latestVersion), + breaking: hasBreakingChange(currentVersion, latestVersion), + message: undefined, + url: releaseNotesUrl, + error: dependencyError, + }; +} + +/** + * Finds the current version of a local project package. + * @param {string} packageName - Name of the package to lookup. + * @returns {Promise} A stringified semver of the found version. + */ +async function getProjectPackageVersion(packageName) { + const stdout = await execWrapper(`npm list ${packageName} --depth=0 --json`); + const currentVersionOutput = JSON.parse(stdout); + if (!currentVersionOutput.dependencies || !currentVersionOutput.dependencies[packageName]) { + throw new Error(`Failed to gather project information about ${packageName}`); + } + return currentVersionOutput.dependencies[packageName].version; +} + +/** + * Gets the latest package version. + * @param {string} packageName - Package to search for the latest version of. + * @returns {Promise} The most recent version of the published package. + */ +async function fetchLatestPackageVersion(packageName) { + const command = `npm info ${packageName} version --tag latest`; + const stdout = await execWrapper(command); + return stdout; +} + +/** + * Formats the URL with the release notes for a package. + * @param {string} packageName - The package with the release. + * @param {string} latestVersion - Recent release version. + * @returns {string | undefined} A URL with release notes. + */ +function getReleaseNotesUrl(packageName, latestVersion) { + if (packageName === SLACK_BOLT_SDK) { + return `https://github.com/slackapi/bolt-js/releases/tag/@slack/bolt@${latestVersion}`; + } if (packageName === SLACK_CLI_HOOKS) { + return `https://github.com/slackapi/node-slack-sdk/releases/tag/@slack/cli-hooks@${latestVersion}`; + } + return undefined; +} + +/** + * Checks if the latest version is more recent than the current version. + * @param {string | undefined} current - Package version available in the project. + * @param {string | undefined} latest - Most up-to-date dependency version available. + * @returns {boolean} If the update will result in a breaking change. + */ +export function hasAvailableUpdates(current, latest) { + if (!current || !latest || !clean(current) || !clean(latest)) { + return false; + } + return gt(latest, current); +} + +/** + * Checks if updating a dependency to the latest version causes a breaking change. + * @param {string | undefined} current - Package version available in the project. + * @param {string | undefined} latest - Most up-to-date dependency version available. + * @returns {boolean} If the update will result in a breaking change. + */ +export function hasBreakingChange(current, latest) { + if (!current || !latest || !clean(current) || !clean(latest)) { + return false; + } + return major(latest) > major(current); +} + +/** + * Wraps and parses the output of the exec() function + * @param {string} command - The command to soon be executed. + * @returns {Promise} the output from the command. + */ +async function execWrapper(command) { + const exec = util.promisify(childProcess.exec); + const { stdout } = await exec(command); + return stdout.trim(); +} + +/** + * Reads and parses a JSON file to return the contents. + * @param {string} filePath - The path of the file being read. + * @returns {Promise} - The parsed file contents. + */ +async function getJSON(filePath) { + if (fs.existsSync(filePath)) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } + throw new Error(`Cannot find a file at path ${filePath}`); +} + +/** + * Creates the check update response in the format expected by the CLI. + * @param {Record} versionMap - Information about packages and updates. + * @param {InaccessibleFile[]} inaccessibleFiles - Array of files that could not be read. + * @returns {UpdateInfo} Update information formatted in the expected manner. + */ +function createCheckUpdateResponse(versionMap, inaccessibleFiles) { + /** @type {string[]} */ + const dependencyErrors = []; + const releases = Object.entries(versionMap).map(([dependency, version]) => { + if (version.error) { + dependencyErrors.push(dependency); + } + return version; + }); + return { + name: 'the Slack SDK', + message: '', + releases, + url: 'https://api.slack.com/automation/changelog', + error: createUpdateErrorMessage(dependencyErrors, inaccessibleFiles), + }; +} + +/** + * Prepares a message about any errors encountered. + * @param {string[]} dependencyErrors - Packages that failed for some unexpected reason. + * @param {InaccessibleFile[]} inaccessibleFiles - Array of files that could not be read. + * @returns {ErrorInfo | undefined} Formatted information about errors. + */ +export function createUpdateErrorMessage(dependencyErrors, inaccessibleFiles) { + if (dependencyErrors.length === 0 && inaccessibleFiles.length === 0) { + return undefined; + } + let message = ''; + if (dependencyErrors.length > 0) { + message = `An error occurred fetching updates for the following packages: ${dependencyErrors.join(', ')}\n`; + } + const fileErrors = inaccessibleFiles.map((file) => ` ${file.name}: ${file.error}\n`); + if (inaccessibleFiles.length > 0) { + message += `An error occurred while reading the following files:\n${fileErrors.join('')}`; + } + return { message }; +} diff --git a/packages/cli-hooks/src/check-update.spec.js b/packages/cli-hooks/src/check-update.spec.js new file mode 100644 index 000000000..217a17bfc --- /dev/null +++ b/packages/cli-hooks/src/check-update.spec.js @@ -0,0 +1,201 @@ +import { after, before, describe, it } from 'mocha'; +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import sinon from 'sinon'; +import util from 'util'; + +import checkForSDKUpdates, { + hasAvailableUpdates, + hasBreakingChange, + createUpdateErrorMessage, +} from './check-update.js'; + +/** + * Mock dependency information for packages of the project. + */ +const packageJSON = { + name: 'Example application', + dependencies: { + '@slack/bolt': '^3.0.0', + }, + devDependencies: { + '@slack/cli-hooks': '^0.0.1', + }, +}; + +/** + * Example package information provided as a mocked npm command. + * @param {string} command - Command to mock a result for. + * @returns {string} - Stringified result of the mocked command. + */ +function mockNPM(command) { + if (command === 'npm info @slack/bolt version --tag latest') { + return '3.1.4'; + } if (command === 'npm info @slack/cli-hooks version --tag latest') { + return '1.0.1'; + } + if (command === 'npm list @slack/bolt --depth=0 --json') { + return '{"dependencies":{"@slack/bolt":{"version":"3.0.0"}}}'; + } if (command === 'npm list @slack/cli-hooks --depth=0 --json') { + return '{"dependencies":{"@slack/cli-hooks":{"version":"0.0.1"}}}'; + } + throw new Error('Unknown NPM command mocked'); +} + +describe('check-update implementation', async () => { + describe('collects recent package versions', async () => { + const tempDir = path.join(process.cwd(), 'tmp'); + const packageJSONFilePath = path.join(tempDir, 'package.json'); + + before(() => { + sinon.stub(util, 'promisify') + .returns((/** @type {string} */ command) => { + const info = mockNPM(command); + return Promise.resolve({ stdout: info }); + }); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir); + } + if (!fs.existsSync(packageJSONFilePath)) { + fs.writeFileSync(packageJSONFilePath, JSON.stringify(packageJSON, null, 2)); + } + }); + + after(() => { + sinon.restore(); + if (fs.existsSync(packageJSONFilePath)) { + fs.unlinkSync(packageJSONFilePath); + } + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + it('shows version information for packages', async () => { + const updates = await checkForSDKUpdates('./tmp'); + const expected = { + name: 'the Slack SDK', + error: undefined, + message: '', + releases: [ + { + name: '@slack/bolt', + current: '3.0.0', + latest: '3.1.4', + error: undefined, + update: true, + message: undefined, + breaking: false, + url: 'https://github.com/slackapi/bolt-js/releases/tag/@slack/bolt@3.1.4', + }, + { + name: '@slack/cli-hooks', + current: '0.0.1', + latest: '1.0.1', + error: undefined, + update: true, + message: undefined, + breaking: true, + url: 'https://github.com/slackapi/node-slack-sdk/releases/tag/@slack/cli-hooks@1.0.1', + }, + ], + url: 'https://api.slack.com/automation/changelog', + }; + assert.deepEqual(updates, expected); + }); + }); + + describe('determines the type of update', async () => { + it('should return if updates are available', () => { + assert(hasAvailableUpdates('0.0.1', '0.0.2')); + assert(hasAvailableUpdates('0.0.1', '0.2.0')); + assert(hasAvailableUpdates('0.0.1', '2.0.0')); + assert(hasAvailableUpdates('0.1.0', '0.1.1')); + assert(hasAvailableUpdates('0.1.0', '0.2.0')); + assert(hasAvailableUpdates('0.1.0', '2.0.0')); + assert(hasAvailableUpdates('1.0.0', '1.0.1')); + assert(hasAvailableUpdates('1.0.0', '1.1.0')); + assert(hasAvailableUpdates('1.0.0', '1.1.1')); + assert(hasAvailableUpdates('1.0.0', '2.0.0')); + assert(hasAvailableUpdates('0.0.2', '0.0.13')); + assert(hasAvailableUpdates('0.0.2-rc.0', '0.0.2-rc.1')); + assert(hasAvailableUpdates('0.0.3-rc.2', '0.0.3')); + assert(!hasAvailableUpdates('0.0.1', '0.0.1')); + assert(!hasAvailableUpdates('0.1.0', '0.1.0')); + assert(!hasAvailableUpdates('0.1.1', '0.1.1')); + assert(!hasAvailableUpdates('1.0.0', '1.0.0')); + assert(!hasAvailableUpdates('1.0.1', '1.0.1')); + assert(!hasAvailableUpdates('1.1.1', '1.1.1')); + assert(!hasAvailableUpdates(undefined, undefined)); + assert(!hasAvailableUpdates(undefined, '1.0.0')); + assert(!hasAvailableUpdates('1.0.0', undefined)); + assert(!hasAvailableUpdates('2.0.0', '1.0.0')); + assert(!hasAvailableUpdates('2.0.0', '0.1.0')); + assert(!hasAvailableUpdates('2.0.0', '0.3.0')); + assert(!hasAvailableUpdates('2.0.0', '0.0.1')); + assert(!hasAvailableUpdates('2.0.0', '0.0.3')); + assert(!hasAvailableUpdates('2.0.0', '1.1.0')); + assert(!hasAvailableUpdates('2.0.0', '1.3.0')); + assert(!hasAvailableUpdates('2.0.0', '1.1.1')); + assert(!hasAvailableUpdates('2.0.0', '1.3.3')); + assert(!hasAvailableUpdates('0.2.0', '0.1.0')); + assert(!hasAvailableUpdates('0.2.0', '0.0.1')); + assert(!hasAvailableUpdates('0.2.0', '0.0.3')); + assert(!hasAvailableUpdates('0.2.0', '0.1.1')); + assert(!hasAvailableUpdates('0.2.0', '0.1.3')); + assert(!hasAvailableUpdates('0.0.2', '0.0.1')); + assert(!hasAvailableUpdates('0.0.20', '0.0.13')); + assert(!hasAvailableUpdates('0.0.2-rc.0', '0.0.2-rc.0')); + assert(!hasAvailableUpdates('0.0.2', '0.0.2-rc.4')); + }); + + it('should return if the update is major', () => { + assert(hasBreakingChange('0.0.1', '1.0.0')); + assert(hasBreakingChange('0.2.3', '1.0.0')); + assert(hasBreakingChange('0.2.3', '2.0.0')); + assert(hasBreakingChange('1.0.0', '4.0.0')); + assert(!hasBreakingChange('1.0.0', '1.0.0')); + assert(!hasBreakingChange('1.0.0', '1.0.1')); + assert(!hasBreakingChange('1.0.0', '1.2.3')); + assert(!hasBreakingChange(undefined, '1.0.0')); + assert(!hasBreakingChange('1.0.0', undefined)); + assert(!hasBreakingChange(undefined, undefined)); + }); + }); + + describe('error messages are formatted', async () => { + it('should not note nonexistant errors', () => { + const message = createUpdateErrorMessage([], []); + const expected = undefined; + assert.deepEqual(message, expected); + }); + + it('should note package update errors', () => { + const message = createUpdateErrorMessage(['@slack/bolt'], []); + const expected = { + message: 'An error occurred fetching updates for the following packages: @slack/bolt\n', + }; + assert.deepEqual(message, expected); + }); + + it('should note any file access errors', () => { + const fileError = { name: 'package.json', error: 'Not found' }; + const message = createUpdateErrorMessage([], [fileError]); + const expected = { + message: 'An error occurred while reading the following files:\n package.json: Not found\n', + }; + assert.deepEqual(message, expected); + }); + + it('should note all errors together', () => { + const fileError = { name: 'package.json', error: 'Not found' }; + const message = createUpdateErrorMessage(['@slack/cli-hooks'], [fileError]); + const expected = { + message: 'An error occurred fetching updates for the following packages: @slack/cli-hooks\n' + + 'An error occurred while reading the following files:\n package.json: Not found\n', + }; + assert.deepEqual(message, expected); + }); + }); +}); diff --git a/packages/cli-hooks/src/get-hooks.js b/packages/cli-hooks/src/get-hooks.js new file mode 100755 index 000000000..1a3556da9 --- /dev/null +++ b/packages/cli-hooks/src/get-hooks.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +import { SUPPORTED_NAMED_PROTOCOLS } from './protocols.js'; + +/** + * Implementation the get-hooks script hook required by the Slack CLI. + * Printed as an object containing featured provided by the SDK. + */ + +if (fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + console.log(JSON.stringify(getHooks())); // eslint-disable-line no-console +} + +/** + * Standardized communication format between the SDK and CLI regarding hooks. + * @typedef SDKInterface + * @property {Record} hooks - Commands available in the package. + * @property {SDKConfig} config - Settings for SDK and CLI communication. + * @property {string} runtime - Target runtime that app functions execute in. + */ + +/** + * Additional configurations provided to the CLI about the SDK. + * @typedef SDKConfig + * @property {string[]} protocol-version - Named CLI protocols used by the SDK. + * @property {SDKConfigWatch} [watch] - Settings for file watching features. + * @property {boolean} [sdk-managed-connection-enabled] - + * If the SDK or CLI manages websocket connections for run command executions. + */ + +/** + * Information about the files to watch for specific changes. + * @typedef SDKConfigWatch + * @property {string} filter-regex - Regex pattern for finding filtered files. + * @property {string[]} paths - Specific locations to begin searching for files. + */ + +/** + * Contains available hooks and other configurations available to the SDK. + * @returns {SDKInterface} Information about the hooks currently supported. + */ +export default function getHooks() { + return { + hooks: { + 'get-manifest': 'npx -q --no-install -p @slack/cli-hooks slack-cli-get-manifest', + 'check-update': 'npx -q --no-install -p @slack/cli-hooks slack-cli-check-update', + start: 'npx -q --no-install -p @slack/cli-hooks slack-cli-start', + }, + config: { + watch: { + 'filter-regex': '^manifest\\.json$', + paths: [ + '.', + ], + }, + 'protocol-version': SUPPORTED_NAMED_PROTOCOLS, + 'sdk-managed-connection-enabled': true, + }, + runtime: 'node', + }; +} diff --git a/packages/cli-hooks/src/get-hooks.spec.js b/packages/cli-hooks/src/get-hooks.spec.js new file mode 100644 index 000000000..acbedc9d8 --- /dev/null +++ b/packages/cli-hooks/src/get-hooks.spec.js @@ -0,0 +1,23 @@ +import { describe, it } from 'mocha'; +import assert from 'assert'; + +import getHooks from './get-hooks.js'; + +describe('get-hooks implementation', async () => { + it('should return scripts for required hooks', async () => { + const { hooks } = getHooks(); + assert(hooks['get-manifest'] === 'npx -q --no-install -p @slack/cli-hooks slack-cli-get-manifest'); + assert(hooks['check-update'] === 'npx -q --no-install -p @slack/cli-hooks slack-cli-check-update'); + assert(hooks.start === 'npx -q --no-install -p @slack/cli-hooks slack-cli-start'); + }); + + it('should return every protocol version', async () => { + const { config } = getHooks(); + assert.deepEqual(config['protocol-version'], ['message-boundaries']); + }); + + it('should return a true managed connection', async () => { + const { config } = getHooks(); + assert(config['sdk-managed-connection-enabled']); + }); +}); diff --git a/packages/cli-hooks/src/get-manifest.js b/packages/cli-hooks/src/get-manifest.js new file mode 100755 index 000000000..a86e66412 --- /dev/null +++ b/packages/cli-hooks/src/get-manifest.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import path from 'path'; + +import { getProtocol } from './protocols.js'; + +/** + * Implemention of the get-manifest hook that provides manifest information. + * Printed as a well-formatted structure of project manifest data to stdout. + */ + +if (fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + const protocol = getProtocol(process.argv.slice(1)); + const cwd = process.cwd(); + const manifest = getManifestData(cwd); + protocol.respond(JSON.stringify(manifest)); +} + +/** + * Returns parsed manifest data for a project. + * @param {string} searchDir - The path to begin searching in. + * @returns {object} Parsed values of the project manifest. + */ +export default function getManifestData(searchDir) { + const manifestJSON = readManifestJSONFile(searchDir, 'manifest.json'); + if (!manifestJSON) { + throw new Error('Failed to find a manifest file in this project'); + } + return manifestJSON; +} + +/** + * Returns a manifest.json if it exists, null otherwise. + * @param {string} searchDir - Directory to begin search in. + * @param {string} filename - The file to search for. + * @returns {object | undefined} The found manifest.json object. + */ +function readManifestJSONFile(searchDir, filename) { + try { + const jsonFilePath = find(searchDir, filename); + if (jsonFilePath && fs.existsSync(jsonFilePath)) { + return JSON.parse(fs.readFileSync(jsonFilePath, 'utf8')); + } + } catch (err) { + let message = 'Failed to parse the manifest file for this project'; + if (err instanceof Error) { + message += `\n${err.name}: ${err.message}`; + } + throw new Error(message); + } + return undefined; +} + +/** + * Searches for a certain file in the provided file path. + * @param {string} currentPath - The current path to begin the search in. + * @param {string} targetFilename - Filename to find and match. + * @returns {string | undefined} Full file path relative to starting path. + */ +function find(currentPath, targetFilename) { + // TODO Cache searched paths and check that they haven't been explored already + // This guards against rare edge case of a subdir in the file tree which is + // symlinked back to root or in such a way that creates a cycle. Can also implement + // max depth check. + if (fs.existsSync(path.join(currentPath, targetFilename))) { + return path.join(currentPath, targetFilename); + } + + /** @type {string | undefined} */ + let targetPath; + if (fs.existsSync(currentPath) && fs.lstatSync(currentPath).isDirectory()) { + const dirents = fs.readdirSync(currentPath); + dirents.some((entry) => { + if (entry !== 'node_modules') { + const newPath = path.resolve(currentPath, entry); + const foundEntry = find(newPath, targetFilename); + if (foundEntry) { + targetPath = foundEntry; + return true; + } + } + return false; + }); + } + return targetPath; +} diff --git a/packages/cli-hooks/src/get-manifest.spec.js b/packages/cli-hooks/src/get-manifest.spec.js new file mode 100644 index 000000000..3e5e6eaae --- /dev/null +++ b/packages/cli-hooks/src/get-manifest.spec.js @@ -0,0 +1,105 @@ +import { after, before, describe, it } from 'mocha'; +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; + +import getManifestData from './get-manifest.js'; + +describe('get-manifest implementation', async () => { + describe('missing project manifest file', async () => { + it('should error if no manifest.json exists', async () => { + try { + getManifestData(process.cwd()); + } catch (err) { + if (err instanceof Error) { + assert(err.message.includes('Failed to find a manifest file in this project')); + return; + } + } + assert(false); + }); + }); + + describe('broken project manifest file exists', async () => { + const tempDir = path.join(process.cwd(), 'tmp'); + const filePath = path.join(tempDir, 'manifest.json'); + + before(() => { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir); + } + fs.writeFileSync(filePath, '{'); + }); + + after(() => { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + it('should error for invalid manifest.json', async () => { + try { + getManifestData(process.cwd()); + } catch (err) { + if (err instanceof Error) { + const nodeV18Error = 'SyntaxError: Unexpected end of JSON input'; + const nodeV20Error = "SyntaxError: Expected property name or '}' in JSON at position 1"; + assert(err.message.includes(nodeV20Error) || err.message.includes(nodeV18Error)); + assert(err.message.includes('Failed to parse the manifest file for this project')); + return; + } + } + assert(false); + }); + }); + + describe('contains project manifest file', async () => { + const tempDir = path.join(process.cwd(), 'tmp'); + const filePath = path.join(tempDir, 'manifest.json'); + const manifest = { + display_information: { + name: 'Example app', + }, + functions: { + sample_function: { + title: 'Sample function', + description: 'Runs a sample function', + input_parameters: { + sample_input: { + title: 'Sample input text', + }, + }, + }, + }, + }; + + before(() => { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir); + } + fs.writeFileSync(filePath, JSON.stringify(manifest, null, 2)); + }); + + after(() => { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + it('should return existing manifest values', async () => { + try { + const parsedManifest = getManifestData(process.cwd()); + assert.deepEqual(manifest, parsedManifest); + } catch (err) { + console.error(err); // eslint-disable-line no-console + assert(false); + } + }); + }); +}); diff --git a/packages/cli-hooks/src/protocols.js b/packages/cli-hooks/src/protocols.js new file mode 100644 index 000000000..9c12a67db --- /dev/null +++ b/packages/cli-hooks/src/protocols.js @@ -0,0 +1,120 @@ +import minimist from 'minimist'; + +/** + * An interface encapsulating a specific set of communication rules that the + * SDK and the CLI both implement. + * @typedef {object} Protocol + * @property {string} name - + * Label representing the name of the protocol as expected by the Slack CLI. + * @property {typeof console.log} log - + * Logging utility to surface diagnostic info from the SDK or username code. + * @property {typeof console.error} error - + * Logging utility to surface errors from the SDK or username code. + * @property {typeof console.warn} warn - + * Logging utility to surface warnings from the SDK or username code. + * @property {(data: string) => void} respond - + * Utility method for responding to hook invocations with stringified JSON. + * @property {() => string[]} [getCLIFlags] - + * Retrieves all command-line flags related to the protocol implementation. + * Most useful when child processes are being spawned by the SDK. + * @property {() => void} [install] - + * Optional instructions for the protocol to install itself into the runtime. + * Similar to stubbing functionality in mocking or testing setup utilties. + * Ensures that the protocol expectations are met by userland and SDK code. + * @property {() => void} [uninstall] - + * Optional instructions for the protocol to remove itself from the runtime. + */ + +const DEFAULT_PROTOCOL = 'default'; +const MSG_BOUNDARY_PROTOCOL = 'message-boundaries'; +export const SUPPORTED_NAMED_PROTOCOLS = [MSG_BOUNDARY_PROTOCOL]; + +/** + * The default CLI-SDK protocol. All responses in this protocol go to stdout. + * The CLI combines both stdout and stderr to interpret the hook response. + * This simplistic protocol has inherent limitation: no logging diagnostics! + * @param {string[]} args - Command-line arguments passed to this process. + * @returns {Protocol} Specified communication rules for the SDK to follow. + */ +export function DefaultProtocol(args) { + const { manifest: manifestOnly = false } = parseArgs(args); + + // If the particular hook invocation is requesting manifest generation we + // ensure any logging is a no-op to prevent littering stdout with logging + // and confusing the CLI's manifest JSON payload parsing. + const loggerMethod = manifestOnly ? () => { } : console.log; // eslint-disable-line no-console + return { + name: DEFAULT_PROTOCOL, + log: loggerMethod, + error: loggerMethod, + warn: loggerMethod, + respond: console.log, // eslint-disable-line no-console + }; +} + +/** + * Protocol implementation that uses both stdout and stderr for logs and alerts, + * but also uses message boundaries to differentiate between application created + * diagnostic information and hook responses. + * @param {string[]} args - Command-line arguments passed to this process. + * @returns {Protocol} Specified communication rules for the SDK to follow. + */ +export function MessageBoundaryProtocol(args) { + const { boundary } = parseArgs(args); + if (!boundary) throw new Error('No boundary argument provided!'); + const protocol = { + name: MSG_BOUNDARY_PROTOCOL, + log: console.log, // eslint-disable-line no-console + error: console.error, // eslint-disable-line no-console + warn: console.warn, // eslint-disable-line no-console + respond: (/** @type {any} */ data) => { + console.log(boundary + data + boundary); // eslint-disable-line no-console + }, + getCLIFlags: () => [ + `--protocol=${MSG_BOUNDARY_PROTOCOL}`, + `--boundary=${boundary}`, + ], + }; + return protocol; +} + +// A map of protocol names to protocol implementations +const PROTOCOL_MAP = { + [SUPPORTED_NAMED_PROTOCOLS[0]]: MessageBoundaryProtocol, +}; + +/** + * Determines the protocol implementation to use for communicating with the CLI. + * Based on the arguments provided by the CLI to the SDK hook process. + * @param {string[]} args - Command-line flags and arguments passed to the hook. + * @returns {Protocol} The interface to follow when messaging to the CLI. + */ +export function getProtocol(args) { + const { protocol: protocolRequestedByCLI } = parseArgs(args); + if (protocolRequestedByCLI) { + if (SUPPORTED_NAMED_PROTOCOLS.includes(protocolRequestedByCLI)) { + const protocol = PROTOCOL_MAP[protocolRequestedByCLI]; + + // Allow support for protocol implementations to either be: + // - a function, using arguments passed to this process to + // dynamically instantiate a Protocol interface + // - an object implementing the Protocol interface directly + if (typeof protocol === 'function') { + return protocol(args); + } + return protocol; + } + } + + // If protocol negotiation fails for any reason, return the default protocol + return DefaultProtocol(args); +} + +/** + * Parses command line arguments into a consumable object. + * @param {string[]} argv - Command line values. + * @returns {import("minimist").ParsedArgs} The object of parsed arguments. + */ +function parseArgs(argv) { + return minimist(argv); +} diff --git a/packages/cli-hooks/src/protocols.spec.js b/packages/cli-hooks/src/protocols.spec.js new file mode 100644 index 000000000..e99c3ea38 --- /dev/null +++ b/packages/cli-hooks/src/protocols.spec.js @@ -0,0 +1,84 @@ +/* eslint-disable no-console */ + +import { afterEach, beforeEach, describe, it } from 'mocha'; +import assert from 'assert'; +import sinon from 'sinon'; + +import { + DefaultProtocol, + MessageBoundaryProtocol, + getProtocol, +} from './protocols.js'; + +describe('protocol implementations', () => { + describe('default protocol', () => { + it('stubs logging methods with a manifest flag', () => { + const protocol = DefaultProtocol(['--manifest']); + assert.notEqual(protocol.log, console.log); + assert.notEqual(protocol.error, console.error); + assert.notEqual(protocol.warn, console.warn); + assert.equal(protocol.respond, console.log); + }); + + it('uses console log methods without a manifest', () => { + const protocol = DefaultProtocol(['--flag']); + assert.equal(protocol.log, console.log); + assert.equal(protocol.error, console.log); + assert.equal(protocol.warn, console.log); + assert.equal(protocol.respond, console.log); + }); + }); + + describe('message boundary protocol', () => { + /** @type {sinon.SinonStub} */ + let consoleLogStub; + + beforeEach(() => { + consoleLogStub = sinon.stub(console, 'log'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('errors if no boundary is specified', () => { + assert.throws( + () => { + MessageBoundaryProtocol([]); + }, + /^Error: No boundary argument provided!$/, + ); + }); + + it('uses the corresponding console methods', () => { + const protocol = MessageBoundaryProtocol(['--boundary=line']); + assert.equal(protocol.log, console.log); + assert.equal(protocol.error, console.error); + assert.equal(protocol.warn, console.warn); + }); + + it('surrounds hook responses with the boundary', () => { + const protocol = MessageBoundaryProtocol(['--boundary=x08o']); + protocol.respond('greetings'); + consoleLogStub.calledWith('x08ogreetingsx08o'); + }); + }); + + describe('get protocol interface', () => { + it('returns the default protocol by default', () => { + const protocol = getProtocol([]).name; + assert.equal(protocol, 'default'); + }); + + it('returns the default protocol if unrecognized', () => { + const protocol = getProtocol(['--protocol=cryptics']).name; + assert.equal(protocol, 'default'); + }); + + it('returns the specified message boundary protocol', () => { + const args = ['--protocol=message-boundaries', '--boundary=racecar']; + const protocol = getProtocol(args).name; + assert.equal(protocol, 'message-boundaries'); + }); + }); +}); diff --git a/packages/cli-hooks/src/start.js b/packages/cli-hooks/src/start.js new file mode 100755 index 000000000..741c48528 --- /dev/null +++ b/packages/cli-hooks/src/start.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import childProcess from 'child_process'; +import path from 'path'; +import fs from 'fs'; + +/** + * Implementation of the start hook that begins a new process to run the app. + * The child processes will continue until exited or interrupted by the caller. + */ + +if (fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + start(process.cwd()); +} + +/** + * Start hook implementation that verifies and runs an app in Socket Mode. + * @param {string} cwd - The current working directory of the project. + */ +export default function start(cwd) { + validateEnvironment(); + + const customPath = process.env.SLACK_CLI_CUSTOM_FILE_PATH; + const pkgJSONMain = getPackageJSONMain(cwd); + const pkgJSONDefault = 'app.js'; + const fullPath = path.resolve(cwd, customPath || pkgJSONMain || pkgJSONDefault); + + const app = childProcess.spawn('node', [`${fullPath}`]); + app.stdout.setEncoding('utf-8'); + app.stdout.on('data', (data) => { + process.stdout.write(data); + }); + app.stderr.on('data', (data) => { + process.stderr.write(data); + }); + app.on('close', (code) => { + console.log(`Local run exited with code ${code}`); // eslint-disable-line no-console + }); +} + +/** + * Gathers the main value from the package.json for the project if present. + * @param {string} cwd - The current working directory of the project. + * @returns {string | undefined} - The main script to run for the project. + */ +function getPackageJSONMain(cwd) { + try { + const packageJSONPath = path.join(cwd, 'package.json'); + const { main } = JSON.parse(fs.readFileSync(packageJSONPath, 'utf-8')); + return main; + } catch { + return undefined; + } +} + +/** + * Confirms environment variables are prepared by the CLI. + */ +function validateEnvironment() { + const missingTokenError = `Missing the {type} token needed to start the app with Socket Mode. +Hints: Setting the {token} environment variable is required. +Check: Confirm that you are using the latest version of the Slack CLI.`; + if (!process.env.SLACK_CLI_XOXB) { + const missingBotTokenError = missingTokenError.replace('{type}', 'bot').replace('{token}', 'SLACK_CLI_XOXB'); + throw new Error(missingBotTokenError); + } + if (!process.env.SLACK_CLI_XAPP) { + const missingAppTokenError = missingTokenError.replace('{type}', 'app').replace('{token}', 'SLACK_CLI_XAPP'); + throw new Error(missingAppTokenError); + } +} diff --git a/packages/cli-hooks/src/start.spec.js b/packages/cli-hooks/src/start.spec.js new file mode 100644 index 000000000..1561c72e9 --- /dev/null +++ b/packages/cli-hooks/src/start.spec.js @@ -0,0 +1,172 @@ +import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; +import assert from 'assert'; +import childProcess from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import sinon from 'sinon'; + +import start from './start.js'; + +/** + * @typedef MockStreams + * @property {sinon.SinonStub} on - The istener function for this event stream. + * @property {() => void} [setEncoding] - Character encoding of the output bytes. + */ + +/** + * @typedef MockSpawnProcess + * @property {MockStreams} stdout - Output logged to standard output streams. + * @property {MockStreams} stderr - Output logged to standard error streams. + * @property {sinon.SinonStub} on - A fallback event to mock the spawn closure. + */ + +describe('start implementation', async () => { + describe('begins the app process', async () => { + /** @type {sinon.SinonStub} */ + let consoleLogStub; + /** @type {sinon.SinonStub} */ + let stdoutWriteStub; + /** @type {sinon.SinonStub} */ + let stderrWriteStub; + /** @type {MockSpawnProcess} */ + let mockSpawnProcess; + /** @type {sinon.SinonStub} */ + let spawnStub; + + beforeEach(() => { + consoleLogStub = sinon.stub(console, 'log'); + stdoutWriteStub = sinon.stub(process.stdout, 'write'); + stderrWriteStub = sinon.stub(process.stderr, 'write'); + mockSpawnProcess = { + stdout: { on: sinon.stub(), setEncoding: () => { } }, + stderr: { on: sinon.stub() }, + on: sinon.stub(), + }; + spawnStub = sinon.stub(childProcess, 'spawn').returns(/** @type {any} */(mockSpawnProcess)); + process.env.SLACK_CLI_XAPP = 'xapp-example'; + process.env.SLACK_CLI_XOXB = 'xoxb-example'; + }); + + afterEach(() => { + sinon.restore(); + delete process.env.SLACK_CLI_XOXB; + delete process.env.SLACK_CLI_XAPP; + }); + + describe('runs the package main path', async () => { + const tempDir = path.join(process.cwd(), 'tmp'); + const packageJSONFilePath = path.join(tempDir, 'package.json'); + + before(() => { + const mainPackageJSON = { name: 'Example application', main: 'start.js' }; + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir); + } + fs.writeFileSync(packageJSONFilePath, JSON.stringify(mainPackageJSON, null, 2)); + }); + + after(() => { + if (fs.existsSync(packageJSONFilePath)) { + fs.unlinkSync(packageJSONFilePath); + } + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + it('writes output from the main script', () => { + start('./tmp'); + mockSpawnProcess.stdout.on.callArgWith(1, 'message'); + mockSpawnProcess.stderr.on.callArgWith(1, 'warning'); + mockSpawnProcess.on.callArgWith(1, 0); + + assert.ok(spawnStub.called); + assert.ok(spawnStub.calledWith('node', [path.resolve('tmp', 'start.js')])); + assert.ok(stdoutWriteStub.calledWith('message')); + assert.ok(stderrWriteStub.calledWith('warning')); + assert.ok(consoleLogStub.calledWith('Local run exited with code 0')); + }); + }); + + describe('runs the default app path', async () => { + it('writes output from the default script', async () => { + start('./tmp'); + mockSpawnProcess.stdout.on.callArgWith(1, 'defaults'); + mockSpawnProcess.stderr.on.callArgWith(1, 'watch out'); + mockSpawnProcess.on.callArgWith(1, 2); + + assert.ok(spawnStub.called); + assert.ok(spawnStub.calledWith('node', [path.resolve('tmp', 'app.js')])); + assert.ok(stdoutWriteStub.calledWith('defaults')); + assert.ok(stderrWriteStub.calledWith('watch out')); + assert.ok(consoleLogStub.calledWith('Local run exited with code 2')); + }); + }); + + describe('runs the custom app path', async () => { + before(() => { + process.env.SLACK_CLI_CUSTOM_FILE_PATH = 'application.js'; + }); + + after(() => { + delete process.env.SLACK_CLI_CUSTOM_FILE_PATH; + }); + + it('writes output from the custom script', async () => { + start('./'); + mockSpawnProcess.stdout.on.callArgWith(1, 'startled'); + mockSpawnProcess.stderr.on.callArgWith(1, 'erroneous'); + mockSpawnProcess.on.callArgWith(1, 4); + + assert.ok(spawnStub.called); + assert.ok(spawnStub.calledWith('node', [path.resolve('application.js')])); + assert.ok(stdoutWriteStub.calledWith('startled')); + assert.ok(stderrWriteStub.calledWith('erroneous')); + assert.ok(consoleLogStub.calledWith('Local run exited with code 4')); + }); + }); + }); + + describe('without valid tokens', async () => { + beforeEach(() => { + sinon.stub(console, 'log'); + delete process.env.SLACK_CLI_XOXB; + delete process.env.SLACK_CLI_XAPP; + }); + afterEach(() => { + sinon.restore(); + delete process.env.SLACK_CLI_XOXB; + delete process.env.SLACK_CLI_XAPP; + }); + + it('should error without a bot token', async () => { + try { + process.env.SLACK_CLI_XAPP = 'xapp-example'; + start('./'); + } catch (err) { + if (err instanceof Error) { + assert(err.message.includes('Missing the bot token needed to start the app with Socket Mode.')); + assert(err.message.includes('Hints: Setting the SLACK_CLI_XOXB environment variable is required.')); + assert(err.message.includes('Check: Confirm that you are using the latest version of the Slack CLI.')); + return; + } + } + assert(false); + }); + + it('should error without an app token', async () => { + try { + process.env.SLACK_CLI_XOXB = 'xoxb-example'; + start('./'); + } catch (err) { + if (err instanceof Error) { + assert(err.message.includes('Missing the app token needed to start the app with Socket Mode.')); + assert(err.message.includes('Hints: Setting the SLACK_CLI_XAPP environment variable is required.')); + assert(err.message.includes('Check: Confirm that you are using the latest version of the Slack CLI.')); + return; + } + } + assert(false); + }); + }); +});