diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6011208..2b59c5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,71 +2,174 @@ name: Fleetbase CLI CI on: push: - branches: [ main ] + branches: [main, v2] tags: - 'v*' pull_request: - branches: [ main, 'release/v*', 'dev-v*' ] + branches: [main, v2, 'release/v*', 'dev-v*'] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - build: + test: + name: Unit tests (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest - strategy: + fail-fast: false matrix: - node-version: [18.x] # Build on Node.js 18 + node-version: ['20.x', '22.x'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + - name: Build + run: npm run build + + - name: Unit tests + run: npm test + + - name: Installer tests + run: npm test -w @fleetbase/installer + + e2e: + name: End-to-end against the Registry Service + runs-on: ubuntu-latest + needs: test + env: + PHP_CLI_SERVER_WORKERS: 4 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - name: Check out the Registry Service (v2) + uses: actions/checkout@v4 + with: + repository: fleetbase/registry + ref: v2 + path: registry + + - uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 - with: - node-version: ${{ matrix.node-version }} + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, bcmath, zip, pdo_sqlite, sqlite3, phar, zlib + coverage: none + tools: composer:v2 + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: composer-${{ runner.os }}-8.3-${{ hashFiles('registry/composer.lock') }} + restore-keys: composer-${{ runner.os }}-8.3- + + - name: Prepare the service + working-directory: registry + # artisan serve reads .env, not the job environment; checks run in the request (sync queue) + run: | + composer install --no-interaction --prefer-dist --no-progress + cp .env.example .env + sed -i 's/^QUEUE_CONNECTION=.*/QUEUE_CONNECTION=sync/; s/^MAIL_MAILER=.*/MAIL_MAILER=log/' .env + php artisan key:generate --ansi + touch database/database.sqlite + php artisan migrate --seed --force + php artisan registry:seed --seed-release + + - name: Start the service + working-directory: registry + run: | + php artisan serve --host=127.0.0.1 --port=8000 > storage/logs/serve.log 2>&1 & + for i in $(seq 1 30); do curl -sf http://127.0.0.1:8000/up > /dev/null && exit 0; sleep 1; done + cat storage/logs/serve.log; exit 1 + + - name: Install and build the CLI + run: | + npm ci + npm run build + + - name: End-to-end + env: + FLB_E2E_REGISTRY: http://127.0.0.1:8000 + FLB_E2E_SERVICE: ${{ github.workspace }}/registry + run: bash test/e2e/run.sh - - name: Install Dependencies - run: npm install + - name: Service logs + if: failure() + working-directory: registry + run: | + cat storage/logs/serve.log || true + tail -n 200 storage/logs/laravel.log || true npm_publish: - needs: build + needs: [test, e2e] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22.x' + registry-url: 'https://registry.npmjs.org' - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 - with: - node-version: 18.x + - name: Install dependencies + run: npm ci - - name: Install Dependencies - run: npm install + - name: Publish @fleetbase/cli + run: npm publish --access public --tag ${{ contains(github.ref_name, '-') && 'next' || 'latest' }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} - - name: Set up npm - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_AUTH_TOKEN }}" > ~/.npmrc + - name: Publish @fleetbase/installer when its version is new + run: | + version=$(node -p "require('./packages/installer/package.json').version") + if npm view "@fleetbase/installer@$version" version >/dev/null 2>&1; then + echo "@fleetbase/installer@$version is already published" + else + npm publish -w @fleetbase/installer --access public + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} - - name: Publish - run: npm publish --access public - github_publish: - needs: build + needs: [test, e2e] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + packages: write steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 + - uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: '22.x' + registry-url: 'https://npm.pkg.github.com' + scope: '@fleetbase' - - name: Install Dependencies - run: npm install - - - name: Configure npm for GitHub registry - run: | - echo "@fleetbase:registry=https://npm.pkg.github.com/" >> ~/.npmrc - echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> ~/.npmrc + - name: Install dependencies + run: npm ci - - name: Publish to GitHub registry + - name: Publish to GitHub Packages run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 28b65b3..31d5472 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ /node_modules -.npmrc \ No newline at end of file +/packages/*/node_modules +/dist +/coverage +.npmrc diff --git a/README.md b/README.md index d52a3df..621b335 100644 --- a/README.md +++ b/README.md @@ -1,432 +1,148 @@ # Fleetbase CLI -FLB (Fleetbase CLI) is a command-line interface tool designed for managing Fleetbase Extensions. It simplifies the process of publishing and managing both npm and PHP Composer packages, particularly for Fleetbase extensions. - -## Features - -- Register and manage Registry Developer Accounts for self-hosted instances -- Publish Fleetbase Extensions to a specified registry -- Unpublish Extensions from the registry -- Install Fleetbase using Docker -- Automatically convert `composer.json` to `package.json` for PHP packages -- Scaffold new Fleetbase extensions -- Set registry token to a Fleetbase instance -- Search and list available extensions from the registry -- Install and Uninstall extensions -- Flexible registry configuration - -## Installation - -To install FLB, run the following command: +`flb` builds and publishes Fleetbase extensions to the Fleetbase Extensions Registry, and +installs, updates and uninstalls them on your Fleetbase instances. ```bash npm install -g @fleetbase/cli +flb --help +``` + +Requires Node.js 20.10 or later. Installing Fleetbase itself moved to its own package: +`npx @fleetbase/installer` (see [packages/installer](packages/installer)). + +## Quick start: publish an extension + +```bash +flb register # or create the account on the publisher website +flb login # email and password; stores a scoped token +flb init --name "Fleet Insights" +cd fleet-insights && pnpm install +flb build # engine and api archives in .fleetbase/build/ +flb publish # creates the draft listing, uploads, follows the registry checks +flb listing show # what the listing still needs before the first submit +flb listing update --subtitle "…" --category telematics --icon chart-line … +flb release submit # submit for review (or publish --submit next time) +flb release status # review state, reasons and history ``` -## Quick Start: Developer Account Registration +A first release always gets a human review; later releases with the same package names, +price and permissions are published as soon as their checks pass. + +## Commands -If you're using a self-hosted Fleetbase instance and want to publish or install extensions, you'll need to register a Registry Developer Account. Follow these steps: +| Command | Does | +| --- | --- | +| `flb login [--email] [--password-stdin]` | Logs in with `POST /v1/auth/login` and stores a token scoped to `read, publish, instance:register` for 90 days (`--scopes`, `--expires`) | +| `flb login --token [flbp_…]` / `--token-stdin` | Stores an existing personal token after checking it with the service | +| `flb logout [--local]` | Revokes the token on the service and removes it from the config | +| `flb whoami` | The account, organizations and token in use | +| `flb register` / `flb verify [--resend]` | Account creation and email verification from the terminal | +| `flb token create --name ci --scopes publish --expires 180d --show-token` | A new personal token, printed once (`--save` stores it instead) | +| `flb token list` / `flb token revoke ` | Masked list; revoke by `tok_` id | +| `flb init [dir]` | Scaffolds from [fleetbase/starter-extension](https://github.com/fleetbase/starter-extension): package names under your organization, PHP namespace, engine route, and an `extension.json` with `compat` ranges | +| `flb build [--prebuilt --console ]` | Engine tarball (`npm pack`), api archive (Composer layout), optional prebuilt engine dist, and `.fleetbase/release.json` | +| `flb bump ` | One version in extension.json, package.json and composer.json | +| `flb publish [--channel beta] [--submit]` | Creates the draft listing if needed, uploads the release, streams the check results | +| `flb release list\|status\|submit\|withdraw\|yank` | Release lifecycle, by `rel_` id or by version inside the extension | +| `flb listing list\|show\|update\|categories` | The catalog listing: subtitle, description, category, tags, URLs, icon (`--file listing.json` for several at once); shows what is still needed before a first release can be submitted | +| `flb search [query]` / `flb info ` | The public catalog | +| `flb instance code` | A one-time registration code to paste into an instance | +| `flb instance register --path \|--docker` | Registers the instance itself: mints a code and pipes it to `php artisan registry:register --code-stdin`, so the instance token stays in the instance's encrypted settings. Without a target (or with `--store-token`) the CLI exchanges the code and keeps the token in the config file. Registering an instance again rotates it on the service rather than creating a second one: the instance (or, with `--store-token`, the config entry for the same `--path` or `--host`) sends its current token, the service also matches the same host in the same organization, and the instance keeps its id, installs and licence seats with a new token ("Re-registered …"). Under Octane the new registration applies to the running workers without a reload | +| `flb instance status\|updates\|list\|forget` | The instance as the service sees it, its licences and available updates | +| `flb install [@version]` / `flb update` / `flb uninstall` | Runs `php artisan registry:install\|update\|uninstall` on an instance (`--path` or `--docker [service]`) and follows the job | +| `flb doctor [--path\|--docker]` | Config, registry connectivity, token validity and scopes, instances, local tools, the instance's installer | +| `flb config list\|get\|set\|unset\|path` | `registry` and `organization` defaults | -### Step 1: Register Your Account +Every command takes `--registry ` (or `FLB_REGISTRY`); most take `--json`. -```bash -flb register --host localhost:8000 -``` +## Configuration and tokens -You'll be prompted for: -- **Username**: Your desired username (alphanumeric, hyphens, and underscores only) -- **Email**: Your email address -- **Password**: A secure password (minimum 8 characters) -- **Name**: Your display name (optional) - -### Step 2: Verify Your Email - -Check your email for a verification code, then run: - -```bash -flb verify -e your@email.com -c 123456 --host localhost:8000 -``` +Everything the CLI keeps is in `~/.fleetbase/config.json` (`FLEETBASE_HOME` moves the +directory), written with mode 600 in a 700 directory: -Or use the command provided in the verification email. Upon successful verification, you'll receive your registry token. - -### Step 3: Set Your Registry Token - -Use the token from the verification step: - -```bash -flb set-auth flb_your_token_here --registry http://localhost:8000 -``` - -### Step 4: You're Ready! - -You can now publish extensions, install extensions, and manage your Fleetbase instance through the CLI. - -**Note:** If you need to regenerate your token later, use: - -```bash -flb generate-token -e your@email.com --host localhost:8000 +```json +{ + "registry": "https://registry.fleetbase.io", + "defaultOrganization": "acme", + "tokens": { "registry.fleetbase.io": "flbp_…" }, + "hosts": { + "registry.fleetbase.io": { + "account": { "email": "dev@acme.test" }, + "tokenId": "tok_…", + "scopes": ["read", "publish", "instance:register"], + "expiresAt": "2027-01-20T00:00:00Z", + "organization": "acme" + } + }, + "instances": { "inst_…": { "registry": "https://registry.fleetbase.io", "token": "flbi_…" } } +} ``` -## Usage +`host`, `registry`, `tokens` (by host) and `defaultOrganization` are the keys the 0.0.x CLI +wrote; they are read as they are and unknown keys are kept. A 0.0.x token is not a Registry +Service token: commands that need one ask you to `flb login` again. -### Registry Developer Account Commands +- No command prints a token unless you pass `--show-token`. +- Tokens are never written anywhere but this file: no `.npmrc`, no `auth.json`, no + `.fleetbase/` files in your extension. +- In CI, set `FLB_TOKEN` instead of logging in; it is never written to disk. -#### Register a Developer Account +## Building -Create a new Registry Developer Account for publishing and installing extensions on self-hosted instances. +`flb build` validates `extension.json` and the package manifests with the same rules as the +registry checks (versions must match across extension.json, package.json and composer.json; +the api version must be one Composer can install), then writes to +`.fleetbase/build//`: -```bash -flb register -``` - -**Options:** -- `-u, --username `: Username for your account -- `-e, --email `: Email address -- `-p, --password `: Password (minimum 8 characters) -- `-n, --name `: Display name (optional) -- `-h, --host `: API host (default: `https://api.fleetbase.io`) - -**Example:** -```bash -flb register --host localhost:8000 -flb register -u myusername -e my@email.com -p mypassword --host localhost:8000 -``` +| Archive | Contents | +| --- | --- | +| `engine-.tgz` | `npm pack` of the extension (the `package/` layout npm publishes) | +| `api-.tar.gz` | `package/` + composer.json, the directories its `autoload` maps point at, README/LICENSE/CHANGELOG; `autoload-dev` paths, `archive.exclude` patterns, vendor directories and secret-looking files (`.env`, `auth.json`, keys) are left out | +| `dist-.tar.gz` | With `--prebuilt --console `: ember-core's `build-engine-release` run in a console checkout that has this version of the engine installed, as the `/` folder with its `manifest.json`. `--console-dist ` reuses an existing console build | -#### Verify Email Address +The prebuilt dist needs an ember-core with runtime engines +([fleetbase/ember-core#99](https://github.com/fleetbase/ember-core/pull/99)) in the console. -Verify your email address using the code sent to your email. +## Registering an instance ```bash -flb verify +flb instance register --path ~/fleetbase # runs registry:register in api/ +flb instance register --docker --service-url https://registry.fleetbase.io ``` -**Options:** -- `-e, --email `: Email address -- `-c, --code `: Verification code from email -- `-h, --host `: API host (default: `https://api.fleetbase.io`) +The code is minted with your login (`instance:register` scope) and written to the command's +stdin, never its arguments. `--service-url` is the registry URL as the instance reaches it +(default: the one the CLI uses). Instances registered this way show in `flb instance list` +with the token hint only. -**Example:** -```bash -flb verify -e my@email.com -c 123456 --host localhost:8000 -``` - -#### Resend Verification Code - -Request a new verification code if the previous one expired. +## Installing on an instance ```bash -flb resend-verification +flb install pallet --path ~/fleetbase # a checkout with api/artisan +flb install @fleetbase/pallet-engine@0.0.2 --docker # inside the compose "application" service +flb update pallet --release 0.0.3 --path ~/fleetbase/api +flb uninstall pallet --docker application -f docker-compose.prod.yml ``` -**Options:** -- `-e, --email `: Email address -- `-h, --host `: API host (default: `https://api.fleetbase.io`) +The CLI calls the same queued installer as the console (registry-bridge +`registry:install|update|uninstall`, which take `--release=` because artisan reserves +`--version`) and follows the job with `registry:job --json` until it completes or fails. +`--sync` runs the job in the artisan process; `--no-wait` returns once it is queued. -**Example:** -```bash -flb resend-verification -e my@email.com --host localhost:8000 -``` - -#### Generate or Regenerate Registry Token - -Generate a new registry authentication token or regenerate an existing one. Useful for existing accounts created before automatic token generation, or if you need to regenerate your token for security reasons. - -```bash -flb generate-token -``` - -**Options:** -- `-e, --email `: Email address -- `-p, --password `: Password -- `-h, --host `: API host (default: `https://api.fleetbase.io`) +## Development -**Example:** ```bash -flb generate-token -e my@email.com --host localhost:8000 +npm install +npm run build +npm test # Vitest unit tests +npm run test:e2e # against a local Registry Service, see test/e2e/run.sh ``` -**Note:** This command requires your account to be verified. Each time you generate a token, it replaces the previous one. - -### Installing Fleetbase - -Install Fleetbase using Docker with a single command. - -```bash -flb install-fleetbase -``` - -**Options:** -- `--host `: Host or IP address to bind to (default: `localhost`) -- `--environment `: Environment: `development` or `production` (default: `development`) -- `--directory `: Installation directory (default: current directory) -- `--app-name `: Application name (default: `Fleetbase`) -- `--non-interactive`: Skip every prompt and use the flags above plus safe defaults (CI/CD, scripted installs) - -The installer creates an empty `api/.env` (bind-mounted by `docker-compose.yml`) when one does not exist. - -**Example:** -```bash -flb install-fleetbase --host 0.0.0.0 --environment production --directory /opt/fleetbase -``` - -**Non-interactive example:** -```bash -flb install-fleetbase --non-interactive --directory /opt/fleetbase -``` - -### Publishing a Extension - -To publish a extension, navigate to the extension directory and run: - -```bash -flb publish [?path] -``` - -- `[path]`: (Optional) The path to the extension directory to be published. Defaults to the current directory. - -For PHP only extensions, `flb` will automatically convert `composer.json` to `package.json` before publishing. - -### Unpublishing a Extension - -To unpublish a extension, use: - -```bash -flb unpublish [extension] -``` - -- `[extension]`: (Optional) The name of the extension to unpublish. If not provided, FLB will attempt to determine the extension name from the current directory. - -### Setup Registry Auth Token - -To install purchased extensions you must setup authorization first which is linked to your Fleetbase account. For cloud users, you can generate a registry token at [https://console.fleetbase.io/extensions/developers/credentials](https://console.fleetbase.io/extensions/developers/credentials). For self-hosted users, use the `flb register` and `flb verify` commands to get your token. - -To setup registry auth use: - -```bash -flb set-auth [token] -``` - -**Options:** -- `-p, --path `: Path of the Fleetbase instance to install setup for (default: `.`) -- `-r, --registry `: Registry URL the credentials are for (default: `https://registry.fleetbase.io`) - -**Example:** -```bash -flb set-auth flb_your_token_here --registry http://localhost:8000 -``` - -### Login to the Fleetbase Registry - -Login to the Fleetbase registry. This command authenticates you with the Fleetbase registry by saving your credentials to your local `.npmrc` file. - -```bash -flb login -``` - -**Options:** -- `-u, --username `: Username for the registry -- `-p, --password `: Password for the registry -- `-e, --email `: Email associated with your account -- `-r, --registry `: Registry URL (default: `https://registry.fleetbase.io`) -- `--scope `: Scope for the registry (optional) -- `--quotes `: Quotes option for `npm-cli-login` (optional) -- `--config-path `: Path to the npm config file (optional) - -**Example:** -```bash -flb login -u myusername -r http://localhost:8000 -``` - -### Scaffolding a Extension - -Fleetbase CLI has the ability to scaffold a starter extension if you intend to develop your own extension. This greatly speeds up the development process as it gives you a correct starting point to build on. - -To scaffold a extension, use: - -```bash -flb scaffold -``` - -**Options:** -- `-p, --path`: The path to place the scaffold extension -- `-n, --name`: The name of the extension to scaffold -- `-d, --description`: The description of the extension to scaffold -- `-a, --author`: The name of the extension author -- `-e, --email`: The email of the extension author -- `-k, --keywords`: The keywords of the extension to scaffold -- `-n, --namespace`: The PHP Namespace of the extension to scaffold -- `-r, --repo`: The Repository URL of the extension to scaffold - -### Searching for Extensions - -Search and list all available extensions from the Fleetbase registry. Supports keyword search, category filtering, and multiple output formats. - -```bash -flb search [query] -``` - -Alias: `flb list-extensions` - -**Options:** -- `[query]`: (Optional) Search keyword to filter by name, slug, subtitle, description, or tags -- `-c, --category `: Filter by category name or slug -- `-f, --free`: Show only free extensions -- `--json`: Output results as raw JSON (useful for scripting) -- `--simple`: Output one extension per line as tab-separated values: `slug`, `name`, `version`, `price` -- `-h, --host `: API host to fetch extensions from (default: `https://api.fleetbase.io`) - -**Examples:** -```bash -# List all available extensions -flb search - -# Search by keyword -flb search routing -flb search "route optimization" - -# Filter by category -flb search --category telematics - -# Show only free extensions -flb search --free - -# Combine filters -flb search --free --category telematics - -# JSON output for scripting -flb search --json -flb search routing --json | jq '.[].slug' - -# Simple tab-separated output -flb search --simple - -# Search against a self-hosted instance -flb search --host https://api.myfleetbase.com -flb search routing --host http://localhost:8000 -``` - -### Installing a Extension - -To install a extension, use: - -```bash -flb install [extension] -``` - -**Options:** -- `[extension]`: The name of the extension to install -- `-p, --path`: (Optional) The path to the fleetbase instance directory. Defaults to the current directory - -**Example:** -```bash -flb install @fleetbase/storefront-api --path /opt/fleetbase -``` - -### Uninstalling a Extension - -To uninstall a extension, use: - -```bash -flb uninstall [extension] -``` - -**Options:** -- `[extension]`: The name of the extension to uninstall -- `-p, --path`: (Optional) The path to the fleetbase instance directory. Defaults to the current directory - -**Example:** -```bash -flb uninstall @fleetbase/storefront-api --path /opt/fleetbase -``` - -### Bundling a Extension - -To bundle a extension, use: - -```bash -flb bundle -``` - -or to bundle and upload the created bundle, use: - -```bash -flb bundle --upload -``` - -**Options:** -- `-p, --path `: Path of the Fleetbase extension (default: `.`) -- `--upload`: After bundling, upload the bundle to the Fleetbase registry using your authentication token -- `--auth-token `: Auth token for uploading the bundle (used with `--upload` option) -- `-r, --registry `: Registry URL (default: `https://registry.fleetbase.io`) - -### Uploading a Extension Bundle - -To upload an extension bundle, use: - -```bash -flb bundle-upload -``` - -**Options:** -- `[bundleFile]`: Path to the bundle file to upload. If not provided, it will look for the bundle in the current directory -- `-p, --path `: Path where the bundle is located (default: `.`) -- `--auth-token `: Auth token for uploading the bundle. If not provided, the token will be read from the `.npmrc` file -- `-r, --registry `: Registry URL (default: `https://registry.fleetbase.io`) - -### Version Bump and Extension - -To bump the version on an extension, use: - -```bash -flb version-bump -``` - -**Options:** -- `-p, --path `: Path of the Fleetbase extension (default: `.`) -- `--major`: Bump major version (e.g., `1.0.0` → `2.0.0`) -- `--minor`: Bump minor version (e.g., `1.0.0` → `1.1.0`) -- `--patch`: Bump patch version (e.g., `1.0.0` → `1.0.1`). This is the default if no flag is provided -- `--pre-release [identifier]`: Add a pre-release identifier (e.g., `1.0.0` → `1.0.0-beta`) - -### Setting a Custom Registry - -To specify a custom registry for publishing and unpublishing, use the `-r` or `--registry` option: - -```bash -flb publish -r http://my-registry.com -flb unpublish -r http://my-registry.com -``` - -## Configuration - -FLB can be configured via command-line options. The most common options include: - -- `-r, --registry [url]`: Specify a custom registry URL -- `-h, --host [url]`: Specify the API host for developer account operations - -## Self-Hosted vs Cloud - -### Self-Hosted Users - -If you're running Fleetbase on your own infrastructure: - -1. Use `flb register` to create a Registry Developer Account -2. Verify your email with `flb verify` -3. Use the provided token with `flb set-auth` -4. Specify `--host` parameter for all commands to point to your instance - -### Cloud Users - -If you're using Fleetbase Cloud (console.fleetbase.io): - -1. Generate a registry token from the Console at [Extensions > Developers > Credentials](https://console.fleetbase.io/extensions/developers/credentials) -2. Use the token with `flb set-auth` -3. No need to specify `--host` parameter (defaults to cloud) - -## Contributing - -Contributions to Fleetbase CLI are welcome. Please ensure that your code adheres to the project's coding standards and include tests for new features or bug fixes. +The end-to-end script needs a running service (`php artisan migrate --seed && +php artisan registry:seed --seed-release && php artisan serve` with `QUEUE_CONNECTION=sync`) +and `FLB_E2E_SERVICE` pointing at its checkout; CI runs it against `fleetbase/registry@v2`. -# License & Copyright +## License -Fleetbase is made available under the terms of the GNU Affero General Public License 3.0 (AGPL 3.0). For other licenses contact us. +AGPL-3.0-or-later. See [LICENSE.md](LICENSE.md). diff --git a/RELEASE.md b/RELEASE.md index da7f6da..0ef174f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,16 +1,21 @@ -> v0.0.7 ~ "Reliable install-fleetbase on macOS, Windows and CI" - ---- -## Fixes -- `flb install-fleetbase --non-interactive` no longer prompts for host, environment, directory or app name. Values come from flags or defaults, so the command runs without a TTY (CI, piped stdin). -- The installer now creates `api/.env` before starting containers. Previously Docker created a directory at that bind-mount path on a fresh clone and the API container could not boot. -- `--directory` is resolved to an absolute path, so relative paths and Git Bash style paths on Windows work. -- `--environment` is validated (`development` or `production`) and exits with a clear error otherwise. +> v2.0.0-beta.0 ~ "CLI v2 for the Fleetbase Extensions Registry" --- ## What's New -- `--app-name ` flag for `flb install-fleetbase`. +- Rewritten in TypeScript against the Registry Service API (`/v1`). Same package (`@fleetbase/cli`) and binary (`flb`). +- `flb login` / `logout` / `whoami`, `flb register` / `verify`, `flb token create|list|revoke`: scoped, expiring personal tokens. +- `flb init` scaffolds from fleetbase/starter-extension with your organization's package names, PHP namespace and an `extension.json` with `compat` ranges. +- `flb build` writes the engine tarball, the Composer api archive and, with `--prebuilt --console `, the prebuilt engine dist from ember-core's `build-engine-release`; `flb bump` keeps the three manifests on one version. +- `flb publish [--channel beta] [--submit]` creates the listing, uploads the release and streams the registry checks; `flb release status|submit|withdraw|yank|list` follow it through review. +- `flb search`, `flb info`: the public catalog. +- `flb listing list|show|update|categories`: complete the catalog listing from the terminal. +- `flb instance code|register|status|updates`: instance registration from a server shell; `register --path|--docker` hands the code to the instance (`registry:register`) so its token never leaves it. +- `flb install|update|uninstall` run registry-bridge's queued installer (`php artisan registry:*`) locally or in a docker compose service and follow the job. +- `flb doctor` checks config, token, connectivity, instances and local tools. --- -## Release process -- Release branches are now named `release/v`. CI runs on pull requests into `release/v*`, and merging a release branch into `main` tags the release, publishes it to npm and creates the GitHub Release from this file. +## Breaking changes +- `install-fleetbase` moved to its own package: `npx @fleetbase/installer`. +- Removed: `set-auth` (use `flb login --token`), `scaffold` (now `init`), `bundle` / `bundle-upload` (`build` / `publish`), `generate-token` (`token create`), `resend-verification` (`verify --resend`), `unpublish` (`release yank`), `version-bump` (`bump`). The old names print what replaced them. +- Tokens live only in `~/.fleetbase/config.json` (mode 600); nothing is written to `.npmrc` or `auth.json`. Tokens from 0.0.x are not accepted by the Registry Service: run `flb login`. +- Requires Node.js 20.10 or later. diff --git a/index.js b/index.js deleted file mode 100755 index 4776753..0000000 --- a/index.js +++ /dev/null @@ -1,2059 +0,0 @@ -#!/usr/bin/env node - -const { program } = require('commander'); -const { exec } = require('child_process'); -const { prompt } = require('enquirer'); -const fs = require('fs-extra'); -const path = require('path'); -const axios = require('axios'); -const tar = require('tar'); -const semver = require('semver'); -const glob = require('glob-promise'); -const FormData = require('form-data'); -const _ = require('lodash'); -const packageJson = require('./package.json'); -const maxBuffer = 1024 * 1024 * 50; // 50MB -const defaultRegistry = 'https://registry.fleetbase.io'; -const packageLookupApi = 'https://api.fleetbase.io/~registry/v1/lookup'; -const bundleUploadApi = 'https://api.fleetbase.io/~registry/v1/bundle-upload'; -const extensionsListApi = 'https://api.fleetbase.io/~registry/v1/extensions'; -const starterExtensionRepo = 'https://github.com/fleetbase/starter-extension.git'; - -function publishPackage (packagePath, registry, options = {}) { - if (typeof options.onBefore === 'function') { - options.onBefore(); - } - - const publishCommand = `npm publish ${packagePath} --registry ${registry}`; - - // Check if logged in - exec(`npm whoami --registry ${registry}`, (error, stdout, stderr) => { - if (error) { - console.error('You must be logged in to publish. Run `npm adduser`.'); - return; - } - - // Publish the package - exec(publishCommand, { maxBuffer: maxBuffer }, async (error, stdout, stderr) => { - if (error) { - console.error(`Error: ${error.message}`); - process.exit(1); - return; - } - if (stderr) { - console.error(`stderr: ${stderr}`); - return; - } - console.log(`stdout: ${stdout}`); - - if (typeof options.onAfter === 'function') { - options.onAfter(); - } - }); - }); -} - -function unpublishPackage (packageName, registry, options = {}) { - if (typeof options.onBefore === 'function') { - options.onBefore(); - } - - const unpublishCommand = `npm unpublish ${packageName} --force --registry=${registry}`; - - exec(unpublishCommand, { maxBuffer: maxBuffer }, (error, stdout, stderr) => { - if (error) { - console.error(`Error: ${error.message}`); - return; - } - if (stderr) { - console.error(`stderr: ${stderr}`); - return; - } - console.log(`stdout: ${stdout}`); - - if (typeof options.onAfter === 'function') { - options.onAfter(); - } - }); -} - -async function getPackageNameFromCurrentDirectory () { - const hasPackageJson = await fs.pathExists('package.json'); - const hasComposerJson = await fs.pathExists('composer.json'); - - if (hasPackageJson) { - const packageJson = await fs.readJson('package.json'); - return packageJson.name; - } else if (hasComposerJson) { - const composerJson = await fs.readJson('composer.json'); - return composerJson.name; - } - - return null; -} - -async function createComposerJsonFromPackage (packagePath) { - const composerJson = await fs.readJson(path.join(packagePath, 'composer.json')); - const packageJson = convertComposerToPackage(composerJson); - - await fs.writeJson(path.join(packagePath, 'package.json'), packageJson, { spaces: 4 }); -} - -function convertComposerToPackage (composerJson) { - let packageName = composerJson.name; - - // Convert to scoped package name if it contains a slash - if (packageName.includes('/')) { - const parts = packageName.split('/'); - packageName = `@${parts[0]}/${parts[1]}`; - } - - const packageJson = { - name: packageName, - version: composerJson.version, - description: composerJson.description, - fleetbase: { - 'from-composer': true, - }, - }; - - return packageJson; -} - -async function onBeforePublishComposer (packagePath) { - console.log('Converting composer.json to package.json...'); - await createComposerJsonFromPackage(packagePath); -} - -function onAfterPublishComposer (packagePath) { - console.log('Cleaning up generated package.json...'); - fs.removeSync(path.join(packagePath, 'package.json')); -} - -async function setAuth (token, fleetbasePath = '.', fleetbaseRegistry = defaultRegistry) { - try { - if (!token) { - console.error('Auth token is required.'); - process.exit(1); - } - - // Expand ~ to the home directory if present - if (fleetbasePath.startsWith('~')) { - fleetbasePath = path.join(require('os').homedir(), fleetbasePath.slice(1)); - } - - // Determine Fleetbase path - const defaultFleetbasePath = '/fleetbase'; - let currentPath = path.resolve(fleetbasePath || '.'); - - const consolePath = path.join(currentPath, 'console'); - const apiPath = path.join(currentPath, 'api'); - - // Check if console and api directories exist in the current path - const consoleExists = await fs.pathExists(consolePath); - const apiExists = await fs.pathExists(apiPath); - - // If not found, fallback to default path - if (!consoleExists || !apiExists) { - currentPath = defaultFleetbasePath; - } - - const npmrcPath = path.join(currentPath, 'console', '.npmrc'); - const composerAuthPath = path.join(currentPath, 'api', 'auth.json'); - - // Set the npmrc token - const authString = `//${new URL(fleetbaseRegistry).host}/:_authToken="${token}"\n`; - - // Append to .npmrc if it exists, otherwise create and write - if (await fs.pathExists(npmrcPath)) { - await fs.appendFile(npmrcPath, authString); - console.log(`NPM auth token set in ${npmrcPath}`); - } else { - await fs.writeFile(npmrcPath, authString); - console.log(`NPM auth token set in ${npmrcPath}`); - } - - // Set the composer auth token - const newBearerConfig = { - bearer: { - [new URL(fleetbaseRegistry).host]: token, - }, - }; - - let currentComposerAuth = {}; - if (await fs.pathExists(composerAuthPath)) { - const jsonContent = await fs.readJson(composerAuthPath); - currentComposerAuth = jsonContent || {}; - } - - const updatedComposerAuth = { - ...currentComposerAuth, - bearer: { - ...currentComposerAuth.bearer, - ...newBearerConfig.bearer, - }, - }; - - await fs.writeJson(composerAuthPath, updatedComposerAuth, { spaces: 4 }); - console.log(`Composer auth token set in ${composerAuthPath}`); - } catch (error) { - console.error(`Error setting auth token: ${error.message}`); - process.exit(1); - } -} - -async function uninstallPackage (packageName, fleetbasePath = '.') { - try { - // Expand ~ to the home directory if present - if (fleetbasePath.startsWith('~')) { - fleetbasePath = path.join(require('os').homedir(), fleetbasePath.slice(1)); - } - - // Resolve the Fleetbase instance path - fleetbasePath = path.resolve(fleetbasePath); - - const consolePath = path.join(fleetbasePath, 'console'); - const apiPath = path.join(fleetbasePath, 'api'); - - // Ensure console and api paths exist - const consoleExists = await fs.pathExists(consolePath); - const apiExists = await fs.pathExists(apiPath); - - if (!consoleExists || !apiExists) { - throw new Error(`Invalid Fleetbase instance path: ${fleetbasePath}`); - } - - // Make the GET request to the lookup API - const response = await axios.get(packageLookupApi, { - params: { package: packageName }, - }); - - const { npm, composer } = response.data; - - if (!npm || !composer) { - throw new Error('Invalid package data received from registry'); - } - - console.log(`Uninstalling npm package: ${npm}`); - await runCommand(`pnpm remove ${npm}`, consolePath); - - console.log(`Uninstalling composer package: ${composer}`); - await runCommand(`composer remove ${composer}`, apiPath); - - console.log('Package uninstall successful!'); - } catch (error) { - console.error(`Unnstall failed: ${error.message}`); - } -} - -async function installPackage (packageName, fleetbasePath = '.') { - try { - // Expand ~ to the home directory if present - if (fleetbasePath.startsWith('~')) { - fleetbasePath = path.join(require('os').homedir(), fleetbasePath.slice(1)); - } - - // Resolve the Fleetbase instance path - fleetbasePath = path.resolve(fleetbasePath); - - const consolePath = path.join(fleetbasePath, 'console'); - const apiPath = path.join(fleetbasePath, 'api'); - - // Ensure console and api paths exist - const consoleExists = await fs.pathExists(consolePath); - const apiExists = await fs.pathExists(apiPath); - - if (!consoleExists || !apiExists) { - throw new Error(`Invalid Fleetbase instance path: ${fleetbasePath}`); - } - - // Make the GET request to the lookup API - const response = await axios.get(packageLookupApi, { - params: { package: packageName }, - }); - - const { npm, composer } = response.data; - - if (!npm || !composer) { - throw new Error('Invalid package data received from registry'); - } - - console.log(`Installing npm package: ${npm}`); - await runCommand(`pnpm install ${npm}`, consolePath); - - console.log(`Installing composer package: ${composer}`); - await runCommand(`composer require ${composer}`, apiPath); - - console.log('Package installation successful!'); - } catch (error) { - console.error(`Installation failed: ${error.message}`); - } -} - -async function scaffoldExtension (options) { - try { - // Prompt for extension details - const answers = await prompt([ - { type: 'input', name: 'name', message: 'Extension Name:', initial: options.name }, - { type: 'input', name: 'description', message: 'Extension Description:', initial: options.description }, - { type: 'input', name: 'author', message: 'Author Name (optional):', initial: options.author }, - { type: 'input', name: 'email', message: 'Author Email (optional):', initial: options.email }, - { type: 'input', name: 'keywords', message: 'Keywords (comma-separated):', initial: options.keywords }, - { type: 'input', name: 'namespace', message: 'PHP Namespace (will be prefixed with "Fleetbase\\", leave blank to use extension name):', initial: options.namespace }, - { type: 'input', name: 'repo', message: 'Repository URL:', initial: options.repo }, - ]); - - const targetDirName = _.kebabCase(answers.name); - const targetPath = path.resolve(options.path || '.', targetDirName); - - // Check if the target directory exists, and if so, append "-1", "-2", etc. until a unique directory is found - let counter = 1; - while (await fs.pathExists(targetPath)) { - targetDirName = `${_.kebabCase(answers.name)}-${counter}`; - targetPath = path.resolve(options.path || '.', targetDirName); - counter++; - } - - // Clone the repository - console.log(`Creating new extension in ${targetPath}...`); - await runCommand(`git clone ${starterExtensionRepo} ${targetPath}`); - - // Process the keywords input - const keywordsArray = answers.keywords.split(',').map(keyword => keyword.trim()); - - // Clean up the author name to remove company entities - const cleanedAuthorName = answers.author ? answers.author.replace(/\b(llc|pte ltd|inc|corp|gmbh|limited|ltd)\b/gi, '').trim() : 'fleetbase'; - - // Determine namespace and package names - const authorSlug = _.kebabCase(cleanedAuthorName); - const extensionNameSlug = _.kebabCase(answers.name); - const extensionClassName = _.startCase(answers.name).replace(/\s+/g, '') + 'Engine'; - const defaultNamespace = `Fleetbase\\${_.startCase(answers.namespace || answers.name).replace(/\s+/g, '')}`; - const packageJsonName = `@${authorSlug}/${extensionNameSlug}-engine`; - const composerJsonName = `${authorSlug}/${extensionNameSlug}-api`; - - // Update extension.json, package.json, and composer.json with the prompted details - const extensionJsonPath = path.join(targetPath, 'extension.json'); - const packageJsonPath = path.join(targetPath, 'package.json'); - const composerJsonPath = path.join(targetPath, 'composer.json'); - const engineJsPath = path.join(targetPath, 'addon/engine.js'); - const controllerPath = path.join(targetPath, 'server/src/Http/Controllers/StarterResourceController.php'); - const serviceProviderPath = path.join(targetPath, 'server/src/Providers/StarterServiceProvider.php'); - const configPath = path.join(targetPath, `server/config/starter.php`); - const routesPath = path.join(targetPath, `server/src/routes.php`); - - // Load and update files - await updateJsonFile(extensionJsonPath, { - name: answers.name, - description: answers.description, - repository: answers.repo, - author: `${answers.author} <${answers.email}>`, - }); - - await updateJsonFile(packageJsonPath, { - name: packageJsonName, - description: answers.description, - repository: answers.repo, - author: `${answers.author} <${answers.email}>`, - keywords: keywordsArray, - fleetbase: { - route: extensionNameSlug, - }, - }); - - await updateJsonFile(composerJsonPath, { - name: composerJsonName, - description: answers.description, - authors: [ - { - name: answers.author, - email: answers.email, - }, - ], - keywords: keywordsArray, - autoload: { - 'psr-4': { - [`${defaultNamespace}\\`]: 'server/src/', - [`${defaultNamespace}\\Seeds\\`]: 'server/seeds/', - }, - }, - 'autoload-dev': { - 'psr-4': { - [`${defaultNamespace}\\Tests\\`]: 'server/tests/', - }, - }, - extra: { - laravel: { - providers: [`${defaultNamespace}\\Providers\\${_.startCase(extensionNameSlug).replace(/\s+/g, '')}ServiceProvider`], - }, - }, - }); - - // Modify files as per the provided extension name - await modifyEngineJs(engineJsPath, extensionClassName, answers.name); - await renameAndRefactorFiles(controllerPath, serviceProviderPath, configPath, routesPath, defaultNamespace, extensionNameSlug); - - // Refactor namespaces across all PHP files in the `server/` directory - await refactorNamespaces(path.join(targetPath, 'server'), defaultNamespace); - - console.log('Extension scaffolded successfully!'); - } catch (error) { - console.error(`Error scaffolding extension: ${error.message}`); - } -} - -async function updateJsonFile (filePath, updates) { - if (await fs.pathExists(filePath)) { - const jsonContent = await fs.readJson(filePath); - const updatedContent = { - ...jsonContent, - ...updates, - // Append keywords if they exist in the current JSON - keywords: jsonContent.keywords ? [...jsonContent.keywords, ...(updates.keywords || [])] : updates.keywords, - }; - await fs.writeJson(filePath, updatedContent, { spaces: 4 }); - } -} - -async function modifyEngineJs (filePath, className, displayName) { - if (await fs.pathExists(filePath)) { - let content = await fs.readFile(filePath, 'utf-8'); - content = content.replace(/class\s+StarterEngine/, `class ${className}`); - content = content.replace(/loadInitializers\(StarterEngine, modulePrefix\)/, `loadInitializers(${className}, modulePrefix)`); - content = content.replace( - /universe\.registerHeaderMenuItem\('Starter',\s*'console\.starter'/, - `universe.registerHeaderMenuItem('${displayName}', 'console.${_.kebabCase(className.replace('Engine', ''))}'` - ); - await fs.writeFile(filePath, content, 'utf-8'); - } -} - -async function renameAndRefactorFiles (controllerPath, serviceProviderPath, configPath, routesPath, namespace, extensionSlug) { - // Renaming and refactoring the controller file - if (await fs.pathExists(controllerPath)) { - const newControllerPath = controllerPath.replace('StarterResourceController.php', `${_.upperFirst(_.camelCase(namespace.split('\\').pop()))}ResourceController.php`); - await fs.rename(controllerPath, newControllerPath); - await refactorPhpFile(newControllerPath, namespace); - } - - // Renaming and refactoring the service provider file - if (await fs.pathExists(serviceProviderPath)) { - const newServiceProviderPath = serviceProviderPath.replace('StarterServiceProvider.php', `${_.upperFirst(_.camelCase(namespace.split('\\').pop()))}ServiceProvider.php`); - await fs.rename(serviceProviderPath, newServiceProviderPath); - await refactorPhpFile(newServiceProviderPath, namespace); - } - - // Refactoring the config file and removing the old one - if (await fs.pathExists(configPath)) { - let content = await fs.readFile(configPath, 'utf-8'); - content = content.replace(/'starter'/g, `'${extensionSlug}'`); - const newConfigPath = configPath.replace('starter.php', `${extensionSlug}.php`); - await fs.writeFile(newConfigPath, content, 'utf-8'); - await fs.remove(configPath); // Remove the old config file - } - - // Refactoring the routes file - if (await fs.pathExists(routesPath)) { - let content = await fs.readFile(routesPath, 'utf-8'); - content = content.replace(/config\('starter\./g, `config('${extensionSlug}.`); - content = content.replace(/'Fleetbase\\Starter\\Http\\Controllers'/g, `'${namespace}\\Http\\Controllers'`); - content = content.replace(/Starter API Routes/g, `${_.startCase(extensionSlug)} API Routes`); - await fs.writeFile(routesPath, content, 'utf-8'); - } -} - -async function refactorPhpFile (filePath, namespace) { - if (await fs.pathExists(filePath)) { - let content = await fs.readFile(filePath, 'utf-8'); - // Replace the namespace declaration - content = content.replace(/public string \$namespace = '\\\\Fleetbase\\\\Starter';/g, `public string $namespace = '\\${namespace}';`); - // Replace any other occurrences of the old namespace - content = content.replace(/Fleetbase\\Starter/g, namespace); - // Replace class names if necessary - content = content.replace(/class\s+StarterResourceController/g, `class ${_.upperFirst(_.camelCase(namespace.split('\\').pop()))}ResourceController`); - content = content.replace(/class\s+StarterServiceProvider/g, `class ${_.upperFirst(_.camelCase(namespace.split('\\').pop()))}ServiceProvider`); - await fs.writeFile(filePath, content, 'utf-8'); - } -} - -async function refactorNamespaces (targetDir, newNamespace) { - const phpFiles = await findPhpFiles(targetDir); - - for (const file of phpFiles) { - await refactorPhpFile(file, newNamespace); - } -} - -async function findPhpFiles (dir) { - const ext = '.php'; - const files = await fs.readdir(dir); - const phpFiles = []; - - for (const file of files) { - const fullPath = path.join(dir, file); - const stat = await fs.stat(fullPath); - - if (stat.isDirectory()) { - phpFiles.push(...(await findPhpFiles(fullPath))); - } else if (fullPath.endsWith(ext)) { - phpFiles.push(fullPath); - } - } - - return phpFiles; -} - -function runCommand (command, workingDirectory) { - return new Promise((resolve, reject) => { - exec(command, { cwd: workingDirectory }, (error, stdout, stderr) => { - if (error) { - console.error(`Error: ${error.message}`); - return reject(error); - } - if (stderr) { - console.error(`stderr: ${stderr}`); - } - console.log(`stdout: ${stdout}`); - resolve(stdout); - }); - }); -} - -// Function to bundle the extension -async function bundleExtension (options) { - const extensionPath = options.path || '.'; - const upload = options.upload; - try { - // Check if extension.json exists in the specified directory - const extensionJsonPath = path.join(extensionPath, 'extension.json'); - if (!(await fs.pathExists(extensionJsonPath))) { - console.error(`extension.json not found in ${extensionPath}`); - process.exit(1); - } - // Read extension.json - const extensionJson = await fs.readJson(extensionJsonPath); - const name = extensionJson.name; - const version = extensionJson.version; - - if (!name || !version) { - console.error('Name or version not specified in extension.json'); - process.exit(1); - } - // Build the bundle filename - const nameDasherized = _.kebabCase(name.replace('@', '')); - const bundleFilename = `${nameDasherized}-v${version}-bundle.tar.gz`; - const bundlePath = path.join(extensionPath, bundleFilename); - - // Exclude directories - const excludeDirs = ['node_modules', 'server_vendor']; - - console.log(`Creating bundle ${bundleFilename}...`); - - await tar.c( - { - gzip: true, - file: bundlePath, - cwd: extensionPath, - filter: (filePath, stat) => { - // Exclude specified directories and the bundle file itself - const relativePath = path.relative(extensionPath, filePath); - - // Exclude directories - if (excludeDirs.some(dir => relativePath.startsWith(dir + path.sep))) { - return false; // exclude - } - - // Exclude the bundle file - if (relativePath === bundleFilename) { - return false; // exclude - } - - // Exclude any existing bundle files matching the pattern - if (relativePath.match(/-v\d+\.\d+\.\d+(-[\w\.]+)?-bundle\.tar\.gz$/)) { - return false; // exclude - } - - return true; // include - }, - }, - ['.'] - ); - - console.log(`Bundle created at ${bundlePath}`); - - if (upload) { - // Call upload function with the bundle path - await uploadBundle(bundlePath, options); - } - } catch (error) { - console.error(`Error bundling extension: ${error.message}`); - process.exit(1); - } -} - -// Function to upload the bundle -async function uploadBundle (bundlePath, options) { - const registry = options.registry || defaultRegistry; - const uploadUrl = bundleUploadApi; - - let authToken = options.authToken; - if (!authToken) { - // Try to get auth token from ~/.npmrc - authToken = await getAuthToken(registry); - if (!authToken) { - console.error(`Auth token not found for registry ${registry}. Please provide an auth token using the --auth-token option.`); - process.exit(1); - } - } - - try { - const form = new FormData(); - form.append('bundle', fs.createReadStream(bundlePath)); - - const response = await axios.post(uploadUrl, form, { - headers: { - ...form.getHeaders(), - Authorization: `Bearer ${authToken}`, - }, - maxContentLength: Infinity, - maxBodyLength: Infinity, - }); - - console.log(`Bundle uploaded successfully: ${response.data.message}`); - } catch (error) { - console.log(error.response.data); - console.error(`Error uploading bundle: ${error.response.data?.error ?? error.message}`); - process.exit(1); - } -} - -// Function to get the auth token from .npmrc -async function getAuthToken (registryUrl) { - const npmrcPath = path.join(require('os').homedir(), '.npmrc'); - if (!(await fs.pathExists(npmrcPath))) { - return null; - } - - const npmrcContent = await fs.readFile(npmrcPath, 'utf-8'); - const lines = npmrcContent.split('\n'); - - const registryHost = new URL(registryUrl).host; - - // Look for line matching //registry.fleetbase.io/:_authToken=... - for (const line of lines) { - const match = line.match(new RegExp(`^//${registryHost}/:_authToken=(.*)$`)); - if (match) { - return match[1].replace(/^"|"$/g, ''); // Remove quotes if present - } - } - - return null; -} - -// Function to find the latest bundle -async function findLatestBundle (directory) { - const pattern = '*-v*-bundle.tar.gz'; - const files = await glob(pattern, { cwd: directory }); - if (files.length === 0) { - return null; - } - // Extract version numbers and sort - const bundles = files - .map(file => { - const match = file.match(/-v(\d+\.\d+\.\d+(-[\w\.]+)?)-bundle\.tar\.gz$/); - if (match) { - const version = match[1]; - return { file, version }; - } - return null; - }) - .filter(Boolean); - - if (bundles.length === 0) { - return null; - } - - // Sort by version - bundles.sort((a, b) => semver.compare(b.version, a.version)); - return bundles[0].file; -} - -// Command to handle the upload -async function uploadCommand (bundleFile, options) { - const directory = options.path || '.'; - const registry = options.registry || defaultRegistry; - const authToken = options.authToken; - - if (!bundleFile) { - bundleFile = await findLatestBundle(directory); - if (!bundleFile) { - console.error('No bundle file found in the current directory.'); - process.exit(1); - } - } - - const bundlePath = path.join(directory, bundleFile); - - await uploadBundle(bundlePath, { registry, authToken }); -} - -// Function to bump the version -async function versionBump (options) { - const extensionPath = options.path || '.'; - const releaseType = options.major ? 'major' : options.minor ? 'minor' : options.patch ? 'patch' : 'patch'; - const preRelease = options.preRelease; - - const files = ['extension.json', 'package.json', 'composer.json']; - for (const file of files) { - const filePath = path.join(extensionPath, file); - if (await fs.pathExists(filePath)) { - const content = await fs.readJson(filePath); - if (content.version) { - let newVersion = semver.inc(content.version, releaseType, preRelease); - if (!newVersion) { - console.error(`Invalid version in ${file}: ${content.version}`); - continue; - } - content.version = newVersion; - await fs.writeJson(filePath, content, { spaces: 4 }); - console.log(`Updated ${file} to version ${newVersion}`); - } - } - } -} - -// Command to handle registration -async function registerCommand(options) { - const host = options.host || 'https://api.fleetbase.io'; - // Ensure host has protocol, add https:// if missing - const apiHost = host.startsWith('http://') || host.startsWith('https://') ? host : `https://${host}`; - const registrationApi = `${apiHost}/~registry/v1/developer-account/register`; - - try { - // Collect registration information - const answers = await prompt([ - { - type: 'input', - name: 'username', - message: 'Username:', - initial: options.username, - skip: !!options.username, - validate: (value) => { - if (!value || value.length < 3) { - return 'Username must be at least 3 characters'; - } - if (!/^[a-zA-Z0-9_-]+$/.test(value)) { - return 'Username can only contain letters, numbers, hyphens, and underscores'; - } - return true; - } - }, - { - type: 'input', - name: 'email', - message: 'Email:', - initial: options.email, - skip: !!options.email, - validate: (value) => { - if (!value || !value.includes('@')) { - return 'Please enter a valid email address'; - } - return true; - } - }, - { - type: 'password', - name: 'password', - message: 'Password:', - skip: !!options.password, - validate: (value) => { - if (!value || value.length < 8) { - return 'Password must be at least 8 characters'; - } - return true; - } - }, - { - type: 'input', - name: 'name', - message: 'Full Name (optional):', - initial: options.name - } - ]); - - const registrationData = { - username: options.username || answers.username, - email: options.email || answers.email, - password: options.password || answers.password, - name: options.name || answers.name || undefined - }; - - console.log('\nRegistering account...'); - - // Make API call to register - const response = await axios.post(registrationApi, registrationData); - - if (response.data.status === 'success') { - console.log('\n✓ Account created successfully!'); - console.log('✓ A verification code has been sent to your email.'); - console.log('\n👉 Next step: Verify your email address'); - const verifyCmd = `flb verify -e ${registrationData.email}` + (host !== 'https://api.fleetbase.io' ? ` --host ${host}` : ''); - console.log(` Run: ${verifyCmd}`); - const loginCmd = `flb login -u ${registrationData.username}` + (host !== 'https://api.fleetbase.io' ? ` --host ${host}` : ''); - console.log(`\n✓ After verification, login with: ${loginCmd}`); - } else { - console.error('Registration failed:', response.data.message || 'Unknown error'); - process.exit(1); - } - } catch (error) { - if (error.response && error.response.data) { - const errorData = error.response.data; - if (errorData.errors) { - console.error('\nRegistration failed with the following errors:'); - Object.keys(errorData.errors).forEach(field => { - const fieldErrors = errorData.errors[field]; - // Handle both array and string error formats - if (Array.isArray(fieldErrors)) { - fieldErrors.forEach(message => { - console.error(` - ${field}: ${message}`); - }); - } else { - console.error(` - ${field}: ${fieldErrors}`); - } - }); - } else { - console.error('Registration failed:', errorData.message || 'Unknown error'); - } - } else { - console.error('Registration failed:', error.message); - } - process.exit(1); - } -} - -// Command to verify developer account email -async function verifyCommand(options) { - console.log('\n📧 Verify Your Registry Developer Account\n'); - - try { - let email = options.email; - let code = options.code; - const host = options.host || 'https://api.fleetbase.io'; - - // Only prompt if values not provided - if (!email || !code) { - const answers = await prompt([ - { - type: 'input', - name: 'email', - message: 'Email address:', - initial: email, - skip: () => !!email, - validate: (value) => value ? true : 'Email is required' - }, - { - type: 'input', - name: 'code', - message: 'Verification code (from email):', - initial: code, - skip: () => !!code, - validate: (value) => value ? true : 'Verification code is required' - } - ]); - email = email || answers.email; - code = code || answers.code; - } - - // Ensure host has protocol - const apiHost = host.startsWith('http://') || host.startsWith('https://') - ? host - : `https://${host}`; - const verificationApi = `${apiHost}/~registry/v1/developer-account/verify`; - - console.log('\nVerifying account...'); - - // Make API call to verify - const response = await axios.post(verificationApi, { - email: email, - code: code - }); - - if (response.data.status === 'success') { - console.log('\n✓ Email verified successfully!'); - - // Display registry token if provided - if (response.data.token) { - console.log('\n🔑 Your Registry Token:'); - console.log(` ${response.data.token}`); - console.log('\n💡 Save this token securely! You\'ll need it to authenticate with the registry.'); - console.log(' Use: flb set-auth ' + response.data.token + (host !== 'https://api.fleetbase.io' ? ` --registry ${host}` : '')); - } - - console.log('\n✓ You can now login with: flb login -u ' + (host !== 'https://api.fleetbase.io' ? ` --host ${host}` : '')); - } else { - console.error('\nVerification failed:', response.data.message || 'Unknown error'); - process.exit(1); - } - } catch (error) { - if (error.response) { - const errorData = error.response.data; - - // Handle different error response formats - let errorMessage = 'Unknown error'; - if (errorData.message) { - errorMessage = errorData.message; - } else if (errorData.error) { - errorMessage = errorData.error; - } else if (errorData.errors && Array.isArray(errorData.errors)) { - errorMessage = errorData.errors.join(', '); - } - - console.error('\nVerification failed:', errorMessage); - } else if (error.request) { - console.error('\nVerification failed: No response from server'); - } else { - console.error('\nVerification failed:', error.message); - } - process.exit(1); - } -} - -// Command to generate or regenerate registry token -async function generateTokenCommand(options) { - console.log('\n🔑 Generate Registry Token\n'); - - try { - let email = options.email; - let password = options.password; - const host = options.host || 'https://api.fleetbase.io'; - - // Prompt for credentials if not provided - if (!email || !password) { - const answers = await prompt([ - { - type: 'input', - name: 'email', - message: 'Email address:', - initial: email, - skip: () => !!email, - validate: (value) => value ? true : 'Email is required' - }, - { - type: 'password', - name: 'password', - message: 'Password:', - skip: () => !!password, - validate: (value) => value ? true : 'Password is required' - } - ]); - email = email || answers.email; - password = password || answers.password; - } - - // Ensure host has protocol - const apiHost = host.startsWith('http://') || host.startsWith('https://') - ? host - : `https://${host}`; - const generateTokenApi = `${apiHost}/~registry/v1/developer-account/generate-token`; - - console.log('\nGenerating token...'); - - // Make API call to generate token - const response = await axios.post(generateTokenApi, { - email: email, - password: password - }); - - if (response.data.status === 'success') { - console.log('\n✓ ' + response.data.message); - console.log('\n🔑 Your Registry Token:'); - console.log(` ${response.data.token}`); - console.log('\n💡 Save this token securely! You\'ll need it to authenticate with the registry.'); - console.log(' Use: flb set-auth ' + response.data.token + (host !== 'https://api.fleetbase.io' ? ` --registry ${host}` : '')); - console.log('\n⚠️ Note: This replaces any previously generated token.'); - } else { - console.error('\nToken generation failed:', response.data.message || 'Unknown error'); - process.exit(1); - } - } catch (error) { - if (error.response) { - const errorData = error.response.data; - - // Handle different error response formats - let errorMessage = 'Unknown error'; - if (errorData.message) { - errorMessage = errorData.message; - } else if (errorData.error) { - errorMessage = errorData.error; - } else if (errorData.errors && Array.isArray(errorData.errors)) { - errorMessage = errorData.errors.join(', '); - } - - console.error('\nToken generation failed:', errorMessage); - } else if (error.request) { - console.error('\nToken generation failed: No response from server'); - } else { - console.error('\nToken generation failed:', error.message); - } - process.exit(1); - } -} - -// Command to resend verification code -async function resendVerificationCommand(options) { - console.log('\n📧 Resend Verification Code\n'); - - try { - let email = options.email; - const host = options.host || 'https://api.fleetbase.io'; - - // Prompt for email if not provided - if (!email) { - const answers = await prompt([ - { - type: 'input', - name: 'email', - message: 'Email address:', - validate: (value) => value ? true : 'Email is required' - } - ]); - email = answers.email; - } - - // Ensure host has protocol - const apiHost = host.startsWith('http://') || host.startsWith('https://') - ? host - : `https://${host}`; - const resendApi = `${apiHost}/~registry/v1/developer-account/resend-verification`; - - console.log('\nResending verification code...'); - - // Make API call to resend - const response = await axios.post(resendApi, { - email: email - }); - - if (response.data.status === 'success') { - console.log('\n✓ Verification code sent!'); - console.log('✓ Check your email for the new verification code.'); - console.log('\n👉 Once you receive it, run:'); - console.log(` flb verify -e ${email}` + (host !== 'https://api.fleetbase.io' ? ` --host ${host}` : '')); - } else { - console.error('\nFailed to resend:', response.data.message || 'Unknown error'); - process.exit(1); - } - } catch (error) { - if (error.response) { - const errorData = error.response.data; - - // Handle different error response formats - let errorMessage = 'Unknown error'; - if (errorData.message) { - errorMessage = errorData.message; - } else if (errorData.error) { - errorMessage = errorData.error; - } else if (errorData.errors && Array.isArray(errorData.errors)) { - errorMessage = errorData.errors.join(', '); - } - - console.error('\nFailed to resend:', errorMessage); - } else if (error.request) { - console.error('\nFailed to resend: No response from server'); - } else { - console.error('\nFailed to resend:', error.message); - } - process.exit(1); - } -} - -// ─── Helpers for install wizard ─────────────────────────────────────────────── - -/** - * Check whether a TCP port is available on the local machine. - * @param {number} port - * @returns {Promise} - */ -function isPortAvailable(port) { - return new Promise((resolve) => { - const net = require('net'); - const server = net.createServer(); - server.once('error', () => resolve(false)); - server.once('listening', () => { server.close(); resolve(true); }); - server.listen(port, '127.0.0.1'); - }); -} - -/** - * Promisified exec helper. - * @param {string} cmd - * @returns {Promise} - */ -function execAsync(cmd) { - return new Promise((resolve, reject) => { - exec(cmd, (err, stdout) => { - if (err) reject(err); - else resolve(stdout.trim()); - }); - }); -} - -/** - * Build a YAML-safe environment block from a plain object, - * omitting keys whose value is null, undefined, or empty string. - * @param {Record} vars - * @param {number} indent number of spaces to indent each line - * @returns {string} - */ -function buildEnvBlock(vars, indent = 6) { - const pad = ' '.repeat(indent); - return Object.entries(vars) - .filter(([, v]) => v !== null && v !== undefined && v !== '') - .map(([k, v]) => `${pad}${k}: ${JSON.stringify(String(v))}`) - .join('\n'); -} - -/** - * Make sure api/.env exists before `docker compose up`. - * docker-compose.yml bind-mounts ./api/.env into the application container; when the - * file is missing Docker creates a *directory* at that path and Laravel cannot boot. - * Values set in docker-compose.override.yml take precedence over this file, so an - * empty file with a comment header is the correct default. - * @param {string} directory resolved installation directory - */ -async function ensureApiEnvFile(directory) { - const envPath = path.join(directory, 'api', '.env'); - if (await fs.pathExists(envPath)) { - const stat = await fs.stat(envPath); - if (stat.isDirectory()) { - console.error(`\n✖ ${envPath} is a directory (left behind by an earlier "docker compose up" before the file existed).`); - console.error(' Remove it and re-run the installer.'); - process.exit(1); - } - console.log('✔ api/.env already present'); - return; - } - await fs.ensureDir(path.dirname(envPath)); - await fs.writeFile(envPath, [ - '# Fleetbase API environment overrides.', - '# Runtime configuration comes from docker-compose.override.yml and takes precedence', - '# over this file; add per-host secrets or extra overrides here.', - '', - ].join('\n')); - console.log('✔ api/.env created'); -} - -// Command to install Fleetbase via Docker -async function installFleetbaseCommand(options) { - const crypto = require('crypto'); - - // ── Step 0: Pre-flight checks ──────────────────────────────────────────── - console.log('\n🚀 Fleetbase Installation Wizard\n'); - console.log('⏳ Running pre-flight checks...'); - - // Check required tools - const requiredTools = [ - { cmd: 'docker --version', name: 'Docker' }, - { cmd: 'docker compose version', name: 'Docker Compose v2' }, - { cmd: 'git --version', name: 'Git' }, - ]; - for (const tool of requiredTools) { - try { - await execAsync(tool.cmd); - console.log(` ✔ ${tool.name} found`); - } catch { - console.error(`\n ✖ ${tool.name} is not installed or not in PATH.`); - console.error(` Please install it and retry.`); - process.exit(1); - } - } - - // Check required ports - const requiredPorts = [ - { port: 8000, label: 'API (8000)' }, - { port: 4200, label: 'Console (4200)' }, - { port: 3306, label: 'MySQL (3306)' }, - { port: 38000, label: 'SocketCluster (38000)' }, - ]; - for (const { port, label } of requiredPorts) { - const available = await isPortAvailable(port); - if (!available) { - console.warn(` ⚠ Port ${label} is already in use — this may cause a conflict.`); - } else { - console.log(` ✔ Port ${label} is free`); - } - } - - console.log('✔ Pre-flight checks complete\n'); - - try { - const nonInteractive = !!options.nonInteractive; - if (nonInteractive) { - console.log(' ℹ Non-interactive mode: no prompts; flags and safe defaults are used.'); - } - - // ── Step 1: Core installation parameters ──────────────────────────── - // Without a TTY (CI, piped stdin) enquirer would block or throw, so in - // non-interactive mode every core value comes from a flag or its default. - const coreAnswers = nonInteractive ? {} : await prompt([ - { - type: 'input', - name: 'host', - message: 'Host or IP address to bind to:', - initial: options.host || 'localhost', - validate: (v) => v ? true : 'Host is required', - }, - { - type: 'select', - name: 'environment', - message: 'Environment:', - initial: options.environment === 'production' ? 1 : 0, - choices: [ - { title: 'Development', value: 'development' }, - { title: 'Production', value: 'production' }, - ], - }, - { - type: 'input', - name: 'directory', - message: 'Installation directory:', - initial: options.directory || process.cwd(), - validate: (v) => v ? true : 'Directory is required', - }, - { - type: 'input', - name: 'appName', - message: 'Application name:', - initial: options.appName || 'Fleetbase', - }, - ]); - - const host = options.host || coreAnswers.host || 'localhost'; - const environment = options.environment || coreAnswers.environment || 'development'; - const appName = options.appName || coreAnswers.appName || 'Fleetbase'; - // Resolve so relative paths (and Git Bash style paths on Windows) work for both - // the file writes below and the `cwd` handed to docker compose. - const directory = path.resolve(options.directory || coreAnswers.directory || process.cwd()); - - if (!['development', 'production'].includes(environment)) { - console.error(`\n✖ Invalid environment "${environment}". Use "development" or "production".`); - process.exit(1); - } - - const useHttps = environment === 'production'; - const appDebug = environment !== 'production'; - const scSecure = useHttps; - const schemeApi = useHttps ? 'https' : 'http'; - const schemeConsole = useHttps ? 'https' : 'http'; - const isLocalhost = host === 'localhost' || host === '0.0.0.0' || host === '127.0.0.1'; - - // ── Step 2: Clone repo if needed ───────────────────────────────────── - const dockerComposePath = path.join(directory, 'docker-compose.yml'); - if (!await fs.pathExists(dockerComposePath)) { - console.log('\n⏳ Fleetbase repository not found — cloning...'); - await fs.ensureDir(directory); - const { execSync } = require('child_process'); - try { - execSync('git clone https://github.com/fleetbase/fleetbase.git .', { - cwd: directory, - stdio: 'inherit', - }); - console.log('✔ Repository cloned'); - } catch (err) { - console.error('\n✖ Failed to clone repository:', err.message); - console.log(' git clone https://github.com/fleetbase/fleetbase.git'); - process.exit(1); - } - } - - // ── Step 3: Database configuration ─────────────────────────────────── - console.log('\n── Database Configuration ──────────────────────────────────────'); - const dbModeAnswer = nonInteractive ? { mode: 'internal' } : await prompt({ - type: 'select', - name: 'mode', - message: 'Database:', - choices: [ - { title: 'Bundled Docker MySQL (recommended for development)', value: 'internal' }, - { title: 'External MySQL server (e.g. AWS RDS, PlanetScale)', value: 'external' }, - ], - }); - - let dbConfig = {}; - if (dbModeAnswer.mode === 'external') { - const extDb = await prompt([ - { type: 'input', name: 'dbHost', message: 'Database host:', initial: '127.0.0.1' }, - { type: 'input', name: 'dbPort', message: 'Database port:', initial: '3306' }, - { type: 'input', name: 'dbDatabase', message: 'Database name:', initial: 'fleetbase' }, - { type: 'input', name: 'dbUsername', message: 'Database username:' }, - { type: 'password', name: 'dbPassword', message: 'Database password:' }, - ]); - dbConfig = { - mode: 'external', - databaseUrl: `mysql://${encodeURIComponent(extDb.dbUsername)}:${encodeURIComponent(extDb.dbPassword)}@${extDb.dbHost}:${extDb.dbPort}/${extDb.dbDatabase}`, - }; - console.log('✔ External database configured'); - } else { - const rootPassword = crypto.randomBytes(20).toString('hex'); - const userPassword = crypto.randomBytes(20).toString('hex'); - dbConfig = { - mode: 'internal', - rootPassword, - dbUsername: 'fleetbase', - dbPassword: userPassword, - dbDatabase: 'fleetbase', - databaseUrl: `mysql://fleetbase:${userPassword}@database/fleetbase`, - }; - console.log('✔ Secure database credentials auto-generated'); - } - - // ── Step 4: Mail configuration ──────────────────────────────────────── - console.log('\n── Mail Configuration ──────────────────────────────────────────'); - const mailSetup = nonInteractive ? { configure: false } : await prompt({ - type: 'confirm', - name: 'configure', - message: 'Configure a mail server? (required for password resets & notifications)', - initial: true, - }); - - let mailConfig = { mailMailer: 'log' }; // safe default - if (mailSetup.configure) { - const mailerChoice = await prompt({ - type: 'select', - name: 'mailer', - message: 'Mail driver:', - choices: [ - { title: 'SMTP', value: 'smtp' }, - { title: 'Mailgun', value: 'mailgun' }, - { title: 'Postmark', value: 'postmark' }, - { title: 'SendGrid', value: 'sendgrid' }, - { title: 'Resend', value: 'resend' }, - { title: 'AWS SES', value: 'ses' }, - { title: 'Log only (development)', value: 'log' }, - ], - }); - - const fromDefaults = await prompt([ - { type: 'input', name: 'mailFromAddress', message: 'From address:', initial: `hello@${isLocalhost ? 'example.com' : host}` }, - { type: 'input', name: 'mailFromName', message: 'From name:', initial: appName }, - ]); - - mailConfig = { mailMailer: mailerChoice.mailer, ...fromDefaults }; - - if (mailerChoice.mailer === 'smtp') { - const smtpDetails = await prompt([ - { type: 'input', name: 'mailHost', message: 'SMTP host:', initial: 'smtp.mailgun.org' }, - { type: 'input', name: 'mailPort', message: 'SMTP port:', initial: '587' }, - { type: 'input', name: 'mailUsername', message: 'SMTP username:' }, - { type: 'password', name: 'mailPassword', message: 'SMTP password:' }, - ]); - mailConfig = { ...mailConfig, ...smtpDetails }; - } else if (mailerChoice.mailer === 'mailgun') { - const mgDetails = await prompt([ - { type: 'input', name: 'mailgunDomain', message: 'Mailgun domain:' }, - { type: 'password', name: 'mailgunSecret', message: 'Mailgun API secret:' }, - ]); - mailConfig = { ...mailConfig, ...mgDetails }; - } else if (mailerChoice.mailer === 'postmark') { - const pmDetails = await prompt([ - { type: 'password', name: 'postmarkToken', message: 'Postmark server token:' }, - ]); - mailConfig = { ...mailConfig, ...pmDetails }; - } else if (mailerChoice.mailer === 'sendgrid') { - const sgDetails = await prompt([ - { type: 'password', name: 'sendgridApiKey', message: 'SendGrid API key:' }, - ]); - mailConfig = { ...mailConfig, ...sgDetails }; - } else if (mailerChoice.mailer === 'resend') { - const rsDetails = await prompt([ - { type: 'password', name: 'resendKey', message: 'Resend API key:' }, - ]); - mailConfig = { ...mailConfig, ...rsDetails }; - } else if (mailerChoice.mailer === 'ses') { - console.log(' ℹ AWS SES uses the AWS credentials configured in the Storage step.'); - } - console.log(`✔ Mail driver set to: ${mailerChoice.mailer}`); - } else { - console.log(' ℹ Skipped — emails will be written to the application log.'); - } - - // ── Step 5: File storage ────────────────────────────────────────────── - console.log('\n── File Storage Configuration ──────────────────────────────────'); - const storageChoice = nonInteractive ? { driver: 'public' } : await prompt({ - type: 'select', - name: 'driver', - message: 'File storage driver:', - choices: [ - { title: 'Local disk (development only — files lost on container rebuild)', value: 'public' }, - { title: 'AWS S3 (recommended for production)', value: 's3' }, - { title: 'Google Cloud Storage', value: 'gcs' }, - ], - }); - - let storageConfig = { filesystemDriver: storageChoice.driver }; - if (storageChoice.driver === 's3') { - const s3Details = await prompt([ - { type: 'input', name: 'awsAccessKeyId', message: 'AWS Access Key ID:' }, - { type: 'password', name: 'awsSecretAccessKey', message: 'AWS Secret Access Key:' }, - { type: 'input', name: 'awsDefaultRegion', message: 'AWS Region:', initial: 'us-east-1' }, - { type: 'input', name: 'awsBucket', message: 'S3 Bucket name:' }, - { type: 'input', name: 'awsUrl', message: 'S3 Public URL (leave blank for default):' }, - { type: 'confirm', name: 'awsUsePathStyleEndpoint', message: 'Use path-style endpoint? (for MinIO / non-AWS S3)', initial: false }, - ]); - storageConfig = { ...storageConfig, ...s3Details }; - console.log('✔ S3 storage configured'); - } else if (storageChoice.driver === 'gcs') { - const gcsDetails = await prompt([ - { type: 'input', name: 'googleCloudProjectId', message: 'GCS Project ID:' }, - { type: 'input', name: 'googleCloudStorageBucket', message: 'GCS Bucket name:' }, - { type: 'input', name: 'googleCloudKeyFile', message: 'Path to GCS key file (JSON):' }, - ]); - storageConfig = { ...storageConfig, ...gcsDetails }; - console.log('✔ Google Cloud Storage configured'); - } else { - console.log(' ℹ Local disk selected — suitable for development only.'); - } - - // ── Step 6: Security & CORS ─────────────────────────────────────────── - console.log('\n── Security & CORS Configuration ───────────────────────────────'); - - // Derive SESSION_DOMAIN from host - const sessionDomain = isLocalhost ? 'localhost' : host; - - // Derive SOCKETCLUSTER_OPTIONS origins from host - const socketOrigins = isLocalhost - ? 'http://localhost:*,https://localhost:*,ws://localhost:*,wss://localhost:*' - : `${schemeConsole}://${host}:*,wss://${host}:*`; - const socketClusterOptions = JSON.stringify({ origins: socketOrigins }); - console.log(`✔ SESSION_DOMAIN set to: ${sessionDomain}`); - console.log(`✔ WebSocket origins restricted to: ${socketOrigins}`); - - // Optional additional frontend hosts - const frontendHostsAnswer = nonInteractive ? { frontendHosts: '' } : await prompt({ - type: 'input', - name: 'frontendHosts', - message: 'Additional frontend hosts for CORS (comma-separated, leave blank for none):', - initial: '', - }); - const frontendHosts = frontendHostsAnswer.frontendHosts || ''; - - // ── Step 7: Optional third-party API keys ───────────────────────────── - console.log('\n── Optional Third-Party Services ───────────────────────────────'); - const thirdPartySetup = nonInteractive ? { configure: false } : await prompt({ - type: 'confirm', - name: 'configure', - message: 'Configure optional third-party API keys now? (Maps, Geolocation, SMS)', - initial: false, - }); - - let thirdPartyConfig = {}; - if (thirdPartySetup.configure) { - thirdPartyConfig = await prompt([ - { type: 'input', name: 'ipinfoApiKey', message: 'IPInfo API key (geolocation, leave blank to skip):', initial: '' }, - { type: 'input', name: 'googleMapsApiKey', message: 'Google Maps API key (leave blank to skip):', initial: '' }, - { type: 'input', name: 'googleMapsLocale', message: 'Google Maps locale:', initial: 'us' }, - { type: 'input', name: 'twilioSid', message: 'Twilio Account SID (SMS, leave blank to skip):', initial: '' }, - { type: 'password', name: 'twilioToken', message: 'Twilio Auth Token:', initial: '' }, - { type: 'input', name: 'twilioFrom', message: 'Twilio From phone number:', initial: '' }, - ]); - console.log('✔ Third-party services configured'); - } else { - console.log(' ℹ Skipped — these can be added later via docker-compose.override.yml'); - } - - // ── Step 8: Generate APP_KEY ────────────────────────────────────────── - console.log('\n⏳ Generating APP_KEY...'); - const appKey = 'base64:' + crypto.randomBytes(32).toString('base64'); - console.log('✔ APP_KEY generated'); - - // ── Step 9: Write docker-compose.override.yml ───────────────────────── - console.log('⏳ Writing docker-compose.override.yml...'); - - // Build the application environment block - const appEnvVars = { - APP_KEY: appKey, - APP_NAME: appName, - APP_URL: `${schemeApi}://${host}:8000`, - CONSOLE_HOST: `${schemeConsole}://${host}:4200`, - ENVIRONMENT: environment, - APP_DEBUG: String(appDebug), - DATABASE_URL: dbConfig.databaseUrl, - SESSION_DOMAIN: sessionDomain, - FRONTEND_HOSTS: frontendHosts || null, - // Mail - MAIL_MAILER: mailConfig.mailMailer, - MAIL_HOST: mailConfig.mailHost || null, - MAIL_PORT: mailConfig.mailPort || null, - MAIL_USERNAME: mailConfig.mailUsername || null, - MAIL_PASSWORD: mailConfig.mailPassword || null, - MAIL_FROM_ADDRESS: mailConfig.mailFromAddress || null, - MAIL_FROM_NAME: mailConfig.mailFromName || null, - MAILGUN_DOMAIN: mailConfig.mailgunDomain || null, - MAILGUN_SECRET: mailConfig.mailgunSecret || null, - POSTMARK_TOKEN: mailConfig.postmarkToken || null, - SENDGRID_API_KEY: mailConfig.sendgridApiKey || null, - RESEND_KEY: mailConfig.resendKey || null, - // Storage - FILESYSTEM_DRIVER: storageConfig.filesystemDriver !== 'public' ? storageConfig.filesystemDriver : null, - AWS_ACCESS_KEY_ID: storageConfig.awsAccessKeyId || null, - AWS_SECRET_ACCESS_KEY: storageConfig.awsSecretAccessKey || null, - AWS_DEFAULT_REGION: storageConfig.awsDefaultRegion || null, - AWS_BUCKET: storageConfig.awsBucket || null, - AWS_URL: storageConfig.awsUrl || null, - AWS_USE_PATH_STYLE_ENDPOINT: storageConfig.awsUsePathStyleEndpoint ? 'true' : null, - GOOGLE_CLOUD_PROJECT_ID: storageConfig.googleCloudProjectId || null, - GOOGLE_CLOUD_STORAGE_BUCKET: storageConfig.googleCloudStorageBucket || null, - GOOGLE_CLOUD_KEY_FILE: storageConfig.googleCloudKeyFile || null, - // Third-party - IPINFO_API_KEY: thirdPartyConfig.ipinfoApiKey || null, - GOOGLE_MAPS_API_KEY: thirdPartyConfig.googleMapsApiKey || null, - GOOGLE_MAPS_LOCALE: thirdPartyConfig.googleMapsLocale || null, - TWILIO_SID: thirdPartyConfig.twilioSid || null, - TWILIO_TOKEN: thirdPartyConfig.twilioToken || null, - TWILIO_FROM: thirdPartyConfig.twilioFrom || null, - }; - - // Build the socket environment block - const socketEnvVars = { - SOCKETCLUSTER_OPTIONS: socketClusterOptions, - }; - - // Build the override file content - let overrideContent = `services: - application: - environment: -${buildEnvBlock(appEnvVars)} - - socket: - environment: -${buildEnvBlock(socketEnvVars)} -`; - - // Add database service block only when using internal container - if (dbConfig.mode === 'internal') { - const dbEnvVars = { - MYSQL_ROOT_PASSWORD: dbConfig.rootPassword, - MYSQL_DATABASE: dbConfig.dbDatabase, - MYSQL_USER: dbConfig.dbUsername, - MYSQL_PASSWORD: dbConfig.dbPassword, - MYSQL_ALLOW_EMPTY_PASSWORD: 'no', - }; - overrideContent += ` - database: - environment: -${buildEnvBlock(dbEnvVars)} -`; - } - - // Back up existing override if present - const overridePath = path.join(directory, 'docker-compose.override.yml'); - if (await fs.pathExists(overridePath)) { - const backupPath = `${overridePath}.bak.${Date.now()}`; - await fs.copy(overridePath, backupPath); - console.log(` ℹ Existing override backed up to ${path.basename(backupPath)}`); - } - await fs.writeFile(overridePath, overrideContent); - console.log('✔ docker-compose.override.yml written'); - - // ── Step 10: Write console config files ─────────────────────────────── - console.log('⏳ Updating console configuration files...'); - const configDir = path.join(directory, 'console'); - await fs.ensureDir(configDir); - - const configContent = { - API_HOST: `${schemeApi}://${host}:8000`, - SOCKETCLUSTER_HOST: host, - SOCKETCLUSTER_PORT: '38000', - SOCKETCLUSTER_SECURE: scSecure, - }; - await fs.writeJson(path.join(configDir, 'fleetbase.config.json'), configContent, { spaces: 2 }); - - const environmentsDir = path.join(configDir, 'environments'); - await fs.ensureDir(environmentsDir); - - const osrmHost = thirdPartyConfig.osrmHost || 'https://router.project-osrm.org'; - - await fs.writeFile(path.join(environmentsDir, '.env.development'), [ - `API_HOST=http://${host}:8000`, - `API_NAMESPACE=int/v1`, - `SOCKETCLUSTER_PATH=/socketcluster/`, - `SOCKETCLUSTER_HOST=${host}`, - `SOCKETCLUSTER_SECURE=false`, - `SOCKETCLUSTER_PORT=38000`, - `OSRM_HOST=${osrmHost}`, - '', - ].join('\n')); - - await fs.writeFile(path.join(environmentsDir, '.env.production'), [ - `API_HOST=https://${host}:8000`, - `API_NAMESPACE=int/v1`, - `API_SECURE=true`, - `SOCKETCLUSTER_PATH=/socketcluster/`, - `SOCKETCLUSTER_HOST=${host}`, - `SOCKETCLUSTER_SECURE=true`, - `SOCKETCLUSTER_PORT=38000`, - `OSRM_HOST=${osrmHost}`, - '', - ].join('\n')); - - console.log('✔ Console configuration files updated'); - - // ── Step 10b: Ensure api/.env exists (bind-mounted by docker-compose.yml) ── - await ensureApiEnvFile(directory); - - // ── Step 11: Start containers ───────────────────────────────────────── - console.log('\n⏳ Starting Fleetbase containers...'); - console.log(' This may take a few minutes on first run...\n'); - - exec('docker compose up -d', { cwd: directory, maxBuffer: maxBuffer }, async (error, stdout, stderr) => { - if (error) { - console.error(`\n✖ Error starting containers: ${error.message}`); - if (stderr) console.error(stderr); - process.exit(1); - } - - console.log(stdout); - console.log('✔ Containers started'); - - // Wait for database to be healthy - console.log('\n⏳ Waiting for database to be ready...'); - const dbService = 'database'; - const dbWaitTimeout = 60; - let elapsed = 0; - let dbReady = false; - while (elapsed < dbWaitTimeout) { - try { - const result = await execAsync( - `docker compose exec -T ${dbService} sh -c "mysqladmin --silent --wait=1 -uroot -h127.0.0.1 ping"` - ); - if (result !== undefined) { dbReady = true; break; } - } catch { /* not ready yet */ } - await new Promise(r => setTimeout(r, 3000)); - elapsed += 3; - } - if (!dbReady) { - console.warn(' ⚠ Database readiness check timed out — proceeding anyway.'); - } else { - console.log('✔ Database is ready'); - } - - // Run deploy script - console.log('\n⏳ Running deployment script...'); - exec('docker compose exec -T application bash -c "./deploy.sh"', { cwd: directory, maxBuffer: maxBuffer }, (deployError, deployStdout, deployStderr) => { - if (deployError) { - console.error(`\n✖ Error during deployment: ${deployError.message}`); - if (deployStderr) console.error(deployStderr); - console.log('\n To run manually:'); - console.log(' docker compose exec application bash -c "./deploy.sh"'); - } else { - console.log(deployStdout); - console.log('✔ Deployment complete'); - } - - // Restart to apply all configuration - exec('docker compose up -d', { cwd: directory }, () => { - // ── Step 12: Post-install summary ───────────────────────── - const configuredItems = [ - dbModeAnswer.mode === 'external' ? 'External Database' : 'Bundled MySQL (secure credentials)', - mailSetup.configure ? `Mail (${mailConfig.mailMailer})` : null, - storageChoice.driver !== 'public' ? `Storage (${storageChoice.driver.toUpperCase()})` : null, - 'WebSocket security (origins restricted)', - thirdPartySetup.configure ? 'Third-party APIs' : null, - ].filter(Boolean); - - const skippedItems = [ - !mailSetup.configure ? 'Mail (using log driver)' : null, - storageChoice.driver === 'public' ? 'File storage (using local disk)' : null, - !thirdPartySetup.configure ? 'Third-party APIs (Maps, Geolocation, SMS)' : null, - ].filter(Boolean); - - console.log('\n' + '═'.repeat(60)); - console.log(' 🏁 Fleetbase Installation Complete'); - console.log('═'.repeat(60)); - console.log(`\n 📍 Endpoints`); - console.log(` API → ${schemeApi}://${host}:8000`); - console.log(` Console → ${schemeConsole}://${host}:4200`); - if (configuredItems.length) { - console.log(`\n ✔ Configured:`); - configuredItems.forEach(i => console.log(` • ${i}`)); - } - if (skippedItems.length) { - console.log(`\n ⚠ Skipped (defaults applied):`); - skippedItems.forEach(i => console.log(` • ${i}`)); - } - console.log(`\n 🔐 Next Steps`); - console.log(` 1. Open the Console URL in your browser.`); - console.log(` 2. Complete the onboarding wizard to create your`); - console.log(` initial organization and administrator account.`); - if (skippedItems.length) { - console.log(` 3. To configure skipped options, edit`); - console.log(` docker-compose.override.yml and run:`); - console.log(` docker compose up -d`); - } - console.log(`\n 📄 Config saved to: docker-compose.override.yml`); - console.log('═'.repeat(60) + '\n'); - }); - }); - }); - } catch (error) { - console.error('\n✖ Installation failed:', error.message); - process.exit(1); - } -} - - - -function loginCommand (options) { - const npmLogin = require('npm-cli-login'); - const username = options.username; - const password = options.password; - const email = options.email; - const registry = options.registry || defaultRegistry; - const scope = options.scope || ''; - const quotes = options.quotes || ''; - const configPath = options.configPath || ''; - - if (!username || !password || !email) { - console.error('Username, password, and email are required for login.'); - process.exit(1); - } - - try { - npmLogin(username, password, email, registry, scope, quotes, configPath); - console.log(`Logged in to registry ${registry}`); - } catch (error) { - console.error(`Error during login: ${error.message}`); - process.exit(1); - } -} - -// Helper: ANSI colour utilities (chalk v5 is ESM-only; use raw ANSI codes in this CJS file) -const ansi = { - reset: '\x1b[0m', - bold: '\x1b[1m', - dim: '\x1b[2m', - green: '\x1b[32m', - yellow: '\x1b[33m', - cyan: '\x1b[36m', - white: '\x1b[37m', - brightWhite: '\x1b[97m', - colorize: (code, text) => `${code}${text}\x1b[0m`, -}; - -// Helper: format extension list for terminal display -function displayExtensionsTable(extensions) { - const count = extensions.length; - console.log(ansi.colorize(ansi.bold + ansi.brightWhite, `Found ${count} extension${count !== 1 ? 's' : ''}:\n`)); - - extensions.forEach((ext, index) => { - const rawPrice = ext.on_sale ? ext.sale_price : ext.price; - const formattedPrice = (rawPrice / 100).toFixed(2); - const price = ext.payment_required - ? ansi.colorize(ansi.yellow, `$${formattedPrice} ${(ext.currency || 'USD').toUpperCase()}`) - : ansi.colorize(ansi.green, 'Free'); - - const installs = ansi.colorize(ansi.dim, `\u2193 ${ext.installs_count ?? 0}`); - const category = ext.category?.name - ? ansi.colorize(ansi.cyan, `[${ext.category.name}]`) - : ''; - const version = ansi.colorize(ansi.dim, `v${ext.version || '?'}`); - const publisher = ext.publisher?.name - ? ansi.colorize(ansi.dim, `by ${ext.publisher.name}`) - : ''; - - console.log(`${ansi.colorize(ansi.bold + ansi.brightWhite, ext.name)} ${version} ${price} ${installs} ${category}`); - console.log(` ${ansi.colorize(ansi.dim, ext.slug)} ${publisher}`); - if (ext.subtitle) { - console.log(` ${ext.subtitle}`); - } - const installSlug = `fleetbase/${ext.slug}`; - console.log(` ${ansi.colorize(ansi.dim, 'Install:')} flb install ${installSlug} ${ansi.colorize(ansi.dim, `or flb install ${ext.id}`)}`); - - if (index < extensions.length - 1) { - console.log(''); - } - }); - - console.log(ansi.colorize(ansi.dim, '\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500')); - console.log(ansi.colorize(ansi.dim, `Use ${ansi.colorize(ansi.white, 'flb install fleetbase/')} or ${ansi.colorize(ansi.white, 'flb install ')} to install an extension.`)); - console.log(ansi.colorize(ansi.dim, `Use ${ansi.colorize(ansi.white, 'flb search --json')} for machine-readable output.\n`)); -} - -// Command: search and list available extensions -async function searchExtensionsCommand(query, options) { - const host = options.host || 'https://api.fleetbase.io'; - const apiHost = host.startsWith('http://') || host.startsWith('https://') - ? host - : `https://${host}`; - const endpoint = `${apiHost}/~registry/v1/extensions`; - - if (!options.json && !options.simple) { - console.log('\n\u{1F50D} Searching Fleetbase Extensions...\n'); - } - - try { - const response = await axios.get(endpoint); - let extensions = response.data; - - if (!Array.isArray(extensions) || extensions.length === 0) { - console.log('No extensions found.'); - return; - } - - // Filter by search query (name, slug, subtitle, description, tags) - if (query) { - const q = query.toLowerCase(); - extensions = extensions.filter(ext => - ext.name?.toLowerCase().includes(q) || - ext.slug?.toLowerCase().includes(q) || - ext.subtitle?.toLowerCase().includes(q) || - ext.description?.toLowerCase().includes(q) || - (Array.isArray(ext.tags) && ext.tags.some(t => t.toLowerCase().includes(q))) - ); - } - - // Filter by category - if (options.category) { - const cat = options.category.toLowerCase(); - extensions = extensions.filter(ext => - ext.category?.slug?.toLowerCase().includes(cat) || - ext.category?.name?.toLowerCase().includes(cat) - ); - } - - // Filter to free only - if (options.free) { - extensions = extensions.filter(ext => !ext.payment_required); - } - - if (extensions.length === 0) { - const qualifier = query || options.category; - console.log(`No extensions found${qualifier ? ` matching "${qualifier}"` : ''}.`); - return; - } - - // JSON output mode - if (options.json) { - console.log(JSON.stringify(extensions, null, 2)); - return; - } - - // Simple one-per-line output mode (for scripting) - if (options.simple) { - extensions.forEach(ext => { - const rawPrice = ext.on_sale ? ext.sale_price : ext.price; - const price = ext.payment_required ? `$${(rawPrice / 100).toFixed(2)}` : 'free'; - console.log(`${ext.slug}\t${ext.name}\tv${ext.version || '?'}\t${price}`); - }); - return; - } - - // Default: formatted table output - displayExtensionsTable(extensions); - - } catch (error) { - if (error.response) { - console.error(`\nSearch failed: ${error.response.status} ${error.response.statusText}`); - } else if (error.request) { - console.error('\nSearch failed: No response from server. Check your --host or network connection.'); - } else { - console.error(`\nSearch failed: ${error.message}`); - } - process.exit(1); - } -} - -program.name('flb').description('CLI tool for managing Fleetbase Extensions').version(`${packageJson.name} ${packageJson.version}`, '-v, --version', 'Output the current version'); -program.option('-r, --registry [url]', 'Specify a fleetbase extension repository', defaultRegistry); - -program - .command('set-auth [token]') - .option('-p, --path ', 'Path of the Fleetbase instance to install setup for') - .option('-r, --registry ', 'Registry URL the credentials are for', defaultRegistry) - .description('Set registry auth token') - .action(async (token, { path, registry }) => { - const fleetbasePath = path || '.'; - const fleetbaseRegistry = program.opts().registry ?? registry; - console.log(`Using registry: ${fleetbaseRegistry}`); - console.log(`Using path: ${fleetbasePath}`); - await setAuth(token, fleetbasePath, fleetbaseRegistry); - }); - -program - .command('scaffold') - .description('Scaffold a new Fleetbase extension') - .option('-p, --path ', 'Path to scaffold the extension into', '.') - .option('-n, --name ', 'Name of the extension to scaffold') - .option('-d, --description ', 'Description of the extension to scaffold') - .option('-a, --author ', 'Name of the extension author') - .option('-e, --email ', 'Email of the extension author') - .option('-k, --keywords ', 'Keywords of the extension to scaffold') - .option('-n, --namespace ', 'PHP Namespace of the extension to scaffold') - .option('-r, --repo ', 'Repository URL of the extension to scaffold', starterExtensionRepo.replace('.git', '')) - .action(scaffoldExtension); - -program - .command('install [packageName]') - .option('-p, --path ', 'Path of the Fleetbase instance to install to') - .description('Install a Fleetbase Extension') - .action(async (packageName, { path }) => { - const fleetbasePath = path || '.'; - console.log(`Installing package: ${packageName}`); - console.log(`Using path: ${fleetbasePath}`); - await installPackage(packageName, fleetbasePath); - }); - -program - .command('search [query]') - .alias('list-extensions') - .description('Search and list available Fleetbase extensions') - .option('-c, --category ', 'Filter by category name or slug') - .option('-f, --free', 'Show only free extensions') - .option('--json', 'Output results as raw JSON') - .option('--simple', 'Output one extension per line: slug, name, version, price (for scripting)') - .option('-h, --host ', 'API host to fetch extensions from (default: https://api.fleetbase.io)') - .action(searchExtensionsCommand); - -program - .command('uninstall [packageName]') - .option('-p, --path ', 'Path of the Fleetbase instance to uninstall for') - .description('Uninstall a Fleetbase Extension') - .action(async (packageName, { path }) => { - const fleetbasePath = path || '.'; - console.log(`Uninstalling package: ${packageName}`); - console.log(`Using path: ${fleetbasePath}`); - await uninstallPackage(packageName, fleetbasePath); - }); - -program - .command('publish [packagePath]') - .option('-r, --registry ', 'Registry URL the credentials are for', defaultRegistry) - .description('Publish a Fleetbase Extension') - .action(async (packagePath = '.', { registry }) => { - const fleetbaseRegistry = program.opts().registry ?? registry; - console.log(`Using registry: ${fleetbaseRegistry}`); - - const hasPackageJson = await fs.pathExists(path.join(packagePath, 'package.json')); - const hasComposerJson = await fs.pathExists(path.join(packagePath, 'composer.json')); - - console.log('Publishing Fleetbase Extension...'); - if (hasPackageJson) { - publishPackage(packagePath, fleetbaseRegistry); - } else if (hasComposerJson) { - await createComposerJsonFromPackage(packagePath); - publishPackage(packagePath, fleetbaseRegistry, { - onBefore: () => onBeforePublishComposer(packagePath), - onAfter: () => onAfterPublishComposer(packagePath), - }); - } else { - console.error('No package.json or composer.json found.'); - } - }); - -program - .command('unpublish [packageName]') - .option('-r, --registry ', 'Registry URL the credentials are for', defaultRegistry) - .description('Unpublish a Fleetbase Extension') - .action(async (packageName, { registry }) => { - const fleetbaseRegistry = program.opts().registry ?? registry; - console.log(`Using registry: ${fleetbaseRegistry}`); - - if (!packageName) { - packageName = await getPackageNameFromCurrentDirectory(); - if (!packageName) { - console.error('Package name could not be determined.'); - return; - } - } - - console.log(`Unpublishing Fleetbase Extension ${packageName}`); - unpublishPackage(packageName, fleetbaseRegistry); - }); - -program - .command('version') - .description('Output the version number') - .action(() => { - console.log(`${packageJson.name} ${packageJson.version}`); - }); - -program - .command('bundle') - .description('Bundle the Fleetbase extension into a tar.gz file') - .option('-p, --path ', 'Path of the Fleetbase extension to bundle', '.') - .option('-u, --upload', 'Upload the created bundle after bundling') - .option('--auth-token ', 'Auth token for uploading the bundle') - .action(bundleExtension); - -program - .command('bundle-upload [bundleFile]') - .alias('upload-bundle') - .description('Upload a Fleetbase extension bundle') - .option('-p, --path ', 'Path where the bundle is located', '.') - .option('--auth-token ', 'Auth token for uploading the bundle') - .action(uploadCommand); - -program - .command('version-bump') - .description('Bump the version of the Fleetbase extension') - .option('-p, --path ', 'Path of the Fleetbase extension', '.') - .option('--major', 'Bump major version') - .option('--minor', 'Bump minor version') - .option('--patch', 'Bump patch version') - .option('--pre-release [identifier]', 'Add pre-release identifier') - .action(versionBump); - -program - .command('register') - .description('Register a new Registry Developer Account') - .option('-u, --username ', 'Username for the registry') - .option('-e, --email ', 'Email address') - .option('-p, --password ', 'Password') - .option('-n, --name ', 'Your full name (optional)') - .option('-h, --host ', 'API host with protocol (default: https://api.fleetbase.io)') - .action(registerCommand); - -program - .command('verify') - .description('Verify your Registry Developer Account email') - .option('-e, --email ', 'Email address') - .option('-c, --code ', 'Verification code from email') - .option('-h, --host ', 'API host with protocol (default: https://api.fleetbase.io)') - .action(verifyCommand); - -program - .command('resend-verification') - .description('Resend verification code to your email') - .option('-e, --email ', 'Email address') - .option('-h, --host ', 'API host with protocol (default: https://api.fleetbase.io)') - .action(resendVerificationCommand); - -program - .command('generate-token') - .description('Generate or regenerate your registry authentication token') - .option('-e, --email ', 'Email address') - .option('-p, --password ', 'Password') - .option('-h, --host ', 'API host with protocol (default: https://api.fleetbase.io)') - .action(generateTokenCommand); - -program - .command('install-fleetbase') - .description('Install Fleetbase using Docker with an interactive setup wizard') - .option('--host ', 'Host or IP address to bind to (default: localhost)') - .option('--environment ', 'Environment: development or production (default: development)') - .option('--directory ', 'Installation directory (default: current directory)') - .option('--app-name ', 'Application name (default: Fleetbase)') - .option('--non-interactive', 'Skip every prompt; use flags and safe defaults (useful for CI/CD)') - .action(installFleetbaseCommand); - -program - .command('login') - .description('Log in to the Fleetbase registry') - .option('-u, --username ', 'Username for the registry') - .option('-p, --password ', 'Password for the registry') - .option('-e, --email ', 'Email associated with your account') - .option('-r, --registry ', 'Registry URL', defaultRegistry) - .option('--scope ', 'Scope for the registry') - .option('--quotes ', 'Quotes option for npm-cli-login') - .option('--config-path ', 'Path to the npm config file') - .action(loginCommand); - -program.parse(process.argv); diff --git a/package-lock.json b/package-lock.json index 9f629a7..d1156e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,1766 +1,1513 @@ { "name": "@fleetbase/cli", - "version": "0.0.7", + "version": "2.0.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fleetbase/cli", - "version": "0.0.7", + "version": "2.0.0-beta.0", "license": "AGPL-3.0-or-later", + "workspaces": [ + "packages/*" + ], "dependencies": { - "axios": "^1.7.3", - "chalk": "^5.3.0", - "commander": "^11.1.0", + "commander": "^14.0.3", "enquirer": "^2.4.1", - "form-data": "^4.0.1", - "fs-extra": "^11.2.0", - "glob-promise": "^6.0.7", - "lodash": "^4.17.21", - "npm-cli-login": "^1.0.0", - "semver": "^7.6.3", + "picocolors": "^1.1.1", + "semver": "^7.7.2", "tar": "^7.4.3" }, "bin": { - "flb": "index.js" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "engines": { - "node": ">=12" + "flb": "dist/bin/flb.js" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "devDependencies": { + "@types/node": "^22.15.0", + "@types/semver": "^7.7.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=20.10" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dependencies": { - "minipass": "^7.0.4" - }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14" + "node": ">=18" } }, - "node_modules/@sentry-internal/tracing": { - "version": "7.119.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.119.1.tgz", - "integrity": "sha512-cI0YraPd6qBwvUA3wQdPGTy8PzAoK0NZiaTN1LM3IczdPegehWOaEG5GVTnpGnTsmBAzn1xnBXNBhgiU4dgcrQ==", - "dependencies": { - "@sentry/core": "7.119.1", - "@sentry/types": "7.119.1", - "@sentry/utils": "7.119.1" - }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@sentry/core": { - "version": "7.119.1", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.1.tgz", - "integrity": "sha512-YUNnH7O7paVd+UmpArWCPH4Phlb5LwrkWVqzFWqL3xPyCcTSof2RL8UmvpkTjgYJjJ+NDfq5mPFkqv3aOEn5Sw==", - "dependencies": { - "@sentry/types": "7.119.1", - "@sentry/utils": "7.119.1" - }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@sentry/integrations": { - "version": "7.119.1", - "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.119.1.tgz", - "integrity": "sha512-CGmLEPnaBqbUleVqrmGYjRjf5/OwjUXo57I9t0KKWViq81mWnYhaUhRZWFNoCNQHns+3+GPCOMvl0zlawt+evw==", - "dependencies": { - "@sentry/core": "7.119.1", - "@sentry/types": "7.119.1", - "@sentry/utils": "7.119.1", - "localforage": "^1.8.1" - }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@sentry/node": { - "version": "7.119.1", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.119.1.tgz", - "integrity": "sha512-rpnoQCMxWh/ccjOe+qsmvXAdlTxQHXEWdaltSxnwj7QY+kOGKGP18WTQFLq/gdOBRw9aa6PEQGwhnLfhBXXaYg==", - "dependencies": { - "@sentry-internal/tracing": "7.119.1", - "@sentry/core": "7.119.1", - "@sentry/integrations": "7.119.1", - "@sentry/types": "7.119.1", - "@sentry/utils": "7.119.1" - }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@sentry/types": { - "version": "7.119.1", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.1.tgz", - "integrity": "sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@sentry/utils": { - "version": "7.119.1", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.1.tgz", - "integrity": "sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==", - "dependencies": { - "@sentry/types": "7.119.1" - }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "node": ">=18" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=18" } }, - "node_modules/aproba": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", - "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", - "optional": true - }, - "node_modules/are-we-there-yet": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", - "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", - "deprecated": "This package is no longer supported.", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^2.0.6" - } - }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "os": [ + "linux" + ], "engines": { - "node": ">=0.8" + "node": ">=18" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", - "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==" - }, - "node_modules/axios": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz", - "integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==" - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" + "node": ">=18" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, - "node_modules/builtins": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", - "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==" - }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" - }, - "node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=18" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/code-point-at": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=7.0.0" + "node": ">=18" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">= 0.8" + "node": ">=18" } }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=16" + "node": ">=18" } }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" ], - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "optional": true - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dependencies": { - "assert-plus": "^1.0.0" - }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=0.10" + "node": ">=18" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.4.0" + "node": ">=18" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "optional": true - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" - }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "node_modules/@fleetbase/installer": { + "resolved": "packages/installer", + "link": true }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" + "minipass": "^7.0.4" }, "engines": { - "node": ">=8.6" + "node": ">=18.0.0" } }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" + "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", + "integrity": "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.0.tgz", + "integrity": "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "engines": [ - "node >=0.6.0" + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.0.tgz", + "integrity": "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" ] }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.0.tgz", + "integrity": "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==", + "cpu": [ + "x64" ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.0.tgz", + "integrity": "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.0.tgz", + "integrity": "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "engines": { - "node": "*" - } + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.0.tgz", + "integrity": "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.0.tgz", + "integrity": "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.0.tgz", + "integrity": "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "peer": true + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.0.tgz", + "integrity": "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.0.tgz", + "integrity": "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/gauge": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", - "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", - "deprecated": "This package is no longer supported.", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.0.tgz", + "integrity": "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", "optional": true, - "dependencies": { - "aproba": "^1.0.3", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.0", - "object-assign": "^4.1.0", - "signal-exit": "^3.0.0", - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wide-align": "^1.1.0" - } - }, - "node_modules/gauge/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.0.tgz", + "integrity": "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, - "engines": { - "node": ">=0.10.0" - } + "os": [ + "linux" + ] }, - "node_modules/gauge/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.0.tgz", + "integrity": "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } + "os": [ + "linux" + ] }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.0.tgz", + "integrity": "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "dependencies": { - "assert-plus": "^1.0.0" - } + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.0.tgz", + "integrity": "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.0.tgz", + "integrity": "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/glob-promise": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/glob-promise/-/glob-promise-6.0.7.tgz", - "integrity": "sha512-DEAe6br1w8ZF+y6KM2pzgdfhpreladtNvyNNVgSkxxkFWzXTJFXxQrJQQbAnc7kL0EUd7w5cR8u4K0P4+/q+Gw==", - "engines": { - "node": ">=16" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/ahmadnassri" - }, - "peerDependencies": { - "glob": "^8.0.3" - } + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.0.tgz", + "integrity": "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.0.tgz", + "integrity": "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.0.tgz", + "integrity": "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.0.tgz", + "integrity": "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.0.tgz", + "integrity": "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", - "engines": { - "node": ">=4" - } + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.0.tgz", + "integrity": "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", - "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "engines": { - "node": ">=6" - } + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.0.tgz", + "integrity": "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.0.tgz", + "integrity": "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "engines": { - "node": ">= 0.4" + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/vitest" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "optional": true - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" + "url": "https://opencollective.com/vitest" }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" - }, - "node_modules/http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, - "engines": { - "node": ">=0.8", - "npm": ">=1.3.7" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "peer": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" + "tinyrainbow": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", - "optional": true, - "dependencies": { - "number-is-nan": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/lie": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", - "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/localforage": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", - "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", - "dependencies": { - "lie": "3.1.1" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" - }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "engines": { - "node": ">=16 || 14 >=14.17" + "url": "https://opencollective.com/vitest" } }, - "node_modules/minizlib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", - "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://opencollective.com/vitest" } }, - "node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/npm-cli-login": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/npm-cli-login/-/npm-cli-login-1.0.0.tgz", - "integrity": "sha512-x9Rj2oSnQ8YUGcQ8IHH6pCqEAY+KqkbE2YyW6HlrN+lbNVhCoqDcDXKPWbGcKSNHQsxt5Zoe7Y5khGAJI9HdlQ==", - "dependencies": { - "npm-registry-client": "8.6.0", - "snyk": "^1.124.1" + "tinyspy": "^4.0.3" }, - "bin": { - "npm-cli-login": "bin/npm-cli-login.js" - } - }, - "node_modules/npm-package-arg": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-6.1.1.tgz", - "integrity": "sha512-qBpssaL3IOZWi5vEKUKW0cO7kzLeT+EQO9W8RsLOZf76KF9E/K9+wH0C7t06HXPpaH8WH5xF1MExLuCwbTqRUg==", - "dependencies": { - "hosted-git-info": "^2.7.1", - "osenv": "^0.1.5", - "semver": "^5.6.0", - "validate-npm-package-name": "^3.0.0" - } - }, - "node_modules/npm-package-arg/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "bin": { - "semver": "bin/semver" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/npm-registry-client": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/npm-registry-client/-/npm-registry-client-8.6.0.tgz", - "integrity": "sha512-Qs6P6nnopig+Y8gbzpeN/dkt+n7IyVd8f45NTMotGk6Qo7GfBmzwYx6jRLoOOgKiMnaQfYxsuyQlD8Mc3guBhg==", - "deprecated": "This package is no longer supported.", + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", "dependencies": { - "concat-stream": "^1.5.2", - "graceful-fs": "^4.1.6", - "normalize-package-data": "~1.0.1 || ^2.0.0", - "npm-package-arg": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", - "once": "^1.3.3", - "request": "^2.74.0", - "retry": "^0.10.0", - "safe-buffer": "^5.1.1", - "semver": "2 >=2.2.1 || 3.x || 4 || 5", - "slide": "^1.1.3", - "ssri": "^5.2.4" + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" }, - "optionalDependencies": { - "npmlog": "2 || ^3.1.0 || ^4.0.0" - } - }, - "node_modules/npm-registry-client/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/npmlog": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", - "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", - "deprecated": "This package is no longer supported.", - "optional": true, - "dependencies": { - "are-we-there-yet": "~1.1.2", - "console-control-strings": "~1.1.0", - "gauge": "~2.7.3", - "set-blocking": "~2.0.0" - } - }, - "node_modules/number-is-nan": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "engines": { - "node": "*" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "optional": true, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", "engines": { - "node": ">= 0.4" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" + "node": ">=6" } }, - "node_modules/os-homedir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/osenv": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", - "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", - "deprecated": "This package is no longer supported.", - "dependencies": { - "os-homedir": "^1.0.0", - "os-tmpdir": "^1.0.0" + "node": ">=12" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 16" } }, - "node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=0.6" + "node": ">=18" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=20" } }, - "node_modules/request/node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" + "ms": "^2.1.3" }, "engines": { - "node": ">= 0.12" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/retry": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz", - "integrity": "sha512-ZXUSQYTHdl3uS7IuCehYfMzKyIDBNoAuUblvy5oGO5UJSUTmStUUVPXbA9Qxd173Bgre53yCQczQuHgRWAdvJQ==", - "engines": { - "node": "*" - } - }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=6" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "bin": { - "semver": "bin/semver.js" + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=10" + "node": ">=8.6" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==" + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dependencies": { - "type-fest": "^0.13.1" + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=10" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "optional": true + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=14.14" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "optional": true + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" }, - "node_modules/slide": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz", - "integrity": "sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==", - "engines": { - "node": "*" - } + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" }, - "node_modules/snyk": { - "version": "1.1293.1", - "resolved": "https://registry.npmjs.org/snyk/-/snyk-1.1293.1.tgz", - "integrity": "sha512-CnbNrsEUMGfajfJ5/03BIgx1ixWKr9Kk+9xDw6sZqKy4K5K01DkyUp/V+WjbCfjr0li9+aE7u70s276KEOuiNA==", - "hasInstallScript": true, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", "dependencies": { - "@sentry/node": "^7.36.0", - "global-agent": "^3.0.0" - }, - "bin": { - "snyk": "bin/snyk" + "universalify": "^2.0.0" }, - "engines": { - "node": ">=12" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==" + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" } }, - "node_modules/spdx-license-ids": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", - "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==" - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=0.10.0" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/ssri": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-5.3.0.tgz", - "integrity": "sha512-XRSIPqLij52MtgoQavH/x/dU1qVKtWUAAZeOHsR9c2Ddi4XerFy3mc1alf+dLJKl9EUIm/Ht+EowFkTUOA6GAQ==", - "dependencies": { - "safe-buffer": "^5.1.1" + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" } }, - "node_modules/string_decoder": { + "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, - "node_modules/string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", - "optional": true, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" }, "engines": { - "node": ">=0.10.0" + "node": "^10 || ^12 || >=14" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/rollup": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", + "integrity": "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==", + "dev": true, + "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=8" + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.0", + "@rollup/rollup-android-arm64": "4.65.0", + "@rollup/rollup-darwin-arm64": "4.65.0", + "@rollup/rollup-darwin-x64": "4.65.0", + "@rollup/rollup-freebsd-arm64": "4.65.0", + "@rollup/rollup-freebsd-x64": "4.65.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", + "@rollup/rollup-linux-arm-musleabihf": "4.65.0", + "@rollup/rollup-linux-arm64-gnu": "4.65.0", + "@rollup/rollup-linux-arm64-musl": "4.65.0", + "@rollup/rollup-linux-loong64-gnu": "4.65.0", + "@rollup/rollup-linux-loong64-musl": "4.65.0", + "@rollup/rollup-linux-ppc64-gnu": "4.65.0", + "@rollup/rollup-linux-ppc64-musl": "4.65.0", + "@rollup/rollup-linux-riscv64-gnu": "4.65.0", + "@rollup/rollup-linux-riscv64-musl": "4.65.0", + "@rollup/rollup-linux-s390x-gnu": "4.65.0", + "@rollup/rollup-linux-x64-gnu": "4.65.0", + "@rollup/rollup-linux-x64-musl": "4.65.0", + "@rollup/rollup-openbsd-x64": "4.65.0", + "@rollup/rollup-openharmony-arm64": "4.65.0", + "@rollup/rollup-win32-arm64-msvc": "4.65.0", + "@rollup/rollup-win32-ia32-msvc": "4.65.0", + "@rollup/rollup-win32-x64-gnu": "4.65.0", + "@rollup/rollup-win32-x64-msvc": "4.65.0", + "fsevents": "~2.3.2" } }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "optional": true, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "optional": true, - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -1768,300 +1515,344 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" + "js-tokens": "^9.0.1" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/antfu" } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { "node": ">=18" } }, - "node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=0.8" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", "engines": { - "node": "*" + "node": "^18.0.0 || >=20.0.0" } }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=14.17" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", "engines": { "node": ">= 10.0.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "bin": { - "uuid": "bin/uuid" - } - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/validate-npm-package-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", - "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", - "dependencies": { - "builtins": "^1.0.3" - } - }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "node_modules/verror/node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "optional": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "vite": "bin/vite.js" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" }, "engines": { - "node": ">=10" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" }, "engines": { - "node": ">=8" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" }, "engines": { "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=18" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "packages/installer": { + "name": "@fleetbase/installer", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" + "commander": "^12.1.0", + "enquirer": "^2.4.1", + "fs-extra": "^11.2.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" + "bin": { + "fleetbase-installer": "index.js" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "packages/installer/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", "engines": { "node": ">=18" } diff --git a/package.json b/package.json index 540b6fc..4544966 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@fleetbase/cli", - "version": "0.0.7", - "description": "CLI tool for managing Fleetbase Extensions", - "repository": "https://github.com/fleetbase/fleetbase", + "version": "2.0.0-beta.0", + "description": "Fleetbase CLI: publish extensions to the Fleetbase Extensions Registry and manage installs on your instances", + "repository": "https://github.com/fleetbase/fleetbase-cli", "license": "AGPL-3.0-or-later", "author": "Fleetbase Pte Ltd ", "keywords": [ @@ -10,24 +10,41 @@ "fleetbase-cli", "fleetbase-extensions" ], - "main": "index.js", + "type": "module", "bin": { - "flb": "./index.js" + "flb": "dist/bin/flb.js" }, + "files": [ + "dist", + "README.md", + "LICENSE.md" + ], + "engines": { + "node": ">=20.10" + }, + "workspaces": [ + "packages/*" + ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build": "tsc -p tsconfig.build.json", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "npm run build && bash test/e2e/run.sh", + "prepack": "npm run clean && npm run build" }, "dependencies": { - "axios": "^1.7.3", - "chalk": "^5.3.0", - "commander": "^11.1.0", + "commander": "^14.0.3", "enquirer": "^2.4.1", - "form-data": "^4.0.1", - "fs-extra": "^11.2.0", - "glob-promise": "^6.0.7", - "lodash": "^4.17.21", - "npm-cli-login": "^1.0.0", - "semver": "^7.6.3", + "picocolors": "^1.1.1", + "semver": "^7.7.2", "tar": "^7.4.3" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/semver": "^7.7.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" } } diff --git a/packages/installer/README.md b/packages/installer/README.md new file mode 100644 index 0000000..0561749 --- /dev/null +++ b/packages/installer/README.md @@ -0,0 +1,27 @@ +# @fleetbase/installer + +Installs [Fleetbase](https://github.com/fleetbase/fleetbase) with Docker through an interactive +setup wizard: clones the repository, asks for the host, environment, database, mail and storage +settings, writes `docker-compose.override.yml` and `api/.env`, starts the stack and runs the +deploy script. + +```bash +npx @fleetbase/installer +``` + +Non-interactive (CI): + +```bash +npx @fleetbase/installer --directory ./fleetbase --environment production --host fleet.example.com --non-interactive +``` + +| Option | Meaning | +| --- | --- | +| `--directory ` | Installation directory (default: current directory) | +| `--environment ` | `development` or `production` (default: development) | +| `--host ` | Host or IP address to bind to (default: localhost) | +| `--app-name ` | Application name (default: Fleetbase) | +| `--non-interactive` | Skip every prompt; flags and safe defaults only | + +This was `flb install-fleetbase` in `@fleetbase/cli` up to 0.0.7. The CLI now only covers +the extensions registry; `flb install-fleetbase` prints a pointer here. diff --git a/packages/installer/index.js b/packages/installer/index.js new file mode 100755 index 0000000..818a779 --- /dev/null +++ b/packages/installer/index.js @@ -0,0 +1,661 @@ +#!/usr/bin/env node +/** + * @fleetbase/installer: installs Fleetbase with Docker through an interactive setup wizard. + * + * npx @fleetbase/installer [--directory ] [--environment development|production] + * [--host ] [--app-name ] [--non-interactive] + * + * Moved out of @fleetbase/cli (it was `flb install-fleetbase` up to CLI 0.0.7); the wizard + * itself is unchanged. + */ + +const { program } = require('commander'); +const { exec } = require('child_process'); +const { prompt } = require('enquirer'); +const fs = require('fs-extra'); +const path = require('path'); +const packageJson = require('./package.json'); +const maxBuffer = 1024 * 1024 * 50; // 50MB + +function isPortAvailable(port) { + return new Promise((resolve) => { + const net = require('net'); + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { server.close(); resolve(true); }); + server.listen(port, '127.0.0.1'); + }); +} + +/** + * Promisified exec helper. + * @param {string} cmd + * @returns {Promise} + */ +function execAsync(cmd) { + return new Promise((resolve, reject) => { + exec(cmd, (err, stdout) => { + if (err) reject(err); + else resolve(stdout.trim()); + }); + }); +} + +/** + * Build a YAML-safe environment block from a plain object, + * omitting keys whose value is null, undefined, or empty string. + * @param {Record} vars + * @param {number} indent number of spaces to indent each line + * @returns {string} + */ +function buildEnvBlock(vars, indent = 6) { + const pad = ' '.repeat(indent); + return Object.entries(vars) + .filter(([, v]) => v !== null && v !== undefined && v !== '') + .map(([k, v]) => `${pad}${k}: ${JSON.stringify(String(v))}`) + .join('\n'); +} + +/** + * Make sure api/.env exists before `docker compose up`. + * docker-compose.yml bind-mounts ./api/.env into the application container; when the + * file is missing Docker creates a *directory* at that path and Laravel cannot boot. + * Values set in docker-compose.override.yml take precedence over this file, so an + * empty file with a comment header is the correct default. + * @param {string} directory resolved installation directory + */ +async function ensureApiEnvFile(directory) { + const envPath = path.join(directory, 'api', '.env'); + if (await fs.pathExists(envPath)) { + const stat = await fs.stat(envPath); + if (stat.isDirectory()) { + console.error(`\n✖ ${envPath} is a directory (left behind by an earlier "docker compose up" before the file existed).`); + console.error(' Remove it and re-run the installer.'); + process.exit(1); + } + console.log('✔ api/.env already present'); + return; + } + await fs.ensureDir(path.dirname(envPath)); + await fs.writeFile(envPath, [ + '# Fleetbase API environment overrides.', + '# Runtime configuration comes from docker-compose.override.yml and takes precedence', + '# over this file; add per-host secrets or extra overrides here.', + '', + ].join('\n')); + console.log('✔ api/.env created'); +} + +// Command to install Fleetbase via Docker +async function installFleetbaseCommand(options) { + const crypto = require('crypto'); + + // ── Step 0: Pre-flight checks ──────────────────────────────────────────── + console.log('\n🚀 Fleetbase Installation Wizard\n'); + console.log('⏳ Running pre-flight checks...'); + + // Check required tools + const requiredTools = [ + { cmd: 'docker --version', name: 'Docker' }, + { cmd: 'docker compose version', name: 'Docker Compose v2' }, + { cmd: 'git --version', name: 'Git' }, + ]; + for (const tool of requiredTools) { + try { + await execAsync(tool.cmd); + console.log(` ✔ ${tool.name} found`); + } catch { + console.error(`\n ✖ ${tool.name} is not installed or not in PATH.`); + console.error(` Please install it and retry.`); + process.exit(1); + } + } + + // Check required ports + const requiredPorts = [ + { port: 8000, label: 'API (8000)' }, + { port: 4200, label: 'Console (4200)' }, + { port: 3306, label: 'MySQL (3306)' }, + { port: 38000, label: 'SocketCluster (38000)' }, + ]; + for (const { port, label } of requiredPorts) { + const available = await isPortAvailable(port); + if (!available) { + console.warn(` ⚠ Port ${label} is already in use — this may cause a conflict.`); + } else { + console.log(` ✔ Port ${label} is free`); + } + } + + console.log('✔ Pre-flight checks complete\n'); + + try { + const nonInteractive = !!options.nonInteractive; + if (nonInteractive) { + console.log(' ℹ Non-interactive mode: no prompts; flags and safe defaults are used.'); + } + + // ── Step 1: Core installation parameters ──────────────────────────── + // Without a TTY (CI, piped stdin) enquirer would block or throw, so in + // non-interactive mode every core value comes from a flag or its default. + const coreAnswers = nonInteractive ? {} : await prompt([ + { + type: 'input', + name: 'host', + message: 'Host or IP address to bind to:', + initial: options.host || 'localhost', + validate: (v) => v ? true : 'Host is required', + }, + { + type: 'select', + name: 'environment', + message: 'Environment:', + initial: options.environment === 'production' ? 1 : 0, + choices: [ + { title: 'Development', value: 'development' }, + { title: 'Production', value: 'production' }, + ], + }, + { + type: 'input', + name: 'directory', + message: 'Installation directory:', + initial: options.directory || process.cwd(), + validate: (v) => v ? true : 'Directory is required', + }, + { + type: 'input', + name: 'appName', + message: 'Application name:', + initial: options.appName || 'Fleetbase', + }, + ]); + + const host = options.host || coreAnswers.host || 'localhost'; + const environment = options.environment || coreAnswers.environment || 'development'; + const appName = options.appName || coreAnswers.appName || 'Fleetbase'; + // Resolve so relative paths (and Git Bash style paths on Windows) work for both + // the file writes below and the `cwd` handed to docker compose. + const directory = path.resolve(options.directory || coreAnswers.directory || process.cwd()); + + if (!['development', 'production'].includes(environment)) { + console.error(`\n✖ Invalid environment "${environment}". Use "development" or "production".`); + process.exit(1); + } + + const useHttps = environment === 'production'; + const appDebug = environment !== 'production'; + const scSecure = useHttps; + const schemeApi = useHttps ? 'https' : 'http'; + const schemeConsole = useHttps ? 'https' : 'http'; + const isLocalhost = host === 'localhost' || host === '0.0.0.0' || host === '127.0.0.1'; + + // ── Step 2: Clone repo if needed ───────────────────────────────────── + const dockerComposePath = path.join(directory, 'docker-compose.yml'); + if (!await fs.pathExists(dockerComposePath)) { + console.log('\n⏳ Fleetbase repository not found — cloning...'); + await fs.ensureDir(directory); + const { execSync } = require('child_process'); + try { + execSync('git clone https://github.com/fleetbase/fleetbase.git .', { + cwd: directory, + stdio: 'inherit', + }); + console.log('✔ Repository cloned'); + } catch (err) { + console.error('\n✖ Failed to clone repository:', err.message); + console.log(' git clone https://github.com/fleetbase/fleetbase.git'); + process.exit(1); + } + } + + // ── Step 3: Database configuration ─────────────────────────────────── + console.log('\n── Database Configuration ──────────────────────────────────────'); + const dbModeAnswer = nonInteractive ? { mode: 'internal' } : await prompt({ + type: 'select', + name: 'mode', + message: 'Database:', + choices: [ + { title: 'Bundled Docker MySQL (recommended for development)', value: 'internal' }, + { title: 'External MySQL server (e.g. AWS RDS, PlanetScale)', value: 'external' }, + ], + }); + + let dbConfig = {}; + if (dbModeAnswer.mode === 'external') { + const extDb = await prompt([ + { type: 'input', name: 'dbHost', message: 'Database host:', initial: '127.0.0.1' }, + { type: 'input', name: 'dbPort', message: 'Database port:', initial: '3306' }, + { type: 'input', name: 'dbDatabase', message: 'Database name:', initial: 'fleetbase' }, + { type: 'input', name: 'dbUsername', message: 'Database username:' }, + { type: 'password', name: 'dbPassword', message: 'Database password:' }, + ]); + dbConfig = { + mode: 'external', + databaseUrl: `mysql://${encodeURIComponent(extDb.dbUsername)}:${encodeURIComponent(extDb.dbPassword)}@${extDb.dbHost}:${extDb.dbPort}/${extDb.dbDatabase}`, + }; + console.log('✔ External database configured'); + } else { + const rootPassword = crypto.randomBytes(20).toString('hex'); + const userPassword = crypto.randomBytes(20).toString('hex'); + dbConfig = { + mode: 'internal', + rootPassword, + dbUsername: 'fleetbase', + dbPassword: userPassword, + dbDatabase: 'fleetbase', + databaseUrl: `mysql://fleetbase:${userPassword}@database/fleetbase`, + }; + console.log('✔ Secure database credentials auto-generated'); + } + + // ── Step 4: Mail configuration ──────────────────────────────────────── + console.log('\n── Mail Configuration ──────────────────────────────────────────'); + const mailSetup = nonInteractive ? { configure: false } : await prompt({ + type: 'confirm', + name: 'configure', + message: 'Configure a mail server? (required for password resets & notifications)', + initial: true, + }); + + let mailConfig = { mailMailer: 'log' }; // safe default + if (mailSetup.configure) { + const mailerChoice = await prompt({ + type: 'select', + name: 'mailer', + message: 'Mail driver:', + choices: [ + { title: 'SMTP', value: 'smtp' }, + { title: 'Mailgun', value: 'mailgun' }, + { title: 'Postmark', value: 'postmark' }, + { title: 'SendGrid', value: 'sendgrid' }, + { title: 'Resend', value: 'resend' }, + { title: 'AWS SES', value: 'ses' }, + { title: 'Log only (development)', value: 'log' }, + ], + }); + + const fromDefaults = await prompt([ + { type: 'input', name: 'mailFromAddress', message: 'From address:', initial: `hello@${isLocalhost ? 'example.com' : host}` }, + { type: 'input', name: 'mailFromName', message: 'From name:', initial: appName }, + ]); + + mailConfig = { mailMailer: mailerChoice.mailer, ...fromDefaults }; + + if (mailerChoice.mailer === 'smtp') { + const smtpDetails = await prompt([ + { type: 'input', name: 'mailHost', message: 'SMTP host:', initial: 'smtp.mailgun.org' }, + { type: 'input', name: 'mailPort', message: 'SMTP port:', initial: '587' }, + { type: 'input', name: 'mailUsername', message: 'SMTP username:' }, + { type: 'password', name: 'mailPassword', message: 'SMTP password:' }, + ]); + mailConfig = { ...mailConfig, ...smtpDetails }; + } else if (mailerChoice.mailer === 'mailgun') { + const mgDetails = await prompt([ + { type: 'input', name: 'mailgunDomain', message: 'Mailgun domain:' }, + { type: 'password', name: 'mailgunSecret', message: 'Mailgun API secret:' }, + ]); + mailConfig = { ...mailConfig, ...mgDetails }; + } else if (mailerChoice.mailer === 'postmark') { + const pmDetails = await prompt([ + { type: 'password', name: 'postmarkToken', message: 'Postmark server token:' }, + ]); + mailConfig = { ...mailConfig, ...pmDetails }; + } else if (mailerChoice.mailer === 'sendgrid') { + const sgDetails = await prompt([ + { type: 'password', name: 'sendgridApiKey', message: 'SendGrid API key:' }, + ]); + mailConfig = { ...mailConfig, ...sgDetails }; + } else if (mailerChoice.mailer === 'resend') { + const rsDetails = await prompt([ + { type: 'password', name: 'resendKey', message: 'Resend API key:' }, + ]); + mailConfig = { ...mailConfig, ...rsDetails }; + } else if (mailerChoice.mailer === 'ses') { + console.log(' ℹ AWS SES uses the AWS credentials configured in the Storage step.'); + } + console.log(`✔ Mail driver set to: ${mailerChoice.mailer}`); + } else { + console.log(' ℹ Skipped — emails will be written to the application log.'); + } + + // ── Step 5: File storage ────────────────────────────────────────────── + console.log('\n── File Storage Configuration ──────────────────────────────────'); + const storageChoice = nonInteractive ? { driver: 'public' } : await prompt({ + type: 'select', + name: 'driver', + message: 'File storage driver:', + choices: [ + { title: 'Local disk (development only — files lost on container rebuild)', value: 'public' }, + { title: 'AWS S3 (recommended for production)', value: 's3' }, + { title: 'Google Cloud Storage', value: 'gcs' }, + ], + }); + + let storageConfig = { filesystemDriver: storageChoice.driver }; + if (storageChoice.driver === 's3') { + const s3Details = await prompt([ + { type: 'input', name: 'awsAccessKeyId', message: 'AWS Access Key ID:' }, + { type: 'password', name: 'awsSecretAccessKey', message: 'AWS Secret Access Key:' }, + { type: 'input', name: 'awsDefaultRegion', message: 'AWS Region:', initial: 'us-east-1' }, + { type: 'input', name: 'awsBucket', message: 'S3 Bucket name:' }, + { type: 'input', name: 'awsUrl', message: 'S3 Public URL (leave blank for default):' }, + { type: 'confirm', name: 'awsUsePathStyleEndpoint', message: 'Use path-style endpoint? (for MinIO / non-AWS S3)', initial: false }, + ]); + storageConfig = { ...storageConfig, ...s3Details }; + console.log('✔ S3 storage configured'); + } else if (storageChoice.driver === 'gcs') { + const gcsDetails = await prompt([ + { type: 'input', name: 'googleCloudProjectId', message: 'GCS Project ID:' }, + { type: 'input', name: 'googleCloudStorageBucket', message: 'GCS Bucket name:' }, + { type: 'input', name: 'googleCloudKeyFile', message: 'Path to GCS key file (JSON):' }, + ]); + storageConfig = { ...storageConfig, ...gcsDetails }; + console.log('✔ Google Cloud Storage configured'); + } else { + console.log(' ℹ Local disk selected — suitable for development only.'); + } + + // ── Step 6: Security & CORS ─────────────────────────────────────────── + console.log('\n── Security & CORS Configuration ───────────────────────────────'); + + // Derive SESSION_DOMAIN from host + const sessionDomain = isLocalhost ? 'localhost' : host; + + // Derive SOCKETCLUSTER_OPTIONS origins from host + const socketOrigins = isLocalhost + ? 'http://localhost:*,https://localhost:*,ws://localhost:*,wss://localhost:*' + : `${schemeConsole}://${host}:*,wss://${host}:*`; + const socketClusterOptions = JSON.stringify({ origins: socketOrigins }); + console.log(`✔ SESSION_DOMAIN set to: ${sessionDomain}`); + console.log(`✔ WebSocket origins restricted to: ${socketOrigins}`); + + // Optional additional frontend hosts + const frontendHostsAnswer = nonInteractive ? { frontendHosts: '' } : await prompt({ + type: 'input', + name: 'frontendHosts', + message: 'Additional frontend hosts for CORS (comma-separated, leave blank for none):', + initial: '', + }); + const frontendHosts = frontendHostsAnswer.frontendHosts || ''; + + // ── Step 7: Optional third-party API keys ───────────────────────────── + console.log('\n── Optional Third-Party Services ───────────────────────────────'); + const thirdPartySetup = nonInteractive ? { configure: false } : await prompt({ + type: 'confirm', + name: 'configure', + message: 'Configure optional third-party API keys now? (Maps, Geolocation, SMS)', + initial: false, + }); + + let thirdPartyConfig = {}; + if (thirdPartySetup.configure) { + thirdPartyConfig = await prompt([ + { type: 'input', name: 'ipinfoApiKey', message: 'IPInfo API key (geolocation, leave blank to skip):', initial: '' }, + { type: 'input', name: 'googleMapsApiKey', message: 'Google Maps API key (leave blank to skip):', initial: '' }, + { type: 'input', name: 'googleMapsLocale', message: 'Google Maps locale:', initial: 'us' }, + { type: 'input', name: 'twilioSid', message: 'Twilio Account SID (SMS, leave blank to skip):', initial: '' }, + { type: 'password', name: 'twilioToken', message: 'Twilio Auth Token:', initial: '' }, + { type: 'input', name: 'twilioFrom', message: 'Twilio From phone number:', initial: '' }, + ]); + console.log('✔ Third-party services configured'); + } else { + console.log(' ℹ Skipped — these can be added later via docker-compose.override.yml'); + } + + // ── Step 8: Generate APP_KEY ────────────────────────────────────────── + console.log('\n⏳ Generating APP_KEY...'); + const appKey = 'base64:' + crypto.randomBytes(32).toString('base64'); + console.log('✔ APP_KEY generated'); + + // ── Step 9: Write docker-compose.override.yml ───────────────────────── + console.log('⏳ Writing docker-compose.override.yml...'); + + // Build the application environment block + const appEnvVars = { + APP_KEY: appKey, + APP_NAME: appName, + APP_URL: `${schemeApi}://${host}:8000`, + CONSOLE_HOST: `${schemeConsole}://${host}:4200`, + ENVIRONMENT: environment, + APP_DEBUG: String(appDebug), + DATABASE_URL: dbConfig.databaseUrl, + SESSION_DOMAIN: sessionDomain, + FRONTEND_HOSTS: frontendHosts || null, + // Mail + MAIL_MAILER: mailConfig.mailMailer, + MAIL_HOST: mailConfig.mailHost || null, + MAIL_PORT: mailConfig.mailPort || null, + MAIL_USERNAME: mailConfig.mailUsername || null, + MAIL_PASSWORD: mailConfig.mailPassword || null, + MAIL_FROM_ADDRESS: mailConfig.mailFromAddress || null, + MAIL_FROM_NAME: mailConfig.mailFromName || null, + MAILGUN_DOMAIN: mailConfig.mailgunDomain || null, + MAILGUN_SECRET: mailConfig.mailgunSecret || null, + POSTMARK_TOKEN: mailConfig.postmarkToken || null, + SENDGRID_API_KEY: mailConfig.sendgridApiKey || null, + RESEND_KEY: mailConfig.resendKey || null, + // Storage + FILESYSTEM_DRIVER: storageConfig.filesystemDriver !== 'public' ? storageConfig.filesystemDriver : null, + AWS_ACCESS_KEY_ID: storageConfig.awsAccessKeyId || null, + AWS_SECRET_ACCESS_KEY: storageConfig.awsSecretAccessKey || null, + AWS_DEFAULT_REGION: storageConfig.awsDefaultRegion || null, + AWS_BUCKET: storageConfig.awsBucket || null, + AWS_URL: storageConfig.awsUrl || null, + AWS_USE_PATH_STYLE_ENDPOINT: storageConfig.awsUsePathStyleEndpoint ? 'true' : null, + GOOGLE_CLOUD_PROJECT_ID: storageConfig.googleCloudProjectId || null, + GOOGLE_CLOUD_STORAGE_BUCKET: storageConfig.googleCloudStorageBucket || null, + GOOGLE_CLOUD_KEY_FILE: storageConfig.googleCloudKeyFile || null, + // Third-party + IPINFO_API_KEY: thirdPartyConfig.ipinfoApiKey || null, + GOOGLE_MAPS_API_KEY: thirdPartyConfig.googleMapsApiKey || null, + GOOGLE_MAPS_LOCALE: thirdPartyConfig.googleMapsLocale || null, + TWILIO_SID: thirdPartyConfig.twilioSid || null, + TWILIO_TOKEN: thirdPartyConfig.twilioToken || null, + TWILIO_FROM: thirdPartyConfig.twilioFrom || null, + }; + + // Build the socket environment block + const socketEnvVars = { + SOCKETCLUSTER_OPTIONS: socketClusterOptions, + }; + + // Build the override file content + let overrideContent = `services: + application: + environment: +${buildEnvBlock(appEnvVars)} + + socket: + environment: +${buildEnvBlock(socketEnvVars)} +`; + + // Add database service block only when using internal container + if (dbConfig.mode === 'internal') { + const dbEnvVars = { + MYSQL_ROOT_PASSWORD: dbConfig.rootPassword, + MYSQL_DATABASE: dbConfig.dbDatabase, + MYSQL_USER: dbConfig.dbUsername, + MYSQL_PASSWORD: dbConfig.dbPassword, + MYSQL_ALLOW_EMPTY_PASSWORD: 'no', + }; + overrideContent += ` + database: + environment: +${buildEnvBlock(dbEnvVars)} +`; + } + + // Back up existing override if present + const overridePath = path.join(directory, 'docker-compose.override.yml'); + if (await fs.pathExists(overridePath)) { + const backupPath = `${overridePath}.bak.${Date.now()}`; + await fs.copy(overridePath, backupPath); + console.log(` ℹ Existing override backed up to ${path.basename(backupPath)}`); + } + await fs.writeFile(overridePath, overrideContent); + console.log('✔ docker-compose.override.yml written'); + + // ── Step 10: Write console config files ─────────────────────────────── + console.log('⏳ Updating console configuration files...'); + const configDir = path.join(directory, 'console'); + await fs.ensureDir(configDir); + + const configContent = { + API_HOST: `${schemeApi}://${host}:8000`, + SOCKETCLUSTER_HOST: host, + SOCKETCLUSTER_PORT: '38000', + SOCKETCLUSTER_SECURE: scSecure, + }; + await fs.writeJson(path.join(configDir, 'fleetbase.config.json'), configContent, { spaces: 2 }); + + const environmentsDir = path.join(configDir, 'environments'); + await fs.ensureDir(environmentsDir); + + const osrmHost = thirdPartyConfig.osrmHost || 'https://router.project-osrm.org'; + + await fs.writeFile(path.join(environmentsDir, '.env.development'), [ + `API_HOST=http://${host}:8000`, + `API_NAMESPACE=int/v1`, + `SOCKETCLUSTER_PATH=/socketcluster/`, + `SOCKETCLUSTER_HOST=${host}`, + `SOCKETCLUSTER_SECURE=false`, + `SOCKETCLUSTER_PORT=38000`, + `OSRM_HOST=${osrmHost}`, + '', + ].join('\n')); + + await fs.writeFile(path.join(environmentsDir, '.env.production'), [ + `API_HOST=https://${host}:8000`, + `API_NAMESPACE=int/v1`, + `API_SECURE=true`, + `SOCKETCLUSTER_PATH=/socketcluster/`, + `SOCKETCLUSTER_HOST=${host}`, + `SOCKETCLUSTER_SECURE=true`, + `SOCKETCLUSTER_PORT=38000`, + `OSRM_HOST=${osrmHost}`, + '', + ].join('\n')); + + console.log('✔ Console configuration files updated'); + + // ── Step 10b: Ensure api/.env exists (bind-mounted by docker-compose.yml) ── + await ensureApiEnvFile(directory); + + // ── Step 11: Start containers ───────────────────────────────────────── + console.log('\n⏳ Starting Fleetbase containers...'); + console.log(' This may take a few minutes on first run...\n'); + + exec('docker compose up -d', { cwd: directory, maxBuffer: maxBuffer }, async (error, stdout, stderr) => { + if (error) { + console.error(`\n✖ Error starting containers: ${error.message}`); + if (stderr) console.error(stderr); + process.exit(1); + } + + console.log(stdout); + console.log('✔ Containers started'); + + // Wait for database to be healthy + console.log('\n⏳ Waiting for database to be ready...'); + const dbService = 'database'; + const dbWaitTimeout = 60; + let elapsed = 0; + let dbReady = false; + while (elapsed < dbWaitTimeout) { + try { + const result = await execAsync( + `docker compose exec -T ${dbService} sh -c "mysqladmin --silent --wait=1 -uroot -h127.0.0.1 ping"` + ); + if (result !== undefined) { dbReady = true; break; } + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, 3000)); + elapsed += 3; + } + if (!dbReady) { + console.warn(' ⚠ Database readiness check timed out — proceeding anyway.'); + } else { + console.log('✔ Database is ready'); + } + + // Run deploy script + console.log('\n⏳ Running deployment script...'); + exec('docker compose exec -T application bash -c "./deploy.sh"', { cwd: directory, maxBuffer: maxBuffer }, (deployError, deployStdout, deployStderr) => { + if (deployError) { + console.error(`\n✖ Error during deployment: ${deployError.message}`); + if (deployStderr) console.error(deployStderr); + console.log('\n To run manually:'); + console.log(' docker compose exec application bash -c "./deploy.sh"'); + } else { + console.log(deployStdout); + console.log('✔ Deployment complete'); + } + + // Restart to apply all configuration + exec('docker compose up -d', { cwd: directory }, () => { + // ── Step 12: Post-install summary ───────────────────────── + const configuredItems = [ + dbModeAnswer.mode === 'external' ? 'External Database' : 'Bundled MySQL (secure credentials)', + mailSetup.configure ? `Mail (${mailConfig.mailMailer})` : null, + storageChoice.driver !== 'public' ? `Storage (${storageChoice.driver.toUpperCase()})` : null, + 'WebSocket security (origins restricted)', + thirdPartySetup.configure ? 'Third-party APIs' : null, + ].filter(Boolean); + + const skippedItems = [ + !mailSetup.configure ? 'Mail (using log driver)' : null, + storageChoice.driver === 'public' ? 'File storage (using local disk)' : null, + !thirdPartySetup.configure ? 'Third-party APIs (Maps, Geolocation, SMS)' : null, + ].filter(Boolean); + + console.log('\n' + '═'.repeat(60)); + console.log(' 🏁 Fleetbase Installation Complete'); + console.log('═'.repeat(60)); + console.log(`\n 📍 Endpoints`); + console.log(` API → ${schemeApi}://${host}:8000`); + console.log(` Console → ${schemeConsole}://${host}:4200`); + if (configuredItems.length) { + console.log(`\n ✔ Configured:`); + configuredItems.forEach(i => console.log(` • ${i}`)); + } + if (skippedItems.length) { + console.log(`\n ⚠ Skipped (defaults applied):`); + skippedItems.forEach(i => console.log(` • ${i}`)); + } + console.log(`\n 🔐 Next Steps`); + console.log(` 1. Open the Console URL in your browser.`); + console.log(` 2. Complete the onboarding wizard to create your`); + console.log(` initial organization and administrator account.`); + if (skippedItems.length) { + console.log(` 3. To configure skipped options, edit`); + console.log(` docker-compose.override.yml and run:`); + console.log(` docker compose up -d`); + } + console.log(`\n 📄 Config saved to: docker-compose.override.yml`); + console.log('═'.repeat(60) + '\n'); + }); + }); + }); + } catch (error) { + console.error('\n✖ Installation failed:', error.message); + process.exit(1); + } +} + +program + .name('fleetbase-installer') + .description('Install Fleetbase using Docker with an interactive setup wizard') + .version(packageJson.version, '-v, --version') + .option('--host ', 'Host or IP address to bind to (default: localhost)') + .option('--environment ', 'Environment: development or production (default: development)') + .option('--directory ', 'Installation directory (default: current directory)') + .option('--app-name ', 'Application name (default: Fleetbase)') + .option('--non-interactive', 'Skip every prompt; use flags and safe defaults (useful for CI/CD)') + .action(installFleetbaseCommand); + +if (require.main === module) { + program.parse(process.argv); +} + +module.exports = { installFleetbaseCommand, buildEnvBlock, isPortAvailable }; diff --git a/packages/installer/package.json b/packages/installer/package.json new file mode 100644 index 0000000..56f9dba --- /dev/null +++ b/packages/installer/package.json @@ -0,0 +1,36 @@ +{ + "name": "@fleetbase/installer", + "version": "0.1.0", + "description": "Install Fleetbase with Docker through an interactive setup wizard", + "repository": { + "type": "git", + "url": "https://github.com/fleetbase/fleetbase-cli", + "directory": "packages/installer" + }, + "license": "AGPL-3.0-or-later", + "author": "Fleetbase Pte Ltd ", + "keywords": [ + "fleetbase", + "installer", + "docker" + ], + "main": "index.js", + "bin": { + "fleetbase-installer": "index.js" + }, + "files": [ + "index.js", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "node --test" + }, + "dependencies": { + "commander": "^12.1.0", + "enquirer": "^2.4.1", + "fs-extra": "^11.2.0" + } +} diff --git a/packages/installer/test/installer.test.js b/packages/installer/test/installer.test.js new file mode 100644 index 0000000..d2bd8e6 --- /dev/null +++ b/packages/installer/test/installer.test.js @@ -0,0 +1,23 @@ +const assert = require('node:assert'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); +const test = require('node:test'); +const { buildEnvBlock } = require('..'); + +const bin = path.join(__dirname, '..', 'index.js'); + +test('--help lists the wizard options', () => { + const help = execFileSync(process.execPath, [bin, '--help'], { encoding: 'utf8' }); + for (const option of ['--directory', '--environment', '--host', '--app-name', '--non-interactive']) { + assert.ok(help.includes(option), `missing ${option}`); + } +}); + +test('--version prints the package version', () => { + const version = execFileSync(process.execPath, [bin, '--version'], { encoding: 'utf8' }).trim(); + assert.strictEqual(version, require('../package.json').version); +}); + +test('buildEnvBlock drops empty values and quotes the rest', () => { + assert.strictEqual(buildEnvBlock({ A: 'x', B: '', C: null, D: 3 }, 2), ' A: "x"\n D: "3"'); +}); diff --git a/src/artisan.ts b/src/artisan.ts new file mode 100644 index 0000000..f4ebbd2 --- /dev/null +++ b/src/artisan.ts @@ -0,0 +1,176 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { CliError } from './util.js'; + +/** + * Runs the registry-bridge installer commands on an instance, either in a local API + * checkout (`php artisan …` in the directory with `artisan`) or in a docker compose + * service (`docker compose exec -T php artisan …`). + * + * registry:install / registry:uninstall / registry:update take the version as + * `--release=1.2.0` or `name@1.2.0`: artisan reserves --version. + */ + +export type Target = { kind: 'path'; cwd: string } | { kind: 'docker'; cwd: string; service: string; composeFile?: string }; + +export interface TargetOptions { + path?: string; + docker?: string | boolean; + composeFile?: string; +} + +export const DEFAULT_SERVICE = 'application'; + +export function resolveTarget(options: TargetOptions, cwd = process.cwd()): Target { + const base = path.resolve(cwd, options.path || '.'); + if (options.docker) { + const service = typeof options.docker === 'string' ? options.docker : DEFAULT_SERVICE; + const composeFile = options.composeFile ? path.resolve(cwd, options.composeFile) : undefined; + if (!composeFile && !['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'].some((file) => fs.existsSync(path.join(base, file)))) { + throw new CliError(`No docker compose file in ${base}.`, 1, ['Pass --path or --compose-file .']); + } + return { kind: 'docker', cwd: base, service, composeFile }; + } + for (const dir of [base, path.join(base, 'api')]) { + if (fs.existsSync(path.join(dir, 'artisan'))) { + return { kind: 'path', cwd: dir }; + } + } + throw new CliError(`No artisan in ${base} or ${path.join(base, 'api')}.`, 1, ['Pass --path , or --docker [service] to run inside the compose stack.']); +} + +/** The full argv for `php artisan ` on the target. */ +export function artisanArgv(target: Target, args: string[]): string[] { + const artisan = ['php', 'artisan', ...args, '--no-interaction', '--no-ansi']; + if (target.kind === 'path') { + return artisan; + } + return ['docker', 'compose', ...(target.composeFile ? ['-f', target.composeFile] : []), 'exec', '-T', target.service, ...artisan]; +} + +export interface InstallerRequest { + action: 'install' | 'uninstall' | 'update'; + extension: string; + version?: string; + company?: string; + sync?: boolean; +} + +export function installerArgs(request: InstallerRequest): string[] { + const args = [`registry:${request.action}`, request.extension]; + if (request.version && request.action !== 'uninstall') { + args.push(`--release=${request.version}`); + } + if (request.company) { + args.push(`--company=${request.company}`); + } + if (request.sync) { + args.push('--sync'); + } + args.push('--json'); + return args; +} + +export interface ProcessResult { + code: number; + stdout: string; + stderr: string; +} + +export interface RunOptions { + onStderr?: (chunk: string) => void; + /** written to the command's stdin, then stdin is closed (secrets go here, never in argv) */ + input?: string; +} + +export type Runner = (argv: string[], cwd: string, options?: RunOptions) => Promise; + +export const spawnRunner: Runner = (argv, cwd, options = {}) => + new Promise((resolve, reject) => { + const child = spawn(argv[0], argv.slice(1), { cwd, stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], shell: process.platform === 'win32' }); + let stdout = ''; + let stderr = ''; + child.stdout!.on('data', (chunk) => (stdout += chunk)); + child.stderr!.on('data', (chunk) => { + stderr += chunk; + options.onStderr?.(String(chunk)); + }); + child.on('error', (error) => reject((error as NodeJS.ErrnoException).code === 'ENOENT' ? new CliError(`${argv[0]} is not installed or not on PATH.`) : error)); + child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr })); + if (options.input !== undefined) { + child.stdin!.on('error', () => undefined); + child.stdin!.end(options.input); + } + }); + +/** The last JSON object in artisan output (warnings may precede it). */ +export function parseJsonOutput(output: string): Record | null { + const text = output.trim(); + for (let start = text.lastIndexOf('{'); start >= 0; start = text.lastIndexOf('{', start - 1)) { + // the object that runs to the end of the output + if (start === 0 || text[start - 1] === '\n') { + try { + const value = JSON.parse(text.slice(start)); + if (value && typeof value === 'object') { + return value; + } + } catch { + // keep looking further back + } + } + if (start === 0) { + break; + } + } + return null; +} + +export const MISSING_COMMAND = /(Command "registry:[a-z]+" is not defined|There are no commands defined in the "registry" namespace)/; + +let activeRunner: Runner = spawnRunner; + +/** Tests swap the process runner for a scripted one. */ +export function setRunner(next: Runner | null): void { + activeRunner = next || spawnRunner; +} + +export class Artisan { + constructor( + readonly target: Target, + private readonly runner: Runner = activeRunner, + ) {} + + describe(): string { + return this.target.kind === 'path' ? this.target.cwd : `docker compose service ${this.target.service} (${this.target.cwd})`; + } + + run(args: string[], options?: RunOptions): Promise { + return this.runner(artisanArgv(this.target, args), this.target.cwd, options); + } + + /** Run a command that prints JSON with --json; null when the command does not exist. */ + async json(args: string[], options?: RunOptions): Promise<{ result: ProcessResult; data: Record | null; missing: boolean }> { + const result = await this.run(args, options); + const combined = `${result.stdout}\n${result.stderr}`; + return { result, data: parseJsonOutput(result.stdout), missing: MISSING_COMMAND.test(combined) }; + } +} + +export interface InstallerJob { + id: string; + action?: string; + status: 'queued' | 'running' | 'interrupted' | 'completed' | 'failed'; + stage?: string | null; + step?: string | null; + progress?: number; + message?: string | null; + failed_stage?: string | null; + log?: string[]; + position?: number; + duplicate?: boolean; + extension?: { id?: string; name?: string }; + error?: string; +} + +export const FINISHED = ['completed', 'failed']; diff --git a/src/bin/flb.ts b/src/bin/flb.ts new file mode 100644 index 0000000..76dc628 --- /dev/null +++ b/src/bin/flb.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import { main } from '../program.js'; + +main(process.argv).then((code) => { + process.exitCode = code; +}); diff --git a/src/build.ts b/src/build.ts new file mode 100644 index 0000000..2e62e82 --- /dev/null +++ b/src/build.ts @@ -0,0 +1,408 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import * as tar from 'tar'; +import type { LoadedExtension } from './manifest.js'; +import { log } from './output.js'; +import { CliError, sha256File } from './util.js'; + +/** + * `flb build`: the archives a release upload takes, plus `.fleetbase/release.json` + * describing them. + * + * engine `npm pack` of the extension (the `package/` layout npm publishes) + * api gzipped ustar with one top-level `package/` folder (Composer strips it): composer.json, + * the directories its autoload maps point at, README/LICENSE/CHANGELOG + * dist with --prebuilt: ember-core's build-engine-release run in a console checkout that has the + * engine installed, tarred as the `/` folder with its manifest.json + */ + +export const BUILD_DIR = '.fleetbase'; +export const RELEASE_FILE = 'release.json'; + +export interface BuiltFile { + /** path relative to the extension root */ + path: string; + sha256: string; + size: number; + package?: string; + builtAgainst?: Record; +} + +export interface ReleaseFile { + schema: 1; + name: string; + version: string; + channel?: string; + manifestSha256: string; + builtAt: string; + cli: string; + files: { engine?: BuiltFile; api?: BuiltFile; dist?: BuiltFile }; +} + +export interface BuildOptions { + out?: string; + engine?: boolean; + api?: boolean; + prebuilt?: boolean; + console?: string; + consoleDist?: string; + keepMaps?: boolean; + ignoreScripts?: boolean; + cliVersion: string; + /** where tool output goes: inherit for humans, stderr when stdout must stay clean */ + quiet?: boolean; +} + +export interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +export function run(command: string, args: string[], options: { cwd: string; passthrough?: boolean; env?: NodeJS.ProcessEnv }): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: options.cwd, env: options.env || process.env, shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + if (options.passthrough) { + process.stderr.write(chunk); + } + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + if (options.passthrough) { + process.stderr.write(chunk); + } + }); + child.on('error', (error) => reject((error as NodeJS.ErrnoException).code === 'ENOENT' ? new CliError(`${command} is not installed or not on PATH.`) : error)); + child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr })); + }); +} + +function describe(root: string, file: string, extra: Partial = {}): BuiltFile { + return { path: path.relative(root, file).split(path.sep).join('/'), sha256: sha256File(file), size: fs.statSync(file).size, ...extra }; +} + +// ─── engine ────────────────────────────────────────────────────────────────── + +/** npm would pack an earlier build into the next one unless .fleetbase/ is ignored. */ +export function ensureNpmIgnore(ext: LoadedExtension): string | null { + if (Array.isArray(ext.engine?.json.files)) { + return null; + } + const file = path.join(ext.root, '.npmignore'); + const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; + if (/^\/?\.fleetbase\/?$/m.test(current)) { + return null; + } + const prefix = current === '' || current.endsWith('\n') ? '' : '\n'; + fs.writeFileSync(file, `${current}${prefix}\n# flb build output\n/.fleetbase/\n`); + return file; +} + +export async function packEngine(ext: LoadedExtension, outDir: string, options: BuildOptions): Promise { + const added = ensureNpmIgnore(ext); + if (added) { + log.info(` added /.fleetbase/ to ${path.relative(process.cwd(), added) || '.npmignore'} so builds are not packed into the engine`); + } + + const args = ['pack', '--json', '--pack-destination', outDir, ...(options.ignoreScripts ? ['--ignore-scripts'] : [])]; + const result = await run('npm', args, { cwd: ext.root }); + if (result.code !== 0) { + throw new CliError(`npm pack failed:\n${(result.stderr || result.stdout).trim()}`); + } + + let filename: string | undefined; + try { + const parsed = JSON.parse(result.stdout.slice(result.stdout.indexOf('['))); + filename = parsed[0]?.filename; + } catch { + filename = result.stdout.trim().split('\n').pop(); + } + if (!filename) { + throw new CliError('npm pack did not report the tarball it wrote.'); + } + + // npm names scoped tarballs scope-name-version.tgz; normalise to engine-.tgz + const packed = path.join(outDir, path.basename(filename.replace(/^@/, '').replace(/\//g, '-'))); + const target = path.join(outDir, `engine-${ext.manifest.version}.tgz`); + fs.renameSync(fs.existsSync(packed) ? packed : path.join(outDir, filename), target); + + const entries = await listEntries(target); + const suspicious = entries.filter((entry) => /^package\/(server_vendor|vendor|\.fleetbase)\//.test(entry) || /(^|\/)(\.env(\..*)?|auth\.json|\.npmrc)$/.test(entry)); + if (suspicious.length) { + log.warn(`The engine tarball contains ${suspicious.slice(0, 3).join(', ')}${suspicious.length > 3 ? ` and ${suspicious.length - 3} more` : ''}; exclude them in .npmignore or package.json "files".`); + } + + return describe(ext.root, target, { package: ext.engine!.name }); +} + +export async function listEntries(file: string): Promise { + const entries: string[] = []; + await tar.t({ file, onReadEntry: (entry) => entries.push(entry.path) }); + return entries; +} + +// ─── api ───────────────────────────────────────────────────────────────────── + +const ALWAYS_EXCLUDED = new Set(['.git', '.github', 'node_modules', 'vendor', 'server_vendor', '.fleetbase', '.idea', '.vscode', 'dist', 'tmp']); +const SECRET_FILES = /^(\.env(\..*)?|auth\.json|\.npmrc|id_rsa|id_ed25519|.*\.pem|.*\.key)$/i; +const DOC_FILES = /^(readme|license|licence|changelog|copying)(\.[a-z]+)?$/i; + +/** Top-level directories and files the composer.json autoload maps reference. */ +export function composerPaths(composer: Record): string[] { + const paths = new Set(); + const autoload = composer.autoload || {}; + const add = (value: unknown) => { + for (const item of Array.isArray(value) ? value : [value]) { + if (typeof item === 'string' && item.trim() !== '') { + const top = item.replace(/^\.\//, '').split('/')[0]; + if (top && top !== '.' && top !== '..') { + paths.add(top); + } + } + } + }; + for (const kind of ['psr-4', 'psr-0']) { + for (const value of Object.values(autoload[kind] || {})) { + add(value); + } + } + add(autoload.classmap); + add(autoload.files); + return [...paths]; +} + +/** `archive.exclude` patterns, simplified: a leading `/` anchors at the root, `*` matches within a segment. */ +function excludeMatcher(patterns: string[]): (relative: string) => boolean { + const regexes = patterns + .filter((pattern) => typeof pattern === 'string' && pattern.trim() !== '' && !pattern.startsWith('!')) + .map((pattern) => { + const anchored = pattern.startsWith('/'); + const body = pattern + .replace(/^\//, '') + .replace(/\/$/, '') + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '\u0000') + .replace(/\*/g, '[^/]*') + .replace(/\u0000/g, '.*'); + return new RegExp(`${anchored ? '^' : '(^|/)'}${body}(/|$)`); + }); + return (relative) => regexes.some((regex) => regex.test(relative)); +} + +export function collectApiFiles(ext: LoadedExtension): { files: string[]; skipped: string[] } { + const composer = ext.api!.json; + const excluded = excludeMatcher(Array.isArray(composer.archive?.exclude) ? composer.archive.exclude : []); + const devDirs = new Set(composerPaths({ autoload: composer['autoload-dev'] || {} }).filter((top) => !composerPaths(composer).includes(top))); + const devPaths = Object.values(composer['autoload-dev']?.['psr-4'] || {}).flatMap((value) => (Array.isArray(value) ? value : [value])).map((value) => String(value).replace(/^\.\//, '').replace(/\/$/, '')); + + const files: string[] = []; + const skipped: string[] = []; + + const visit = (relative: string) => { + const absolute = path.join(ext.root, relative); + const stat = fs.lstatSync(absolute); + const base = path.basename(relative); + if (stat.isSymbolicLink()) { + skipped.push(`${relative} (symlink)`); + return; + } + if (excluded(relative) || devPaths.includes(relative)) { + return; + } + if (stat.isDirectory()) { + if (ALWAYS_EXCLUDED.has(base) || devDirs.has(relative)) { + return; + } + for (const child of fs.readdirSync(absolute).sort()) { + visit(path.posix.join(relative, child)); + } + return; + } + if (!stat.isFile()) { + skipped.push(`${relative} (not a regular file)`); + return; + } + if (SECRET_FILES.test(base) || base === '.DS_Store' || base.endsWith('.cache')) { + if (SECRET_FILES.test(base)) { + skipped.push(`${relative} (looks like a secret)`); + } + return; + } + files.push(relative); + }; + + const roots = new Set(['composer.json', ...composerPaths(composer)]); + for (const entry of fs.readdirSync(ext.root)) { + if (DOC_FILES.test(entry) && fs.statSync(path.join(ext.root, entry)).isFile()) { + roots.add(entry); + } + } + for (const root of [...roots].sort()) { + if (fs.existsSync(path.join(ext.root, root))) { + visit(root); + } else if (root !== 'composer.json') { + skipped.push(`${root} (in composer.json autoload but missing)`); + } + } + + return { files, skipped }; +} + +export async function packApi(ext: LoadedExtension, outDir: string): Promise { + const { files, skipped } = collectApiFiles(ext); + for (const item of skipped) { + log.warn(`api archive: skipped ${item}`); + } + if (files.length <= 1) { + log.warn('The api archive holds only composer.json; check the autoload paths in composer.json.'); + } + + const target = path.join(outDir, `api-${ext.manifest.version}.tar.gz`); + await tar.c({ file: target, gzip: true, cwd: ext.root, portable: true, prefix: 'package', noDirRecurse: true, follow: false, mtime: new Date(0) }, files); + return describe(ext.root, target, { package: ext.api!.name }); +} + +// ─── prebuilt dist ─────────────────────────────────────────────────────────── + +export function findBuildEngineRelease(consoleDir: string): string { + const pkg = path.join(consoleDir, 'package.json'); + if (!fs.existsSync(pkg)) { + throw new CliError(`${consoleDir} is not a console checkout (no package.json).`); + } + try { + return createRequire(pkg).resolve('@fleetbase/ember-core/bin/build-engine-release.js'); + } catch { + throw new CliError(`The console's @fleetbase/ember-core has no bin/build-engine-release.js.`, 1, [ + 'Prebuilt releases need an ember-core with runtime engines (fleetbase/ember-core#99). Install it in the console, or build without --prebuilt.', + ]); + } +} + +export async function buildDist(ext: LoadedExtension, outDir: string, options: BuildOptions): Promise { + if (!ext.engine) { + throw new CliError('--prebuilt needs an engine (extension.json declares none).'); + } + if (!options.console) { + throw new CliError('--prebuilt needs --console : a console checkout with this engine installed (for example with `pnpm link`).'); + } + + const consoleDir = path.resolve(options.console); + const script = findBuildEngineRelease(consoleDir); + const installed = path.join(consoleDir, 'node_modules', ...ext.engine.name.split('/'), 'package.json'); + if (!fs.existsSync(installed)) { + throw new CliError(`${ext.engine.name} is not installed in ${consoleDir}.`, 1, ['Link it first, for example `pnpm link ` in the console, then build again.']); + } + const installedVersion = JSON.parse(fs.readFileSync(installed, 'utf8')).version; + if (installedVersion !== ext.manifest.version) { + throw new CliError(`The console has ${ext.engine.name}@${installedVersion}, not ${ext.manifest.version}; the prebuilt dist must be built from this version.`); + } + + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'flb-dist-')); + try { + const args = [script, '--console', consoleDir, '--engine', ext.engine.name, '--out', work]; + if (options.consoleDist) { + args.push('--dist', path.resolve(options.consoleDist)); + } + if (options.keepMaps) { + args.push('--keep-maps'); + } + log.step(options.consoleDist ? 'Extracting the prebuilt engine from the console build' : 'Building the console to extract the prebuilt engine (this can take a long time)'); + const result = await run(process.execPath, args, { cwd: consoleDir, passthrough: !options.quiet }); + if (result.code !== 0) { + throw new CliError(`build-engine-release failed (exit ${result.code}).${options.quiet ? `\n${result.stderr.trim()}` : ''}`); + } + + const manifestFile = path.join(work, ...ext.engine.name.split('/'), 'manifest.json'); + if (!fs.existsSync(manifestFile)) { + throw new CliError(`build-engine-release wrote no ${ext.engine.name}/manifest.json.`); + } + const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')); + if (manifest.name !== ext.engine.name || manifest.version !== ext.manifest.version) { + throw new CliError(`The prebuilt manifest is for ${manifest.name}@${manifest.version}, not ${ext.engine.name}@${ext.manifest.version}.`); + } + + const target = path.join(outDir, `dist-${ext.manifest.version}.tar.gz`); + // node-tar reads an entry starting with @ as an archive to append, so a scope goes in as the prefix + const [scope, name] = ext.engine.name.startsWith('@') ? ext.engine.name.split('/') : [undefined, ext.engine.name]; + await tar.c({ file: target, gzip: true, cwd: scope ? path.join(work, scope) : work, prefix: scope, portable: true, follow: false, mtime: new Date(0) }, [name]); + return describe(ext.root, target, { package: ext.engine.name, builtAgainst: manifest.builtAgainst || {} }); + } finally { + fs.rmSync(work, { recursive: true, force: true }); + } +} + +// ─── build ─────────────────────────────────────────────────────────────────── + +export function releaseFilePath(root: string): string { + return path.join(root, BUILD_DIR, RELEASE_FILE); +} + +export async function build(ext: LoadedExtension, options: BuildOptions): Promise { + const outDir = path.resolve(ext.root, options.out || path.join(BUILD_DIR, 'build', ext.manifest.version)); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + + const files: ReleaseFile['files'] = {}; + if (ext.engine && options.engine !== false) { + log.step(`Packing the engine ${ext.engine.name}`); + files.engine = await packEngine(ext, outDir, options); + } + if (ext.api && options.api !== false) { + log.step(`Archiving the api ${ext.api.name}`); + files.api = await packApi(ext, outDir); + } + if (options.prebuilt) { + files.dist = await buildDist(ext, outDir, options); + } + if (!files.engine && !files.api) { + throw new CliError('Nothing to build: the release needs an engine or an api archive.'); + } + + const release: ReleaseFile = { + schema: 1, + name: ext.manifest.name, + version: ext.manifest.version, + ...(ext.manifest.channel ? { channel: ext.manifest.channel } : {}), + manifestSha256: sha256File(ext.manifestPath), + builtAt: new Date().toISOString(), + cli: options.cliVersion, + files, + }; + fs.mkdirSync(path.join(ext.root, BUILD_DIR), { recursive: true }); + fs.writeFileSync(releaseFilePath(ext.root), JSON.stringify(release, null, 4) + '\n'); + return release; +} + +/** release.json when it matches the current extension.json and its archives are intact, else why not. */ +export function readRelease(ext: LoadedExtension): { release?: ReleaseFile; stale?: string } { + const file = releaseFilePath(ext.root); + if (!fs.existsSync(file)) { + return { stale: 'not built yet' }; + } + let release: ReleaseFile; + try { + release = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return { stale: `${file} is unreadable` }; + } + if (release.version !== ext.manifest.version) { + return { stale: `the build is for ${release.version}, extension.json says ${ext.manifest.version}` }; + } + if (release.manifestSha256 !== sha256File(ext.manifestPath)) { + return { stale: 'extension.json changed since the build' }; + } + for (const [kind, built] of Object.entries(release.files)) { + const absolute = path.join(ext.root, built!.path); + if (!fs.existsSync(absolute) || sha256File(absolute) !== built!.sha256) { + return { stale: `the ${kind} archive is missing or changed` }; + } + } + return { release }; +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..3e61a63 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,303 @@ +import os from 'node:os'; +import type { Command } from 'commander'; +import { PERSONAL_TOKEN, clearToken, configDisplayPath, getToken, hostMeta, maskToken, readConfig, setToken, updateConfig, type HostMeta } from '../config.js'; +import { type Context, context, registryFlag } from '../context.js'; +import { ApiError } from '../http.js'; +import { colors, formatDate, log } from '../output.js'; +import { ask, askSecret, interactive } from '../prompt.js'; +import type { Account, IssuedToken, TokenDetails } from '../types.js'; +import { CliError, parseDays, readStdin, splitList } from '../util.js'; + +/** Scopes a `flb login` token gets unless --scopes says otherwise: enough to publish and register instances. */ +export const LOGIN_SCOPES = ['read', 'publish', 'instance:register']; +export const LOGIN_DAYS = 90; +export const SCOPES = ['read', 'publish', 'install', 'instance:register', 'review', '*']; + +export function validateScopes(scopes: string[]): string[] { + const unknown = scopes.filter((scope) => !SCOPES.includes(scope)); + if (unknown.length) { + throw new CliError(`Unknown scope ${unknown.join(', ')}. Scopes: ${SCOPES.join(', ')}.`); + } + return [...new Set(scopes)]; +} + +export function register(program: Command): void { + program + .command('login') + .description('Log in to the Registry Service and store a scoped token') + .option('-e, --email ', 'account email') + .option('--password-stdin', 'read the password from stdin') + .option('--token [token]', 'use an existing personal token instead of a password (prompted when no value is given)') + .option('--token-stdin', 'read an existing personal token from stdin') + .option('--name ', 'name of the token the login creates', `flb on ${os.hostname()}`) + .option('--scopes ', `scopes of the token the login creates (comma separated)`, LOGIN_SCOPES.join(',')) + .option('--expires ', 'lifetime of the token the login creates (max 365d)', `${LOGIN_DAYS}d`) + .option('-o, --organization ', 'default organization for this registry') + .option('--show-token', 'print the token (hidden by default)') + .action((opts, command: Command) => login(context(command), opts)); + + program + .command('logout') + .description('Revoke the stored token on the service and remove it from the config') + .option('--local', 'only remove it from the config; leave it valid on the service') + .action((opts, command: Command) => logout(context(command), opts)); + + program + .command('whoami') + .description('Show the account and token in use') + .option('--json', 'print JSON') + .action((_opts, command: Command) => whoami(context(command))); + + program + .command('register') + .description('Create a registry account (then verify the email and log in)') + .option('-n, --name ', 'your name') + .option('-e, --email ', 'account email') + .option('-u, --username ', 'username (optional)') + .option('--password-stdin', 'read the password from stdin') + .option('--no-verify', 'do not prompt for the verification code') + .option('--no-login', 'do not log in after verifying') + .option('--show-token', 'print the token issued at login (hidden by default)') + .action((opts, command: Command) => registerAccount(context(command), opts)); + + program + .command('verify') + .description('Verify the account email with the 6-digit code that was emailed') + .option('-e, --email ', 'account email') + .option('-c, --code ', 'verification code') + .option('--resend', 'email a new code instead') + .action((opts, command: Command) => verify(context(command), opts)); +} + +// ─── login ─────────────────────────────────────────────────────────────────── + +interface LoginOptions { + email?: string; + passwordStdin?: boolean; + token?: string | boolean; + tokenStdin?: boolean; + name: string; + scopes: string; + expires: string; + organization?: string; + showToken?: boolean; +} + +export async function login(ctx: Context, opts: LoginOptions): Promise { + const client = ctx.anonymous(); + const previous = getToken(ctx.config, ctx.registry); + + let token: string; + let account: Account; + let details: TokenDetails | undefined; + let source: HostMeta['source']; + + if (opts.token !== undefined || opts.tokenStdin) { + token = (opts.tokenStdin ? await readStdin() : typeof opts.token === 'string' ? opts.token : await askSecret('Personal token (flbp_…)', '--token or --token-stdin')).trim(); + if (!PERSONAL_TOKEN.test(token)) { + throw new CliError('That is not a personal token: they start with flbp_ followed by 40 characters.'); + } + const authed = client.withToken(token); + account = await authed.get('v1/account').catch((error) => { + throw error instanceof ApiError && error.status === 401 ? new CliError(`${ctx.host} does not accept that token (expired, revoked or for another registry).`) : error; + }); + const tokens = await authed.get('v1/account/tokens').catch(() => []); + details = tokens.find((item) => !item.revoked_at && item.prefix === 'flbp_' && token.endsWith(item.last_four)); + source = 'pasted'; + } else { + const email = opts.email || (await ask('Email', '--email')); + const password = opts.passwordStdin ? await readStdin() : await askSecret('Password', '--password-stdin'); + const scopes = validateScopes(splitList(opts.scopes)); + let issued: IssuedToken; + try { + issued = await client.post('v1/auth/login', { + email, + password, + token_name: opts.name, + scopes, + expires_in_days: parseDays(opts.expires), + }); + } catch (error) { + if (error instanceof ApiError && error.code === 'email_unverified') { + throw new CliError(error.message, 1, [`Verify it with \`flb verify --email ${email}${registryFlag(ctx.registry)}\` (add --resend for a new code).`]); + } + throw error; + } + token = issued.token; + account = issued.account!; + details = issued.token_details; + source = 'login'; + } + + const organization = opts.organization ? checkOrganization(account, opts.organization) : hostMeta(ctx.config, ctx.registry).organization; + + updateConfig((config) => + setToken(config, ctx.registry, token, { + account: { id: account.id, email: account.email, name: account.name, username: account.username ?? undefined }, + tokenId: details?.id, + tokenName: details?.name, + scopes: details?.scopes, + expiresAt: details?.expires_at ?? null, + organization, + source, + }), + ); + + // A token the CLI minted at an earlier login is replaced, so revoke it rather than leave it live. + if (previous && previous.source === 'config' && !previous.legacy && previous.token !== token && hostMeta(ctx.config, ctx.registry).source === 'login') { + await client.post('v1/auth/logout', {}, { token: previous.token }).catch(() => undefined); + } + + log.success(`Logged in to ${ctx.host} as ${account.email}.`); + if (details) { + log.info(` token ${details.name} (${details.masked}), scopes ${details.scopes.join(', ')}, expires ${formatDate(details.expires_at) || 'never'}`); + } + if (organization) { + log.info(` default organization ${organization}`); + } + log.info(` saved to ${configDisplayPath()}`); + if (opts.showToken) { + log.secret(token); + } +} + +function checkOrganization(account: Account, slug: string): string { + const organization = account.organizations?.find((org) => org.slug === slug || org.id === slug); + if (!organization) { + const known = (account.organizations || []).map((org) => org.slug).join(', '); + throw new CliError(`You are not a member of organization "${slug}".${known ? ` Yours: ${known}.` : ''}`); + } + return organization.slug; +} + +// ─── logout ────────────────────────────────────────────────────────────────── + +export async function logout(ctx: Context, opts: { local?: boolean }): Promise { + const stored = getToken(ctx.config, ctx.registry); + if (!stored) { + log.info(`Not logged in to ${ctx.host}.`); + return; + } + if (stored.source === 'env') { + throw new CliError('The token comes from FLB_TOKEN; unset it to log out.'); + } + + if (!opts.local && !stored.legacy) { + try { + await ctx.anonymous().post('v1/auth/logout', {}, { token: stored.token }); + } catch (error) { + if (!(error instanceof ApiError && error.status === 401)) { + throw new CliError(`Could not revoke the token on ${ctx.host}: ${(error as Error).message}`, 1, ['Run `flb logout --local` to only remove it from the config.']); + } + } + } + + updateConfig((config) => clearToken(config, ctx.registry)); + log.success(opts.local || stored.legacy ? `Removed the token for ${ctx.host} from ${configDisplayPath()}.` : `Logged out of ${ctx.host}; the token is revoked.`); +} + +// ─── whoami ────────────────────────────────────────────────────────────────── + +export async function whoami(ctx: Context): Promise { + const client = ctx.authed(); + const account = await client.get('v1/account'); + const meta = hostMeta(ctx.config, ctx.registry); + const stored = getToken(ctx.config, ctx.registry)!; + + if (ctx.json) { + log.json({ registry: ctx.registry, account, token: { source: stored.source, masked: maskToken(stored.token), id: meta.tokenId, scopes: meta.scopes, expires_at: meta.expiresAt }, organization: ctx.organization() }); + return; + } + + log.line(`${colors.bold(account.email)} on ${ctx.host}${account.name ? ` (${account.name})` : ''}`); + for (const org of account.organizations || []) { + const marker = org.slug === ctx.organization() ? colors.green(' (default)') : ''; + log.line(` ${org.slug} ${org.role || ''}${org.is_personal ? ', personal' : ''}${marker}`); + } + log.line(`token ${maskToken(stored.token)}${stored.source === 'env' ? ' from FLB_TOKEN' : ''}${meta.scopes ? `, scopes ${meta.scopes.join(', ')}` : ''}${meta.expiresAt ? `, expires ${formatDate(meta.expiresAt)}` : ''}`); +} + +// ─── register / verify ─────────────────────────────────────────────────────── + +interface RegisterOptions { + name?: string; + email?: string; + username?: string; + passwordStdin?: boolean; + verify: boolean; + login: boolean; + showToken?: boolean; +} + +export async function registerAccount(ctx: Context, opts: RegisterOptions): Promise { + const client = ctx.anonymous(); + const name = opts.name || (await ask('Name', '--name')); + const email = opts.email || (await ask('Email', '--email')); + let password: string; + if (opts.passwordStdin) { + password = await readStdin(); + } else { + password = await askSecret('Password', '--password-stdin'); + if ((await askSecret('Confirm password', '--password-stdin')) !== password) { + throw new CliError('The passwords do not match.'); + } + } + + await client.post('v1/auth/register', { name, email, password, ...(opts.username ? { username: opts.username } : {}) }); + log.success(`Account created on ${ctx.host}. A verification code was sent to ${email}.`); + + if (!opts.verify || !interactive()) { + log.info(`Verify it with: flb verify --email ${email} --code ${registryFlag(ctx.registry)}`); + return; + } + + const code = await ask('Verification code', '--code', undefined, (value) => /^\d{6}$/.test(value) || 'Enter the 6-digit code'); + await client.post('v1/auth/verify', { email, code }); + log.success('Email verified.'); + + if (opts.login) { + await loginWithPassword(ctx, email, password, opts.showToken); + } +} + +async function loginWithPassword(ctx: Context, email: string, password: string, showToken?: boolean): Promise { + const issued = await ctx.anonymous().post('v1/auth/login', { + email, + password, + token_name: `flb on ${os.hostname()}`, + scopes: LOGIN_SCOPES, + expires_in_days: LOGIN_DAYS, + }); + const details = issued.token_details; + updateConfig((config) => + setToken(config, ctx.registry, issued.token, { + account: { id: issued.account?.id, email, name: issued.account?.name }, + tokenId: details.id, + tokenName: details.name, + scopes: details.scopes, + expiresAt: details.expires_at, + source: 'login', + }), + ); + log.success(`Logged in as ${email}; token saved to ${configDisplayPath()}.`); + if (showToken) { + log.secret(issued.token); + } +} + +export async function verify(ctx: Context, opts: { email?: string; code?: string; resend?: boolean }): Promise { + const client = ctx.anonymous(); + const email = opts.email || readConfig().hosts?.[ctx.host]?.account?.email || (await ask('Email', '--email')); + + if (opts.resend) { + await client.post('v1/auth/verify/resend', { email }); + log.success(`A new verification code was sent to ${email}.`); + return; + } + + const code = opts.code || (await ask('Verification code', '--code', undefined, (value) => /^\d{6}$/.test(value) || 'Enter the 6-digit code')); + const result = await client.post<{ verified: boolean; message: string }>('v1/auth/verify', { email, code }); + log.success(result.message || 'Email verified.'); + log.info(`Log in with: flb login --email ${email}${registryFlag(ctx.registry)}`); +} + diff --git a/src/commands/build.ts b/src/commands/build.ts new file mode 100644 index 0000000..6f3ae9d --- /dev/null +++ b/src/commands/build.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; +import type { Command } from 'commander'; +import { type BuildOptions, build, releaseFilePath, type ReleaseFile } from '../build.js'; +import { context } from '../context.js'; +import { type LoadedExtension, loadExtension } from '../manifest.js'; +import { formatBytes, log } from '../output.js'; +import { VERSION } from '../version.js'; + +export function addBuildOptions(command: Command): Command { + return command + .option('--out ', 'output directory (default .fleetbase/build/)') + .option('--no-engine', 'skip the engine tarball') + .option('--no-api', 'skip the api archive') + .option('--prebuilt', 'also build the prebuilt engine dist (needs --console)') + .option('--console ', 'console checkout with this engine installed, for --prebuilt') + .option('--console-dist ', 'an existing console build (ember build output) to extract from instead of building the console') + .option('--keep-maps', 'keep source maps in the prebuilt dist') + .option('--ignore-scripts', 'run npm pack with --ignore-scripts'); +} + +export function register(program: Command): void { + addBuildOptions( + program + .command('build') + .description('Build the release archives (engine, api, optional prebuilt dist) and .fleetbase/release.json') + .argument('[path]', 'extension directory', '.'), + ) + .option('--json', 'print release.json') + .action(async (dir: string, opts, command: Command) => { + const ctx = context(command); + const ext = loadExtension(dir); + const release = await runBuild(ext, opts, ctx.json); + if (ctx.json) { + log.json(release); + } + }); +} + +export async function runBuild(ext: LoadedExtension, opts: Record, quiet = false): Promise { + for (const warning of ext.warnings) { + log.warn(warning); + } + log.step(`Building ${ext.manifest.name} ${ext.manifest.version}`); + const options: BuildOptions = { + out: opts.out, + engine: opts.engine, + api: opts.api, + prebuilt: opts.prebuilt, + console: opts.console, + consoleDist: opts.consoleDist, + keepMaps: opts.keepMaps, + ignoreScripts: opts.ignoreScripts, + cliVersion: VERSION, + quiet, + }; + const release = await build(ext, options); + + for (const [kind, file] of Object.entries(release.files)) { + const pinned = file!.builtAgainst ? `, built against ${Object.entries(file!.builtAgainst).filter(([name]) => name.startsWith('@fleetbase/')).map(([name, version]) => `${name}@${version}`).join(', ')}` : ''; + log.success(`${kind.padEnd(6)} ${file!.path} (${formatBytes(file!.size)})${pinned}`); + } + log.info(` wrote ${path.relative(process.cwd(), releaseFilePath(ext.root)) || releaseFilePath(ext.root)}`); + return release; +} diff --git a/src/commands/bump.ts b/src/commands/bump.ts new file mode 100644 index 0000000..9406c17 --- /dev/null +++ b/src/commands/bump.ts @@ -0,0 +1,66 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Command } from 'commander'; +import semver from 'semver'; +import { findExtensionRoot, isSemver } from '../manifest.js'; +import { log } from '../output.js'; +import { CliError } from '../util.js'; + +/** + * `flb bump`: one version in extension.json, package.json and composer.json (when it has one), + * which the registry checks require to agree. + */ + +const RELEASES = ['major', 'minor', 'patch', 'premajor', 'preminor', 'prepatch', 'prerelease'] as const; + +export function register(program: Command): void { + program + .command('bump') + .description('Set the extension version in extension.json, package.json and composer.json') + .argument('', `an exact version, or ${RELEASES.join(', ')}`) + .option('--preid ', 'pre-release identifier for pre* bumps (beta, alpha, rc)', 'beta') + .option('-p, --path ', 'extension directory', '.') + .action((target: string, opts: { preid: string; path: string }) => { + const root = findExtensionRoot(opts.path); + const version = bump(root, target, opts.preid); + log.success(`Version ${version}`); + }); +} + +export function bump(root: string, target: string, preid = 'beta'): string { + const manifestFile = path.join(root, 'extension.json'); + const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')); + const current = String(manifest.version || ''); + + let next: string | null; + if ((RELEASES as readonly string[]).includes(target)) { + if (!semver.valid(current)) { + throw new CliError(`extension.json version "${current}" is not semver; pass an exact version.`); + } + next = semver.inc(current, target as semver.ReleaseType, preid); + } else { + next = target.replace(/^v/, ''); + } + if (!next || !isSemver(next)) { + throw new CliError(`"${target}" is not a version or one of ${RELEASES.join(', ')}.`); + } + + const files: string[] = []; + for (const name of ['extension.json', 'package.json', 'composer.json']) { + const file = path.join(root, name); + if (!fs.existsSync(file)) { + continue; + } + const raw = fs.readFileSync(file, 'utf8'); + const json = JSON.parse(raw); + if (name === 'composer.json' && json.version === undefined) { + continue; + } + json.version = next; + const indent = raw.match(/^[ \t]+(?=")/m)?.[0] || ' '; + fs.writeFileSync(file, JSON.stringify(json, null, indent) + '\n'); + files.push(name); + } + log.info(` ${current} → ${next} in ${files.join(', ')}`); + return next; +} diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts new file mode 100644 index 0000000..6e2228d --- /dev/null +++ b/src/commands/catalog.ts @@ -0,0 +1,140 @@ +import type { Command } from 'commander'; +import { type Context, context } from '../context.js'; +import { ApiError, type Paged } from '../http.js'; +import { colors, formatBytes, formatDate, log, table } from '../output.js'; +import type { CatalogListing, CatalogRelease } from '../types.js'; +import { CliError } from '../util.js'; + +export function register(program: Command): void { + program + .command('search') + .alias('list-extensions') + .description('Search the public catalog') + .argument('[query]', 'words to search for') + .option('-c, --category ', 'category slug (comma separated for several)') + .option('--pricing ', 'free or paid') + .option('--free', 'only free extensions (same as --pricing free)') + .option('--hosting ', 'cloud or self_hosted') + .option('--publisher ', 'publisher slug') + .option('--sort ', 'popular, newest, updated, name or price') + .option('-l, --limit ', 'results per page', '20') + .option('-p, --page ', 'page', '1') + .option('--json', 'print JSON') + .action((query: string | undefined, opts, command: Command) => search(context(command), query, opts)); + + program + .command('info') + .description('Show a catalog listing and its releases') + .argument('', 'slug or ext_ id') + .option('--json', 'print JSON') + .action((slug: string, _opts, command: Command) => info(context(command), slug)); +} + +function price(listing: CatalogListing): string { + if (!listing.price || !listing.is_paid) { + return 'free'; + } + return `${(listing.price.effective_amount / 100).toFixed(2)} ${listing.price.currency}`; +} + +async function search(ctx: Context, query: string | undefined, opts: Record): Promise { + const result = await ctx.anonymous().get>('v1/catalog/extensions', { + query: { + q: query, + category: opts.category as string, + pricing: opts.free ? 'free' : (opts.pricing as string), + hosting: opts.hosting as string, + publisher: opts.publisher as string, + sort: opts.sort as string, + limit: opts.limit as string, + page: opts.page as string, + }, + }); + + if (ctx.json) { + log.json(result); + return; + } + if (result.data.length === 0) { + log.info(query ? `No extensions match "${query}".` : 'No extensions found.'); + return; + } + log.line( + table(result.data, [ + { header: 'SLUG', value: (l) => l.slug }, + { header: 'NAME', value: (l) => l.name, max: 30 }, + { header: 'VERSION', value: (l) => l.latest_version || '' }, + { header: 'PUBLISHER', value: (l) => (l.publisher ? `${l.publisher.slug}${l.publisher.is_verified ? ' ✓' : ''}` : '') }, + { header: 'PRICE', value: price }, + { header: 'INSTALLS', value: (l) => l.installs_count }, + { header: 'SUMMARY', value: (l) => l.subtitle || '', max: 50 }, + ]), + ); + const { meta } = result; + if (meta.last_page > 1) { + log.info(colors.dim(`page ${meta.current_page} of ${meta.last_page} (${meta.total} extensions); --page ${meta.current_page + 1} for more`)); + } +} + +async function info(ctx: Context, slug: string): Promise { + const client = ctx.anonymous(); + let listing: CatalogListing; + try { + listing = await client.get(`v1/catalog/extensions/${encodeURIComponent(slug)}`); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + throw new CliError(`No published extension "${slug}" on ${ctx.host}.`, 1, ['Search with `flb search `.']); + } + throw error; + } + const releases = await client.get<{ latest: string | null; channels: Record; releases: CatalogRelease[] }>(`v1/catalog/extensions/${encodeURIComponent(slug)}/releases`); + + if (ctx.json) { + log.json({ ...listing, releases: releases.releases, channels: releases.channels }); + return; + } + + log.line(`${colors.bold(listing.name)} ${colors.dim(listing.slug)}${listing.latest_version ? ` ${listing.latest_version}` : ''}`); + if (listing.subtitle) { + log.line(listing.subtitle); + } + log.line(''); + const rows: [string, string | null | undefined][] = [ + ['publisher', listing.publisher ? `${listing.publisher.name} (${listing.publisher.slug})${listing.publisher.is_verified ? ' ✓ verified' : ''}` : null], + ['category', listing.category?.name], + ['price', price(listing)], + ['hosting', listing.hosting], + ['compat', listing.compat ? `console ${listing.compat.console || 'any'}, api ${listing.compat.api || 'any'}` : null], + ['npm', listing.packages.npm], + ['composer', listing.packages.composer], + ['installs', String(listing.installs_count)], + ['website', listing.website_url], + ['source', listing.repo_url], + ['support', listing.support_url], + ['tags', listing.tags.join(', ')], + ]; + for (const [label, value] of rows) { + if (value) { + log.line(` ${label.padEnd(10)} ${value}`); + } + } + if (listing.description) { + log.line(''); + log.line(listing.description); + } + if (releases.releases.length) { + log.line(''); + log.line( + table(releases.releases.slice(0, 10), [ + { header: 'VERSION', value: (r) => r.version }, + { header: 'CHANNEL', value: (r) => r.channel }, + { header: 'STATUS', value: (r) => (r.status === 'yanked' ? colors.red('yanked') : r.status) }, + { header: 'PUBLISHED', value: (r) => formatDate(r.published_at) }, + { header: 'SIZE', value: (r) => formatBytes(r.size_bytes) }, + { header: 'COMPAT', value: (r) => (r.compat ? `console ${r.compat.console || 'any'}, api ${r.compat.api || 'any'}` : '') }, + ]), + ); + } + log.line(''); + log.line(`Install on an instance: flb install ${listing.slug} --path `); +} diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..44bb14c --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,77 @@ +import type { Command } from 'commander'; +import { configDisplayPath, configPath, hostKey, maskToken, normalizeUrl, readConfig, updateConfig } from '../config.js'; +import { log } from '../output.js'; +import { CliError } from '../util.js'; + +/** Keys `flb config set` may change; tokens only change through login, logout and token commands. */ +const KEYS: Record string }> = { + registry: { key: 'registry', normalize: (value) => normalizeUrl(value) }, + organization: { key: 'defaultOrganization' }, +}; + +export function register(program: Command): void { + const config = program.command('config').description(`Read and change ${configDisplayPath()}`); + + config + .command('list') + .description('Show the config with tokens masked') + .action(() => { + const current = readConfig(); + const masked = { + ...current, + tokens: Object.fromEntries(Object.entries(current.tokens || {}).map(([host, token]) => [host, maskToken(token)])), + instances: Object.fromEntries(Object.entries(current.instances || {}).map(([id, entry]) => [id, { ...entry, ...(entry.token ? { token: maskToken(entry.token) } : {}) }])), + }; + log.line(JSON.stringify(masked, null, 2)); + }); + + config + .command('get ') + .description(`Print a value (${Object.keys(KEYS).join(', ')})`) + .action((name: string) => { + const spec = KEYS[name]; + if (!spec) { + throw new CliError(`Unknown key "${name}". Keys: ${Object.keys(KEYS).join(', ')}.`); + } + const value = readConfig()[spec.key]; + if (value !== undefined) { + log.line(String(value)); + } + }); + + config + .command('set ') + .description(`Set a value (${Object.keys(KEYS).join(', ')})`) + .action((name: string, value: string) => { + const spec = KEYS[name]; + if (!spec) { + throw new CliError(`Unknown key "${name}". Keys: ${Object.keys(KEYS).join(', ')}.`); + } + const next = spec.normalize ? spec.normalize(value) : value; + if (spec.key === 'registry') { + hostKey(next); + } + updateConfig((current) => ({ ...current, [spec.key]: next })); + log.success(`${name} = ${next}`); + }); + + config + .command('unset ') + .description('Remove a value') + .action((name: string) => { + const spec = KEYS[name]; + if (!spec) { + throw new CliError(`Unknown key "${name}". Keys: ${Object.keys(KEYS).join(', ')}.`); + } + updateConfig((current) => { + delete current[spec.key]; + return current; + }); + log.success(`${name} unset`); + }); + + config + .command('path') + .description('Print the config file path') + .action(() => log.line(configPath())); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..d5050e3 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,189 @@ +import os from 'node:os'; +import fs from 'node:fs'; +import type { Command } from 'commander'; +import semver from 'semver'; +import { Artisan, resolveTarget } from '../artisan.js'; +import { run } from '../build.js'; +import { type ConfigError, configDisplayPath, configModeProblem, configPath, getToken, hostKey, instancesFor, readConfig, resolveRegistry } from '../config.js'; +import { ApiError, RegistryClient } from '../http.js'; +import { colors, formatDate, log } from '../output.js'; +import type { Account, TokenDetails } from '../types.js'; + +/** + * `flb doctor`: config, registry connectivity, token validity and scopes, registered + * instances, the local tools the CLI shells out to, and (with --path/--docker) the instance's + * installer commands. + */ + +type Level = 'ok' | 'warn' | 'fail'; + +export interface Finding { + check: string; + level: Level; + detail: string; +} + +export function register(program: Command): void { + program + .command('doctor') + .description('Check the config, token, registry connectivity, instances and local tools') + .option('-p, --path ', 'also check the Fleetbase instance at this path') + .option('-d, --docker [service]', 'check the instance inside this docker compose service') + .option('-f, --compose-file ', 'docker compose file') + .option('--json', 'print JSON') + .action(async (opts, command: Command) => { + const all = command.optsWithGlobals(); + const findings = await diagnose({ registry: all.registry, path: opts.path, docker: opts.docker, composeFile: opts.composeFile }); + if (all.json) { + log.json(findings); + } else { + for (const finding of findings) { + const mark = finding.level === 'ok' ? colors.green('✓') : finding.level === 'warn' ? colors.yellow('!') : colors.red('✗'); + log.line(`${mark} ${finding.check.padEnd(20)} ${finding.detail}`); + } + } + if (findings.some((finding) => finding.level === 'fail')) { + process.exitCode = 1; + } + }); +} + +export interface DiagnoseOptions { + registry?: string; + path?: string; + docker?: string | boolean; + composeFile?: string; + fetch?: typeof fetch; +} + +export async function diagnose(options: DiagnoseOptions): Promise { + const findings: Finding[] = []; + const add = (check: string, level: Level, detail: string) => findings.push({ check, level, detail }); + + add('node', semver.gte(process.versions.node, '20.10.0') ? 'ok' : 'fail', `v${process.versions.node}${semver.gte(process.versions.node, '20.10.0') ? '' : ' (needs 20.10 or later)'}`); + + // config + let config; + try { + config = readConfig(); + } catch (error) { + add('config', 'fail', (error as ConfigError).message); + return findings; + } + if (!fs.existsSync(configPath())) { + add('config', 'warn', `${configDisplayPath()} does not exist yet (flb login creates it)`); + } else { + const mode = configModeProblem(); + add('config', mode ? 'warn' : 'ok', mode ? `${configDisplayPath()} ${mode}; run chmod 600 ${configPath()}` : configDisplayPath()); + } + + // registry + let registry: string; + try { + registry = resolveRegistry(options.registry, config); + } catch (error) { + add('registry', 'fail', (error as Error).message); + return findings; + } + const client = new RegistryClient(registry, { fetch: options.fetch, timeoutMs: 10_000 }); + let reachable = false; + try { + const categories = await client.get('v1/catalog/categories'); + reachable = true; + add('registry', Array.isArray(categories) || (categories as { data?: unknown })?.data ? 'ok' : 'warn', `${registry}${Array.isArray(categories) ? '' : ' (answers, but not like a Registry Service v2)'}`); + } catch (error) { + add('registry', 'fail', `${registry}: ${(error as Error).message}`); + } + + // token + const stored = getToken(config, registry); + if (!stored) { + add('token', 'warn', `none for ${hostKey(registry)}; run flb login`); + } else if (stored.legacy) { + add('token', 'fail', `${stored.source === 'env' ? 'FLB_TOKEN' : 'the stored token'} is not a Registry Service token (v1?); run flb login`); + } else if (reachable) { + const authed = client.withToken(stored.token); + try { + const account = await authed.get('v1/account'); + const tokens = await authed.get('v1/account/tokens').catch(() => [] as TokenDetails[]); + const meta = config.hosts?.[hostKey(registry)]; + const details = tokens.find((item) => item.id === meta?.tokenId) || tokens.find((item) => !item.revoked_at && stored.token.endsWith(item.last_four)); + const scopes = details?.scopes || meta?.scopes || []; + const expires = details?.expires_at || meta?.expiresAt; + const days = expires ? Math.floor((new Date(expires).getTime() - Date.now()) / 86_400_000) : null; + add('token', 'ok', `${account.email}${stored.source === 'env' ? ' (FLB_TOKEN)' : ''}${expires ? `, expires ${formatDate(expires)}` : ''}`); + if (days !== null && days < 14) { + add('token expiry', 'warn', `expires in ${days} day(s); run flb login to replace it`); + } + const missing = ['publish', 'instance:register'].filter((scope) => !scopes.includes(scope) && !scopes.includes('*')); + add('token scopes', missing.length ? 'warn' : 'ok', `${scopes.join(', ') || 'unknown'}${missing.length ? `; without ${missing.join(', ')} some commands are refused` : ''}`); + } catch (error) { + add('token', 'fail', error instanceof ApiError && error.status === 401 ? 'expired or revoked; run flb login' : (error as Error).message); + } + } + + // instances + if (reachable) { + for (const entry of instancesFor(config, registry)) { + if (!entry.token) { + add(`instance ${entry.id}`, 'ok', `${entry.name || entry.id}: token held by the instance (${entry.target?.path || 'remote'})`); + continue; + } + try { + const me = await client.get<{ instance: { status: string; name: string } }>('v1/instances/me', { token: entry.token }); + add(`instance ${entry.id}`, me.instance.status === 'active' ? 'ok' : 'warn', `${me.instance.name}: ${me.instance.status}`); + } catch (error) { + add(`instance ${entry.id}`, 'fail', error instanceof ApiError && error.status === 401 ? 'token revoked; register again' : (error as Error).message); + } + } + } + + // tools + const tools: [string, string[], Level][] = [ + ['npm', ['--version'], 'fail'], + ['git', ['--version'], 'warn'], + ['pnpm', ['--version'], 'warn'], + ['composer', ['--version', '--no-ansi'], 'warn'], + ['php', ['--version'], 'warn'], + ['docker', ['--version'], 'warn'], + ]; + const results = await Promise.all( + tools.map(async ([tool, args, missingLevel]): Promise<[string, Level, string]> => { + try { + // from the temp dir: pnpm and composer read workspace files in the current directory + const result = await run(tool, args, { cwd: os.tmpdir() }); + const version = (result.stdout || result.stderr).trim().split('\n')[0]; + return [tool, result.code === 0 ? 'ok' : missingLevel, version || `exit ${result.code}`]; + } catch { + return [tool, missingLevel, 'not found']; + } + }), + ); + for (const [tool, level, detail] of results) { + add(tool, level, detail); + } + + // instance installer + if (options.path || options.docker) { + try { + const artisan = new Artisan(resolveTarget(options)); + const result = await artisan.run(['list', 'registry', '--format=json']); + const commands: string[] = (() => { + try { + return (JSON.parse(result.stdout).commands || []).map((command: { name: string }) => command.name); + } catch { + return []; + } + })(); + const needed = ['registry:install', 'registry:uninstall', 'registry:update']; + const missing = needed.filter((name) => !commands.includes(name)); + add('instance installer', missing.length ? 'fail' : 'ok', missing.length ? `${artisan.describe()}: missing ${missing.join(', ')} (update registry-bridge)` : artisan.describe()); + add('installer job status', commands.includes('registry:job') ? 'ok' : 'warn', commands.includes('registry:job') ? 'registry:job available' : 'no registry:job command: flb install cannot follow queued jobs (use --sync)'); + add('instance registration', commands.includes('registry:register') ? 'ok' : 'warn', commands.includes('registry:register') ? 'registry:register available' : 'no registry:register command: flb instance register --path cannot register the instance (update registry-bridge)'); + } catch (error) { + add('instance installer', 'fail', (error as Error).message); + } + } + + return findings; +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..4b595bf --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,384 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Command } from 'commander'; +import { run } from '../build.js'; +import { hostMeta } from '../config.js'; +import { type Context, context } from '../context.js'; +import { COMPOSER_NAME, type ExtensionManifest, NPM_NAME, validRange, validateManifest } from '../manifest.js'; +import { log } from '../output.js'; +import { ask, interactive } from '../prompt.js'; +import { CliError, slugify, splitList, studly } from '../util.js'; + +/** + * `flb init`: a new extension from fleetbase/starter-extension, with the names, PHP namespace, + * engine route and extension.json (version, engine/api, compat ranges) rewritten. + */ + +export const STARTER_TEMPLATE = 'https://github.com/fleetbase/starter-extension.git'; +/** console 0.7.x and core-api 1.6+ are what the starter targets today */ +export const DEFAULT_COMPAT = { console: '>=0.7.0 <0.8.0', api: '^1.6' }; +export const INITIAL_VERSION = '0.1.0'; + +const SKIP = new Set(['.git', 'node_modules', 'server_vendor', 'vendor', 'dist', 'tmp', '.php-cs-fixer.cache']); +const TEXT = /\.(js|ts|mjs|cjs|json|php|hbs|yaml|yml|md|css|html|txt|neon|dist|xml)$|^\.[a-z-]+rc(\.js)?$/i; +const LOCKFILES = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'composer.lock']); + +export function register(program: Command): void { + program + .command('init') + .alias('scaffold') + .description('Create a new extension from fleetbase/starter-extension') + .argument('[directory]', 'directory to create (default: the extension slug)') + .option('-n, --name ', 'extension name, for example "Fleet Insights"') + .option('-d, --description ', 'one-line description') + .option('-o, --organization ', 'npm scope and Composer vendor (default: your default organization)') + .option('--package ', 'npm package name (default: @/-engine)') + .option('--composer ', 'Composer package name (default: /-api)') + .option('--namespace ', 'PHP namespace (default: \\)') + .option('-a, --author ', 'author, for example "Acme Ltd "') + .option('-k, --keywords ', 'comma separated keywords') + .option('--repository ', 'source repository URL') + .option('--console ', 'compat.console: console versions this works with', DEFAULT_COMPAT.console) + .option('--api ', 'compat.api: core-api versions this works with', DEFAULT_COMPAT.api) + .option('--template ', 'git URL or local directory to start from', process.env.FLB_STARTER_TEMPLATE || STARTER_TEMPLATE) + .option('--ref ', 'branch or tag of the template') + .option('--no-git', 'do not run git init') + .option('-y, --yes', 'accept defaults instead of prompting') + .action(async (directory: string | undefined, opts, command: Command) => { + await init(context(command), directory, opts); + }); +} + +export interface InitOptions { + name?: string; + description?: string; + organization?: string; + package?: string; + composer?: string; + namespace?: string; + author?: string; + keywords?: string; + repository?: string; + console: string; + api: string; + template: string; + ref?: string; + git: boolean; + yes?: boolean; +} + +export interface Names { + name: string; + slug: string; + studly: string; + organization: string; + npm: string; + composer: string; + namespace: string; +} + +export function deriveNames(name: string, organization: string, overrides: { package?: string; composer?: string; namespace?: string } = {}): Names { + const slug = slugify(name); + const org = slugify(organization); + if (!slug) { + throw new CliError(`"${name}" does not make a usable slug; use letters or digits.`); + } + if (!org) { + throw new CliError(`"${organization}" is not a usable organization slug.`); + } + const names: Names = { + name, + slug, + studly: studly(name), + organization: org, + npm: overrides.package || `@${org}/${slug}-engine`, + composer: overrides.composer || `${org}/${slug}-api`, + namespace: (overrides.namespace || `${studly(org)}\\${studly(name)}`).replace(/^\\+|\\+$/g, ''), + }; + if (!NPM_NAME.test(names.npm)) { + throw new CliError(`"${names.npm}" is not a valid npm package name.`); + } + if (!COMPOSER_NAME.test(names.composer)) { + throw new CliError(`"${names.composer}" is not a valid Composer package name.`); + } + if (!/^[A-Z][A-Za-z0-9]*(\\[A-Z][A-Za-z0-9]*)*$/.test(names.namespace)) { + throw new CliError(`"${names.namespace}" is not a PHP namespace like Acme\\FleetInsights.`); + } + return names; +} + +export async function init(ctx: Context, directory: string | undefined, opts: InitOptions): Promise { + const prompting = interactive() && !opts.yes; + const name = opts.name || (prompting ? await ask('Extension name', '--name') : undefined); + if (!name) { + throw new CliError('Missing --name.'); + } + const account = hostMeta(ctx.config, ctx.registry).account; + const fallbackOrg = ctx.organization(opts.organization) || account?.username || undefined; + const organization = opts.organization || fallbackOrg || (prompting ? await ask('Organization slug (npm scope and Composer vendor)', '--organization') : undefined); + if (!organization) { + throw new CliError('Missing --organization (your registry organization slug, used as the npm scope and Composer vendor).'); + } + const description = opts.description ?? (prompting ? await ask('Description', '--description', `${name} extension for Fleetbase`) : `${name} extension for Fleetbase`); + + for (const [kind, range] of [['console', opts.console], ['api', opts.api]] as const) { + if (!validRange(range, kind)) { + throw new CliError(`--${kind} "${range}" is not a valid version range.`); + } + } + + const names = deriveNames(name, organization, opts); + const target = path.resolve(directory || names.slug); + if (fs.existsSync(target) && fs.readdirSync(target).length > 0) { + throw new CliError(`${target} exists and is not empty.`); + } + + log.step(`Creating ${names.name} in ${target}`); + await fetchTemplate(opts.template, target, opts.ref); + const manifest = await scaffold(target, names, { + description, + author: opts.author, + keywords: splitList(opts.keywords), + repository: opts.repository, + compat: { console: opts.console, api: opts.api }, + }); + + if (opts.git) { + const result = await run('git', ['init', '-q'], { cwd: target }).catch(() => null); + if (!result || result.code !== 0) { + log.warn('git init failed; the directory is not a repository.'); + } + } + + log.success(`Scaffolded ${names.npm} and ${names.composer} (namespace ${names.namespace}).`); + log.info(` extension.json: version ${manifest.version}, compat console ${manifest.compat?.console}, api ${manifest.compat?.api}`); + log.info(''); + log.info('Next:'); + log.info(` cd ${path.relative(process.cwd(), target) || '.'}`); + log.info(' pnpm install'); + log.info(' flb build'); + log.info(' flb publish'); + return target; +} + +async function fetchTemplate(source: string, target: string, ref?: string): Promise { + const local = !/^(https?|git|ssh):\/\/|^git@/.test(source) && fs.existsSync(source); + if (local) { + copyDir(path.resolve(source), target); + return; + } + const parent = path.dirname(target); + fs.mkdirSync(parent, { recursive: true }); + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'flb-init-')); + try { + const args = ['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), source, work]; + const result = await run('git', args, { cwd: parent }); + if (result.code !== 0) { + throw new CliError(`Could not clone ${source}:\n${result.stderr.trim()}`); + } + copyDir(work, target); + } finally { + fs.rmSync(work, { recursive: true, force: true }); + } +} + +function copyDir(from: string, to: string): void { + fs.cpSync(from, to, { + recursive: true, + filter: (src) => !SKIP.has(path.basename(src)) || src === from, + verbatimSymlinks: true, + }); +} + +interface ScaffoldDetails { + description: string; + author?: string; + keywords: string[]; + repository?: string; + compat: { console: string; api: string }; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); +} + +/** Escape a value for a single-quoted JS or PHP string literal: backslashes first, then quotes. */ +function quoteSingle(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +function replacements(names: Names, description: string): [RegExp, string][] { + const ns = names.namespace; + const nsEscaped = ns.replace(/\\/g, '\\\\'); + return [ + [/@fleetbase\/starter-engine/g, names.npm], + [/fleetbase\/(starter-api|extension-starter)/g, names.composer], + [/Fleetbase\\\\Starter/g, nsEscaped], + [/Fleetbase\\Starter/g, ns], + [/StarterEngine/g, `${names.studly}Engine`], + [/StarterServiceProvider/g, `${names.studly}ServiceProvider`], + [/StarterResourceController/g, `${names.studly}ResourceController`], + [/console\.starter\b/g, `console.${names.slug}`], + [/config\('starter\./g, `config('${names.slug}.`], + [/'prefix' => 'starter'/g, `'prefix' => '${names.slug}'`], + [/, 'starter'\)/g, `, '${names.slug}')`], + [/registerHeaderMenuItem\('Starter'/g, `registerHeaderMenuItem('${quoteSingle(names.name)}'`], + [/^starter:/gm, `${names.slug}:`], + [/extension-name: Starter/g, `extension-name: ${names.name}`], + [/starter-virtual-/g, `${names.slug}-virtual-`], + [/Starter API Routes/g, `${names.name} API Routes`], + [/Starter extension service provider/g, `${names.name} extension service provider`], + [/'Samp?le starter extension for developing in Fleetbase\.'/g, `'${quoteSingle(description)}'`], + [/Welcome to your Fleetbase starter extension\./g, `Welcome to ${escapeHtml(names.name)}.`], + ]; +} + +function walk(dir: string, visit: (file: string) => void): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (SKIP.has(entry.name)) { + continue; + } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, visit); + } else if (entry.isFile()) { + visit(full); + } + } +} + +function readJsonFile(file: string): Record | null { + return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null; +} + +function writeJsonFile(file: string, value: unknown): void { + fs.writeFileSync(file, JSON.stringify(value, null, 4) + '\n'); +} + +export async function scaffold(root: string, names: Names, details: ScaffoldDetails): Promise { + // 1. text replacements across the template + const rules = replacements(names, details.description); + walk(root, (file) => { + const base = path.basename(file); + if (LOCKFILES.has(base) || !TEXT.test(base)) { + return; + } + const before = fs.readFileSync(file, 'utf8'); + const after = rules.reduce((text, [pattern, replacement]) => text.replace(pattern, () => replacement), before); + if (after !== before) { + fs.writeFileSync(file, after); + } + }); + + // 2. file renames + const renames: [string, string][] = [ + ['server/config/starter.php', `server/config/${names.slug}.php`], + ['server/src/Providers/StarterServiceProvider.php', `server/src/Providers/${names.studly}ServiceProvider.php`], + ['server/src/Http/Controllers/StarterResourceController.php', `server/src/Http/Controllers/${names.studly}ResourceController.php`], + ]; + for (const [from, to] of renames) { + const source = path.join(root, from); + if (fs.existsSync(source)) { + fs.renameSync(source, path.join(root, to)); + } + } + + // 3. manifests + const author = details.author; + const pkgFile = path.join(root, 'package.json'); + const pkg = readJsonFile(pkgFile); + if (pkg) { + pkg.name = names.npm; + pkg.version = INITIAL_VERSION; + pkg.description = details.description; + pkg.keywords = [...new Set([...(pkg.keywords || []), ...details.keywords])]; + pkg.fleetbase = { ...(pkg.fleetbase || {}), route: names.slug }; + if (details.repository) { + pkg.repository = details.repository; + } else { + delete pkg.repository; + } + if (author) { + pkg.author = author; + } + // the starter's publish scripts point at npmjs and GitHub Packages; releases go through flb publish + for (const script of ['publish:npm', 'publish:github']) { + delete pkg.scripts?.[script]; + } + writeJsonFile(pkgFile, pkg); + } + + const composerFile = path.join(root, 'composer.json'); + const composer = readJsonFile(composerFile); + if (composer) { + composer.name = names.composer; + composer.description = details.description; + if (composer.version !== undefined) { + composer.version = INITIAL_VERSION; + } + composer.keywords = [...new Set([...(composer.keywords || []), ...details.keywords])]; + if (author) { + const match = author.match(/^(.*?)\s*<([^>]+)>$/); + composer.authors = [match ? { name: match[1], email: match[2] } : { name: author }]; + } + writeJsonFile(composerFile, composer); + } + + const manifestFile = path.join(root, 'extension.json'); + const existing = readJsonFile(manifestFile) || {}; + const manifest: ExtensionManifest = { + name: names.name, + version: INITIAL_VERSION, + description: details.description, + ...(pkg ? { engine: 'package.json' } : {}), + ...(composer ? { api: 'composer.json' } : {}), + compat: details.compat, + ...(details.keywords.length ? { keywords: details.keywords.slice(0, 20) } : {}), + ...(details.repository ? { repository: details.repository } : {}), + license: typeof existing.license === 'string' ? existing.license : 'AGPL-3.0-or-later', + ...(author ? { author } : {}), + }; + const problems = validateManifest(manifest); + if (problems.length) { + throw new CliError(`The generated extension.json is invalid: ${problems.join(' ')}`); + } + writeJsonFile(manifestFile, manifest); + + // 4. keep build output out of git and out of the engine tarball + for (const ignore of ['.gitignore', '.npmignore']) { + const file = path.join(root, ignore); + const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; + if (!/^\/?\.fleetbase\/?$/m.test(current)) { + fs.writeFileSync(file, `${current}${current === '' || current.endsWith('\n') ? '' : '\n'}\n# flb build output\n/.fleetbase/\n`); + } + } + + // 5. a README for this extension instead of the starter's + fs.writeFileSync( + path.join(root, 'README.md'), + [ + `# ${names.name}`, + '', + details.description, + '', + '## Packages', + '', + `- Engine (console): \`${names.npm}\``, + `- API (server): \`${names.composer}\``, + '', + '## Develop and publish', + '', + '```bash', + 'pnpm install', + 'flb build # engine and api archives in .fleetbase/build', + 'flb publish # upload, run the registry checks, then submit with --submit', + '```', + '', + `Compatibility is declared in \`extension.json\`: console \`${details.compat.console}\`, api \`${details.compat.api}\`.`, + '', + ].join('\n'), + ); + + return manifest; +} diff --git a/src/commands/installer.ts b/src/commands/installer.ts new file mode 100644 index 0000000..f66e065 --- /dev/null +++ b/src/commands/installer.ts @@ -0,0 +1,173 @@ +import type { Command } from 'commander'; +import { Artisan, FINISHED, type InstallerJob, type InstallerRequest, installerArgs, resolveTarget } from '../artisan.js'; + +export { setRunner } from '../artisan.js'; +import { colors, log } from '../output.js'; +import { CliError, parseTimeout, sleep, splitVersion } from '../util.js'; + +/** + * `flb install | uninstall | update`: the same installer job the console uses, started with + * `php artisan registry:*` on the instance and followed until it finishes. + * + * Following a queued job uses `php artisan registry:job --json`. Bridges without that + * command get the job id and a hint instead; `--sync` runs the job in the artisan process. + */ + +function addOptions(command: Command, versioned: boolean): Command { + command + .option('-p, --path ', 'Fleetbase checkout or API directory (default: current directory)') + .option('-d, --docker [service]', 'run inside the docker compose service (default service: application)') + .option('-f, --compose-file ', 'docker compose file (default: the one in --path)') + .option('--company ', 'company uuid or public id (needed on instances with several companies)') + .option('--sync', 'run the job in the artisan process instead of the installer queue') + .option('--no-wait', 'return once the job is queued') + .option('--timeout ', 'how long to follow the job', '2h') + .option('--json', 'print the final job record as JSON'); + if (versioned) { + command.option('--release ', 'version to install (or name@version)'); + } + return command; +} + +export function register(program: Command): void { + const actions: [InstallerRequest['action'], string, boolean][] = [ + ['install', 'Install an extension on a Fleetbase instance', true], + ['update', 'Update an installed extension (default: to the newest compatible release)', true], + ['uninstall', 'Uninstall an extension from a Fleetbase instance', false], + ]; + for (const [action, description, versioned] of actions) { + addOptions(program.command(action).description(description).argument('', versioned ? 'slug, ext_ id or package name, optionally @version' : 'slug, ext_ id or package name'), versioned).action(async (extension: string, opts, command: Command) => { + await runInstaller(action, extension, { ...opts, json: command.optsWithGlobals().json }); + }); + } +} + +interface InstallerOptions { + path?: string; + docker?: string | boolean; + composeFile?: string; + company?: string; + sync?: boolean; + wait: boolean; + timeout: string; + release?: string; + json?: boolean; + /** poll interval, for tests */ + intervalMs?: number; +} + +export async function runInstaller(action: InstallerRequest['action'], ref: string, opts: InstallerOptions): Promise { + const [extension, inlineVersion] = action === 'uninstall' ? [ref, undefined] : splitVersion(ref); + if (inlineVersion && opts.release && inlineVersion !== opts.release) { + throw new CliError(`Two versions given: ${ref} and --release ${opts.release}.`); + } + const version = opts.release || inlineVersion; + const artisan = new Artisan(resolveTarget(opts)); + const label = `${extension}${version ? `@${version}` : ''}`; + const timeoutMs = parseTimeout(opts.timeout); + + log.step(`${capitalize(action)} ${label} on ${artisan.describe()}`); + const { result, data, missing } = await artisan.json(installerArgs({ action, extension, version, company: opts.company, sync: opts.sync }), { + onStderr: (chunk) => { + if (opts.sync && !opts.json) { + process.stderr.write(chunk); + } + }, + }); + + if (missing) { + throw new CliError(`This instance's registry-bridge has no registry:${action} command.`, 1, ['Update fleetbase/registry-bridge to a release with the queued installer (registry-bridge#45).']); + } + if (!data) { + throw new CliError(`php artisan registry:${action} failed (exit ${result.code}):\n${(result.stderr || result.stdout).trim().split('\n').slice(-20).join('\n')}`); + } + if (data.error) { + throw new CliError(String(data.error)); + } + + let job = data as InstallerJob; + if (job.duplicate) { + log.info(` already queued as job ${job.id}; following that one`); + } + + if (!FINISHED.includes(job.status) && opts.wait) { + job = await follow(artisan, job, { timeoutMs, intervalMs: opts.intervalMs ?? 2000, quiet: Boolean(opts.json) }); + } + + if (opts.json) { + log.json(job); + } + report(action, label, job); + if (job.status === 'failed') { + process.exitCode = 1; + } + return job; +} + +async function follow(artisan: Artisan, initial: InstallerJob, options: { timeoutMs: number; intervalMs: number; quiet: boolean }): Promise { + const deadline = Date.now() + options.timeoutMs; + let job = initial; + let lastLine = ''; + let printedLog = 0; + const show = (current: InstallerJob) => { + if (options.quiet) { + return; + } + const line = current.status === 'queued' ? `queued${current.position ? `, ${current.position} job(s) ahead` : ', next in line'}` : `${current.stage || current.status}${current.progress !== undefined ? ` ${current.progress}%` : ''}${current.message ? `: ${current.message}` : ''}`; + if (line !== lastLine) { + lastLine = line; + log.info(` ${colors.dim(new Date().toLocaleTimeString())} ${line}`); + } + const lines = current.log || []; + for (const entry of lines.slice(printedLog)) { + log.info(` ${colors.dim(entry)}`); + } + printedLog = Math.max(printedLog, lines.length); + }; + + show(job); + for (;;) { + if (Date.now() > deadline) { + throw new CliError(`Job ${job.id} is still ${job.status} after the timeout; it keeps running on the instance.`, 2); + } + await sleep(options.intervalMs); + const { data, missing } = await artisan.json(['registry:job', job.id, '--json']); + if (missing) { + if (!options.quiet) { + log.warn(`This registry-bridge has no registry:job command, so the CLI cannot follow job ${job.id}.`); + log.info(' Watch it in the console (Extensions), or rerun with --sync to run it in this process.'); + } + return job; + } + if (!data || data.error) { + throw new CliError(`Could not read job ${job.id}: ${data?.error || 'no output'}`); + } + job = data as InstallerJob; + show(job); + if (FINISHED.includes(job.status)) { + return job; + } + } +} + +function report(action: string, label: string, job: InstallerJob): void { + switch (job.status) { + case 'completed': + log.success(`${capitalize(action)} of ${label} completed (job ${job.id}).`); + break; + case 'failed': + log.error(`${capitalize(action)} of ${label} failed at ${job.failed_stage || job.stage || 'an unknown stage'}: ${job.message || 'no message'}`); + for (const line of (job.log || []).slice(-50)) { + log.info(` ${line}`); + } + log.info(' The installer rolls back to the snapshot it took before the job.'); + break; + default: + log.success(`${capitalize(action)} of ${label} queued as job ${job.id}${job.position ? ` (${job.position} ahead)` : ''}.`); + log.info(' It runs on the installer queue: php artisan queue:work --queue=installer --timeout=7200'); + } +} + +function capitalize(value: string): string { + return value[0].toUpperCase() + value.slice(1); +} diff --git a/src/commands/instance.ts b/src/commands/instance.ts new file mode 100644 index 0000000..a33b996 --- /dev/null +++ b/src/commands/instance.ts @@ -0,0 +1,415 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Command } from 'commander'; +import { INSTANCE_TOKEN, configDisplayPath, getToken, hostMeta, instancesFor, type InstanceEntry, maskToken, removeInstance, saveInstance, updateConfig } from '../config.js'; +import { Artisan, resolveTarget, type Target } from '../artisan.js'; +import { type Context, context, registryFlag } from '../context.js'; +import { ApiError } from '../http.js'; +import { colors, formatDate, log, table } from '../output.js'; +import { ask } from '../prompt.js'; +import type { Instance } from '../types.js'; +import { CliError } from '../util.js'; + +/** + * `flb instance`: registration codes and instance tokens. + * + * `register` exchanges a one-time code (minted here when you are logged in with the + * `instance:register` scope, or pasted from the publisher website) for an instance token at + * `POST /v1/instances/register`. The token is written to the config file and nowhere else. + */ + +export function register(program: Command): void { + const instance = program.command('instance').description('Register Fleetbase instances with the Registry Service'); + + instance + .command('code') + .description('Mint a one-time registration code (valid 15 minutes) to paste into an instance') + .option('-o, --organization ', 'organization the instance will belong to') + .option('--json', 'print JSON') + .action((opts, command: Command) => mintCode(context(command), opts)); + + instance + .command('register') + .description('Register an instance: on the instance itself with --path/--docker, else keep its token in the config') + .option('--code ', 'registration code (default: mint one with your login)') + .option('-o, --organization ', 'organization, when minting the code') + .option('-p, --path ', 'Fleetbase checkout or API directory to register (runs php artisan registry:register there)') + .option('-d, --docker [service]', 'register the instance inside this docker compose service (default service: application)') + .option('-f, --compose-file ', 'docker compose file (default: the one in --path)') + .option('--service-url ', 'Registry Service URL as the instance reaches it (default: the registry this CLI uses)') + .option('--store-token', 'exchange the code here and keep the instance token in the config, even with --path') + .option('-n, --name ', 'instance name (default: the instance app name, or this hostname with --store-token)') + .option('--host ', 'the instance URL (its app.url)') + .option('--kind ', 'self_hosted or cloud (with --store-token; an instance reports its own)', 'self_hosted') + .option('--console-version ', 'console version (default: read from --path)') + .option('--api-version ', 'core-api version, with --store-token (default: read from --path)') + .option('--show-token', 'print the instance token, with --store-token (hidden by default)') + .option('--json', 'print JSON') + .action((opts, command: Command) => registerInstance(context(command), opts)); + + instance + .command('status') + .description('Show a registered instance as the service sees it, with its licences') + .argument('[id]', 'instance id (default: the last one registered for this registry)') + .option('--json', 'print JSON') + .action((id: string | undefined, _opts, command: Command) => instanceStatus(context(command), id)); + + instance + .command('updates') + .description('Extensions on the instance with a newer compatible release') + .argument('[id]', 'instance id') + .option('-c, --channel ', 'include beta releases with beta') + .option('--json', 'print JSON') + .action((id: string | undefined, opts, command: Command) => instanceUpdates(context(command), id, opts)); + + instance + .command('list') + .alias('ls') + .description('Instances registered from this machine') + .action((_opts, command: Command) => listInstances(context(command))); + + instance + .command('forget') + .description('Remove an instance token from the config (an organization admin revokes it on the service)') + .argument('', 'instance id') + .action((id: string, _opts, command: Command) => { + const ctx = context(command); + if (!ctx.config.instances?.[id]) { + throw new CliError(`No instance ${id} in ${configDisplayPath()}.`); + } + updateConfig((config) => removeInstance(config, id)); + log.success(`Removed ${id} from ${configDisplayPath()}.`); + }); +} + +interface CodeResponse { + code: string; + organization: string; + expires_at: string; + expires_in: number; +} + +async function requestCode(ctx: Context, organization?: string): Promise { + try { + return await ctx.authed().post('v1/account/instance-codes', organization ? { organization } : {}); + } catch (error) { + if (error instanceof ApiError && error.status === 403 && /scope/i.test(error.message)) { + throw new CliError(`Your token lacks the instance:register scope.`, 1, [`Log in again (\`flb login${registryFlag(ctx.registry)}\` asks for it by default), or create a token with --scopes instance:register.`]); + } + throw error; + } +} + +async function mintCode(ctx: Context, opts: { organization?: string }): Promise { + const code = await requestCode(ctx, ctx.organization(opts.organization)); + if (ctx.json) { + log.line(JSON.stringify(code, null, 2)); + return; + } + log.line(code.code); + log.info(`Single use, valid until ${new Date(code.expires_at).toLocaleTimeString()} (${Math.round(code.expires_in / 60)} minutes), for ${code.organization}.`); + log.info('Paste it on the instance: Extensions > Registry settings, or `flb instance register --code `.'); +} + +/** core-api version from api/composer.lock and console version from console/package.json. */ +export function detectVersions(dir: string): { console?: string; api?: string } { + const root = path.resolve(dir); + const result: { console?: string; api?: string } = {}; + for (const lock of [path.join(root, 'api', 'composer.lock'), path.join(root, 'composer.lock')]) { + if (fs.existsSync(lock)) { + try { + const packages = JSON.parse(fs.readFileSync(lock, 'utf8')).packages || []; + const core = packages.find((pkg: { name: string }) => pkg.name === 'fleetbase/core-api'); + if (core?.version) { + result.api = String(core.version).replace(/^v/, ''); + break; + } + } catch { + // unreadable lock file: leave the version out + } + } + } + for (const pkg of [path.join(root, 'console', 'package.json'), path.join(root, 'package.json')]) { + if (fs.existsSync(pkg)) { + try { + const json = JSON.parse(fs.readFileSync(pkg, 'utf8')); + if (json.name === '@fleetbase/console' && json.version) { + result.console = json.version; + break; + } + } catch { + // ignore + } + } + } + return result; +} + +interface RegisterOptions { + code?: string; + organization?: string; + path?: string; + docker?: string | boolean; + composeFile?: string; + serviceUrl?: string; + storeToken?: boolean; + name?: string; + host?: string; + kind: string; + consoleVersion?: string; + apiVersion?: string; + showToken?: boolean; +} + +/** Hand the registration to the instance when there is one to hand it to. */ +function instanceTarget(opts: RegisterOptions): Target | null { + if (opts.storeToken) { + return null; + } + if (opts.docker) { + return resolveTarget(opts); + } + if (opts.path) { + try { + return resolveTarget({ path: opts.path }); + } catch { + return null; + } + } + return null; +} + +async function obtainCode(ctx: Context, opts: RegisterOptions): Promise { + if (opts.code) { + return opts.code.trim().toUpperCase(); + } + if (getToken(ctx.config, ctx.registry)) { + const minted = await requestCode(ctx, ctx.organization(opts.organization)); + log.step(`Minted a registration code for ${minted.organization}`); + return minted.code; + } + return (await ask('Registration code (XXXX-XXXX-XXXX-XXXX)', '--code')).trim().toUpperCase(); +} + +async function registerInstance(ctx: Context, opts: RegisterOptions): Promise { + if (!['self_hosted', 'cloud'].includes(opts.kind)) { + throw new CliError('--kind must be self_hosted or cloud.'); + } + const target = instanceTarget(opts); + if (target && opts.showToken) { + throw new CliError('The instance keeps its own token, so there is none to show; pass --store-token to keep it in the config instead.'); + } + const code = await obtainCode(ctx, opts); + return target ? registerOnInstance(ctx, opts, target, code) : registerHere(ctx, opts, code); +} + +/** `php artisan registry:register` on the instance; the code goes over stdin and the token never leaves it. */ +async function registerOnInstance(ctx: Context, opts: RegisterOptions, target: Target, code: string): Promise { + const artisan = new Artisan(target); + const args = [ + 'registry:register', + '--code-stdin', + `--service-url=${opts.serviceUrl || ctx.registry}`, + ...(opts.name ? [`--name=${opts.name}`] : []), + ...(opts.host ? [`--host=${opts.host}`] : []), + ...(opts.consoleVersion ? [`--console-version=${opts.consoleVersion}`] : []), + '--json', + ]; + log.step(`Registering ${artisan.describe()}`); + const { result, data, missing } = await artisan.json(args, { input: code + '\n' }); + if (missing) { + throw new CliError("This instance's registry-bridge has no registry:register command.", 1, ['Update fleetbase/registry-bridge, or rerun with --store-token to keep the instance token in the config.']); + } + if (!data) { + throw new CliError(`php artisan registry:register failed (exit ${result.code}):\n${(result.stderr || result.stdout).trim().split('\n').slice(-20).join('\n')}`); + } + if (data.error) { + throw new CliError(`Registration refused: ${data.error}`, 1, ['Codes are single use and expire after 15 minutes; mint a new one with `flb instance code`.']); + } + + const summary = data as { id: string; name?: string; kind?: string; status?: string; organization?: { id?: string; slug?: string; name?: string }; service_url?: string; token_hint?: string; rotated?: boolean }; + const entry: InstanceEntry = { + id: summary.id, + name: summary.name, + kind: summary.kind, + registry: ctx.registry, + organization: summary.organization, + tokenHint: summary.token_hint, + target: { path: target.cwd, ...(target.kind === 'docker' ? { docker: target.service, ...(target.composeFile ? { composeFile: target.composeFile } : {}) } : {}) }, + registeredAt: new Date().toISOString(), + }; + updateConfig((config) => saveInstance(config, entry)); + + if (ctx.json) { + log.json(summary); + return; + } + log.success(`${summary.rotated ? 'Re-registered' : 'Registered'} ${summary.name || summary.id} (${summary.id})${summary.organization?.slug ? ` in ${summary.organization.slug}` : ''}.`); + if (summary.rotated) { + log.info(ROTATED_NOTE); + } + log.info(` the instance keeps its token (${summary.token_hint || 'flbi_…'}) in its encrypted settings; nothing secret was written here`); +} + +const ROTATED_NOTE = ' same instance on the service: its installs and licence seats are kept, and the previous token no longer works'; + +/** + * The instance this machine already registered for the same --path or --host on this registry, whose token is sent as + * `previous_token` so the service rotates that instance instead of creating a second one. + */ +function previousEntry(ctx: Context, opts: RegisterOptions): (InstanceEntry & { token: string }) | null { + const wantedPath = opts.path ? path.resolve(opts.path) : null; + const found = instancesFor(ctx.config, ctx.registry) + .reverse() + .find((entry) => entry.token && ((wantedPath && entry.path === wantedPath) || (opts.host && entry.host === opts.host))); + return found && found.token ? (found as InstanceEntry & { token: string }) : null; +} + +/** Exchange the code from this machine and keep the instance token in the config. */ +async function registerHere(ctx: Context, opts: RegisterOptions, code: string): Promise { + const detected = opts.path ? detectVersions(opts.path) : {}; + const previous = previousEntry(ctx, opts); + const body = { + code, + name: opts.name || os.hostname(), + kind: opts.kind, + ...(opts.host ? { host: opts.host } : {}), + ...((opts.consoleVersion || detected.console) ? { console_version: opts.consoleVersion || detected.console } : {}), + ...((opts.apiVersion || detected.api) ? { api_version: opts.apiVersion || detected.api } : {}), + ...(previous ? { previous_token: previous.token } : {}), + }; + + let result: { token: string; rotated?: boolean; instance: Instance }; + try { + result = await ctx.anonymous().post('v1/instances/register', body, { auth: false }); + } catch (error) { + if (error instanceof ApiError && [404, 410, 422].includes(error.status)) { + throw new CliError(`Registration refused: ${error.message}`, 1, ['Codes are single use and expire after 15 minutes; mint a new one with `flb instance code`.']); + } + throw error; + } + if (!INSTANCE_TOKEN.test(result.token)) { + throw new CliError('The service returned no instance token.'); + } + + const entry: InstanceEntry = { + id: result.instance.id, + name: result.instance.name, + kind: result.instance.kind, + registry: ctx.registry, + organization: { id: result.instance.organization.id, slug: result.instance.organization.slug, name: result.instance.organization.name }, + token: result.token, + ...(opts.host ? { host: opts.host } : {}), + ...(opts.path ? { path: path.resolve(opts.path) } : {}), + registeredAt: new Date().toISOString(), + }; + updateConfig((config) => saveInstance(config, entry)); + + if (ctx.json) { + const out = { instance: result.instance, ...(opts.showToken ? { token: result.token } : {}) }; + (opts.showToken ? log.secret : log.line)(JSON.stringify(out, null, 2)); + return; + } + log.success(`${result.rotated ? 'Re-registered' : 'Registered'} ${result.instance.name} (${result.instance.id}) in ${result.instance.organization.slug} as ${result.instance.kind}.`); + if (result.rotated) { + log.info(ROTATED_NOTE); + } + log.info(` instance token ${maskToken(result.token)} saved to ${configDisplayPath()}`); + if (!opts.storeToken) { + log.info(' To register a Fleetbase instance itself, pass --path or --docker; this token is only for `flb instance status` and `updates`.'); + } + if (opts.showToken) { + log.secret(result.token); + } +} + +function pickInstance(ctx: Context, id?: string): InstanceEntry & { token: string } { + const entries = instancesFor(ctx.config, ctx.registry); + const wanted = id || hostMeta(ctx.config, ctx.registry).instance; + const entry = wanted ? ctx.config.instances?.[wanted] : entries[entries.length - 1]; + if (!entry) { + throw new CliError(id ? `No instance ${id} in ${configDisplayPath()}.` : `No instance registered for ${ctx.host} from this machine.`, 1, ['Register one with `flb instance register`.']); + } + if (!entry.token) { + throw new CliError(`${entry.id} keeps its token on the instance (${entry.target?.path || 'registered with --path or --docker'}), so this machine cannot query the service as it.`, 1, [ + 'See it in the console (Extensions > Registry settings), or check the installer with `flb doctor --path`.', + ]); + } + return entry as InstanceEntry & { token: string }; +} + +async function instanceStatus(ctx: Context, id?: string): Promise { + const entry = pickInstance(ctx, id); + let me: { instance: Instance; licences: Array> }; + try { + me = await ctx.anonymous().get('v1/instances/me', { token: entry.token }); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + throw new CliError(`The service no longer accepts the token of ${entry.id} (revoked?).`, 1, [`Register again, then \`flb instance forget ${entry.id}\`.`]); + } + throw error; + } + if (ctx.json) { + log.json(me); + return; + } + const { instance } = me; + log.line(`${colors.bold(instance.name)} ${colors.dim(instance.id)} ${instance.kind} ${instance.status === 'active' ? colors.green(instance.status) : colors.red(instance.status)}`); + log.line(` organization ${instance.organization.name} (${instance.organization.slug})`); + log.line(` versions console ${instance.console_version || 'unknown'}, api ${instance.api_version || 'unknown'}`); + log.line(` last seen ${formatDate(instance.last_seen_at)}`); + if (me.licences.length) { + log.line(''); + log.line( + table(me.licences, [ + { header: 'EXTENSION', value: (l) => l.extension?.slug || l.extension?.name || '' }, + { header: 'KIND', value: (l) => l.kind }, + { header: 'STATUS', value: (l) => l.status }, + { header: 'SEATS', value: (l) => `${l.seats_used ?? '?'}/${l.max_activations ?? '∞'}` }, + { header: 'HERE', value: (l) => (l.activated_here ? 'yes' : '') }, + ]), + ); + } +} + +async function instanceUpdates(ctx: Context, id: string | undefined, opts: { channel?: string }): Promise { + const entry = pickInstance(ctx, id); + const response = await ctx.anonymous().get<{ data?: Array> } | Array>>('v1/instances/me/updates', { token: entry.token, query: { channel: opts.channel } }); + const rows = Array.isArray(response) ? response : response.data || []; + if (ctx.json) { + log.json(rows); + return; + } + if (rows.length === 0) { + log.info('No extensions installed on this instance.'); + return; + } + log.line( + table(rows, [ + { header: 'EXTENSION', value: (r) => r.extension?.slug || '' }, + { header: 'INSTALLED', value: (r) => r.installed?.version || r.installed_release?.version || '' }, + { header: 'LATEST', value: (r) => r.latest?.version || r.latest_compatible?.version || '' }, + { header: 'UPDATE', value: (r) => (r.update_available ? colors.green('yes') : '') }, + ]), + ); +} + +function listInstances(ctx: Context): void { + const entries = instancesFor(ctx.config, ctx.registry); + if (entries.length === 0) { + log.info(`No instances registered for ${ctx.host} from this machine.`); + return; + } + const current = hostMeta(ctx.config, ctx.registry).instance; + log.line( + table(entries, [ + { header: 'ID', value: (e) => (e.id === current ? `${e.id} ${colors.green('*')}` : e.id) }, + { header: 'NAME', value: (e) => e.name || '' }, + { header: 'ORG', value: (e) => e.organization?.slug || '' }, + { header: 'KIND', value: (e) => e.kind || '' }, + { header: 'TOKEN', value: (e) => (e.token ? maskToken(e.token) : `${e.tokenHint || 'flbi_…'} on the instance`) }, + { header: 'REGISTERED', value: (e) => formatDate(e.registeredAt) }, + ]), + ); +} diff --git a/src/commands/legacy.ts b/src/commands/legacy.ts new file mode 100644 index 0000000..db14acb --- /dev/null +++ b/src/commands/legacy.ts @@ -0,0 +1,42 @@ +import type { Command } from 'commander'; +import { log } from '../output.js'; + +/** + * v1 commands that are gone, kept as hidden stubs that say what replaced them. + */ +const MOVED: [string, string][] = [ + ['set-auth', 'flb login --token (the token is kept in ~/.fleetbase/config.json; nothing is written to .npmrc or auth.json any more)'], + ['generate-token', 'flb token create --scopes publish --show-token'], + ['resend-verification', 'flb verify --resend'], + ['bundle', 'flb build'], + ['bundle-upload', 'flb publish'], + ['upload-bundle', 'flb publish'], + ['unpublish', 'flb release yank '], + ['version-bump', 'flb bump '], +]; + +export function register(program: Command): void { + program + .command('install-fleetbase', { hidden: true }) + .description('Moved to @fleetbase/installer') + .allowUnknownOption() + .allowExcessArguments() + .action(() => { + log.error('install-fleetbase moved out of the CLI into its own package.'); + log.info('Run it with:'); + log.info(' npx @fleetbase/installer [--directory ] [--environment development|production] [--non-interactive]'); + process.exitCode = 1; + }); + + for (const [name, replacement] of MOVED) { + program + .command(name, { hidden: true }) + .allowUnknownOption() + .allowExcessArguments() + .action(() => { + log.error(`flb ${name} was removed in CLI v2.`); + log.info(`Use: ${replacement}`); + process.exitCode = 1; + }); + } +} diff --git a/src/commands/listing.ts b/src/commands/listing.ts new file mode 100644 index 0000000..0855788 --- /dev/null +++ b/src/commands/listing.ts @@ -0,0 +1,226 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Command } from 'commander'; +import { type Context, context } from '../context.js'; +import { ApiError } from '../http.js'; +import { colors, log, table } from '../output.js'; +import { resolveSlug } from '../releases.js'; +import type { PublisherExtension } from '../types.js'; +import { CliError, splitList } from '../util.js'; + +/** + * `flb listing`: the catalog listing of an extension (what Explore shows), edited through + * `PATCH /v1/publisher/extensions/{slug}`. Listing edits never need a review; a first + * release can only be submitted once the listing is complete (`readiness`). + */ + +/** flag → API field; text fields take "" to clear */ +const FIELDS: [flag: string, field: string, description: string][] = [ + ['name', 'name', 'listing name'], + ['subtitle', 'subtitle', 'one-line summary'], + ['description', 'description', 'full description (or --description-file)'], + ['promotional-text', 'promotional_text', 'short promotional text'], + ['category', 'category', 'category slug (see `flb listing categories`)'], + ['hosting', 'hosting', 'cloud, self_hosted or both'], + ['website', 'website_url', 'website URL'], + ['repo', 'repo_url', 'source repository URL'], + ['support', 'support_url', 'support URL'], + ['privacy', 'privacy_url', 'privacy policy URL'], + ['tos', 'tos_url', 'terms of service URL'], + ['copyright', 'copyright', 'copyright notice'], + ['icon', 'fa_icon', 'Font Awesome icon name, for example chart-line'], +]; +const LISTS: [flag: string, field: string, description: string][] = [ + ['tags', 'tags', 'comma separated tags'], + ['languages', 'languages', 'comma separated language codes'], +]; +const ALLOWED = new Set([...FIELDS, ...LISTS].map(([, field]) => field)); + +export function register(program: Command): void { + const listing = program.command('listing').description('Show and edit the catalog listing of an extension'); + const target = (command: Command) => command.option('-x, --extension ', 'listing slug (default: the listing of the extension in this directory)'); + + listing + .command('list') + .alias('ls') + .description('Your listings with their status and readiness') + .option('--json', 'print JSON') + .action(async (_opts, command: Command) => { + const ctx = context(command); + const rows = await ctx.authed().get('v1/publisher/extensions'); + if (ctx.json) { + log.json(rows); + return; + } + if (rows.length === 0) { + log.info('No listings yet; `flb publish` creates one.'); + return; + } + log.line( + table(rows, [ + { header: 'SLUG', value: (l) => l.slug }, + { header: 'NAME', value: (l) => l.name, max: 30 }, + { header: 'STATUS', value: (l) => l.status }, + { header: 'ORG', value: (l) => l.organization?.slug }, + { header: 'PACKAGES', value: (l) => l.packages.map((p) => p.name).join(', ') }, + { header: 'READY', value: (l) => (l.readiness?.ready ? colors.green('yes') : colors.yellow(`${l.readiness?.missing.length ?? '?'} missing`)) }, + ]), + ); + }); + + target(listing.command('show').description('Show a listing and what it still needs before a first release can be submitted')) + .option('--json', 'print JSON') + .action(async (opts, command: Command) => { + const ctx = context(command); + const client = ctx.authed(); + const slug = await resolveSlug(ctx, client, opts.extension); + const current = await client.get(`v1/publisher/extensions/${encodeURIComponent(slug)}`); + if (ctx.json) { + log.json(current); + return; + } + printListing(current); + }); + + const update = target(listing.command('update').description('Change listing fields (no review needed)')); + for (const [flag, , description] of [...FIELDS, ...LISTS]) { + update.option(`--${flag} `, description); + } + update + .option('--description-file ', 'read the description from a file (Markdown)') + .option('-f, --file ', 'JSON object of listing fields (API names, for example {"subtitle": "…", "tags": ["…"]}); flags override it') + .option('--json', 'print the updated listing as JSON') + .action((opts, command: Command) => updateListing(context(command), opts)); + + listing + .command('categories') + .description('Category slugs for --category') + .option('--json', 'print JSON') + .action(async (_opts, command: Command) => { + const ctx = context(command); + const response = await ctx.anonymous().get | { data: Array<{ slug: string; name: string; extensions_count?: number }> }>('v1/catalog/categories'); + const rows = Array.isArray(response) ? response : response.data || []; + if (ctx.json) { + log.json(rows); + return; + } + log.line(table(rows, [{ header: 'SLUG', value: (c) => c.slug }, { header: 'NAME', value: (c) => c.name }, { header: 'EXTENSIONS', value: (c) => c.extensions_count ?? '' }])); + }); +} + +/** The PATCH body from --file and the flags; "" clears a field. */ +export function listingChanges(opts: Record, cwd = process.cwd()): Record { + let body: Record = {}; + if (typeof opts.file === 'string') { + const file = path.resolve(cwd, opts.file); + try { + body = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + throw new CliError(`${file} is not a JSON object of listing fields (${(error as Error).message}).`); + } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new CliError(`${file} must hold a JSON object of listing fields.`); + } + const unknown = Object.keys(body).filter((key) => !ALLOWED.has(key)); + if (unknown.length) { + throw new CliError(`Unknown listing field(s) in ${opts.file}: ${unknown.join(', ')}. Fields: ${[...ALLOWED].join(', ')}.`); + } + } + + const camel = (flag: string) => flag.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); + for (const [flag, field] of FIELDS) { + const value = opts[camel(flag)]; + if (typeof value === 'string') { + body[field] = value === '' ? null : value; + } + } + for (const [flag, field] of LISTS) { + const value = opts[camel(flag)]; + if (typeof value === 'string') { + body[field] = splitList(value); + } + } + if (typeof opts.descriptionFile === 'string') { + body.description = fs.readFileSync(path.resolve(cwd, opts.descriptionFile), 'utf8').trim(); + } + if (body.hosting !== undefined && body.hosting !== null && !['cloud', 'self_hosted', 'both'].includes(String(body.hosting))) { + throw new CliError('--hosting must be cloud, self_hosted or both.'); + } + return body; +} + +async function updateListing(ctx: Context, opts: Record): Promise { + const changes = listingChanges(opts); + if (Object.keys(changes).length === 0) { + throw new CliError('Nothing to change: pass fields as flags (see --help) or --file listing.json.'); + } + const client = ctx.authed(); + const slug = await resolveSlug(ctx, client, opts.extension as string | undefined); + + let updated: PublisherExtension; + try { + updated = await client.patch(`v1/publisher/extensions/${encodeURIComponent(slug)}`, changes); + } catch (error) { + if (error instanceof ApiError && error.status === 422 && error.fields?.category) { + throw new CliError(error.message, 1, ['List the category slugs with `flb listing categories`.']); + } + throw error; + } + if (!updated?.readiness) { + updated = await client.get(`v1/publisher/extensions/${encodeURIComponent(slug)}`); + } + + if (ctx.json) { + log.json(updated); + return; + } + log.success(`Updated ${Object.keys(changes).join(', ')} on ${updated.slug}.`); + printReadiness(updated); +} + +function printReadiness(listing: PublisherExtension): void { + if (!listing.readiness) { + return; + } + if (listing.readiness.ready) { + log.info(` ${colors.green('ready')}: a release can be submitted for review`); + return; + } + log.info(` still needed before a first release can be submitted:`); + for (const item of listing.readiness.missing) { + log.info(` - ${item}`); + } +} + +function printListing(listing: PublisherExtension): void { + log.line(`${colors.bold(listing.name)} ${colors.dim(listing.slug)} ${listing.status} ${listing.organization?.slug || ''}`); + const rows: [string, unknown][] = [ + ['subtitle', listing.subtitle], + ['category', listing.category?.slug], + ['hosting', listing.hosting], + ['tags', listing.tags?.join(', ')], + ['languages', listing.languages?.join(', ')], + ['website', listing.website_url], + ['repo', listing.repo_url], + ['support', listing.support_url], + ['privacy', listing.privacy_url], + ['tos', listing.tos_url], + ['copyright', listing.copyright], + ['icon', listing.fa_icon], + ['packages', listing.packages.map((p) => `${p.kind} ${p.name}`).join(', ')], + ['price', listing.price ? (listing.price.model === 'free' ? 'free' : `${(listing.price.amount / 100).toFixed(2)} ${listing.price.currency}`) : null], + ]; + for (const [label, value] of rows) { + if (value) { + log.line(` ${label.padEnd(10)} ${value}`); + } + } + if (listing.promotional_text) { + log.line(` promo ${listing.promotional_text}`); + } + if (listing.description) { + log.line(''); + log.line(listing.description); + } + printReadiness(listing); +} diff --git a/src/commands/publish.ts b/src/commands/publish.ts new file mode 100644 index 0000000..54b4bb4 --- /dev/null +++ b/src/commands/publish.ts @@ -0,0 +1,211 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Command } from 'commander'; +import { readRelease, type ReleaseFile } from '../build.js'; +import { type Context, context } from '../context.js'; +import { ApiError } from '../http.js'; +import { type LoadedExtension, loadExtension } from '../manifest.js'; +import { colors, formatBytes, log } from '../output.js'; +import { isSettled, listingFor, printCheck, reviewReasons, statusLabel, watchRelease } from '../releases.js'; +import type { PublisherExtension, ReleaseDetail } from '../types.js'; +import { CliError, parseTimeout, slugify } from '../util.js'; +import { addBuildOptions, runBuild } from './build.js'; + +export function register(program: Command): void { + addBuildOptions( + program + .command('publish') + .description('Upload a release to the Registry Service and follow its checks') + .argument('[path]', 'extension directory', '.') + .option('-c, --channel ', 'stable or beta (default: extension.json channel, else stable)') + .option('--submit', 'submit for review as soon as the checks pass') + .option('--notes ', 'release notes') + .option('--notes-file ', 'read release notes from a file') + .option('-o, --organization ', 'organization that owns a new listing (default: your default organization)') + .option('--no-build', 'fail instead of building when .fleetbase/release.json is missing or stale') + .option('--rebuild', 'build again even when the last build is current') + .option('--no-wait', 'return after the upload without following the checks') + .option('--timeout ', 'how long to follow the checks', '15m'), + ) + .option('--json', 'print the release as JSON') + .action(async (dir: string, opts, command: Command) => { + await publish(context(command), dir, opts); + }); +} + +interface PublishOptions { + channel?: string; + submit?: boolean; + notes?: string; + notesFile?: string; + organization?: string; + build: boolean; + rebuild?: boolean; + wait: boolean; + timeout: string; + [key: string]: unknown; +} + +export async function publish(ctx: Context, dir: string, opts: PublishOptions): Promise { + const client = ctx.authed(); + const ext = loadExtension(dir); + const channel = opts.channel || ext.manifest.channel || 'stable'; + if (!['stable', 'beta'].includes(channel)) { + throw new CliError(`Unknown channel "${channel}": use stable or beta.`); + } + if (opts.channel && ext.manifest.channel && opts.channel !== ext.manifest.channel) { + throw new CliError(`extension.json declares channel ${ext.manifest.channel}; --channel ${opts.channel} would be refused. Change one of them.`); + } + const notes = opts.notesFile ? fs.readFileSync(path.resolve(opts.notesFile), 'utf8') : opts.notes; + const timeoutMs = parseTimeout(opts.timeout); + + // 1. the archives + let release: ReleaseFile; + const current = readRelease(ext); + if (current.release && !opts.rebuild && (!opts.prebuilt || current.release.files.dist)) { + release = current.release; + log.step(`Using the build from ${new Date(release.builtAt).toLocaleString()}`); + } else if (!opts.build) { + throw new CliError(`No current build (${current.stale || 'rebuild requested'}); run \`flb build\` first or drop --no-build.`); + } else { + if (current.stale && current.stale !== 'not built yet') { + log.info(` rebuilding: ${current.stale}`); + } + release = await runBuild(ext, opts, ctx.json); + } + + // 2. the listing + const listing = await ensureListing(ctx, ext, opts.organization); + + // 3. the upload + const form = new FormData(); + form.append('manifest', await fs.openAsBlob(ext.manifestPath, { type: 'application/json' }), 'extension.json'); + for (const kind of ['engine', 'api', 'dist'] as const) { + const built = release.files[kind]; + if (built) { + form.append(kind, await fs.openAsBlob(path.join(ext.root, built.path), { type: 'application/gzip' }), path.basename(built.path)); + } + } + form.append('channel', channel); + if (notes) { + form.append('notes', notes); + } + if (opts.submit) { + form.append('submit', '1'); + } + + const sizes = Object.entries(release.files).map(([kind, file]) => `${kind} ${formatBytes(file!.size)}`).join(', '); + log.step(`Uploading ${ext.manifest.version} (${channel}) to ${listing.slug}: ${sizes}`); + let uploaded: ReleaseDetail; + try { + uploaded = await client.request('POST', `v1/publisher/extensions/${encodeURIComponent(listing.slug)}/releases`, { form }); + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + throw new CliError(`${listing.slug} already has a release ${ext.manifest.version}: ${error.message}`, 1, ['Bump the version in extension.json and package.json, then publish again.']); + } + throw error; + } + log.success(`Uploaded ${uploaded.id}`); + + // 4. the checks + let final = uploaded; + if (opts.wait) { + if (!ctx.json) { + log.info(' checks'); + } + final = await watchRelease(client, uploaded, { + timeoutMs, + onCheck: (check) => !ctx.json && printCheck(check), + }); + } + + if (ctx.json) { + log.json(final); + } else { + summarize(final, Boolean(opts.submit), opts.wait); + } + + if (final.status === 'failed_checks' || (opts.submit && opts.wait && final.status === 'uploaded')) { + process.exitCode = 1; + } + return final; +} + +async function ensureListing(ctx: Context, ext: LoadedExtension, organizationFlag?: string): Promise { + const client = ctx.authed(); + const packages = { npm: ext.engine?.name, composer: ext.api?.name }; + const existing = await listingFor(client, packages); + const missingKind = existing && ((packages.npm && !existing.packages.some((p) => p.kind === 'npm')) || (packages.composer && !existing.packages.some((p) => p.kind === 'composer'))); + if (existing && !missingKind) { + return existing; + } + + const organization = ctx.organization(organizationFlag); + const body = { + name: ext.manifest.name, + packages: Object.fromEntries(Object.entries(packages).filter(([, name]) => name)), + ...(organization && !existing ? { organization } : {}), + ...(ext.manifest.description && !existing ? { description: ext.manifest.description } : {}), + }; + try { + let listing: PublisherExtension; + try { + listing = await client.post('v1/publisher/extensions', body); + } catch (error) { + // listing slugs are global: when the name's slug is taken, prefix the package scope + const prefix = (packages.npm?.match(/^@([^/]+)\//)?.[1] || packages.composer?.split('/')[0]) ?? ''; + if (existing || !prefix || !(error instanceof ApiError && error.status === 422 && error.fields?.slug)) { + throw error; + } + listing = await client.post('v1/publisher/extensions', { ...body, slug: `${prefix}-${slugify(ext.manifest.name)}` }); + } + log.success(existing ? `Added ${missingKind} package to the listing ${listing.slug}` : `Created the draft listing ${listing.slug}${organization ? ` in ${organization}` : ''}`); + return listing; + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + throw new CliError(`The registry refused the package names: ${error.message}`); + } + if (error instanceof ApiError && error.status === 422) { + const hint = Object.keys(error.fields || {}).some((field) => field.startsWith('packages')) + ? ['New scopes must match your organization slug (or one it already publishes under). Rename the packages, or ask Fleetbase staff to grant the namespace.'] + : []; + throw new CliError(`Could not create the listing: ${error.message}`, 1, hint); + } + throw error; + } +} + +function summarize(release: ReleaseDetail, submitRequested: boolean, waited: boolean): void { + log.info(''); + log.info(`${colors.bold(`${release.extension.slug}@${release.version}`)} is ${statusLabel(release.status)}`); + + if (!waited) { + log.info(` follow it with: flb release status ${release.id} --watch`); + return; + } + if (release.status === 'failed_checks') { + log.info(' Fix the failing checks and publish again; a version that failed its checks is replaced, so it keeps its number.'); + return; + } + const errors = release.submission?.errors || []; + if (release.status === 'uploaded') { + if (submitRequested && errors.length) { + log.info(' Not submitted; the listing is not ready:'); + for (const error of errors) { + log.info(` - ${error}`); + } + log.info(` Complete it with \`flb listing update\` (see \`flb listing show\`), then run: flb release submit ${release.id}`); + } else if (!isSettled(release)) { + log.info(` The submit is pending; follow it with: flb release status ${release.id} --watch`); + } else { + log.info(` Checks passed. Submit it for review with: flb release submit ${release.id}`); + } + return; + } + const reasons = reviewReasons(release); + if (['submitted', 'in_review'].includes(release.status)) { + log.info(` Waiting for review${reasons.length ? ` (${reasons.join('; ')})` : ''}. Status: flb release status ${release.id}`); + } else if (release.status === 'published') { + log.info(' Published: instances see it in Explore within a minute.'); + } +} diff --git a/src/commands/release.ts b/src/commands/release.ts new file mode 100644 index 0000000..786ddd5 --- /dev/null +++ b/src/commands/release.ts @@ -0,0 +1,119 @@ +import type { Command } from 'commander'; +import { type Context, context } from '../context.js'; +import { ApiError } from '../http.js'; +import { log } from '../output.js'; +import { confirm, interactive } from '../prompt.js'; +import { printCheck, printRelease, releasesTable, resolveRelease, resolveSlug, statusLabel, watchRelease } from '../releases.js'; +import type { ReleaseDetail } from '../types.js'; +import { CliError, parseTimeout } from '../util.js'; + +export function register(program: Command): void { + const release = program.command('release').description('Follow and manage releases (by rel_ id, or version inside an extension)'); + const target = (command: Command) => command.option('-x, --extension ', 'listing slug (default: the listing of the extension in this directory)'); + + target(release.command('list').alias('ls').description('List the releases of a listing')) + .option('--json', 'print JSON') + .action(async (opts, command: Command) => { + const ctx = context(command); + const client = ctx.authed(); + const slug = await resolveSlug(ctx, client, opts.extension); + const response = await client.get(`v1/publisher/extensions/${encodeURIComponent(slug)}/releases`); + const rows = (Array.isArray(response) ? response : response.data || []).sort((a, b) => b.created_at.localeCompare(a.created_at)); + if (ctx.json) { + log.json(rows); + } else { + log.line(rows.length ? releasesTable(rows) : `${slug} has no releases.`); + } + }); + + target(release.command('status').description('Show checks, review state and history of a release (default: the latest)')) + .argument('[release]', 'rel_ id or version') + .option('-w, --watch', 'keep polling until the release settles') + .option('--timeout ', 'how long to watch', '15m') + .option('--json', 'print JSON') + .action((ref: string | undefined, opts, command: Command) => status(context(command), ref, opts)); + + target(release.command('submit').description('Submit a release for review')) + .argument('[release]', 'rel_ id or version (default: the latest)') + .option('--no-wait', 'do not wait for running checks') + .option('--timeout ', 'how long to wait for running checks', '15m') + .action((ref: string | undefined, opts, command: Command) => submit(context(command), ref, opts)); + + target(release.command('withdraw').description('Take a release out of review (back to uploaded)')) + .argument('[release]', 'rel_ id or version (default: the latest)') + .option('--reason ', 'why') + .action((ref: string | undefined, opts, command: Command) => transition(context(command), ref, 'withdraw', opts)); + + target(release.command('yank').description('Yank a published release: it leaves metadata and the catalog; lockfiles keep working')) + .argument('', 'rel_ id or version') + .option('--reason ', 'why') + .option('-y, --yes', 'do not ask for confirmation') + .action((ref: string, opts, command: Command) => transition(context(command), ref, 'yank', opts)); +} + +async function status(ctx: Context, ref: string | undefined, opts: { extension?: string; watch?: boolean; timeout: string }): Promise { + const client = ctx.authed(); + let release = await resolveRelease(ctx, client, ref, opts.extension); + if (opts.watch) { + release = await watchRelease(client, release, { + timeoutMs: parseTimeout(opts.timeout), + onStatus: (state) => !ctx.json && log.info(` ${statusLabel(state)}`), + onCheck: (check) => !ctx.json && printCheck(check), + }); + } + if (ctx.json) { + log.json(release); + return; + } + printRelease(release); +} + +async function submit(ctx: Context, ref: string | undefined, opts: { extension?: string; wait: boolean; timeout: string }): Promise { + const client = ctx.authed(); + const release = await resolveRelease(ctx, client, ref, opts.extension); + let response: ReleaseDetail; + try { + response = await client.post(`v1/publisher/releases/${release.id}/submit`); + } catch (error) { + if (error instanceof ApiError && error.status === 422) { + const reasons = error.errors.length ? error.errors : [error.message]; + throw new CliError(`${release.extension.slug}@${release.version} cannot be submitted:\n${reasons.map((reason) => ` - ${reason}`).join('\n')}`); + } + throw error; + } + + let final = response?.id ? response : await client.get(`v1/publisher/releases/${release.id}`); + if (final.status === 'checking' || final.status === 'uploaded') { + log.info('Checks are still running; it is submitted when they pass.'); + if (opts.wait) { + final = await watchRelease(client, final, { timeoutMs: parseTimeout(opts.timeout), onCheck: printCheck }); + } + } + log.success(`${final.extension.slug}@${final.version} is ${statusLabel(final.status)}.`); + if (final.status === 'failed_checks' || (opts.wait && final.status === 'uploaded')) { + process.exitCode = 1; + } +} + +async function transition(ctx: Context, ref: string | undefined, action: 'withdraw' | 'yank', opts: { extension?: string; reason?: string; yes?: boolean }): Promise { + const client = ctx.authed(); + const release = await resolveRelease(ctx, client, ref, opts.extension); + if (action === 'yank' && !opts.yes) { + if (!interactive()) { + throw new CliError('Yanking needs confirmation: pass --yes.'); + } + if (!(await confirm(`Yank ${release.extension.slug}@${release.version}? Instances stop being offered it.`))) { + throw new CliError('Cancelled.', 130); + } + } + try { + await client.post(`v1/publisher/releases/${release.id}/${action}`, opts.reason ? { reason: opts.reason } : {}); + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + throw new CliError(`${release.extension.slug}@${release.version} is ${release.status.replace(/_/g, ' ')}: ${error.message}`); + } + throw error; + } + const updated = await client.get(`v1/publisher/releases/${release.id}`); + log.success(`${updated.extension.slug}@${updated.version} is ${statusLabel(updated.status)}.`); +} diff --git a/src/commands/token.ts b/src/commands/token.ts new file mode 100644 index 0000000..66b9792 --- /dev/null +++ b/src/commands/token.ts @@ -0,0 +1,139 @@ +import type { Command } from 'commander'; +import { clearToken, configDisplayPath, hostMeta, setToken, updateConfig } from '../config.js'; +import { type Context, context } from '../context.js'; +import { colors, formatDate, log, table } from '../output.js'; +import type { IssuedToken, TokenDetails } from '../types.js'; +import { CliError, parseDays, splitList } from '../util.js'; +import { validateScopes } from './auth.js'; + +export function register(program: Command): void { + const token = program.command('token').description('Create, list and revoke personal tokens'); + + token + .command('create') + .description('Create a personal token (shown once with --show-token, or stored with --save)') + .requiredOption('-n, --name ', 'token name') + .option('-s, --scopes ', 'scopes: read, publish, install, instance:register, review, * (comma separated)', 'read,publish') + .option('--expires ', 'lifetime, for example 90d, 12w, 1y (max 365d)', '90d') + .option('-o, --organization ', 'pin the token to one organization') + .option('--show-token', 'print the token once') + .option('--save', 'store it as the token for this registry instead of the current one') + .option('--json', 'print JSON') + .action((opts, command: Command) => createToken(context(command), opts)); + + token + .command('list') + .alias('ls') + .description('List your tokens (masked)') + .option('--json', 'print JSON') + .action((_opts, command: Command) => listTokens(context(command))); + + token + .command('revoke') + .description('Revoke tokens by id (tok_…)') + .argument('', 'token ids') + .action((ids: string[], _opts, command: Command) => revokeTokens(context(command), ids)); +} + +interface CreateOptions { + name: string; + scopes: string; + expires: string; + organization?: string; + showToken?: boolean; + save?: boolean; +} + +export async function createToken(ctx: Context, opts: CreateOptions): Promise { + if (!opts.showToken && !opts.save) { + throw new CliError('A new token is only shown once: pass --show-token to print it, or --save to store it in the config.'); + } + const scopes = validateScopes(splitList(opts.scopes)); + const issued = await ctx.authed().post('v1/account/tokens', { + name: opts.name, + scopes, + expires_in_days: parseDays(opts.expires), + ...(opts.organization ? { organization: opts.organization } : {}), + }); + const details = issued.token_details; + + if (opts.save) { + const previous = hostMeta(ctx.config, ctx.registry); + updateConfig((config) => + setToken(config, ctx.registry, issued.token, { + account: previous.account, + tokenId: details.id, + tokenName: details.name, + scopes: details.scopes, + expiresAt: details.expires_at, + organization: details.organization?.slug ?? previous.organization, + source: 'token-create', + }), + ); + } + + if (ctx.json) { + const body = { ...details, ...(opts.showToken ? { token: issued.token } : {}) }; + // log.json masks tokens; this output carries one on purpose + (opts.showToken ? log.secret : log.line)(JSON.stringify(body, null, 2)); + return; + } + + log.success(`Created token ${details.name} (${details.id}), scopes ${details.scopes.join(', ')}, expires ${formatDate(details.expires_at) || 'never'}${details.organization ? `, pinned to ${details.organization.slug}` : ''}.`); + if (opts.save) { + log.info(` saved to ${configDisplayPath()} as the token for ${ctx.host}`); + } + if (opts.showToken) { + log.info(' It is shown once; store it now.'); + log.secret(issued.token); + } +} + +export async function listTokens(ctx: Context): Promise { + const tokens = await ctx.authed().get('v1/account/tokens'); + if (ctx.json) { + log.json(tokens); + return; + } + if (tokens.length === 0) { + log.info('No tokens.'); + return; + } + const current = hostMeta(ctx.config, ctx.registry).tokenId; + log.line( + table(tokens, [ + { header: 'ID', value: (t) => (t.id === current ? `${t.id} ${colors.green('*')}` : t.id) }, + { header: 'NAME', value: (t) => t.name, max: 32 }, + { header: 'TOKEN', value: (t) => t.masked }, + { header: 'SCOPES', value: (t) => t.scopes.join(',') }, + { header: 'ORG', value: (t) => t.organization?.slug || '' }, + { header: 'LAST USED', value: (t) => formatDate(t.last_used_at) }, + { header: 'EXPIRES', value: (t) => (t.revoked_at ? colors.red('revoked') : formatDate(t.expires_at)) }, + ]), + ); + if (current) { + log.info(`${colors.green('*')} the token this CLI uses`); + } +} + +export async function revokeTokens(ctx: Context, ids: string[]): Promise { + const client = ctx.authed(); + const current = hostMeta(ctx.config, ctx.registry).tokenId; + let failed = 0; + for (const id of ids) { + try { + await client.delete(`v1/account/tokens/${encodeURIComponent(id)}`); + log.success(`Revoked ${id}.`); + if (id === current) { + updateConfig((config) => clearToken(config, ctx.registry)); + log.warn(`That was the token this CLI used for ${ctx.host}; it is removed from the config. Run \`flb login\` again.`); + } + } catch (error) { + failed++; + log.error(`${id}: ${(error as Error).message}`); + } + } + if (failed) { + throw new CliError(`${failed} of ${ids.length} token(s) could not be revoked.`); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f1b31b5 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,324 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * ~/.fleetbase/config.json: the one place the CLI keeps state, and the only file it ever + * writes a token to. Written with mode 600 inside a 700 directory. + * + * The phase-0 CLI (v1) wrote the same file; its keys are kept as they are: + * + * host v1: default Fleetbase API host. Not used by v2 commands, never rewritten. + * registry default Registry Service URL + * tokens personal tokens by host[:port] of the service ("registry.fleetbase.io": "flbp_…") + * defaultOrganization organization slug used when a command needs one + * + * v2 adds: + * + * hosts what the CLI knows about each token, by the same host key: account, + * token id, name, scopes, expiry, default organization. No secrets. + * instances instance tokens from `flb instance register`, by instance id + * + * Unknown keys are preserved on write. + */ + +export const DEFAULT_REGISTRY = 'https://registry.fleetbase.io'; + +export interface HostAccount { + id?: string; + email?: string; + name?: string; + username?: string; +} + +export interface HostMeta { + account?: HostAccount; + tokenId?: string; + tokenName?: string; + scopes?: string[]; + expiresAt?: string | null; + organization?: string; + /** `login` when the CLI minted the token itself (and may revoke it on logout or re-login) */ + source?: 'login' | 'pasted' | 'token-create'; + instance?: string; + savedAt?: string; +} + +export interface InstanceEntry { + id: string; + name?: string; + kind?: string; + registry: string; + organization?: { id?: string; slug?: string; name?: string }; + /** set when the CLI exchanged the code itself; absent when the instance holds its own token */ + token?: string; + /** masked token the instance reported (flbi_…a1b2) */ + tokenHint?: string; + /** the instance URL it was registered with (`--host`) */ + host?: string; + path?: string; + /** where the instance was registered from, when it holds its own token */ + target?: { path: string; docker?: string; composeFile?: string }; + registeredAt: string; +} + +export interface Config { + host?: string; + registry?: string; + tokens?: Record; + defaultOrganization?: string; + hosts?: Record; + instances?: Record; + [key: string]: unknown; +} + +export function configDir(): string { + return process.env.FLEETBASE_HOME || path.join(os.homedir(), '.fleetbase'); +} + +export function configPath(): string { + return path.join(configDir(), 'config.json'); +} + +/** The path as shown to users: `~/.fleetbase/config.json` unless FLEETBASE_HOME moved it. */ +export function configDisplayPath(): string { + return process.env.FLEETBASE_HOME ? configPath() : '~/.fleetbase/config.json'; +} + +export class ConfigError extends Error {} + +export function readConfig(): Config { + const file = configPath(); + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw new ConfigError(`Could not read ${file}: ${(error as Error).message}`); + } + + if (raw.trim() === '') { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new ConfigError(`${file} is not valid JSON (${(error as Error).message}). Fix or remove it.`); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new ConfigError(`${file} must hold a JSON object.`); + } + + return parsed as Config; +} + +/** + * Write the config atomically (temp file in the same directory, then rename) with mode 600. + */ +export function writeConfig(config: Config): string { + const dir = configDir(); + const file = configPath(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + tightenMode(dir, 0o700); + + const tmp = path.join(dir, `.config.json.${process.pid}.${Date.now()}.tmp`); + const data = JSON.stringify(prune(config), null, 4) + '\n'; + try { + fs.writeFileSync(tmp, data, { mode: 0o600, flag: 'wx' }); + fs.renameSync(tmp, file); + } catch (error) { + fs.rmSync(tmp, { force: true }); + throw error; + } + tightenMode(file, 0o600); + + return file; +} + +export function updateConfig(updater: (config: Config) => Config | void): Config { + const config = readConfig(); + const next = updater(config) || config; + writeConfig(next); + return next; +} + +function tightenMode(target: string, mode: number): void { + if (process.platform === 'win32') { + return; + } + try { + fs.chmodSync(target, mode); + } catch { + // best effort: not every filesystem supports chmod + } +} + +/** Drop empty maps so the file stays readable. */ +function prune(config: Config): Config { + const copy: Config = { ...config }; + for (const key of ['tokens', 'hosts', 'instances'] as const) { + const value = copy[key]; + if (value && typeof value === 'object' && Object.keys(value).length === 0) { + delete copy[key]; + } + } + return copy; +} + +/** Mode bits of the config file that are wider than 600, or null when fine or not applicable. */ +export function configModeProblem(): string | null { + if (process.platform === 'win32') { + return null; + } + try { + const mode = fs.statSync(configPath()).mode & 0o777; + return mode & 0o077 ? `mode ${mode.toString(8).padStart(3, '0')} (should be 600)` : null; + } catch { + return null; + } +} + +// ─── Hosts ─────────────────────────────────────────────────────────────────── + +/** + * Normalise a host into a URL with a scheme (the phase-0 rules): + * - an existing scheme is kept + * - localhost, loopback addresses and hosts with an explicit port get http:// + * - everything else gets https:// + * Trailing slashes are stripped. + */ +export function normalizeUrl(host: string): string { + const value = host.trim().replace(/\/+$/, ''); + if (!value) { + return value; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) { + return value; + } + + const authority = value.split('/')[0]; + const bracketed = authority.match(/^\[([^\]]+)\](?::(\d+))?$/); + const hostname = (bracketed ? bracketed[1] : authority.replace(/:\d+$/, '')).toLowerCase(); + const hasPort = bracketed ? Boolean(bracketed[2]) : /:\d+$/.test(authority); + const isLocal = ['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(hostname) || hostname.endsWith('.localhost'); + + return `${isLocal || hasPort ? 'http' : 'https'}://${value}`; +} + +/** host[:port] of a URL: the key tokens are stored under. */ +export function hostKey(host: string): string { + return new URL(normalizeUrl(host)).host; +} + +/** The registry for this invocation: flag, then FLB_REGISTRY, then the config, then the default. */ +export function resolveRegistry(flag: string | undefined, config: Config): string { + const candidate = flag || process.env.FLB_REGISTRY || config.registry || DEFAULT_REGISTRY; + const url = normalizeUrl(candidate); + try { + new URL(url); + } catch { + throw new ConfigError(`Invalid registry URL: ${candidate}`); + } + return url; +} + +// ─── Tokens ────────────────────────────────────────────────────────────────── + +export const PERSONAL_TOKEN = /^flbp_[A-Za-z0-9]{20,}$/; +export const INSTANCE_TOKEN = /^flbi_[A-Za-z0-9]{20,}$/; + +export interface StoredToken { + token: string; + source: 'env' | 'config'; + /** a phase-0 token (Verdaccio / registry-bridge): the Registry Service does not accept it */ + legacy: boolean; +} + +/** FLB_TOKEN wins (CI); otherwise the token stored for the registry's host. */ +export function getToken(config: Config, registry: string): StoredToken | null { + const env = process.env.FLB_TOKEN?.trim(); + if (env) { + return { token: env, source: 'env', legacy: !PERSONAL_TOKEN.test(env) }; + } + + const token = config.tokens?.[hostKey(registry)]; + if (typeof token === 'string' && token !== '') { + return { token, source: 'config', legacy: !PERSONAL_TOKEN.test(token) }; + } + + return null; +} + +export function setToken(config: Config, registry: string, token: string, meta: HostMeta): Config { + const key = hostKey(registry); + config.tokens = { ...(config.tokens || {}), [key]: token }; + config.hosts = { ...(config.hosts || {}), [key]: { ...(config.hosts?.[key]?.instance ? { instance: config.hosts[key].instance } : {}), ...meta, savedAt: new Date().toISOString() } }; + return config; +} + +export function clearToken(config: Config, registry: string): Config { + const key = hostKey(registry); + if (config.tokens) { + delete config.tokens[key]; + } + if (config.hosts?.[key]) { + const { instance } = config.hosts[key]; + config.hosts[key] = instance ? { instance } : {}; + if (!instance) { + delete config.hosts[key]; + } + } + return config; +} + +export function hostMeta(config: Config, registry: string): HostMeta { + return config.hosts?.[hostKey(registry)] || {}; +} + +/** Organization for a command: flag, then the host's default, then the global default. */ +export function resolveOrganization(flag: string | undefined, config: Config, registry: string): string | undefined { + return flag || hostMeta(config, registry).organization || config.defaultOrganization || undefined; +} + +/** `flbp_…a1b2` */ +export function maskToken(token: string): string { + const value = String(token ?? ''); + const underscore = value.indexOf('_'); + const prefix = underscore > 0 && underscore < 8 ? value.slice(0, underscore + 1) : ''; + return value.length <= 8 ? '********' : `${prefix}…${value.slice(-4)}`; +} + +// ─── Instances ─────────────────────────────────────────────────────────────── + +export function instancesFor(config: Config, registry: string): InstanceEntry[] { + const key = hostKey(registry); + return Object.values(config.instances || {}).filter((entry) => entry && hostKey(entry.registry) === key); +} + +export function saveInstance(config: Config, entry: InstanceEntry): Config { + config.instances = { ...(config.instances || {}), [entry.id]: entry }; + const key = hostKey(entry.registry); + config.hosts = { ...(config.hosts || {}), [key]: { ...(config.hosts?.[key] || {}), instance: entry.id } }; + return config; +} + +export function removeInstance(config: Config, id: string): Config { + const entry = config.instances?.[id]; + if (!entry) { + return config; + } + delete config.instances![id]; + const key = hostKey(entry.registry); + if (config.hosts?.[key]?.instance === id) { + delete config.hosts[key].instance; + if (Object.keys(config.hosts[key]).length === 0) { + delete config.hosts[key]; + } + } + return config; +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..715fb1e --- /dev/null +++ b/src/context.ts @@ -0,0 +1,74 @@ +import type { Command } from 'commander'; +import { type Config, configDisplayPath, getToken, hostKey, readConfig, resolveOrganization, resolveRegistry } from './config.js'; +import { RegistryClient } from './http.js'; +import { CliError } from './util.js'; + +/** + * What every command needs: options (with the global ones), the config, the registry URL, + * and clients for it. + */ +export interface Context { + opts: Record; + config: Config; + registry: string; + host: string; + json: boolean; + /** client without a token (public catalog, auth routes) */ + anonymous(): RegistryClient; + /** client with the stored token; fails with a login hint when there is none */ + authed(): RegistryClient; + organization(flag?: string): string | undefined; +} + +export type FetchLike = typeof fetch; + +let fetchOverride: FetchLike | undefined; + +/** Tests route requests to a fake service. */ +export function setFetch(impl: FetchLike | undefined): void { + fetchOverride = impl; +} + +export function context(command: Command): Context { + const opts = command.optsWithGlobals(); + const config = readConfig(); + const registry = resolveRegistry(opts.registry, config); + const host = hostKey(registry); + + return { + opts, + config, + registry, + host, + json: Boolean(opts.json), + anonymous: () => new RegistryClient(registry, { fetch: fetchOverride }), + authed: () => new RegistryClient(registry, { fetch: fetchOverride, token: requireToken(config, registry) }), + organization: (flag?: string) => resolveOrganization(flag ?? opts.organization, config, registry), + }; +} + +export function requireToken(config: Config, registry: string): string { + const stored = getToken(config, registry); + const host = hostKey(registry); + if (!stored) { + throw new CliError(`Not logged in to ${host}.`, 1, [`Run \`flb login${registryFlag(registry)}\`, or set FLB_TOKEN.`]); + } + if (stored.legacy) { + const where = stored.source === 'env' ? 'FLB_TOKEN' : `the token stored for ${host} in ${configDisplayPath()}`; + throw new CliError(`${where} is not a Registry Service token (they start with flbp_).`, 1, [ + `It is probably a v1 registry token. Run \`flb login${registryFlag(registry)}\` to replace it.`, + ]); + } + return stored.token; +} + +/** ` --registry ` when the registry is not the configured default, for printed follow-ups. */ +export function registryFlag(registry: string): string { + try { + const config = readConfig(); + const fallback = resolveRegistry(undefined, config); + return registry === fallback ? '' : ` --registry ${registry}`; + } catch { + return ` --registry ${registry}`; + } +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..fa5449f --- /dev/null +++ b/src/http.ts @@ -0,0 +1,170 @@ +import { VERSION } from './version.js'; + +/** + * Client for the Registry Service API (`/v1`). Errors use the Fleetbase envelope + * `{"errors": ["..."], "fields"?: {...}, "code"?: "..."}`, surfaced as ApiError. + * The bearer token never appears in an error message. + */ + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly errors: string[] = [], + readonly body: unknown = null, + readonly code?: string, + ) { + super(message); + } + + get fields(): Record | undefined { + return (this.body as { fields?: Record } | null)?.fields; + } +} + +export class NetworkError extends Error {} + +export type Query = Record; + +export interface RequestOptions { + query?: Query; + json?: unknown; + form?: FormData; + /** send the token (default true when the client has one) */ + auth?: boolean; + /** override the client's token for this request */ + token?: string; + signal?: AbortSignal; +} + +export interface ClientOptions { + token?: string; + fetch?: typeof fetch; + timeoutMs?: number; +} + +export class RegistryClient { + readonly baseUrl: string; + private readonly token?: string; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + + constructor(baseUrl: string, options: ClientOptions = {}) { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.token = options.token; + this.fetchImpl = options.fetch || globalThis.fetch; + this.timeoutMs = options.timeoutMs ?? 60_000; + } + + get hasToken(): boolean { + return Boolean(this.token); + } + + withToken(token: string | undefined): RegistryClient { + return new RegistryClient(this.baseUrl, { token, fetch: this.fetchImpl, timeoutMs: this.timeoutMs }); + } + + url(path: string, query?: Query): string { + const url = new URL(this.baseUrl + '/' + path.replace(/^\/+/, '')); + for (const [key, value] of Object.entries(query || {})) { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, String(value)); + } + } + return url.toString(); + } + + async request(method: string, path: string, options: RequestOptions = {}): Promise { + const headers: Record = { + Accept: 'application/json', + 'User-Agent': `flb/${VERSION} node/${process.versions.node}`, + }; + const token = options.token ?? (options.auth === false ? undefined : this.token); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + let body: string | FormData | undefined; + if (options.form) { + body = options.form; + } else if (options.json !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(options.json); + } + + const url = this.url(path, options.query); + const timeout = AbortSignal.timeout(options.form ? Math.max(this.timeoutMs, 600_000) : this.timeoutMs); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + + let response: Response; + try { + response = await this.fetchImpl(url, { method, headers, body, signal }); + } catch (error) { + const cause = (error as { cause?: { code?: string; message?: string } }).cause; + const reason = (error as Error).name === 'TimeoutError' ? 'timed out' : cause?.code || cause?.message || (error as Error).message; + throw new NetworkError(`Could not reach ${this.baseUrl} (${reason}).`); + } + + const text = await response.text(); + let data: unknown = null; + if (text !== '') { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + + if (!response.ok) { + throw toApiError(response.status, data, method, path); + } + + return data as T; + } + + get(path: string, options?: RequestOptions): Promise { + return this.request('GET', path, options); + } + + post(path: string, json?: unknown, options: RequestOptions = {}): Promise { + return this.request('POST', path, { ...options, json: json ?? {} }); + } + + patch(path: string, json?: unknown, options: RequestOptions = {}): Promise { + return this.request('PATCH', path, { ...options, json: json ?? {} }); + } + + delete(path: string, options?: RequestOptions): Promise { + return this.request('DELETE', path, options); + } +} + +function toApiError(status: number, data: unknown, method: string, path: string): ApiError { + const envelope = (data && typeof data === 'object' ? data : {}) as { errors?: unknown; message?: unknown; error?: unknown; code?: unknown }; + let errors: string[] = []; + if (Array.isArray(envelope.errors)) { + errors = envelope.errors.map((e) => (typeof e === 'string' ? e : JSON.stringify(e))); + } else if (typeof envelope.message === 'string') { + errors = [envelope.message]; + } else if (typeof envelope.error === 'string') { + errors = [envelope.error]; + } + + const fallback: Record = { + 401: 'Not authenticated: the token is missing, expired or revoked. Run `flb login`.', + 403: 'Forbidden.', + 404: 'Not found.', + 409: 'Conflict.', + 413: 'The upload is too large.', + 429: 'Too many requests; wait a moment and try again.', + }; + const message = errors.length ? errors.join(' ') : fallback[status] || `${method} ${path} failed with HTTP ${status}.`; + + return new ApiError(message, status, errors, data, typeof envelope.code === 'string' ? envelope.code : undefined); +} + +/** Paged list envelope from the service. */ +export interface Paged { + data: T[]; + meta: { total: number; per_page: number; current_page: number; last_page: number }; +} diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..357aa8c --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,231 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import semver from 'semver'; +import { CliError } from './util.js'; + +/** + * extension.json, checked locally against the same rules as the service's `manifest`, + * `compat`, `package_names` and `version` checks, so `flb build` fails before an upload would. + * Schema: registry-bridge `server/resources/schemas/extension.schema.json` (draft 2020-12). + */ + +/** + * Strict SemVer 2.0.0: the parsed, canonical form (build metadata included) must equal the + * input, which rejects loose forms such as `v1.0.0` or `=1.0.0`. Replaces a regex that CodeQL + * flagged for exponential backtracking on long numeric prerelease identifiers. + */ +export function isSemver(version: string): boolean { + const parsed = semver.parse(version); + if (!parsed) { + return false; + } + const canonical = parsed.build.length > 0 ? `${parsed.version}+${parsed.build.join('.')}` : parsed.version; + + return canonical === version; +} +export const NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; +// Composer's vendor/package rule, written so each separator is required between alphanumeric +// runs: the same language as Composer's `[a-z0-9]([_.-]?[a-z0-9]+)*` form without its +// exponential backtracking (CodeQL js/redos). +export const COMPOSER_NAME = /^[a-z0-9]+(?:[_.-][a-z0-9]+)*\/[a-z0-9]+(?:(?:[_.]|-{1,2})[a-z0-9]+)*$/; +const RANGE_CHARS = /^[0-9A-Za-z.*^~<>=|! +-]+$/; +const CHANNELS = ['stable', 'beta']; + +export interface ExtensionManifest { + name: string; + version: string; + description?: string; + engine?: string; + api?: string; + compat?: { console?: string; api?: string }; + channel?: 'stable' | 'beta'; + keywords?: string[]; + repository?: string; + license?: string; + author?: string; + [key: string]: unknown; +} + +export interface LoadedExtension { + root: string; + manifestPath: string; + manifest: ExtensionManifest; + /** package.json when the manifest declares an engine */ + engine?: { name: string; version: string; file: string; json: Record }; + /** composer.json when the manifest declares an api */ + api?: { name: string; version?: string; file: string; json: Record }; + warnings: string[]; +} + +export class ManifestError extends CliError { + constructor(readonly problems: string[], file: string) { + super(`${file} is not valid:\n${problems.map((problem) => ` - ${problem}`).join('\n')}`); + } +} + +function readJson(file: string): Record { + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch { + throw new CliError(`${file} not found.`); + } + try { + const value = JSON.parse(raw); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('not an object'); + } + return value; + } catch (error) { + throw new CliError(`${file} is not a JSON object (${(error as Error).message}).`); + } +} + +export function findExtensionRoot(start: string): string { + let dir = path.resolve(start); + for (;;) { + if (fs.existsSync(path.join(dir, 'extension.json'))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new CliError(`No extension.json in ${path.resolve(start)} or above. Run this inside an extension, or create one with \`flb init\`.`); + } + dir = parent; + } +} + +/** Composer can install X.Y.Z with -alpha, -beta, -RC, -patch/-pl/-p suffixes (optionally numbered); not -next.1 and the like. */ +export function composerInstallable(version: string): boolean { + const parsed = semver.parse(version); + if (!parsed) { + return false; + } + if (parsed.prerelease.length === 0) { + return true; + } + const pre = parsed.prerelease.join('.'); + return /^(alpha|beta|rc|a|b|patch|pl|p|dev)(\.?\d+)?$/i.test(pre); +} + +export function validRange(range: string, kind: 'console' | 'api'): boolean { + if (!RANGE_CHARS.test(range) || range.length > 100) { + return false; + } + if (kind === 'console') { + return semver.validRange(range) !== null; + } + // Composer ranges (the schema allows no commas or stability flags): node-semver parses them once `|` is `||` + const normalized = range.replace(/\s*\|\|?\s*/g, ' || '); + return semver.validRange(normalized) !== null; +} + +export function validateManifest(manifest: Record): string[] { + const problems: string[] = []; + if (typeof manifest.name !== 'string' || manifest.name.trim() === '' || manifest.name.length > 120) { + problems.push('`name` must be a non-empty string of at most 120 characters.'); + } + if (typeof manifest.version !== 'string' || !isSemver(manifest.version)) { + problems.push('`version` must be a semantic version without a leading v (for example 1.2.0).'); + } + if (manifest.engine === undefined && manifest.api === undefined) { + problems.push('declare `engine` (package.json) and/or `api` (composer.json).'); + } + for (const kind of ['engine', 'api'] as const) { + const value = manifest[kind]; + if (value === undefined) { + continue; + } + const ok = typeof value === 'string' && (value === 'package.json' || value === 'composer.json' || NPM_NAME.test(value) || COMPOSER_NAME.test(value)); + if (!ok) { + problems.push(`\`${kind}\` must be "${kind === 'engine' ? 'package.json' : 'composer.json'}" or a package name.`); + } + } + if (manifest.compat !== undefined) { + if (!manifest.compat || typeof manifest.compat !== 'object' || Array.isArray(manifest.compat)) { + problems.push('`compat` must be an object with `console` and/or `api` ranges.'); + } else { + for (const key of Object.keys(manifest.compat)) { + if (key !== 'console' && key !== 'api') { + problems.push(`\`compat.${key}\` is not allowed (only console and api).`); + } + } + for (const kind of ['console', 'api'] as const) { + const range = manifest.compat[kind]; + if (range !== undefined && (typeof range !== 'string' || !validRange(range, kind))) { + problems.push(`\`compat.${kind}\` "${range}" is not a valid ${kind === 'console' ? 'npm' : 'Composer'} version range.`); + } + } + } + } + if (manifest.channel !== undefined && !CHANNELS.includes(manifest.channel)) { + problems.push('`channel` must be stable or beta.'); + } + if (manifest.keywords !== undefined && (!Array.isArray(manifest.keywords) || manifest.keywords.some((k: unknown) => typeof k !== 'string' || k === '' || (k as string).length > 64) || manifest.keywords.length > 20)) { + problems.push('`keywords` must be at most 20 strings of 1 to 64 characters.'); + } + return problems; +} + +export function loadExtension(start = '.'): LoadedExtension { + const root = findExtensionRoot(start); + const manifestPath = path.join(root, 'extension.json'); + const manifest = readJson(manifestPath); + const problems = validateManifest(manifest); + const warnings: string[] = []; + + let engine: LoadedExtension['engine']; + let api: LoadedExtension['api']; + + if (manifest.engine !== undefined) { + const file = path.join(root, 'package.json'); + if (!fs.existsSync(file)) { + problems.push('`engine` is declared but there is no package.json.'); + } else { + const json = readJson(file); + if (typeof json.name !== 'string' || !NPM_NAME.test(json.name)) { + problems.push(`package.json name "${json.name}" is not a valid npm package name.`); + } else if (manifest.engine !== 'package.json' && manifest.engine !== json.name) { + problems.push(`\`engine\` is "${manifest.engine}" but package.json is named "${json.name}".`); + } + if (json.version !== manifest.version) { + problems.push(`package.json version ${json.version} does not match extension.json version ${manifest.version} (\`flb bump\` updates both).`); + } + if (json.private === true) { + warnings.push('package.json has "private": true; the engine is still packed and uploaded to the registry.'); + } + engine = { name: json.name, version: json.version, file, json }; + } + } + + if (manifest.api !== undefined) { + const file = path.join(root, 'composer.json'); + if (!fs.existsSync(file)) { + problems.push('`api` is declared but there is no composer.json.'); + } else { + const json = readJson(file); + if (typeof json.name !== 'string' || !COMPOSER_NAME.test(json.name)) { + problems.push(`composer.json name "${json.name}" is not a valid Composer package name (vendor/name).`); + } else if (manifest.api !== 'composer.json' && manifest.api !== json.name) { + problems.push(`\`api\` is "${manifest.api}" but composer.json is named "${json.name}".`); + } + if (json.version !== undefined && json.version !== manifest.version) { + problems.push(`composer.json version ${json.version} does not match extension.json version ${manifest.version} (remove it from composer.json or keep them equal; \`flb bump\` updates all three).`); + } + if (typeof manifest.version === 'string' && isSemver(manifest.version) && !composerInstallable(manifest.version)) { + problems.push(`Composer cannot install version ${manifest.version}: use -alpha, -beta or -RC pre-release suffixes when the extension has an api.`); + } + api = { name: json.name, version: json.version, file, json }; + } + } + + if (manifest.compat === undefined || manifest.compat.console === undefined || manifest.compat.api === undefined) { + warnings.push('extension.json has no compat ranges for both console and api; instances of any version will be offered this release.'); + } + + if (problems.length) { + throw new ManifestError(problems, manifestPath); + } + + return { root, manifestPath, manifest: manifest as ExtensionManifest, engine, api, warnings }; +} diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..8d893e8 --- /dev/null +++ b/src/output.ts @@ -0,0 +1,113 @@ +import pc from 'picocolors'; + +/** + * Terminal output. Everything a command prints goes through here so tokens can be + * redacted in one place: personal (flbp_), instance (flbi_) and session (flbs_) tokens + * are masked in every message unless a command prints one on purpose with `secret()`. + */ + +const TOKEN_PATTERN = /\b(flb[pis]_)[A-Za-z0-9]{8,}/g; + +export function redact(text: string): string { + return text.replace(TOKEN_PATTERN, (match, prefix: string) => `${prefix}…${match.slice(-4)}`); +} + +export interface Writer { + out(text: string): void; + err(text: string): void; +} + +const processWriter: Writer = { + out: (text) => process.stdout.write(text), + err: (text) => process.stderr.write(text), +}; + +let writer: Writer = processWriter; + +/** Tests capture output by swapping the writer. */ +export function setWriter(next: Writer | null): void { + writer = next || processWriter; +} + +export const colors = pc; + +export const log = { + /** Plain line on stdout. */ + line(text = ''): void { + writer.out(redact(text) + '\n'); + }, + info(text: string): void { + writer.err(redact(text) + '\n'); + }, + step(text: string): void { + writer.err(`${pc.cyan('›')} ${redact(text)}\n`); + }, + success(text: string): void { + writer.err(`${pc.green('✓')} ${redact(text)}\n`); + }, + warn(text: string): void { + writer.err(`${pc.yellow('!')} ${redact(text)}\n`); + }, + error(text: string): void { + writer.err(`${pc.red('✗')} ${redact(text)}\n`); + }, + /** The one way to print a token: callers decide (--show-token). */ + secret(text: string): void { + writer.out(text + '\n'); + }, + json(value: unknown): void { + writer.out(redact(JSON.stringify(value, null, 2)) + '\n'); + }, +}; + +export interface Column { + header: string; + value: (row: T) => string | number | null | undefined; + max?: number; +} + +export function table(rows: T[], columns: Column[]): string { + const cells = rows.map((row) => columns.map((column) => truncate(String(column.value(row) ?? ''), column.max))); + const widths = columns.map((column, i) => Math.max(column.header.length, ...cells.map((row) => visibleLength(row[i])))); + const render = (row: string[]) => + row + .map((cell, i) => (i === row.length - 1 ? cell : cell + ' '.repeat(widths[i] - visibleLength(cell)))) + .join(' ') + .trimEnd(); + + return [pc.bold(render(columns.map((column) => column.header))), ...cells.map(render)].join('\n'); +} + +function truncate(text: string, max?: number): string { + return max && text.length > max ? text.slice(0, max - 1) + '…' : text; +} + +function visibleLength(text: string): number { + // eslint-disable-next-line no-control-regex + return text.replace(/\u001b\[[0-9;]*m/g, '').length; +} + +export function formatBytes(bytes: number | null | undefined): string { + if (bytes === null || bytes === undefined) { + return ''; + } + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; +} + +export function formatDate(value: string | null | undefined): string { + if (!value) { + return ''; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toISOString().slice(0, 10); +} diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 0000000..6c94521 --- /dev/null +++ b/src/program.ts @@ -0,0 +1,78 @@ +import { Command, CommanderError } from 'commander'; +import { ConfigError, DEFAULT_REGISTRY, configDisplayPath } from './config.js'; +import { ApiError, NetworkError } from './http.js'; +import { log } from './output.js'; +import { CliError } from './util.js'; +import { VERSION } from './version.js'; +import * as auth from './commands/auth.js'; +import * as build from './commands/build.js'; +import * as bump from './commands/bump.js'; +import * as catalog from './commands/catalog.js'; +import * as config from './commands/config.js'; +import * as doctor from './commands/doctor.js'; +import * as init from './commands/init.js'; +import * as installer from './commands/installer.js'; +import * as instance from './commands/instance.js'; +import * as legacy from './commands/legacy.js'; +import * as listing from './commands/listing.js'; +import * as publish from './commands/publish.js'; +import * as release from './commands/release.js'; +import * as token from './commands/token.js'; + +export function createProgram(): Command { + const program = new Command(); + program + .name('flb') + .description('Fleetbase CLI: build and publish extensions, manage installs on your instances') + .version(VERSION, '-v, --version') + .option('-r, --registry ', `Registry Service URL (default: FLB_REGISTRY, then ${configDisplayPath()}, then ${DEFAULT_REGISTRY})`) + .showHelpAfterError('(run with --help for usage)') + .configureHelp({ sortSubcommands: false }); + + for (const module of [auth, token, init, build, bump, publish, release, listing, catalog, instance, installer, doctor, config, legacy]) { + module.register(program); + } + return program; +} + +/** Message and hints for an error, without stack traces for expected failures. */ +export function describeError(error: unknown): { message: string; hints: string[]; exitCode: number } { + if (error instanceof CliError) { + return { message: error.message, hints: error.hints, exitCode: error.exitCode }; + } + if (error instanceof ApiError) { + const hints: string[] = []; + if (error.status === 401) { + hints.push('The token is missing, expired or revoked: run `flb login`.'); + } else if (error.status === 403 && /scope/i.test(error.message)) { + hints.push('The token lacks a scope this needs; log in again or create a token with the scope (`flb token create --scopes …`).'); + } + const fields = Object.entries(error.fields || {}).flatMap(([field, messages]) => messages.map((message) => `${field}: ${message}`)); + const message = fields.length && error.errors.length <= 1 ? `${error.message}\n${fields.map((line) => ` - ${line}`).join('\n')}` : error.message; + return { message, hints, exitCode: 1 }; + } + if (error instanceof NetworkError || error instanceof ConfigError) { + return { message: error.message, hints: [], exitCode: 1 }; + } + const err = error as Error; + return { message: process.env.FLB_DEBUG ? String(err?.stack || err) : err?.message || String(error), hints: [], exitCode: 1 }; +} + +export async function main(argv: string[]): Promise { + const program = createProgram(); + program.exitOverride(); + try { + await program.parseAsync(argv); + return typeof process.exitCode === 'number' ? process.exitCode : 0; + } catch (error) { + if (error instanceof CommanderError) { + return error.exitCode; + } + const { message, hints, exitCode } = describeError(error); + log.error(message); + for (const hint of hints) { + log.info(` ${hint}`); + } + return exitCode; + } +} diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 0000000..2fd35e2 --- /dev/null +++ b/src/prompt.ts @@ -0,0 +1,53 @@ +import enquirer from 'enquirer'; +import { CliError } from './util.js'; + +/** + * Prompts that refuse to hang: without a TTY they fail with the flag to pass instead. + */ + +export function interactive(): boolean { + return Boolean(process.stdin.isTTY && process.stderr.isTTY) && process.env.CI !== 'true'; +} + +function requireTty(flag: string): void { + if (!interactive()) { + throw new CliError(`Missing ${flag} (no terminal to prompt on).`); + } +} + +export async function ask(message: string, flag: string, initial?: string, validate?: (value: string) => true | string): Promise { + requireTty(flag); + const { value } = await enquirer.prompt<{ value: string }>({ + type: 'input', + name: 'value', + message, + initial, + stdout: process.stderr, + validate: (input: string) => (validate ? validate(input.trim()) : input.trim() !== '' || 'Required'), + } as Parameters[0]); + return value.trim(); +} + +export async function askSecret(message: string, flag: string): Promise { + requireTty(flag); + const { value } = await enquirer.prompt<{ value: string }>({ + type: 'password', + name: 'value', + message, + stdout: process.stderr, + validate: (input: string) => input !== '' || 'Required', + } as Parameters[0]); + return value; +} + +export async function confirm(message: string, flag = '--yes', initial = false): Promise { + requireTty(flag); + const { value } = await enquirer.prompt<{ value: boolean }>({ + type: 'confirm', + name: 'value', + message, + initial, + stdout: process.stderr, + } as Parameters[0]); + return value; +} diff --git a/src/releases.ts b/src/releases.ts new file mode 100644 index 0000000..43400d7 --- /dev/null +++ b/src/releases.ts @@ -0,0 +1,194 @@ +import type { Context } from './context.js'; +import { ApiError, type RegistryClient } from './http.js'; +import { findExtensionRoot, loadExtension } from './manifest.js'; +import { colors, formatDate, log, table } from './output.js'; +import type { PublisherExtension, ReleaseCheck, ReleaseDetail } from './types.js'; +import { CliError, sleep } from './util.js'; + +/** Statuses after which nothing changes without someone acting. */ +export const SETTLED = ['failed_checks', 'submitted', 'in_review', 'approved', 'rejected', 'published', 'yanked']; + +/** Done watching: checks are over and, if a submit was requested, it has happened or been refused. */ +export function isSettled(release: ReleaseDetail): boolean { + if (release.status === 'checking') { + return false; + } + if (release.status === 'uploaded') { + // checks passed; a requested submit is still pending only while it has no errors + return !(release.submission?.requested && (release.submission.errors || []).length === 0 && !release.submission.submitted_at); + } + return true; +} + +export function checkLine(check: ReleaseCheck): string { + const mark = check.skipped ? colors.dim('–') : check.passed ? colors.green('✓') : colors.red('✗'); + const name = check.check.replace(/_/g, ' '); + return `${mark} ${name.padEnd(13)} ${check.summary || ''}`; +} + +export function printCheck(check: ReleaseCheck): void { + log.info(` ${checkLine(check)}`); + for (const error of check.errors || []) { + log.info(` ${colors.red(error)}`); + } + for (const warning of check.warnings || []) { + log.info(` ${colors.yellow(warning)}`); + } +} + +export interface WatchOptions { + intervalMs?: number; + timeoutMs?: number; + /** called once per check the first time it has a result, in service order */ + onCheck?: (check: ReleaseCheck) => void; + onStatus?: (status: string, release: ReleaseDetail) => void; +} + +/** + * Poll `GET /v1/publisher/releases/{id}` until the release settles, reporting each check + * as its result lands and each status change. + */ +export async function watchRelease(client: RegistryClient, initial: ReleaseDetail, options: WatchOptions = {}): Promise { + const interval = options.intervalMs ?? 2000; + const deadline = Date.now() + (options.timeoutMs ?? 15 * 60_000); + const seen = new Set(); + let status = ''; + let release = initial; + + for (;;) { + // a check row only exists once that check has a result + for (const check of release.checks || []) { + if (!seen.has(check.check)) { + seen.add(check.check); + options.onCheck?.(check); + } + } + if (release.status !== status) { + status = release.status; + options.onStatus?.(status, release); + } + if (isSettled(release)) { + return release; + } + if (Date.now() > deadline) { + throw new CliError(`Still ${release.status} after the timeout; follow it with \`flb release status ${release.id} --watch\`.`, 2); + } + await sleep(interval); + release = await client.get(`v1/publisher/releases/${release.id}`); + } +} + +export function statusLabel(status: string): string { + const color = ['published', 'approved'].includes(status) ? colors.green : ['failed_checks', 'rejected', 'yanked'].includes(status) ? colors.red : colors.yellow; + return color(status.replace(/_/g, ' ')); +} + +export function reviewReasons(release: ReleaseDetail): string[] { + const reasons = release.review?.reasons; + if (!reasons) { + return []; + } + if (Array.isArray(reasons)) { + return reasons.map(String); + } + return Object.entries(reasons).map(([key, value]) => (typeof value === 'string' ? value : `${key.replace(/_/g, ' ')}${value === true ? '' : `: ${JSON.stringify(value)}`}`)); +} + +export function printRelease(release: ReleaseDetail, options: { checks?: boolean; timeline?: number } = {}): void { + log.line(`${colors.bold(`${release.extension.slug}@${release.version}`)} ${colors.dim(release.id)} ${release.channel} ${statusLabel(release.status)}`); + if (release.compat && (release.compat.console || release.compat.api)) { + log.line(` compat console ${release.compat.console || 'any'}, api ${release.compat.api || 'any'}`); + } + if (release.published_at) { + log.line(` published ${formatDate(release.published_at)}`); + } + if (release.yanked_at) { + log.line(` yanked ${formatDate(release.yanked_at)}`); + } + if (options.checks !== false && release.checks?.length) { + log.line(' checks'); + for (const check of release.checks) { + log.line(` ${checkLine(check)}`); + for (const error of check.errors || []) { + log.line(` ${colors.red(error)}`); + } + } + } + const errors = release.submission?.errors || []; + if (errors.length && ['uploaded', 'rejected'].includes(release.status)) { + log.line(' not submittable yet'); + for (const error of errors) { + log.line(` - ${error}`); + } + } + const reasons = reviewReasons(release); + if (reasons.length) { + log.line(` human review: ${reasons.join('; ')}`); + } + const timeline = (release.reviews || []).slice(-(options.timeline ?? 5)); + if (timeline.length) { + log.line(' history'); + for (const entry of timeline) { + log.line(` ${formatDate(entry.created_at)} ${entry.action.padEnd(12)} ${entry.to_status.padEnd(13)} ${entry.actor}${entry.by ? ` (${entry.by.name})` : ''}${entry.reason ? `: ${entry.reason}` : ''}`); + for (const item of entry.items || []) { + log.line(` ${item.passed ? colors.green('✓') : colors.red('✗')} ${item.code}${item.note ? `: ${item.note}` : ''}`); + } + } + } +} + +// ─── resolving a release from the command line ────────────────────────────── + +export async function listingFor(client: RegistryClient, packages: { npm?: string; composer?: string }): Promise { + const listings = await client.get('v1/publisher/extensions'); + const rows = Array.isArray(listings) ? listings : listings.data || []; + return rows.find((listing) => listing.packages.some((pkg) => (pkg.kind === 'npm' && pkg.name === packages.npm) || (pkg.kind === 'composer' && pkg.name === packages.composer))); +} + +/** The listing slug: --extension, else the listing that owns the local extension's packages. */ +export async function resolveSlug(ctx: Context, client: RegistryClient, flag?: string, cwd = '.'): Promise { + if (flag) { + return flag; + } + let ext; + try { + findExtensionRoot(cwd); + ext = loadExtension(cwd); + } catch { + throw new CliError('Pass --extension , or run this inside an extension.'); + } + const listing = await listingFor(client, { npm: ext.engine?.name, composer: ext.api?.name }); + if (!listing) { + throw new CliError(`No listing on ${ctx.host} owns ${[ext.engine?.name, ext.api?.name].filter(Boolean).join(' or ')}; publish it first with \`flb publish\`.`); + } + return listing.slug; +} + +export async function resolveRelease(ctx: Context, client: RegistryClient, ref: string | undefined, extensionFlag?: string): Promise { + if (ref && /^rel_[a-z0-9]+$/i.test(ref)) { + return client.get(`v1/publisher/releases/${ref}`).catch((error) => { + throw error instanceof ApiError && error.status === 404 ? new CliError(`Release ${ref} not found (or not yours).`) : error; + }); + } + + const slug = await resolveSlug(ctx, client, extensionFlag); + const releases = await client.get(`v1/publisher/extensions/${encodeURIComponent(slug)}/releases`); + const rows = Array.isArray(releases) ? releases : releases.data || []; + const version = ref ? ref.replace(/^v/, '') : undefined; + const match = version ? rows.find((release) => release.version === version) : [...rows].sort((a, b) => b.created_at.localeCompare(a.created_at))[0]; + if (!match) { + throw new CliError(version ? `${slug} has no release ${version}.` : `${slug} has no releases yet.`); + } + return client.get(`v1/publisher/releases/${match.id}`); +} + +export function releasesTable(rows: ReleaseDetail[]): string { + return table(rows, [ + { header: 'VERSION', value: (r) => r.version }, + { header: 'CHANNEL', value: (r) => r.channel }, + { header: 'STATUS', value: (r) => statusLabel(r.status) }, + { header: 'ID', value: (r) => r.id }, + { header: 'CREATED', value: (r) => formatDate(r.created_at) }, + { header: 'PUBLISHED', value: (r) => formatDate(r.published_at) }, + ]); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6c8b4cf --- /dev/null +++ b/src/types.ts @@ -0,0 +1,167 @@ +/** Shapes returned by the Registry Service (only the fields the CLI reads). */ + +export interface Organization { + id: string; + slug: string; + name: string; + role?: string; + is_personal?: boolean; +} + +export interface Account { + id: string; + name: string; + username?: string | null; + email: string; + status: string; + email_verified_at?: string | null; + organizations?: Organization[]; +} + +export interface TokenDetails { + id: string; + name: string; + masked: string; + prefix: string; + last_four: string; + scopes: string[]; + organization: { id: string; slug: string; name: string } | null; + expires_at: string | null; + last_used_at: string | null; + revoked_at: string | null; + created_at: string; +} + +export interface IssuedToken { + token: string; + token_type: string; + expires_at: string | null; + token_details: TokenDetails; + account?: Account; +} + +export interface Package { + kind: 'npm' | 'composer'; + name: string; +} + +export interface PublisherExtension { + id: string; + slug: string; + name: string; + status: string; + organization: { id: string; slug: string; name: string }; + packages: Package[]; + readiness?: { ready: boolean; missing: string[] }; + subtitle?: string | null; + description?: string | null; + promotional_text?: string | null; + hosting?: string; + category?: { slug: string; name: string } | null; + tags?: string[]; + languages?: string[]; + website_url?: string | null; + repo_url?: string | null; + support_url?: string | null; + privacy_url?: string | null; + tos_url?: string | null; + copyright?: string | null; + fa_icon?: string | null; + price?: { model: string; amount: number; currency: string } | null; +} + +export interface ReleaseCheck { + check: string; + passed: boolean; + skipped: boolean; + summary: string | null; + errors: string[]; + warnings: string[]; + updated_at?: string; +} + +export interface ReviewEntry { + action: string; + from_status: string | null; + to_status: string; + actor: string; + by: { id: string; name: string } | null; + reason: string | null; + items: { code: string; passed: boolean; note?: string }[]; + created_at: string; +} + +export interface ReleaseFile { + id: string; + name: string; + size: number; + sha256: string; +} + +export interface ReleaseDetail { + id: string; + extension: { id: string; slug: string; name: string; status: string; organization?: { slug: string } }; + version: string; + channel: string; + status: string; + compat: { console?: string | null; api?: string | null } | null; + notes: string | null; + size_bytes: number | null; + files?: { engine: ReleaseFile | null; api: ReleaseFile | null; dist: ReleaseFile | null }; + checks_passed?: boolean | null; + checks?: ReleaseCheck[]; + submission?: { requested: boolean; submitted_at: string | null; errors: string[] }; + review?: { required: boolean | null; reasons: Record | unknown[] }; + reviews?: ReviewEntry[]; + published_at: string | null; + yanked_at: string | null; + created_at: string; + updated_at?: string; +} + +export interface CatalogListing { + id: string; + slug: string; + name: string; + subtitle: string | null; + description?: string | null; + tags: string[]; + hosting: string; + status: string; + publisher: { id: string; slug: string; name: string; is_verified?: boolean } | null; + category: { slug: string; name: string } | null; + latest_version: string | null; + compat: { console?: string | null; api?: string | null } | null; + packages: { npm?: string | null; composer?: string | null }; + price: { model: string; amount: number; currency: string; effective_amount: number } | null; + is_paid: boolean; + installs_count: number; + published_at: string | null; + website_url?: string | null; + repo_url?: string | null; + support_url?: string | null; +} + +export interface CatalogRelease { + id: string; + version: string; + channel: string; + status: string; + compat: { console?: string | null; api?: string | null } | null; + size_bytes: number | null; + notes: string | null; + published_at: string | null; + yanked_at: string | null; +} + +export interface Instance { + id: string; + name: string; + host: string | null; + kind: string; + console_version: string | null; + api_version: string | null; + status: string; + organization: { id: string; slug: string; name: string }; + last_seen_at: string | null; +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..7ad6aac --- /dev/null +++ b/src/util.ts @@ -0,0 +1,110 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; + +/** An expected failure: printed without a stack trace, exits with `exitCode`. */ +export class CliError extends Error { + constructor( + message: string, + readonly exitCode = 1, + readonly hints: string[] = [], + ) { + super(message); + } +} + +/** + * Token lifetime in days from `90`, `90d`, `12w`, `6mo` or `1y`. The service caps tokens at 365 days. + */ +export function parseDays(value: string | number): number { + if (typeof value === 'number') { + return checkDays(value, String(value)); + } + const match = String(value).trim().toLowerCase().match(/^(\d+)\s*(d|days?|w|weeks?|mo|months?|y|years?)?$/); + if (!match) { + throw new CliError(`Invalid duration "${value}": use days (90 or 90d), weeks (12w), months (6mo) or years (1y).`); + } + const amount = Number(match[1]); + const unit = match[2] || 'd'; + const factor = unit.startsWith('w') ? 7 : unit.startsWith('mo') ? 30 : unit.startsWith('y') ? 365 : 1; + return checkDays(amount * factor, value); +} + +function checkDays(days: number, raw: string): number { + if (!Number.isInteger(days) || days < 1 || days > 365) { + throw new CliError(`Invalid duration "${raw}": tokens last between 1 and 365 days.`); + } + return days; +} + +/** Durations for timeouts: `90s`, `15m`, `2h`, or seconds. Returns milliseconds. */ +export function parseTimeout(value: string): number { + const match = String(value).trim().toLowerCase().match(/^(\d+)\s*(ms|s|m|h)?$/); + if (!match) { + throw new CliError(`Invalid timeout "${value}": use seconds (90s), minutes (15m) or hours (2h).`); + } + const amount = Number(match[1]); + const unit = match[2] || 's'; + return amount * (unit === 'ms' ? 1 : unit === 's' ? 1000 : unit === 'm' ? 60_000 : 3_600_000); +} + +export function splitList(value: string | string[] | undefined): string[] { + const items = Array.isArray(value) ? value : value ? [value] : []; + return items + .flatMap((item) => item.split(/[\s,]+/)) + .map((item) => item.trim()) + .filter(Boolean); +} + +let stdinOverride: string | null = null; + +/** Tests feed stdin through this instead of the process stream. */ +export function setStdin(value: string | null): void { + stdinOverride = value; +} + +export async function readStdin(): Promise { + if (stdinOverride !== null) { + return stdinOverride.replace(/\r?\n$/, ''); + } + if (process.stdin.isTTY) { + throw new CliError('Nothing on stdin: pipe the value in, for example `printf %s "$TOKEN" | flb login --token-stdin`.'); + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, ''); +} + +export function sha256File(file: string): string { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function slugify(value: string): string { + return value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/([a-z])([A-Z])/g, '$1-$2') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export function studly(value: string): string { + return slugify(value) + .split('-') + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(''); +} + +/** `name@1.2.0`, `@scope/name@1.2.0` → [name, version]; `@scope/name` keeps its scope. */ +export function splitVersion(ref: string): [string, string | undefined] { + const at = ref.lastIndexOf('@'); + if (at > 0) { + return [ref.slice(0, at), ref.slice(at + 1) || undefined]; + } + return [ref, undefined]; +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..794b617 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,6 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +// package.json sits one level above both src/ (tests) and dist/ (the published build) +export const VERSION: string = (require('../package.json') as { version: string }).version; diff --git a/test/e2e/fixtures/artisan b/test/e2e/fixtures/artisan new file mode 100755 index 0000000..6867de2 --- /dev/null +++ b/test/e2e/fixtures/artisan @@ -0,0 +1,75 @@ + reports it running, then +// completed (or failed at the composer stage when FLB_STUB_FAIL is set). + +$dir = __DIR__; +$args = array_slice($argv, 1); +file_put_contents("$dir/calls.log", implode(' ', $args) . "\n", FILE_APPEND); + +$command = $args[0] ?? 'list'; +$state = "$dir/job.state"; + +function out(array $value): void +{ + echo "Some framework warning on stdout\n"; + echo json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; +} + +switch ($command) { + case 'list': + echo json_encode(['commands' => array_map(fn ($name) => ['name' => $name], ['registry:init', 'registry:install', 'registry:uninstall', 'registry:update', 'registry:job', 'registry:register'])]); + exit(0); + + case 'registry:install': + case 'registry:update': + case 'registry:uninstall': + file_put_contents($state, '0'); + out(['id' => 'job_e2e', 'action' => substr($command, 9), 'status' => 'queued', 'position' => 1, 'extension' => ['name' => $args[1] ?? '']]); + exit(0); + + case 'registry:register': + // the real command: InstanceRegistration::payload, the service call, then ::store (encrypted settings) + $options = []; + foreach ($args as $arg) { + if (preg_match('/^--([a-z-]+)=(.*)$/', $arg, $m)) { + $options[$m[1]] = $m[2]; + } + } + $code = in_array('--code-stdin', $args, true) ? trim((string) stream_get_contents(STDIN)) : ($options['code'] ?? ''); + $payload = array_filter(['code' => strtoupper($code), 'name' => $options['name'] ?? 'Stub Instance', 'kind' => 'self_hosted', 'host' => $options['host'] ?? null, 'api_version' => '1.6.71']); + $response = @file_get_contents(rtrim($options['service-url'] ?? '', '/') . '/v1/instances/register', false, stream_context_create(['http' => [ + 'method' => 'POST', 'ignore_errors' => true, 'content' => json_encode($payload), + 'header' => "Content-Type: application/json\r\nAccept: application/json\r\n", + ]])); + $body = json_decode((string) $response, true) ?: []; + if (empty($body['token'])) { + out(['error' => implode(' ', $body['errors'] ?? ['The Registry Service could not be reached.'])]); + exit(1); + } + file_put_contents("$dir/settings.json", json_encode(['instance_token' => $body['token']])); + $instance = $body['instance']; + out([ + 'id' => $instance['id'], 'name' => $instance['name'], 'host' => $instance['host'], 'kind' => $instance['kind'], 'status' => $instance['status'], + 'organization' => ['id' => $instance['organization']['id'], 'slug' => $instance['organization']['slug'], 'name' => $instance['organization']['name']], + 'service_url' => $options['service-url'], 'token_hint' => 'flbi_…' . substr($body['token'], -4), 'registered_at' => date(DATE_ATOM), + ]); + exit(0); + + case 'registry:job': + $polls = (int) @file_get_contents($state); + file_put_contents($state, (string) ($polls + 1)); + if ($polls === 0) { + out(['id' => $args[1], 'status' => 'running', 'stage' => 'download', 'progress' => 20, 'message' => 'Downloading the release', 'log' => ['resolved release']]); + } elseif (getenv('FLB_STUB_FAIL')) { + out(['id' => $args[1], 'status' => 'failed', 'stage' => 'composer', 'failed_stage' => 'composer', 'message' => 'composer require failed', 'log' => ['resolved release', 'Problem 1: conflict']]); + } else { + out(['id' => $args[1], 'status' => 'completed', 'stage' => 'record', 'progress' => 100, 'log' => ['resolved release', 'composer require done', 'caches cleared']]); + } + exit(0); + + default: + fwrite(STDERR, "Command \"$command\" is not defined.\n"); + exit(1); +} diff --git a/test/e2e/run.sh b/test/e2e/run.sh new file mode 100755 index 0000000..0f03a6c --- /dev/null +++ b/test/e2e/run.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# End-to-end run of the built CLI (dist/) against a local Registry Service. +# +# FLB_E2E_REGISTRY service URL (default http://127.0.0.1:8000) +# FLB_E2E_SERVICE the service checkout: its storage/logs/laravel.log carries the verification +# email (MAIL_MAILER=log) and `php artisan registry:staff` flags the reviewer +# FLB_E2E_TEMPLATE extension template for `flb init` (default: fleetbase/starter-extension on GitHub) +# +# Start the service first (see the registry README): +# php artisan migrate --seed && php artisan registry:seed --seed-release && php artisan serve +# with QUEUE_CONNECTION=sync, or a queue worker beside it. +# +# Covers: register, verify, login, whoami, token create/list/revoke, init, build, publish (checks +# streamed), listing show/update/categories/list, release list/status/submit/yank, staff approval +# through the admin API, search, info, a second release auto-published by the review policy, a beta +# channel release, instance code/register/status (token kept here, and handed to the instance with +# registry:register), install/update/uninstall through a stub artisan, doctor, logout, and that no +# token is printed or written outside the config file. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +REGISTRY="${FLB_E2E_REGISTRY:-http://127.0.0.1:8000}" +SERVICE="${FLB_E2E_SERVICE:?set FLB_E2E_SERVICE to the registry service checkout}" +TEMPLATE="${FLB_E2E_TEMPLATE:-https://github.com/fleetbase/starter-extension.git}" +MAIL_LOG="$SERVICE/storage/logs/laravel.log" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +export FLEETBASE_HOME="$WORK/home" +export FLB_REGISTRY="$REGISTRY" +export NO_COLOR=1 +unset FLB_TOKEN + +STAMP="$(date +%s)$RANDOM" +USERNAME="e2e${STAMP}" +EMAIL="e2e+${STAMP}@example.com" +PASSWORD="E2e-password-${STAMP}" +OUT="$WORK/out.log" + +flb() { node "$ROOT/dist/bin/flb.js" "$@"; } +step() { printf '\n\033[1m== %s\033[0m\n' "$*"; } +fail() { printf '\033[31mFAIL: %s\033[0m\n' "$*" >&2; [ -f "$OUT" ] && cat "$OUT" >&2; exit 1; } +# run a CLI command, keep its stdout+stderr in $OUT, fail on a non-zero exit unless told otherwise +run() { local expect=0; if [ "$1" = "--expect" ]; then expect="$2"; shift 2; fi + set +e; flb "$@" >"$OUT" 2>&1; local code=$?; set -e + cat "$OUT" + [ "$code" -eq "$expect" ] || fail "flb $* exited $code, expected $expect"; } +contains() { grep -q -- "$1" "$OUT" || fail "output does not contain: $1"; } +no_tokens() { if grep -Eq 'flb[pi]_[A-Za-z0-9]{20,}' "$OUT"; then fail "a token was printed by: $*"; fi; } +api() { local method="$1" path="$2" token="$3"; shift 3 + curl -sf -X "$method" -H 'Accept: application/json' -H 'Content-Type: application/json' -H "Authorization: Bearer $token" "$REGISTRY$path" "$@"; } + +curl -sf "$REGISTRY/v1/catalog/categories" >/dev/null || fail "no Registry Service at $REGISTRY" + +step "register and verify" +printf %s "$PASSWORD" | flb register --name "E2E Developer" --email "$EMAIL" --username "$USERNAME" --password-stdin >"$OUT" 2>&1 || fail register +cat "$OUT" +CODE="$(grep -oE "flb verify --email ${EMAIL//+/\\+} --code [0-9]{6}" "$MAIL_LOG" | tail -1 | awk '{print $NF}')" +[ -n "$CODE" ] || fail "no verification code for $EMAIL in $MAIL_LOG" +run verify --email "$EMAIL" --code "$CODE" +contains "verified" + +step "login stores a scoped token in the config only" +printf %s "$PASSWORD" | flb login --email "$EMAIL" --password-stdin >"$OUT" 2>&1 || fail login +cat "$OUT"; no_tokens login +contains "scopes read, publish, instance:register" +[ "$(stat -c %a "$FLEETBASE_HOME/config.json" 2>/dev/null || stat -f %Lp "$FLEETBASE_HOME/config.json")" = "600" ] || fail "config.json is not mode 600" +run whoami +contains "$EMAIL"; contains "$USERNAME" + +step "tokens" +run token create --name ci-token --scopes publish,read --expires 30d --show-token +CI_TOKEN="$(grep -m1 -oE 'flbp_[A-Za-z0-9]{40}' "$OUT")" +[ -n "$CI_TOKEN" ] || fail "token create --show-token printed no token" +run --expect 1 token create --name hidden +contains "--show-token" +run token list +no_tokens token list +CI_TOKEN_ID="$(grep 'ci-token' "$OUT" | awk '{print $1}')" +FLB_TOKEN="$CI_TOKEN" flb whoami >"$OUT" 2>&1 || fail "FLB_TOKEN not accepted" +grep -q "$EMAIL" "$OUT" || fail "whoami with FLB_TOKEN" + +step "init and build" +cd "$WORK" +run init --name "E2E Insights" --template "$TEMPLATE" --yes --author "E2E Developer <$EMAIL>" --keywords e2e,testing +cd "$WORK/e2e-insights" +grep -q "\"name\": \"@${USERNAME}/e2e-insights-engine\"" package.json || fail "package name" +grep -q "\"name\": \"${USERNAME}/e2e-insights-api\"" composer.json || fail "composer name" +grep -q '"console": ">=0.7.0 <0.8.0"' extension.json || fail "compat.console" +run build +[ -f .fleetbase/release.json ] || fail "no release.json" +# list to files first: under pipefail, grep -q closing the pipe early makes GNU tar fail +tar tzf .fleetbase/build/0.1.0/api-0.1.0.tar.gz > "$WORK/api.list" +tar tzf .fleetbase/build/0.1.0/engine-0.1.0.tgz > "$WORK/engine.list" +grep -q '^package/composer.json$' "$WORK/api.list" || fail "api archive layout" +grep -q '^package/package.json$' "$WORK/engine.list" || fail "engine tarball layout" +if grep -q '\.fleetbase/' "$WORK/engine.list"; then fail "build output packed into the engine"; fi + +step "publish streams the checks; --submit reports an incomplete listing" +run --expect 1 publish --submit --notes "First release" +contains "✓ manifest"; contains "✓ secrets scan"; contains "Not submitted; the listing is not ready" +no_tokens publish +SLUG="$(grep -oE 'Created the draft listing [a-z0-9-]+' "$OUT" | awk '{print $NF}')" +[ -n "$SLUG" ] || fail "no listing slug in the publish output" +REL1="$(grep -m1 -oE 'rel_[a-z0-9]+' "$OUT")" + +step "complete the listing with flb listing, then submit" +run listing show +contains "still needed"; contains "The listing needs a subtitle." +run listing categories +contains "telematics" +run --expect 1 listing update --category no-such-category +run listing update --subtitle "End-to-end test extension" --description "Built and published by the CLI end-to-end test." \ + --promotional-text "Proves flb init, build and publish" --tags e2e,cli --copyright "E2E" \ + --website https://example.com --support https://example.com/support --privacy https://example.com/privacy +contains "still needed"; contains "an icon" +printf '{"fa_icon": "flask", "category": "telematics"}' > "$WORK/listing.json" +run listing update --file "$WORK/listing.json" +contains "ready" +run listing list +contains "$SLUG" +run release submit "$REL1" +contains "submitted" +run release status +contains "submitted"; contains "first release" + +step "staff review through the admin API" +REVIEWER_HOME="$WORK/reviewer"; mkdir -p "$REVIEWER_HOME" +REVIEWER="rev${STAMP}"; REVIEWER_EMAIL="rev+${STAMP}@example.com" +printf %s "$PASSWORD" | FLEETBASE_HOME="$REVIEWER_HOME" flb register --name Reviewer --email "$REVIEWER_EMAIL" --username "$REVIEWER" --password-stdin >/dev/null 2>&1 +RCODE="$(grep -oE "flb verify --email ${REVIEWER_EMAIL//+/\\+} --code [0-9]{6}" "$MAIL_LOG" | tail -1 | awk '{print $NF}')" +FLEETBASE_HOME="$REVIEWER_HOME" flb verify --email "$REVIEWER_EMAIL" --code "$RCODE" >/dev/null +(cd "$SERVICE" && php artisan registry:staff "$REVIEWER" >/dev/null) +printf %s "$PASSWORD" | FLEETBASE_HOME="$REVIEWER_HOME" flb login --email "$REVIEWER_EMAIL" --password-stdin --scopes review,read >/dev/null 2>&1 +REVIEW_TOKEN="$(FLEETBASE_HOME="$REVIEWER_HOME" flb token create --name review --scopes review --show-token 2>/dev/null | grep -oE 'flbp_[A-Za-z0-9]{40}')" +api POST "/v1/admin/releases/$REL1/approve" "$REVIEW_TOKEN" -d '{"reason":"e2e"}' >/dev/null || fail "approve" +run release status "$REL1" +contains "published" + +step "catalog" +run search "E2E Insights" +contains "$SLUG" +run info "$SLUG" +contains "@${USERNAME}/e2e-insights-engine"; contains "0.1.0" +run search --json +grep -q '"meta"' "$OUT" || fail "search --json" + +step "second release is published by the review policy without a human" +run bump 0.2.0 +run publish --submit +contains "published" +run release list +contains "0.2.0"; contains "0.1.0" + +step "beta channel" +run bump prerelease --preid beta +contains "0.2.0 → 0.2.1-beta.0" +run publish --channel beta +contains "(beta)" +run publish --channel beta --rebuild +contains "Uploaded" # never submitted, so the upload replaces it + +step "a published version cannot be uploaded again" +run bump 0.2.0 +run --expect 1 publish +contains "already has a release 0.2.0" +node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync("extension.json")); j.description="changed"; fs.writeFileSync("extension.json", JSON.stringify(j,null,4)+"\n");' +run --expect 1 publish --no-build +contains "extension.json changed since the build" + +step "yank" +run release yank 0.1.0 --yes --reason "e2e" +contains "yanked" +run --expect 1 release yank 0.1.0 --yes +contains "cannot be yanked" + +step "instance registration" +run instance code +contains "Single use" +run instance register --name e2e-instance --host https://fleet.example.test --console-version 0.7.69 --api-version 1.6.71 +no_tokens instance register +contains "Registered e2e-instance" +grep -q '"flbi_' "$FLEETBASE_HOME/config.json" || fail "instance token not in config.json" +run instance status +contains "e2e-instance"; contains "active" + +step "instance registration handed to the instance (registry:register)" +INSTANCE="$WORK/instance"; mkdir -p "$INSTANCE" +cp "$ROOT/test/e2e/fixtures/artisan" "$INSTANCE/artisan" +run instance register --path "$INSTANCE" --name e2e-server +no_tokens instance register --path +contains "Registered e2e-server"; contains "keeps its token" +grep -q 'flbi_' "$INSTANCE/settings.json" || fail "the instance did not store its token" +grep -q -- 'registry:register --code-stdin --service-url=' "$INSTANCE/calls.log" || fail "registry:register arguments" +if grep -Eq '[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}' "$INSTANCE/calls.log"; then fail "the registration code was passed in argv"; fi +SERVER_ID="$(grep -m1 -oE 'inst_[a-z0-9]+' "$OUT")" +node -e 'const c=require(process.argv[1]); const e=c.instances[process.argv[2]]; if (!e || e.token) process.exit(1);' "$FLEETBASE_HOME/config.json" "$SERVER_ID" || fail "the CLI kept a token for an instance that holds its own" +run --expect 1 instance status +contains "keeps its token on the instance" +run instance list +no_tokens instance list +contains "on the instance" + +step "install, update and uninstall through artisan" +run install "$SLUG@0.2.0" --path "$INSTANCE" +contains "Install of $SLUG@0.2.0 completed" +grep -q "registry:install $SLUG --release=0.2.0" "$INSTANCE/calls.log" || fail "artisan got the wrong arguments" +run update "$SLUG" --path "$INSTANCE" --release 0.2.1-beta.0 +contains "Update of $SLUG@0.2.1-beta.0 completed" +FLB_STUB_FAIL=1 run --expect 1 uninstall "$SLUG" --path "$INSTANCE" +contains "failed at composer" + +step "doctor" +run doctor --path "$INSTANCE" +contains "✓ token"; contains "✓ registry"; contains "instance installer"; contains "registry:register available" + +step "revoke and logout" +run token revoke "$CI_TOKEN_ID" +FLB_TOKEN="$CI_TOKEN" flb whoami >"$OUT" 2>&1 && fail "revoked token still works" +run logout +contains "revoked" +run --expect 1 whoami +contains "Not logged in" + +step "no token outside the config file (settings.json is the stub instance's own storage)" +if grep -rlE 'flb[pi]_[A-Za-z0-9]{40}' "$WORK" --exclude=config.json --exclude=out.log --exclude=settings.json 2>/dev/null | grep -v "^$WORK/reviewer"; then fail "a token was written outside config.json"; fi + +printf '\n\033[32mE2E passed\033[0m\n' diff --git a/test/fixtures/extension/LICENSE.md b/test/fixtures/extension/LICENSE.md new file mode 100644 index 0000000..c085337 --- /dev/null +++ b/test/fixtures/extension/LICENSE.md @@ -0,0 +1 @@ +AGPL diff --git a/test/fixtures/extension/README.md b/test/fixtures/extension/README.md new file mode 100644 index 0000000..f5c7d90 --- /dev/null +++ b/test/fixtures/extension/README.md @@ -0,0 +1 @@ +# Acme Insights diff --git a/test/fixtures/extension/addon/engine.js b/test/fixtures/extension/addon/engine.js new file mode 100644 index 0000000..9ecff60 --- /dev/null +++ b/test/fixtures/extension/addon/engine.js @@ -0,0 +1 @@ +export default class Engine {} diff --git a/test/fixtures/extension/composer.json b/test/fixtures/extension/composer.json new file mode 100644 index 0000000..2eb74da --- /dev/null +++ b/test/fixtures/extension/composer.json @@ -0,0 +1,7 @@ +{ + "name": "acme/insights-api", + "version": "1.2.0", + "autoload": { "psr-4": { "Acme\\Insights\\": "server/src/" } }, + "autoload-dev": { "psr-4": { "Acme\\Insights\\Tests\\": "server/tests/" } }, + "archive": { "exclude": ["/server/migrations/*.bak"] } +} diff --git a/test/fixtures/extension/extension.json b/test/fixtures/extension/extension.json new file mode 100644 index 0000000..5d43a77 --- /dev/null +++ b/test/fixtures/extension/extension.json @@ -0,0 +1,8 @@ +{ + "name": "Acme Insights", + "version": "1.2.0", + "description": "Fixture extension", + "engine": "package.json", + "api": "composer.json", + "compat": { "console": ">=0.7.0 <0.8.0", "api": "^1.6" } +} diff --git a/test/fixtures/extension/package.json b/test/fixtures/extension/package.json new file mode 100644 index 0000000..49b0767 --- /dev/null +++ b/test/fixtures/extension/package.json @@ -0,0 +1,6 @@ +{ + "name": "@acme/insights-engine", + "version": "1.2.0", + "description": "Fixture engine", + "keywords": ["ember-engine"] +} diff --git a/test/fixtures/extension/server/migrations/2026_01_01_create.php b/test/fixtures/extension/server/migrations/2026_01_01_create.php new file mode 100644 index 0000000..878a8be --- /dev/null +++ b/test/fixtures/extension/server/migrations/2026_01_01_create.php @@ -0,0 +1 @@ +>_ + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: **(1)** assert copyright on the software, and **(2)** offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions + +“This License” refers to version 3 of the GNU Affero General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this +License. Each licensee is addressed as “you”. “Licensees” and +“recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a “modified version” of the +earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based +on the Program. + +To “propagate” a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” +to the extent that it includes a convenient and prominently visible +feature that **(1)** displays an appropriate copyright notice, and **(2)** +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code + +The “source code” for a work means the preferred form of the work +for making modifications to it. “Object code” means any non-source +form of a work. + +A “Standard Interface” means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other +than the work as a whole, that **(a)** is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and **(b)** serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +“Major Component”, in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +### 2. Basic Permissions + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +### 4. Conveying Verbatim Copies + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + +* **a)** The work must carry prominent notices stating that you modified +it, and giving a relevant date. +* **b)** The work must carry prominent notices stating that it is +released under this License and any conditions added under section 7. +This requirement modifies the requirement in section 4 to +“keep intact all notices”. +* **c)** You must license the entire work, as a whole, under this +License to anyone who comes into possession of a copy. This +License will therefore apply, along with any applicable section 7 +additional terms, to the whole of the work, and all its parts, +regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not +invalidate such permission if you have separately received it. +* **d)** If the work has interactive user interfaces, each must display +Appropriate Legal Notices; however, if the Program has interactive +interfaces that do not display Appropriate Legal Notices, your +work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +“aggregate” if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + +* **a)** Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by the +Corresponding Source fixed on a durable physical medium +customarily used for software interchange. +* **b)** Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by a +written offer, valid for at least three years and valid for as +long as you offer spare parts or customer support for that product +model, to give anyone who possesses the object code either **(1)** a +copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical +medium customarily used for software interchange, for a price no +more than your reasonable cost of physically performing this +conveying of source, or **(2)** access to copy the +Corresponding Source from a network server at no charge. +* **c)** Convey individual copies of the object code with a copy of the +written offer to provide the Corresponding Source. This +alternative is allowed only occasionally and noncommercially, and +only if you received the object code with such an offer, in accord +with subsection 6b. +* **d)** Convey the object code by offering access from a designated +place (gratis or for a charge), and offer equivalent access to the +Corresponding Source in the same way through the same place at no +further charge. You need not require recipients to copy the +Corresponding Source along with the object code. If the place to +copy the object code is a network server, the Corresponding Source +may be on a different server (operated by you or a third party) +that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the +Corresponding Source, you remain obligated to ensure that it is +available for as long as needed to satisfy these requirements. +* **e)** Convey the object code using peer-to-peer transmission, provided +you inform other peers where the object code and Corresponding +Source of the work are being offered to the general public at no +charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A “User Product” is either **(1)** a “consumer product”, which means any +tangible personal property which is normally used for personal, family, +or household purposes, or **(2)** anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, “normally used” refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms + +“Additional permissions” are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + +* **a)** Disclaiming warranty or limiting liability differently from the +terms of sections 15 and 16 of this License; or +* **b)** Requiring preservation of specified reasonable legal notices or +author attributions in that material or in the Appropriate Legal +Notices displayed by works containing it; or +* **c)** Prohibiting misrepresentation of the origin of that material, or +requiring that modified versions of such material be marked in +reasonable ways as different from the original version; or +* **d)** Limiting the use for publicity purposes of names of licensors or +authors of the material; or +* **e)** Declining to grant rights under trademark law for use of some +trade names, trademarks, or service marks; or +* **f)** Requiring indemnification of licensors and authors of that +material by anyone who conveys the material (or modified versions of +it) with contractual assumptions of liability to the recipient, for +any liability that these contractual assumptions directly impose on +those licensors and authors. + +All other non-permissive additional terms are considered “further +restrictions” within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +### 8. Termination + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated **(a)** +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and **(b)** permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents + +A “contributor” is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, “control” includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To “grant” such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either **(1)** cause the Corresponding Source to be so +available, or **(2)** arrange to deprive yourself of the benefit of the +patent license for this particular work, or **(3)** arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. “Knowingly relying” means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is “discriminatory” if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license **(a)** in connection with copies of the covered work +conveyed by you (or copies made from those copies), or **(b)** primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +### 13. Remote Network Interaction; Use with the GNU General Public License + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +### 14. Revised Versions of this License + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License “or any later version” applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +### 16. Limitation of Liability + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16 + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +_END OF TERMS AND CONDITIONS_ + +## How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the “copyright” line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a “Source” link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a “copyright disclaimer” for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<>. \ No newline at end of file diff --git a/test/fixtures/starter/addon/engine.js b/test/fixtures/starter/addon/engine.js new file mode 100644 index 0000000..adc1540 --- /dev/null +++ b/test/fixtures/starter/addon/engine.js @@ -0,0 +1,17 @@ +import Engine from '@ember/engine'; +import loadInitializers from 'ember-load-initializers'; +import Resolver from 'ember-resolver'; +import config from './config/environment'; +import { services, externalRoutes } from '@fleetbase/ember-core/exports'; + +const { modulePrefix } = config; +export default class StarterEngine extends Engine { + modulePrefix = modulePrefix; + Resolver = Resolver; + dependencies = { + services, + externalRoutes, + }; +} + +loadInitializers(StarterEngine, modulePrefix); diff --git a/test/fixtures/starter/addon/extension.js b/test/fixtures/starter/addon/extension.js new file mode 100644 index 0000000..1891acd --- /dev/null +++ b/test/fixtures/starter/addon/extension.js @@ -0,0 +1,15 @@ +import { MenuItem, Widget, ExtensionComponent } from '@fleetbase/ember-core/contracts'; + +export default { + setupExtension(app, universe) { + const menuService = universe.getService('universe/menu-service'); + + // Register in header menu + menuService.registerHeaderMenuItem('Starter', 'console.starter', { + icon: 'layer-group', + priority: 5, + description: 'Samle starter extension for developing in Fleetbase.', + shortcuts: [], + }); + } +}; diff --git a/test/fixtures/starter/addon/templates/application.hbs b/test/fixtures/starter/addon/templates/application.hbs new file mode 100644 index 0000000..dfcbbf3 --- /dev/null +++ b/test/fixtures/starter/addon/templates/application.hbs @@ -0,0 +1,7 @@ + + Home + + + + {{outlet}} + \ No newline at end of file diff --git a/test/fixtures/starter/addon/templates/home.hbs b/test/fixtures/starter/addon/templates/home.hbs new file mode 100644 index 0000000..7db14ab --- /dev/null +++ b/test/fixtures/starter/addon/templates/home.hbs @@ -0,0 +1,4 @@ +
+

Welcome to your Fleetbase starter extension.

+
+{{outlet}} \ No newline at end of file diff --git a/test/fixtures/starter/addon/templates/virtual.hbs b/test/fixtures/starter/addon/templates/virtual.hbs new file mode 100644 index 0000000..7e0b629 --- /dev/null +++ b/test/fixtures/starter/addon/templates/virtual.hbs @@ -0,0 +1,10 @@ + + + +
+
+ {{component (lazy-engine-component @model.component) params=@model.componentParams}} +
+
+ +
\ No newline at end of file diff --git a/test/fixtures/starter/app/routes/home.js b/test/fixtures/starter/app/routes/home.js new file mode 100644 index 0000000..fc3c427 --- /dev/null +++ b/test/fixtures/starter/app/routes/home.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/starter-engine/routes/home'; diff --git a/test/fixtures/starter/app/templates/home.js b/test/fixtures/starter/app/templates/home.js new file mode 100644 index 0000000..77c87f8 --- /dev/null +++ b/test/fixtures/starter/app/templates/home.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/starter-engine/templates/home'; diff --git a/test/fixtures/starter/composer.json b/test/fixtures/starter/composer.json new file mode 100644 index 0000000..baad3e5 --- /dev/null +++ b/test/fixtures/starter/composer.json @@ -0,0 +1,67 @@ +{ + "name": "fleetbase/extension-starter", + "version": "0.0.1", + "description": "Starter Extension for Fleetbase", + "keywords": [ + "fleetbase", + "fleetbase-extension" + ], + "license": "AGPL-3.0-or-later", + "authors": [], + "require": { + "php": "^8.0", + "fleetbase/core-api": "*", + "php-http/guzzle7-adapter": "^1.0", + "psr/http-factory-implementation": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.34.1", + "nunomaduro/collision": "^5.11.0|^6.4.0", + "pestphp/pest": "^1.22.6", + "phpstan/phpstan": "^1.10.38", + "symfony/var-dumper": "^5.4.29" + }, + "autoload": { + "psr-4": { + "Fleetbase\\Starter\\": "server/src/", + "Fleetbase\\Starter\\Seeds\\": "server/seeds/" + } + }, + "autoload-dev": { + "psr-4": { + "Fleetbase\\Starter\\Tests\\": "server/tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "laravel": { + "providers": [ + "Fleetbase\\Starter\\Providers\\StarterServiceProvider" + ] + }, + "fleetbase": { + "sandbox-migrations": false + } + }, + "config": { + "vendor-dir": "./server_vendor", + "sort-packages": true, + "preferred-install": "dist", + "allow-plugins": { + "pestphp/pest-plugin": true, + "php-http/discovery": true + } + }, + "scripts": { + "lint": "php-cs-fixer fix -v", + "test:lint": "php-cs-fixer fix -v --dry-run", + "test:types": "phpstan analyse --ansi --memory-limit=0", + "test:unit": "pest --colors=always", + "test": [ + "@test:lint", + "@test:types", + "@test:unit" + ] + } +} diff --git a/test/fixtures/starter/extension.json b/test/fixtures/starter/extension.json new file mode 100644 index 0000000..34575d3 --- /dev/null +++ b/test/fixtures/starter/extension.json @@ -0,0 +1,10 @@ +{ + "name": "Starter", + "version": "0.0.1", + "description": "Starter Extension for Fleetbase", + "repository": "https://github.com/fleetbase/starter-extension", + "license": "AGPL-3.0-or-later", + "author": "Fleetbase Pte Ltd ", + "engine": "package.json", + "api": "composer.json" +} diff --git a/test/fixtures/starter/package.json b/test/fixtures/starter/package.json new file mode 100644 index 0000000..ce82110 --- /dev/null +++ b/test/fixtures/starter/package.json @@ -0,0 +1,68 @@ +{ + "name": "@fleetbase/starter-engine", + "version": "0.0.1", + "description": "Starter Extension for Fleetbasee", + "keywords": [ + "fleetbase-extension", + "fleetbase", + "ember-addon", + "ember-engine" + ], + "fleetbase": { + "route": "starter" + }, + "repository": "https://github.com/fleetbase/starter-extension", + "license": "AGPL-3.0-or-later", + "author": "Fleetbase Pte Ltd ", + "directories": { + "app": "app", + "addon": "addon", + "tests": "tests" + }, + "scripts": { + "build": "ember build --environment=production", + "lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\"", + "lint:css": "stylelint \"**/*.css\"", + "lint:css:fix": "concurrently \"npm:lint:css -- --fix\"", + "lint:fix": "concurrently \"npm:lint:*:fix\" --names \"fix:\"", + "lint:hbs": "ember-template-lint .", + "lint:hbs:fix": "ember-template-lint . --fix", + "lint:js": "eslint . --cache", + "lint:js:fix": "eslint . --fix", + "start": "ember serve", + "test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"", + "test:ember": "ember test", + "test:ember-compatibility": "ember try:each", + "publish:npm": "npm config set registry https://registry.npmjs.org/ && npm publish", + "publish:github": "npm config set '@fleetbase:registry' https://npm.pkg.github.com/ && npm publish" + }, + "dependencies": {}, + "devDependencies": {}, + "peerDependencies": { + "ember-engines": "^0.9.0" + }, + "engines": { + "node": ">= 18" + }, + "ember": { + "edition": "octane" + }, + "ember-addon": { + "configPath": "tests/dummy/config" + }, + "prettier": { + "trailingComma": "es5", + "tabWidth": 4, + "semi": true, + "singleQuote": true, + "printWidth": 190, + "overrides": [ + { + "files": "*.hbs", + "options": { + "singleQuote": false + } + } + ] + } +} diff --git a/test/fixtures/starter/server/config/starter.php b/test/fixtures/starter/server/config/starter.php new file mode 100644 index 0000000..322e10c --- /dev/null +++ b/test/fixtures/starter/server/config/starter.php @@ -0,0 +1,16 @@ + [ + 'version' => '0.0.1', + 'routing' => [ + 'prefix' => 'starter', + 'internal_prefix' => 'int' + ], + ] +]; diff --git a/test/fixtures/starter/server/src/Http/Controllers/StarterResourceController.php b/test/fixtures/starter/server/src/Http/Controllers/StarterResourceController.php new file mode 100644 index 0000000..8b98e6b --- /dev/null +++ b/test/fixtures/starter/server/src/Http/Controllers/StarterResourceController.php @@ -0,0 +1,13 @@ +app->register(CoreServiceProvider::class); + } + + /** + * Bootstrap any package services. + * + * @return void + * + * @throws \Exception if the `fleetbase/core-api` package is not installed + */ + public function boot() + { + $this->registerObservers(); + $this->registerExpansionsFrom(__DIR__ . '/../Expansions'); + $this->loadRoutesFrom(__DIR__ . '/../routes.php'); + $this->loadMigrationsFrom(__DIR__ . '/../../migrations'); + } +} diff --git a/test/fixtures/starter/server/src/Support/Utils.php b/test/fixtures/starter/server/src/Support/Utils.php new file mode 100644 index 0000000..6253ef5 --- /dev/null +++ b/test/fixtures/starter/server/src/Support/Utils.php @@ -0,0 +1,9 @@ +namespace('Fleetbase\Starter\Http\Controllers')->group( + function ($router) { + /* + |-------------------------------------------------------------------------- + | Starter API Routes + |-------------------------------------------------------------------------- + | + | Primary internal routes for console. + */ + $router->prefix(config('starter.api.routing.internal_prefix', 'int'))->group( + function ($router) { + $router->group( + ['prefix' => 'v1', 'middleware' => ['fleetbase.protected']], + function ($router) { + // $router->fleetbaseRoutes('resource'); + } + ); + } + ); + } +); diff --git a/test/fixtures/starter/translations/en-us.yaml b/test/fixtures/starter/translations/en-us.yaml new file mode 100644 index 0000000..0cca1bf --- /dev/null +++ b/test/fixtures/starter/translations/en-us.yaml @@ -0,0 +1,2 @@ +starter: + extension-name: Starter \ No newline at end of file diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts new file mode 100644 index 0000000..b264e65 --- /dev/null +++ b/test/unit/auth.test.ts @@ -0,0 +1,226 @@ +import fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setStdin } from '../../src/util.js'; +import { OTHER_TOKEN, TOKEN, account, capture, fakeRegistry, flb, readConfigFile, reset, tokenDetails, useTempHome, writeConfig } from './helpers.js'; + +const loggedIn = () => + writeConfig({ + tokens: { 'registry.test:8000': TOKEN }, + hosts: { 'registry.test:8000': { tokenId: 'tok_1', scopes: ['read', 'publish'], source: 'login', account: { email: account.email } } }, + }); + +describe('login', () => { + let configFile: string; + beforeEach(() => { + configFile = useTempHome(); + }); + afterEach(reset); + + it('logs in with a password from stdin-equivalent flags, stores a scoped token, prints no token', async () => { + const { requests } = fakeRegistry({ + 'POST /v1/auth/login': { token: TOKEN, token_type: 'Bearer', expires_at: '2027-01-20T00:00:00Z', token_details: tokenDetails(), account }, + }); + const out = capture(); + setStdin('s3cret\n'); + expect(await flb('login', '--email', 'dev@acme.test', '--password-stdin', '--expires', '30d')).toBe(0); + + expect(requests[0].body).toEqual({ email: 'dev@acme.test', password: 's3cret', token_name: expect.stringMatching(/^flb on /), scopes: ['read', 'publish', 'instance:register'], expires_in_days: 30 }); + expect(out.all).not.toContain(TOKEN); + expect(out.all).toContain('flbp_…Z9x1'); + const config = readConfigFile(); + expect(config.tokens['registry.test:8000']).toBe(TOKEN); + expect(config.hosts['registry.test:8000']).toMatchObject({ tokenId: 'tok_1', scopes: ['read', 'publish', 'instance:register'], source: 'login', account: { email: 'dev@acme.test' } }); + if (process.platform !== 'win32') { + expect(fs.statSync(configFile).mode & 0o777).toBe(0o600); + } + }); + + it('accepts a pasted token after checking it, and prints it only with --show-token', async () => { + fakeRegistry({ 'GET /v1/account': account, 'GET /v1/account/tokens': [tokenDetails()] }); + let out = capture(); + expect(await flb('login', '--token', TOKEN, '--organization', 'acme')).toBe(0); + expect(out.all).not.toContain(TOKEN); + expect(readConfigFile().hosts['registry.test:8000']).toMatchObject({ tokenId: 'tok_1', source: 'pasted', organization: 'acme' }); + + out = capture(); + expect(await flb('login', '--token', TOKEN, '--show-token')).toBe(0); + expect(out.stdout.trim().split('\n').pop()).toBe(TOKEN); + }); + + it('rejects a token the service does not accept, and organizations you are not in', async () => { + fakeRegistry({ 'GET /v1/account': { status: 401, body: { errors: ['Unauthenticated.'] } } }); + let out = capture(); + expect(await flb('login', '--token', TOKEN)).toBe(1); + expect(out.stderr).toMatch(/does not accept that token/); + + fakeRegistry({ 'GET /v1/account': account, 'GET /v1/account/tokens': [] }); + out = capture(); + expect(await flb('login', '--token', TOKEN, '--organization', 'globex')).toBe(1); + expect(out.stderr).toMatch(/not a member of organization "globex". Yours: acme/); + expect(() => readConfigFile()).toThrow(); + }); + + it('refuses a malformed token without calling the service', async () => { + const { requests } = fakeRegistry({}); + const out = capture(); + expect(await flb('login', '--token', 'not-a-token')).toBe(1); + expect(out.stderr).toMatch(/start with flbp_/); + expect(requests).toEqual([]); + }); + + it('revokes the token it minted at the previous login', async () => { + loggedIn(); + const { requests } = fakeRegistry({ + 'POST /v1/auth/login': { token: OTHER_TOKEN, token_type: 'Bearer', expires_at: null, token_details: tokenDetails({ id: 'tok_2', last_four: 'Q7k2' }), account }, + 'POST /v1/auth/logout': { status: 204, body: undefined }, + }); + capture(); + setStdin('pw'); + expect(await flb('login', '--email', 'dev@acme.test', '--password-stdin')).toBe(0); + const logout = requests.find((request) => request.path === '/v1/auth/logout'); + expect(logout?.headers.authorization).toBe(`Bearer ${TOKEN}`); + expect(readConfigFile().tokens['registry.test:8000']).toBe(OTHER_TOKEN); + }); + + it('explains an unverified email', async () => { + fakeRegistry({ 'POST /v1/auth/login': { status: 403, body: { errors: ['Email address is not verified.'], code: 'email_unverified' } } }); + const out = capture(); + setStdin('pw'); + expect(await flb('login', '--email', 'dev@acme.test', '--password-stdin')).toBe(1); + expect(out.stderr).toMatch(/flb verify --email dev@acme.test/); + }); +}); + +describe('logout, whoami', () => { + beforeEach(() => { + useTempHome(); + loggedIn(); + }); + afterEach(reset); + + it('revokes on the service and clears the config', async () => { + const { requests } = fakeRegistry({ 'POST /v1/auth/logout': { status: 204, body: undefined } }); + capture(); + expect(await flb('logout')).toBe(0); + expect(requests[0].headers.authorization).toBe(`Bearer ${TOKEN}`); + expect(readConfigFile().tokens).toBeUndefined(); + }); + + it('--local only forgets the token', async () => { + const { requests } = fakeRegistry({}); + capture(); + expect(await flb('logout', '--local')).toBe(0); + expect(requests).toEqual([]); + expect(readConfigFile().tokens).toBeUndefined(); + }); + + it('whoami shows the account and a masked token', async () => { + fakeRegistry({ 'GET /v1/account': account }); + const out = capture(); + expect(await flb('whoami')).toBe(0); + expect(out.stdout).toContain('dev@acme.test'); + expect(out.stdout).toContain('flbp_…Z9x1'); + expect(out.all).not.toContain(TOKEN); + }); + + it('asks to log in again when the stored token is a v1 token', async () => { + writeConfig({ tokens: { 'registry.test:8000': 'verdaccio-token' } }); + fakeRegistry({}); + const out = capture(); + expect(await flb('whoami')).toBe(1); + expect(out.stderr).toMatch(/not a Registry Service token/); + expect(out.stderr).toMatch(/flb login/); + }); + + it('a 401 suggests logging in', async () => { + fakeRegistry({ 'GET /v1/account': { status: 401, body: { errors: ['Unauthenticated.'] } } }); + const out = capture(); + expect(await flb('whoami')).toBe(1); + expect(out.stderr).toMatch(/Unauthenticated/); + expect(out.stderr).toMatch(/flb login/); + }); +}); + +describe('token', () => { + beforeEach(() => { + useTempHome(); + loggedIn(); + }); + afterEach(reset); + + it('create refuses to mint a token nobody will see', async () => { + const { requests } = fakeRegistry({}); + const out = capture(); + expect(await flb('token', 'create', '--name', 'ci')).toBe(1); + expect(out.stderr).toMatch(/--show-token/); + expect(requests).toEqual([]); + }); + + it('create --show-token prints it once; --save stores it instead', async () => { + const { requests } = fakeRegistry({ + 'POST /v1/account/tokens': { status: 201, body: { token: OTHER_TOKEN, token_type: 'Bearer', expires_at: null, token_details: tokenDetails({ id: 'tok_2', name: 'ci', scopes: ['publish'] }) } }, + }); + let out = capture(); + expect(await flb('token', 'create', '--name', 'ci', '--scopes', 'publish', '--expires', '180d', '--show-token')).toBe(0); + expect(requests[0].body).toEqual({ name: 'ci', scopes: ['publish'], expires_in_days: 180 }); + expect(out.stdout.trim()).toBe(OTHER_TOKEN); + expect(readConfigFile().tokens['registry.test:8000']).toBe(TOKEN); + + out = capture(); + expect(await flb('token', 'create', '--name', 'ci', '--save')).toBe(0); + expect(out.all).not.toContain(OTHER_TOKEN); + expect(readConfigFile().tokens['registry.test:8000']).toBe(OTHER_TOKEN); + expect(readConfigFile().hosts['registry.test:8000']).toMatchObject({ tokenId: 'tok_2', source: 'token-create' }); + }); + + it('create rejects unknown scopes', async () => { + fakeRegistry({}); + const out = capture(); + expect(await flb('token', 'create', '--name', 'x', '--scopes', 'admin', '--show-token')).toBe(1); + expect(out.stderr).toMatch(/Unknown scope admin/); + }); + + it('list shows masked tokens and marks the current one', async () => { + fakeRegistry({ 'GET /v1/account/tokens': [tokenDetails(), tokenDetails({ id: 'tok_2', name: 'ci', masked: 'flbp_…Q7k2' })] }); + const out = capture(); + expect(await flb('token', 'list')).toBe(0); + expect(out.stdout).toMatch(/tok_1 \*/); + expect(out.stdout).toContain('flbp_…Q7k2'); + }); + + it('revoking the current token removes it from the config', async () => { + const { requests } = fakeRegistry({ 'DELETE /v1/account/tokens/tok_1': { status: 204, body: undefined }, 'DELETE /v1/account/tokens/tok_9': { status: 404, body: { errors: ['Resource not found.'] } } }); + const out = capture(); + expect(await flb('token', 'revoke', 'tok_1', 'tok_9')).toBe(1); + expect(requests.map((request) => request.path)).toEqual(['/v1/account/tokens/tok_1', '/v1/account/tokens/tok_9']); + expect(out.stderr).toMatch(/Revoked tok_1/); + expect(out.stderr).toMatch(/tok_9: Resource not found/); + expect(readConfigFile().tokens).toBeUndefined(); + }); +}); + +describe('register and verify', () => { + beforeEach(useTempHome); + afterEach(reset); + + it('verify posts the code; --resend asks for a new one', async () => { + const { requests } = fakeRegistry({ + 'POST /v1/auth/verify': { verified: true, message: 'Email verified. You can now log in.' }, + 'POST /v1/auth/verify/resend': { message: 'sent' }, + }); + capture(); + expect(await flb('verify', '--email', 'dev@acme.test', '--code', '123456')).toBe(0); + expect(await flb('verify', '--email', 'dev@acme.test', '--resend')).toBe(0); + expect(requests.map((request) => [request.path, request.body])).toEqual([ + ['/v1/auth/verify', { email: 'dev@acme.test', code: '123456' }], + ['/v1/auth/verify/resend', { email: 'dev@acme.test' }], + ]); + }); + + it('validation errors list the fields', async () => { + fakeRegistry({ 'POST /v1/auth/verify': { status: 422, body: { errors: ['The code field must be 6 digits.'], fields: { code: ['The code field must be 6 digits.'] } } } }); + const out = capture(); + expect(await flb('verify', '--email', 'dev@acme.test', '--code', '12')).toBe(1); + expect(out.stderr).toMatch(/code: The code field must be 6 digits/); + }); +}); diff --git a/test/unit/build.test.ts b/test/unit/build.test.ts new file mode 100644 index 0000000..37adb41 --- /dev/null +++ b/test/unit/build.test.ts @@ -0,0 +1,104 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { build, collectApiFiles, composerPaths, listEntries, readRelease } from '../../src/build.js'; +import { loadExtension } from '../../src/manifest.js'; +import { capture, copyFixture, reset, tempDir } from './helpers.js'; + +function fakeConsole(engine: string, version: string): string { + const consoleDir = tempDir('flb-console-'); + fs.writeFileSync(path.join(consoleDir, 'package.json'), JSON.stringify({ name: '@fleetbase/console', version: '0.7.69' })); + const core = path.join(consoleDir, 'node_modules', '@fleetbase', 'ember-core'); + fs.mkdirSync(path.join(core, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(core, 'package.json'), JSON.stringify({ name: '@fleetbase/ember-core', version: '0.3.27' })); + // stands in for ember-core's build-engine-release: writes //manifest.json and one asset + fs.writeFileSync( + path.join(core, 'bin', 'build-engine-release.js'), + `const fs = require('fs'); const path = require('path'); +const args = process.argv.slice(2); const get = (name) => args[args.indexOf('--' + name) + 1]; +if (!args.includes('--dist')) { console.error('expected --dist'); process.exit(3); } +const dir = path.join(get('out'), get('engine')); fs.mkdirSync(path.join(dir, 'assets'), { recursive: true }); +fs.writeFileSync(path.join(dir, 'assets', 'engine.js'), 'define()'); +fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ schema: 1, name: get('engine'), version: ${JSON.stringify(version)}, + builtAgainst: { '@fleetbase/ember-core': '0.3.27', '@fleetbase/ember-ui': '0.4.6' }, runtime: { extension: 'extension.js' }, files: {} })); +console.log('built');`, + ); + const installed = path.join(consoleDir, 'node_modules', ...engine.split('/')); + fs.mkdirSync(installed, { recursive: true }); + fs.writeFileSync(path.join(installed, 'package.json'), JSON.stringify({ name: engine, version })); + return consoleDir; +} + +describe('build', () => { + let root: string; + beforeEach(() => { + root = copyFixture('extension'); + fs.writeFileSync(path.join(root, 'server', '.env'), 'APP_KEY=base64:fixture'); + capture(); + }); + afterEach(reset); + + it('reads composer autoload paths', () => { + expect(composerPaths({ autoload: { 'psr-4': { 'A\\': 'server/src/', 'B\\': ['lib/'] }, classmap: ['./legacy'], files: ['helpers.php'] } })).toEqual(['server', 'lib', 'legacy', 'helpers.php']); + }); + + it('collects the api files: autoload dirs and docs, without dev autoload, archive.exclude or secrets', () => { + const ext = loadExtension(root); + const { files, skipped } = collectApiFiles(ext); + expect(files).toEqual(['LICENSE.md', 'README.md', 'composer.json', 'server/migrations/2026_01_01_create.php', 'server/src/Insights.php']); + expect(skipped).toEqual(['server/.env (looks like a secret)']); + }); + + it('writes the engine and api archives in the layouts the service expects, and release.json', async () => { + const ext = loadExtension(root); + const release = await build(ext, { cliVersion: 'test' }); + + expect(release.version).toBe('1.2.0'); + expect(release.files.engine?.path).toBe('.fleetbase/build/1.2.0/engine-1.2.0.tgz'); + expect(release.files.api?.path).toBe('.fleetbase/build/1.2.0/api-1.2.0.tar.gz'); + + const engine = await listEntries(path.join(root, release.files.engine!.path)); + expect(engine).toContain('package/package.json'); + expect(engine.some((entry) => entry.includes('.fleetbase'))).toBe(false); + // npm would have packed .fleetbase/ without this + expect(fs.readFileSync(path.join(root, '.npmignore'), 'utf8')).toMatch(/^\/\.fleetbase\/$/m); + + const api = await listEntries(path.join(root, release.files.api!.path)); + expect(api).toEqual(['package/LICENSE.md', 'package/README.md', 'package/composer.json', 'package/server/migrations/2026_01_01_create.php', 'package/server/src/Insights.php']); + + expect(readRelease(ext).release?.files.api?.sha256).toBe(release.files.api?.sha256); + }); + + it('marks the build stale when extension.json or an archive changes', async () => { + const ext = loadExtension(root); + const release = await build(ext, { cliVersion: 'test' }); + fs.appendFileSync(path.join(root, release.files.api!.path), 'x'); + expect(readRelease(ext).stale).toMatch(/api archive/); + fs.writeFileSync(ext.manifestPath, JSON.stringify({ ...ext.manifest, description: 'changed' })); + expect(readRelease(loadExtension(root)).stale).toMatch(/extension.json changed/); + }); + + it('builds the prebuilt dist with ember-core build-engine-release and tars the / folder', async () => { + const ext = loadExtension(root); + const consoleDir = fakeConsole('@acme/insights-engine', '1.2.0'); + const release = await build(ext, { cliVersion: 'test', prebuilt: true, console: consoleDir, consoleDist: tempDir(), quiet: true }); + + expect(release.files.dist?.builtAgainst).toEqual({ '@fleetbase/ember-core': '0.3.27', '@fleetbase/ember-ui': '0.4.6' }); + const entries = await listEntries(path.join(root, release.files.dist!.path)); + expect(entries).toContain('@acme/insights-engine/manifest.json'); + expect(entries).toContain('@acme/insights-engine/assets/engine.js'); + }); + + it('refuses a prebuilt dist when the console has another version of the engine', async () => { + const ext = loadExtension(root); + const consoleDir = fakeConsole('@acme/insights-engine', '1.1.0'); + await expect(build(ext, { cliVersion: 'test', prebuilt: true, console: consoleDir, quiet: true })).rejects.toThrow(/has @acme\/insights-engine@1.1.0, not 1.2.0/); + }); + + it('explains a console without ember-core#99', async () => { + const ext = loadExtension(root); + const consoleDir = tempDir(); + fs.writeFileSync(path.join(consoleDir, 'package.json'), '{"name":"@fleetbase/console"}'); + await expect(build(ext, { cliVersion: 'test', prebuilt: true, console: consoleDir })).rejects.toThrow(/build-engine-release/); + }); +}); diff --git a/test/unit/catalog-doctor.test.ts b/test/unit/catalog-doctor.test.ts new file mode 100644 index 0000000..3c64397 --- /dev/null +++ b/test/unit/catalog-doctor.test.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { diagnose } from '../../src/commands/doctor.js'; +import { INSTANCE_TOKEN, TOKEN, account, capture, fakeRegistry, flb, reset, tokenDetails, useTempHome, writeConfig } from './helpers.js'; + +const listing = { + id: 'ext_1', + slug: 'pallet', + name: 'Pallet', + subtitle: 'Warehouse management', + tags: ['wms'], + hosting: 'both', + status: 'published', + publisher: { id: 'org_1', slug: 'fleetbase', name: 'Fleetbase', is_verified: true }, + category: { slug: 'warehousing', name: 'Warehousing' }, + latest_version: '0.0.2', + compat: { console: '>=0.7.0', api: '^1.6' }, + packages: { npm: '@fleetbase/pallet-engine', composer: 'fleetbase/pallet-api' }, + price: { model: 'free', amount: 0, currency: 'USD', effective_amount: 0 }, + is_paid: false, + installs_count: 12, + published_at: '2026-10-01T00:00:00Z', +}; + +describe('search and info', () => { + beforeEach(useTempHome); + afterEach(reset); + + it('search passes filters and prints a table, without a token', async () => { + writeConfig({ tokens: { 'registry.test:8000': TOKEN } }); + const { requests } = fakeRegistry({ 'GET /v1/catalog/extensions': { data: [listing], meta: { total: 30, per_page: 20, current_page: 1, last_page: 2 } } }); + const out = capture(); + expect(await flb('search', 'warehouse', '--free', '--sort', 'newest', '--limit', '20')).toBe(0); + expect(Object.fromEntries(requests[0].query)).toEqual({ q: 'warehouse', pricing: 'free', sort: 'newest', limit: '20', page: '1' }); + expect(requests[0].headers.authorization).toBeUndefined(); + expect(out.stdout).toMatch(/pallet\s+Pallet\s+0.0.2\s+fleetbase ✓\s+free\s+12/); + expect(out.stderr).toMatch(/page 1 of 2/); + }); + + it('info shows the listing and releases; 404 is explained', async () => { + fakeRegistry({ + 'GET /v1/catalog/extensions/pallet': listing, + 'GET /v1/catalog/extensions/pallet/releases': { latest: '0.0.2', channels: { stable: '0.0.2' }, releases: [{ id: 'rel_1', version: '0.0.2', channel: 'stable', status: 'published', compat: null, size_bytes: 2048, notes: null, published_at: '2026-10-01T00:00:00Z', yanked_at: null }] }, + }); + let out = capture(); + expect(await flb('info', 'pallet')).toBe(0); + expect(out.stdout).toMatch(/npm\s+@fleetbase\/pallet-engine/); + expect(out.stdout).toMatch(/0.0.2\s+stable\s+published\s+2026-10-01\s+2.0 KB/); + + out = capture(); + expect(await flb('info', 'nope')).toBe(1); + expect(out.stderr).toMatch(/No published extension "nope"/); + }); +}); + +describe('doctor', () => { + beforeEach(useTempHome); + afterEach(reset); + + it('checks config, registry, token, scopes and instances', async () => { + writeConfig({ + tokens: { 'registry.test:8000': TOKEN }, + hosts: { 'registry.test:8000': { tokenId: 'tok_1' } }, + instances: { inst_1: { id: 'inst_1', registry: 'http://registry.test:8000', token: INSTANCE_TOKEN, registeredAt: 'x' } }, + }); + const fake = fakeRegistry({ + 'GET /v1/catalog/categories': [], + 'GET /v1/account': account, + 'GET /v1/account/tokens': [tokenDetails({ scopes: ['read'], expires_at: new Date(Date.now() + 3 * 86_400_000).toISOString() })], + 'GET /v1/instances/me': (request) => (request.headers.authorization === `Bearer ${INSTANCE_TOKEN}` ? { instance: { status: 'active', name: 'prod' } } : { status: 401, body: {} }), + }); + const findings = await diagnose({ fetch: fake.fetch }); + const byCheck = Object.fromEntries(findings.map((finding) => [finding.check, finding])); + expect(byCheck.config.level).toBe('ok'); + expect(byCheck.registry.level).toBe('ok'); + expect(byCheck.token).toMatchObject({ level: 'ok', detail: expect.stringContaining('dev@acme.test') }); + expect(byCheck['token expiry'].level).toBe('warn'); + expect(byCheck['token scopes']).toMatchObject({ level: 'warn', detail: expect.stringContaining('publish, instance:register') }); + expect(byCheck['instance inst_1']).toMatchObject({ level: 'ok', detail: 'prod: active' }); + expect(byCheck.npm.level).toBe('ok'); + }); + + it('fails on an unreachable registry and a widened config mode', async () => { + writeConfig({}); + if (process.platform !== 'win32') { + fs.chmodSync(`${process.env.FLEETBASE_HOME}/config.json`, 0o644); + } + const findings = await diagnose({ + fetch: (async () => { + throw new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } }); + }) as typeof fetch, + }); + const byCheck = Object.fromEntries(findings.map((finding) => [finding.check, finding])); + expect(byCheck.registry).toMatchObject({ level: 'fail', detail: expect.stringContaining('ECONNREFUSED') }); + if (process.platform !== 'win32') { + expect(byCheck.config).toMatchObject({ level: 'warn', detail: expect.stringContaining('644') }); + } + }); +}); diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts new file mode 100644 index 0000000..edde35a --- /dev/null +++ b/test/unit/config.test.ts @@ -0,0 +1,153 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + ConfigError, + clearToken, + configModeProblem, + getToken, + hostKey, + instancesFor, + maskToken, + normalizeUrl, + readConfig, + removeInstance, + resolveOrganization, + resolveRegistry, + saveInstance, + setToken, + updateConfig, + writeConfig, +} from '../../src/config.js'; +import { INSTANCE_TOKEN, TOKEN, useTempHome } from './helpers.js'; + +describe('normalizeUrl / hostKey (phase-0 rules)', () => { + it('keeps an existing scheme', () => { + expect(normalizeUrl('https://registry.fleetbase.io')).toBe('https://registry.fleetbase.io'); + expect(normalizeUrl('http://localhost:8000')).toBe('http://localhost:8000'); + }); + + it('uses http for localhost, loopback and explicit ports', () => { + expect(normalizeUrl('localhost:8000')).toBe('http://localhost:8000'); + expect(normalizeUrl('127.0.0.1')).toBe('http://127.0.0.1'); + expect(normalizeUrl('[::1]:8000')).toBe('http://[::1]:8000'); + expect(normalizeUrl('registry.localhost')).toBe('http://registry.localhost'); + expect(normalizeUrl('10.0.0.5:8000')).toBe('http://10.0.0.5:8000'); + }); + + it('uses https otherwise and strips trailing slashes', () => { + expect(normalizeUrl('registry.fleetbase.io/')).toBe('https://registry.fleetbase.io'); + expect(normalizeUrl(' registry.example.com// ')).toBe('https://registry.example.com'); + }); + + it('keys tokens by host[:port]', () => { + expect(hostKey('https://registry.fleetbase.io/v1')).toBe('registry.fleetbase.io'); + expect(hostKey('localhost:8000')).toBe('localhost:8000'); + }); +}); + +describe('config file', () => { + let file: string; + beforeEach(() => { + file = useTempHome(); + }); + afterEach(() => { + delete process.env.FLB_TOKEN; + }); + + it('reads a missing file as empty', () => { + expect(readConfig()).toEqual({}); + }); + + it('reads the phase-0 format and preserves its keys and unknown keys on write', () => { + const phase0 = { + host: 'https://api.fleetbase.io', + registry: 'https://registry.fleetbase.io', + tokens: { 'api.fleetbase.io': 'legacy-api-token', 'registry.fleetbase.io': 'legacy-registry-token' }, + defaultOrganization: 'acme', + somethingElse: { kept: true }, + }; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(phase0)); + + updateConfig((config) => setToken(config, 'https://registry.fleetbase.io', TOKEN, { tokenId: 'tok_1' })); + + const written = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(written.host).toBe('https://api.fleetbase.io'); + expect(written.defaultOrganization).toBe('acme'); + expect(written.somethingElse).toEqual({ kept: true }); + expect(written.tokens['api.fleetbase.io']).toBe('legacy-api-token'); + expect(written.tokens['registry.fleetbase.io']).toBe(TOKEN); + expect(written.hosts['registry.fleetbase.io'].tokenId).toBe('tok_1'); + }); + + it('writes mode 600 in a 700 directory, atomically', () => { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o755 }); + fs.writeFileSync(file, '{}', { mode: 0o644 }); + writeConfig({ registry: 'https://registry.fleetbase.io' }); + if (process.platform !== 'win32') { + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.dirname(file)).mode & 0o777).toBe(0o700); + expect(configModeProblem()).toBeNull(); + } + expect(fs.readdirSync(path.dirname(file)).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + + it('reports a widened mode', () => { + writeConfig({}); + if (process.platform !== 'win32') { + fs.chmodSync(file, 0o644); + expect(configModeProblem()).toMatch(/644/); + } + }); + + it('refuses invalid JSON with the file name', () => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '{nope'); + expect(() => readConfig()).toThrow(ConfigError); + expect(() => readConfig()).toThrow(file); + }); + + it('prefers FLB_TOKEN and flags phase-0 tokens as legacy', () => { + const config = { tokens: { 'registry.test:8000': 'some-v1-token' } }; + expect(getToken(config, 'http://registry.test:8000')).toEqual({ token: 'some-v1-token', source: 'config', legacy: true }); + process.env.FLB_TOKEN = TOKEN; + expect(getToken(config, 'http://registry.test:8000')).toEqual({ token: TOKEN, source: 'env', legacy: false }); + }); + + it('clears a token but keeps the host default instance', () => { + const config = setToken({}, 'http://registry.test:8000', TOKEN, { tokenId: 'tok_1' }); + config.hosts!['registry.test:8000'].instance = 'inst_1'; + clearToken(config, 'http://registry.test:8000'); + expect(config.tokens).toEqual({}); + expect(config.hosts!['registry.test:8000']).toEqual({ instance: 'inst_1' }); + }); + + it('resolves registry and organization from flag, env, config, default', () => { + expect(resolveRegistry(undefined, {})).toBe(process.env.FLB_REGISTRY); + delete process.env.FLB_REGISTRY; + expect(resolveRegistry(undefined, {})).toBe('https://registry.fleetbase.io'); + expect(resolveRegistry(undefined, { registry: 'localhost:8000' })).toBe('http://localhost:8000'); + expect(resolveRegistry('registry.example.com', { registry: 'localhost:8000' })).toBe('https://registry.example.com'); + + const config = { defaultOrganization: 'global', hosts: { 'registry.example.com': { organization: 'per-host' } } }; + expect(resolveOrganization(undefined, config, 'https://registry.example.com')).toBe('per-host'); + expect(resolveOrganization(undefined, config, 'https://other.example.com')).toBe('global'); + expect(resolveOrganization('flag', config, 'https://registry.example.com')).toBe('flag'); + }); + + it('stores instances by id and per registry', () => { + const config = saveInstance({}, { id: 'inst_1', registry: 'http://registry.test:8000', token: INSTANCE_TOKEN, registeredAt: 'now' }); + expect(instancesFor(config, 'http://registry.test:8000').map((entry) => entry.id)).toEqual(['inst_1']); + expect(instancesFor(config, 'https://other.test')).toEqual([]); + expect(config.hosts!['registry.test:8000'].instance).toBe('inst_1'); + removeInstance(config, 'inst_1'); + expect(config.instances).toEqual({}); + expect(config.hosts).toEqual({}); + }); + + it('masks tokens', () => { + expect(maskToken(TOKEN)).toBe('flbp_…Z9x1'); + expect(maskToken('short')).toBe('********'); + }); +}); diff --git a/test/unit/helpers.ts b/test/unit/helpers.ts new file mode 100644 index 0000000..517d0b7 --- /dev/null +++ b/test/unit/helpers.ts @@ -0,0 +1,143 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setFetch } from '../../src/context.js'; +import { setWriter } from '../../src/output.js'; +import { main } from '../../src/program.js'; +import { setStdin } from '../../src/util.js'; + +export const REGISTRY = 'http://registry.test:8000'; +export const TOKEN = 'flbp_' + 'a'.repeat(36) + 'Z9x1'; +export const OTHER_TOKEN = 'flbp_' + 'b'.repeat(36) + 'Q7k2'; +export const INSTANCE_TOKEN = 'flbi_' + 'c'.repeat(36) + 'M3n4'; + +export function tempDir(prefix = 'flb-test-'): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +/** Point FLEETBASE_HOME at a fresh directory; returns the config file path. */ +export function useTempHome(): string { + const home = tempDir('flb-home-'); + process.env.FLEETBASE_HOME = home; + process.env.FLB_REGISTRY = REGISTRY; + delete process.env.FLB_TOKEN; + return path.join(home, 'config.json'); +} + +export function writeConfig(value: unknown): void { + const file = path.join(process.env.FLEETBASE_HOME!, 'config.json'); + fs.writeFileSync(file, JSON.stringify(value), { mode: 0o600 }); +} + +export function readConfigFile(): any { + return JSON.parse(fs.readFileSync(path.join(process.env.FLEETBASE_HOME!, 'config.json'), 'utf8')); +} + +export interface Captured { + stdout: string; + stderr: string; + readonly all: string; +} + +export function capture(): Captured { + const captured = { + stdout: '', + stderr: '', + get all() { + return this.stdout + this.stderr; + }, + }; + setWriter({ out: (text) => (captured.stdout += text), err: (text) => (captured.stderr += text) }); + return captured; +} + +export interface RecordedRequest { + method: string; + path: string; + query: URLSearchParams; + headers: Record; + body: any; + form?: FormData; +} + +export type Handler = (request: RecordedRequest) => { status?: number; body?: unknown } | unknown; + +/** + * A fake Registry Service: routes are `"METHOD /path"` → handler (or a static body). + * Unrouted requests answer 404 in the service's error envelope. + */ +export function fakeRegistry(routes: Record): { requests: RecordedRequest[]; fetch: typeof fetch } { + const requests: RecordedRequest[] = []; + const impl = async (input: string | URL | Request, init: RequestInit = {}) => { + const url = new URL(String(input)); + const method = (init.method || 'GET').toUpperCase(); + const headers = Object.fromEntries(Object.entries((init.headers as Record) || {}).map(([k, v]) => [k.toLowerCase(), v])); + let body: any = null; + let form: FormData | undefined; + if (init.body instanceof FormData) { + form = init.body; + } else if (typeof init.body === 'string') { + body = JSON.parse(init.body); + } + const request: RecordedRequest = { method, path: url.pathname, query: url.searchParams, headers, body, form }; + requests.push(request); + + const route = routes[`${method} ${url.pathname}`]; + if (route === undefined) { + return new Response(JSON.stringify({ errors: ['Resource not found.'] }), { status: 404 }); + } + const result: any = typeof route === 'function' ? (route as Handler)(request) : route; + const status = result && typeof result === 'object' && 'status' in result && 'body' in result ? result.status : 200; + const payload = result && typeof result === 'object' && 'status' in result && 'body' in result ? result.body : result; + return new Response(payload === undefined || status === 204 ? null : JSON.stringify(payload), { status, headers: { 'Content-Type': 'application/json' } }); + }; + setFetch(impl as typeof fetch); + return { requests, fetch: impl as typeof fetch }; +} + +export async function flb(...args: string[]): Promise { + process.exitCode = undefined; + const code = await main(['node', 'flb', ...args]); + process.exitCode = undefined; + return code; +} + +export function reset(): void { + setFetch(undefined); + setWriter(null); + setStdin(null); + process.exitCode = undefined; +} + +export const account = { + id: 'acct_1', + name: 'Dev', + username: 'acme', + email: 'dev@acme.test', + status: 'active', + organizations: [{ id: 'org_1', slug: 'acme', name: 'Acme', role: 'owner', is_personal: true }], +}; + +export function tokenDetails(overrides: Record = {}) { + return { + id: 'tok_1', + name: 'flb on test', + masked: 'flbp_…Z9x1', + prefix: 'flbp_', + last_four: 'Z9x1', + scopes: ['read', 'publish', 'instance:register'], + organization: null, + expires_at: '2027-01-20T00:00:00Z', + last_used_at: null, + revoked_at: null, + created_at: '2026-10-22T00:00:00Z', + ...overrides, + }; +} + +export function copyFixture(name: string): string { + const target = path.join(tempDir('flb-fixture-'), name); + fs.cpSync(fileURLToPath(new URL(`../fixtures/${name}`, import.meta.url)), target, { recursive: true }); + return target; +} diff --git a/test/unit/init.test.ts b/test/unit/init.test.ts new file mode 100644 index 0000000..b162cc7 --- /dev/null +++ b/test/unit/init.test.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { deriveNames } from '../../src/commands/init.js'; +import { loadExtension } from '../../src/manifest.js'; +import { capture, flb, reset, tempDir, useTempHome, writeConfig } from './helpers.js'; + +const STARTER = fileURLToPath(new URL('../fixtures/starter', import.meta.url)); + +function read(root: string, file: string): string { + return fs.readFileSync(path.join(root, file), 'utf8'); +} + +describe('init', () => { + beforeEach(() => { + useTempHome(); + }); + afterEach(reset); + + it('derives package names and namespace from the name and organization', () => { + expect(deriveNames('Fleet Insights', 'acme')).toMatchObject({ slug: 'fleet-insights', npm: '@acme/fleet-insights-engine', composer: 'acme/fleet-insights-api', namespace: 'Acme\\FleetInsights', studly: 'FleetInsights' }); + expect(deriveNames('Fleet Insights', 'acme', { namespace: '\\Vendor\\Thing\\' }).namespace).toBe('Vendor\\Thing'); + expect(() => deriveNames('Fleet', 'acme', { package: 'Bad Name' })).toThrow(/npm package name/); + expect(() => deriveNames('Fleet', 'acme', { namespace: 'lower\\case' })).toThrow(/PHP namespace/); + }); + + it('scaffolds from the starter with names, namespace, route, compat and a valid extension.json', async () => { + const dir = path.join(tempDir(), 'insights'); + capture(); + expect(await flb('init', dir, '--name', 'Fleet Insights', '--organization', 'acme', '--template', STARTER, '--yes', '--no-git', '--author', 'Acme Ltd ', '--keywords', 'fleet,analytics', '--console', '>=0.7.60 <0.8.0')).toBe(0); + + const manifest = JSON.parse(read(dir, 'extension.json')); + expect(manifest).toMatchObject({ name: 'Fleet Insights', version: '0.1.0', engine: 'package.json', api: 'composer.json', compat: { console: '>=0.7.60 <0.8.0', api: '^1.6' }, keywords: ['fleet', 'analytics'] }); + + const pkg = JSON.parse(read(dir, 'package.json')); + expect(pkg).toMatchObject({ name: '@acme/fleet-insights-engine', version: '0.1.0', fleetbase: { route: 'fleet-insights' }, author: 'Acme Ltd ' }); + expect(pkg.scripts['publish:npm']).toBeUndefined(); + const composer = JSON.parse(read(dir, 'composer.json')); + expect(composer.name).toBe('acme/fleet-insights-api'); + expect(composer.version).toBe('0.1.0'); + expect(composer.autoload['psr-4']).toHaveProperty(['Acme\\FleetInsights\\']); + expect(composer.extra.laravel.providers).toEqual(['Acme\\FleetInsights\\Providers\\FleetInsightsServiceProvider']); + expect(composer.authors).toEqual([{ name: 'Acme Ltd', email: 'dev@acme.test' }]); + + expect(fs.existsSync(path.join(dir, 'server/config/fleet-insights.php'))).toBe(true); + expect(fs.existsSync(path.join(dir, 'server/src/Providers/FleetInsightsServiceProvider.php'))).toBe(true); + expect(fs.existsSync(path.join(dir, 'server/src/Http/Controllers/FleetInsightsResourceController.php'))).toBe(true); + expect(read(dir, 'server/src/Http/Controllers/FleetInsightsResourceController.php')).toContain("public string $namespace = '\\\\Acme\\\\FleetInsights';"); + expect(read(dir, 'server/src/routes.php')).toContain("config('fleet-insights.api.routing.prefix', 'fleet-insights')"); + expect(read(dir, 'addon/engine.js')).toContain('class FleetInsightsEngine'); + expect(read(dir, 'addon/extension.js')).toContain("registerHeaderMenuItem('Fleet Insights', 'console.fleet-insights'"); + expect(read(dir, 'app/routes/home.js')).toContain('@acme/fleet-insights-engine/routes/home'); + expect(read(dir, 'translations/en-us.yaml')).toMatch(/^fleet-insights:\n {2}extension-name: Fleet Insights/); + expect(read(dir, '.npmignore')).toMatch(/^\/\.fleetbase\/$/m); + expect(read(dir, '.gitignore')).toMatch(/^\/\.fleetbase\/$/m); + + const leftovers = (fs.readdirSync(dir, { recursive: true }) as string[]) + .filter((file) => fs.statSync(path.join(dir, file)).isFile() && !file.endsWith('LICENSE.md')) + .filter((file) => /starter/i.test(read(dir, file))); + expect(leftovers).toEqual([]); + + // the scaffold passes the same checks flb build runs + expect(loadExtension(dir).warnings).toEqual([]); + }); + + it('defaults the organization to the logged-in account', async () => { + writeConfig({ tokens: {}, hosts: { 'registry.test:8000': { account: { username: 'globex' } } } }); + const dir = path.join(tempDir(), 'x'); + capture(); + expect(await flb('init', dir, '--name', 'Thing', '--template', STARTER, '--yes', '--no-git')).toBe(0); + expect(JSON.parse(read(dir, 'package.json')).name).toBe('@globex/thing-engine'); + }); + + it('refuses a non-empty directory and invalid ranges', async () => { + const dir = tempDir(); + fs.writeFileSync(path.join(dir, 'file'), 'x'); + let out = capture(); + expect(await flb('init', dir, '--name', 'Thing', '--organization', 'acme', '--template', STARTER, '--yes')).toBe(1); + expect(out.stderr).toMatch(/not empty/); + + out = capture(); + expect(await flb('init', path.join(dir, 'y'), '--name', 'Thing', '--organization', 'acme', '--template', STARTER, '--yes', '--api', 'whatever')).toBe(1); + expect(out.stderr).toMatch(/--api "whatever"/); + }); +}); diff --git a/test/unit/installer.test.ts b/test/unit/installer.test.ts new file mode 100644 index 0000000..12f7d95 --- /dev/null +++ b/test/unit/installer.test.ts @@ -0,0 +1,127 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { artisanArgv, installerArgs, parseJsonOutput, resolveTarget, type Runner } from '../../src/artisan.js'; +import { runInstaller, setRunner } from '../../src/commands/installer.js'; +import { capture, reset, tempDir } from './helpers.js'; + +function instanceDir(): string { + const root = tempDir('flb-instance-'); + fs.mkdirSync(path.join(root, 'api')); + fs.writeFileSync(path.join(root, 'api', 'artisan'), '>) { + const calls: { argv: string[]; cwd: string }[] = []; + const runner: Runner = async (argv, cwd) => { + calls.push({ argv, cwd }); + const artisanAt = argv.indexOf('artisan'); + const command = argv[artisanAt + 1]; + const queue = responses[command] || []; + const next = queue.length > 1 ? queue.shift()! : queue[0] || { stderr: `Command "${command}" is not defined.`, code: 1 }; + return { code: next.code ?? 0, stdout: next.stdout ?? '', stderr: next.stderr ?? '' }; + }; + return { runner, calls }; +} + +const job = (record: Record) => ({ stdout: ` INFO Some notice.\n${JSON.stringify(record, null, 4)}\n` }); + +describe('artisan targets', () => { + it('finds artisan in the path or its api/ directory', () => { + const root = instanceDir(); + expect(resolveTarget({ path: root })).toEqual({ kind: 'path', cwd: path.join(root, 'api') }); + expect(resolveTarget({ path: path.join(root, 'api') })).toEqual({ kind: 'path', cwd: path.join(root, 'api') }); + expect(() => resolveTarget({ path: tempDir() })).toThrow(/No artisan/); + }); + + it('runs inside the compose service with --docker', () => { + const root = instanceDir(); + const target = resolveTarget({ path: root, docker: true }); + expect(target).toEqual({ kind: 'docker', cwd: root, service: 'application', composeFile: undefined }); + expect(artisanArgv(target, ['registry:install', 'pallet'])).toEqual(['docker', 'compose', 'exec', '-T', 'application', 'php', 'artisan', 'registry:install', 'pallet', '--no-interaction', '--no-ansi']); + const custom = resolveTarget({ path: root, docker: 'api', composeFile: 'compose.prod.yml' }); + expect(artisanArgv(custom, ['list'])).toEqual(['docker', 'compose', '-f', path.resolve('compose.prod.yml'), 'exec', '-T', 'api', 'php', 'artisan', 'list', '--no-interaction', '--no-ansi']); + }); + + it('passes the version as --release, never --version', () => { + expect(installerArgs({ action: 'install', extension: '@acme/engine', version: '1.2.0', company: 'co_1', sync: true })).toEqual(['registry:install', '@acme/engine', '--release=1.2.0', '--company=co_1', '--sync', '--json']); + expect(installerArgs({ action: 'uninstall', extension: 'pallet', version: '1.0.0' })).toEqual(['registry:uninstall', 'pallet', '--json']); + }); + + it('finds the JSON record after other output', () => { + expect(parseJsonOutput('warning\n{\n "id": "job_1",\n "extension": {\n "name": "x"\n }\n}\n')).toEqual({ id: 'job_1', extension: { name: 'x' } }); + expect(parseJsonOutput('no json here')).toBeNull(); + }); +}); + +describe('install, update, uninstall', () => { + let root: string; + beforeEach(() => { + root = instanceDir(); + }); + afterEach(() => { + setRunner(null); + reset(); + }); + + it('queues the job and follows it with registry:job until it completes', async () => { + const { runner, calls } = scripted({ + 'registry:install': [job({ id: 'job_1', status: 'queued', position: 1, extension: { name: 'Pallet' } })], + 'registry:job': [ + job({ id: 'job_1', status: 'running', stage: 'download', progress: 20, log: ['resolved'] }), + job({ id: 'job_1', status: 'completed', stage: 'record', progress: 100, log: ['resolved', 'composer done'] }), + ], + }); + setRunner(runner); + const out = capture(); + const result = await runInstaller('install', 'pallet@0.0.2', { path: root, wait: true, timeout: '1m', intervalMs: 1 }); + + expect(result.status).toBe('completed'); + expect(calls[0].cwd).toBe(path.join(root, 'api')); + expect(calls[0].argv).toEqual(['php', 'artisan', 'registry:install', 'pallet', '--release=0.0.2', '--json', '--no-interaction', '--no-ansi']); + expect(calls[1].argv.slice(2, 5)).toEqual(['registry:job', 'job_1', '--json']); + expect(out.stderr).toMatch(/queued, 1 job\(s\) ahead/); + expect(out.stderr).toMatch(/download 20%/); + expect(out.stderr).toMatch(/composer done/); + expect(out.stderr).toMatch(/Install of pallet@0.0.2 completed/); + }); + + it('reports a failed job with its stage and log, and sets exit code 1', async () => { + const { runner } = scripted({ + 'registry:update': [job({ id: 'job_2', status: 'queued' })], + 'registry:job': [job({ id: 'job_2', status: 'failed', failed_stage: 'composer', message: 'composer require failed', log: ['Problem 1'] })], + }); + setRunner(runner); + const out = capture(); + const result = await runInstaller('update', '@acme/engine', { path: root, release: '2.0.0', wait: true, timeout: '1m', intervalMs: 1 }); + expect(result.status).toBe('failed'); + expect(process.exitCode).toBe(1); + expect(out.stderr).toMatch(/failed at composer: composer require failed/); + expect(out.stderr).toMatch(/Problem 1/); + }); + + it('falls back to the job id when the bridge has no registry:job', async () => { + const { runner } = scripted({ 'registry:uninstall': [job({ id: 'job_3', status: 'queued', position: 0 })] }); + setRunner(runner); + const out = capture(); + const result = await runInstaller('uninstall', 'pallet', { path: root, wait: true, timeout: '1m', intervalMs: 1 }); + expect(result.status).toBe('queued'); + expect(out.stderr).toMatch(/no registry:job command/); + expect(out.stderr).toMatch(/--sync/); + }); + + it('prints the bridge error and explains an old bridge', async () => { + setRunner(scripted({ 'registry:install': [{ stdout: '{\n "error": "This extension is already installed."\n}\n', code: 1 }] }).runner); + await expect(runInstaller('install', 'pallet', { path: root, wait: true, timeout: '1m' })).rejects.toThrow('This extension is already installed.'); + + setRunner(scripted({}).runner); + await expect(runInstaller('install', 'pallet', { path: root, wait: true, timeout: '1m' })).rejects.toThrow(/no registry:install command/); + }); + + it('refuses two different versions', async () => { + await expect(runInstaller('install', 'pallet@1.0.0', { path: root, release: '2.0.0', wait: true, timeout: '1m' })).rejects.toThrow(/Two versions/); + }); +}); diff --git a/test/unit/instance.test.ts b/test/unit/instance.test.ts new file mode 100644 index 0000000..eee5177 --- /dev/null +++ b/test/unit/instance.test.ts @@ -0,0 +1,205 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setRunner, type Runner } from '../../src/artisan.js'; +import { detectVersions } from '../../src/commands/instance.js'; +import { INSTANCE_TOKEN, TOKEN, capture, fakeRegistry, flb, readConfigFile, reset, tempDir, useTempHome, writeConfig } from './helpers.js'; + +const instance = { id: 'inst_1', name: 'prod', host: null, kind: 'self_hosted', console_version: '0.7.69', api_version: '1.6.71', status: 'active', organization: { id: 'org_1', slug: 'acme', name: 'Acme' }, last_seen_at: null }; + +describe('instance', () => { + let configFile: string; + beforeEach(() => { + configFile = useTempHome(); + writeConfig({ tokens: { 'registry.test:8000': TOKEN } }); + }); + afterEach(() => { + setRunner(null); + reset(); + }); + + it('register mints a code, exchanges it without a bearer, and keeps the instance token in the config only', async () => { + const fleetbase = tempDir(); + fs.mkdirSync(path.join(fleetbase, 'api')); + fs.mkdirSync(path.join(fleetbase, 'console')); + fs.writeFileSync(path.join(fleetbase, 'api', 'composer.lock'), JSON.stringify({ packages: [{ name: 'fleetbase/core-api', version: 'v1.6.71' }] })); + fs.writeFileSync(path.join(fleetbase, 'console', 'package.json'), JSON.stringify({ name: '@fleetbase/console', version: '0.7.69' })); + + const { requests } = fakeRegistry({ + 'POST /v1/account/instance-codes': { status: 201, body: { code: 'ABCD-EFGH-JKLM-NPQR', organization: 'org_1', expires_at: '2026-10-22T12:00:00Z', expires_in: 900 } }, + 'POST /v1/instances/register': { status: 201, body: { token: INSTANCE_TOKEN, token_type: 'Bearer', instance } }, + }); + const out = capture(); + expect(await flb('instance', 'register', '--name', 'prod', '--path', fleetbase, '--host', 'https://fleet.acme.test')).toBe(0); + + expect(requests[0].headers.authorization).toBe(`Bearer ${TOKEN}`); + expect(requests[1].headers.authorization).toBeUndefined(); + expect(requests[1].body).toEqual({ code: 'ABCD-EFGH-JKLM-NPQR', name: 'prod', kind: 'self_hosted', host: 'https://fleet.acme.test', console_version: '0.7.69', api_version: '1.6.71' }); + expect(out.all).not.toContain(INSTANCE_TOKEN); + expect(out.stderr).toContain('flbi_…M3n4'); + + const config = readConfigFile(); + expect(config.instances.inst_1).toMatchObject({ id: 'inst_1', token: INSTANCE_TOKEN, registry: 'http://registry.test:8000', organization: { slug: 'acme' } }); + expect(config.hosts['registry.test:8000'].instance).toBe('inst_1'); + expect(fs.readdirSync(path.dirname(configFile))).toEqual(['config.json']); + expect(JSON.stringify(fs.readdirSync(fleetbase, { recursive: true }))).not.toMatch(/flbi_/); + }); + + it('register --code works without a login and --show-token prints the token', async () => { + writeConfig({}); + const { requests } = fakeRegistry({ 'POST /v1/instances/register': { status: 201, body: { token: INSTANCE_TOKEN, token_type: 'Bearer', instance } } }); + const out = capture(); + expect(await flb('instance', 'register', '--code', 'abcd-efgh-jklm-npqr', '--show-token')).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0].body.code).toBe('ABCD-EFGH-JKLM-NPQR'); + expect(out.stdout.trim()).toBe(INSTANCE_TOKEN); + }); + + it('explains a used or expired code', async () => { + fakeRegistry({ 'POST /v1/instances/register': { status: 422, body: { errors: ['Invalid or expired registration code.'] } } }); + const out = capture(); + expect(await flb('instance', 'register', '--code', 'X')).toBe(1); + expect(out.stderr).toMatch(/Registration refused: Invalid or expired/); + expect(out.stderr).toMatch(/flb instance code/); + }); + + it('status calls /instances/me with the instance token', async () => { + writeConfig({ + tokens: { 'registry.test:8000': TOKEN }, + instances: { inst_1: { id: 'inst_1', registry: 'http://registry.test:8000', token: INSTANCE_TOKEN, registeredAt: 'x' } }, + hosts: { 'registry.test:8000': { instance: 'inst_1' } }, + }); + const { requests } = fakeRegistry({ 'GET /v1/instances/me': { instance, licences: [{ extension: { slug: 'pallet' }, kind: 'free', status: 'active', seats_used: 1, max_activations: null, activated_here: true }] } }); + const out = capture(); + expect(await flb('instance', 'status')).toBe(0); + expect(requests[0].headers.authorization).toBe(`Bearer ${INSTANCE_TOKEN}`); + expect(out.stdout).toMatch(/prod inst_1\s+self_hosted\s+active/); + expect(out.stdout).toMatch(/pallet\s+free\s+active\s+1\/∞\s+yes/); + }); + + it('code prints the one-time code', async () => { + fakeRegistry({ 'POST /v1/account/instance-codes': { status: 201, body: { code: 'ABCD-EFGH-JKLM-NPQR', organization: 'org_1', expires_at: '2026-10-22T12:00:00Z', expires_in: 900 } } }); + const out = capture(); + expect(await flb('instance', 'code', '--organization', 'acme')).toBe(0); + expect(out.stdout.trim()).toBe('ABCD-EFGH-JKLM-NPQR'); + }); + + it('register --path hands the code to registry:register over stdin; the CLI keeps no token', async () => { + const fleetbase = tempDir(); + fs.mkdirSync(path.join(fleetbase, 'api')); + fs.writeFileSync(path.join(fleetbase, 'api', 'artisan'), ' { + calls.push({ argv, cwd, input: options?.input }); + const summary = { id: 'inst_9', name: 'prod', host: 'https://fleet.acme.test', kind: 'self_hosted', status: 'active', organization: { id: 'org_1', slug: 'acme', name: 'Acme' }, service_url: 'http://registry.test:8000', token_hint: 'flbi_…w9x8', registered_at: 'now' }; + return { code: 0, stdout: `Registered.\n${JSON.stringify(summary, null, 4)}\n`, stderr: '' }; + }; + setRunner(runner); + const { requests } = fakeRegistry({ + 'POST /v1/account/instance-codes': { status: 201, body: { code: 'ABCD-EFGH-JKLM-NPQR', organization: 'org_1', expires_at: '2026-10-22T12:00:00Z', expires_in: 900 } }, + }); + const out = capture(); + expect(await flb('instance', 'register', '--path', fleetbase, '--host', 'https://fleet.acme.test')).toBe(0); + + expect(requests.map((r) => r.path)).toEqual(['/v1/account/instance-codes']); + expect(calls).toHaveLength(1); + expect(calls[0].cwd).toBe(path.join(fleetbase, 'api')); + expect(calls[0].argv).toEqual(['php', 'artisan', 'registry:register', '--code-stdin', '--service-url=http://registry.test:8000', '--host=https://fleet.acme.test', '--json', '--no-interaction', '--no-ansi']); + expect(calls[0].argv.join(' ')).not.toContain('ABCD-EFGH'); + expect(calls[0].input).toBe('ABCD-EFGH-JKLM-NPQR\n'); + expect(out.stderr).toMatch(/Registered prod \(inst_9\) in acme/); + expect(out.stderr).toMatch(/keeps its token \(flbi_…w9x8\)/); + + const entry = readConfigFile().instances.inst_9; + expect(entry.token).toBeUndefined(); + expect(entry).toMatchObject({ tokenHint: 'flbi_…w9x8', target: { path: path.join(fleetbase, 'api') } }); + + const status = capture(); + expect(await flb('instance', 'status')).toBe(1); + expect(status.stderr).toMatch(/keeps its token on the instance/); + }); + + it('register --store-token again sends the stored token for the same --host and reports the rotated instance', async () => { + const previousToken = 'flbi_' + 'p'.repeat(40); + writeConfig({ + tokens: { 'registry.test:8000': TOKEN }, + instances: { + inst_1: { id: 'inst_1', registry: 'http://registry.test:8000', token: previousToken, host: 'https://fleet.acme.test', registeredAt: 'x' }, + inst_2: { id: 'inst_2', registry: 'http://registry.test:8000', token: 'flbi_' + 'q'.repeat(40), host: 'https://eu.acme.test', registeredAt: 'x' }, + }, + }); + const { requests } = fakeRegistry({ 'POST /v1/instances/register': { status: 200, body: { token: INSTANCE_TOKEN, token_type: 'Bearer', rotated: true, instance } } }); + const out = capture(); + expect(await flb('instance', 'register', '--code', 'abcd-efgh-jklm-npqr', '--host', 'https://fleet.acme.test')).toBe(0); + + expect(requests[0].body.previous_token).toBe(previousToken); + expect(out.stderr).toMatch(/Re-registered prod \(inst_1\) in acme/); + expect(out.stderr).toMatch(/installs and licence seats are kept/); + expect(out.all).not.toContain(previousToken); + expect(readConfigFile().instances.inst_1).toMatchObject({ token: INSTANCE_TOKEN, host: 'https://fleet.acme.test' }); + }); + + it('register --store-token for a new host sends no previous token', async () => { + writeConfig({ instances: { inst_1: { id: 'inst_1', registry: 'http://registry.test:8000', token: 'flbi_' + 'p'.repeat(40), host: 'https://fleet.acme.test', registeredAt: 'x' } } }); + const { requests } = fakeRegistry({ 'POST /v1/instances/register': { status: 201, body: { token: INSTANCE_TOKEN, token_type: 'Bearer', rotated: false, instance: { ...instance, id: 'inst_3' } } } }); + const out = capture(); + expect(await flb('instance', 'register', '--code', 'abcd-efgh-jklm-npqr', '--host', 'https://new.acme.test')).toBe(0); + + expect(requests[0].body).not.toHaveProperty('previous_token'); + expect(out.stderr).toMatch(/Registered prod \(inst_3\)/); + expect(out.stderr).not.toMatch(/Re-registered/); + }); + + it('register --path reports a rotated instance from registry:register', async () => { + const fleetbase = tempDir(); + fs.mkdirSync(path.join(fleetbase, 'api')); + fs.writeFileSync(path.join(fleetbase, 'api', 'artisan'), ' { + const summary = { id: 'inst_9', name: 'prod', kind: 'self_hosted', status: 'active', organization: { id: 'org_1', slug: 'acme', name: 'Acme' }, service_url: 'http://registry.test:8000', token_hint: 'flbi_…w9x8', registered_at: 'now', rotated: true }; + return { code: 0, stdout: JSON.stringify(summary, null, 4) + '\n', stderr: '' }; + }); + const out = capture(); + expect(await flb('instance', 'register', '--path', fleetbase, '--code', 'abcd-efgh-jklm-npqr')).toBe(0); + + expect(out.stderr).toMatch(/Re-registered prod \(inst_9\) in acme/); + expect(out.stderr).toMatch(/previous token no longer works/); + }); + + it('register --docker uses the compose service and --service-url; a refused code and an old bridge are explained', async () => { + const fleetbase = tempDir(); + fs.writeFileSync(path.join(fleetbase, 'docker-compose.yml'), 'services: {}'); + const responses: Array<{ code: number; stdout: string; stderr: string }> = [ + { code: 1, stdout: '{\n "error": "Invalid or expired registration code."\n}\n', stderr: '' }, + { code: 1, stdout: '', stderr: 'Command "registry:register" is not defined.' }, + ]; + const calls: string[][] = []; + setRunner(async (argv) => { + calls.push(argv); + return responses.shift()!; + }); + fakeRegistry({}); + let out = capture(); + expect(await flb('instance', 'register', '--path', fleetbase, '--docker', '--code', 'abcd-efgh-jklm-npqr', '--service-url', 'http://registry:8000')).toBe(1); + expect(calls[0].slice(0, 8)).toEqual(['docker', 'compose', 'exec', '-T', 'application', 'php', 'artisan', 'registry:register']); + expect(calls[0]).toContain('--service-url=http://registry:8000'); + expect(out.stderr).toMatch(/Registration refused: Invalid or expired registration code/); + + out = capture(); + expect(await flb('instance', 'register', '--path', fleetbase, '--docker', '--code', 'abcd-efgh-jklm-npqr')).toBe(1); + expect(out.stderr).toMatch(/no registry:register command/); + expect(out.stderr).toMatch(/--store-token/); + }); + + it('--show-token is refused when the instance keeps the token', async () => { + const fleetbase = tempDir(); + fs.writeFileSync(path.join(fleetbase, 'artisan'), ' { + expect(detectVersions(tempDir())).toEqual({}); + }); +}); diff --git a/test/unit/listing.test.ts b/test/unit/listing.test.ts new file mode 100644 index 0000000..896105d --- /dev/null +++ b/test/unit/listing.test.ts @@ -0,0 +1,97 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { listingChanges } from '../../src/commands/listing.js'; +import { TOKEN, capture, fakeRegistry, flb, reset, tempDir, useTempHome, writeConfig } from './helpers.js'; + +const listing = (overrides: Record = {}) => ({ + id: 'ext_1', + slug: 'acme-insights', + name: 'Acme Insights', + status: 'draft', + organization: { id: 'org_1', slug: 'acme', name: 'Acme' }, + packages: [{ kind: 'npm', name: '@acme/insights-engine' }], + readiness: { ready: false, missing: ['The listing needs a subtitle.', 'The listing needs an icon.'] }, + ...overrides, +}); + +describe('listingChanges', () => { + it('maps flags to API fields, splits lists and clears with ""', () => { + expect(listingChanges({ subtitle: 'Fleet analytics', promotionalText: 'Fast', tags: 'fleet, analytics', icon: 'chart-line', website: 'https://acme.test', repo: '' })).toEqual({ + subtitle: 'Fleet analytics', + promotional_text: 'Fast', + tags: ['fleet', 'analytics'], + fa_icon: 'chart-line', + website_url: 'https://acme.test', + repo_url: null, + }); + }); + + it('reads --file and --description-file; flags win over the file', () => { + const dir = tempDir(); + fs.writeFileSync(path.join(dir, 'listing.json'), JSON.stringify({ subtitle: 'From file', category: 'telematics' })); + fs.writeFileSync(path.join(dir, 'DESCRIPTION.md'), '# Acme\n\nLong text.\n'); + expect(listingChanges({ file: 'listing.json', descriptionFile: 'DESCRIPTION.md', subtitle: 'From flag' }, dir)).toEqual({ subtitle: 'From flag', category: 'telematics', description: '# Acme\n\nLong text.' }); + }); + + it('refuses unknown fields in the file and a bad hosting value', () => { + const dir = tempDir(); + fs.writeFileSync(path.join(dir, 'listing.json'), JSON.stringify({ price: 100 })); + expect(() => listingChanges({ file: 'listing.json' }, dir)).toThrow(/Unknown listing field\(s\) in listing.json: price/); + expect(() => listingChanges({ hosting: 'everywhere' })).toThrow(/--hosting/); + }); +}); + +describe('flb listing', () => { + beforeEach(() => { + useTempHome(); + writeConfig({ tokens: { 'registry.test:8000': TOKEN } }); + }); + afterEach(reset); + + it('update PATCHes the fields and prints what is still missing', async () => { + const { requests } = fakeRegistry({ + 'PATCH /v1/publisher/extensions/acme-insights': (request) => listing({ subtitle: request.body.subtitle, readiness: { ready: false, missing: ['The listing needs an icon.'] } }), + }); + const out = capture(); + expect(await flb('listing', 'update', '--extension', 'acme-insights', '--subtitle', 'Fleet analytics', '--tags', 'fleet,analytics')).toBe(0); + expect(requests[0].body).toEqual({ subtitle: 'Fleet analytics', tags: ['fleet', 'analytics'] }); + expect(requests[0].headers.authorization).toBe(`Bearer ${TOKEN}`); + expect(out.stderr).toMatch(/Updated subtitle, tags on acme-insights/); + expect(out.stderr).toMatch(/still needed[\s\S]*The listing needs an icon/); + }); + + it('update says when the listing is ready, and needs at least one field', async () => { + fakeRegistry({ 'PATCH /v1/publisher/extensions/acme-insights': listing({ readiness: { ready: true, missing: [] } }) }); + let out = capture(); + expect(await flb('listing', 'update', '-x', 'acme-insights', '--icon', 'flask')).toBe(0); + expect(out.stderr).toMatch(/ready/); + + out = capture(); + expect(await flb('listing', 'update', '-x', 'acme-insights')).toBe(1); + expect(out.stderr).toMatch(/Nothing to change/); + }); + + it('an unknown category points at flb listing categories', async () => { + fakeRegistry({ 'PATCH /v1/publisher/extensions/acme-insights': { status: 422, body: { errors: ['Unknown category nope.'], fields: { category: ['Unknown category nope.'] } } } }); + const out = capture(); + expect(await flb('listing', 'update', '-x', 'acme-insights', '--category', 'nope')).toBe(1); + expect(out.stderr).toMatch(/flb listing categories/); + }); + + it('show prints the listing and its readiness; list shows readiness counts', async () => { + fakeRegistry({ + 'GET /v1/publisher/extensions/acme-insights': listing({ subtitle: 'Fleet analytics', tags: ['fleet'] }), + 'GET /v1/publisher/extensions': [listing(), listing({ slug: 'other', readiness: { ready: true, missing: [] } })], + }); + let out = capture(); + expect(await flb('listing', 'show', '-x', 'acme-insights')).toBe(0); + expect(out.stdout).toMatch(/subtitle\s+Fleet analytics/); + expect(out.stderr).toMatch(/The listing needs a subtitle/); + + out = capture(); + expect(await flb('listing', 'list')).toBe(0); + expect(out.stdout).toMatch(/acme-insights.*2 missing/); + expect(out.stdout).toMatch(/other.*yes/); + }); +}); diff --git a/test/unit/manifest.test.ts b/test/unit/manifest.test.ts new file mode 100644 index 0000000..dd06d4f --- /dev/null +++ b/test/unit/manifest.test.ts @@ -0,0 +1,106 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { composerInstallable, isSemver, loadExtension, ManifestError, validRange, validateManifest } from '../../src/manifest.js'; +import { copyFixture } from './helpers.js'; + +const valid = { name: 'Acme', version: '1.0.0', engine: 'package.json', api: 'composer.json', compat: { console: '>=0.7.0 <0.8.0', api: '^1.6' } }; + +describe('validateManifest (extension.schema.json rules)', () => { + it('accepts a valid manifest', () => { + expect(validateManifest(valid)).toEqual([]); + expect(validateManifest({ name: 'Api only', version: '2.0.0-beta.1', api: 'acme/api', channel: 'beta' })).toEqual([]); + }); + + it('needs a name, a semver version and an engine or api', () => { + const problems = validateManifest({ version: 'v1.0' }); + expect(problems.join('\n')).toMatch(/name/); + expect(problems.join('\n')).toMatch(/semantic version/); + expect(problems.join('\n')).toMatch(/engine.*api/); + }); + + it('checks compat ranges and keys', () => { + expect(validateManifest({ ...valid, compat: { console: 'latest' } }).join()).toMatch(/compat.console/); + expect(validateManifest({ ...valid, compat: { api: '^1.6', web: '1' } }).join()).toMatch(/compat.web/); + expect(validateManifest({ ...valid, channel: 'nightly' }).join()).toMatch(/channel/); + }); + + it('parses npm and Composer ranges', () => { + expect(validRange('>=0.7.0 <0.8.0', 'console')).toBe(true); + expect(validRange('^1.6', 'api')).toBe(true); + expect(validRange('^1.6 || ^2.0', 'api')).toBe(true); + expect(validRange('>=1.6 <2.0', 'api')).toBe(true); + // the schema's pattern has no comma, so Composer's `,` (and) is refused there too + expect(validRange('>=1.6, <2.0', 'api')).toBe(false); + expect(validRange('not a range', 'console')).toBe(false); + expect(validRange('1.0; rm -rf', 'api')).toBe(false); + }); + + it('knows which versions Composer installs', () => { + expect(composerInstallable('1.2.0')).toBe(true); + expect(composerInstallable('1.2.0-beta.1')).toBe(true); + expect(composerInstallable('1.2.0-RC2')).toBe(true); + expect(composerInstallable('1.2.0-next.1')).toBe(false); + }); +}); + +describe('loadExtension', () => { + it('loads the fixture with its packages', () => { + const root = copyFixture('extension'); + const ext = loadExtension(path.join(root, 'server')); + expect(ext.root).toBe(root); + expect(ext.engine?.name).toBe('@acme/insights-engine'); + expect(ext.api?.name).toBe('acme/insights-api'); + expect(ext.warnings).toEqual([]); + }); + + it('fails when package.json or composer.json versions disagree, as the service checks would', () => { + const root = copyFixture('extension'); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ ...pkg, version: '1.1.0' })); + const composer = JSON.parse(fs.readFileSync(path.join(root, 'composer.json'), 'utf8')); + fs.writeFileSync(path.join(root, 'composer.json'), JSON.stringify({ ...composer, version: '1.0.0' })); + try { + loadExtension(root); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(ManifestError); + expect((error as ManifestError).problems.join('\n')).toMatch(/package.json version 1.1.0/); + expect((error as ManifestError).problems.join('\n')).toMatch(/composer.json version 1.0.0/); + } + }); + + it('refuses a Composer-uninstallable version when there is an api', () => { + const root = copyFixture('extension'); + for (const file of ['extension.json', 'package.json', 'composer.json']) { + const json = JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')); + fs.writeFileSync(path.join(root, file), JSON.stringify({ ...json, version: '1.3.0-next.1' })); + } + expect(() => loadExtension(root)).toThrow(/Composer cannot install/); + }); + + it('says how to start when there is no extension.json', () => { + expect(() => loadExtension('/')).toThrow(/flb init/); + }); +}); + +describe('isSemver', () => { + it('accepts strict SemVer 2.0.0 versions', () => { + for (const version of ['0.0.1', '1.2.3', '1.0.0-beta.1', '1.0.0-0.3.7', '1.0.0-x.7.z.92', '1.0.0+build.5']) { + expect(isSemver(version)).toBe(true); + } + }); + + it('rejects loose or malformed versions', () => { + for (const version of ['v1.0.0', '=1.0.0', '1.0', '01.0.0', '1.0.0-01', '']) { + expect(isSemver(version)).toBe(false); + } + }); + + it('answers quickly on a long numeric prerelease and rejects versions over 256 characters', () => { + const started = Date.now(); + expect(isSemver(`1.0.0-${'0'.repeat(240)}x`)).toBe(true); + expect(isSemver(`1.0.0-${'0'.repeat(50_000)}x`)).toBe(false); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); diff --git a/test/unit/publish.test.ts b/test/unit/publish.test.ts new file mode 100644 index 0000000..f3349e1 --- /dev/null +++ b/test/unit/publish.test.ts @@ -0,0 +1,228 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isSettled, watchRelease } from '../../src/releases.js'; +import { RegistryClient } from '../../src/http.js'; +import type { ReleaseCheck, ReleaseDetail } from '../../src/types.js'; +import { TOKEN, capture, copyFixture, fakeRegistry, flb, reset, useTempHome, writeConfig } from './helpers.js'; + +const check = (name: string, passed = true, extra: Partial = {}): ReleaseCheck => ({ check: name, passed, skipped: false, summary: `${name} ${passed ? 'ok' : 'bad'}`, errors: passed ? [] : [`${name} failed`], warnings: [], ...extra }); + +function release(overrides: Partial = {}): ReleaseDetail { + return { + id: 'rel_1', + extension: { id: 'ext_1', slug: 'acme-insights', name: 'Acme Insights', status: 'draft' }, + version: '1.2.0', + channel: 'stable', + status: 'checking', + compat: null, + notes: null, + size_bytes: 1, + checks: [], + submission: { requested: false, submitted_at: null, errors: [] }, + review: { required: null, reasons: {} }, + reviews: [], + published_at: null, + yanked_at: null, + created_at: '2026-10-22T00:00:00Z', + ...overrides, + }; +} + +const listing = { id: 'ext_1', slug: 'acme-insights', name: 'Acme Insights', status: 'draft', organization: { id: 'org_1', slug: 'acme', name: 'Acme' }, packages: [{ kind: 'npm', name: '@acme/insights-engine' }, { kind: 'composer', name: 'acme/insights-api' }] }; + +describe('watchRelease', () => { + it('reports each check once as it lands and stops when the release settles', async () => { + const polls = [ + release({ checks: [check('manifest')] }), + release({ checks: [check('manifest'), check('compat')] }), + release({ status: 'uploaded', checks: [check('manifest'), check('compat'), check('secrets_scan')] }), + ]; + const fake = fakeRegistry({ 'GET /v1/publisher/releases/rel_1': () => polls.shift() }); + const seen: string[] = []; + const final = await watchRelease(new RegistryClient('http://registry.test:8000', { fetch: fake.fetch, token: TOKEN }), release(), { intervalMs: 1, onCheck: (c) => seen.push(c.check) }); + reset(); + expect(final.status).toBe('uploaded'); + expect(seen).toEqual(['manifest', 'compat', 'secrets_scan']); + }); + + it('waits for a requested submit, but not one refused for readiness', () => { + expect(isSettled(release({ status: 'uploaded', submission: { requested: true, submitted_at: null, errors: [] } }))).toBe(false); + expect(isSettled(release({ status: 'uploaded', submission: { requested: true, submitted_at: null, errors: ['The listing needs a subtitle.'] } }))).toBe(true); + expect(isSettled(release({ status: 'submitted' }))).toBe(true); + expect(isSettled(release({ status: 'checking' }))).toBe(false); + }); +}); + +describe('publish', () => { + let root: string; + beforeEach(() => { + useTempHome(); + writeConfig({ tokens: { 'registry.test:8000': TOKEN }, hosts: { 'registry.test:8000': { organization: 'acme' } } }); + root = copyFixture('extension'); + vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 500 }); + }); + afterEach(() => { + vi.useRealTimers(); + reset(); + }); + + it('builds, creates the listing, uploads multipart, streams checks and submits', async () => { + const polls = [ + release({ status: 'uploaded', checks: [check('manifest'), check('package_names')], submission: { requested: true, submitted_at: null, errors: [] } }), + release({ status: 'submitted', checks: [check('manifest'), check('package_names')], submission: { requested: true, submitted_at: 'now', errors: [] }, review: { required: true, reasons: { first_release: "This is the extension's first release." } } }), + ]; + const { requests } = fakeRegistry({ + 'GET /v1/publisher/extensions': [], + 'POST /v1/publisher/extensions': { status: 201, body: listing }, + 'POST /v1/publisher/extensions/acme-insights/releases': { status: 201, body: release({ checks: [] , submission: { requested: true, submitted_at: null, errors: [] } }) }, + 'GET /v1/publisher/releases/rel_1': () => polls.shift(), + }); + const out = capture(); + + expect(await flb('publish', root, '--submit', '--notes', 'First')).toBe(0); + + const create = requests.find((r) => r.method === 'POST' && r.path === '/v1/publisher/extensions'); + expect(create?.body).toEqual({ name: 'Acme Insights', packages: { npm: '@acme/insights-engine', composer: 'acme/insights-api' }, organization: 'acme', description: 'Fixture extension' }); + + const upload = requests.find((r) => r.path.endsWith('/releases'))!; + expect(upload.headers.authorization).toBe(`Bearer ${TOKEN}`); + const form = upload.form!; + expect((form.get('manifest') as File).name).toBe('extension.json'); + expect((form.get('engine') as File).name).toBe('engine-1.2.0.tgz'); + expect((form.get('api') as File).name).toBe('api-1.2.0.tar.gz'); + expect(form.get('dist')).toBeNull(); + expect(form.get('channel')).toBe('stable'); + expect(form.get('notes')).toBe('First'); + expect(form.get('submit')).toBe('1'); + + expect(out.stderr).toMatch(/✓ manifest\s+manifest ok/); + expect(out.stderr).toMatch(/✓ package names/); + expect(out.stderr).toMatch(/is submitted/); + expect(out.stderr).toMatch(/first release/); + expect(out.all).not.toContain(TOKEN); + expect(fs.existsSync(path.join(root, '.fleetbase', 'release.json'))).toBe(true); + }); + + it('reuses an existing listing, and exits 1 when checks fail', async () => { + const { requests } = fakeRegistry({ + 'GET /v1/publisher/extensions': [listing], + 'POST /v1/publisher/extensions/acme-insights/releases': { status: 201, body: release() }, + 'GET /v1/publisher/releases/rel_1': release({ status: 'failed_checks', checks: [check('manifest'), check('secrets_scan', false)] }), + }); + const out = capture(); + expect(await flb('publish', root)).toBe(1); + expect(requests.some((r) => r.method === 'POST' && r.path === '/v1/publisher/extensions')).toBe(false); + expect(out.stderr).toMatch(/✗ secrets scan/); + expect(out.stderr).toMatch(/secrets_scan failed/); + }); + + it('prefixes the scope when the listing slug is taken', async () => { + const bodies: any[] = []; + fakeRegistry({ + 'GET /v1/publisher/extensions': [], + 'POST /v1/publisher/extensions': (request) => { + bodies.push(request.body); + return request.body.slug ? { status: 201, body: { ...listing, slug: request.body.slug } } : { status: 422, body: { errors: ['The slug acme-insights is taken.'], fields: { slug: ['The slug acme-insights is taken.'] } } }; + }, + 'POST /v1/publisher/extensions/acme-acme-insights/releases': { status: 201, body: release({ status: 'uploaded' }) }, + }); + capture(); + expect(await flb('publish', root, '--no-wait')).toBe(0); + expect(bodies[1].slug).toBe('acme-acme-insights'); + }); + + it('explains a version that already exists', async () => { + fakeRegistry({ + 'GET /v1/publisher/extensions': [listing], + 'POST /v1/publisher/extensions/acme-insights/releases': { status: 409, body: { errors: ['Version 1.2.0 is already published; release a new version.'] } }, + }); + const out = capture(); + expect(await flb('publish', root)).toBe(1); + expect(out.stderr).toMatch(/already has a release 1.2.0/); + expect(out.stderr).toMatch(/Bump the version/); + }); + + it('refuses --channel that contradicts extension.json, and --no-build without a build', async () => { + fakeRegistry({}); + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'extension.json'), 'utf8')); + fs.writeFileSync(path.join(root, 'extension.json'), JSON.stringify({ ...manifest, channel: 'stable' })); + let out = capture(); + expect(await flb('publish', root, '--channel', 'beta')).toBe(1); + expect(out.stderr).toMatch(/declares channel stable/); + + out = capture(); + expect(await flb('publish', root, '--no-build')).toBe(1); + expect(out.stderr).toMatch(/No current build \(not built yet\)/); + }); + + it('needs a login', async () => { + writeConfig({}); + const out = capture(); + expect(await flb('publish', root)).toBe(1); + expect(out.stderr).toMatch(/Not logged in to registry.test:8000/); + }); +}); + +describe('release', () => { + beforeEach(() => { + useTempHome(); + writeConfig({ tokens: { 'registry.test:8000': TOKEN } }); + }); + afterEach(reset); + + it('status by version resolves the listing from the local extension', async () => { + const root = copyFixture('extension'); + const { requests } = fakeRegistry({ + 'GET /v1/publisher/extensions': [listing], + 'GET /v1/publisher/extensions/acme-insights/releases': [release({ id: 'rel_0', version: '1.1.0', status: 'published' }), release({ id: 'rel_1', status: 'uploaded' })], + 'GET /v1/publisher/releases/rel_0': release({ id: 'rel_0', version: '1.1.0', status: 'published', checks: [check('manifest')], reviews: [{ action: 'publish', from_status: 'approved', to_status: 'published', actor: 'staff', by: { id: 'acct_2', name: 'Reviewer' }, reason: null, items: [], created_at: '2026-10-22T00:00:00Z' }] }), + }); + const out = capture(); + const cwd = process.cwd(); + process.chdir(root); + try { + expect(await flb('release', 'status', '1.1.0')).toBe(0); + } finally { + process.chdir(cwd); + } + expect(requests.map((r) => r.path)).toContain('/v1/publisher/releases/rel_0'); + expect(out.stdout).toMatch(/acme-insights@1.1.0 rel_0\s+stable\s+published/); + expect(out.stdout).toMatch(/publish\s+published\s+staff \(Reviewer\)/); + }); + + it('submit lists the reasons a release cannot be submitted', async () => { + fakeRegistry({ + 'GET /v1/publisher/releases/rel_1': release({ status: 'uploaded' }), + 'POST /v1/publisher/releases/rel_1/submit': { status: 422, body: { errors: ['The listing needs a subtitle.', 'The listing needs an icon.'] } }, + }); + const out = capture(); + expect(await flb('release', 'submit', 'rel_1')).toBe(1); + expect(out.stderr).toMatch(/cannot be submitted:\n {2}- The listing needs a subtitle.\n {2}- The listing needs an icon./); + }); + + it('yank needs --yes without a terminal and sends the reason', async () => { + const { requests } = fakeRegistry({ + 'GET /v1/publisher/releases/rel_1': () => release({ status: requests.some((r) => r.path.endsWith('/yank')) ? 'yanked' : 'published' }), + 'POST /v1/publisher/releases/rel_1/yank': release({ status: 'yanked' }), + }); + let out = capture(); + expect(await flb('release', 'yank', 'rel_1')).toBe(1); + expect(out.stderr).toMatch(/--yes/); + + out = capture(); + expect(await flb('release', 'yank', 'rel_1', '--yes', '--reason', 'broken')).toBe(0); + expect(requests.find((r) => r.path.endsWith('/yank'))?.body).toEqual({ reason: 'broken' }); + expect(out.stderr).toMatch(/is yanked/); + }); + + it('withdraw explains a release in the wrong state', async () => { + fakeRegistry({ + 'GET /v1/publisher/releases/rel_1': release({ status: 'published' }), + 'POST /v1/publisher/releases/rel_1/withdraw': { status: 409, body: { errors: ['A release that is published cannot be withdrawn.'] } }, + }); + const out = capture(); + expect(await flb('release', 'withdraw', 'rel_1')).toBe(1); + expect(out.stderr).toMatch(/is published: A release that is published cannot be withdrawn/); + }); +}); diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts new file mode 100644 index 0000000..e90364e --- /dev/null +++ b/test/unit/util.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { redact } from '../../src/output.js'; +import { parseDays, parseTimeout, slugify, splitList, splitVersion, studly } from '../../src/util.js'; +import { INSTANCE_TOKEN, TOKEN } from './helpers.js'; + +describe('util', () => { + it('parses token lifetimes in days, capped at 365', () => { + expect(parseDays('90')).toBe(90); + expect(parseDays('180d')).toBe(180); + expect(parseDays('12w')).toBe(84); + expect(parseDays('6mo')).toBe(180); + expect(parseDays('1y')).toBe(365); + expect(() => parseDays('2y')).toThrow(/between 1 and 365/); + expect(() => parseDays('soon')).toThrow(/Invalid duration/); + }); + + it('parses timeouts', () => { + expect(parseTimeout('90s')).toBe(90_000); + expect(parseTimeout('15m')).toBe(900_000); + expect(parseTimeout('2h')).toBe(7_200_000); + expect(parseTimeout('30')).toBe(30_000); + }); + + it('splits name@version, keeping npm scopes', () => { + expect(splitVersion('pallet@0.0.2')).toEqual(['pallet', '0.0.2']); + expect(splitVersion('@acme/engine@1.2.0')).toEqual(['@acme/engine', '1.2.0']); + expect(splitVersion('@acme/engine')).toEqual(['@acme/engine', undefined]); + expect(splitVersion('acme/api')).toEqual(['acme/api', undefined]); + }); + + it('slugs and studly-cases names', () => { + expect(slugify('Fleet Insights')).toBe('fleet-insights'); + expect(slugify('FleetInsights')).toBe('fleet-insights'); + expect(slugify('E2E Insights')).toBe('e2e-insights'); + expect(slugify('Café Ops!')).toBe('cafe-ops'); + expect(studly('fleet insights')).toBe('FleetInsights'); + }); + + it('splits lists', () => { + expect(splitList('read, publish,,install')).toEqual(['read', 'publish', 'install']); + expect(splitList(undefined)).toEqual([]); + }); + + it('redacts personal and instance tokens in output', () => { + const text = `token ${TOKEN} and ${INSTANCE_TOKEN}`; + expect(redact(text)).toBe('token flbp_…Z9x1 and flbi_…M3n4'); + expect(redact('flbp_…Z9x1')).toBe('flbp_…Z9x1'); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..5e256ee --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f1be3da --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": false, + "noImplicitOverride": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "outDir": "dist", + "declaration": false, + "sourceMap": false + }, + "include": ["src", "test"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b51ccc2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/unit/**/*.test.ts'], + environment: 'node', + testTimeout: 30000, + // Tests point FLEETBASE_HOME at a temp directory; never touch the real ~/.fleetbase + env: { FLEETBASE_HOME: '', FLB_TOKEN: '', FLB_REGISTRY: '', NO_COLOR: '1' }, + }, +});