├── README.md ├── .gitignore ├── src ├── tests │ ├── fixtures │ │ ├── manifests │ │ │ ├── invalid-manifest-json │ │ │ │ └── manifest.json │ │ │ ├── valid-manifest-json │ │ │ │ └── manifest.json │ │ │ ├── invalid-manifest-js │ │ │ │ └── manifest.js │ │ │ ├── valid-manifest-js │ │ │ │ └── manifest.js │ │ │ ├── non-object-manifest-js │ │ │ │ └── manifest.js │ │ │ ├── non-object-manifest-ts │ │ │ │ └── manifest.ts │ │ │ ├── invalid-manifest-ts │ │ │ │ └── manifest.ts │ │ │ └── valid-manifest-ts │ │ │ │ └── manifest.ts │ │ ├── apps │ │ │ ├── function-no-default-export │ │ │ │ ├── functions │ │ │ │ │ └── test_function_no_export_file.ts │ │ │ │ └── manifest.json │ │ │ ├── non-function-default-export │ │ │ │ ├── functions │ │ │ │ │ └── test_function_not_function_file.ts │ │ │ │ └── manifest.json │ │ │ ├── missing-source_file │ │ │ │ └── manifest.json │ │ │ └── non-existent-function │ │ │ │ └── manifest.json │ │ ├── functions │ │ │ ├── test_function_not_function_file.ts │ │ │ ├── test_function_file.ts │ │ │ └── test_function_no_export_file.ts │ │ └── triggers │ │ │ ├── valid_trigger.json │ │ │ ├── valid_trigger.ts │ │ │ ├── non_object_trigger.ts │ │ │ └── no_default_export_trigger.ts │ ├── mod_test.ts │ ├── flags_test.ts │ ├── get_trigger_test.ts │ ├── doctor_test.ts │ ├── utilities_test.ts │ ├── get_manifest_test.ts │ ├── install_update_test.ts │ ├── check_update_test.ts │ └── build_test.ts ├── version.ts ├── errors.ts ├── bundler │ ├── mods.ts │ ├── esbuild_bundler_test.ts │ ├── deno2_bundler.ts │ ├── deno_bundler.ts │ ├── esbuild_bundler.ts │ ├── deno2_bundler_test.ts │ └── deno_bundler_test.ts ├── dev_deps.ts ├── libraries.ts ├── flags.ts ├── get_trigger.ts ├── mod.ts ├── doctor.ts ├── utilities.ts ├── get_manifest.ts ├── build.ts ├── install_update.ts ├── README.md └── check_update.ts ├── .vscode └── settings.json ├── .github ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── CODE_OF_CONDUCT.md ├── workflows │ ├── deno-cd.yml │ └── deno-ci.yml ├── CONTRIBUTING.md └── maintainers_guide.md ├── LICENSE └── deno.jsonc /README.md: -------------------------------------------------------------------------------- 1 | src/README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .DS_Store 3 | lcov.info 4 | -------------------------------------------------------------------------------- /src/tests/fixtures/manifests/invalid-manifest-json/manifest.json: -------------------------------------------------------------------------------- 1 | {"invalid-json-lol 2 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | const VERSION = "1.5.0"; 2 | export default VERSION; 3 | 4 | if (import.meta.main) { 5 | console.log(VERSION); 6 | } 7 | -------------------------------------------------------------------------------- /src/tests/fixtures/apps/function-no-default-export/functions/test_function_no_export_file.ts: -------------------------------------------------------------------------------- 1 | ../../../functions/test_function_no_export_file.ts -------------------------------------------------------------------------------- /src/tests/fixtures/manifests/valid-manifest-json/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proxy for an app with only a manifest.json in its root" 3 | } 4 | -------------------------------------------------------------------------------- /src/tests/fixtures/apps/non-function-default-export/functions/test_function_not_function_file.ts: -------------------------------------------------------------------------------- 1 | ../../../functions/test_function_not_function_file.ts -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class BundleError extends Error { 2 | constructor(options?: ErrorOptions) { 3 | super("Error bundling function file", options); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/tests/fixtures/functions/test_function_not_function_file.ts: -------------------------------------------------------------------------------- 1 | // Consumed in the build and get-manifest hook tests as well as utilities tests 2 | export default "hello"; 3 | -------------------------------------------------------------------------------- /src/tests/fixtures/manifests/invalid-manifest-js/manifest.js: -------------------------------------------------------------------------------- 1 | export default 2 | name: "i love anyscript", 3 | description: "this is a manifest.js for testing only" 4 | } 5 | -------------------------------------------------------------------------------- /src/tests/fixtures/manifests/valid-manifest-js/manifest.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "i love anyscript", 3 | description: "this is a manifest.js for testing only" 4 | } 5 | -------------------------------------------------------------------------------- /src/bundler/mods.ts: -------------------------------------------------------------------------------- 1 | export { EsbuildBundler } from "./esbuild_bundler.ts"; 2 | export { DenoBundler } from "./deno_bundler.ts"; 3 | export { Deno2Bundler } from "./deno2_bundler.ts"; 4 | -------------------------------------------------------------------------------- /src/tests/fixtures/manifests/non-object-manifest-js/manifest.js: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | name: "this is not a valid manifest", 3 | description: "because the default export should be an object" 4 | }) 5 | -------------------------------------------------------------------------------- /src/tests/fixtures/manifests/non-object-manifest-ts/manifest.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | name: "this is not a valid manifest", 3 | description: "because the default export should be an object" 4 | }) 5 | -------------------------------------------------------------------------------- /src/tests/fixtures/functions/test_function_file.ts: -------------------------------------------------------------------------------- 1 | // Consumed in the build and get-manifest hook tests as well as utilities tests 2 | export default () => { 3 | console.log("this is my custom function"); 4 | }; 5 | -------------------------------------------------------------------------------- /src/tests/fixtures/manifests/invalid-manifest-ts/manifest.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | display_information: { 3 | name: "surreptitious-nardwhal-420", 4 | description: "a valid minimal manifest.ts for testing purposes", 5 | -------------------------------------------------------------------------------- /src/tests/fixtures/functions/test_function_no_export_file.ts: -------------------------------------------------------------------------------- 1 | // Consumed in the build and get-manifest hook tests as well as utilities tests 2 | export const func = () => { 3 | console.log("this is my custom function"); 4 | }; 5 | -------------------------------------------------------------------------------- /src/tests/fixtures/triggers/valid_trigger.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "shortcut", 3 | "name": "Send a greeting", 4 | "description": "Send greeting to channel", 5 | "workflow": "#/workflows/greeting_workflow", 6 | "inputs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/tests/fixtures/manifests/valid-manifest-ts/manifest.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | display_information: { 3 | name: "surreptitious-nardwhal-420", 4 | description: "a valid minimal manifest.ts for testing purposes", 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /src/tests/fixtures/triggers/valid_trigger.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | type: "shortcut", 3 | name: "Send a greeting", 4 | description: "Send greeting to channel", 5 | workflow: "#/workflows/greeting_workflow", 6 | inputs: {}, 7 | } 8 | -------------------------------------------------------------------------------- /src/tests/fixtures/triggers/non_object_trigger.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | type: "shortcut", 3 | name: "Send a greeting", 4 | description: "Send greeting to channel", 5 | workflow: "#/workflows/greeting_workflow", 6 | inputs: {}, 7 | }) 8 | -------------------------------------------------------------------------------- /src/tests/fixtures/triggers/no_default_export_trigger.ts: -------------------------------------------------------------------------------- 1 | export const trigger = { 2 | type: "shortcut", 3 | name: "Send a greeting", 4 | description: "Send greeting to channel", 5 | workflow: "#/workflows/greeting_workflow", 6 | inputs: {}, 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.config": "./deno.jsonc", 5 | "[typescript]": { 6 | "editor.formatOnSave": true, 7 | "editor.defaultFormatter": "denoland.vscode-deno" 8 | }, 9 | "deno.suggest.imports.hosts": { 10 | "https://deno.land": false 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | interval: "monthly" 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | Describe the goal of this PR. Mention any related Issue numbers. 4 | 5 | ### Requirements (place an `x` in each `[ ]`) 6 | 7 | * [ ] I've read and understood the [Contributing Guidelines](https://github.com/{project_slug}/blob/main/.github/CONTRIBUTING.md) and have done my best effort to follow them. 8 | * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Salesforce Open Source project configuration 2 | # Learn more: https://github.com/salesforce/oss-template 3 | #ECCN:Open Source 4 | #GUSINFO:Open Source,Open Source Workflow 5 | 6 | # @slackapi/denosaurs 7 | # are code reviewers for all changes in this repo. 8 | * @slackapi/denosaurs 9 | 10 | # @slackapi/developer-education 11 | # are code reviewers for changes in the `/docs` directory. 12 | /docs/ @slackapi/developer-education 13 | -------------------------------------------------------------------------------- /src/dev_deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assertEquals, 3 | assertExists, 4 | assertRejects, 5 | assertStringIncludes, 6 | } from "jsr:@std/assert@1.0.13"; 7 | export { 8 | assertSpyCall, 9 | assertSpyCalls, 10 | returnsNext, 11 | type Spy, 12 | spy, 13 | stub, 14 | } from "jsr:@std/testing@1.0.13/mock"; 15 | export * as mockFile from "https://deno.land/x/mock_file@v1.1.2/mod.ts"; 16 | export { MockProtocol } from "jsr:@slack/protocols@0.0.3/mock"; 17 | -------------------------------------------------------------------------------- /src/tests/fixtures/apps/missing-source_file/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "test_function": { 4 | "title": "Test function", 5 | "description": "this function definition doesnt even have a source_file", 6 | "input_parameters": { 7 | "required": [], 8 | "properties": {} 9 | }, 10 | "output_parameters": { 11 | "required": [], 12 | "properties": {} 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/tests/mod_test.ts: -------------------------------------------------------------------------------- 1 | import { assertStringIncludes } from "../dev_deps.ts"; 2 | import { VERSIONS } from "../libraries.ts"; 3 | import { projectScripts } from "../mod.ts"; 4 | 5 | Deno.test("projectScripts should return a start hook that points to the enshrined deno-slack-runtime version", () => { 6 | const result = projectScripts([]); 7 | assertStringIncludes( 8 | result.hooks["start"], 9 | `deno_slack_runtime@${VERSIONS.deno_slack_runtime}/local-run.ts`, 10 | ); 11 | }); 12 | -------------------------------------------------------------------------------- /src/tests/fixtures/apps/function-no-default-export/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "test_function": { 4 | "title": "Test function", 5 | "description": "this is a test", 6 | "source_file": 7 | "functions/test_function_no_export_file.ts", 8 | "input_parameters": { 9 | "required": [], 10 | "properties": {} 11 | }, 12 | "output_parameters": { 13 | "required": [], 14 | "properties": {} 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/tests/fixtures/apps/non-function-default-export/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "test_function": { 4 | "title": "Test function", 5 | "description": "this is a test", 6 | "source_file": 7 | "functions/test_function_not_function_file.ts", 8 | "input_parameters": { 9 | "required": [], 10 | "properties": {} 11 | }, 12 | "output_parameters": { 13 | "required": [], 14 | "properties": {} 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/tests/fixtures/apps/non-existent-function/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "test_function": { 4 | "title": "Test function", 5 | "description": "the below source_file actually doesnt exist", 6 | "source_file": 7 | "functions/i-dont-exist.ts", 8 | "input_parameters": { 9 | "required": [], 10 | "properties": {} 11 | }, 12 | "output_parameters": { 13 | "required": [], 14 | "properties": {} 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/libraries.ts: -------------------------------------------------------------------------------- 1 | import hooksVersion from "./version.ts"; 2 | 3 | export const DENO_SLACK_SDK = "deno_slack_sdk"; 4 | export const DENO_SLACK_API = "deno_slack_api"; 5 | export const DENO_SLACK_HOOKS = "deno_slack_hooks"; 6 | export const DENO_SLACK_RUNTIME = "deno_slack_runtime"; 7 | 8 | export const VERSIONS = { 9 | [DENO_SLACK_RUNTIME]: "1.1.3", 10 | [DENO_SLACK_HOOKS]: hooksVersion, 11 | }; 12 | 13 | export const RUNTIME_TAG = `${DENO_SLACK_RUNTIME}@${ 14 | VERSIONS[DENO_SLACK_RUNTIME] 15 | }`; 16 | export const HOOKS_TAG = `${DENO_SLACK_HOOKS}@${VERSIONS[DENO_SLACK_HOOKS]}`; 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Description 11 | 12 | Describe your request here. 13 | 14 | ### Requirements (place an `x` in each of the `[ ]`) 15 | * [ ] I've read and understood the [Contributing guidelines](../CONTRIBUTING.md) and have done my best effort to follow them. 16 | * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). 17 | * [ ] I've searched for any related issues and avoided creating a duplicate issue. 18 | -------------------------------------------------------------------------------- /src/flags.ts: -------------------------------------------------------------------------------- 1 | import { parseArgs } from "jsr:@std/cli@^1.0.4"; 2 | 3 | const UNSAFELY_IGNORE_CERT_ERRORS_FLAG = 4 | "sdk-unsafely-ignore-certificate-errors"; 5 | const SLACK_DEV_DOMAIN_FLAG = "sdk-slack-dev-domain"; 6 | 7 | export const getStartHookAdditionalDenoFlags = (args: string[]): string => { 8 | const parsedArgs = parseArgs(args); 9 | const extraFlagValue = parsedArgs[SLACK_DEV_DOMAIN_FLAG] ?? 10 | parsedArgs[UNSAFELY_IGNORE_CERT_ERRORS_FLAG] ?? ""; 11 | 12 | let extraFlag = ""; 13 | if (extraFlagValue) { 14 | extraFlag = `--sdk-slack-dev-domain=${extraFlagValue}`; 15 | } 16 | return extraFlag; 17 | }; 18 | -------------------------------------------------------------------------------- /src/bundler/esbuild_bundler_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std@0.138.0/testing/asserts.ts"; 2 | import { assertExists } from "../dev_deps.ts"; 3 | import { EsbuildBundler } from "./esbuild_bundler.ts"; 4 | 5 | Deno.test("Esbuild Bundler tests", async (t) => { 6 | await t.step(EsbuildBundler.bundle.name, async (tt) => { 7 | await tt.step( 8 | "should invoke 'esbuild.build' successfully", 9 | async () => { 10 | const bundle = await EsbuildBundler.bundle( 11 | { 12 | entrypoint: "src/tests/fixtures/functions/test_function_file.ts", 13 | configPath: `${Deno.cwd()}/deno.jsonc`, 14 | absWorkingDir: Deno.cwd(), 15 | }, 16 | ); 17 | assertExists(bundle); 18 | assertEquals(bundle.length, 195); 19 | }, 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/bundler/deno2_bundler.ts: -------------------------------------------------------------------------------- 1 | import { BundleError } from "../errors.ts"; 2 | 3 | type DenoBundleOptions = { 4 | /** The path to the file being bundled */ 5 | entrypoint: string; 6 | /** The path where the bundled file should be written. */ 7 | outFile: string; 8 | }; 9 | 10 | export const Deno2Bundler = { 11 | bundle: async (options: DenoBundleOptions): Promise => { 12 | // call out to deno to handle bundling 13 | const command = new Deno.Command(Deno.execPath(), { 14 | args: [ 15 | "bundle", 16 | "--quiet", 17 | options.entrypoint, 18 | "--output", 19 | options.outFile, 20 | ], 21 | }); 22 | 23 | const { code, stderr } = await command.output(); 24 | if (code !== 0) { 25 | throw new BundleError({ 26 | cause: new TextDecoder().decode(stderr), 27 | }); 28 | } 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Introduction 4 | 5 | Diversity and inclusion make our community strong. We encourage participation from the most varied and diverse backgrounds possible and want to be very clear about where we stand. 6 | 7 | Our goal is to maintain a safe, helpful and friendly community for everyone, regardless of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other defining characteristic. 8 | 9 | This code and related procedures also apply to unacceptable behavior occurring outside the scope of community activities, in all community venues (online and in-person) as well as in all one-on-one communications, and anywhere such behavior has the potential to adversely affect the safety and well-being of community members. 10 | 11 | For more information on our code of conduct, please visit [https://slackhq.github.io/code-of-conduct](https://slackhq.github.io/code-of-conduct) -------------------------------------------------------------------------------- /src/bundler/deno_bundler.ts: -------------------------------------------------------------------------------- 1 | import { BundleError } from "../errors.ts"; 2 | 3 | type DenoBundleOptions = { 4 | /** The path to the file being bundled */ 5 | entrypoint: string; 6 | /** The path where the bundled file should be written. */ 7 | outFile: string; 8 | }; 9 | 10 | /** 11 | * @deprecated This bundler only works with Deno 1.x versions. For Deno 2.x and newer, 12 | * use the Deno2Bundler or EsbuildBundler instead. 13 | */ 14 | 15 | export const DenoBundler = { 16 | bundle: async (options: DenoBundleOptions): Promise => { 17 | // call out to deno to handle bundling 18 | const command = new Deno.Command(Deno.execPath(), { 19 | args: [ 20 | "bundle", 21 | "--quiet", 22 | options.entrypoint, 23 | options.outFile, 24 | ], 25 | }); 26 | 27 | const { code, stderr } = await command.output(); 28 | if (code !== 0) { 29 | throw new BundleError({ 30 | cause: new TextDecoder().decode(stderr), 31 | }); 32 | } 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ### Requirements (place an `x` in each of the `[ ]`)** 14 | * [ ] I've read and understood the [Contributing guidelines](../CONTRIBUTING.md) and have done my best effort to follow them. 15 | * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). 16 | * [ ] I've searched for any related issues and avoided creating a duplicate issue. 17 | 18 | ### To Reproduce 19 | Steps to reproduce the behavior: 20 | 21 | ### Expected behavior 22 | A clear and concise description of what you expected to happen. 23 | 24 | #### Screenshots 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | #### Reproducible in: 28 | 29 | {project_name} version: 30 | 31 | {platform_name} version: 32 | 33 | OS version(s): 34 | 35 | #### Additional context 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Slack Technologies, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", 3 | "fmt": { 4 | "include": ["src", "docs", "README.md"], 5 | "exclude": ["src/tests/fixtures"], 6 | "semiColons": true, 7 | "indentWidth": 2, 8 | "lineWidth": 80, 9 | "proseWrap": "always", 10 | "singleQuote": false, 11 | "useTabs": false 12 | }, 13 | "lint": { 14 | "include": ["src"], 15 | "exclude": ["src/tests/fixtures"], 16 | "rules": { 17 | "exclude": ["no-import-prefix"] 18 | } 19 | }, 20 | "test": { 21 | "include": ["src"], 22 | "exclude": ["src/tests/fixtures"] 23 | }, 24 | "tasks": { 25 | "test": "deno fmt --check && deno lint && deno test --allow-read --allow-net --allow-write --allow-run --allow-env src", 26 | "generate-lcov": "rm -rf .coverage && deno test --reporter=dot --allow-read --allow-net --allow-write --allow-run --allow-env --coverage=.coverage src && deno coverage --exclude=fixtures --exclude=test --lcov --output=lcov.info .coverage", 27 | "test:coverage": "deno task generate-lcov && deno coverage --exclude=fixtures --exclude=test .coverage" 28 | }, 29 | "lock": false 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/deno-cd.yml: -------------------------------------------------------------------------------- 1 | name: Deno Continuous Deployment 2 | 3 | on: 4 | push: 5 | paths: 6 | - "src/version.ts" 7 | branches: 8 | - main 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | steps: 16 | - name: Setup repo 17 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 18 | with: 19 | persist-credentials: false 20 | 21 | - name: Setup Deno 22 | uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3 23 | with: 24 | deno-version: v2.x 25 | 26 | - name: Extract Tag Name from version.ts 27 | id: save_version 28 | run: | 29 | echo "TAG_NAME=$(deno run -q --allow-read src/version.ts)" >> $GITHUB_ENV 30 | echo "We will now create the ${{ env.TAG_NAME }} tag." 31 | 32 | - name: Create and push git tag 33 | uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1.7.2 34 | with: 35 | tag: "${{ env.TAG_NAME }}" 36 | 37 | - name: Create GitHub Release 38 | uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 39 | with: 40 | tag: "${{ env.TAG_NAME }}" 41 | name: "${{ env.TAG_NAME }}" 42 | generateReleaseNotes: true 43 | prerelease: true 44 | -------------------------------------------------------------------------------- /src/bundler/esbuild_bundler.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from "npm:esbuild@0.24.2"; 2 | import { denoPlugins } from "jsr:@luca/esbuild-deno-loader@0.11.1"; 3 | 4 | type EsbuildBundleOptions = { 5 | /** The path to the file being bundled */ 6 | entrypoint: string; 7 | /** The path to the deno.json / deno.jsonc config file. */ 8 | configPath: string; 9 | /** specify the working directory to use for the build */ 10 | absWorkingDir: string; 11 | }; 12 | 13 | export const EsbuildBundler = { 14 | bundle: async (options: EsbuildBundleOptions): Promise => { 15 | try { 16 | // esbuild configuration options https://esbuild.github.io/api/#overview 17 | const result = await esbuild.build({ 18 | entryPoints: [options.entrypoint], 19 | platform: "browser", 20 | // TODO: the versions should come from the user defined input 21 | target: "deno1", 22 | format: "esm", // esm format stands for "ECMAScript module" 23 | bundle: true, // inline any imported dependencies into the file itself 24 | absWorkingDir: options.absWorkingDir, 25 | write: false, // Favor returning the contents 26 | outdir: "out", // Nothing is being written to file here 27 | plugins: [ 28 | ...denoPlugins({ configPath: options.configPath }), 29 | ], 30 | }); 31 | return result.outputFiles[0].contents; 32 | } finally { 33 | esbuild.stop(); 34 | } 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/get_trigger.ts: -------------------------------------------------------------------------------- 1 | import { getProtocolInterface } from "jsr:@slack/protocols@0.0.3"; 2 | import * as path from "jsr:@std/path@^1.0.3"; 3 | import { parseArgs } from "jsr:@std/cli@^1.0.4"; 4 | 5 | import { getDefaultExport } from "./utilities.ts"; 6 | 7 | export const getTrigger = async (args: string[]) => { 8 | const source = parseArgs(args).source as string; 9 | 10 | if (!source) throw new Error("A source path needs to be defined"); 11 | 12 | const fullPath = path.isAbsolute(source) 13 | ? source 14 | : path.join(Deno.cwd(), source || ""); 15 | 16 | return await readFile(fullPath); 17 | }; 18 | 19 | const readFile = async (path: string) => { 20 | try { 21 | const { isFile } = await Deno.stat(path); 22 | if (!isFile) throw new Error("The specified source is not a valid file."); 23 | if (path.endsWith(".json")) return readJSONFile(path); 24 | } catch (e) { 25 | if (e instanceof Deno.errors.NotFound) { 26 | throw new Error("Trigger Definition file cannot be found"); 27 | } 28 | throw e; 29 | } 30 | // `getDefaultExport` will throw if no default export exists in module 31 | const trigger = await getDefaultExport(path); 32 | if (typeof trigger != "object") { 33 | throw new Error(`Trigger file: ${path} default export is not an object!`); 34 | } 35 | return trigger; 36 | }; 37 | 38 | const readJSONFile = async (path: string) => { 39 | const jsonString = await Deno.readTextFile(path); 40 | return JSON.parse(jsonString); 41 | }; 42 | 43 | if (import.meta.main) { 44 | const protocol = getProtocolInterface(Deno.args); 45 | protocol.respond( 46 | JSON.stringify(await getTrigger(Deno.args)), 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | import { HOOKS_TAG, RUNTIME_TAG } from "./libraries.ts"; 2 | import { getStartHookAdditionalDenoFlags } from "./flags.ts"; 3 | 4 | export const projectScripts = (args: string[]) => { 5 | const startHookFlags = getStartHookAdditionalDenoFlags(args); 6 | return { 7 | "runtime": "deno", 8 | "hooks": { 9 | "get-manifest": 10 | `deno run -q --config=deno.jsonc --allow-read --allow-net --allow-env --allow-sys=osRelease https://deno.land/x/${HOOKS_TAG}/get_manifest.ts`, 11 | "get-trigger": 12 | `deno run -q --config=deno.jsonc --allow-read --allow-net --allow-env https://deno.land/x/${HOOKS_TAG}/get_trigger.ts`, 13 | "build": 14 | `deno run -q --config=deno.jsonc --allow-read --allow-write --allow-net --allow-run --allow-env --allow-sys=osRelease https://deno.land/x/${HOOKS_TAG}/build.ts`, 15 | "start": 16 | `deno run -q --config=deno.jsonc --allow-read --allow-net --allow-run --allow-env --allow-sys=osRelease https://deno.land/x/${RUNTIME_TAG}/local-run.ts ${startHookFlags}`, 17 | "check-update": 18 | `deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/${HOOKS_TAG}/check_update.ts`, 19 | "install-update": 20 | `deno run -q --config=deno.jsonc --allow-run --allow-read --allow-write --allow-net https://deno.land/x/${HOOKS_TAG}/install_update.ts`, 21 | "doctor": 22 | `deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/${HOOKS_TAG}/doctor.ts`, 23 | }, 24 | "config": { 25 | "protocol-version": ["message-boundaries"], 26 | "watch": { 27 | "filter-regex": "\\.(ts|js)$", 28 | "paths": ["."], 29 | }, 30 | }, 31 | }; 32 | }; 33 | 34 | if (import.meta.main) { 35 | console.log(JSON.stringify(projectScripts(Deno.args))); 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/deno-ci.yml: -------------------------------------------------------------------------------- 1 | name: Deno Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | deno-version: 18 | - v1.x 19 | - v2.x 20 | permissions: 21 | contents: read 22 | steps: 23 | - name: Setup repo 24 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 25 | with: 26 | persist-credentials: false 27 | - name: Setup Deno ${{ matrix.deno-version }} 28 | uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3 29 | with: 30 | deno-version: ${{ matrix.deno-version }} 31 | - name: Run tests 32 | run: deno task test 33 | - name: Generate CodeCov-friendly coverage report 34 | run: deno task generate-lcov 35 | - name: Upload coverage to CodeCov 36 | uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 37 | if: matrix.deno-version == 'v2.x' 38 | with: 39 | files: ./lcov.info 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | 42 | health-score: 43 | needs: test 44 | permissions: 45 | checks: write 46 | contents: read 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Setup repo 50 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 51 | with: 52 | persist-credentials: false 53 | - name: Report health score 54 | uses: slackapi/slack-health-score@d58a419f15cdaff97e9aa7f09f95772830ab66f7 # v0.1.1 55 | with: 56 | codecov_token: ${{ secrets.FILS_CODECOV_API_TOKEN }} 57 | github_token: ${{ secrets.GITHUB_TOKEN }} 58 | extension: ts 59 | include: src 60 | -------------------------------------------------------------------------------- /src/tests/flags_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../dev_deps.ts"; 2 | import { getStartHookAdditionalDenoFlags } from "../flags.ts"; 3 | 4 | Deno.test("getStartHookAdditionalFlags sets sdk-slack-dev-domain, with legacy flag using =", () => { 5 | const result = getStartHookAdditionalDenoFlags([ 6 | "--sdk-unsafely-ignore-certificate-errors=https://dev1234.slack.com", 7 | ]); 8 | assertEquals( 9 | result, 10 | "--sdk-slack-dev-domain=https://dev1234.slack.com", 11 | ); 12 | }); 13 | 14 | Deno.test("getStartHookAdditionalFlags sets sdk-slack-dev-domain, with legacy flag", () => { 15 | const result = getStartHookAdditionalDenoFlags([ 16 | "--sdk-unsafely-ignore-certificate-errors", 17 | "https://dev1234.slack.com", 18 | ]); 19 | assertEquals( 20 | result, 21 | "--sdk-slack-dev-domain=https://dev1234.slack.com", 22 | ); 23 | }); 24 | 25 | Deno.test("getStartHookAdditionalFlags sets sdk-slack-dev-domain, with sdk-slack-dev-domain flag using =", () => { 26 | const result = getStartHookAdditionalDenoFlags([ 27 | "--sdk-slack-dev-domain=https://dev1234.slack.com", 28 | ]); 29 | assertEquals( 30 | result, 31 | "--sdk-slack-dev-domain=https://dev1234.slack.com", 32 | ); 33 | }); 34 | 35 | Deno.test("getStartHookAdditionalFlags sets sdk-slack-dev-domain, with sdk-slack-dev-domain flag", () => { 36 | const result = getStartHookAdditionalDenoFlags([ 37 | "--sdk-slack-dev-domain", 38 | "https://dev1234.slack.com", 39 | ]); 40 | assertEquals( 41 | result, 42 | "--sdk-slack-dev-domain=https://dev1234.slack.com", 43 | ); 44 | }); 45 | 46 | Deno.test("getStartHookAdditionalFlags passes through empty flags", () => { 47 | const result = getStartHookAdditionalDenoFlags([]); 48 | assertEquals(result, ""); 49 | }); 50 | 51 | Deno.test("getStartHookAdditionalFlags passes ignores unsupported flags", () => { 52 | const result = getStartHookAdditionalDenoFlags(["--nonsense=foo"]); 53 | assertEquals(result, ""); 54 | }); 55 | -------------------------------------------------------------------------------- /src/tests/get_trigger_test.ts: -------------------------------------------------------------------------------- 1 | import { getTrigger } from "../get_trigger.ts"; 2 | import { assertRejects, assertStringIncludes } from "../dev_deps.ts"; 3 | 4 | Deno.test("get-trigger hook", async (t) => { 5 | await t.step("getTrigger function", async (tt) => { 6 | await tt.step("should throw if no source CLI flag provided", async () => { 7 | await assertRejects( 8 | async () => await getTrigger([]), 9 | Error, 10 | "source path needs to be defined", 11 | ); 12 | }); 13 | 14 | await tt.step("should throw if provided source is not a file", async () => { 15 | await assertRejects( 16 | async () => await getTrigger(["--source", "src"]), 17 | Error, 18 | "source is not a valid file", 19 | ); 20 | }); 21 | 22 | await tt.step("should return contents of a valid JSON file", async () => { 23 | const json = await getTrigger([ 24 | "--source", 25 | "src/tests/fixtures/triggers/valid_trigger.json", 26 | ]); 27 | assertStringIncludes(json.name, "greeting"); 28 | }); 29 | 30 | await tt.step("should return contents of a valid .ts file", async () => { 31 | const json = await getTrigger([ 32 | "--source", 33 | "src/tests/fixtures/triggers/valid_trigger.ts", 34 | ]); 35 | assertStringIncludes(json.name, "greeting"); 36 | }); 37 | 38 | await tt.step( 39 | "should throw if provided .ts has no default export", 40 | async () => { 41 | await assertRejects( 42 | async () => 43 | await getTrigger([ 44 | "--source", 45 | "src/tests/fixtures/triggers/no_default_export_trigger.ts", 46 | ]), 47 | Error, 48 | "no default export", 49 | ); 50 | }, 51 | ); 52 | 53 | await tt.step( 54 | "should throw if provided .ts has a non-object default export", 55 | async () => { 56 | await assertRejects( 57 | async () => 58 | await getTrigger([ 59 | "--source", 60 | "src/tests/fixtures/triggers/non_object_trigger.ts", 61 | ]), 62 | Error, 63 | "not an object", 64 | ); 65 | }, 66 | ); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/doctor.ts: -------------------------------------------------------------------------------- 1 | import { getProtocolInterface } from "jsr:@slack/protocols@0.0.3"; 2 | import type { Protocol } from "jsr:@slack/protocols@0.0.3/types"; 3 | 4 | import { isNewSemverRelease } from "./utilities.ts"; 5 | 6 | type RuntimeVersion = { 7 | name: string; 8 | current: string; 9 | } & RuntimeDetails; 10 | 11 | type RuntimeDetails = { 12 | message?: string; 13 | error?: { 14 | message: string; 15 | }; 16 | }; 17 | 18 | const getHostedDenoRuntimeVersion = async ( 19 | protocol: Protocol, 20 | ): Promise => { 21 | try { 22 | const metadataURL = "https://api.slack.com/slackcli/metadata.json"; 23 | const response = await fetch(metadataURL); 24 | if (!response.ok || response.status !== 200) { 25 | protocol.warn("Failed to collect upstream CLI metadata:"); 26 | protocol.warn(response); 27 | return {}; 28 | } 29 | const metadata = await response.json(); 30 | const version = metadata?.["deno-runtime"]?.releases[0]?.version; 31 | if (!version) { 32 | const details = JSON.stringify(metadata, null, " "); 33 | protocol.warn( 34 | "Failed to find the minimum Deno version in the upstream CLI metadata response:", 35 | ); 36 | protocol.warn(details); 37 | return {}; 38 | } 39 | const message = Deno.version.deno !== version 40 | ? `Applications deployed to Slack use Deno version ${version}` 41 | : undefined; 42 | if (isNewSemverRelease(Deno.version.deno, version)) { 43 | return { 44 | message, 45 | error: { message: "The installed runtime version is not supported" }, 46 | }; 47 | } 48 | return { message }; 49 | } catch (err) { 50 | protocol.warn("Failed to collect or process upstream CLI metadata:"); 51 | protocol.warn(err); 52 | return {}; 53 | } 54 | }; 55 | 56 | export const getRuntimeVersions = async (protocol: Protocol): Promise<{ 57 | versions: RuntimeVersion[]; 58 | }> => { 59 | const hostedDenoRuntimeVersion = await getHostedDenoRuntimeVersion(protocol); 60 | const versions = [ 61 | { 62 | "name": "deno", 63 | "current": Deno.version.deno, 64 | ...hostedDenoRuntimeVersion, 65 | }, 66 | { 67 | "name": "typescript", 68 | "current": Deno.version.typescript, 69 | }, 70 | { 71 | "name": "v8", 72 | "current": Deno.version.v8, 73 | }, 74 | ]; 75 | return { versions }; 76 | }; 77 | 78 | if (import.meta.main) { 79 | const protocol = getProtocolInterface(Deno.args); 80 | const prunedDoctor = await getRuntimeVersions(protocol); 81 | protocol.respond(JSON.stringify(prunedDoctor)); 82 | } 83 | -------------------------------------------------------------------------------- /src/bundler/deno2_bundler_test.ts: -------------------------------------------------------------------------------- 1 | import { assertRejects, assertSpyCall, stub } from "../dev_deps.ts"; 2 | import { BundleError } from "../errors.ts"; 3 | import { Deno2Bundler } from "./deno2_bundler.ts"; 4 | 5 | Deno.test("Deno2 Bundler tests", async (t) => { 6 | await t.step(Deno2Bundler.bundle.name, async (tt) => { 7 | const expectedEntrypoint = "./function.ts"; 8 | const expectedOutFile = "./dist/bundle.ts"; 9 | 10 | await tt.step( 11 | "should invoke 'deno bundle' successfully", 12 | async () => { 13 | const commandResp = { 14 | output: () => Promise.resolve({ code: 0 }), 15 | } as Deno.Command; 16 | 17 | const commandStub = stub( 18 | Deno, 19 | "Command", 20 | () => commandResp, 21 | ); 22 | 23 | try { 24 | await Deno2Bundler.bundle( 25 | { entrypoint: expectedEntrypoint, outFile: expectedOutFile }, 26 | ); 27 | assertSpyCall(commandStub, 0, { 28 | args: [ 29 | Deno.execPath(), 30 | { 31 | args: [ 32 | "bundle", 33 | "--quiet", 34 | expectedEntrypoint, 35 | "--output", 36 | expectedOutFile, 37 | ], 38 | }, 39 | ], 40 | }); 41 | } finally { 42 | commandStub.restore(); 43 | } 44 | }, 45 | ); 46 | 47 | await tt.step( 48 | "should throw an exception if the 'deno bundle' command fails", 49 | async () => { 50 | const commandResp = { 51 | output: () => 52 | Promise.resolve({ 53 | code: 1, 54 | stderr: new TextEncoder().encode( 55 | "error: unrecognized subcommand 'bundle'", 56 | ), 57 | }), 58 | } as Deno.Command; 59 | 60 | const commandStub = stub( 61 | Deno, 62 | "Command", 63 | () => commandResp, 64 | ); 65 | 66 | try { 67 | await assertRejects( 68 | () => 69 | Deno2Bundler.bundle( 70 | { 71 | entrypoint: expectedEntrypoint, 72 | outFile: expectedOutFile, 73 | }, 74 | ), 75 | BundleError, 76 | "Error bundling function file", 77 | ); 78 | assertSpyCall(commandStub, 0, { 79 | args: [ 80 | Deno.execPath(), 81 | { 82 | args: [ 83 | "bundle", 84 | "--quiet", 85 | expectedEntrypoint, 86 | "--output", 87 | expectedOutFile, 88 | ], 89 | }, 90 | ], 91 | }); 92 | } finally { 93 | commandStub.restore(); 94 | } 95 | }, 96 | ); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/bundler/deno_bundler_test.ts: -------------------------------------------------------------------------------- 1 | import { assertRejects, assertSpyCall, stub } from "../dev_deps.ts"; 2 | import { BundleError } from "../errors.ts"; 3 | import { DenoBundler } from "./deno_bundler.ts"; 4 | 5 | Deno.test("Deno Bundler tests", async (t) => { 6 | await t.step(DenoBundler.bundle.name, async (tt) => { 7 | const expectedEntrypoint = "./function.ts"; 8 | const expectedOutFile = "./dist/bundle.ts"; 9 | 10 | await tt.step( 11 | "should invoke 'deno bundle' successfully", 12 | async () => { 13 | const commandResp = { 14 | output: () => Promise.resolve({ code: 0 }), 15 | } as Deno.Command; 16 | 17 | // Stub out call to `Deno.Command` and fake return a success 18 | const commandStub = stub( 19 | Deno, 20 | "Command", 21 | () => commandResp, 22 | ); 23 | 24 | try { 25 | await DenoBundler.bundle( 26 | { entrypoint: expectedEntrypoint, outFile: expectedOutFile }, 27 | ); 28 | assertSpyCall(commandStub, 0, { 29 | args: [ 30 | Deno.execPath(), 31 | { 32 | args: [ 33 | "bundle", 34 | "--quiet", 35 | expectedEntrypoint, 36 | expectedOutFile, 37 | ], 38 | }, 39 | ], 40 | }); 41 | } finally { 42 | commandStub.restore(); 43 | } 44 | }, 45 | ); 46 | 47 | await tt.step( 48 | "should throw an exception if the 'deno bundle' command fails", 49 | async () => { 50 | const commandResp = { 51 | output: () => 52 | Promise.resolve({ 53 | code: 1, 54 | stderr: new TextEncoder().encode( 55 | "error: unrecognized subcommand 'bundle'", 56 | ), 57 | }), 58 | } as Deno.Command; 59 | 60 | // Stub out call to `Deno.Command` and fake return a success 61 | const commandStub = stub( 62 | Deno, 63 | "Command", 64 | () => commandResp, 65 | ); 66 | 67 | try { 68 | await assertRejects( 69 | () => 70 | DenoBundler.bundle( 71 | { 72 | entrypoint: expectedEntrypoint, 73 | outFile: expectedOutFile, 74 | }, 75 | ), 76 | BundleError, 77 | "Error bundling function file", 78 | ); 79 | assertSpyCall(commandStub, 0, { 80 | args: [ 81 | Deno.execPath(), 82 | { 83 | args: [ 84 | "bundle", 85 | "--quiet", 86 | expectedEntrypoint, 87 | expectedOutFile, 88 | ], 89 | }, 90 | ], 91 | }); 92 | } finally { 93 | commandStub.restore(); 94 | } 95 | }, 96 | ); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributors Guide 2 | 3 | Interested in contributing? Awesome! Before you do though, please read our 4 | [Code of Conduct](https://slackhq.github.io/code-of-conduct). We take it very seriously, and expect that you will as 5 | well. 6 | 7 | There are many ways you can contribute! :heart: 8 | 9 | ### Bug Reports and Fixes :bug: 10 | - At this early stage of development we are not accepting bug reports or feature requests through GitHub. Yet. 11 | 12 | 37 | 38 | ## Requirements 39 | 40 | For your contribution to be accepted: 41 | 42 | - [x] You must have signed the [Contributor License Agreement (CLA)](https://cla.salesforce.com/sign-cla). 43 | - [x] The test suite must be complete and pass. 44 | - [x] The changes must be approved by code review. 45 | - [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number. 46 | 47 | If the contribution doesn't meet the above criteria, you may fail our automated checks or a maintainer will discuss it with you. You can continue to improve a Pull Request by adding commits to the branch from which the PR was created. 48 | 49 | [Interested in knowing more about about pull requests at Slack?](https://slack.engineering/on-empathy-pull-requests-979e4257d158#.awxtvmb2z) 50 | 51 | ## Creating a Pull Request 52 | 53 | 1. :fork_and_knife: Fork the repository on GitHub. 54 | 2. :runner: Clone/fetch your fork to your local development machine. It's a good idea to run the tests just 55 | to make sure everything is in order. 56 | 3. :herb: Create a new branch and check it out. 57 | 4. :crystal_ball: Make your changes and commit them locally. Magic happens here! 58 | 5. :arrow_heading_up: Push your new branch to your fork. (e.g. `git push username fix-issue-16`). 59 | 6. :inbox_tray: Open a Pull Request on github.com from your new branch on your fork to `main` in this 60 | repository. 61 | 62 | ## Maintainers 63 | 64 | There are more details about processes and workflow in the [Maintainer's Guide](./maintainers_guide.md). 65 | -------------------------------------------------------------------------------- /src/utilities.ts: -------------------------------------------------------------------------------- 1 | import * as path from "jsr:@std/path@^1.0.3"; 2 | import { parse as parseJSONC } from "jsr:@std/jsonc@^1.0.1"; 3 | import type { JsonValue } from "jsr:@std/jsonc@^1.0.1"; 4 | 5 | /** 6 | * getJSON attempts to read the given file. If successful, 7 | * it returns the contents of the file. If the extraction 8 | * fails, it returns an empty object. 9 | */ 10 | export async function getJSON(file: string): Promise { 11 | try { 12 | const fileContents = await Deno.readTextFile(file); 13 | return parseJSONC(fileContents); 14 | } catch (err) { 15 | if (err instanceof Error) { 16 | throw new Error(err.message, { cause: err }); 17 | } 18 | throw new Error( 19 | `Caught non-Error value: ${String(err)} (type: ${typeof err})`, 20 | ); 21 | } 22 | } 23 | 24 | /** 25 | * Imports the provided file path and returns its default export. Throws an exception if the module 26 | * has no default export. 27 | * @param {string} functionFilePath - Absolute file path to an importable ECMAScript module 28 | */ 29 | export async function getDefaultExport( 30 | functionFilePath: string, 31 | // deno-lint-ignore no-explicit-any 32 | ): Promise { 33 | const functionModule = await import(`file://${functionFilePath}`); 34 | if (!functionModule.default) { 35 | throw new Error(`File: ${functionFilePath} has no default export!`); 36 | } 37 | return functionModule.default; 38 | } 39 | 40 | /** 41 | * Performs basic validation on all function definitions in a manifest; if any definition fails 42 | * validation, an exception will be thrown. 43 | * @param {string} applicationRoot - Absolute path to application root directory. 44 | * @param {any} manifest - An object representing the application manifest. Should contain a `functions` key that is a map of function IDs to function definitions. 45 | */ 46 | export async function validateManifestFunctions( 47 | applicationRoot: string, 48 | // deno-lint-ignore no-explicit-any 49 | manifest: any, 50 | ): Promise { 51 | for (const fnId in manifest.functions) { 52 | const fnDef = manifest.functions[fnId]; 53 | // For API type functions, there are no function files. 54 | if (fnDef.type === "API") { 55 | continue; 56 | } 57 | 58 | // Ensure source_file for this function definition exists 59 | if (!fnDef.source_file) { 60 | throw new Error( 61 | `No source_file property provided for function with ID ${fnId}!`, 62 | ); 63 | } 64 | const fnFilePath = path.join(applicationRoot, fnDef.source_file); 65 | try { 66 | const { isFile } = await Deno.stat(fnFilePath); 67 | if (!isFile) { 68 | throw new Error( 69 | `Could not find source_file: ${fnFilePath} for function with ID ${fnId}!`, 70 | ); 71 | } 72 | } catch (e) { 73 | if (e instanceof Deno.errors.NotFound) { 74 | throw new Error( 75 | `Could not find file: ${fnFilePath}. Make sure the function definition with ID ${fnId}'s source_file is relative to your project root.`, 76 | ); 77 | } 78 | throw e; 79 | } 80 | 81 | // The following line throws an exception if the file path does not contain a default export. 82 | const defaultExport = await getDefaultExport(fnFilePath); 83 | if (typeof defaultExport !== "function") { 84 | throw new Error( 85 | `The function with ID ${fnId} located at ${fnFilePath}'s default export is not a function!`, 86 | ); 87 | } 88 | } 89 | } 90 | 91 | /** 92 | * isNewSemverRelease takes two semver formatted strings 93 | * and compares them to see if the second argument is a 94 | * newer version than the first argument. 95 | * If it's newer it returns true, otherwise returns false. 96 | */ 97 | export const isNewSemverRelease = (current: string, target: string) => { 98 | const [currMajor, currMinor, currPatch] = current 99 | .split(".") 100 | .map((val) => Number(val)); 101 | const [targetMajor, targetMinor, targetPatch] = target 102 | .split(".") 103 | .map((val) => Number(val)); 104 | 105 | if (targetMajor !== currMajor) return targetMajor > currMajor; 106 | if (targetMinor !== currMinor) return targetMinor > currMinor; 107 | return targetPatch > currPatch; 108 | }; 109 | -------------------------------------------------------------------------------- /src/get_manifest.ts: -------------------------------------------------------------------------------- 1 | import { getProtocolInterface } from "jsr:@slack/protocols@0.0.3"; 2 | import * as path from "jsr:@std/path@^1.0.3"; 3 | import { deepMerge } from "jsr:@std/collections@^1.0.5"; 4 | 5 | import { getDefaultExport, validateManifestFunctions } from "./utilities.ts"; 6 | 7 | // Responsible for taking a working directory, and an output directory 8 | // and placing a manifest.json in the root of the output directory 9 | 10 | /** 11 | * Returns a merged manifest object from expected files used to represent an application manifest: 12 | * `manifest.json`, `manifest.ts` and `manifest.js`. If both a `json` and `ts` _or_ `js` are present, 13 | * then first the `json` file will be used as a base object, then the `.ts` or the `.js` file export 14 | * will be merged over the `json` file. If a `.ts` file exists, the `.js` will be ignored. Otherwise, 15 | * the `.js` file will be merged over the `.json`. 16 | * @param {string} cwd - Absolute path to the root of an application. 17 | */ 18 | export const getManifest = async (cwd: string) => { 19 | let foundManifest = false; 20 | // deno-lint-ignore no-explicit-any 21 | let manifest: any = {}; 22 | 23 | const manifestJSON = await readManifestJSONFile(path.join( 24 | cwd, 25 | "manifest.json", 26 | )); 27 | if (manifestJSON !== false) { 28 | manifest = deepMerge(manifest, manifestJSON); 29 | foundManifest = true; 30 | } 31 | 32 | // First check if there's a manifest.ts file 33 | const manifestTS = await readImportedManifestFile( 34 | path.join(cwd, "manifest.ts"), 35 | ); 36 | if (manifestTS === false) { 37 | // Now check for a manifest.js file 38 | const manifestJS = await readImportedManifestFile( 39 | path.join(cwd, "manifest.js"), 40 | ); 41 | if (manifestJS !== false) { 42 | manifest = deepMerge(manifest, manifestJS); 43 | foundManifest = true; 44 | } 45 | } else { 46 | manifest = deepMerge(manifest, manifestTS); 47 | foundManifest = true; 48 | } 49 | 50 | if (!foundManifest) { 51 | throw new Error( 52 | "Could not find a manifest.json, manifest.ts or manifest.js file", 53 | ); 54 | } 55 | 56 | return manifest; 57 | }; 58 | 59 | // Remove any properties in the manifest specific to the tooling that don't belong in the API payloads 60 | // deno-lint-ignore no-explicit-any 61 | export const cleanManifest = (manifest: any) => { 62 | for (const fnId in manifest.functions) { 63 | const fnDef = manifest.functions[fnId]; 64 | delete fnDef.source_file; 65 | } 66 | 67 | return manifest; 68 | }; 69 | 70 | /** 71 | * Reads and parses an app's `manifest.json` file, and returns its contents. If the file does not exist 72 | * or otherwise reading the file fails, returns `false`. If the file contents are invalid JSON, this method 73 | * will throw an exception. 74 | * @param {string} manifestJSONFilePath - Absolute path to an app's `manifest.json` file. 75 | */ 76 | async function readManifestJSONFile(manifestJSONFilePath: string) { 77 | // deno-lint-ignore no-explicit-any 78 | let manifestJSON: any = {}; 79 | 80 | try { 81 | const { isFile } = await Deno.stat(manifestJSONFilePath); 82 | 83 | if (!isFile) { 84 | return false; 85 | } 86 | } catch (_e) { 87 | return false; 88 | } 89 | 90 | const jsonString = await Deno.readTextFile(manifestJSONFilePath); 91 | manifestJSON = JSON.parse(jsonString); 92 | 93 | return manifestJSON; 94 | } 95 | 96 | /** 97 | * Reads and parses an app's manifest file, and returns its contents. The file is expected to be one that the 98 | * deno runtime can import, and one that returns a default export. If the file does not exist otherwise reading 99 | * the file fails, returns `false`. If the file does not contain a default export, this method will throw and 100 | * exception. 101 | * @param {string} filename - Absolute path to an app's manifest file, to be imported by the deno runtime. 102 | */ 103 | async function readImportedManifestFile(filename: string) { 104 | // Look for manifest.[js|ts] in working directory 105 | // - if present, default export should be a manifest json object 106 | try { 107 | const { isFile } = await Deno.stat(filename); 108 | 109 | if (!isFile) { 110 | return false; 111 | } 112 | } catch (_e) { 113 | return false; 114 | } 115 | 116 | // `getDefaultExport` will throw if no default export present 117 | const manifest = await getDefaultExport(filename); 118 | if (typeof manifest !== "object") { 119 | throw new Error( 120 | `Manifest file: ${filename} default export is not an object!`, 121 | ); 122 | } 123 | return manifest; 124 | } 125 | 126 | /** 127 | * Retrieves a merged application manifest, validates the manifest and all its specified functions, 128 | * and cleans up any bits from it not relevant for the Slack manifest APIs. 129 | * @param {string} applicationRoot - An absolute path to the application root, which presumably contains manifest files. 130 | */ 131 | export async function getValidateAndCleanManifest(applicationRoot: string) { 132 | const generatedManifest = await getManifest(applicationRoot); 133 | await validateManifestFunctions(applicationRoot, generatedManifest); 134 | return cleanManifest(generatedManifest); 135 | } 136 | 137 | if (import.meta.main) { 138 | const protocol = getProtocolInterface(Deno.args); 139 | const prunedManifest = await getValidateAndCleanManifest(Deno.cwd()); 140 | protocol.respond(JSON.stringify(prunedManifest)); 141 | } 142 | -------------------------------------------------------------------------------- /src/build.ts: -------------------------------------------------------------------------------- 1 | import * as path from "jsr:@std/path@^1.0.3"; 2 | import { parseArgs } from "jsr:@std/cli@^1.0.4"; 3 | import { ensureDir } from "jsr:@std/fs@^1.0.2"; 4 | import { getProtocolInterface } from "jsr:@slack/protocols@0.0.3"; 5 | import type { Protocol } from "jsr:@slack/protocols@0.0.3/types"; 6 | 7 | import { cleanManifest, getManifest } from "./get_manifest.ts"; 8 | import { validateManifestFunctions } from "./utilities.ts"; 9 | import { Deno2Bundler, DenoBundler, EsbuildBundler } from "./bundler/mods.ts"; 10 | import { BundleError } from "./errors.ts"; 11 | 12 | export const validateAndCreateFunctions = async ( 13 | workingDirectory: string, 14 | outputDirectory: string, 15 | // deno-lint-ignore no-explicit-any 16 | manifest: any, 17 | protocol: Protocol, 18 | ) => { 19 | // Ensure functions output directory exists 20 | const functionsPath = path.join(outputDirectory, "functions"); 21 | await ensureDir(functionsPath); 22 | 23 | // Ensure manifest and function userland exists and is valid 24 | await validateManifestFunctions( 25 | workingDirectory, 26 | manifest, 27 | ); 28 | 29 | // Write out functions to disk 30 | for (const fnId in manifest.functions) { 31 | const fnDef = manifest.functions[fnId]; 32 | // For API type functions, there are no function files. 33 | if (fnDef.type === "API") { 34 | continue; 35 | } 36 | const fnFilePath = path.join( 37 | workingDirectory, 38 | fnDef.source_file, 39 | ); 40 | await createFunctionFile( 41 | workingDirectory, 42 | outputDirectory, 43 | fnId, 44 | fnFilePath, 45 | protocol, 46 | ); 47 | } 48 | }; 49 | 50 | async function resolveDenoConfigPath( 51 | directory: string = Deno.cwd(), 52 | ): Promise { 53 | for (const name of ["deno.json", "deno.jsonc"]) { 54 | const denoConfigPath = path.join(directory, name); 55 | try { 56 | await Deno.stat(denoConfigPath); 57 | return denoConfigPath; 58 | } catch (error) { 59 | if (!(error instanceof Deno.errors.NotFound)) { 60 | throw error; 61 | } 62 | } 63 | } 64 | throw new Error( 65 | "Could not find a deno.json or deno.jsonc file in the current directory.", 66 | ); 67 | } 68 | 69 | const createFunctionFile = async ( 70 | workingDirectory: string, 71 | outputDirectory: string, 72 | fnId: string, 73 | fnFilePath: string, 74 | protocol: Protocol, 75 | ) => { 76 | const fnFileRelative = path.join("functions", `${fnId}.js`); 77 | const fnBundledPath = path.join(outputDirectory, fnFileRelative); 78 | 79 | try { 80 | // TODO: Remove this try/catch block once Deno 1.x is no longer supported 81 | await DenoBundler.bundle({ 82 | entrypoint: fnFilePath, 83 | outFile: fnBundledPath, 84 | }); 85 | return; 86 | } catch (denoBundleErr) { 87 | if (!(denoBundleErr instanceof BundleError)) { 88 | protocol.error(`Failed to bundle function "${fnId}" using Deno bundler`); 89 | throw denoBundleErr; 90 | } 91 | // TODO: once Protocol can handle debug add a debug statement for the error 92 | } 93 | 94 | try { 95 | await Deno2Bundler.bundle({ 96 | entrypoint: fnFilePath, 97 | outFile: fnBundledPath, 98 | }); 99 | return; 100 | } catch (denoBundleErr) { 101 | if (!(denoBundleErr instanceof BundleError)) { 102 | protocol.error( 103 | `Failed to bundle function "${fnId}" using Deno2 bundler`, 104 | ); 105 | throw denoBundleErr; 106 | } 107 | // TODO: once Protocol can handle debug add a debug statement for the error 108 | } 109 | 110 | try { 111 | const bundle = await EsbuildBundler.bundle({ 112 | entrypoint: fnFilePath, 113 | absWorkingDir: workingDirectory, 114 | configPath: await resolveDenoConfigPath(workingDirectory), 115 | }); 116 | await Deno.writeFile(fnBundledPath, bundle); 117 | } catch (esbuildError) { 118 | protocol.error( 119 | `Failed to bundle function "${fnId}": Attempt with Deno bundle and esbuild - all failed`, 120 | ); 121 | throw esbuildError; 122 | } 123 | }; 124 | 125 | /** 126 | * Recursively deletes the specified directory. 127 | * 128 | * @param directoryPath the directory to delete 129 | * @returns true when the directory is deleted or throws unexpected errors 130 | */ 131 | async function removeDirectory(directoryPath: string): Promise { 132 | try { 133 | await Deno.remove(directoryPath, { recursive: true }); 134 | return true; 135 | } catch (err) { 136 | if (err instanceof Deno.errors.NotFound) { 137 | return false; 138 | } 139 | 140 | throw err; 141 | } 142 | } 143 | 144 | if (import.meta.main) { 145 | const protocol = getProtocolInterface(Deno.args); 146 | 147 | // Massage source and output directories 148 | let { source, output } = parseArgs(Deno.args); 149 | if (!output) output = "dist"; 150 | const outputDirectory = path.isAbsolute(output) 151 | ? output 152 | : path.join(Deno.cwd(), output); 153 | 154 | // Clean output dir prior to build 155 | await removeDirectory(outputDirectory); 156 | 157 | const workingDirectory = path.isAbsolute(source || "") 158 | ? source 159 | : path.join(Deno.cwd(), source || ""); 160 | 161 | const generatedManifest = await getManifest(Deno.cwd()); 162 | await validateAndCreateFunctions( 163 | workingDirectory, 164 | outputDirectory, 165 | generatedManifest, 166 | protocol, 167 | ); 168 | const prunedManifest = cleanManifest(generatedManifest); 169 | const manifestPath = path.join(outputDirectory, "manifest.json"); 170 | await Deno.writeTextFile( 171 | manifestPath, 172 | JSON.stringify(prunedManifest, null, 2), 173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /.github/maintainers_guide.md: -------------------------------------------------------------------------------- 1 | # Maintainers Guide 2 | 3 | This document describes tools, tasks and workflow that one needs to be familiar with in order to effectively maintain 4 | this project. If you use this package within your own software as is but don't plan on modifying it, this guide is 5 | **not** for you. 6 | 7 | ## Tools 8 | 9 | You will need [Deno](https://deno.land). 10 | 11 | ## Tasks 12 | 13 | ### Testing 14 | 15 | This package has unit tests in the `src/tests` directory. You can run the entire test suite (along with linting and formatting) via: 16 | 17 | ```zsh 18 | deno task test 19 | ``` 20 | 21 | To run the tests along with a coverage report: 22 | 23 | ```zsh 24 | deno task test:coverage 25 | ``` 26 | 27 | This command is also executed by GitHub Actions, the continuous integration service, for every Pull Request and branch. 28 | 29 | ### Lint and format 30 | 31 | The linting and formatting rules are defined in the `deno.jsonc` file, your IDE can be set up to follow these rules: 32 | 33 | 1. Refer to the [Deno Set Up Your Environment](https://deno.land/manual/getting_started/setup_your_environment) guidelines to set up your IDE with the proper plugin. 34 | 2. Ensure that the `deno.jsonc` file is set as the configuration file for your IDE plugin 35 | * If you are using VS code [this](https://deno.land/manual/references/vscode_deno#using-a-configuration-file) is already configured in `.vscode/settings.json` 36 | 37 | #### Linting 38 | 39 | The list of linting rules can be found in [the linting deno docs](https://lint.deno.land/). 40 | Currently we apply all recommended rules. 41 | 42 | #### Format 43 | 44 | The list of format options is defined in the `deno.jsonc` file. They closely resemble the default values. 45 | 46 | ### Releasing 47 | 48 | Releasing can feel intimidating at first, but rest assured: if you make a mistake, don't fret! We can always roll forward with another release 😃 49 | 50 | 1. Make sure your local `main` branch has the latest changes. 51 | 2. Run the tests as per the above Testing section, and any other local verification, such as: 52 | * Local integration tests between the Slack CLI, deno-sdk-based application template(s) and this repo. One can modify a deno-sdk-based app project's `slack.json` file to point the `get-hooks` hook to a local version of this repo rather than the deno.land-hosted version. 53 | 3. Bump the version number for this repo in adherence to [Semantic Versioning][semver] in `src/version.ts`. 54 | * Make a single commit with a message for the version bump. 55 | 4. Send a pull request with this change and tag @slackapi/HDX and/or @slackapi/denosaurs for review. 56 | 5. Once approved and merged, a deployment workflow will kick off. This workflow will: 57 | * Create a `git` tag matching the version string you changed in `src/version.ts`. 58 | * Create a new GitHub Release (initially set to a pre-release) for the version. 59 | * As soon as the `git` tag lands in the repo, this will kick off an automatic deployment to deno.land for this module: https://deno.land/x/deno_slack_hooks 60 | 6. Edit the latest generated GitHub Release from the [Releases page](https://github.com/slackapi/deno-slack-hooks/releases): 61 | * Ensure the changelog notes are accurate, up-to-date, and understandable by non-contributors,but each commit should still have it's own line. 62 | * Un-check the "This is a pre-release" checkbox once you are happy with the release notes. 63 | * Check the "Set as the latest release" checkbox. 64 | * Click "Update release." 65 | 7. If all went well, you should see your version up on [deno.land](https://deno.land/x/deno_slack_hooks)! If it didn't work, check: 66 | * The [GitHub Actions page for the continuous deployment workflow](https://github.com/slackapi/deno-slack-hooks/actions/workflows/deno-cd.yml). Did it fail? If so, why? 67 | 68 | ## Workflow 69 | 70 | ### Versioning and Tags 71 | 72 | This project is versioned using [Semantic Versioning][semver]. 73 | 74 | ### Branches 75 | 76 | > Describe any specific branching workflow. For example: 77 | > `main` is where active development occurs. 78 | > Long running branches named feature branches are occasionally created for collaboration on a feature that has a large scope (because everyone cannot push commits to another person's open Pull Request) 79 | 80 | 105 | 106 | ## Everything else 107 | 108 | When in doubt, find the other maintainers and ask. 109 | 110 | [semver]: http://semver.org/ 111 | -------------------------------------------------------------------------------- /src/tests/doctor_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | assertSpyCall, 4 | assertSpyCalls, 5 | MockProtocol, 6 | Spy, 7 | stub, 8 | } from "../dev_deps.ts"; 9 | import { getRuntimeVersions } from "../doctor.ts"; 10 | 11 | const REAL_DENO_VERSION = Deno.version; 12 | const MOCK_DENO_VERSION = { 13 | deno: "1.2.3", 14 | typescript: "5.0.0", 15 | v8: "12.3.456.78", 16 | }; 17 | 18 | const MOCK_SLACK_CLI_MANIFEST = { 19 | "slack-cli": { 20 | "title": "Slack CLI", 21 | "description": "CLI for creating, building, and deploying Slack apps.", 22 | "releases": [ 23 | { 24 | "version": "2.19.0", 25 | "release_date": "2024-03-11", 26 | }, 27 | ], 28 | }, 29 | "deno-runtime": { 30 | "title": "Deno Runtime", 31 | "releases": [ 32 | { 33 | "version": "1.101.1", 34 | "release_date": "2023-09-19", 35 | }, 36 | ], 37 | }, 38 | }; 39 | 40 | Deno.test("doctor hook tests", async (t) => { 41 | Object.defineProperty(Deno, "version", { 42 | value: MOCK_DENO_VERSION, 43 | writable: true, 44 | configurable: true, 45 | }); 46 | const stubMetadataJsonFetch = (response: Response) => { 47 | return stub( 48 | globalThis, 49 | "fetch", 50 | (url: string | URL | Request, options?: RequestInit) => { 51 | const req = url instanceof Request ? url : new Request(url, options); 52 | assertEquals(req.method, "GET"); 53 | assertEquals( 54 | req.url, 55 | "https://api.slack.com/slackcli/metadata.json", 56 | ); 57 | return Promise.resolve(response); 58 | }, 59 | ); 60 | }; 61 | 62 | await t.step("known runtime values for the system are returned", async () => { 63 | const protocol = MockProtocol(); 64 | const mockResponse = new Response(null, { status: 404 }); 65 | using _fetchStub = stubMetadataJsonFetch(mockResponse); 66 | 67 | Deno.version.deno = "1.2.3"; 68 | 69 | const actual = await getRuntimeVersions(protocol); 70 | const expected = { 71 | versions: [ 72 | { 73 | name: "deno", 74 | current: "1.2.3", 75 | }, 76 | { 77 | name: "typescript", 78 | current: "5.0.0", 79 | }, 80 | { 81 | name: "v8", 82 | current: "12.3.456.78", 83 | }, 84 | ], 85 | }; 86 | assertEquals(actual, expected); 87 | assertSpyCalls(protocol.warn as Spy, 2); 88 | assertSpyCall(protocol.warn as Spy, 0, { 89 | args: ["Failed to collect upstream CLI metadata:"], 90 | }); 91 | assertSpyCall(protocol.warn as Spy, 1, { 92 | args: [mockResponse], 93 | }); 94 | }); 95 | 96 | await t.step("matched upstream requirements return success", async () => { 97 | const protocol = MockProtocol(); 98 | using _fetchStub = stubMetadataJsonFetch( 99 | new Response(JSON.stringify(MOCK_SLACK_CLI_MANIFEST)), 100 | ); 101 | 102 | Deno.version.deno = "1.101.1"; 103 | 104 | const actual = await getRuntimeVersions(protocol); 105 | const expected = { 106 | versions: [ 107 | { 108 | name: "deno", 109 | current: "1.101.1", 110 | message: undefined, 111 | }, 112 | { 113 | name: "typescript", 114 | current: "5.0.0", 115 | }, 116 | { 117 | name: "v8", 118 | current: "12.3.456.78", 119 | }, 120 | ], 121 | }; 122 | assertEquals(actual, expected); 123 | }); 124 | 125 | await t.step("unsupported upstream runtimes note differences", async () => { 126 | const protocol = MockProtocol(); 127 | using _fetchStub = stubMetadataJsonFetch( 128 | new Response(JSON.stringify(MOCK_SLACK_CLI_MANIFEST)), 129 | ); 130 | 131 | Deno.version.deno = "1.2.3"; 132 | 133 | const actual = await getRuntimeVersions(protocol); 134 | const expected = { 135 | versions: [ 136 | { 137 | name: "deno", 138 | current: "1.2.3", 139 | message: "Applications deployed to Slack use Deno version 1.101.1", 140 | error: { 141 | message: "The installed runtime version is not supported", 142 | }, 143 | }, 144 | { 145 | name: "typescript", 146 | current: "5.0.0", 147 | }, 148 | { 149 | name: "v8", 150 | current: "12.3.456.78", 151 | }, 152 | ], 153 | }; 154 | assertEquals(actual, expected); 155 | assertSpyCalls(protocol.warn as Spy, 0); 156 | }); 157 | 158 | await t.step("missing minimums from cli metadata are noted", async () => { 159 | const protocol = MockProtocol(); 160 | const metadata = { 161 | runtimes: ["deno", "node"], 162 | }; 163 | using _fetchStub = stubMetadataJsonFetch( 164 | new Response(JSON.stringify(metadata)), 165 | ); 166 | 167 | Deno.version.deno = "1.2.3"; 168 | 169 | const actual = await getRuntimeVersions(protocol); 170 | const expected = { 171 | versions: [ 172 | { 173 | name: "deno", 174 | current: "1.2.3", 175 | }, 176 | { 177 | name: "typescript", 178 | current: "5.0.0", 179 | }, 180 | { 181 | name: "v8", 182 | current: "12.3.456.78", 183 | }, 184 | ], 185 | }; 186 | assertEquals(actual, expected); 187 | assertSpyCalls(protocol.warn as Spy, 2); 188 | assertSpyCall(protocol.warn as Spy, 0, { 189 | args: [ 190 | "Failed to find the minimum Deno version in the upstream CLI metadata response:", 191 | ], 192 | }); 193 | assertSpyCall(protocol.warn as Spy, 1, { 194 | args: [JSON.stringify(metadata, null, " ")], 195 | }); 196 | }); 197 | 198 | await t.step("invalid body in http responses are caught", async () => { 199 | const protocol = MockProtocol(); 200 | using _fetchStub = stubMetadataJsonFetch( 201 | new Response("{"), 202 | ); 203 | Deno.version.deno = "2.2.2"; 204 | 205 | const actual = await getRuntimeVersions(protocol); 206 | const expected = { 207 | versions: [ 208 | { 209 | name: "deno", 210 | current: "2.2.2", 211 | }, 212 | { 213 | name: "typescript", 214 | current: "5.0.0", 215 | }, 216 | { 217 | name: "v8", 218 | current: "12.3.456.78", 219 | }, 220 | ], 221 | }; 222 | assertEquals(actual, expected); 223 | assertSpyCalls(protocol.warn as Spy, 2); 224 | assertSpyCall(protocol.warn as Spy, 0, { 225 | args: [ 226 | "Failed to collect or process upstream CLI metadata:", 227 | ], 228 | }); 229 | }); 230 | 231 | Object.defineProperty(Deno, "version", { 232 | value: REAL_DENO_VERSION, 233 | writable: false, 234 | configurable: false, 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /src/install_update.ts: -------------------------------------------------------------------------------- 1 | import { getProtocolInterface } from "jsr:@slack/protocols@0.0.3"; 2 | import type { JsonValue } from "jsr:@std/jsonc@^1.0.1"; 3 | import { join } from "jsr:@std/path@1.1.0"; 4 | 5 | import { 6 | checkForSDKUpdates, 7 | gatherDependencyFiles, 8 | Release, 9 | } from "./check_update.ts"; 10 | import { getJSON } from "./utilities.ts"; 11 | import { projectScripts } from "./mod.ts"; 12 | 13 | export const SDK_NAME = "the Slack SDK"; 14 | 15 | interface InstallUpdateResponse { 16 | name: string; 17 | updates: Update[]; 18 | error?: { 19 | message: string; 20 | } | null; 21 | } 22 | 23 | export interface Update { 24 | name: string; 25 | previous: string; 26 | installed: string; 27 | error?: { 28 | message: string; 29 | } | null; 30 | } 31 | 32 | /** 33 | * updateDependencies checks for SDK-related dependency updates. If 34 | * updatable releases are found, dependency files are updated with the 35 | * latest dependency versions and the project changes are cached. 36 | */ 37 | export const updateDependencies = async () => { 38 | const { releases } = await checkForSDKUpdates(); 39 | const updatableReleases = releases.filter((r) => 40 | r.update && r.current && r.latest 41 | ); 42 | const updateResp = await createUpdateResp(updatableReleases); 43 | 44 | // If no errors occurred during installation, re-build 45 | // project as a means to cache the changes 46 | if (!updateResp.error) { 47 | try { 48 | // TODO: This try/catch should be nested within createUpdateResp 49 | // but doing so surfaces an issue with the --allow-run flag not 50 | // being used, despite its presence and success at this level 51 | runBuildHook(); 52 | } catch (err: unknown) { 53 | updateResp.error = { 54 | message: err instanceof Error ? err.message : String(err), 55 | }; 56 | } 57 | } 58 | 59 | return updateResp; 60 | }; 61 | 62 | /** 63 | * createUpdateResp creates an object that contains each update, 64 | * featuring information about the current and latest version, as well 65 | * as if any errors occurred while attempting to update each. 66 | */ 67 | export async function createUpdateResp( 68 | releases: Release[], 69 | ): Promise { 70 | const updateResp: InstallUpdateResponse = { name: SDK_NAME, updates: [] }; 71 | 72 | if (!releases.length) return updateResp; 73 | 74 | try { 75 | const cwd = Deno.cwd(); 76 | const { dependencyFiles } = await gatherDependencyFiles(cwd); 77 | 78 | for (const [file, _] of dependencyFiles) { 79 | // Update dependency file with latest dependency versions 80 | try { 81 | const fileUpdateResp = await updateDependencyFile( 82 | join(cwd, file), 83 | releases, 84 | ); 85 | updateResp.updates = [...updateResp.updates, ...fileUpdateResp]; 86 | } catch (err) { 87 | const message = err instanceof Error 88 | ? err.message 89 | : `Caught non-Error value: ${String(err)} (type: ${typeof err})`; 90 | updateResp.error = updateResp.error 91 | ? { message: updateResp.error.message += `\n ${message}` } 92 | : { message: message }; 93 | } 94 | } 95 | } catch (err) { 96 | const message = err instanceof Error 97 | ? err.message 98 | : `Caught non-Error value: ${String(err)} (type: ${typeof err})`; 99 | updateResp.error = { message }; 100 | } 101 | 102 | // Pare down updates by removing duplicates 103 | updateResp.updates = [...new Set(updateResp.updates)]; 104 | 105 | return updateResp; 106 | } 107 | 108 | /** 109 | * updateDependencyFile reads the contents of a provided path, calls updateDependencyMap 110 | * to swap out the current versions with the latest releases available, and then writes 111 | * to the dependency file. Returns a summary of updates made. 112 | */ 113 | export async function updateDependencyFile( 114 | path: string, 115 | releases: Release[], 116 | ): Promise { 117 | try { 118 | const dependencyJSON = await getJSON(path); 119 | const isParsable = dependencyJSON && typeof dependencyJSON === "object" && 120 | !Array.isArray(dependencyJSON) && 121 | (dependencyJSON.imports || dependencyJSON.hooks); 122 | 123 | // If file doesn't exist, dependency key is missing or is of wrong type 124 | if (!isParsable) return []; 125 | 126 | const dependencyKey = dependencyJSON.imports ? "imports" : "hooks"; 127 | 128 | if (dependencyJSON[dependencyKey] === undefined) { 129 | return []; 130 | } 131 | 132 | // Update only the dependency-related key in given file ("imports" or "hooks") 133 | const { updatedDependencies, updateSummary } = updateDependencyMap( 134 | dependencyJSON[dependencyKey], 135 | releases, 136 | ); 137 | 138 | // Replace the dependency-related section with the updated version 139 | dependencyJSON[dependencyKey] = updatedDependencies; 140 | await Deno.writeTextFile( 141 | path, 142 | JSON.stringify(dependencyJSON, null, 2).concat("\n"), 143 | ); 144 | 145 | return updateSummary; 146 | } catch (err) { 147 | const error = err instanceof Error 148 | ? err 149 | : Error(`Caught non-Error value: ${String(err)} (type: ${typeof err})`, { 150 | cause: err, 151 | }); 152 | if (!(error.cause instanceof Deno.errors.NotFound)) throw err; 153 | } 154 | 155 | return []; 156 | } 157 | 158 | /** 159 | * updateDependencyMap takes in a map of the dependencies' key/value pairs and, 160 | * if an update exists for a dependency of the same name in the releases provided, 161 | * replaces the existing version with the latest version of the dependency. Returns 162 | * an updated map of all dependencies, as well as an update summary of each. 163 | */ 164 | export function updateDependencyMap( 165 | dependencyMap: JsonValue, 166 | releases: Release[], 167 | ) { 168 | const mapIsObject = dependencyMap && typeof dependencyMap === "object" && 169 | !Array.isArray(dependencyMap); 170 | 171 | const updatedDependencies = mapIsObject ? { ...dependencyMap } : {}; 172 | const updateSummary: Update[] = []; 173 | 174 | // Loop over key, val pairs of 'imports' or 'hooks', depending on file provided 175 | for (const [key, val] of Object.entries(updatedDependencies)) { 176 | for (const { name, current, latest } of releases) { 177 | // If the dependency matches an available release, 178 | // and an update is available, replace the version 179 | if (current && latest && typeof val === "string" && val.includes(name)) { 180 | updatedDependencies[key] = val.replace(current, latest); 181 | updateSummary.push({ 182 | name, 183 | previous: current, 184 | installed: latest, 185 | }); 186 | } 187 | } 188 | } 189 | 190 | return { updatedDependencies, updateSummary }; 191 | } 192 | 193 | /** 194 | * runBuildHook runs the `build` hook found in mod.ts. In the context of 195 | * `install-update`, this is in order to cache the dependency version 196 | * updates that occurred. 197 | */ 198 | function runBuildHook(): void { 199 | try { 200 | const { hooks: { build } } = projectScripts([]); 201 | const buildArgs = build.split(" "); 202 | 203 | // TODO: move to Deno.command only compatible with 204 | // @ts-ignore: Deno.run is deprecated but still needed for compatibility 205 | // deno-lint-ignore no-deprecated-deno-api 206 | Deno.run({ cmd: buildArgs }); 207 | } catch (err) { 208 | if (err instanceof Error) { 209 | throw new Error(err.message, { cause: err }); 210 | } 211 | throw new Error( 212 | `Caught non-Error value: ${String(err)} (type: ${typeof err})`, 213 | ); 214 | } 215 | } 216 | 217 | if (import.meta.main) { 218 | const protocol = getProtocolInterface(Deno.args); 219 | protocol.respond(JSON.stringify(await updateDependencies())); 220 | } 221 | -------------------------------------------------------------------------------- /src/tests/utilities_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertRejects } from "../dev_deps.ts"; 2 | import { isNewSemverRelease, validateManifestFunctions } from "../utilities.ts"; 3 | 4 | Deno.test("utilities.ts", async (t) => { 5 | await t.step("validateManifestFunctions function", async (tt) => { 6 | await tt.step( 7 | "should throw an exception if a function file that does not have a default export", 8 | async () => { 9 | const manifest = { 10 | "functions": { 11 | "test_function": { 12 | "title": "Test function", 13 | "description": "this is a test", 14 | "source_file": 15 | "src/tests/fixtures/functions/test_function_no_export_file.ts", 16 | "input_parameters": { 17 | "required": [], 18 | "properties": {}, 19 | }, 20 | "output_parameters": { 21 | "required": [], 22 | "properties": {}, 23 | }, 24 | }, 25 | }, 26 | }; 27 | await assertRejects( 28 | () => 29 | validateManifestFunctions( 30 | Deno.cwd(), 31 | manifest, 32 | ), 33 | Error, 34 | "no default export", 35 | ); 36 | }, 37 | ); 38 | 39 | await tt.step( 40 | "should throw an exception when fed a function file that has a non-function default export", 41 | async () => { 42 | const manifest = { 43 | "functions": { 44 | "test_function": { 45 | "title": "Test function", 46 | "description": "this is a test", 47 | "source_file": 48 | "src/tests/fixtures/functions/test_function_not_function_file.ts", 49 | "input_parameters": { 50 | "required": [], 51 | "properties": {}, 52 | }, 53 | "output_parameters": { 54 | "required": [], 55 | "properties": {}, 56 | }, 57 | }, 58 | }, 59 | }; 60 | await assertRejects( 61 | () => 62 | validateManifestFunctions( 63 | Deno.cwd(), 64 | manifest, 65 | ), 66 | Error, 67 | "default export is not a function", 68 | ); 69 | }, 70 | ); 71 | 72 | await tt.step( 73 | "should throw an exception when manifest entry for a function points to a non-existent file", 74 | async () => { 75 | const manifest = { 76 | "functions": { 77 | "test_function": { 78 | "title": "Test function", 79 | "description": "this is a test", 80 | "source_file": 81 | "src/tests/fixtures/functions/test_function_this_file_does_not_exist.ts", 82 | "input_parameters": { 83 | "required": [], 84 | "properties": {}, 85 | }, 86 | "output_parameters": { 87 | "required": [], 88 | "properties": {}, 89 | }, 90 | }, 91 | }, 92 | }; 93 | await assertRejects( 94 | () => 95 | validateManifestFunctions( 96 | Deno.cwd(), 97 | manifest, 98 | ), 99 | Error, 100 | "Could not find file", 101 | ); 102 | }, 103 | ); 104 | 105 | await tt.step( 106 | "should throw an exception when manifest entry for a function does not have a source_file defined", 107 | async () => { 108 | const manifest = { 109 | "functions": { 110 | "test_function": { 111 | "title": "Test function", 112 | "description": "this is a test", 113 | "input_parameters": { 114 | "required": [], 115 | "properties": {}, 116 | }, 117 | "output_parameters": { 118 | "required": [], 119 | "properties": {}, 120 | }, 121 | }, 122 | }, 123 | }; 124 | await assertRejects( 125 | () => 126 | validateManifestFunctions( 127 | Deno.cwd(), 128 | manifest, 129 | ), 130 | Error, 131 | "No source_file property provided", 132 | ); 133 | }, 134 | ); 135 | 136 | await tt.step("should ignore functions of type 'API'", async () => { 137 | const manifest = { 138 | "functions": { 139 | "test_function": { 140 | "title": "Test function", 141 | "description": "this is a test", 142 | "source_file": 143 | "src/tests/fixtures/functions/test_function_not_function_file.ts", 144 | "type": "API", 145 | "input_parameters": { 146 | "required": [], 147 | "properties": {}, 148 | }, 149 | "output_parameters": { 150 | "required": [], 151 | "properties": {}, 152 | }, 153 | }, 154 | }, 155 | }; 156 | await validateManifestFunctions( 157 | Deno.cwd(), 158 | manifest, 159 | ); 160 | // If the above doesn't throw, we good 161 | }); 162 | }); 163 | 164 | await t.step("isNewSemverRelease method", async (evT) => { 165 | await evT.step("returns true for semver updates", () => { 166 | assertEquals(isNewSemverRelease("0.0.1", "0.0.2"), true); 167 | assertEquals(isNewSemverRelease("0.0.1", "0.2.0"), true); 168 | assertEquals(isNewSemverRelease("0.0.1", "2.0.0"), true); 169 | assertEquals(isNewSemverRelease("0.1.0", "0.1.1"), true); 170 | assertEquals(isNewSemverRelease("0.1.0", "0.2.0"), true); 171 | assertEquals(isNewSemverRelease("0.1.0", "2.0.0"), true); 172 | assertEquals(isNewSemverRelease("1.0.0", "1.0.1"), true); 173 | assertEquals(isNewSemverRelease("1.0.0", "1.1.0"), true); 174 | assertEquals(isNewSemverRelease("1.0.0", "1.1.1"), true); 175 | assertEquals(isNewSemverRelease("1.0.0", "2.0.0"), true); 176 | assertEquals(isNewSemverRelease("0.0.2", "0.0.13"), true); 177 | }); 178 | await evT.step("returns false for semver downgrades", () => { 179 | assertEquals(isNewSemverRelease("2.0.0", "1.0.0"), false); 180 | assertEquals(isNewSemverRelease("2.0.0", "0.1.0"), false); 181 | assertEquals(isNewSemverRelease("2.0.0", "0.3.0"), false); 182 | assertEquals(isNewSemverRelease("2.0.0", "0.0.1"), false); 183 | assertEquals(isNewSemverRelease("2.0.0", "0.0.3"), false); 184 | assertEquals(isNewSemverRelease("2.0.0", "1.1.0"), false); 185 | assertEquals(isNewSemverRelease("2.0.0", "1.3.0"), false); 186 | assertEquals(isNewSemverRelease("2.0.0", "1.1.1"), false); 187 | assertEquals(isNewSemverRelease("2.0.0", "1.3.3"), false); 188 | assertEquals(isNewSemverRelease("0.2.0", "0.1.0"), false); 189 | assertEquals(isNewSemverRelease("0.2.0", "0.0.1"), false); 190 | assertEquals(isNewSemverRelease("0.2.0", "0.0.3"), false); 191 | assertEquals(isNewSemverRelease("0.2.0", "0.1.1"), false); 192 | assertEquals(isNewSemverRelease("0.2.0", "0.1.3"), false); 193 | assertEquals(isNewSemverRelease("0.0.2", "0.0.1"), false); 194 | assertEquals(isNewSemverRelease("0.0.20", "0.0.13"), false); 195 | }); 196 | await evT.step("returns false for semver matches", () => { 197 | assertEquals(isNewSemverRelease("0.0.1", "0.0.1"), false); 198 | assertEquals(isNewSemverRelease("0.1.0", "0.1.0"), false); 199 | assertEquals(isNewSemverRelease("0.1.1", "0.1.1"), false); 200 | assertEquals(isNewSemverRelease("1.0.0", "1.0.0"), false); 201 | assertEquals(isNewSemverRelease("1.0.1", "1.0.1"), false); 202 | assertEquals(isNewSemverRelease("1.1.1", "1.1.1"), false); 203 | }); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /src/tests/get_manifest_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cleanManifest, 3 | getManifest, 4 | getValidateAndCleanManifest, 5 | } from "../get_manifest.ts"; 6 | import { 7 | assertEquals, 8 | assertRejects, 9 | assertStringIncludes, 10 | } from "../dev_deps.ts"; 11 | import * as path from "jsr:@std/path@^1.0.3"; 12 | 13 | Deno.test("get-manifest hook tests", async (t) => { 14 | await t.step( 15 | "cleanManifest function", 16 | async (tt) => { 17 | await tt.step( 18 | "should remove `source_file` property from function definitions", 19 | () => { 20 | // Create a partial of a manifest w/ just a function 21 | const manifest = { 22 | "functions": { 23 | "reverse": { 24 | "title": "Reverse", 25 | "description": "Takes a string and reverses it", 26 | "source_file": "functions/reverse.ts", 27 | "input_parameters": { 28 | "required": [ 29 | "stringToReverse", 30 | ], 31 | "properties": { 32 | "stringToReverse": { 33 | "type": "string", 34 | "description": "The string to reverse", 35 | }, 36 | }, 37 | }, 38 | "output_parameters": { 39 | "required": [ 40 | "reverseString", 41 | ], 42 | "properties": { 43 | "reverseString": { 44 | "type": "string", 45 | "description": "The string in reverse", 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }; 52 | 53 | const cleanedManifest = cleanManifest(manifest); 54 | 55 | assertEquals( 56 | cleanedManifest.functions.reverse.source_file, 57 | undefined, 58 | ); 59 | }, 60 | ); 61 | }, 62 | ); 63 | 64 | await t.step("getManifest function", async (tt) => { 65 | // TODO: one of two things need to happen in order to test getManifest out more: 66 | // 1. need the ability to mock out `Deno.stat` using mock-file (see https://github.com/ayame113/mock-file/issues/7), or 67 | // 2. we re-write the code in get_manifest.ts to use `Deno.fstat` in place of `Deno.stat` 68 | // In the mean time, we can use actual files on the actual filesystem, as un-unit-testy as that is, 69 | // to ensure we arent doing anything silly or dangerous 70 | await tt.step( 71 | "should return valid manifest.json contents if it solely exists", 72 | async () => { 73 | const manifest = await getManifest( 74 | path.join( 75 | Deno.cwd(), 76 | "src/tests/fixtures/manifests/valid-manifest-json", 77 | ), 78 | ); 79 | assertStringIncludes(manifest.name, "only a manifest.json"); 80 | }, 81 | ); 82 | 83 | await tt.step( 84 | "should throw if invalid manifest.json is present", 85 | async () => { 86 | await assertRejects( 87 | () => 88 | getManifest( 89 | path.join( 90 | Deno.cwd(), 91 | "src/tests/fixtures/manifests/invalid-manifest-json", 92 | ), 93 | ), 94 | ); 95 | }, 96 | ); 97 | 98 | await tt.step( 99 | "should return valid manifest.ts contents if it solely exists", 100 | async () => { 101 | const manifest = await getManifest( 102 | path.join( 103 | Deno.cwd(), 104 | "src/tests/fixtures/manifests/valid-manifest-ts", 105 | ), 106 | ); 107 | assertStringIncludes(manifest.display_information.name, "nardwhal"); 108 | assertStringIncludes( 109 | manifest.display_information.description, 110 | "manifest.ts for testing", 111 | ); 112 | }, 113 | ); 114 | 115 | await tt.step( 116 | "should throw if invalid manifest.ts is present", 117 | async () => { 118 | await assertRejects( 119 | () => 120 | getManifest( 121 | path.join( 122 | Deno.cwd(), 123 | "src/tests/fixtures/manifests/invalid-manifest-ts", 124 | ), 125 | ), 126 | ); 127 | }, 128 | ); 129 | 130 | await tt.step( 131 | "should throw if manifest.ts default export is not an object", 132 | async () => { 133 | await assertRejects( 134 | () => 135 | getManifest( 136 | path.join( 137 | Deno.cwd(), 138 | "src/tests/fixtures/manifests/non-object-manifest-ts", 139 | ), 140 | ), 141 | Error, 142 | "default export is not an object", 143 | ); 144 | }, 145 | ); 146 | 147 | await tt.step( 148 | "should return valid manifest.js contents if it solely exists", 149 | async () => { 150 | const manifest = await getManifest( 151 | path.join( 152 | Deno.cwd(), 153 | "src/tests/fixtures/manifests/valid-manifest-js", 154 | ), 155 | ); 156 | assertStringIncludes(manifest.name, "anyscript"); 157 | assertStringIncludes(manifest.description, "manifest.js for testing"); 158 | }, 159 | ); 160 | 161 | await tt.step( 162 | "should throw if invalid manifest.js is present", 163 | async () => { 164 | await assertRejects( 165 | () => 166 | getManifest( 167 | path.join( 168 | Deno.cwd(), 169 | "src/tests/fixtures/manifests/invalid-manifest-js", 170 | ), 171 | ), 172 | ); 173 | }, 174 | ); 175 | 176 | await tt.step( 177 | "should throw if manifest.js default export is not an object", 178 | async () => { 179 | await assertRejects( 180 | () => 181 | getManifest( 182 | path.join( 183 | Deno.cwd(), 184 | "src/tests/fixtures/manifests/non-object-manifest-js", 185 | ), 186 | ), 187 | Error, 188 | "default export is not an object", 189 | ); 190 | }, 191 | ); 192 | 193 | await tt.step( 194 | "should throw if no manifest JSON, TS or JS found", 195 | async () => { 196 | await assertRejects( 197 | () => getManifest(Deno.cwd()), 198 | Error, 199 | "Could not find a manifest.json, manifest.ts or manifest.js", 200 | ); 201 | }, 202 | ); 203 | }); 204 | 205 | await t.step("getValidateAndCleanManifest function", async (tt) => { 206 | await tt.step( 207 | "should throw an exception if a function file does not have a default export", 208 | async () => { 209 | await assertRejects( 210 | () => 211 | getValidateAndCleanManifest( 212 | path.join( 213 | Deno.cwd(), 214 | "src/tests/fixtures/apps/function-no-default-export", 215 | ), 216 | ), 217 | Error, 218 | "no default export", 219 | ); 220 | }, 221 | ); 222 | 223 | await tt.step( 224 | "should throw an exception if a function file has a non-function default export", 225 | async () => { 226 | await assertRejects( 227 | () => 228 | getValidateAndCleanManifest( 229 | path.join( 230 | Deno.cwd(), 231 | "src/tests/fixtures/apps/non-function-default-export", 232 | ), 233 | ), 234 | Error, 235 | "default export is not a function", 236 | ); 237 | }, 238 | ); 239 | 240 | await tt.step( 241 | "should throw an exception when manifest entry for a function points to a non-existent file", 242 | async () => { 243 | await assertRejects( 244 | () => 245 | getValidateAndCleanManifest( 246 | path.join( 247 | Deno.cwd(), 248 | "src/tests/fixtures/apps/non-existent-function", 249 | ), 250 | ), 251 | Error, 252 | "Could not find file", 253 | ); 254 | }, 255 | ); 256 | 257 | await tt.step( 258 | "should throw an exception when manifest entry for a function does not have a source_file defined", 259 | async () => { 260 | await assertRejects( 261 | () => 262 | getValidateAndCleanManifest( 263 | path.join( 264 | Deno.cwd(), 265 | "src/tests/fixtures/apps/missing-source_file", 266 | ), 267 | ), 268 | Error, 269 | "No source_file property provided", 270 | ); 271 | }, 272 | ); 273 | }); 274 | }); 275 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # deno-slack-hooks 2 | 3 | [![codecov](https://codecov.io/gh/slackapi/deno-slack-hooks/graph/badge.svg?token=0WPCEDYVYB)](https://codecov.io/gh/slackapi/deno-slack-hooks) 4 | 5 | This library is intended to be used in applications running on Slack's 6 | next-generation application platform, focused on remixable units of 7 | functionality encapsulated as ephemeral functions. It implements the 8 | communication contract between the [Slack CLI][cli] and any Slack app 9 | development SDKs. 10 | 11 | ## Overview 12 | 13 | This library enables inter-process communication between the 14 | [Slack CLI tool][cli] and apps authored for Slack's 15 | [next-generation platform][nextgen]. The CLI delegates various tasks to the 16 | application SDK by means of invoking a process and expecting specific kinds of 17 | responses in the process' resultant stdout. For a full list of these tasks, 18 | check out the [Supported Scripts](#supported-scripts) section. 19 | 20 | ## Requirements 21 | 22 | This library requires a recent (at least 1.44) version of 23 | [deno](https://deno.land). 24 | 25 | Any invocations of this library require additional 26 | [deno permissions](https://deno.land/manual/getting_started/permissions), 27 | depending on which of the [Supported Scripts](#supported-scripts) is being 28 | invoked. 29 | 30 | ## Supported Scripts 31 | 32 | The hooks currently provided by this repo are `build`, `check-update`, `doctor`, 33 | `get-hooks`, `get-manifest`, `get-trigger`, `install-update`, and `start`. 34 | 35 | | Hook Name | CLI Command | Description | 36 | | ---------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 37 | | `build` | `slack deploy` | Bundles any functions with Deno into an output directory that's compatible with the Run on Slack runtime. Implemented in `build.ts`. | 38 | | `check-update` | `slack upgrade` | Checks the App's SDK dependencies to determine whether or not any of your libraries need to be updated. Implemented in `check_update.ts`. | 39 | | `doctor` | `slack doctor` | Returns runtime versions and other system dependencies required by the application. Implemented in `doctor.ts`. | 40 | | `get-hooks` | All | Fetches the list of available hooks for the CLI from this repository. Implemented in `mod.ts`. | 41 | | `get-manifest` | `slack manifest` | Converts a `manifest.json`, `manifest.js`, or `manifest.ts` file into a valid manifest JSON payload. Implemented in `get_manifest.ts`. | 42 | | `get-trigger` | `slack trigger create` | Converts a specified `json`, `js`, or `ts` file into a valid trigger JSON payload to be uploaded by the CLI to the `workflows.triggers.create` Slack API endpoint. Implemented in `get_trigger.ts`. | 43 | | `install-update` | `slack upgrade` | Prompts the user to automatically update any dependencies that need to be updated based on the result of the `check-update` hook. Implemented in `install_update.ts`. | 44 | | `start` | `slack run` | While developing and locally running a deno-slack-based application, the CLI manages a socket connection with Slack's backend and delegates to this hook for invoking the correct application function for relevant events incoming via this connection. For more information, see the [deno-slack-runtime](https://github.com/slackapi/deno-slack-runtime) repository's details on `local-run`. | 45 | 46 | ### Check Update Script Usage 47 | 48 | The `check_update.ts` file is executed as a Deno program and takes no arguments. 49 | 50 | #### Example 51 | 52 | ```bash 53 | deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/deno_slack_hooks/check_update.ts 54 | ``` 55 | 56 | ### Get Hooks Script Usage 57 | 58 | The `mod.ts` file is executed as a Deno program and takes no arguments. 59 | 60 | #### Example 61 | 62 | ```bash 63 | deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/deno_slack_hooks/mod.ts 64 | ``` 65 | 66 | ### Get Trigger Script Usage 67 | 68 | The `get_trigger.ts` file is executed as a Deno program and takes one required 69 | argument: 70 | 71 | | Arguments | Description | 72 | | ---------- | --------------------------------------------------------------------------------------------------------------------- | 73 | | `--source` | Absolute or relative path to your target trigger file. The trigger object must be exported as default from this file. | 74 | 75 | #### Example 76 | 77 | ```bash 78 | deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/deno_slack_hooks/get_trigger.ts --source="./trigger.ts" 79 | ``` 80 | 81 | ### Install Update Script Usage 82 | 83 | The `install_update.ts` file is executed as a Deno program and takes no 84 | arguments. 85 | 86 | #### Example 87 | 88 | ```bash 89 | deno run -q --config=deno.jsonc --allow-run --allow-read --allow-write --allow-net https://deno.land/x/deno_slack_hooks/install_update.ts 90 | ``` 91 | 92 | ## Script Overrides Usage 93 | 94 | If you find yourself needing to override a hook script specified by this 95 | library, you can do so in your Slack app's `/slack.json` file! Just specify a 96 | new script for the hook in question. All supported hooks can be overwritten. 97 | 98 | Below is an example `/slack.json` file that overrides the `build` script to 99 | point to your local repo for development purposes. It's using an implicit 100 | "latest" version of the script, 101 | but we suggest pinning it to whatever the latest version is. 102 | 103 | ```json 104 | { 105 | "hooks": { 106 | "get-hooks": "deno run -q --allow-read --allow-net https://deno.land/x/deno_slack_hooks/mod.ts", 107 | "build": "deno run -q --config=deno.jsonc --allow-read --allow-write --allow-net --allow-run file:////mod.ts" 108 | } 109 | } 110 | ``` 111 | 112 | The [Slack CLI][cli] will automatically know to pick up your local hook 113 | definition and use that instead of what's defined by this library. 114 | 115 | This can also be used to change the flags sent to the `deno run` command if you 116 | decide to change the location of your config file, or switch to an import map 117 | instead. 118 | 119 | --- 120 | 121 | ### Getting Help 122 | 123 | We welcome contributions from everyone! Please check out our 124 | [Contributor's Guide](https://github.com/slackapi/deno-slack-hooks/blob/main/.github/CONTRIBUTING.md) 125 | for how to contribute in a helpful and collaborative way. 126 | 127 | [cli]: https://github.com/slackapi/slack-cli 128 | [nextgen]: https://api.slack.com/automation 129 | -------------------------------------------------------------------------------- /src/tests/install_update_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../dev_deps.ts"; 2 | import { mockFile } from "../dev_deps.ts"; 3 | import { 4 | createUpdateResp, 5 | SDK_NAME, 6 | updateDependencyFile, 7 | updateDependencyMap, 8 | } from "../install_update.ts"; 9 | 10 | const MOCK_RELEASES = [ 11 | { 12 | name: "deno_slack_sdk", 13 | current: "0.0.6", 14 | latest: "0.0.7", 15 | update: true, 16 | breaking: false, 17 | error: null, 18 | }, 19 | { 20 | name: "deno_slack_api", 21 | current: "0.0.6", 22 | latest: "0.0.7", 23 | update: true, 24 | breaking: false, 25 | error: null, 26 | }, 27 | { 28 | name: "deno_slack_hooks", 29 | current: "0.0.9", 30 | latest: "0.0.10", 31 | update: true, 32 | breaking: false, 33 | error: null, 34 | }, 35 | ]; 36 | 37 | const MOCK_HOOKS_JSON = JSON.stringify({ 38 | hooks: { 39 | "get-hooks": 40 | "deno run -q --allow-read --allow-net https://deno.land/x/deno_slack_hooks@0.0.9/mod.ts", 41 | }, 42 | }); 43 | 44 | const MOCK_IMPORTS_JSON = JSON.stringify({ 45 | imports: { 46 | "deno-slack-sdk/": "https://deno.land/x/deno_slack_sdk@0.0.6/", 47 | "deno-slack-api/": "https://deno.land/x/deno_slack_api@0.0.6/", 48 | }, 49 | }); 50 | 51 | const MOCK_DENO_JSON = JSON.stringify({ 52 | "importMap": "import_map.json", 53 | }); 54 | 55 | // Returns any because Type 'Uint8Array' is a not generic in Deno 1 56 | // deno-lint-ignore no-explicit-any 57 | const encodeStringToUint8Array = (data: string): any => { 58 | return new TextEncoder().encode( 59 | data, 60 | ); 61 | }; 62 | const MOCK_SLACK_JSON_FILE = encodeStringToUint8Array(MOCK_HOOKS_JSON); 63 | const MOCK_DOT_SLACK_HOOKS_JSON_FILE = encodeStringToUint8Array( 64 | MOCK_HOOKS_JSON, 65 | ); 66 | const MOCK_IMPORT_MAP_FILE = encodeStringToUint8Array(MOCK_IMPORTS_JSON); 67 | const MOCK_DENO_JSON_FILE = encodeStringToUint8Array(MOCK_DENO_JSON); 68 | const MOCK_IMPORTS_IN_DENO_JSON_FILE = encodeStringToUint8Array( 69 | MOCK_IMPORTS_JSON, 70 | ); 71 | const EMPTY_JSON_FILE = encodeStringToUint8Array("{}"); 72 | 73 | const setEmptyJsonFiles = () => { 74 | mockFile.prepareVirtualFile("./slack.json", EMPTY_JSON_FILE); 75 | mockFile.prepareVirtualFile("./slack.jsonc", EMPTY_JSON_FILE); 76 | mockFile.prepareVirtualFile("./deno.json", EMPTY_JSON_FILE); 77 | mockFile.prepareVirtualFile("./deno.jsonc", EMPTY_JSON_FILE); 78 | mockFile.prepareVirtualFile("./import_map.json", EMPTY_JSON_FILE); 79 | mockFile.prepareVirtualFile("./.slack/hooks.json", EMPTY_JSON_FILE); 80 | }; 81 | 82 | Deno.test("update hook tests", async (t) => { 83 | await t.step("createUpdateResp", async (evT) => { 84 | await evT.step( 85 | "if dependency files are not found, response does not include an error", 86 | async () => { 87 | // Absence of prepareVirtualFile ensures that file does not exist 88 | // NOTE: *must* go before .prepareVirtualFile-dependent tests below until 89 | // it's clear how to "unmount" a file. Once it's there.. it's there. 90 | const actual = await createUpdateResp(MOCK_RELEASES); 91 | const expected = { name: SDK_NAME, updates: [] }; 92 | 93 | assertEquals(actual, expected); 94 | }, 95 | ); 96 | 97 | await evT.step( 98 | "if referenced importMap in deno.json has available updates, then response includes those updates", 99 | async () => { 100 | setEmptyJsonFiles(); 101 | mockFile.prepareVirtualFile( 102 | "./slack.json", 103 | MOCK_SLACK_JSON_FILE, 104 | ); 105 | mockFile.prepareVirtualFile("./deno.json", MOCK_DENO_JSON_FILE); 106 | mockFile.prepareVirtualFile("./import_map.json", MOCK_IMPORT_MAP_FILE); 107 | 108 | const expectedHooksUpdateSummary = [{ 109 | name: "deno_slack_hooks", 110 | previous: "0.0.9", 111 | installed: "0.0.10", 112 | }]; 113 | 114 | const expectedImportsUpdateSummary = [ 115 | { 116 | name: "deno_slack_sdk", 117 | previous: "0.0.6", 118 | installed: "0.0.7", 119 | }, 120 | { 121 | name: "deno_slack_api", 122 | previous: "0.0.6", 123 | installed: "0.0.7", 124 | }, 125 | ]; 126 | 127 | const actual = await createUpdateResp(MOCK_RELEASES); 128 | const expected = { 129 | name: SDK_NAME, 130 | updates: [ 131 | ...expectedHooksUpdateSummary, 132 | ...expectedImportsUpdateSummary, 133 | ], 134 | }; 135 | 136 | assertEquals(actual, expected); 137 | }, 138 | ); 139 | 140 | await evT.step( 141 | "if referenced imports in deno.json has available updates, then response includes those updates", 142 | async () => { 143 | setEmptyJsonFiles(); 144 | mockFile.prepareVirtualFile( 145 | "./.slack/hooks.json", 146 | MOCK_DOT_SLACK_HOOKS_JSON_FILE, 147 | ); 148 | mockFile.prepareVirtualFile( 149 | "./deno.jsonc", 150 | MOCK_IMPORTS_IN_DENO_JSON_FILE, 151 | ); 152 | 153 | const expectedHooksUpdateSummary = [{ 154 | name: "deno_slack_hooks", 155 | previous: "0.0.9", 156 | installed: "0.0.10", 157 | }]; 158 | 159 | const expectedImportsUpdateSummary = [ 160 | { 161 | name: "deno_slack_sdk", 162 | previous: "0.0.6", 163 | installed: "0.0.7", 164 | }, 165 | { 166 | name: "deno_slack_api", 167 | previous: "0.0.6", 168 | installed: "0.0.7", 169 | }, 170 | ]; 171 | 172 | const actual = await createUpdateResp(MOCK_RELEASES); 173 | const expected = { 174 | name: SDK_NAME, 175 | updates: [ 176 | ...expectedImportsUpdateSummary, 177 | ...expectedHooksUpdateSummary, 178 | ], 179 | }; 180 | 181 | assertEquals(actual, expected); 182 | }, 183 | ); 184 | }); 185 | 186 | await t.step("updateDependencyFile", async (evT) => { 187 | await evT.step( 188 | "if dependency updates are available, the correct update summary is returned", 189 | async () => { 190 | setEmptyJsonFiles(); 191 | const expectedHooksUpdateResp = [ 192 | { 193 | name: "deno_slack_hooks", 194 | previous: "0.0.9", 195 | installed: "0.0.10", 196 | }, 197 | ]; 198 | 199 | const expectedImportsUpdateResp = [ 200 | { 201 | name: "deno_slack_sdk", 202 | previous: "0.0.6", 203 | installed: "0.0.7", 204 | }, 205 | { 206 | name: "deno_slack_api", 207 | previous: "0.0.6", 208 | installed: "0.0.7", 209 | }, 210 | ]; 211 | 212 | mockFile.prepareVirtualFile( 213 | "./slack.json", 214 | MOCK_SLACK_JSON_FILE, 215 | ); 216 | 217 | mockFile.prepareVirtualFile( 218 | "./import_map.json", 219 | MOCK_IMPORT_MAP_FILE, 220 | ); 221 | 222 | assertEquals( 223 | await updateDependencyFile("./slack.json", MOCK_RELEASES), 224 | expectedHooksUpdateResp, 225 | ); 226 | 227 | assertEquals( 228 | await updateDependencyFile("./import_map.json", MOCK_RELEASES), 229 | expectedImportsUpdateResp, 230 | ); 231 | }, 232 | ); 233 | 234 | await evT.step( 235 | "if file isn't found, an empty update array is returned", 236 | async () => { 237 | setEmptyJsonFiles(); 238 | assertEquals( 239 | await updateDependencyFile("./bad_file.json", MOCK_RELEASES), 240 | [], 241 | "correct dependency update response for slack.json was not returned", 242 | ); 243 | }, 244 | ); 245 | }); 246 | 247 | await t.step("updateDependencyMap", async (evT) => { 248 | await evT.step( 249 | "update versions are correctly mapped to the file's dependency map", 250 | () => { 251 | setEmptyJsonFiles(); 252 | const { hooks } = JSON.parse(MOCK_HOOKS_JSON); 253 | const { imports } = JSON.parse(MOCK_IMPORTS_JSON); 254 | 255 | const expectedHooksJSON = { 256 | hooks: { 257 | "get-hooks": 258 | // Bump the version from 0.0.9 => 0.0.10 259 | "deno run -q --allow-read --allow-net https://deno.land/x/deno_slack_hooks@0.0.10/mod.ts", 260 | }, 261 | }; 262 | 263 | const expectedHooksUpdateSummary = [{ 264 | name: "deno_slack_hooks", 265 | previous: "0.0.9", 266 | installed: "0.0.10", 267 | }]; 268 | 269 | const expectedImportMapJSON = { 270 | imports: { 271 | // Bump the versions from 0.0.6 => 0.0.7 272 | "deno-slack-sdk/": "https://deno.land/x/deno_slack_sdk@0.0.7/", 273 | "deno-slack-api/": "https://deno.land/x/deno_slack_api@0.0.7/", 274 | }, 275 | }; 276 | 277 | const expectedImportsUpdateSummary = [ 278 | { 279 | name: "deno_slack_sdk", 280 | previous: "0.0.6", 281 | installed: "0.0.7", 282 | }, 283 | { 284 | name: "deno_slack_api", 285 | previous: "0.0.6", 286 | installed: "0.0.7", 287 | }, 288 | ]; 289 | 290 | assertEquals( 291 | updateDependencyMap(hooks, MOCK_RELEASES), 292 | { 293 | updatedDependencies: expectedHooksJSON.hooks, 294 | updateSummary: expectedHooksUpdateSummary, 295 | }, 296 | "expected update of slack.json map not returned", 297 | ); 298 | 299 | assertEquals( 300 | updateDependencyMap(imports, MOCK_RELEASES), 301 | { 302 | updatedDependencies: expectedImportMapJSON.imports, 303 | updateSummary: expectedImportsUpdateSummary, 304 | }, 305 | "expected update of import_map.json map not returned", 306 | ); 307 | }, 308 | ); 309 | }); 310 | }); 311 | -------------------------------------------------------------------------------- /src/check_update.ts: -------------------------------------------------------------------------------- 1 | import type { JsonValue } from "jsr:@std/jsonc@^1.0.1"; 2 | import { join } from "jsr:@std/path@1.1.0"; 3 | import { getProtocolInterface } from "jsr:@slack/protocols@0.0.3"; 4 | 5 | import { 6 | DENO_SLACK_API, 7 | DENO_SLACK_HOOKS, 8 | DENO_SLACK_SDK, 9 | } from "./libraries.ts"; 10 | import { getJSON, isNewSemverRelease } from "./utilities.ts"; 11 | 12 | const IMPORT_MAP_SDKS = [DENO_SLACK_SDK, DENO_SLACK_API]; 13 | const SLACK_JSON_SDKS = [ 14 | DENO_SLACK_HOOKS, // should be the only one needed now that the get-hooks hook is supported 15 | ]; 16 | 17 | interface CheckUpdateResponse { 18 | name: string; 19 | releases: Release[]; 20 | message?: string; 21 | url?: string; 22 | error?: { 23 | message: string; 24 | } | null; 25 | } 26 | 27 | interface VersionMap { 28 | [key: string]: Release; 29 | } 30 | 31 | export interface Release { 32 | name: string; 33 | current?: string; 34 | latest?: string; 35 | update?: boolean; 36 | breaking?: boolean; 37 | message?: string; 38 | url?: string; 39 | error?: { 40 | message: string; 41 | } | null; 42 | } 43 | 44 | interface InaccessibleFile { 45 | name: string; 46 | error: Error; 47 | } 48 | 49 | export const checkForSDKUpdates = async () => { 50 | const { versionMap, inaccessibleFiles } = await createVersionMap(); 51 | const updateResp = createUpdateResp(versionMap, inaccessibleFiles); 52 | return updateResp; 53 | }; 54 | 55 | /** 56 | * createVersionMap creates an object that contains each dependency, 57 | * featuring information about the current and latest versions, as well 58 | * as if breaking changes are present and if any errors occurred during 59 | * version retrieval. 60 | */ 61 | export async function createVersionMap(): Promise< 62 | { versionMap: VersionMap; inaccessibleFiles: InaccessibleFile[] } 63 | > { 64 | const { versionMap, inaccessibleFiles } = await readProjectDependencies(); 65 | 66 | // Check each dependency for updates, classify update as breaking or not, 67 | // craft message with information retrieved, and note any error that occurred. 68 | for (const [sdk, value] of Object.entries(versionMap)) { 69 | if (value) { 70 | const current = versionMap[sdk].current || ""; 71 | let latest = "", error = null; 72 | 73 | try { 74 | latest = await fetchLatestModuleVersion(sdk); 75 | } catch (err) { 76 | if (err instanceof Error) { 77 | error = err; 78 | } else { 79 | error = new Error( 80 | `Caught non-Error value: ${String(err)} (type: ${typeof err})`, 81 | { 82 | cause: err, 83 | }, 84 | ); 85 | } 86 | } 87 | 88 | const update = (!!current && !!latest) && 89 | isNewSemverRelease(current, latest); 90 | const breaking = hasBreakingChange(current, latest); 91 | 92 | versionMap[sdk] = { 93 | ...versionMap[sdk], 94 | latest, 95 | update, 96 | breaking, 97 | error, 98 | }; 99 | } 100 | } 101 | 102 | return { versionMap, inaccessibleFiles }; 103 | } 104 | 105 | /** readProjectDependencies cycles through supported project 106 | * dependency files and maps them to the versionMap that contains 107 | * each dependency's update information. 108 | */ 109 | export async function readProjectDependencies(): Promise< 110 | { versionMap: VersionMap; inaccessibleFiles: InaccessibleFile[] } 111 | > { 112 | const cwd = Deno.cwd(); 113 | const versionMap: VersionMap = {}; 114 | const { dependencyFiles, inaccessibleDenoFiles } = 115 | await gatherDependencyFiles(cwd); 116 | const inaccessibleFiles = [...inaccessibleDenoFiles]; 117 | 118 | for (const [fileName, depKey] of dependencyFiles) { 119 | try { 120 | const fileJSON = await getJSON(join(cwd, fileName)); 121 | const fileDependencies = extractDependencies(fileJSON, depKey); 122 | 123 | // For each dependency found, compare to SDK-related dependency 124 | // list and, if known, update the versionMap with version information 125 | for (const [_, val] of fileDependencies) { 126 | for (const sdk of [...IMPORT_MAP_SDKS, ...SLACK_JSON_SDKS]) { 127 | if (val.includes(sdk)) { 128 | versionMap[sdk] = { 129 | name: sdk, 130 | current: extractVersion(val), 131 | }; 132 | } 133 | } 134 | } 135 | } catch (err: unknown) { 136 | const error = err instanceof Error ? err : new Error( 137 | `Caught non-Error value: ${String(err)} (type: ${typeof err})`, 138 | { 139 | cause: err, 140 | }, 141 | ); 142 | inaccessibleFiles.push({ name: fileName, error }); 143 | } 144 | } 145 | 146 | return { versionMap, inaccessibleFiles }; 147 | } 148 | 149 | /** 150 | * gatherDependencyFiles rounds up all SDK-supported dependency files, as well 151 | * as those dependency files referenced in deno.json or deno.jsonc, and returns 152 | * an array of arrays made up of filename and dependency key pairs. 153 | */ 154 | export async function gatherDependencyFiles( 155 | cwd: string, 156 | ): Promise< 157 | { 158 | dependencyFiles: [string, "imports" | "hooks"][]; 159 | inaccessibleDenoFiles: InaccessibleFile[]; 160 | } 161 | > { 162 | const dependencyFiles: [string, "imports" | "hooks"][] = [ 163 | ["deno.json", "imports"], 164 | ["deno.jsonc", "imports"], 165 | ["slack.json", "hooks"], 166 | ["slack.jsonc", "hooks"], 167 | [join(".slack", "hooks.json"), "hooks"], 168 | ]; 169 | 170 | // Parse deno.* files for `importMap` dependency file 171 | const { denoJSONDepFiles, inaccessibleDenoFiles } = 172 | await getDenoImportMapFiles(cwd); 173 | dependencyFiles.push(...denoJSONDepFiles); 174 | 175 | return { dependencyFiles, inaccessibleDenoFiles }; 176 | } 177 | 178 | /** 179 | * getDenoImportMapFiles cycles through supported deno.* files and, 180 | * if an `importMap` key is found, returns an array of arrays made up 181 | * of filename and dependency key pairs. 182 | * 183 | * ex: [["import_map.json", "imports"], ["custom_map.json", "imports"]] 184 | * 185 | * With Deno 2 we favor imports in deno.json but kept this for backward compatibility. 186 | */ 187 | export async function getDenoImportMapFiles( 188 | cwd: string, 189 | ): Promise< 190 | { 191 | denoJSONDepFiles: [string, "imports"][]; 192 | inaccessibleDenoFiles: InaccessibleFile[]; 193 | } 194 | > { 195 | const denoJSONFiles = ["deno.json", "deno.jsonc"]; 196 | const denoJSONDepFiles: [string, "imports"][] = []; 197 | const inaccessibleDenoFiles: InaccessibleFile[] = []; 198 | 199 | for (const fileName of denoJSONFiles) { 200 | try { 201 | const denoJSON = await getJSON(join(cwd, fileName)); 202 | const jsonIsParsable = denoJSON && typeof denoJSON === "object" && 203 | !Array.isArray(denoJSON) && denoJSON.importMap; 204 | 205 | if (jsonIsParsable) { 206 | denoJSONDepFiles.push([`${denoJSON.importMap}`, "imports"]); 207 | } 208 | } catch (err) { 209 | const error = err instanceof Error ? err : new Error( 210 | `Caught non-Error value: ${String(err)} (type: ${typeof err})`, 211 | { 212 | cause: err, 213 | }, 214 | ); 215 | inaccessibleDenoFiles.push({ name: fileName, error }); 216 | } 217 | } 218 | 219 | return { denoJSONDepFiles, inaccessibleDenoFiles }; 220 | } 221 | 222 | /** 223 | * extractDependencies accepts the contents of a JSON file and a 224 | * top-level, file-specific key within that file that corresponds to 225 | * recognized project dependencies. If found, returns an array of key, 226 | * value pairs that make use of the dependencies. 227 | */ 228 | export function extractDependencies( 229 | json: JsonValue, 230 | key: string, 231 | ): [string, string][] { 232 | // Determine if the JSON passed is an object 233 | const jsonIsParsable = json !== null && typeof json === "object" && 234 | !Array.isArray(json); 235 | 236 | if (jsonIsParsable) { 237 | const dependencyMap = json[key]; 238 | return dependencyMap ? Object.entries(dependencyMap) : []; 239 | } 240 | 241 | return []; 242 | } 243 | 244 | /** fetchLatestModuleVersion retrieves the published metadata.json that 245 | * contains all releases and returns the latest published version 246 | */ 247 | export async function fetchLatestModuleVersion( 248 | moduleName: string, 249 | ): Promise { 250 | try { 251 | const res = await fetch("https://api.slack.com/slackcli/metadata.json"); 252 | const jsonData = await res.json(); 253 | const hypenatedModuleName = moduleName.replaceAll("_", "-"); 254 | return jsonData[hypenatedModuleName].releases[0].version; 255 | } catch (err: unknown) { 256 | const error = err instanceof Error ? err : new Error( 257 | `Caught non-Error value: ${String(err)} (type: ${typeof err})`, 258 | { 259 | cause: err, 260 | }, 261 | ); 262 | throw error; 263 | } 264 | } 265 | 266 | /** 267 | * extractVersion takes in a URL formatted string, searches for a version, 268 | * and, if version is found, returns that version. 269 | * 270 | * Example input: https://deno.land/x/deno_slack_sdk@2.6.0/ 271 | */ 272 | export function extractVersion(str: string): string { 273 | const at = str.indexOf("@"); 274 | 275 | // Doesn't contain an @ version 276 | if (at === -1) return ""; 277 | 278 | const slash = str.indexOf("/", at); 279 | const version = slash < at 280 | ? str.substring(at + 1) 281 | : str.substring(at + 1, slash); 282 | return version; 283 | } 284 | 285 | /** 286 | * hasBreakingChange determines whether or not there is a 287 | * major version difference of greater or equal to 1 between the current 288 | * and latest version. 289 | */ 290 | export function hasBreakingChange(current: string, latest: string): boolean { 291 | const currMajor = current.split(".")[0]; 292 | const latestMajor = latest.split(".")[0]; 293 | return +latestMajor - +currMajor >= 1; 294 | } 295 | 296 | /** 297 | * createUpdateResp creates and returns an CheckUpdateResponse object 298 | * that contains information about a collection of release dependencies 299 | * in the shape of an object that the CLI expects to consume 300 | */ 301 | export function createUpdateResp( 302 | versionMap: VersionMap, 303 | inaccessibleFiles: InaccessibleFile[], 304 | ): CheckUpdateResponse { 305 | const name = "the Slack SDK"; 306 | const releases = []; 307 | const message = ""; 308 | const url = "https://docs.slack.dev/changelog"; 309 | const fileErrorMsg = createFileErrorMsg(inaccessibleFiles); 310 | 311 | let error = null; 312 | let errorMsg = ""; 313 | 314 | // Output information for each dependency 315 | for (const sdk of Object.values(versionMap)) { 316 | // Dependency has an update OR the fetch of update failed 317 | if (sdk) { 318 | releases.push(sdk); 319 | 320 | // Add the dependency that failed to be fetched to the top-level error message 321 | if (sdk.error && sdk.error.message) { 322 | errorMsg += errorMsg 323 | ? `, ${sdk.name}` 324 | : `An error occurred fetching updates for the following packages: ${sdk.name}`; 325 | } 326 | } 327 | } 328 | 329 | // If there were issues accessing dependency files, append error message(s) 330 | if (inaccessibleFiles.length) { 331 | errorMsg += errorMsg ? `\n\n ${fileErrorMsg}` : fileErrorMsg; 332 | } 333 | 334 | if (errorMsg) error = { message: errorMsg }; 335 | 336 | return { 337 | name, 338 | message, 339 | releases, 340 | url, 341 | error, 342 | }; 343 | } 344 | 345 | /** 346 | * createFileErrorMsg creates and returns an error message that 347 | * contains lightly formatted information about the dependency 348 | * files that were found but otherwise inaccessible/unreadable. 349 | */ 350 | export function createFileErrorMsg( 351 | inaccessibleFiles: InaccessibleFile[], 352 | ): string { 353 | let fileErrorMsg = ""; 354 | 355 | // There were issues with reading some of the files that were found 356 | for (const file of inaccessibleFiles) { 357 | // Skip surfacing error to user if supported file was merely not found 358 | if (file.error.cause instanceof Deno.errors.NotFound) continue; 359 | 360 | fileErrorMsg += fileErrorMsg 361 | ? `\n ${file.name}: ${file.error.message}` 362 | : `An error occurred while reading the following files: \n\n ${file.name}: ${file.error.message}`; 363 | } 364 | 365 | return fileErrorMsg; 366 | } 367 | 368 | if (import.meta.main) { 369 | const protocol = getProtocolInterface(Deno.args); 370 | protocol.respond(JSON.stringify(await checkForSDKUpdates())); 371 | } 372 | -------------------------------------------------------------------------------- /src/tests/check_update_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertRejects, stub } from "../dev_deps.ts"; 2 | import { mockFile } from "../dev_deps.ts"; 3 | import { 4 | createFileErrorMsg, 5 | createUpdateResp, 6 | extractDependencies, 7 | extractVersion, 8 | fetchLatestModuleVersion, 9 | getDenoImportMapFiles, 10 | hasBreakingChange, 11 | readProjectDependencies, 12 | } from "../check_update.ts"; 13 | 14 | const MOCK_HOOKS_JSON = JSON.stringify({ 15 | hooks: { 16 | "get-hooks": 17 | "deno run -q --allow-read --allow-net https://deno.land/x/deno_slack_hooks@0.0.9/mod.ts", 18 | }, 19 | }); 20 | 21 | const MOCK_IMPORTS_JSON = JSON.stringify({ 22 | imports: { 23 | "deno-slack-sdk/": "https://deno.land/x/deno_slack_sdk@0.0.6/", 24 | "deno-slack-api/": "https://deno.land/x/deno_slack_api@0.0.6/", 25 | }, 26 | }); 27 | 28 | const MOCK_DENO_JSON = JSON.stringify({ 29 | "importMap": "import_map.json", 30 | }); 31 | 32 | // Returns any because Type 'Uint8Array' is a not generic in Deno 1 33 | // deno-lint-ignore no-explicit-any 34 | const encodeStringToUint8Array = (data: string): any => { 35 | return new TextEncoder().encode( 36 | data, 37 | ); 38 | }; 39 | const MOCK_SLACK_JSON_FILE = encodeStringToUint8Array(MOCK_HOOKS_JSON); 40 | const MOCK_DOT_SLACK_HOOKS_JSON_FILE = encodeStringToUint8Array( 41 | MOCK_HOOKS_JSON, 42 | ); 43 | const MOCK_IMPORT_MAP_FILE = encodeStringToUint8Array(MOCK_IMPORTS_JSON); 44 | const MOCK_DENO_JSON_FILE = encodeStringToUint8Array(MOCK_DENO_JSON); 45 | const MOCK_IMPORTS_IN_DENO_JSON_FILE = encodeStringToUint8Array( 46 | MOCK_IMPORTS_JSON, 47 | ); 48 | const EMPTY_JSON_FILE = encodeStringToUint8Array("{}"); 49 | 50 | const setEmptyJsonFiles = () => { 51 | mockFile.prepareVirtualFile("./slack.json", EMPTY_JSON_FILE); 52 | mockFile.prepareVirtualFile("./slack.jsonc", EMPTY_JSON_FILE); 53 | mockFile.prepareVirtualFile("./deno.json", EMPTY_JSON_FILE); 54 | mockFile.prepareVirtualFile("./deno.jsonc", EMPTY_JSON_FILE); 55 | mockFile.prepareVirtualFile("./import_map.json", EMPTY_JSON_FILE); 56 | mockFile.prepareVirtualFile("./.slack/hooks.json", EMPTY_JSON_FILE); 57 | }; 58 | 59 | Deno.test("check-update hook tests", async (t) => { 60 | // readProjectDependencies 61 | await t.step("readProjectDependencies method", async (evT) => { 62 | await evT.step( 63 | "if slack.json, deno.json & import_map.json contain recognized dependency, version appears in returned map", 64 | async () => { 65 | setEmptyJsonFiles(); 66 | mockFile.prepareVirtualFile("./slack.json", MOCK_SLACK_JSON_FILE); 67 | mockFile.prepareVirtualFile("./deno.json", MOCK_DENO_JSON_FILE); 68 | mockFile.prepareVirtualFile("./import_map.json", MOCK_IMPORT_MAP_FILE); 69 | 70 | const { versionMap } = await readProjectDependencies(); 71 | 72 | // Expected dependencies are present in returned versionMap 73 | assertEquals( 74 | true, 75 | "deno_slack_hooks" in versionMap && 76 | "deno_slack_api" in versionMap && 77 | "deno_slack_hooks" in versionMap, 78 | ); 79 | 80 | // Initial expected versionMap properties are present (name, current) 81 | assertEquals( 82 | true, 83 | Object.values(versionMap).every((dep) => dep.name && dep.current), 84 | "slack.json dependency wasn't found in returned versionMap", 85 | ); 86 | }, 87 | ); 88 | 89 | await evT.step( 90 | "if .slack/hooks.json and deno.json contain recognized dependency, version appears in returned map", 91 | async () => { 92 | setEmptyJsonFiles(); 93 | mockFile.prepareVirtualFile( 94 | "./.slack/hooks.json", 95 | MOCK_DOT_SLACK_HOOKS_JSON_FILE, 96 | ); 97 | mockFile.prepareVirtualFile( 98 | "./deno.json", 99 | MOCK_IMPORTS_IN_DENO_JSON_FILE, 100 | ); 101 | 102 | const { versionMap } = await readProjectDependencies(); 103 | 104 | // Expected dependencies are present in returned versionMap 105 | assertEquals( 106 | true, 107 | "deno_slack_hooks" in versionMap && 108 | "deno_slack_api" in versionMap && 109 | "deno_slack_hooks" in versionMap, 110 | ); 111 | 112 | // Initial expected versionMap properties are present (name, current) 113 | assertEquals( 114 | true, 115 | Object.values(versionMap).every((dep) => dep.name && dep.current), 116 | "slack.json dependency wasn't found in returned versionMap", 117 | ); 118 | }, 119 | ); 120 | }); 121 | 122 | // getDenoImportMapFiles 123 | await t.step("getDenoImportMapFiles method", async (evT) => { 124 | await evT.step( 125 | "if deno.json file is unavailable, or no importMap key is present, an empty array is returned", 126 | async () => { 127 | setEmptyJsonFiles(); 128 | // Clear out deno.json file that's in memory from previous test(s) 129 | mockFile.prepareVirtualFile( 130 | "./deno.json", 131 | encodeStringToUint8Array(""), 132 | ); 133 | 134 | const cwd = Deno.cwd(); 135 | const { denoJSONDepFiles } = await getDenoImportMapFiles(cwd); 136 | const expected: [string, "imports"][] = []; 137 | 138 | assertEquals( 139 | denoJSONDepFiles, 140 | expected, 141 | `Expected: ${JSON.stringify(expected)}\n Actual: ${ 142 | JSON.stringify(denoJSONDepFiles) 143 | }`, 144 | ); 145 | }, 146 | ); 147 | 148 | await evT.step( 149 | "if deno.json file is available, the correct filename + dependency key pair is returned", 150 | async () => { 151 | setEmptyJsonFiles(); 152 | const cwd = Deno.cwd(); 153 | mockFile.prepareVirtualFile("./deno.json", MOCK_DENO_JSON_FILE); 154 | 155 | const { denoJSONDepFiles } = await getDenoImportMapFiles(cwd); 156 | 157 | // Correct custom importMap file name is returned 158 | assertEquals( 159 | denoJSONDepFiles, 160 | [["import_map.json", "imports"]], 161 | ); 162 | }, 163 | ); 164 | }); 165 | 166 | // extractDependencies 167 | await t.step("extractDependencies method", async (evT) => { 168 | await evT.step( 169 | "given import_map.json or slack.json file contents, an array of key, value dependency pairs is returned", 170 | () => { 171 | setEmptyJsonFiles(); 172 | const importMapActual = extractDependencies( 173 | JSON.parse(MOCK_IMPORTS_JSON), 174 | "imports", 175 | ); 176 | 177 | const slackHooksActual = extractDependencies( 178 | JSON.parse(MOCK_HOOKS_JSON), 179 | "hooks", 180 | ); 181 | 182 | const importMapExpected: [string, string][] = [[ 183 | "deno-slack-sdk/", 184 | "https://deno.land/x/deno_slack_sdk@0.0.6/", 185 | ], ["deno-slack-api/", "https://deno.land/x/deno_slack_api@0.0.6/"]]; 186 | 187 | const slackHooksExpected: [string, string][] = [ 188 | [ 189 | "get-hooks", 190 | "deno run -q --allow-read --allow-net https://deno.land/x/deno_slack_hooks@0.0.9/mod.ts", 191 | ], 192 | ]; 193 | 194 | assertEquals( 195 | importMapActual, 196 | importMapExpected, 197 | `Expected: ${JSON.stringify([])}\n Actual: ${ 198 | JSON.stringify(importMapActual) 199 | }`, 200 | ); 201 | 202 | assertEquals( 203 | slackHooksActual, 204 | slackHooksExpected, 205 | `Expected: ${JSON.stringify([])}\n Actual: ${ 206 | JSON.stringify(importMapActual) 207 | }`, 208 | ); 209 | }, 210 | ); 211 | }); 212 | 213 | // extractVersion 214 | await t.step("extractVersion method", async (evT) => { 215 | await evT.step( 216 | "if version string does not contain an '@' then return empty", 217 | () => { 218 | assertEquals( 219 | extractVersion("bat country"), 220 | "", 221 | "empty string not returned", 222 | ); 223 | }, 224 | ); 225 | 226 | await evT.step( 227 | "if version string contains a slash after the '@' should return just the version", 228 | () => { 229 | assertEquals( 230 | extractVersion("https://deon.land/x/slack_goodise@0.1.0/mod.ts"), 231 | "0.1.0", 232 | "version not returned", 233 | ); 234 | }, 235 | ); 236 | 237 | await evT.step( 238 | "if version string does not contain a slash after the '@' should return just the version", 239 | () => { 240 | assertEquals( 241 | extractVersion("https://deon.land/x/slack_goodise@0.1.0"), 242 | "0.1.0", 243 | "version not returned", 244 | ); 245 | }, 246 | ); 247 | }); 248 | 249 | // hasBreakingChange 250 | await t.step("hasBreakingChange method", async (evT) => { 251 | await evT.step( 252 | "should return true if version difference is 1.0.0 or greater", 253 | () => { 254 | const currentVersion = "1.0.0"; 255 | const latestVersion = "2.0.0"; 256 | assertEquals(hasBreakingChange(currentVersion, latestVersion), true); 257 | }, 258 | ); 259 | 260 | await evT.step( 261 | "should return false if version difference is 1.0.0 or less", 262 | () => { 263 | const currentVersion = "1.0.0"; 264 | const latestVersion = "1.5.0"; 265 | assertEquals(hasBreakingChange(currentVersion, latestVersion), false); 266 | }, 267 | ); 268 | }); 269 | 270 | // fetchLatestModuleVersion 271 | await t.step("fetchLatestModuleVersion method", async (evT) => { 272 | const stubMetadataJsonFetch = (response: Response) => { 273 | return stub( 274 | globalThis, 275 | "fetch", 276 | (url: string | URL | Request, options?: RequestInit) => { 277 | const req = url instanceof Request ? url : new Request(url, options); 278 | assertEquals(req.method, "GET"); 279 | assertEquals( 280 | req.url, 281 | "https://api.slack.com/slackcli/metadata.json", 282 | ); 283 | return Promise.resolve(response); 284 | }, 285 | ); 286 | }; 287 | 288 | const mockMetadataJSON = JSON.stringify({ 289 | "deno-slack-sdk": { 290 | title: "Deno Slack SDK", 291 | releases: [ 292 | { 293 | version: "1.3.0", 294 | release_date: "2022-10-17", 295 | }, 296 | { 297 | version: "1.2.7", 298 | release_date: "2022-10-10", 299 | }, 300 | ], 301 | }, 302 | }); 303 | 304 | await evT.step( 305 | "should throw if module is not found", 306 | async () => { 307 | using _fetchStub = stubMetadataJsonFetch( 308 | new Response(mockMetadataJSON), 309 | ); 310 | await assertRejects(async () => { 311 | return await fetchLatestModuleVersion("non-existent-module"); 312 | }); 313 | }, 314 | ); 315 | await evT.step( 316 | "should return latest module version from metadata.json", 317 | async () => { 318 | using _fetchStub = stubMetadataJsonFetch( 319 | new Response(mockMetadataJSON), 320 | ); 321 | const version = await fetchLatestModuleVersion("deno-slack-sdk"); 322 | assertEquals(version, "1.3.0", "incorrect version returned"); 323 | }, 324 | ); 325 | }); 326 | 327 | // createUpdateResp 328 | await t.step("createUpdateResp method", async (evT) => { 329 | await evT.step( 330 | "response should include errors if there are inaccessible files found", 331 | () => { 332 | const error = new Error("test", { 333 | cause: new Deno.errors.PermissionDenied(), 334 | }); 335 | const versionMap = {}; 336 | const inaccessibleFiles = [{ name: "import_map.json", error }]; 337 | const updateResp = createUpdateResp(versionMap, inaccessibleFiles); 338 | 339 | assertEquals( 340 | updateResp.error && 341 | updateResp.error.message.includes("import_map.json"), 342 | true, 343 | ); 344 | }, 345 | ); 346 | 347 | await evT.step( 348 | "response should not include errors if they are of type NotFound", 349 | () => { 350 | const error = new Error("test", { 351 | cause: new Deno.errors.NotFound(), 352 | }); 353 | const versionMap = {}; 354 | const inaccessibleFiles = [{ name: "import_map.json", error }]; 355 | const updateResp = createUpdateResp(versionMap, inaccessibleFiles); 356 | 357 | assertEquals(!updateResp.error, true); 358 | }, 359 | ); 360 | }); 361 | 362 | // createFileErrorMsg 363 | await t.step("createFileErrorMsg method", async (evT) => { 364 | await evT.step( 365 | "message should not include errors if they're instance of NotFound", 366 | () => { 367 | const error = new Error("test", { 368 | cause: new Deno.errors.NotFound(), 369 | }); 370 | const inaccessibleFiles = [{ name: "import_map.json", error }]; 371 | const errorMsg = createFileErrorMsg(inaccessibleFiles); 372 | assertEquals(errorMsg, ""); 373 | }, 374 | ); 375 | 376 | await evT.step( 377 | "message should include errors if they're not instances of NotFound", 378 | () => { 379 | const notFoundError = new Error("test", { 380 | cause: new Deno.errors.NotFound(), 381 | }); 382 | const permissionError = new Error("test", { 383 | cause: new Deno.errors.PermissionDenied(), 384 | }); 385 | const inaccessibleFiles = [ 386 | { name: "import_map.json", error: notFoundError }, 387 | { name: "slack.json", error: permissionError }, 388 | ]; 389 | const errorMsg = createFileErrorMsg(inaccessibleFiles); 390 | assertEquals(true, !errorMsg.includes("import_map.json")); 391 | assertEquals(true, errorMsg.includes("slack.json")); 392 | }, 393 | ); 394 | }); 395 | }); 396 | -------------------------------------------------------------------------------- /src/tests/build_test.ts: -------------------------------------------------------------------------------- 1 | import { validateAndCreateFunctions } from "../build.ts"; 2 | import { Deno2Bundler, DenoBundler, EsbuildBundler } from "../bundler/mods.ts"; 3 | import { 4 | assertExists, 5 | assertRejects, 6 | assertSpyCalls, 7 | spy, 8 | stub, 9 | } from "../dev_deps.ts"; 10 | import { MockProtocol } from "../dev_deps.ts"; 11 | import { BundleError } from "../errors.ts"; 12 | 13 | Deno.test("build hook tests", async (t) => { 14 | await t.step("validateAndCreateFunctions", async (tt) => { 15 | await tt.step("should exist", () => { 16 | assertExists(validateAndCreateFunctions); 17 | }); 18 | 19 | const validManifest = { 20 | "functions": { 21 | "test_function_one": { 22 | "title": "Test function 1", 23 | "description": "this is a test", 24 | "source_file": "src/tests/fixtures/functions/test_function_file.ts", 25 | "input_parameters": { 26 | "required": [], 27 | "properties": {}, 28 | }, 29 | "output_parameters": { 30 | "required": [], 31 | "properties": {}, 32 | }, 33 | }, 34 | "test_function_two": { 35 | "title": "Test function 2", 36 | "description": "this is a test", 37 | "source_file": "src/tests/fixtures/functions/test_function_file.ts", 38 | "input_parameters": { 39 | "required": [], 40 | "properties": {}, 41 | }, 42 | "output_parameters": { 43 | "required": [], 44 | "properties": {}, 45 | }, 46 | }, 47 | "api_function_that_should_not_be_built": { 48 | "type": "API", 49 | "title": "API function", 50 | "description": "should most definitely not be bundled", 51 | "source_file": "src/tests/fixtures/functions/this_shouldnt_matter.ts", 52 | "input_parameters": { 53 | "required": [], 54 | "properties": {}, 55 | }, 56 | "output_parameters": { 57 | "required": [], 58 | "properties": {}, 59 | }, 60 | }, 61 | }, 62 | }; 63 | 64 | await tt.step( 65 | "should invoke `deno bundle` once per non-API function", 66 | async () => { 67 | const protocol = MockProtocol(); 68 | const outputDir = await Deno.makeTempDir(); 69 | 70 | const commandResp = { 71 | output: () => Promise.resolve({ code: 0 }), 72 | } as Deno.Command; 73 | 74 | // Stub out call to `Deno.Command` and fake return a success 75 | const denoBundlerCommandStub = stub( 76 | Deno, 77 | "Command", 78 | () => commandResp, 79 | ); 80 | 81 | const deno2BundleCommandStub = spy( 82 | Deno2Bundler, 83 | "bundle", 84 | ); 85 | 86 | const esbuildBundlerSpy = spy( 87 | EsbuildBundler, 88 | "bundle", 89 | ); 90 | 91 | try { 92 | await validateAndCreateFunctions( 93 | Deno.cwd(), 94 | outputDir, 95 | validManifest, 96 | protocol, 97 | ); 98 | assertSpyCalls(denoBundlerCommandStub, 2); 99 | assertSpyCalls(deno2BundleCommandStub, 0); 100 | assertSpyCalls(esbuildBundlerSpy, 0); 101 | } finally { 102 | denoBundlerCommandStub.restore(); 103 | deno2BundleCommandStub.restore(); 104 | esbuildBundlerSpy.restore(); 105 | } 106 | }, 107 | ); 108 | 109 | await tt.step( 110 | "should invoke `deno2 bundle` once per non-API function if bundle fails", 111 | async () => { 112 | const protocol = MockProtocol(); 113 | const outputDir = await Deno.makeTempDir(); 114 | 115 | const commandResp = { 116 | output: () => Promise.resolve({ code: 0 }), 117 | } as Deno.Command; 118 | 119 | const denoBundlerCommandStub = stub( 120 | DenoBundler, 121 | "bundle", 122 | () => { 123 | throw new BundleError(); 124 | }, 125 | ); 126 | 127 | // Stub out call to `Deno.Command` and fake return a success 128 | const deno2BundleCommandStub = stub( 129 | Deno, 130 | "Command", 131 | () => commandResp, 132 | ); 133 | 134 | const esbuildBundlerSpy = spy( 135 | EsbuildBundler, 136 | "bundle", 137 | ); 138 | 139 | try { 140 | await validateAndCreateFunctions( 141 | Deno.cwd(), 142 | outputDir, 143 | validManifest, 144 | protocol, 145 | ); 146 | assertSpyCalls(denoBundlerCommandStub, 2); 147 | assertSpyCalls(deno2BundleCommandStub, 2); 148 | assertSpyCalls(esbuildBundlerSpy, 0); 149 | } finally { 150 | denoBundlerCommandStub.restore(); 151 | deno2BundleCommandStub.restore(); 152 | esbuildBundlerSpy.restore(); 153 | } 154 | }, 155 | ); 156 | 157 | await tt.step( 158 | "should invoke `esbuild` once per non-API function if deno1 and deno2 bundle fails", 159 | async () => { 160 | const protocol = MockProtocol(); 161 | 162 | const outputDir = await Deno.makeTempDir(); 163 | 164 | const denoBundleCommandStub = stub( 165 | DenoBundler, 166 | "bundle", 167 | () => { 168 | throw new BundleError(); 169 | }, 170 | ); 171 | 172 | const deno2BundleCommandStub = stub( 173 | Deno2Bundler, 174 | "bundle", 175 | () => { 176 | throw new BundleError(); 177 | }, 178 | ); 179 | 180 | // Stub out call to `Deno.writeFile` and fake response 181 | const writeFileStub = stub( 182 | Deno, 183 | "writeFile", 184 | async () => {}, 185 | ); 186 | 187 | try { 188 | await validateAndCreateFunctions( 189 | Deno.cwd(), 190 | outputDir, 191 | validManifest, 192 | protocol, 193 | ); 194 | assertSpyCalls(denoBundleCommandStub, 2); 195 | assertSpyCalls(deno2BundleCommandStub, 2); 196 | assertSpyCalls(writeFileStub, 2); 197 | } finally { 198 | denoBundleCommandStub.restore(); 199 | deno2BundleCommandStub.restore(); 200 | writeFileStub.restore(); 201 | } 202 | }, 203 | ); 204 | 205 | await tt.step( 206 | "should throw an exception if `DenoBundler.bundle` fails unexpectedly", 207 | async () => { 208 | const protocol = MockProtocol(); 209 | const outputDir = await Deno.makeTempDir(); 210 | 211 | // Stub out call to `Deno.Command` and fake throw error 212 | const denoBundleCommandStub = stub( 213 | DenoBundler, 214 | "bundle", 215 | () => { 216 | throw new Error("something was unexpected"); 217 | }, 218 | ); 219 | 220 | // Stub out call to `Deno.Command` and fake throw error 221 | const deno2BundleCommandStub = spy( 222 | Deno2Bundler, 223 | "bundle", 224 | ); 225 | 226 | const esbuildBundlerSpy = spy( 227 | EsbuildBundler, 228 | "bundle", 229 | ); 230 | 231 | try { 232 | await assertRejects( 233 | () => 234 | validateAndCreateFunctions( 235 | Deno.cwd(), 236 | outputDir, 237 | validManifest, 238 | protocol, 239 | ), 240 | Error, 241 | "something was unexpected", 242 | ); 243 | assertSpyCalls(denoBundleCommandStub, 1); 244 | assertSpyCalls(deno2BundleCommandStub, 0); 245 | assertSpyCalls(esbuildBundlerSpy, 0); 246 | } finally { 247 | denoBundleCommandStub.restore(); 248 | deno2BundleCommandStub.restore(); 249 | esbuildBundlerSpy.restore(); 250 | } 251 | }, 252 | ); 253 | 254 | await tt.step( 255 | "should throw an exception if `Deno2Bundler.bundle` fails unexpectedly", 256 | async () => { 257 | const protocol = MockProtocol(); 258 | const outputDir = await Deno.makeTempDir(); 259 | 260 | // Stub out call to `Deno.Command` and fake throw error 261 | const denoBundleCommandStub = stub( 262 | DenoBundler, 263 | "bundle", 264 | () => { 265 | throw new BundleError(); 266 | }, 267 | ); 268 | 269 | // Stub out call to `Deno.Command` and fake throw error 270 | const deno2BundleCommandStub = stub( 271 | Deno2Bundler, 272 | "bundle", 273 | () => { 274 | throw new Error("something was unexpected"); 275 | }, 276 | ); 277 | 278 | const esbuildBundlerSpy = spy( 279 | EsbuildBundler, 280 | "bundle", 281 | ); 282 | 283 | try { 284 | await assertRejects( 285 | () => 286 | validateAndCreateFunctions( 287 | Deno.cwd(), 288 | outputDir, 289 | validManifest, 290 | protocol, 291 | ), 292 | Error, 293 | "something was unexpected", 294 | ); 295 | assertSpyCalls(denoBundleCommandStub, 1); 296 | assertSpyCalls(deno2BundleCommandStub, 1); 297 | assertSpyCalls(esbuildBundlerSpy, 0); 298 | } finally { 299 | denoBundleCommandStub.restore(); 300 | deno2BundleCommandStub.restore(); 301 | esbuildBundlerSpy.restore(); 302 | } 303 | }, 304 | ); 305 | 306 | await tt.step( 307 | "should throw an exception if a function file does not have a default export", 308 | async () => { 309 | const protocol = MockProtocol(); 310 | const outputDir = await Deno.makeTempDir(); 311 | const manifest = { 312 | "functions": { 313 | "test_function": { 314 | "title": "Test function", 315 | "description": "this is a test", 316 | "source_file": 317 | "src/tests/fixtures/functions/test_function_no_export_file.ts", 318 | "input_parameters": { 319 | "required": [], 320 | "properties": {}, 321 | }, 322 | "output_parameters": { 323 | "required": [], 324 | "properties": {}, 325 | }, 326 | }, 327 | }, 328 | }; 329 | await assertRejects( 330 | () => 331 | validateAndCreateFunctions( 332 | Deno.cwd(), 333 | outputDir, 334 | manifest, 335 | protocol, 336 | ), 337 | Error, 338 | "no default export", 339 | ); 340 | }, 341 | ); 342 | 343 | await tt.step( 344 | "should throw an exception if a function file has a non-function default export", 345 | async () => { 346 | const manifest = { 347 | "functions": { 348 | "test_function": { 349 | "title": "Test function", 350 | "description": "this is a test", 351 | "source_file": 352 | "src/tests/fixtures/functions/test_function_not_function_file.ts", 353 | "input_parameters": { 354 | "required": [], 355 | "properties": {}, 356 | }, 357 | "output_parameters": { 358 | "required": [], 359 | "properties": {}, 360 | }, 361 | }, 362 | }, 363 | }; 364 | const protocol = MockProtocol(); 365 | const outputDir = await Deno.makeTempDir(); 366 | await assertRejects( 367 | () => 368 | validateAndCreateFunctions( 369 | Deno.cwd(), 370 | outputDir, 371 | manifest, 372 | protocol, 373 | ), 374 | Error, 375 | "default export is not a function", 376 | ); 377 | }, 378 | ); 379 | 380 | await tt.step( 381 | "should throw an exception when manifest entry for a function points to a non-existent file", 382 | async () => { 383 | const manifest = { 384 | "functions": { 385 | "test_function": { 386 | "title": "Test function", 387 | "description": "this is a test", 388 | "source_file": 389 | "src/tests/fixtures/functions/test_function_this_file_does_not_exist.ts", 390 | "input_parameters": { 391 | "required": [], 392 | "properties": {}, 393 | }, 394 | "output_parameters": { 395 | "required": [], 396 | "properties": {}, 397 | }, 398 | }, 399 | }, 400 | }; 401 | const protocol = MockProtocol(); 402 | const outputDir = await Deno.makeTempDir(); 403 | await assertRejects( 404 | () => 405 | validateAndCreateFunctions( 406 | Deno.cwd(), 407 | outputDir, 408 | manifest, 409 | protocol, 410 | ), 411 | Error, 412 | "Could not find file", 413 | ); 414 | }, 415 | ); 416 | 417 | await tt.step( 418 | "should throw an exception when manifest entry for a function does not have a source_file defined", 419 | async () => { 420 | const manifest = { 421 | "functions": { 422 | "test_function": { 423 | "title": "Test function", 424 | "description": "this is a test", 425 | "input_parameters": { 426 | "required": [], 427 | "properties": {}, 428 | }, 429 | "output_parameters": { 430 | "required": [], 431 | "properties": {}, 432 | }, 433 | }, 434 | }, 435 | }; 436 | const protocol = MockProtocol(); 437 | const outputDir = await Deno.makeTempDir(); 438 | await assertRejects( 439 | () => 440 | validateAndCreateFunctions( 441 | Deno.cwd(), 442 | outputDir, 443 | manifest, 444 | protocol, 445 | ), 446 | Error, 447 | "No source_file property provided", 448 | ); 449 | }, 450 | ); 451 | 452 | await tt.step("should ignore functions of type 'API'", async () => { 453 | const manifest = { 454 | "functions": { 455 | "test_function": { 456 | "title": "Test function", 457 | "description": "this is a test", 458 | "source_file": 459 | "src/tests/fixtures/functions/test_function_not_function_file.ts", 460 | "type": "API", 461 | "input_parameters": { 462 | "required": [], 463 | "properties": {}, 464 | }, 465 | "output_parameters": { 466 | "required": [], 467 | "properties": {}, 468 | }, 469 | }, 470 | }, 471 | }; 472 | const protocol = MockProtocol(); 473 | const outputDir = await Deno.makeTempDir(); 474 | await validateAndCreateFunctions( 475 | Deno.cwd(), 476 | outputDir, 477 | manifest, 478 | protocol, 479 | ); 480 | 481 | // Spy on `Deno.Command` and `writeFile` 482 | const commandStub = spy( 483 | Deno, 484 | "Command", 485 | ); 486 | const writeFileStub = spy( 487 | Deno, 488 | "writeFile", 489 | ); 490 | 491 | try { 492 | await validateAndCreateFunctions( 493 | Deno.cwd(), 494 | outputDir, 495 | manifest, 496 | protocol, 497 | ); 498 | assertSpyCalls(commandStub, 0); 499 | assertSpyCalls(writeFileStub, 0); 500 | } finally { 501 | commandStub.restore(); 502 | writeFileStub.restore(); 503 | } 504 | }); 505 | }); 506 | }); 507 | --------------------------------------------------------------------------------