diff --git a/.github/workflows/check-bun-dependencies.yml b/.github/workflows/check-bun-dependencies.yml new file mode 100644 index 0000000..f87e507 --- /dev/null +++ b/.github/workflows/check-bun-dependencies.yml @@ -0,0 +1,12 @@ +name: check-bun-dependencies +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" +permissions: + contents: read + pull-requests: write +jobs: + call-check-bun-dependencies: + uses: flowscripter/.github/.github/workflows/check-bun-dependencies.yml@v1 + secrets: inherit diff --git a/.github/workflows/lint-pr-message.yml b/.github/workflows/lint-pr-message.yml new file mode 100644 index 0000000..1f8f1c6 --- /dev/null +++ b/.github/workflows/lint-pr-message.yml @@ -0,0 +1,13 @@ +name: lint-pr-message +on: + pull_request_target: + types: + - opened + - edited + - synchronize +permissions: + contents: read +jobs: + call-lint-pr-message: + uses: flowscripter/.github/.github/workflows/lint-pr-message.yml@v1 + secrets: inherit diff --git a/.github/workflows/release-bun-executable.yml b/.github/workflows/release-bun-executable.yml new file mode 100644 index 0000000..be8697f --- /dev/null +++ b/.github/workflows/release-bun-executable.yml @@ -0,0 +1,16 @@ +name: release-bun-executable +on: + push: + branches: [main] +permissions: + contents: write + issues: write + pull-requests: write + id-token: write + pages: write +jobs: + call-release-bun-executable: + uses: flowscripter/.github/.github/workflows/release-bun-executable.yml@v1 + secrets: inherit + with: + executable-name: "example-host-application" diff --git a/.github/workflows/validate-bun-executable-pr.yml b/.github/workflows/validate-bun-executable-pr.yml new file mode 100644 index 0000000..a0fe86a --- /dev/null +++ b/.github/workflows/validate-bun-executable-pr.yml @@ -0,0 +1,10 @@ +name: validate-bun-executable-pr +on: + pull_request: + branches: [main] +permissions: + contents: read +jobs: + call-validate-bun-executable-pr: + uses: flowscripter/.github/.github/workflows/validate-bun-executable-pr.yml@v1 + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ca95c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,181 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store +docs/ +template-bun-executable +/.releaserc +functional_tests/features/support/__pycache__ +example-host-application +functional_tests/.venv \ No newline at end of file diff --git a/README.md b/README.md index 14051c7..d469858 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,94 @@ # example-host-application -Example host application executable for the https://github.com/flowscripter/dynamic-plugin-framework + +[![version](https://img.shields.io/github/v/release/flowscripter/example-host-application?sort=semver)](https://github.com/flowscripter/example-host-application/releases) +[![build](https://img.shields.io/github/actions/workflow/status/flowscripter/example-host-application/release-bun-executable.yml)](https://github.com/flowscripter/example-host-application/actions/workflows/release-bun-executable.yml) +[![coverage](https://codecov.io/gh/flowscripter/example-host-application/branch/main/graph/badge.svg?token=EMFT2938ZF)](https://codecov.io/gh/flowscripter/example-host-application) +[![docs](https://img.shields.io/badge/docs-API-blue)](https://flowscripter.github.io/example-host-application/index.html) +[![license: MIT](https://img.shields.io/github/license/flowscripter/example-host-application)](https://github.com/flowscripter/example-host-application/blob/main/LICENSE) + +> Example host application executable for the +> [dynamic-plugin-framework](https://github.com/flowscripter/dynamic-plugin-framework) + +## Binary Executable Usage + +**NOTE**: The binaries are 10's of megabytes in size as the entire Bun runtime +is included. + +#### MacOS + +Via [Homebrew](https://brew.sh/): + +`brew install flowscripter/tap/example-host-application` + +#### Linux + +In a terminal: + +`curl -fsSL https://raw.githubusercontent.com/flowscripter/example-host-application/main/script/install.sh | sh` + +#### Windows + +Via [Winget](https://github.com/microsoft/winget-cli): + +`winget install Flowscripter.example-host-application` + +#### Manual Install + +You can download and extract the binary zip files from the +[releases](https://github.com/flowscripter/example-host-application/releases) +page. + +## Functional Tests + +Refer to [functional_tests/README.md](functional_tests/README.md) + +## Development + +Install dependencies: + +`bun install` + +Test: + +`bun test` + +Run: + +`bun run index.ts` + +Compile binary: + +`bun build index.ts --compile --outfile /tmp/example-host-application` + +**NOTE**: The following tasks use Deno as it excels at these and Bun does not +currently provide such functionality: + +Format: + +`deno fmt` + +Lint: + +`deno lint index.ts src/ tests/` + +Generate HTML API Documentation: + +`deno doc --html --name=example-host-application index.ts` + +## Documentation + +### Framework API + +Refer to the +[dynamic-plugin-framework](https://github.com/flowscripter/dynamic-plugin-framework) +for an overview of what this example is demonstrating. + +### API + +Link to auto-generated API docs: + +[API Documentation](https://flowscripter.github.io/example-host-application/index.html) + +## License + +MIT © Flowscripter diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0a61f07 --- /dev/null +++ b/bun.lock @@ -0,0 +1,33 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@flowscripter/example-host-application", + "dependencies": { + "@flowscripter/dynamic-plugin-framework": "^1.3.18", + "@flowscripter/example-plugin-api": "^1.0.8", + }, + "devDependencies": { + "@types/bun": "^1.2.10", + }, + "peerDependencies": { + "typescript": "^5.8.2", + }, + }, + }, + "packages": { + "@flowscripter/dynamic-plugin-framework": ["@flowscripter/dynamic-plugin-framework@1.3.18", "", { "peerDependencies": { "typescript": "^5.8.3" } }, "sha512-OjH8O4MWXU/Wt1so49ef6OnnHlJkHuKKya8zVLR3nkxXhHbR3jHf2VmWl+mOR6liRyN9YN5IyeCvKonnAG6jTQ=="], + + "@flowscripter/example-plugin-api": ["@flowscripter/example-plugin-api@1.0.8", "", { "peerDependencies": { "typescript": "^5.8.2" } }, "sha512-hRF0DRg+8aKmnAwLDQyA7phjRbET0yD9bBYdny2csL2N5JiV/KLFdfwtJSSSL/LlMdkwtl0VKAVroVc78eWAsw=="], + + "@types/bun": ["@types/bun@1.2.10", "", { "dependencies": { "bun-types": "1.2.10" } }, "sha512-eilv6WFM3M0c9ztJt7/g80BDusK98z/FrFwseZgT4bXCq2vPhXD4z8R3oddmAn+R/Nmz9vBn4kweJKmGTZj+lg=="], + + "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], + + "bun-types": ["bun-types@1.2.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-b5ITZMnVdf3m1gMvJHG+gIfeJHiQPJak0f7925Hxu6ZN5VKA8AGy4GZ4lM+Xkn6jtWxg5S3ldWvfmXdvnkp3GQ=="], + + "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + } +} diff --git a/functional_tests/README.md b/functional_tests/README.md new file mode 100644 index 0000000..437544c --- /dev/null +++ b/functional_tests/README.md @@ -0,0 +1,23 @@ +## Executable Functional Tests + +#### Setup + +Ensure the executable is built: + + bun build ../index.ts --compile --outfile /tmp/example-host-application + +Install requirements: + + pip3 install -r pip-requirements.txt + +#### Testing + +Run the functional tests: + + export EXECUTABLE=/tmp/example-host-application + behave + +To run with logging output from the test steps (this is the best set of +arguments I can find): + + behave --no-logcapture --no-color --logging-level=DEBUG diff --git a/functional_tests/features/environment.py b/functional_tests/features/environment.py new file mode 100644 index 0000000..9639296 --- /dev/null +++ b/functional_tests/features/environment.py @@ -0,0 +1,9 @@ +import os + +from support.pexpect_wrapper import PExpectWrapper + + +def before_scenario(context, scenario): + + context.config.setup_logging() + context.pexpect_wrapper = PExpectWrapper(os.environ.get('EXECUTABLE')) diff --git a/functional_tests/features/executable.feature b/functional_tests/features/executable.feature new file mode 100644 index 0000000..a287ec8 --- /dev/null +++ b/functional_tests/features/executable.feature @@ -0,0 +1,7 @@ +Feature: Executable + + Scenario: Executable success + When the executable is launched + Then the executable should complete successfully + And the executable should have output "Invoking extension" + And the executable should have output "hello world" diff --git a/functional_tests/features/steps/executable.py b/functional_tests/features/steps/executable.py new file mode 100644 index 0000000..ae01469 --- /dev/null +++ b/functional_tests/features/steps/executable.py @@ -0,0 +1,18 @@ +from behave import when, then + + +@when('the executable is launched') +def step_impl(context): + context.pexpect_wrapper.start() + + +@then('the executable should complete successfully') +def step_impl(context): + context.pexpect_wrapper.expect_eof() + status = context.pexpect_wrapper.complete() + assert status == 0, 'unexpected exit status: {}'.format(status) + + +@then('the executable should have output "{message}"') +def step_impl(context, message): + context.pexpect_wrapper.expect(message) diff --git a/functional_tests/features/support/pexpect_wrapper.py b/functional_tests/features/support/pexpect_wrapper.py new file mode 100644 index 0000000..fd75ca2 --- /dev/null +++ b/functional_tests/features/support/pexpect_wrapper.py @@ -0,0 +1,45 @@ +from pexpect.popen_spawn import PopenSpawn +from pexpect import EOF +import logging +log = logging.getLogger("pexpect_wrapper") + + +class PExpectWrapper: + + def __init__(self, executable): + self.executable = executable + self.child = None + self.output = None + + def start(self): + assert self.child is None + + self.child = PopenSpawn(self.executable, encoding='utf-8') + + def expect(self, message): + assert self.child is not None + + found = '' + while len(self.output) > 0: + next_line = self.output.pop(0) + log.debug('looking for "{}" in "{}"'.format(message, next_line)) + if message in next_line: + found = next_line + break + + assert found != '', 'expected {} in output'.format(message) + + + def expect_eof(self): + assert self.child is not None + + self.child.expect(EOF) + + def complete(self): + assert self.child is not None + + exit_status = self.child.wait() + + self.output = self.child.before.split('\n') + + return exit_status diff --git a/functional_tests/pip-requirements.txt b/functional_tests/pip-requirements.txt new file mode 100644 index 0000000..ce62611 --- /dev/null +++ b/functional_tests/pip-requirements.txt @@ -0,0 +1,2 @@ +pexpect +behave diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..4d9195e --- /dev/null +++ b/index.ts @@ -0,0 +1,3 @@ +import { exampleHostApplication } from "./src/ExampleHostApplication.ts"; + +await exampleHostApplication(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..a2b0249 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@flowscripter/example-host-application", + "description": "Example host application for the https://github.com/flowscripter/dynamic-plugin-framework", + "repository": "@flowscripter/example-host-application", + "license": "MIT", + "keywords": [ + "bun", + "example", + "plugin", + "framework", + "dynamic", + "import", + "executable", + "cli" + ], + "module": "index.ts", + "type": "module", + "version": "0.0.0", + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/bun": "^1.2.10" + }, + "peerDependencies": { + "typescript": "^5.8.2" + }, + "dependencies": { + "@flowscripter/dynamic-plugin-framework": "^1.3.18", + "@flowscripter/example-plugin-api": "^1.0.8" + } +} diff --git a/script/install.sh b/script/install.sh new file mode 100755 index 0000000..4a8ab51 --- /dev/null +++ b/script/install.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +set -e # Exit on error + +# Define the download URL +URL="https://github.com/flowscripter/example-host-application/releases/latest/download/example-host-application_Linux_x86_64.zip" + +# Create a temporary directory +TMP_DIR=$(mktemp -d) +cd "$TMP_DIR" + +# Download and extract +echo "Downloading example-host-application..." +curl -fsSL "$URL" -o executable.zip +unzip executable.zip + +# Install +chmod +x example-host-application +sudo mv example-host-application /usr/local/bin/ + +# Clean up +cd - +rm -rf "$TMP_DIR" + +echo "✅ Installation complete! Run 'example-host-application' to get started." diff --git a/src/ExampleHostApplication.ts b/src/ExampleHostApplication.ts new file mode 100644 index 0000000..8a95194 --- /dev/null +++ b/src/ExampleHostApplication.ts @@ -0,0 +1,75 @@ +import { + EXTENSION_POINT_1, + type ExtensionPoint1, +} from "@flowscripter/example-plugin-api"; +import { + DefaultPluginManager, + type ExtensionInfo, + UrlListPluginRepository, +} from "@flowscripter/dynamic-plugin-framework"; + +/** + * Searches for an extension, instantiates it and invokes it. + */ +export async function exampleHostApplication(): Promise { + const pluginRepository = new UrlListPluginRepository( + new Set([ + { + url: "https://unpkg.com/@flowscripter/example-plugin/dist/bundle.js", + extensionPoints: [EXTENSION_POINT_1], + }, + ]), + ); + + const pluginManager = new DefaultPluginManager([pluginRepository]); + + console.info( + `Registering extensions for ${EXTENSION_POINT_1} extension point`, + ); + + await pluginManager.registerExtensions(EXTENSION_POINT_1); + + console.info("Registered extensions:"); + + const extensionInfos = await pluginManager.getRegisteredExtensions( + EXTENSION_POINT_1, + ); + + extensionInfos.forEach((extensionInfo: ExtensionInfo) => { + let extensionInfoString = + `extensionHandle: ${extensionInfo.extensionHandle}\n`; + + if (extensionInfo.extensionData) { + extensionInfoString += `extensionData:\n`; + for (const entry of extensionInfo.extensionData.entries()) { + extensionInfoString += `\t${entry[0]} => ${entry[1]}\n`; + } + } + if (extensionInfo.pluginData) { + extensionInfoString += `pluginData:\n`; + for (const entry of extensionInfo.pluginData.entries()) { + extensionInfoString += `\t${entry[0]} => ${entry[1]}\n`; + } + } + + console.info(extensionInfoString); + }); + + if (extensionInfos.length > 0) { + console.info("Instantiating first extension"); + + const extension = await pluginManager.instantiate( + extensionInfos[0].extensionHandle, + new Map([[ + "host_foo", + "host_bar", + ]]), + ) as ExtensionPoint1; + + console.info("Invoking extension"); + + extension.sayHello(); + } else { + throw new Error("No extensions found"); + } +} diff --git a/tests/hello_test.ts b/tests/hello_test.ts new file mode 100644 index 0000000..ec4214b --- /dev/null +++ b/tests/hello_test.ts @@ -0,0 +1,8 @@ +import { describe, test } from "bun:test"; +import { exampleHostApplication } from "../src/ExampleHostApplication.ts"; + +describe("ExampleHostApplication Tests", () => { + test("Invoke example host application", async () => { + await exampleHostApplication(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}