diff --git a/.github/workflows/trigger-reusable-lib-qvac-cli.yml b/.github/workflows/trigger-reusable-lib-qvac-cli.yml new file mode 100644 index 0000000000..bbb21cf541 --- /dev/null +++ b/.github/workflows/trigger-reusable-lib-qvac-cli.yml @@ -0,0 +1,164 @@ +name: General CI/CD (Qvac-cli) +# This workflow is used to call the reusable workflows in the qvac-devops repository +# https://github.com/tetherto/qvac-devops/blob/production-workflows-tag/.github/workflows/public-reusable.yml + +permissions: + contents: write + packages: write + pull-requests: write + id-token: write + +on: + push: + branches: + - main + - release-* + - feature-* + - tmp-* + paths: + - "packages/qvac-cli/**" + pull_request: + branches: + - release-* + paths: + - "packages/qvac-cli/**" + workflow_dispatch: + inputs: + custom_input_1: + required: false + type: string + custom_input_2: + required: false + type: string + custom_input_3: + required: false + type: string + +jobs: + release-pr-guard: + name: Release PR Guard + if: github.event_name == 'pull_request' && startsWith(github.base_ref, 'release-') + uses: tetherto/qvac-devops/.github/workflows/release-pr-guard.yml@monorepo_update + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + with: + package-slug: qvac-cli + package-json-path: packages/qvac-cli/package.json + changelog-path: packages/qvac-cli/CHANGELOG.md + + publish-logic: + runs-on: ubuntu-latest + outputs: + publish_main: ${{ steps.logic.outputs.publish_main }} + publish_release: ${{ steps.logic.outputs.publish_release }} + publish_feature: ${{ steps.logic.outputs.publish_feature }} + publish_tmp: ${{ steps.logic.outputs.publish_tmp }} + gpr_tag: ${{ steps.logic.outputs.gpr_tag }} + steps: + - id: logic + shell: bash + run: | + set -euo pipefail + ref_name="${GITHUB_REF_NAME}" + event_name="${GITHUB_EVENT_NAME}" + + publish_main="false" + publish_release="false" + publish_feature="false" + publish_tmp="false" + + if [ "$event_name" = "push" ] || [ "$event_name" = "workflow_dispatch" ]; then + if [ "$ref_name" = "main" ]; then + publish_main="true" + elif [[ "$ref_name" == release-* ]]; then + publish_release="true" + elif [[ "$ref_name" == feature-* ]]; then + publish_feature="true" + elif [[ "$ref_name" == tmp-* ]]; then + publish_tmp="true" + fi + fi + + gpr_tag="dev" + if [ "$ref_name" = "main" ]; then + gpr_tag="dev" + elif [[ "$ref_name" == feature-* ]] || [[ "$ref_name" == tmp-* ]]; then + gpr_tag="$ref_name" + fi + + echo "publish_main=$publish_main" >> "$GITHUB_OUTPUT" + echo "publish_release=$publish_release" >> "$GITHUB_OUTPUT" + echo "publish_feature=$publish_feature" >> "$GITHUB_OUTPUT" + echo "publish_tmp=$publish_tmp" >> "$GITHUB_OUTPUT" + echo "gpr_tag=$gpr_tag" >> "$GITHUB_OUTPUT" + + publish-main-gpr-dev: + needs: publish-logic + if: needs.publish-logic.outputs.publish_main == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Publish to GitHub Packages (dev) + uses: tetherto/qvac-devops/.github/actions/publish-library-to-gpr@monorepo_update + with: + secret-token: ${{ secrets.GITHUB_TOKEN }} + pat-token: ${{ secrets.PAT_TOKEN }} + npm-token: ${{ secrets.NPM_TOKEN }} + tag: ${{ needs.publish-logic.outputs.gpr_tag }} + workdir: packages/qvac-cli + name-suffix: "-mono" + + publish-release-npm: + needs: publish-logic + if: needs.publish-logic.outputs.publish_release == 'true' + uses: tetherto/qvac-devops/.github/workflows/public-reusable-npm.yml@monorepo_update + secrets: inherit + with: + workdir: packages/qvac-cli + + publish-feature-gpr: + needs: publish-logic + if: needs.publish-logic.outputs.publish_feature == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Publish to GitHub Packages (feature) + uses: tetherto/qvac-devops/.github/actions/publish-library-to-gpr@monorepo_update + with: + secret-token: ${{ secrets.GITHUB_TOKEN }} + pat-token: ${{ secrets.PAT_TOKEN }} + npm-token: ${{ secrets.NPM_TOKEN }} + tag: ${{ needs.publish-logic.outputs.gpr_tag }} + workdir: packages/qvac-cli + name-suffix: "-mono" + + publish-tmp-gpr: + needs: publish-logic + if: needs.publish-logic.outputs.publish_tmp == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Publish to GitHub Packages (tmp) + uses: tetherto/qvac-devops/.github/actions/publish-library-to-gpr@monorepo_update + with: + secret-token: ${{ secrets.GITHUB_TOKEN }} + pat-token: ${{ secrets.PAT_TOKEN }} + npm-token: ${{ secrets.NPM_TOKEN }} + tag: ${{ needs.publish-logic.outputs.gpr_tag }} + workdir: packages/qvac-cli + name-suffix: "-mono" diff --git a/packages/qvac-cli/.gitignore b/packages/qvac-cli/.gitignore new file mode 100644 index 0000000000..f854f3fb3a --- /dev/null +++ b/packages/qvac-cli/.gitignore @@ -0,0 +1,3 @@ +node_modules +.DS_Store +package-lock.json diff --git a/packages/qvac-cli/CHANGELOG.md b/packages/qvac-cli/CHANGELOG.md new file mode 100644 index 0000000000..c62bbd6400 --- /dev/null +++ b/packages/qvac-cli/CHANGELOG.md @@ -0,0 +1,6 @@ +## Changelog + +### 0.1.0 + +- Initial release with `qvac bundle sdk`. + diff --git a/packages/qvac-cli/LICENSE b/packages/qvac-cli/LICENSE new file mode 100644 index 0000000000..e454a52586 --- /dev/null +++ b/packages/qvac-cli/LICENSE @@ -0,0 +1,178 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + diff --git a/packages/qvac-cli/PULL_REQUEST_TEMPLATE.md b/packages/qvac-cli/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..569cbc8d3a --- /dev/null +++ b/packages/qvac-cli/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ +# Description + +This PR is for... + +# Checklist + +- [ ] The **title matches the expected** format `TICKET prefix[tags]: subject` (_Example_: `QVAC-123 feat: add bundle sdk command`) +- [ ] I have **removed unused sub-sections** (e.g. Deprecated, Removed, etc.) +- [ ] **I have bumped the version**. Criteria: PATCH for bug fixing, MINOR for new backward compatible functionality, and **MAJOR for backward incompatible** +- [ ] The PR is **focused** on a single feature or fix +- [ ] The PR includes a **clear, structured description** +- [ ] I have added **screenshots, logs, or examples** when relevant +- [ ] I wrote **meaningful commit messages** (i.e. not 'feat: fix') +- [ ] I have **avoided unrelated changes** (e.g. formatting-only) +- [ ] Linting is applied before opening the PR +- [ ] Dependency updates are checked for security issues + +# Review Etiquette + +- **Be constructive and respectful** +- **Suggest improvements** clearly, offer alternatives if possible +- Use **formal reviews** (Approve/Request changes) avoid just adding comments +- All feedback must be **addressed** before merging +- Default **SLA for review is 24 hours** +- Wrap up with a **review summary when possible** + +# Related PRs + +Please link any related pull requests across repositories if applicable. diff --git a/packages/qvac-cli/README.md b/packages/qvac-cli/README.md new file mode 100644 index 0000000000..645002e07b --- /dev/null +++ b/packages/qvac-cli/README.md @@ -0,0 +1,154 @@ +# QVAC CLI + +A command-line interface for the QVAC ecosystem. QVAC CLI provides tooling for building, bundling, and managing QVAC-powered applications. + +## Table of Contents + +- [Installation](#installation) +- [Command Reference](#command-reference) + - [`bundle sdk`](#bundle-sdk) +- [Configuration](#configuration) +- [Development](#development) +- [License](#license) + +## Installation + +Install globally: + +```bash +npm i -g qvac +``` + +Or use directly via npx: + +```bash +npx qvac +``` + +## Command Reference + +### `bundle sdk` + +Generate a tree-shaken Bare worker bundle containing the plugins you select (defaults to all built-in plugins). + +```bash +qvac bundle sdk [options] +``` + +**What it does:** + +1. Reads `qvac.config.*` from your project root (if present) +2. Resolves enabled plugins from the `plugins` array (defaults to all built-in plugins if omitted) +3. Generates worker entry files with **static imports only** +4. Bundles with `bare-pack --linked` +5. Generates `addons.manifest.json` from the bundle graph + +**Options:** + +| Flag | Description | +|------|-------------| +| `--config, -c ` | Config file path (default: auto-detect `qvac.config.*`) | +| `--host ` | Target host (repeatable, default: all platforms) | +| `--defer ` | Defer a module (repeatable, for mobile targets) | +| `--quiet, -q` | Minimal output | +| `--verbose, -v` | Detailed output | + +**Examples:** + +```bash +# Bundle with default settings (all platforms) +qvac bundle sdk + +# Bundle for specific platforms only +qvac bundle sdk --host darwin-arm64 --host linux-x64 + +# Use a custom config file +qvac bundle sdk --config ./my-config.json + +# Verbose output for debugging +qvac bundle sdk --verbose +``` + +**Output:** + +| File | Description | +|------|-------------| +| `qvac/worker.entry.mjs` | Standalone/Electron worker with RPC + lifecycle | +| `qvac/worker.pear.entry.mjs` | Pear desktop apps (registers plugins → loads app worker) | +| `qvac/worker.bundle.js` | Final bundle for mobile/Pear runtimes | +| `qvac/addons.manifest.json` | Native addon allowlist for tree-shaking | + +> **Note:** Your project must have `@qvac/sdk` and `bare-pack` installed. Install `bare-pack` as a devDependency: `npm i -D bare-pack` or `bun add -d bare-pack` + +## Configuration + +The CLI reads configuration from `qvac.config.{json,js,mjs,ts}` in your project root. + +If no config file is found, the CLI bundles all built-in plugins. + +> **Note:** `qvac.config.ts` is supported via `tsx` internally (no user setup required). + +This file is primarily the SDK runtime config, but `qvac bundle sdk` also reads these **bundler-only** keys (ignored by the SDK at runtime): + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `plugins` | `string[]` | No | Module specifiers, each ending with `/plugin` (defaults to all built-in plugins) | +| `pearWorker` | `string` | No | Path to your app worker module (default: `worker.js`) | + +> **Custom plugin contract:** custom `*/plugin` modules must **default-export** the plugin object. + +**Built-in plugins:** + +``` +@qvac/sdk/llamacpp-completion/plugin +@qvac/sdk/llamacpp-embedding/plugin +@qvac/sdk/whispercpp-transcription/plugin +@qvac/sdk/nmtcpp-translation/plugin +@qvac/sdk/onnx-tts/plugin +@qvac/sdk/onnx-ocr/plugin +``` + +**Example configurations:** + +```json +// qvac.config.json - LLM only +{ + "plugins": [ + "@qvac/sdk/llamacpp-completion/plugin" + ] +} +``` + +```json +// qvac.config.json - Multiple plugins + custom Pear worker +{ + "plugins": [ + "@qvac/sdk/llamacpp-completion/plugin", + "@qvac/sdk/whispercpp-transcription/plugin", + "@qvac/sdk/nmtcpp-translation/plugin" + ], + "pearWorker": "src/worker.js" +} +``` + +## Development + +**Prerequisites:** + +- Node.js >= 18.0.0 +- npm or bun + +**Run locally:** + +```bash +# From the qvac-cli package directory +node ./src/index.js bundle sdk + +# Or link globally for testing +npm link +qvac bundle sdk +``` + +## License + +This project is licensed under the Apache-2.0 License - see the [LICENSE](LICENSE) file for details. diff --git a/packages/qvac-cli/package.json b/packages/qvac-cli/package.json new file mode 100644 index 0000000000..eb7891b64c --- /dev/null +++ b/packages/qvac-cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "qvac", + "version": "0.1.0", + "description": "Command-line interface for the QVAC ecosystem", + "author": "Tether", + "license": "Apache-2.0", + "type": "module", + "bin": { + "qvac": "./src/index.js" + }, + "files": [ + "src/**/*", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "tether", + "cli", + "qvac", + "bare", + "bundler" + ], + "bugs": "https://github.com/tetherto/qvac/issues", + "repository": { + "type": "git", + "url": "https://github.com/tetherto/qvac.git", + "directory": "packages/qvac-cli" + }, + "scripts": { + "lint": "standard", + "lint:fix": "standard --fix" + }, + "dependencies": { + "commander": "^14.0.3", + "tsx": "^4.21.0" + }, + "devDependencies": { + "standard": "^17.1.2" + } +} diff --git a/packages/qvac-cli/src/bundle-sdk/bare-pack.js b/packages/qvac-cli/src/bundle-sdk/bare-pack.js new file mode 100644 index 0000000000..bb2a644436 --- /dev/null +++ b/packages/qvac-cli/src/bundle-sdk/bare-pack.js @@ -0,0 +1,85 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { BarePackNotInstalledError, BarePackError } from '../errors.js' + +function resolveBarePackBin (projectRoot) { + const binName = process.platform === 'win32' ? 'bare-pack.cmd' : 'bare-pack' + return path.join(projectRoot, 'node_modules', '.bin', binName) +} + +async function detectBarePackMajorVersion (barePackBin, entryPath) { + return new Promise((resolve) => { + const proc = spawn(barePackBin, ['--version', entryPath], { + stdio: ['ignore', 'pipe', 'ignore'] + }) + + let output = '' + proc.stdout.on('data', (data) => { + output += data.toString() + }) + + proc.on('close', () => { + const match = output.match(/v?(\d+)\./) + const majorVersion = match?.[1] ? parseInt(match[1], 10) : 2 + resolve(majorVersion) + }) + + proc.on('error', () => resolve(2)) // Default to v2 on error + }) +} + +export async function runBarePack (options) { + const { + projectRoot, + entryPath, + outputPath, + hosts, + importsMapPath, + deferModules, + logLevel, + logger + } = options + + const barePackBin = resolveBarePackBin(projectRoot) + if (!fs.existsSync(barePackBin)) { + throw new BarePackNotInstalledError() + } + + const majorVersion = await detectBarePackMajorVersion(barePackBin, entryPath) + const platformFlag = majorVersion < 2 ? '--target' : '--host' + logger.verbose( + `šŸ“¦ Detected bare-pack v${majorVersion} (using ${platformFlag})` + ) + + return new Promise((resolve, reject) => { + const hostArgs = hosts.flatMap((h) => [platformFlag, h]) + const deferArgs = deferModules.flatMap((m) => ['--defer', m]) + const args = [ + ...hostArgs, + '--linked', + '--imports', + importsMapPath, + ...deferArgs, + '--out', + outputPath, + entryPath + ] + + logger.verbose(`\nšŸ“¦ Running: ${barePackBin} ${args.join(' ')}`) + + const proc = spawn(barePackBin, args, { + stdio: logLevel === 'quiet' ? 'ignore' : 'inherit' + }) + + proc.on('close', (code) => { + if (code === 0) { + resolve() + } else { + reject(new BarePackError(code ?? 1, entryPath, outputPath)) + } + }) + + proc.on('error', reject) + }) +} diff --git a/packages/qvac-cli/src/bundle-sdk/constants.js b/packages/qvac-cli/src/bundle-sdk/constants.js new file mode 100644 index 0000000000..2b880ee8a9 --- /dev/null +++ b/packages/qvac-cli/src/bundle-sdk/constants.js @@ -0,0 +1,29 @@ +/** + * Built-in plugin registry mapping suffix to export name. + * Specifier format: `${sdkName}/${suffix}/plugin` + */ +export const BUILTIN_PLUGINS = { + 'llamacpp-completion': { exportName: 'llmPlugin' }, + 'llamacpp-embedding': { exportName: 'embeddingsPlugin' }, + 'whispercpp-transcription': { exportName: 'whisperPlugin' }, + 'nmtcpp-translation': { exportName: 'nmtPlugin' }, + 'onnx-tts': { exportName: 'ttsPlugin' }, + 'onnx-ocr': { exportName: 'ocrPlugin' } +} + +export const BUILTIN_SUFFIXES = Object.keys(BUILTIN_PLUGINS) + +/** Supported bare-pack host targets */ +export const DEFAULT_HOSTS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-x64', + 'android-arm64', + 'ios-arm64', + 'ios-arm64-simulator', + 'ios-x64-simulator' +] + +export const DEFAULT_SDK_NAME = '@qvac/sdk' diff --git a/packages/qvac-cli/src/bundle-sdk/entry-gen.js b/packages/qvac-cli/src/bundle-sdk/entry-gen.js new file mode 100644 index 0000000000..31b27221a0 --- /dev/null +++ b/packages/qvac-cli/src/bundle-sdk/entry-gen.js @@ -0,0 +1,125 @@ +import path from 'node:path' +import { parseBuiltinSpecifier } from './plugins.js' + +/** + * Generates the worker entry file with selected plugins. + * Uses only static imports for tree-shaking. + * @param {string[]} pluginSpecifiers + * @param {string} sdkName + */ +export function generateWorkerEntry (pluginSpecifiers, sdkName) { + const imports = [] + const registrations = [] + let varIndex = 0 + + for (const specifier of pluginSpecifiers) { + const builtin = parseBuiltinSpecifier(specifier, sdkName) + if (builtin) { + imports.push( + `import { ${builtin.exportName} } from "${sdkName}/${builtin.suffix}/plugin";` + ) + registrations.push(`registerPlugin(${builtin.exportName});`) + } else { + const varName = `customPlugin${varIndex++}` + imports.push(`import ${varName} from "${specifier}";`) + registrations.push(`registerPlugin(${varName});`) + } + } + + const importsStr = imports.join('\n') + const registrationsStr = registrations.join('\n') + const pluginsList = pluginSpecifiers.map((p) => `* - ${p}`).join('\n') + + return `/** + * QVAC SDK Worker Entry (auto-generated) + * Generated by: npx qvac bundle sdk + * Plugins: ${pluginSpecifiers.length} + * +${pluginsList} + */ + +import { initializeWorkerCore, ensureRPCSetup } from "${sdkName}/dist/server/worker-core.js"; +import { registerPlugin } from "${sdkName}/dist/server/plugins/index.js"; +import { getServerLogger } from "${sdkName}/dist/logging/index.js"; + +${importsStr} + +const { hasRPCConfig } = initializeWorkerCore(); + +const logger = getServerLogger(); +logger.info("🐻 QVAC Worker (custom bundle)"); +logger.info("šŸ“¦ Plugins: ${pluginSpecifiers.length}"); + +${registrationsStr} + +// Auto-setup RPC if config present +if (hasRPCConfig) { + ensureRPCSetup(); +} +` +} + +/** + * Generates a Pear worker entry file that: + * 1) Registers selected plugins (built-in + custom) + * 2) Then loads the app worker module (dynamic import to preserve ordering) + * @param {string[]} pluginSpecifiers + * @param {string} sdkName + * @param {string} appWorkerImport + */ +export function generatePearWorkerEntry (pluginSpecifiers, sdkName, appWorkerImport) { + const imports = [] + const registrations = [] + let varIndex = 0 + + for (const specifier of pluginSpecifiers) { + const builtin = parseBuiltinSpecifier(specifier, sdkName) + if (builtin) { + imports.push( + `import { ${builtin.exportName} } from "${sdkName}/${builtin.suffix}/plugin";` + ) + registrations.push(`registerPlugin(${builtin.exportName});`) + } else { + const varName = `customPlugin${varIndex++}` + imports.push(`import ${varName} from "${specifier}";`) + registrations.push(`registerPlugin(${varName});`) + } + } + + const importsStr = imports.join('\n') + const registrationsStr = registrations.join('\n') + const pluginsList = pluginSpecifiers.map((p) => `* - ${p}`).join('\n') + + return `/** + * QVAC Pear Worker Entry (auto-generated) + * Generated by: npx qvac bundle sdk + * Plugins: ${pluginSpecifiers.length} + * +${pluginsList} + */ + +import { registerPlugin } from "${sdkName}/dist/server/plugins/index.js"; + +${importsStr} + +${registrationsStr} + +await import(${JSON.stringify(appWorkerImport)}); +` +} + +function toPosixPath (p) { + return p.replace(/\\\\/g, '/') +} + +/** + * Converts an absolute path to a relative import specifier. + * @param {string} fromDir + * @param {string} targetPath + */ +export function toRelativeImportSpecifier (fromDir, targetPath) { + let rel = path.relative(fromDir, targetPath) + rel = toPosixPath(rel) + if (!rel.startsWith('.')) rel = `./${rel}` + return rel +} diff --git a/packages/qvac-cli/src/bundle-sdk/index.js b/packages/qvac-cli/src/bundle-sdk/index.js new file mode 100644 index 0000000000..775be0551e --- /dev/null +++ b/packages/qvac-cli/src/bundle-sdk/index.js @@ -0,0 +1,191 @@ +import fs, { promises as fsp } from 'node:fs' +import path from 'node:path' +import { DEFAULT_HOSTS, DEFAULT_SDK_NAME } from './constants.js' +import { createLogger } from '../logger.js' +import { findConfigFile, loadConfig, CONFIG_CANDIDATES } from '../config.js' +import { BareImportsMapNotFoundError } from '../errors.js' +import { resolvePluginSpecifiers, parseBuiltinSpecifier } from './plugins.js' +import { + generateWorkerEntry, + generatePearWorkerEntry, + toRelativeImportSpecifier +} from './entry-gen.js' +import { runBarePack } from './bare-pack.js' +import { generateAddonsManifest } from './manifest.js' + +async function resolveSdkName (projectRoot) { + const sdkPackageJsonPath = path.join( + projectRoot, + 'node_modules', + '@qvac', + 'sdk', + 'package.json' + ) + + try { + if (fs.existsSync(sdkPackageJsonPath)) { + const content = await fsp.readFile(sdkPackageJsonPath, 'utf8') + const pkg = JSON.parse(content) + return pkg.name + } + } catch { + // Fall through to default + } + + return DEFAULT_SDK_NAME +} + +function resolveImportsMapPath (projectRoot, sdkName) { + const fromNodeModules = path.join( + projectRoot, + 'node_modules', + sdkName, + 'bare-imports.json' + ) + + if (fs.existsSync(fromNodeModules)) { + return fromNodeModules + } + + throw new BareImportsMapNotFoundError(sdkName, fromNodeModules) +} + +export async function bundleSdk (options = {}) { + const startTime = Date.now() + + const projectRoot = options.projectRoot ?? process.cwd() + const outputDir = path.join(projectRoot, 'qvac') + const entryPath = path.join(outputDir, 'worker.entry.mjs') + const pearWorkerEntryPath = path.join(outputDir, 'worker.pear.entry.mjs') + const bundlePath = path.join(outputDir, 'worker.bundle.js') + + let logLevel = 'normal' + if (options.quiet) logLevel = 'quiet' + else if (options.verbose) logLevel = 'verbose' + + const logger = createLogger(logLevel) + + logger.log('šŸ”§ QVAC SDK Worker Bundler\n') + + const configPath = findConfigFile(projectRoot, options.configPath) + + let config = {} + if (configPath) { + logger.log(`šŸ“„ Config: ${path.relative(projectRoot, configPath)}`) + config = await loadConfig(configPath) + } else { + logger.log('šŸ“„ Config: (none)') + logger.log('āš ļø No config file found — continuing with defaults.') + logger.log( + ' To customize bundling, create one of:\n' + + CONFIG_CANDIDATES.map((c) => ` - ${c}`).join('\n') + + '\n' + ) + } + + const sdkName = await resolveSdkName(projectRoot) + logger.log(`šŸ“¦ SDK: ${sdkName}`) + + const pluginSpecifiers = resolvePluginSpecifiers(config, sdkName, logger) + logger.log(`\nšŸ“¦ Plugins to include (${pluginSpecifiers.length}):`) + for (const spec of pluginSpecifiers) { + const label = parseBuiltinSpecifier(spec, sdkName) + ? 'āœ“ built-in' + : 'āŠ• custom' + logger.log(` ${label}: ${spec}`) + } + + const hosts = + options.hosts && options.hosts.length > 0 ? options.hosts : DEFAULT_HOSTS + + const deferModules = options.defer ?? [] + + await fsp.mkdir(outputDir, { recursive: true }) + + logger.log('\nšŸ“ Generating worker entry...') + const workerEntry = generateWorkerEntry(pluginSpecifiers, sdkName) + await fsp.writeFile(entryPath, workerEntry, 'utf8') + logger.log(` Created: ${path.relative(projectRoot, entryPath)}`) + + const pearWorker = + typeof config.pearWorker === 'string' && config.pearWorker.length > 0 + ? config.pearWorker + : 'worker.js' + + const pearWorkerAbs = path.isAbsolute(pearWorker) + ? pearWorker + : path.join(projectRoot, pearWorker) + const pearWorkerImport = toRelativeImportSpecifier(outputDir, pearWorkerAbs) + + const pearWorkerEntry = generatePearWorkerEntry( + pluginSpecifiers, + sdkName, + pearWorkerImport + ) + await fsp.writeFile(pearWorkerEntryPath, pearWorkerEntry, 'utf8') + logger.log(` Created: ${path.relative(projectRoot, pearWorkerEntryPath)}`) + + const importsMapPath = resolveImportsMapPath(projectRoot, sdkName) + logger.log(` Using: ${path.relative(projectRoot, importsMapPath)}`) + + logger.log('\nšŸ”Ø Bundling with bare-pack...') + logger.log(` Hosts: ${hosts.join(', ')}`) + if (deferModules.length > 0) { + logger.log(` Deferred: ${deferModules.join(', ')}`) + } + + await runBarePack({ + projectRoot, + entryPath, + outputPath: bundlePath, + hosts, + importsMapPath, + deferModules, + logLevel, + logger + }) + + const stats = await fsp.stat(bundlePath) + const sizeKB = (stats.size / 1024).toFixed(1) + logger.log(`\nāœ… Bundle created: ${path.relative(projectRoot, bundlePath)}`) + logger.log(` Size: ${sizeKB} KB`) + + const manifestResult = await generateAddonsManifest({ + bundlePath, + outputDir, + projectRoot, + logger + }) + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2) + logger.log(`\nšŸŽ‰ Done in ${elapsed}s!\n`) + logger.log('Generated files:') + logger.log( + ' - qvac/worker.entry.mjs (standalone worker with RPC + lifecycle)' + ) + logger.log( + ' - qvac/worker.pear.entry.mjs (Pear worker entrypoint: plugins + app worker)' + ) + logger.log( + ' - qvac/worker.bundle.js (mobile bundle for Expo/React Native BareKit)' + ) + logger.log(' - qvac/addons.manifest.json\n') + logger.log( + 'Pear apps: Spawn qvac/worker.pear.entry.mjs as your worker entrypoint' + ) + logger.log('Mobile: Expo plugin auto-configures worker.bundle.js') + logger.log( + 'Standalone: Import qvac/worker.entry.mjs for full worker with RPC\n' + ) + + return { + bundlePath, + plugins: pluginSpecifiers, + addons: manifestResult.addons, + entryPaths: { + worker: entryPath, + pearWorker: pearWorkerEntryPath + }, + manifestPath: manifestResult.manifestPath + } +} diff --git a/packages/qvac-cli/src/bundle-sdk/manifest.js b/packages/qvac-cli/src/bundle-sdk/manifest.js new file mode 100644 index 0000000000..f817bc3d04 --- /dev/null +++ b/packages/qvac-cli/src/bundle-sdk/manifest.js @@ -0,0 +1,214 @@ +import fs, { promises as fsp } from 'node:fs' +import path from 'node:path' + +/** + * Extracts the packed string from a bare-pack bundle without executing it. + * Bundle format: `module.exports = "";` + * @param {string} bundleJsText + */ +export function extractPackedString (bundleJsText) { + const idx = bundleJsText.indexOf('module.exports') + if (idx === -1) { + throw new Error("bundle does not contain 'module.exports'") + } + + const eq = bundleJsText.indexOf('=', idx) + if (eq === -1) { + throw new Error("could not find '=' after module.exports") + } + + let i = eq + 1 + while (i < bundleJsText.length && /\s/.test(bundleJsText[i])) i++ + + const quote = bundleJsText[i] + if (quote !== '"' && quote !== "'") { + throw new Error('export value is not a string literal') + } + i++ // past opening quote + + let out = '' + let esc = false + + for (; i < bundleJsText.length; i++) { + const ch = bundleJsText[i] + + if (esc) { + switch (ch) { + case 'n': + out += '\n' + break + case 'r': + out += '\r' + break + case 't': + out += '\t' + break + case 'b': + out += '\b' + break + case 'f': + out += '\f' + break + case 'v': + out += '\v' + break + case '\\': + out += '\\' + break + case '"': + out += '"' + break + case "'": + out += "'" + break + case 'x': { + // \xHH + const hex = bundleJsText.slice(i + 1, i + 3) + if (!/^[0-9a-fA-F]{2}$/.test(hex)) throw new Error('bad \\x escape') + out += String.fromCharCode(parseInt(hex, 16)) + i += 2 + break + } + case 'u': { + // \uHHHH + const hex = bundleJsText.slice(i + 1, i + 5) + if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new Error('bad \\u escape') + out += String.fromCharCode(parseInt(hex, 16)) + i += 4 + break + } + default: + // Keep unknown escapes as-is + out += ch + } + esc = false + continue + } + + if (ch === '\\') { + esc = true + continue + } + if (ch === quote) break // end of string literal + + out += ch + } + + if (i >= bundleJsText.length) { + throw new Error('unterminated string literal') + } + + return out +} + +export function extractBarePackHeader (packed) { + const firstNL = packed.indexOf('\n') + if (firstNL === -1) { + throw new Error('packed string missing first newline separator') + } + + const jsonStart = packed.indexOf('{', firstNL + 1) + if (jsonStart === -1) { + throw new Error('could not find header JSON start in packed string') + } + + let i = jsonStart + let depth = 0 + let inStr = false + let esc = false + + for (; i < packed.length; i++) { + const ch = packed[i] + + if (inStr) { + if (esc) esc = false + else if (ch === '\\') esc = true + else if (ch === '"') inStr = false + continue + } + + if (ch === '"') inStr = true + else if (ch === '{') depth++ + else if (ch === '}') { + depth-- + if (depth === 0) { + i++ // include closing brace + break + } + } + } + + if (depth !== 0) { + throw new Error('unbalanced braces while extracting header JSON') + } + + return JSON.parse(packed.slice(jsonStart, i)) +} + +export async function generateAddonsManifest (options) { + const { bundlePath, outputDir, projectRoot, logger } = options + + logger.log('\nšŸ“¦ Generating addons manifest...') + + const bundleJsText = await fsp.readFile(bundlePath, 'utf8') + const packed = extractPackedString(bundleJsText) + const header = extractBarePackHeader(packed) + const resolutions = header.resolutions ?? {} + + // Extract package names from resolution keys + const packageNames = new Set() + const nodeModulesRegex = /\/node_modules\/(@[^/]+\/[^/]+|[^/]+)\// + + for (const key of Object.keys(resolutions)) { + const match = key.match(nodeModulesRegex) + if (match) { + packageNames.add(match[1]) + } + } + + // Check which packages have "addon": true + const addons = [] + for (const pkgName of packageNames) { + const pkgJsonPath = path.join( + projectRoot, + 'node_modules', + pkgName, + 'package.json' + ) + try { + if (fs.existsSync(pkgJsonPath)) { + const pkgJson = JSON.parse(await fsp.readFile(pkgJsonPath, 'utf8')) + if (pkgJson.addon === true) { + addons.push(pkgName) + } + } + } catch (err) { + logger.verbose(` āš ļø Could not read ${pkgName}/package.json: ${err.message}`) + } + } + + // Sort for deterministic output + addons.sort() + + const bundleId = + typeof header.id === 'string' && header.id.length > 0 + ? header.id + : 'unknown' + + const manifest = { + version: 1, + bundleId, + addons + } + + const manifestPath = path.join(outputDir, 'addons.manifest.json') + await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n') + + logger.log(` Found ${packageNames.size} packages in bundle graph`) + logger.log( + ` Identified ${addons.length} native addons: ${addons.join(', ') || '(none)'}` + ) + logger.log(` Wrote ${manifestPath}`) + + return { manifestPath, addons } +} diff --git a/packages/qvac-cli/src/bundle-sdk/plugins.js b/packages/qvac-cli/src/bundle-sdk/plugins.js new file mode 100644 index 0000000000..f524e6b5ec --- /dev/null +++ b/packages/qvac-cli/src/bundle-sdk/plugins.js @@ -0,0 +1,64 @@ +import { BUILTIN_PLUGINS, BUILTIN_SUFFIXES } from './constants.js' +import { InvalidPluginSpecifierError } from '../errors.js' + +export function buildBuiltinSpecifier (sdkName, suffix) { + return `${sdkName}/${suffix}/plugin` +} + +export function parseBuiltinSpecifier (specifier, sdkName) { + const prefix = `${sdkName}/` + const pluginSuffix = '/plugin' + + if (specifier.startsWith(prefix) && specifier.endsWith(pluginSuffix)) { + const middle = specifier.slice(prefix.length, -pluginSuffix.length) + const info = BUILTIN_PLUGINS[middle] + if (!middle.includes('/') && info) { + return { suffix: middle, exportName: info.exportName } + } + } + + return null +} + +export function resolvePluginSpecifiers (config, sdkName, logger) { + let { plugins = [] } = config + + // Default to all built-in plugins if none specified + if (!plugins || plugins.length === 0) { + const allBuiltins = BUILTIN_SUFFIXES.map((suffix) => + buildBuiltinSpecifier(sdkName, suffix) + ) + logger.log('āš ļø No plugins specified — bundling ALL built-in plugins.') + logger.log(" For smaller bundles, add a 'plugins' array to qvac.config.*\n") + plugins = allBuiltins + } + + const uniquePlugins = [...new Set(plugins)] + + const resolved = [] + const customPlugins = [] + const errors = [] + + for (const specifier of uniquePlugins) { + const builtin = parseBuiltinSpecifier(specifier, sdkName) + if (builtin) { + resolved.push(specifier) + } else { + if (!specifier.endsWith('/plugin')) { + errors.push(specifier) + } else { + customPlugins.push(specifier) + } + } + } + + if (errors.length > 0) { + throw new InvalidPluginSpecifierError(errors) + } + + if (customPlugins.length > 0) { + logger.log(`šŸ“¦ Custom plugins: ${customPlugins.join(', ')}`) + } + + return [...resolved, ...customPlugins] +} diff --git a/packages/qvac-cli/src/config.js b/packages/qvac-cli/src/config.js new file mode 100644 index 0000000000..4de0f66c99 --- /dev/null +++ b/packages/qvac-cli/src/config.js @@ -0,0 +1,59 @@ +import fs, { promises as fsp } from 'node:fs' +import path from 'node:path' +import { ConfigNotFoundError, ConfigLoadError } from './errors.js' + +export const CONFIG_CANDIDATES = [ + 'qvac.config.json', + 'qvac.config.js', + 'qvac.config.mjs', + 'qvac.config.ts' +] + +export function findConfigFile (projectRoot, explicitPath) { + if (explicitPath) { + const absPath = path.resolve(projectRoot, explicitPath) + if (fs.existsSync(absPath)) return absPath + throw new ConfigNotFoundError(explicitPath) + } + + for (const candidate of CONFIG_CANDIDATES) { + const configPath = path.join(projectRoot, candidate) + if (fs.existsSync(configPath)) return configPath + } + + return null +} + +export async function loadConfig (configPath) { + if (!configPath) { + throw new ConfigNotFoundError(null, CONFIG_CANDIDATES) + } + + const ext = path.extname(configPath).toLowerCase() + + try { + if (ext === '.json') { + const content = await fsp.readFile(configPath, 'utf8') + return JSON.parse(content) + } + + if (ext === '.js' || ext === '.mjs') { + const fileUrl = `file://${configPath}` + const module = await import(fileUrl) + return module.default ?? module + } + + if (ext === '.ts') { + const { tsImport } = await import('tsx/esm/api') + const module = await tsImport(configPath, import.meta.url) + return module.default ?? module + } + + throw new Error( + `Unsupported config format: ${ext}. Use .json, .js, .mjs, or .ts` + ) + } catch (error) { + if (error instanceof ConfigNotFoundError) throw error + throw new ConfigLoadError(configPath, error) + } +} diff --git a/packages/qvac-cli/src/errors.js b/packages/qvac-cli/src/errors.js new file mode 100644 index 0000000000..25b6813a80 --- /dev/null +++ b/packages/qvac-cli/src/errors.js @@ -0,0 +1,109 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Config Errors +// ───────────────────────────────────────────────────────────────────────────── + +export class ConfigNotFoundError extends Error { + constructor (explicitPath, candidates = []) { + const message = explicitPath + ? `Config file not found: ${explicitPath}` + : `No config file found. Create one of:\n${candidates.map((c) => ` - ${c}`).join('\n')}` + super(message) + this.name = 'ConfigNotFoundError' + } +} + +export class ConfigLoadError extends Error { + constructor (configPath, cause) { + const causeMessage = + cause instanceof Error ? cause.message : String(cause) + super(`Failed to load config from ${configPath}: ${causeMessage}`) + this.name = 'ConfigLoadError' + this.cause = cause + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Errors +// ───────────────────────────────────────────────────────────────────────────── + +export class InvalidPluginSpecifierError extends Error { + constructor (specifiers) { + const list = specifiers.map((s) => ` - ${s}`).join('\n') + super(`Invalid plugin specifiers (must end with /plugin):\n${list}`) + this.name = 'InvalidPluginSpecifierError' + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Bundler Errors +// ───────────────────────────────────────────────────────────────────────────── + +export class BarePackNotInstalledError extends Error { + constructor () { + super( + 'bare-pack is not installed.\n\n' + + ' To bundle the SDK, install bare-pack:\n' + + ' npm install -D bare-pack\n' + + ' # or: bun add -d bare-pack\n\n' + + ' Then run: npx qvac bundle sdk' + ) + this.name = 'BarePackNotInstalledError' + } +} + +export class BarePackError extends Error { + constructor (exitCode, entryPath, outputPath) { + super( + `bare-pack exited with code ${exitCode}\n\n` + + ` Entry file: ${entryPath}\n` + + ` Output file: ${outputPath}\n\n` + + ' Run bare-pack manually for more details.' + ) + this.name = 'BarePackError' + this.entryPath = entryPath + this.outputPath = outputPath + } +} + +export class BareImportsMapNotFoundError extends Error { + constructor (sdkName, expectedPath) { + super( + 'bare-imports.json not found.\n\n' + + ` Expected at: ${expectedPath}\n\n` + + ` Make sure ${sdkName} is installed in your project.` + ) + this.name = 'BareImportsMapNotFoundError' + this.sdkName = sdkName + this.expectedPath = expectedPath + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Error Handler (for CLI output) +// ───────────────────────────────────────────────────────────────────────────── + +const ERROR_LABELS = { + ConfigNotFoundError: 'Configuration Error', + ConfigLoadError: 'Config Load Error', + InvalidPluginSpecifierError: 'Plugin Error', + BarePackNotInstalledError: 'Bundler Error', + BarePackError: 'Bundle Failed', + BareImportsMapNotFoundError: 'SDK Error' +} + +export function handleError (error) { + if (error instanceof Error) { + const label = ERROR_LABELS[error.name] + if (label) { + console.error(`\nāŒ ${label}:`) + console.error(` ${error.message}\n`) + } else { + console.error('\nāŒ Error:', error.message) + if (process.env.DEBUG) { + console.error(error.stack) + } + } + } else { + console.error('\nāŒ Error:', error) + } +} diff --git a/packages/qvac-cli/src/index.js b/packages/qvac-cli/src/index.js new file mode 100644 index 0000000000..ad50b459ab --- /dev/null +++ b/packages/qvac-cli/src/index.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module' +import { Command } from 'commander' +import { bundleSdk } from './bundle-sdk/index.js' +import { handleError } from './errors.js' + +const require = createRequire(import.meta.url) +const pkg = require('../package.json') + +// ───────────────────────────────────────────────────────────────────────────── +// CLI Entry Point +// ───────────────────────────────────────────────────────────────────────────── + +function collect (value, previous) { + return previous.concat([value]) +} + +function setupCli () { + const program = new Command() + + program + .name('qvac') + .description('Command-line interface for the QVAC ecosystem') + .version(pkg.version) + + const bundleCmd = program + .command('bundle') + .description('Bundle QVAC artifacts for different runtimes') + + bundleCmd + .command('sdk') + .description('Generate a tree-shaken Bare worker bundle with selected plugins') + .option('-c, --config ', 'Config file path (default: auto-detect qvac.config.*)') + .option('--host ', 'Target host (repeatable)', collect, []) + .option('--defer ', 'Defer a module (repeatable)', collect, []) + .option('-q, --quiet', 'Minimal output') + .option('-v, --verbose', 'Detailed output') + .action(async (options) => { + try { + await bundleSdk({ + projectRoot: process.cwd(), + configPath: options.config, + hosts: options.host.length > 0 ? options.host : undefined, + defer: options.defer.length > 0 ? options.defer : undefined, + quiet: options.quiet, + verbose: options.verbose + }) + } catch (error) { + handleError(error) + process.exit(1) + } + }) + + program.parse() +} + +setupCli() diff --git a/packages/qvac-cli/src/logger.js b/packages/qvac-cli/src/logger.js new file mode 100644 index 0000000000..acd1b445b4 --- /dev/null +++ b/packages/qvac-cli/src/logger.js @@ -0,0 +1,18 @@ +/** + * Creates a logger with the specified log level. + * @param {"quiet" | "normal" | "verbose"} logLevel + */ +export function createLogger (logLevel) { + return { + log (message, level = 'normal') { + if (logLevel === 'quiet' && level !== 'quiet') return + if (level === 'verbose' && logLevel !== 'verbose') return + console.log(message) + }, + verbose (message) { + if (logLevel === 'verbose') { + console.log(message) + } + } + } +}